Skip to content

Commit 0f40aeb

Browse files
authored
Merge pull request #5913 from BookStackApp/slug_history
Slug History Tracking & Usage
2 parents ad582ab + cdd164e commit 0f40aeb

25 files changed

+585
-120
lines changed

app/App/SluggableInterface.php

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,9 @@
55
/**
66
* Assigned to models that can have slugs.
77
* Must have the below properties.
8+
*
9+
* @property string $slug
810
*/
911
interface SluggableInterface
1012
{
11-
/**
12-
* Regenerate the slug for this model.
13-
*/
14-
public function refreshSlug(): string;
1513
}

app/Entities/Controllers/BookController.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use BookStack\Activity\Tools\UserEntityWatchOptions;
99
use BookStack\Entities\Queries\BookQueries;
1010
use BookStack\Entities\Queries\BookshelfQueries;
11+
use BookStack\Entities\Queries\EntityQueries;
1112
use BookStack\Entities\Repos\BookRepo;
1213
use BookStack\Entities\Tools\BookContents;
1314
use BookStack\Entities\Tools\Cloner;
@@ -31,6 +32,7 @@ public function __construct(
3132
protected ShelfContext $shelfContext,
3233
protected BookRepo $bookRepo,
3334
protected BookQueries $queries,
35+
protected EntityQueries $entityQueries,
3436
protected BookshelfQueries $shelfQueries,
3537
protected ReferenceFetcher $referenceFetcher,
3638
) {
@@ -127,7 +129,16 @@ public function store(Request $request, ?string $shelfSlug = null)
127129
*/
128130
public function show(Request $request, ActivityQueries $activities, string $slug)
129131
{
130-
$book = $this->queries->findVisibleBySlugOrFail($slug);
132+
try {
133+
$book = $this->queries->findVisibleBySlugOrFail($slug);
134+
} catch (NotFoundException $exception) {
135+
$book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
136+
if (is_null($book)) {
137+
throw $exception;
138+
}
139+
return redirect($book->getUrl());
140+
}
141+
131142
$bookChildren = (new BookContents($book))->getTree(true);
132143
$bookParentShelves = $book->shelves()->scopes('visible')->get();
133144

app/Entities/Controllers/BookshelfController.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use BookStack\Activity\Models\View;
77
use BookStack\Entities\Queries\BookQueries;
88
use BookStack\Entities\Queries\BookshelfQueries;
9+
use BookStack\Entities\Queries\EntityQueries;
910
use BookStack\Entities\Repos\BookshelfRepo;
1011
use BookStack\Entities\Tools\ShelfContext;
1112
use BookStack\Exceptions\ImageUploadException;
@@ -23,6 +24,7 @@ class BookshelfController extends Controller
2324
public function __construct(
2425
protected BookshelfRepo $shelfRepo,
2526
protected BookshelfQueries $queries,
27+
protected EntityQueries $entityQueries,
2628
protected BookQueries $bookQueries,
2729
protected ShelfContext $shelfContext,
2830
protected ReferenceFetcher $referenceFetcher,
@@ -105,7 +107,16 @@ public function store(Request $request)
105107
*/
106108
public function show(Request $request, ActivityQueries $activities, string $slug)
107109
{
108-
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
110+
try {
111+
$shelf = $this->queries->findVisibleBySlugOrFail($slug);
112+
} catch (NotFoundException $exception) {
113+
$shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
114+
if (is_null($shelf)) {
115+
throw $exception;
116+
}
117+
return redirect($shelf->getUrl());
118+
}
119+
109120
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
110121

111122
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([

app/Entities/Controllers/ChapterController.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,15 @@ public function store(Request $request, string $bookSlug)
7777
*/
7878
public function show(string $bookSlug, string $chapterSlug)
7979
{
80-
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
80+
try {
81+
$chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
82+
} catch (NotFoundException $exception) {
83+
$chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
84+
if (is_null($chapter)) {
85+
throw $exception;
86+
}
87+
return redirect($chapter->getUrl());
88+
}
8189

8290
$sidebarTree = (new BookContents($chapter->book))->getTree();
8391
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();

app/Entities/Controllers/PageController.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
use BookStack\Entities\Tools\PageEditActivity;
1818
use BookStack\Entities\Tools\PageEditorData;
1919
use BookStack\Exceptions\NotFoundException;
20-
use BookStack\Exceptions\NotifyException;
2120
use BookStack\Exceptions\PermissionsException;
2221
use BookStack\Http\Controller;
2322
use BookStack\Permissions\Permission;
@@ -140,9 +139,7 @@ public function show(string $bookSlug, string $pageSlug)
140139
try {
141140
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
142141
} catch (NotFoundException $e) {
143-
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
144-
$page = $revision->page ?? null;
145-
142+
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
146143
if (is_null($page)) {
147144
throw $e;
148145
}

app/Entities/Models/BookChild.php

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace BookStack\Entities\Models;
44

5-
use BookStack\References\ReferenceUpdater;
65
use Illuminate\Database\Eloquent\Relations\BelongsTo;
76

87
/**
@@ -17,34 +16,10 @@ abstract class BookChild extends Entity
1716
{
1817
/**
1918
* Get the book this page sits in.
19+
* @return BelongsTo<Book, $this>
2020
*/
2121
public function book(): BelongsTo
2222
{
2323
return $this->belongsTo(Book::class)->withTrashed();
2424
}
25-
26-
/**
27-
* Change the book that this entity belongs to.
28-
*/
29-
public function changeBook(int $newBookId): self
30-
{
31-
$oldUrl = $this->getUrl();
32-
$this->book_id = $newBookId;
33-
$this->unsetRelation('book');
34-
$this->refreshSlug();
35-
$this->save();
36-
37-
if ($oldUrl !== $this->getUrl()) {
38-
app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
39-
}
40-
41-
// Update all child pages if a chapter
42-
if ($this instanceof Chapter) {
43-
foreach ($this->pages()->withTrashed()->get() as $page) {
44-
$page->changeBook($newBookId);
45-
}
46-
}
47-
48-
return $this;
49-
}
5025
}

app/Entities/Models/Entity.php

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
use BookStack\Activity\Models\Watch;
1414
use BookStack\App\Model;
1515
use BookStack\App\SluggableInterface;
16-
use BookStack\Entities\Tools\SlugGenerator;
1716
use BookStack\Permissions\JointPermissionBuilder;
1817
use BookStack\Permissions\Models\EntityPermission;
1918
use BookStack\Permissions\Models\JointPermission;
@@ -405,16 +404,6 @@ public function indexForSearch(): void
405404
app()->make(SearchIndex::class)->indexEntity(clone $this);
406405
}
407406

408-
/**
409-
* {@inheritdoc}
410-
*/
411-
public function refreshSlug(): string
412-
{
413-
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
414-
415-
return $this->slug;
416-
}
417-
418407
/**
419408
* {@inheritdoc}
420409
*/
@@ -441,6 +430,14 @@ public function watches(): MorphMany
441430
return $this->morphMany(Watch::class, 'watchable');
442431
}
443432

433+
/**
434+
* Get the related slug history for this entity.
435+
*/
436+
public function slugHistory(): MorphMany
437+
{
438+
return $this->morphMany(SlugHistory::class, 'sluggable');
439+
}
440+
444441
/**
445442
* {@inheritdoc}
446443
*/
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
namespace BookStack\Entities\Models;
4+
5+
use BookStack\App\Model;
6+
use BookStack\Permissions\Models\JointPermission;
7+
use Illuminate\Database\Eloquent\Factories\HasFactory;
8+
use Illuminate\Database\Eloquent\Relations\HasMany;
9+
10+
/**
11+
* @property int $id
12+
* @property int $sluggable_id
13+
* @property string $sluggable_type
14+
* @property string $slug
15+
* @property ?string $parent_slug
16+
*/
17+
class SlugHistory extends Model
18+
{
19+
use HasFactory;
20+
21+
protected $table = 'slug_history';
22+
23+
public function jointPermissions(): HasMany
24+
{
25+
return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
26+
->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
27+
}
28+
}

app/Entities/Queries/EntityQueries.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use BookStack\Entities\Models\Entity;
66
use BookStack\Entities\Models\EntityTable;
7+
use BookStack\Entities\Tools\SlugHistory;
78
use Illuminate\Database\Eloquent\Builder;
89
use Illuminate\Database\Query\Builder as QueryBuilder;
910
use Illuminate\Database\Query\JoinClause;
@@ -18,6 +19,7 @@ public function __construct(
1819
public ChapterQueries $chapters,
1920
public PageQueries $pages,
2021
public PageRevisionQueries $revisions,
22+
protected SlugHistory $slugHistory,
2123
) {
2224
}
2325

@@ -31,9 +33,30 @@ public function findVisibleByStringIdentifier(string $identifier): ?Entity
3133
$explodedId = explode(':', $identifier);
3234
$entityType = $explodedId[0];
3335
$entityId = intval($explodedId[1]);
34-
$queries = $this->getQueriesForType($entityType);
3536

36-
return $queries->findVisibleById($entityId);
37+
return $this->findVisibleById($entityType, $entityId);
38+
}
39+
40+
/**
41+
* Find an entity by its ID.
42+
*/
43+
public function findVisibleById(string $type, int $id): ?Entity
44+
{
45+
$queries = $this->getQueriesForType($type);
46+
return $queries->findVisibleById($id);
47+
}
48+
49+
/**
50+
* Find an entity by looking up old slugs in the slug history.
51+
*/
52+
public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
53+
{
54+
$id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
55+
if ($id === null) {
56+
return null;
57+
}
58+
59+
return $this->findVisibleById($type, $id);
3760
}
3861

3962
/**

app/Entities/Repos/BaseRepo.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use BookStack\Entities\Models\HasDescriptionInterface;
99
use BookStack\Entities\Models\Entity;
1010
use BookStack\Entities\Queries\PageQueries;
11+
use BookStack\Entities\Tools\SlugGenerator;
12+
use BookStack\Entities\Tools\SlugHistory;
1113
use BookStack\Exceptions\ImageUploadException;
1214
use BookStack\References\ReferenceStore;
1315
use BookStack\References\ReferenceUpdater;
@@ -25,6 +27,8 @@ public function __construct(
2527
protected ReferenceStore $referenceStore,
2628
protected PageQueries $pageQueries,
2729
protected BookSorter $bookSorter,
30+
protected SlugGenerator $slugGenerator,
31+
protected SlugHistory $slugHistory,
2832
) {
2933
}
3034

@@ -43,7 +47,7 @@ public function create(Entity $entity, array $input): Entity
4347
'updated_by' => user()->id,
4448
'owned_by' => user()->id,
4549
]);
46-
$entity->refreshSlug();
50+
$this->refreshSlug($entity);
4751

4852
if ($entity instanceof HasDescriptionInterface) {
4953
$this->updateDescription($entity, $input);
@@ -78,7 +82,7 @@ public function update(Entity $entity, array $input): Entity
7882
$entity->updated_by = user()->id;
7983

8084
if ($entity->isDirty('name') || empty($entity->slug)) {
81-
$entity->refreshSlug();
85+
$this->refreshSlug($entity);
8286
}
8387

8488
if ($entity instanceof HasDescriptionInterface) {
@@ -155,4 +159,13 @@ protected function updateDescription(Entity $entity, array $input): void
155159
$entity->descriptionInfo()->set('', $input['description']);
156160
}
157161
}
162+
163+
/**
164+
* Refresh the slug for the given entity.
165+
*/
166+
public function refreshSlug(Entity $entity): void
167+
{
168+
$this->slugHistory->recordForEntity($entity);
169+
$this->slugGenerator->regenerateForEntity($entity);
170+
}
158171
}

0 commit comments

Comments
 (0)