diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index d74f2f195..59d8077a4 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -7,8 +7,12 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\ChapterRepo; use BookStack\Entities\Repos\PageRepo; +use BookStack\Uploads\Image; +use BookStack\Uploads\ImageService; +use Illuminate\Http\UploadedFile; class Cloner { @@ -23,10 +27,22 @@ class Cloner */ protected $chapterRepo; - public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo) + /** + * @var BookRepo + */ + protected $bookRepo; + + /** + * @var ImageService + */ + protected $imageService; + + public function __construct(PageRepo $pageRepo, ChapterRepo $chapterRepo, BookRepo $bookRepo, ImageService $imageService) { $this->pageRepo = $pageRepo; $this->chapterRepo = $chapterRepo; + $this->bookRepo = $bookRepo; + $this->imageService = $imageService; } /** @@ -66,6 +82,55 @@ class Cloner return $copyChapter; } + /** + * Clone the given book. + * Clones all child chapters & pages. + */ + public function cloneBook(Book $original, string $newName): Book + { + $bookDetails = $original->getAttributes(); + $bookDetails['name'] = $newName; + $bookDetails['tags'] = $this->entityTagsToInputArray($original); + + $copyBook = $this->bookRepo->create($bookDetails); + + $directChildren = $original->getDirectChildren(); + foreach ($directChildren as $child) { + + if ($child instanceof Chapter && userCan('chapter-create', $copyBook)) { + $this->cloneChapter($child, $copyBook, $child->name); + } + + if ($child instanceof Page && !$child->draft && userCan('page-create', $copyBook)) { + $this->clonePage($child, $copyBook, $child->name); + } + } + + if ($original->cover) { + try { + $tmpImgFile = tmpfile(); + $uploadedFile = $this->imageToUploadedFile($original->cover, $tmpImgFile); + $this->bookRepo->updateCoverImage($copyBook, $uploadedFile, false); + } catch (\Exception $exception) { + } + } + + return $copyBook; + } + + /** + * Convert an image instance to an UploadedFile instance to mimic + * a file being uploaded. + */ + protected function imageToUploadedFile(Image $image, &$tmpFile): ?UploadedFile + { + $imgData = $this->imageService->getImageData($image); + $tmpImgFilePath = stream_get_meta_data($tmpFile)['uri']; + file_put_contents($tmpImgFilePath, $imgData); + + return new UploadedFile($tmpImgFilePath, basename($image->path)); + } + /** * Convert the tags on the given entity to the raw format * that's used for incoming request data. diff --git a/app/Facades/Activity.php b/app/Facades/Activity.php index 76493efd7..6c279a057 100644 --- a/app/Facades/Activity.php +++ b/app/Facades/Activity.php @@ -4,6 +4,9 @@ namespace BookStack\Facades; use Illuminate\Support\Facades\Facade; +/** + * @see \BookStack\Actions\ActivityLogger + */ class Activity extends Facade { /** diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index 5434afaf8..bc403c6d0 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -2,16 +2,18 @@ namespace BookStack\Http\Controllers; -use Activity; use BookStack\Actions\ActivityQueries; use BookStack\Actions\ActivityType; use BookStack\Actions\View; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\Cloner; use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Entities\Tools\ShelfContext; use BookStack\Exceptions\ImageUploadException; +use BookStack\Exceptions\NotFoundException; +use BookStack\Facades\Activity; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; @@ -225,4 +227,39 @@ class BookController extends Controller return redirect($book->getUrl()); } + + /** + * Show the view to copy a book. + * + * @throws NotFoundException + */ + public function showCopy(string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-view', $book); + + session()->flashInput(['name' => $book->name]); + + return view('books.copy', [ + 'book' => $book, + ]); + } + + /** + * Create a copy of a book within the requested target destination. + * + * @throws NotFoundException + */ + public function copy(Request $request, Cloner $cloner, string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-view', $book); + $this->checkPermission('book-create-all'); + + $newName = $request->get('name') ?: $book->name; + $bookCopy = $cloner->cloneBook($book, $newName); + $this->showSuccessNotification(trans('entities.books_copy_success')); + + return redirect($bookCopy->getUrl()); + } } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 085285fc6..16f0779ca 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -210,7 +210,7 @@ class ChapterController extends Controller } /** - * Create a copy of a page within the requested target destination. + * Create a copy of a chapter within the requested target destination. * * @throws NotFoundException * @throws Throwable diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index 665e833f4..7a6930546 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -143,6 +143,8 @@ return [ 'books_sort_chapters_last' => 'Chapters Last', 'books_sort_show_other' => 'Show Other Books', 'books_sort_save' => 'Save New Order', + 'books_copy' => 'Copy Book', + 'books_copy_success' => 'Book successfully copied', // Chapters 'chapter' => 'Chapter', diff --git a/resources/views/books/copy.blade.php b/resources/views/books/copy.blade.php new file mode 100644 index 000000000..4f01f55e2 --- /dev/null +++ b/resources/views/books/copy.blade.php @@ -0,0 +1,38 @@ +@extends('layouts.simple') + +@section('body') + + <div class="container small"> + + <div class="my-s"> + @include('entities.breadcrumbs', ['crumbs' => [ + $book, + $book->getUrl('/copy') => [ + 'text' => trans('entities.books_copy'), + 'icon' => 'copy', + ] + ]]) + </div> + + <div class="card content-wrap auto-height"> + + <h1 class="list-heading">{{ trans('entities.books_copy') }}</h1> + + <form action="{{ $book->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 text-right"> + <a href="{{ $book->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a> + <button type="submit" class="button">{{ trans('entities.books_copy') }}</button> + </div> + </form> + + </div> + </div> + +@stop diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index 25a6f69fa..5263bc810 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -110,6 +110,12 @@ <span>{{ trans('common.sort') }}</span> </a> @endif + @if(userCan('book-create-all')) + <a href="{{ $book->getUrl('/copy') }}" class="icon-list-item"> + <span>@icon('copy')</span> + <span>{{ trans('common.copy') }}</span> + </a> + @endif @if(userCan('restrictions-manage', $book)) <a href="{{ $book->getUrl('/permissions') }}" class="icon-list-item"> <span>@icon('lock')</span> diff --git a/routes/web.php b/routes/web.php index 13cf2909b..73cc3dc66 100644 --- a/routes/web.php +++ b/routes/web.php @@ -80,6 +80,8 @@ Route::middleware('auth')->group(function () { Route::get('/books/{bookSlug}/permissions', [BookController::class, 'showPermissions']); Route::put('/books/{bookSlug}/permissions', [BookController::class, 'permissions']); Route::get('/books/{slug}/delete', [BookController::class, 'showDelete']); + Route::get('/books/{bookSlug}/copy', [BookController::class, 'showCopy']); + Route::post('/books/{bookSlug}/copy', [BookController::class, 'copy']); Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']); Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']); Route::get('/books/{bookSlug}/export/html', [BookExportController::class, 'html']); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 2894fbb98..7f102a17e 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -3,10 +3,15 @@ namespace Tests\Entity; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\BookChild; +use BookStack\Entities\Repos\BookRepo; use Tests\TestCase; +use Tests\Uploads\UsesImages; class BookTest extends TestCase { + use UsesImages; + public function test_create() { $book = Book::factory()->make([ @@ -204,4 +209,88 @@ class BookTest extends TestCase $this->assertEquals('parta-partb-partc', $book->slug); } + + public function test_show_view_has_copy_button() + { + /** @var Book $book */ + $book = Book::query()->first(); + $resp = $this->asEditor()->get($book->getUrl()); + + $resp->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy'); + } + + public function test_copy_view() + { + /** @var Book $book */ + $book = Book::query()->first(); + $resp = $this->asEditor()->get($book->getUrl('/copy')); + + $resp->assertOk(); + $resp->assertSee('Copy Book'); + $resp->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]"); + } + + public function test_copy() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + $resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $resp->assertRedirect($copy->getUrl()); + $this->assertEquals($book->getDirectChildren()->count(), $copy->getDirectChildren()->count()); + } + + public function test_copy_does_not_copy_non_visible_content() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('pages')->first(); + + // Hide child content + /** @var BookChild $page */ + foreach ($book->getDirectChildren() as $child) { + $child->restricted = true; + $child->save(); + $this->regenEntityPermissions($child); + } + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->getDirectChildren()->count()); + } + + public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create() + { + /** @var Book $book */ + $book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first(); + $viewer = $this->getViewer(); + $this->giveUserPermissions($viewer, ['book-create-all']); + + $this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']); + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + + $this->assertEquals(0, $copy->pages()->count()); + $this->assertEquals(0, $copy->chapters()->count()); + } + + public function test_copy_clones_cover_image_if_existing() + { + /** @var Book $book */ + $book = Book::query()->first(); + $bookRepo = $this->app->make(BookRepo::class); + $coverImageFile = $this->getTestImage('cover.png'); + $bookRepo->updateCoverImage($book, $coverImageFile); + + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + + /** @var Book $copy */ + $copy = Book::query()->where('name', '=', 'My copy book')->first(); + $this->assertNotNull($copy->cover); + $this->assertNotEquals($book->cover->id, $copy->cover->id); + } }