Skip to content

Commit cf84797

Browse files
authored
Merge pull request BookStackApp#5917 from BookStackApp/copy_references
Internal reference handling on content copying
2 parents bb35063 + 3cd3e73 commit cf84797

File tree

8 files changed

+540
-283
lines changed

8 files changed

+540
-283
lines changed

app/Entities/Models/Page.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,14 @@ public function getUrl(string $path = ''): string
124124
return url('/' . implode('/', $parts));
125125
}
126126

127+
/**
128+
* Get the ID-based permalink for this page.
129+
*/
130+
public function getPermalink(): string
131+
{
132+
return url("/link/{$this->id}");
133+
}
134+
127135
/**
128136
* Get this page for JSON display.
129137
*/

app/Entities/Tools/Cloner.php

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,62 @@
1313
use BookStack\Entities\Repos\ChapterRepo;
1414
use BookStack\Entities\Repos\PageRepo;
1515
use BookStack\Permissions\Permission;
16+
use BookStack\References\ReferenceChangeContext;
17+
use BookStack\References\ReferenceUpdater;
1618
use BookStack\Uploads\Image;
1719
use BookStack\Uploads\ImageService;
1820
use Illuminate\Http\UploadedFile;
1921

2022
class Cloner
2123
{
24+
protected ReferenceChangeContext $referenceChangeContext;
25+
2226
public function __construct(
2327
protected PageRepo $pageRepo,
2428
protected ChapterRepo $chapterRepo,
2529
protected BookRepo $bookRepo,
2630
protected ImageService $imageService,
31+
protected ReferenceUpdater $referenceUpdater,
2732
) {
33+
$this->referenceChangeContext = new ReferenceChangeContext();
2834
}
2935

3036
/**
3137
* Clone the given page into the given parent using the provided name.
3238
*/
3339
public function clonePage(Page $original, Entity $parent, string $newName): Page
40+
{
41+
$context = $this->newReferenceChangeContext();
42+
$page = $this->createPageClone($original, $parent, $newName);
43+
$this->referenceUpdater->changeReferencesUsingContext($context);
44+
return $page;
45+
}
46+
47+
protected function createPageClone(Page $original, Entity $parent, string $newName): Page
3448
{
3549
$copyPage = $this->pageRepo->getNewDraftPage($parent);
3650
$pageData = $this->entityToInputData($original);
3751
$pageData['name'] = $newName;
3852

39-
return $this->pageRepo->publishDraft($copyPage, $pageData);
53+
$newPage = $this->pageRepo->publishDraft($copyPage, $pageData);
54+
$this->referenceChangeContext->add($original, $newPage);
55+
56+
return $newPage;
4057
}
4158

4259
/**
4360
* Clone the given page into the given parent using the provided name.
4461
* Clones all child pages.
4562
*/
4663
public function cloneChapter(Chapter $original, Book $parent, string $newName): Chapter
64+
{
65+
$context = $this->newReferenceChangeContext();
66+
$chapter = $this->createChapterClone($original, $parent, $newName);
67+
$this->referenceUpdater->changeReferencesUsingContext($context);
68+
return $chapter;
69+
}
70+
71+
protected function createChapterClone(Chapter $original, Book $parent, string $newName): Chapter
4772
{
4873
$chapterDetails = $this->entityToInputData($original);
4974
$chapterDetails['name'] = $newName;
@@ -53,10 +78,12 @@ public function cloneChapter(Chapter $original, Book $parent, string $newName):
5378
if (userCan(Permission::PageCreate, $copyChapter)) {
5479
/** @var Page $page */
5580
foreach ($original->getVisiblePages() as $page) {
56-
$this->clonePage($page, $copyChapter, $page->name);
81+
$this->createPageClone($page, $copyChapter, $page->name);
5782
}
5883
}
5984

85+
$this->referenceChangeContext->add($original, $copyChapter);
86+
6087
return $copyChapter;
6188
}
6289

@@ -65,6 +92,14 @@ public function cloneChapter(Chapter $original, Book $parent, string $newName):
6592
* Clones all child chapters and pages.
6693
*/
6794
public function cloneBook(Book $original, string $newName): Book
95+
{
96+
$context = $this->newReferenceChangeContext();
97+
$book = $this->createBookClone($original, $newName);
98+
$this->referenceUpdater->changeReferencesUsingContext($context);
99+
return $book;
100+
}
101+
102+
protected function createBookClone(Book $original, string $newName): Book
68103
{
69104
$bookDetails = $this->entityToInputData($original);
70105
$bookDetails['name'] = $newName;
@@ -76,11 +111,11 @@ public function cloneBook(Book $original, string $newName): Book
76111
$directChildren = $original->getDirectVisibleChildren();
77112
foreach ($directChildren as $child) {
78113
if ($child instanceof Chapter && userCan(Permission::ChapterCreate, $copyBook)) {
79-
$this->cloneChapter($child, $copyBook, $child->name);
114+
$this->createChapterClone($child, $copyBook, $child->name);
80115
}
81116

82117
if ($child instanceof Page && !$child->draft && userCan(Permission::PageCreate, $copyBook)) {
83-
$this->clonePage($child, $copyBook, $child->name);
118+
$this->createPageClone($child, $copyBook, $child->name);
84119
}
85120
}
86121

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

130+
$this->referenceChangeContext->add($original, $copyBook);
131+
95132
return $copyBook;
96133
}
97134

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

156193
return $tags;
157194
}
195+
196+
protected function newReferenceChangeContext(): ReferenceChangeContext
197+
{
198+
$this->referenceChangeContext = new ReferenceChangeContext();
199+
return $this->referenceChangeContext;
200+
}
158201
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace BookStack\References;
4+
5+
use BookStack\Entities\Models\Entity;
6+
7+
class ReferenceChangeContext
8+
{
9+
/**
10+
* Entity pairs where the first is the old entity and the second is the new entity.
11+
* @var array<array{0: Entity, 1: Entity}>
12+
*/
13+
protected array $changes = [];
14+
15+
public function add(Entity $oldEntity, Entity $newEntity): void
16+
{
17+
$this->changes[] = [$oldEntity, $newEntity];
18+
}
19+
20+
/**
21+
* Get all the new entities from the changes.
22+
*/
23+
public function getNewEntities(): array
24+
{
25+
return array_column($this->changes, 1);
26+
}
27+
28+
/**
29+
* Get all the old entities from the changes.
30+
*/
31+
public function getOldEntities(): array
32+
{
33+
return array_column($this->changes, 0);
34+
}
35+
36+
public function getNewForOld(Entity $oldEntity): ?Entity
37+
{
38+
foreach ($this->changes as [$old, $new]) {
39+
if ($old->id === $oldEntity->id && $old->type === $oldEntity->type) {
40+
return $new;
41+
}
42+
}
43+
return null;
44+
}
45+
}

app/References/ReferenceUpdater.php

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use BookStack\Entities\Models\Book;
66
use BookStack\Entities\Models\HasDescriptionInterface;
77
use BookStack\Entities\Models\Entity;
8-
use BookStack\Entities\Models\EntityContainerData;
98
use BookStack\Entities\Models\Page;
109
use BookStack\Entities\Repos\RevisionRepo;
1110
use BookStack\Util\HtmlDocument;
@@ -30,6 +29,47 @@ public function updateEntityReferences(Entity $entity, string $oldLink): void
3029
}
3130
}
3231

32+
/**
33+
* Change existing references for a range of entities using the given context.
34+
*/
35+
public function changeReferencesUsingContext(ReferenceChangeContext $context): void
36+
{
37+
$bindings = [];
38+
foreach ($context->getOldEntities() as $old) {
39+
$bindings[] = $old->getMorphClass();
40+
$bindings[] = $old->id;
41+
}
42+
43+
// No targets to update within the context, so no need to continue.
44+
if (count($bindings) < 2) {
45+
return;
46+
}
47+
48+
$toReferenceQuery = '(to_type, to_id) IN (' . rtrim(str_repeat('(?,?),', count($bindings) / 2), ',') . ')';
49+
50+
// Cycle each new entity in the context
51+
foreach ($context->getNewEntities() as $new) {
52+
// For each, get all references from it which lead to other items within the context of the change
53+
$newReferencesInContext = $new->referencesFrom()->whereRaw($toReferenceQuery, $bindings)->get();
54+
// For each reference, update the URL and the reference entry
55+
foreach ($newReferencesInContext as $reference) {
56+
$oldToEntity = $reference->to;
57+
$newToEntity = $context->getNewForOld($oldToEntity);
58+
if ($newToEntity === null) {
59+
continue;
60+
}
61+
62+
$this->updateReferencesWithinEntity($new, $oldToEntity->getUrl(), $newToEntity->getUrl());
63+
if ($newToEntity instanceof Page && $oldToEntity instanceof Page) {
64+
$this->updateReferencesWithinEntity($new, $oldToEntity->getPermalink(), $newToEntity->getPermalink());
65+
}
66+
$reference->to_id = $newToEntity->id;
67+
$reference->to_type = $newToEntity->getMorphClass();
68+
$reference->save();
69+
}
70+
}
71+
}
72+
3373
/**
3474
* @return Reference[]
3575
*/

tests/Entity/BookTest.php

Lines changed: 0 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -264,108 +264,4 @@ public function test_show_view_displays_description_if_no_description_html_set()
264264
$resp = $this->asEditor()->get($book->getUrl());
265265
$resp->assertSee("<p>My great<br>\ndescription<br>\n<br>\nwith newlines</p>", false);
266266
}
267-
268-
public function test_show_view_has_copy_button()
269-
{
270-
$book = $this->entities->book();
271-
$resp = $this->asEditor()->get($book->getUrl());
272-
273-
$this->withHtml($resp)->assertElementContains("a[href=\"{$book->getUrl('/copy')}\"]", 'Copy');
274-
}
275-
276-
public function test_copy_view()
277-
{
278-
$book = $this->entities->book();
279-
$resp = $this->asEditor()->get($book->getUrl('/copy'));
280-
281-
$resp->assertOk();
282-
$resp->assertSee('Copy Book');
283-
$this->withHtml($resp)->assertElementExists("input[name=\"name\"][value=\"{$book->name}\"]");
284-
}
285-
286-
public function test_copy()
287-
{
288-
/** @var Book $book */
289-
$book = Book::query()->whereHas('chapters')->whereHas('pages')->first();
290-
$resp = $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
291-
292-
/** @var Book $copy */
293-
$copy = Book::query()->where('name', '=', 'My copy book')->first();
294-
295-
$resp->assertRedirect($copy->getUrl());
296-
$this->assertEquals($book->getDirectVisibleChildren()->count(), $copy->getDirectVisibleChildren()->count());
297-
298-
$this->get($copy->getUrl())->assertSee($book->description_html, false);
299-
}
300-
301-
public function test_copy_does_not_copy_non_visible_content()
302-
{
303-
/** @var Book $book */
304-
$book = Book::query()->whereHas('chapters')->whereHas('pages')->first();
305-
306-
// Hide child content
307-
/** @var BookChild $page */
308-
foreach ($book->getDirectVisibleChildren() as $child) {
309-
$this->permissions->setEntityPermissions($child, [], []);
310-
}
311-
312-
$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
313-
/** @var Book $copy */
314-
$copy = Book::query()->where('name', '=', 'My copy book')->first();
315-
316-
$this->assertEquals(0, $copy->getDirectVisibleChildren()->count());
317-
}
318-
319-
public function test_copy_does_not_copy_pages_or_chapters_if_user_cant_create()
320-
{
321-
/** @var Book $book */
322-
$book = Book::query()->whereHas('chapters')->whereHas('directPages')->whereHas('chapters')->first();
323-
$viewer = $this->users->viewer();
324-
$this->permissions->grantUserRolePermissions($viewer, ['book-create-all']);
325-
326-
$this->actingAs($viewer)->post($book->getUrl('/copy'), ['name' => 'My copy book']);
327-
/** @var Book $copy */
328-
$copy = Book::query()->where('name', '=', 'My copy book')->first();
329-
330-
$this->assertEquals(0, $copy->pages()->count());
331-
$this->assertEquals(0, $copy->chapters()->count());
332-
}
333-
334-
public function test_copy_clones_cover_image_if_existing()
335-
{
336-
$book = $this->entities->book();
337-
$bookRepo = $this->app->make(BookRepo::class);
338-
$coverImageFile = $this->files->uploadedImage('cover.png');
339-
$bookRepo->updateCoverImage($book, $coverImageFile);
340-
341-
$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect();
342-
/** @var Book $copy */
343-
$copy = Book::query()->where('name', '=', 'My copy book')->first();
344-
345-
$this->assertNotNull($copy->coverInfo()->getImage());
346-
$this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id);
347-
}
348-
349-
public function test_copy_adds_book_to_shelves_if_edit_permissions_allows()
350-
{
351-
/** @var Bookshelf $shelfA */
352-
/** @var Bookshelf $shelfB */
353-
[$shelfA, $shelfB] = Bookshelf::query()->take(2)->get();
354-
$book = $this->entities->book();
355-
356-
$shelfA->appendBook($book);
357-
$shelfB->appendBook($book);
358-
359-
$viewer = $this->users->viewer();
360-
$this->permissions->grantUserRolePermissions($viewer, ['book-update-all', 'book-create-all', 'bookshelf-update-all']);
361-
$this->permissions->setEntityPermissions($shelfB);
362-
363-
364-
$this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']);
365-
/** @var Book $copy */
366-
$copy = Book::query()->where('name', '=', 'My copy book')->first();
367-
368-
$this->assertTrue($copy->shelves()->where('id', '=', $shelfA->id)->exists());
369-
$this->assertFalse($copy->shelves()->where('id', '=', $shelfB->id)->exists());
370-
}
371267
}

0 commit comments

Comments
 (0)