]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #5913 from BookStackApp/slug_history
authorDan Brown <redacted>
Mon, 24 Nov 2025 20:29:44 +0000 (20:29 +0000)
committerGitHub <redacted>
Mon, 24 Nov 2025 20:29:44 +0000 (20:29 +0000)
Slug History Tracking & Usage

25 files changed:
app/App/SluggableInterface.php
app/Entities/Controllers/BookController.php
app/Entities/Controllers/BookshelfController.php
app/Entities/Controllers/ChapterController.php
app/Entities/Controllers/PageController.php
app/Entities/Models/BookChild.php
app/Entities/Models/Entity.php
app/Entities/Models/SlugHistory.php [new file with mode: 0644]
app/Entities/Queries/EntityQueries.php
app/Entities/Repos/BaseRepo.php
app/Entities/Repos/ChapterRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Tools/HierarchyTransformer.php
app/Entities/Tools/ParentChanger.php [new file with mode: 0644]
app/Entities/Tools/SlugGenerator.php
app/Entities/Tools/SlugHistory.php [new file with mode: 0644]
app/Entities/Tools/TrashCan.php
app/Sorting/BookSorter.php
app/Users/Models/User.php
app/Users/UserRepo.php
database/factories/Entities/Models/SlugHistoryFactory.php [new file with mode: 0644]
database/migrations/2025_11_23_161812_create_slug_history_table.php [new file with mode: 0644]
tests/Entity/BookTest.php
tests/Entity/PageTest.php
tests/Entity/SlugTest.php [new file with mode: 0644]

index 96af49cd3239ae2fbbb663f32834e01f8e7b9a03..dd544f5ed21275f8fadf71d03f887adf935edddd 100644 (file)
@@ -5,11 +5,9 @@ namespace BookStack\App;
 /**
  * Assigned to models that can have slugs.
  * Must have the below properties.
+ *
+ * @property string $slug
  */
 interface SluggableInterface
 {
-    /**
-     * Regenerate the slug for this model.
-     */
-    public function refreshSlug(): string;
 }
index cbf7ffb7984896d0f70d2c88bd3073d11be771b7..0610c2ef5eafa2951ecd7d99f0fb0b6aa97323c9 100644 (file)
@@ -8,6 +8,7 @@ use BookStack\Activity\Models\View;
 use BookStack\Activity\Tools\UserEntityWatchOptions;
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Queries\BookshelfQueries;
+use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Repos\BookRepo;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\Cloner;
@@ -31,6 +32,7 @@ class BookController extends Controller
         protected ShelfContext $shelfContext,
         protected BookRepo $bookRepo,
         protected BookQueries $queries,
+        protected EntityQueries $entityQueries,
         protected BookshelfQueries $shelfQueries,
         protected ReferenceFetcher $referenceFetcher,
     ) {
@@ -127,7 +129,16 @@ class BookController extends Controller
      */
     public function show(Request $request, ActivityQueries $activities, string $slug)
     {
-        $book = $this->queries->findVisibleBySlugOrFail($slug);
+        try {
+            $book = $this->queries->findVisibleBySlugOrFail($slug);
+        } catch (NotFoundException $exception) {
+            $book = $this->entityQueries->findVisibleByOldSlugs('book', $slug);
+            if (is_null($book)) {
+                throw $exception;
+            }
+            return redirect($book->getUrl());
+        }
+
         $bookChildren = (new BookContents($book))->getTree(true);
         $bookParentShelves = $book->shelves()->scopes('visible')->get();
 
index 8d7ffb8f9b04142524507fa974ee03b461e30194..c4b861c90105bce0207c2d3e9ffbb3f7629273c8 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityQueries;
 use BookStack\Activity\Models\View;
 use BookStack\Entities\Queries\BookQueries;
 use BookStack\Entities\Queries\BookshelfQueries;
+use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Repos\BookshelfRepo;
 use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Exceptions\ImageUploadException;
@@ -23,6 +24,7 @@ class BookshelfController extends Controller
     public function __construct(
         protected BookshelfRepo $shelfRepo,
         protected BookshelfQueries $queries,
+        protected EntityQueries $entityQueries,
         protected BookQueries $bookQueries,
         protected ShelfContext $shelfContext,
         protected ReferenceFetcher $referenceFetcher,
@@ -105,7 +107,16 @@ class BookshelfController extends Controller
      */
     public function show(Request $request, ActivityQueries $activities, string $slug)
     {
-        $shelf = $this->queries->findVisibleBySlugOrFail($slug);
+        try {
+            $shelf = $this->queries->findVisibleBySlugOrFail($slug);
+        } catch (NotFoundException $exception) {
+            $shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug);
+            if (is_null($shelf)) {
+                throw $exception;
+            }
+            return redirect($shelf->getUrl());
+        }
+
         $this->checkOwnablePermission(Permission::BookshelfView, $shelf);
 
         $listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
index a1af29de269ec6f710e9f41f3e993d5d4608580a..878ee42b5ae75e92def1a9cecec69eb7796029df 100644 (file)
@@ -77,7 +77,15 @@ class ChapterController extends Controller
      */
     public function show(string $bookSlug, string $chapterSlug)
     {
-        $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
+        try {
+            $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug);
+        } catch (NotFoundException $exception) {
+            $chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug);
+            if (is_null($chapter)) {
+                throw $exception;
+            }
+            return redirect($chapter->getUrl());
+        }
 
         $sidebarTree = (new BookContents($chapter->book))->getTree();
         $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
index 603d015ef4a0768aac2cc3c557e8c77aa7f19d71..a648bc298829baf2eb1118f9fae230c7f050e515 100644 (file)
@@ -17,7 +17,6 @@ use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Tools\PageEditActivity;
 use BookStack\Entities\Tools\PageEditorData;
 use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\PermissionsException;
 use BookStack\Http\Controller;
 use BookStack\Permissions\Permission;
@@ -140,9 +139,7 @@ class PageController extends Controller
         try {
             $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
         } catch (NotFoundException $e) {
-            $revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
-            $page = $revision->page ?? null;
-
+            $page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
             if (is_null($page)) {
                 throw $e;
             }
index 4a2e52aedd5e811160e9049f69983e4a7d7ec1d4..9a8493c3a0a48c9f97a5b60fde9eda09920de6e5 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace BookStack\Entities\Models;
 
-use BookStack\References\ReferenceUpdater;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
@@ -17,34 +16,10 @@ abstract class BookChild extends Entity
 {
     /**
      * Get the book this page sits in.
+     * @return BelongsTo<Book, $this>
      */
     public function book(): BelongsTo
     {
         return $this->belongsTo(Book::class)->withTrashed();
     }
-
-    /**
-     * Change the book that this entity belongs to.
-     */
-    public function changeBook(int $newBookId): self
-    {
-        $oldUrl = $this->getUrl();
-        $this->book_id = $newBookId;
-        $this->unsetRelation('book');
-        $this->refreshSlug();
-        $this->save();
-
-        if ($oldUrl !== $this->getUrl()) {
-            app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl);
-        }
-
-        // Update all child pages if a chapter
-        if ($this instanceof Chapter) {
-            foreach ($this->pages()->withTrashed()->get() as $page) {
-                $page->changeBook($newBookId);
-            }
-        }
-
-        return $this;
-    }
 }
index 641fe29d5466b3c256ed3678090cd524a57f7340..47e13462691fa884df55ec93ae372d15123f1799 100644 (file)
@@ -13,7 +13,6 @@ use BookStack\Activity\Models\Viewable;
 use BookStack\Activity\Models\Watch;
 use BookStack\App\Model;
 use BookStack\App\SluggableInterface;
-use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Permissions\JointPermissionBuilder;
 use BookStack\Permissions\Models\EntityPermission;
 use BookStack\Permissions\Models\JointPermission;
@@ -405,16 +404,6 @@ abstract class Entity extends Model implements
         app()->make(SearchIndex::class)->indexEntity(clone $this);
     }
 
-    /**
-     * {@inheritdoc}
-     */
-    public function refreshSlug(): string
-    {
-        $this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
-
-        return $this->slug;
-    }
-
     /**
      * {@inheritdoc}
      */
@@ -441,6 +430,14 @@ abstract class Entity extends Model implements
         return $this->morphMany(Watch::class, 'watchable');
     }
 
+    /**
+     * Get the related slug history for this entity.
+     */
+    public function slugHistory(): MorphMany
+    {
+        return $this->morphMany(SlugHistory::class, 'sluggable');
+    }
+
     /**
      * {@inheritdoc}
      */
diff --git a/app/Entities/Models/SlugHistory.php b/app/Entities/Models/SlugHistory.php
new file mode 100644 (file)
index 0000000..4041ced
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\App\Model;
+use BookStack\Permissions\Models\JointPermission;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+/**
+ * @property int $id
+ * @property int $sluggable_id
+ * @property string $sluggable_type
+ * @property string $slug
+ * @property ?string $parent_slug
+ */
+class SlugHistory extends Model
+{
+    use HasFactory;
+
+    protected $table = 'slug_history';
+
+    public function jointPermissions(): HasMany
+    {
+        return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
+            ->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
+    }
+}
index 91c6a43633d8480ead05df70e459c34a0d180de9..3ffa0adf3dbfb6692f7ca36e3d8a0470c1d978c1 100644 (file)
@@ -4,6 +4,7 @@ namespace BookStack\Entities\Queries;
 
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\EntityTable;
+use BookStack\Entities\Tools\SlugHistory;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Query\Builder as QueryBuilder;
 use Illuminate\Database\Query\JoinClause;
@@ -18,6 +19,7 @@ class EntityQueries
         public ChapterQueries $chapters,
         public PageQueries $pages,
         public PageRevisionQueries $revisions,
+        protected SlugHistory $slugHistory,
     ) {
     }
 
@@ -31,9 +33,30 @@ class EntityQueries
         $explodedId = explode(':', $identifier);
         $entityType = $explodedId[0];
         $entityId = intval($explodedId[1]);
-        $queries = $this->getQueriesForType($entityType);
 
-        return $queries->findVisibleById($entityId);
+        return $this->findVisibleById($entityType, $entityId);
+    }
+
+    /**
+     * Find an entity by its ID.
+     */
+    public function findVisibleById(string $type, int $id): ?Entity
+    {
+        $queries = $this->getQueriesForType($type);
+        return $queries->findVisibleById($id);
+    }
+
+    /**
+     * Find an entity by looking up old slugs in the slug history.
+     */
+    public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
+    {
+        $id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
+        if ($id === null) {
+            return null;
+        }
+
+        return $this->findVisibleById($type, $id);
     }
 
     /**
index fd88625cd9af22cef6da07b597159e1bc83d0855..717e9c9f82a09012245d7f3025f7ce2648da9f60 100644 (file)
@@ -8,6 +8,8 @@ use BookStack\Entities\Models\HasCoverInterface;
 use BookStack\Entities\Models\HasDescriptionInterface;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Queries\PageQueries;
+use BookStack\Entities\Tools\SlugGenerator;
+use BookStack\Entities\Tools\SlugHistory;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\References\ReferenceStore;
 use BookStack\References\ReferenceUpdater;
@@ -25,6 +27,8 @@ class BaseRepo
         protected ReferenceStore $referenceStore,
         protected PageQueries $pageQueries,
         protected BookSorter $bookSorter,
+        protected SlugGenerator $slugGenerator,
+        protected SlugHistory $slugHistory,
     ) {
     }
 
@@ -43,7 +47,7 @@ class BaseRepo
             'updated_by' => user()->id,
             'owned_by'   => user()->id,
         ]);
-        $entity->refreshSlug();
+        $this->refreshSlug($entity);
 
         if ($entity instanceof HasDescriptionInterface) {
             $this->updateDescription($entity, $input);
@@ -78,7 +82,7 @@ class BaseRepo
         $entity->updated_by = user()->id;
 
         if ($entity->isDirty('name') || empty($entity->slug)) {
-            $entity->refreshSlug();
+            $this->refreshSlug($entity);
         }
 
         if ($entity instanceof HasDescriptionInterface) {
@@ -155,4 +159,13 @@ class BaseRepo
             $entity->descriptionInfo()->set('', $input['description']);
         }
     }
+
+    /**
+     * Refresh the slug for the given entity.
+     */
+    public function refreshSlug(Entity $entity): void
+    {
+        $this->slugHistory->recordForEntity($entity);
+        $this->slugGenerator->regenerateForEntity($entity);
+    }
 }
index d5feb30fdfd90fcab252a47d0607655a08d31207..a528eece092262d4cf12b339ad556c9d9b548d65 100644 (file)
@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Tools\BookContents;
+use BookStack\Entities\Tools\ParentChanger;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\PermissionsException;
@@ -21,6 +22,7 @@ class ChapterRepo
         protected BaseRepo $baseRepo,
         protected EntityQueries $entityQueries,
         protected TrashCan $trashCan,
+        protected ParentChanger $parentChanger,
     ) {
     }
 
@@ -97,7 +99,7 @@ class ChapterRepo
         }
 
         return (new DatabaseTransaction(function () use ($chapter, $parent) {
-            $chapter = $chapter->changeBook($parent->id);
+            $this->parentChanger->changeBook($chapter, $parent->id);
             $chapter->rebuildPermissions();
             Activity::add(ActivityType::CHAPTER_MOVE, $chapter);
 
index f2e558210aee6330b099d5c575a8b1ee2c9907c5..bc590785d931f712e4c0e14a3500061e62f97dee 100644 (file)
@@ -12,6 +12,7 @@ use BookStack\Entities\Queries\EntityQueries;
 use BookStack\Entities\Tools\BookContents;
 use BookStack\Entities\Tools\PageContent;
 use BookStack\Entities\Tools\PageEditorType;
+use BookStack\Entities\Tools\ParentChanger;
 use BookStack\Entities\Tools\TrashCan;
 use BookStack\Exceptions\MoveOperationException;
 use BookStack\Exceptions\PermissionsException;
@@ -31,6 +32,7 @@ class PageRepo
         protected ReferenceStore $referenceStore,
         protected ReferenceUpdater $referenceUpdater,
         protected TrashCan $trashCan,
+        protected ParentChanger $parentChanger,
     ) {
     }
 
@@ -242,7 +244,7 @@ class PageRepo
         }
 
         $page->updated_by = user()->id;
-        $page->refreshSlug();
+        $this->baseRepo->refreshSlug($page);
         $page->save();
         $page->indexForSearch();
         $this->referenceStore->updateForEntity($page);
@@ -284,7 +286,7 @@ class PageRepo
         return (new DatabaseTransaction(function () use ($page, $parent) {
             $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
             $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
-            $page = $page->changeBook($newBookId);
+            $this->parentChanger->changeBook($page, $newBookId);
             $page->rebuildPermissions();
 
             Activity::add(ActivityType::PAGE_MOVE, $page);
index fa45fcd116b5817ea42ec46f34c7c8205b768033..c58d29bd07374d3dde79a9108e52844c71e281b2 100644 (file)
@@ -17,7 +17,8 @@ class HierarchyTransformer
         protected BookRepo $bookRepo,
         protected BookshelfRepo $shelfRepo,
         protected Cloner $cloner,
-        protected TrashCan $trashCan
+        protected TrashCan $trashCan,
+        protected ParentChanger $parentChanger,
     ) {
     }
 
@@ -35,7 +36,7 @@ class HierarchyTransformer
         foreach ($chapter->pages as $page) {
             $page->chapter_id = 0;
             $page->save();
-            $page->changeBook($book->id);
+            $this->parentChanger->changeBook($page, $book->id);
         }
 
         $this->trashCan->destroyEntity($chapter);
diff --git a/app/Entities/Tools/ParentChanger.php b/app/Entities/Tools/ParentChanger.php
new file mode 100644 (file)
index 0000000..00ce42a
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Chapter;
+use BookStack\References\ReferenceUpdater;
+
+class ParentChanger
+{
+    public function __construct(
+        protected SlugGenerator $slugGenerator,
+        protected ReferenceUpdater $referenceUpdater
+    ) {
+    }
+
+    /**
+     * Change the parent book of a chapter or page.
+     */
+    public function changeBook(BookChild $child, int $newBookId): void
+    {
+        $oldUrl = $child->getUrl();
+
+        $child->book_id = $newBookId;
+        $child->unsetRelation('book');
+        $this->slugGenerator->regenerateForEntity($child);
+        $child->save();
+
+        if ($oldUrl !== $child->getUrl()) {
+            $this->referenceUpdater->updateEntityReferences($child, $oldUrl);
+        }
+
+        // Update all child pages if a chapter
+        if ($child instanceof Chapter) {
+            foreach ($child->pages()->withTrashed()->get() as $page) {
+                $this->changeBook($page, $newBookId);
+            }
+        }
+    }
+}
index fb912318750e89744fd15d2d0ce8d3e98f34cb23..6eec84a91c1bee4c9e5704aa0963770a1fa0e95f 100644 (file)
@@ -5,12 +5,14 @@ namespace BookStack\Entities\Tools;
 use BookStack\App\Model;
 use BookStack\App\SluggableInterface;
 use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Entity;
+use BookStack\Users\Models\User;
 use Illuminate\Support\Str;
 
 class SlugGenerator
 {
     /**
-     * Generate a fresh slug for the given entity.
+     * Generate a fresh slug for the given item.
      * The slug will be generated so that it doesn't conflict within the same parent item.
      */
     public function generate(SluggableInterface&Model $model, string $slugSource): string
@@ -23,6 +25,26 @@ class SlugGenerator
         return $slug;
     }
 
+    /**
+     * Regenerate the slug for the given entity.
+     */
+    public function regenerateForEntity(Entity $entity): string
+    {
+        $entity->slug = $this->generate($entity, $entity->name);
+
+        return $entity->slug;
+    }
+
+    /**
+     * Regenerate the slug for a user.
+     */
+    public function regenerateForUser(User $user): string
+    {
+        $user->slug = $this->generate($user, $user->name);
+
+        return $user->slug;
+    }
+
     /**
      * Format a name as a URL slug.
      */
diff --git a/app/Entities/Tools/SlugHistory.php b/app/Entities/Tools/SlugHistory.php
new file mode 100644 (file)
index 0000000..2c8d881
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\EntityTable;
+use BookStack\Entities\Models\SlugHistory as SlugHistoryModel;
+use BookStack\Permissions\PermissionApplicator;
+use Illuminate\Support\Facades\DB;
+
+class SlugHistory
+{
+    public function __construct(
+        protected PermissionApplicator $permissions,
+    ) {
+    }
+
+    /**
+     * Record the current slugs for the given entity.
+     */
+    public function recordForEntity(Entity $entity): void
+    {
+        if (!$entity->id || !$entity->slug) {
+            return;
+        }
+
+        $parentSlug = null;
+        if ($entity instanceof BookChild) {
+            $parentSlug = $entity->book()->first()?->slug;
+        }
+
+        $latest = $this->getLatestEntryForEntity($entity);
+        if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
+            return;
+        }
+
+        $info = [
+            'sluggable_type' => $entity->getMorphClass(),
+            'sluggable_id'   => $entity->id,
+            'slug'           => $entity->slug,
+            'parent_slug'    => $parentSlug,
+        ];
+
+        $entry = new SlugHistoryModel();
+        $entry->forceFill($info);
+        $entry->save();
+
+        if ($entity instanceof Book) {
+            $this->recordForBookChildren($entity);
+        }
+    }
+
+    protected function recordForBookChildren(Book $book): void
+    {
+        $query = EntityTable::query()
+            ->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')])
+            ->where('book_id', '=', $book->id)
+            ->whereNotNull('book_id');
+
+        SlugHistoryModel::query()->insertUsing(
+            ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
+            $query
+        );
+    }
+
+    /**
+     * Find the latest visible entry for an entity which uses the given slug(s) in the history.
+     */
+    public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
+    {
+        $query = SlugHistoryModel::query()
+            ->where('sluggable_type', '=', $type)
+            ->where('slug', '=', $slug);
+
+        if ($parentSlug) {
+            $query->where('parent_slug', '=', $parentSlug);
+        }
+
+        $query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
+
+        /** @var SlugHistoryModel|null $result */
+        $result = $query->orderBy('created_at', 'desc')->first();
+
+        return $result?->sluggable_id;
+    }
+
+    protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
+    {
+        return SlugHistoryModel::query()
+            ->where('sluggable_type', '=', $entity->getMorphClass())
+            ->where('sluggable_id', '=', $entity->id)
+            ->orderBy('created_at', 'desc')
+            ->first();
+    }
+}
index c298169c383edb48fcbcebef5cbc56ea209abb96..96645aebfa6c2670bfee0a337ec5090a3922a094 100644 (file)
@@ -388,7 +388,7 @@ class TrashCan
     /**
      * Update entity relations to remove or update outstanding connections.
      */
-    protected function destroyCommonRelations(Entity $entity)
+    protected function destroyCommonRelations(Entity $entity): void
     {
         Activity::removeEntity($entity);
         $entity->views()->delete();
@@ -402,6 +402,7 @@ class TrashCan
         $entity->watches()->delete();
         $entity->referencesTo()->delete();
         $entity->referencesFrom()->delete();
+        $entity->slugHistory()->delete();
 
         if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
             $imageService = app()->make(ImageService::class);
index 99e307e35cc94e3ea7ebcca349e6a26ff4c364d9..b4f93d47b11a6988f9b9325e65a0b60f479dd007 100644 (file)
@@ -8,12 +8,14 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use BookStack\Entities\Queries\EntityQueries;
+use BookStack\Entities\Tools\ParentChanger;
 use BookStack\Permissions\Permission;
 
 class BookSorter
 {
     public function __construct(
         protected EntityQueries $queries,
+        protected ParentChanger $parentChanger,
     ) {
     }
 
@@ -155,7 +157,7 @@ class BookSorter
 
         // Action the required changes
         if ($bookChanged) {
-            $model = $model->changeBook($newBook->id);
+            $this->parentChanger->changeBook($model, $newBook->id);
         }
 
         if ($model instanceof Page && $chapterChanged) {
index 8bbf11695e2cee2bc6b9599ad5aec5b0ff6b7362..50efdcdad603bc4272765266a137c819bb67271e 100644 (file)
@@ -11,7 +11,6 @@ use BookStack\Activity\Models\Watch;
 use BookStack\Api\ApiToken;
 use BookStack\App\Model;
 use BookStack\App\SluggableInterface;
-use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Permissions\Permission;
 use BookStack\Translation\LocaleDefinition;
 use BookStack\Translation\LocaleManager;
@@ -358,14 +357,4 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
     {
         return "({$this->id}) {$this->name}";
     }
-
-    /**
-     * {@inheritdoc}
-     */
-    public function refreshSlug(): string
-    {
-        $this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
-
-        return $this->slug;
-    }
 }
index 894d7c01f7d00e42efb1cab25aa4aa1a5fbefa2c..2c0897ceffb87141c4b6c6b21f2eafdedaf5dc15 100644 (file)
@@ -5,6 +5,7 @@ namespace BookStack\Users;
 use BookStack\Access\UserInviteException;
 use BookStack\Access\UserInviteService;
 use BookStack\Activity\ActivityType;
+use BookStack\Entities\Tools\SlugGenerator;
 use BookStack\Exceptions\NotifyException;
 use BookStack\Exceptions\UserUpdateException;
 use BookStack\Facades\Activity;
@@ -21,7 +22,8 @@ class UserRepo
 {
     public function __construct(
         protected UserAvatars $userAvatar,
-        protected UserInviteService $inviteService
+        protected UserInviteService $inviteService,
+        protected SlugGenerator $slugGenerator,
     ) {
     }
 
@@ -63,7 +65,7 @@ class UserRepo
         $user->email_confirmed = $emailConfirmed;
         $user->external_auth_id = $data['external_auth_id'] ?? '';
 
-        $user->refreshSlug();
+        $this->slugGenerator->regenerateForUser($user);
         $user->save();
 
         if (!empty($data['language'])) {
@@ -109,7 +111,7 @@ class UserRepo
     {
         if (!empty($data['name'])) {
             $user->name = $data['name'];
-            $user->refreshSlug();
+            $this->slugGenerator->regenerateForUser($user);
         }
 
         if (!empty($data['email']) && $manageUsersAllowed) {
diff --git a/database/factories/Entities/Models/SlugHistoryFactory.php b/database/factories/Entities/Models/SlugHistoryFactory.php
new file mode 100644 (file)
index 0000000..c8c57e0
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use BookStack\Entities\Models\Book;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Entities\Models\SlugHistory>
+ */
+class SlugHistoryFactory extends Factory
+{
+    protected $model = \BookStack\Entities\Models\SlugHistory::class;
+
+    /**
+     * Define the model's default state.
+     *
+     * @return array<string, mixed>
+     */
+    public function definition(): array
+    {
+        return [
+            'sluggable_id' => Book::factory(),
+            'sluggable_type' => 'book',
+            'slug' => $this->faker->slug(),
+            'parent_slug' => null,
+        ];
+    }
+}
diff --git a/database/migrations/2025_11_23_161812_create_slug_history_table.php b/database/migrations/2025_11_23_161812_create_slug_history_table.php
new file mode 100644 (file)
index 0000000..df30bf0
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        // Create the table for storing slug history
+        Schema::create('slug_history', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('sluggable_type', 10)->index();
+            $table->unsignedBigInteger('sluggable_id')->index();
+            $table->string('slug')->index();
+            $table->string('parent_slug')->nullable()->index();
+            $table->timestamps();
+        });
+
+        // Migrate in slugs from page revisions
+        $revisionSlugQuery = DB::table('page_revisions')
+            ->select([
+                DB::raw('\'page\' as sluggable_type'),
+                'page_id as sluggable_id',
+                'slug',
+                'book_slug as parent_slug',
+                DB::raw('min(created_at) as created_at'),
+                DB::raw('min(updated_at) as updated_at'),
+            ])
+            ->where('type', '=', 'version')
+            ->groupBy(['sluggable_id', 'slug', 'parent_slug']);
+
+        DB::table('slug_history')->insertUsing(
+            ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
+            $revisionSlugQuery,
+        );
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('slug_history');
+    }
+};
index 543c4e8bbdb032d910577b103d06a20e0b7c8b1b..545d6b305784bf2c80dc6e772323cbd2535b945a 100644 (file)
@@ -238,30 +238,6 @@ class BookTest extends TestCase
         $this->assertEquals('list', setting()->getUser($editor, 'books_view_type'));
     }
 
-    public function test_slug_multi_byte_url_safe()
-    {
-        $book = $this->entities->newBook([
-            'name' => 'информация',
-        ]);
-
-        $this->assertEquals('informaciia', $book->slug);
-
-        $book = $this->entities->newBook([
-            'name' => '¿Qué?',
-        ]);
-
-        $this->assertEquals('que', $book->slug);
-    }
-
-    public function test_slug_format()
-    {
-        $book = $this->entities->newBook([
-            'name' => 'PartA / PartB / PartC',
-        ]);
-
-        $this->assertEquals('parta-partb-partc', $book->slug);
-    }
-
     public function test_description_limited_to_specific_html()
     {
         $book = $this->entities->book();
index b9e1294e0ec44ca7b911e4363f51109f676ba511..afe15f4d4a355adef55c41439873e65d3b6e6ad1 100644 (file)
@@ -269,28 +269,6 @@ class PageTest extends TestCase
         ]);
     }
 
-    public function test_old_page_slugs_redirect_to_new_pages()
-    {
-        $page = $this->entities->page();
-
-        // Need to save twice since revisions are not generated in seeder.
-        $this->asAdmin()->put($page->getUrl(), [
-            'name' => 'super test',
-            'html' => '<p></p>',
-        ]);
-
-        $page->refresh();
-        $pageUrl = $page->getUrl();
-
-        $this->put($pageUrl, [
-            'name' => 'super test page',
-            'html' => '<p></p>',
-        ]);
-
-        $this->get($pageUrl)
-            ->assertRedirect("/books/{$page->book->slug}/page/super-test-page");
-    }
-
     public function test_page_within_chapter_deletion_returns_to_chapter()
     {
         $chapter = $this->entities->chapter();
diff --git a/tests/Entity/SlugTest.php b/tests/Entity/SlugTest.php
new file mode 100644 (file)
index 0000000..51cf34e
--- /dev/null
@@ -0,0 +1,212 @@
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\SlugHistory;
+use Tests\TestCase;
+
+class SlugTest extends TestCase
+{
+    public function test_slug_multi_byte_url_safe()
+    {
+        $book = $this->entities->newBook([
+            'name' => 'информация',
+        ]);
+
+        $this->assertEquals('informaciia', $book->slug);
+
+        $book = $this->entities->newBook([
+            'name' => '¿Qué?',
+        ]);
+
+        $this->assertEquals('que', $book->slug);
+    }
+
+    public function test_slug_format()
+    {
+        $book = $this->entities->newBook([
+            'name' => 'PartA / PartB / PartC',
+        ]);
+
+        $this->assertEquals('parta-partb-partc', $book->slug);
+    }
+
+    public function test_old_page_slugs_redirect_to_new_pages()
+    {
+        $page = $this->entities->page();
+        $pageUrl = $page->getUrl();
+
+        $this->asAdmin()->put($pageUrl, [
+            'name' => 'super test page',
+            'html' => '<p></p>',
+        ]);
+
+        $this->get($pageUrl)
+            ->assertRedirect("/books/{$page->book->slug}/page/super-test-page");
+    }
+
+    public function test_old_shelf_slugs_redirect_to_new_shelf()
+    {
+        $shelf = $this->entities->shelf();
+        $shelfUrl = $shelf->getUrl();
+
+        $this->asAdmin()->put($shelf->getUrl(), [
+            'name' => 'super test shelf',
+        ]);
+
+        $this->get($shelfUrl)
+            ->assertRedirect("/shelves/super-test-shelf");
+    }
+
+    public function test_old_book_slugs_redirect_to_new_book()
+    {
+        $book = $this->entities->book();
+        $bookUrl = $book->getUrl();
+
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'super test book',
+        ]);
+
+        $this->get($bookUrl)
+            ->assertRedirect("/books/super-test-book");
+    }
+
+    public function test_old_chapter_slugs_redirect_to_new_chapter()
+    {
+        $chapter = $this->entities->chapter();
+        $chapterUrl = $chapter->getUrl();
+
+        $this->asAdmin()->put($chapter->getUrl(), [
+            'name' => 'super test chapter',
+        ]);
+
+        $this->get($chapterUrl)
+            ->assertRedirect("/books/{$chapter->book->slug}/chapter/super-test-chapter");
+    }
+
+    public function test_old_book_slugs_in_page_urls_redirect_to_current_page_url()
+    {
+        $page = $this->entities->page();
+        $book = $page->book;
+        $pageUrl = $page->getUrl();
+
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'super test book',
+        ]);
+
+        $this->get($pageUrl)
+            ->assertRedirect("/books/super-test-book/page/{$page->slug}");
+    }
+
+    public function test_old_book_slugs_in_chapter_urls_redirect_to_current_chapter_url()
+    {
+        $chapter = $this->entities->chapter();
+        $book = $chapter->book;
+        $chapterUrl = $chapter->getUrl();
+
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'super test book',
+        ]);
+
+        $this->get($chapterUrl)
+            ->assertRedirect("/books/super-test-book/chapter/{$chapter->slug}");
+    }
+
+    public function test_slug_lookup_controlled_by_permissions()
+    {
+        $editor = $this->users->editor();
+        $pageA = $this->entities->page();
+        $pageB = $this->entities->page();
+
+        SlugHistory::factory()->create(['sluggable_id' => $pageA->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()]);
+        SlugHistory::factory()->create(['sluggable_id' => $pageB->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()->subDay()]);
+
+        // Defaults to latest where visible
+        $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl());
+
+        $this->permissions->disableEntityInheritedPermissions($pageA);
+
+        // Falls back to other entry where the latest is not visible
+        $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageB->getUrl());
+
+        // Original still accessible where permissions allow
+        $this->asAdmin()->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl());
+    }
+
+    public function test_slugs_recorded_in_history_on_page_update()
+    {
+        $page = $this->entities->page();
+        $this->asAdmin()->put($page->getUrl(), [
+            'name' => 'new slug',
+            'html' => '<p></p>',
+        ]);
+
+        $oldSlug = $page->slug;
+        $page->refresh();
+        $this->assertNotEquals($oldSlug, $page->slug);
+
+        $this->assertDatabaseHas('slug_history', [
+            'sluggable_id' => $page->id,
+            'sluggable_type' => 'page',
+            'slug' => $oldSlug,
+            'parent_slug' => $page->book->slug,
+        ]);
+    }
+
+    public function test_slugs_recorded_in_history_on_chapter_update()
+    {
+        $chapter = $this->entities->chapter();
+        $this->asAdmin()->put($chapter->getUrl(), [
+            'name' => 'new slug',
+        ]);
+
+        $oldSlug = $chapter->slug;
+        $chapter->refresh();
+        $this->assertNotEquals($oldSlug, $chapter->slug);
+
+        $this->assertDatabaseHas('slug_history', [
+            'sluggable_id' => $chapter->id,
+            'sluggable_type' => 'chapter',
+            'slug' => $oldSlug,
+            'parent_slug' => $chapter->book->slug,
+        ]);
+    }
+
+    public function test_slugs_recorded_in_history_on_book_update()
+    {
+        $book = $this->entities->book();
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'new slug',
+        ]);
+
+        $oldSlug = $book->slug;
+        $book->refresh();
+        $this->assertNotEquals($oldSlug, $book->slug);
+
+        $this->assertDatabaseHas('slug_history', [
+            'sluggable_id' => $book->id,
+            'sluggable_type' => 'book',
+            'slug' => $oldSlug,
+            'parent_slug' => null,
+        ]);
+    }
+
+    public function test_slugs_recorded_in_history_on_shelf_update()
+    {
+        $shelf = $this->entities->shelf();
+        $this->asAdmin()->put($shelf->getUrl(), [
+            'name' => 'new slug',
+        ]);
+
+        $oldSlug = $shelf->slug;
+        $shelf->refresh();
+        $this->assertNotEquals($oldSlug, $shelf->slug);
+
+        $this->assertDatabaseHas('slug_history', [
+            'sluggable_id' => $shelf->id,
+            'sluggable_type' => 'bookshelf',
+            'slug' => $oldSlug,
+            'parent_slug' => null,
+        ]);
+    }
+}