diff --git a/app/App/SluggableInterface.php b/app/App/SluggableInterface.php index 96af49cd3..dd544f5ed 100644 --- a/app/App/SluggableInterface.php +++ b/app/App/SluggableInterface.php @@ -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; } diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index 4a2e52aed..7819f1614 100644 --- a/app/Entities/Models/BookChild.php +++ b/app/Entities/Models/BookChild.php @@ -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; - } } diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 641fe29d5..2949754e3 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -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} */ diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index fd88625cd..1a8985ad4 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -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); + } } diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index d5feb30fd..a528eece0 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -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); diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index f2e558210..bc590785d 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -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); diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php index fa45fcd11..c58d29bd0 100644 --- a/app/Entities/Tools/HierarchyTransformer.php +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -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 index 000000000..00ce42aae --- /dev/null +++ b/app/Entities/Tools/ParentChanger.php @@ -0,0 +1,40 @@ +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); + } + } + } +} diff --git a/app/Entities/Tools/SlugGenerator.php b/app/Entities/Tools/SlugGenerator.php index fb9123187..6eec84a91 100644 --- a/app/Entities/Tools/SlugGenerator.php +++ b/app/Entities/Tools/SlugGenerator.php @@ -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 index 000000000..5114d67c7 --- /dev/null +++ b/app/Entities/Tools/SlugHistory.php @@ -0,0 +1,40 @@ +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(); + } +} diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 99e307e35..b4f93d47b 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -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) { diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php index 8bbf11695..50efdcdad 100644 --- a/app/Users/Models/User.php +++ b/app/Users/Models/User.php @@ -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; - } } diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index 894d7c01f..2c0897cef 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -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) {