]> BookStack Code Mirror - bookstack/commitdiff
Slugs: Added slug recording at points of generation
authorDan Brown <redacted>
Sun, 23 Nov 2025 23:29:30 +0000 (23:29 +0000)
committerDan Brown <redacted>
Sun, 23 Nov 2025 23:29:30 +0000 (23:29 +0000)
Also moved some model-level helpers, which used app container
resolution, to be injected services instead.

13 files changed:
app/App/SluggableInterface.php
app/Entities/Models/BookChild.php
app/Entities/Models/Entity.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/Sorting/BookSorter.php
app/Users/Models/User.php
app/Users/UserRepo.php

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 4a2e52aedd5e811160e9049f69983e4a7d7ec1d4..7819f161450bb7964c488622e647bc4b01c618d3 100644 (file)
@@ -2,7 +2,6 @@
 
 namespace BookStack\Entities\Models;
 
-use BookStack\References\ReferenceUpdater;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
 /**
@@ -22,29 +21,4 @@ abstract class BookChild extends Entity
     {
         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..2949754e33158ba71a2fbd167988ef2d284aa77e 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}
      */
index fd88625cd9af22cef6da07b597159e1bc83d0855..1a8985ad4f9d9e3cb67b866db244dcd84a5d7323 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,16 @@ class BaseRepo
             $entity->descriptionInfo()->set('', $input['description']);
         }
     }
+
+    /**
+     * Refresh the slug for the given entity.
+     */
+    public function refreshSlug(Entity $entity): void
+    {
+        if ($entity->id) {
+            $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..5114d67
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Entity;
+use Illuminate\Support\Facades\DB;
+
+class SlugHistory
+{
+    /**
+     * Record the current slugs for the given entity.
+     */
+    public function recordForEntity(Entity $entity): void
+    {
+        $latest = $this->getLatestEntryForEntity($entity);
+        if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $entity->getParent()?->slug) {
+            return;
+        }
+
+        $info = [
+            'sluggable_type' => $entity->getMorphClass(),
+            'sluggable_id'   => $entity->id,
+            'slug'           => $entity->slug,
+            'parent_slug'    => $entity->getParent()?->slug,
+            'created_at'     => now(),
+            'updated_at'     => now(),
+        ];
+
+        DB::table('slug_history')->insert($info);
+    }
+
+    protected function getLatestEntryForEntity(Entity $entity): \stdClass|null
+    {
+        return DB::table('slug_history')
+            ->where('sluggable_type', '=', $entity->getMorphClass())
+            ->where('sluggable_id', '=', $entity->id)
+            ->orderBy('created_at', 'desc')
+            ->first();
+    }
+}
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) {