Skip to content

Additional copy/clone abilities #3118

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/Entities/Models/Chapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

/**
Expand Down
33 changes: 23 additions & 10 deletions app/Entities/Repos/ChapterRepo.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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;
Expand Down Expand Up @@ -87,17 +88,9 @@ public function destroy(Chapter $chapter)
*/
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');
}

Expand All @@ -107,4 +100,24 @@ public function move(Chapter $chapter, string $parentIdentifier): Book

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();
}
}
41 changes: 2 additions & 39 deletions app/Entities/Repos/PageRepo.php
Original file line number Diff line number Diff line change
Expand Up @@ -347,50 +347,13 @@ public function move(Page $page, string $parentIdentifier): Entity
}

/**
* Copy an existing page in the system.
* Optionally providing a new parent via string identifier and a new name.
*
* @throws MoveOperationException
* @throws PermissionsException
*/
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
{
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent();
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}

if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}

$copyPage = $this->getNewDraftPage($parent);
$pageData = $page->getAttributes();

// Update name
if (!empty($newName)) {
$pageData['name'] = $newName;
}

// Copy tags from previous page if set
if ($page->tags) {
$pageData['tags'] = [];
foreach ($page->tags as $tag) {
$pageData['tags'][] = ['name' => $tag->name, 'value' => $tag->value];
}
}

return $this->publishDraft($copyPage, $pageData);
}

/**
* 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).
*
* @throws MoveOperationException
*/
protected function findParentByIdentifier(string $identifier): ?Entity
public function findParentByIdentifier(string $identifier): ?Entity
{
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
Expand Down
150 changes: 150 additions & 0 deletions app/Entities/Tools/Cloner.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

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\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
{

/**
* @var PageRepo
*/
protected $pageRepo;

/**
* @var ChapterRepo
*/
protected $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;
}

/**
* Clone the given page into the given parent using the provided name.
*/
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $original->getAttributes();

// 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);

$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;
}

/**
* 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.
*/
protected function entityTagsToInputArray(Entity $entity): array
{
$tags = [];

/** @var Tag $tag */
foreach ($entity->tags as $tag) {
$tags[] = ['name' => $tag->name, 'value' => $tag->value];
}

return $tags;
}

}
3 changes: 3 additions & 0 deletions app/Facades/Activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

use Illuminate\Support\Facades\Facade;

/**
* @see \BookStack\Actions\ActivityLogger
*/
class Activity extends Facade
{
/**
Expand Down
39 changes: 38 additions & 1 deletion app/Http/Controllers/BookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -225,4 +227,39 @@ public function permissions(Request $request, PermissionsUpdater $permissionsUpd

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());
}
}
47 changes: 47 additions & 0 deletions app/Http/Controllers/ChapterController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
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;
Expand Down Expand Up @@ -190,6 +191,52 @@ public function move(Request $request, string $bookSlug, string $chapterSlug)
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 chapter 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.
*
Expand Down
Loading