]> BookStack Code Mirror - bookstack/commitdiff
Slugs: Rolled out history lookup to other types
authorDan Brown <redacted>
Mon, 24 Nov 2025 19:49:34 +0000 (19:49 +0000)
committerDan Brown <redacted>
Mon, 24 Nov 2025 19:49:34 +0000 (19:49 +0000)
Added testing to cover.
Also added batch recording of child slug pairs on book slug changes.

app/Entities/Controllers/BookController.php
app/Entities/Controllers/BookshelfController.php
app/Entities/Controllers/ChapterController.php
app/Entities/Models/BookChild.php
app/Entities/Models/Entity.php
app/Entities/Tools/SlugHistory.php
app/Entities/Tools/TrashCan.php
tests/Entity/SlugTest.php

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 7819f161450bb7964c488622e647bc4b01c618d3..9a8493c3a0a48c9f97a5b60fde9eda09920de6e5 100644 (file)
@@ -16,6 +16,7 @@ abstract class BookChild extends Entity
 {
     /**
      * Get the book this page sits in.
+     * @return BelongsTo<Book, $this>
      */
     public function book(): BelongsTo
     {
index 2949754e33158ba71a2fbd167988ef2d284aa77e..47e13462691fa884df55ec93ae372d15123f1799 100644 (file)
@@ -430,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}
      */
index 1584db9cfb106d50ce67a74871ec440502055778..2c8d88129b638a72812bca97c91fa8d98da817bf 100644 (file)
@@ -2,10 +2,13 @@
 
 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
 {
@@ -43,6 +46,23 @@ class SlugHistory
         $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
+        );
     }
 
     /**
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 d60daff24d2c260f963d116315e5d63c6c339a9c..e8565d00f13980216e1e4d6da86529b02e629558 100644 (file)
@@ -33,23 +33,82 @@ class SlugTest extends TestCase
     public function test_old_page_slugs_redirect_to_new_pages()
     {
         $page = $this->entities->page();
+        $pageUrl = $page->getUrl();
 
-        // Need to save twice since revisions are not generated in seeder.
-        $this->asAdmin()->put($page->getUrl(), [
-            'name' => 'super test',
+        $this->asAdmin()->put($pageUrl, [
+            'name' => 'super test page',
             'html' => '<p></p>',
         ]);
 
-        $page->refresh();
+        $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->put($pageUrl, [
-            'name' => 'super test page',
-            'html' => '<p></p>',
+        $this->asAdmin()->put($book->getUrl(), [
+            'name' => 'super test book',
         ]);
 
         $this->get($pageUrl)
-            ->assertRedirect("/books/{$page->book->slug}/page/super-test-page");
+            ->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_slugs_recorded_in_history_on_page_update()