diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index cbf7ffb79..0610c2ef5 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -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(); diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index 8d7ffb8f9..c4b861c90 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -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([ diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index a1af29de2..878ee42b5 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -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(); diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index 7819f1614..9a8493c3a 100644 --- a/app/Entities/Models/BookChild.php +++ b/app/Entities/Models/BookChild.php @@ -16,6 +16,7 @@ abstract class BookChild extends Entity { /** * Get the book this page sits in. + * @return BelongsTo */ public function book(): BelongsTo { diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 2949754e3..47e134626 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -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} */ diff --git a/app/Entities/Tools/SlugHistory.php b/app/Entities/Tools/SlugHistory.php index 1584db9cf..2c8d88129 100644 --- a/app/Entities/Tools/SlugHistory.php +++ b/app/Entities/Tools/SlugHistory.php @@ -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 + ); } /** diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index c298169c3..96645aebf 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -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); diff --git a/tests/Entity/SlugTest.php b/tests/Entity/SlugTest.php index d60daff24..e8565d00f 100644 --- a/tests/Entity/SlugTest.php +++ b/tests/Entity/SlugTest.php @@ -33,17 +33,9 @@ class SlugTest 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' => '

', - ]); - - $page->refresh(); $pageUrl = $page->getUrl(); - $this->put($pageUrl, [ + $this->asAdmin()->put($pageUrl, [ 'name' => 'super test page', 'html' => '

', ]); @@ -52,6 +44,73 @@ class SlugTest extends TestCase ->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_slugs_recorded_in_history_on_page_update() { $page = $this->entities->page();