Skip to content
Open
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
8 changes: 8 additions & 0 deletions app/Entities/Models/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@ public function getUrl(string $path = ''): string
return url('/' . implode('/', $parts));
}

/**
* Get the ID-based permalink for this page.
*/
public function getPermalink(): string
{
return url("/link/{$this->id}");
}

/**
* Get this page for JSON display.
*/
Expand Down
51 changes: 47 additions & 4 deletions app/Entities/Tools/Cloner.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,37 +13,62 @@
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Permissions\Permission;
use BookStack\References\ReferenceChangeContext;
use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
use Illuminate\Http\UploadedFile;

class Cloner
{
protected ReferenceChangeContext $referenceChangeContext;

public function __construct(
protected PageRepo $pageRepo,
protected ChapterRepo $chapterRepo,
protected BookRepo $bookRepo,
protected ImageService $imageService,
protected ReferenceUpdater $referenceUpdater,
) {
$this->referenceChangeContext = new ReferenceChangeContext();
}

/**
* Clone the given page into the given parent using the provided name.
*/
public function clonePage(Page $original, Entity $parent, string $newName): Page
{
$context = $this->newReferenceChangeContext();
$page = $this->createPageClone($original, $parent, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $page;
}

protected function createPageClone(Page $original, Entity $parent, string $newName): Page
{
$copyPage = $this->pageRepo->getNewDraftPage($parent);
$pageData = $this->entityToInputData($original);
$pageData['name'] = $newName;

return $this->pageRepo->publishDraft($copyPage, $pageData);
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
$this->referenceChangeContext->add($original, $newPage);

return $newPage;
}

/**
* 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
{
$context = $this->newReferenceChangeContext();
$chapter = $this->createChapterClone($original, $parent, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $chapter;
}

protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
{
$chapterDetails = $this->entityToInputData($original);
$chapterDetails['name'] = $newName;
Expand All @@ -53,10 +78,12 @@ public function cloneChapter(Chapter $original, Book $parent, string $newName):
if (userCan(Permission::PageCreate, $copyChapter)) {
/** @var Page $page */
foreach ($original->getVisiblePages() as $page) {
$this->clonePage($page, $copyChapter, $page->name);
$this->createPageClone($page, $copyChapter, $page->name);
}
}

$this->referenceChangeContext->add($original, $copyChapter);

return $copyChapter;
}

Expand All @@ -65,6 +92,14 @@ public function cloneChapter(Chapter $original, Book $parent, string $newName):
* Clones all child chapters and pages.
*/
public function cloneBook(Book $original, string $newName): Book
{
$context = $this->newReferenceChangeContext();
$book = $this->createBookClone($original, $newName);
$this->referenceUpdater->changeReferencesUsingContext($context);
return $book;
}

protected function createBookClone(Book $original, string $newName): Book
{
$bookDetails = $this->entityToInputData($original);
$bookDetails['name'] = $newName;
Expand All @@ -76,11 +111,11 @@ public function cloneBook(Book $original, string $newName): Book
$directChildren = $original->getDirectVisibleChildren();
foreach ($directChildren as $child) {
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
$this->cloneChapter($child, $copyBook, $child->name);
$this->createChapterClone($child, $copyBook, $child->name);
}

if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
$this->clonePage($child, $copyBook, $child->name);
$this->createPageClone($child, $copyBook, $child->name);
}
}

Expand All @@ -92,6 +127,8 @@ public function cloneBook(Book $original, string $newName): Book
}
}

$this->referenceChangeContext->add($original, $copyBook);

return $copyBook;
}

Expand Down Expand Up @@ -155,4 +192,10 @@ protected function entityTagsToInputArray(Entity $entity): array

return $tags;
}

protected function newReferenceChangeContext(): ReferenceChangeContext
{
$this->referenceChangeContext = new ReferenceChangeContext();
return $this->referenceChangeContext;
}
}
56 changes: 56 additions & 0 deletions app/References/ReferenceChangeContext.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace BookStack\References;

use BookStack\Entities\Models\Entity;

class ReferenceChangeContext
{
/**
* Entity pairs where the first is the old entity and the second is the new entity.
* @var array<array{0: Entity, 1: Entity}>
*/
protected array $changes = [];

public function add(Entity $oldEntity, Entity $newEntity): void
{
$this->changes[] = [$oldEntity, $newEntity];
}

/**
* Get all the change pairs.
* Returned array is an array of pairs, where the first item is the old entity
* and the second is the new entity.
* @return array<array{0: Entity, 1: Entity}>
*/
public function getChanges(): array
{
return $this->changes;
}

/**
* Get all the new entities from the changes.
*/
public function getNewEntities(): array
{
return array_column($this->changes, 1);
}

/**
* Get all the old entities from the changes.
*/
public function getOldEntities(): array
{
return array_column($this->changes, 0);
}

public function getNewForOld(Entity $oldEntity): ?Entity
{
foreach ($this->changes as [$old, $new]) {
if ($old->id === $oldEntity->id && $old->type === $oldEntity->type) {
return $new;
}
}
return null;
}
}
42 changes: 41 additions & 1 deletion app/References/ReferenceUpdater.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityContainerData;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Repos\RevisionRepo;
use BookStack\Util\HtmlDocument;
Expand All @@ -30,6 +29,47 @@ public function updateEntityReferences(Entity $entity, string $oldLink): void
}
}

/**
* Change existing references for a range of entities using the given context.
*/
public function changeReferencesUsingContext(ReferenceChangeContext $context): void
{
$bindings = [];
foreach ($context->getOldEntities() as $old) {
$bindings[] = $old->getMorphClass();
$bindings[] = $old->id;
}

// No targets to update within the context, so no need to continue.
if (count($bindings) < 2) {
return;
}

$toReferenceQuery = '(to_type, to_id) IN (' . rtrim(str_repeat('(?,?),', count($bindings) / 2), ',') . ')';

// Cycle each new entity in the context
foreach ($context->getNewEntities() as $new) {
// For each, get all references from it which lead to other items within the context of the change
$newReferencesInContext = $new->referencesFrom()->whereRaw($toReferenceQuery, $bindings)->get();
// For each reference, update the URL and the reference entry
foreach ($newReferencesInContext as $reference) {
$oldToEntity = $reference->to;
$newToEntity = $context->getNewForOld($oldToEntity);
if ($newToEntity === null) {
continue;
}

$this->updateReferencesWithinEntity($new, $oldToEntity->getUrl(), $newToEntity->getUrl());
if ($newToEntity instanceof Page && $oldToEntity instanceof Page) {
$this->updateReferencesWithinPage($newToEntity, $oldToEntity->getPermalink(), $newToEntity->getPermalink());
}
$reference->to_id = $newToEntity->id;
$reference->to_type = $newToEntity->getMorphClass();
$reference->save();
}
}
}

/**
* @return Reference[]
*/
Expand Down
104 changes: 0 additions & 104 deletions tests/Entity/BookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -264,108 +264,4 @@ public function test_show_view_displays_description_if_no_description_html_set()
$resp = $this->asEditor()->get($book->getUrl());
$resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
}

public function test_show_view_has_copy_button()
{
$book = $this->entities->book();
$resp = $this->asEditor()->get($book->getUrl());

$this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy');
}

public function test_copy_view()
{
$book = $this->entities->book();
$resp = $this->asEditor()->get($book->getUrl('/copy'));

$resp->assertOk();
$resp->assertSee('Copy Book');
$this->withHtml($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->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count());

$this->get($copy->getUrl())->assertSee($book->description_html, false);
}

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->getDirectVisibleChildren() as $child) {
$this->permissions->setEntityPermissions($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->getDirectVisibleChildren()->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->users->viewer();
$this->permissions->grantUserRolePermissions($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()
{
$book = $this->entities->book();
$bookRepo = $this->app->make(BookRepo::class);
$coverImageFile = $this->files->uploadedImage('cover.png');
$bookRepo->updateCoverImage($book, $coverImageFile);

$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect();
/** @var Book $copy */
$copy = Book::query()->where('name', '=', 'My copy book')->first();

$this->assertNotNull($copy->coverInfo()->getImage());
$this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id);
}

public function test_copy_adds_book_to_shelves_if_edit_permissions_allows()
{
/** @var Bookshelf $shelfA */
/** @var Bookshelf $shelfB */
[$shelfA, $shelfB] = Bookshelf::query()->take(2)->get();
$book = $this->entities->book();

$shelfA->appendBook($book);
$shelfB->appendBook($book);

$viewer = $this->users->viewer();
$this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']);
$this->permissions->setEntityPermissions($shelfB);


$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
/** @var Book $copy */
$copy = Book::query()->where('name', '=', 'My copy book')->first();

$this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists());
$this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists());
}
}
Loading