1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-12-13 07:42:23 +03:00

Slugs: Added slug recording at points of generation

Also moved some model-level helpers, which used app container
resolution, to be injected services instead.
This commit is contained in:
Dan Brown
2025-11-23 23:29:30 +00:00
parent e64fc60bdf
commit 291a807d98
13 changed files with 141 additions and 64 deletions

View File

@@ -5,11 +5,9 @@ namespace BookStack\App;
/** /**
* Assigned to models that can have slugs. * Assigned to models that can have slugs.
* Must have the below properties. * Must have the below properties.
*
* @property string $slug
*/ */
interface SluggableInterface interface SluggableInterface
{ {
/**
* Regenerate the slug for this model.
*/
public function refreshSlug(): string;
} }

View File

@@ -2,7 +2,6 @@
namespace BookStack\Entities\Models; namespace BookStack\Entities\Models;
use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
/** /**
@@ -22,29 +21,4 @@ abstract class BookChild extends Entity
{ {
return $this->belongsTo(Book::class)->withTrashed(); 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;
}
} }

View File

@@ -13,7 +13,6 @@ use BookStack\Activity\Models\Viewable;
use BookStack\Activity\Models\Watch; use BookStack\Activity\Models\Watch;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\SluggableInterface; use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\JointPermissionBuilder; use BookStack\Permissions\JointPermissionBuilder;
use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\EntityPermission;
use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\Models\JointPermission;
@@ -405,16 +404,6 @@ abstract class Entity extends Model implements
app()->make(SearchIndex::class)->indexEntity(clone $this); 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} * {@inheritdoc}
*/ */

View File

@@ -8,6 +8,8 @@ use BookStack\Entities\Models\HasCoverInterface;
use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\HasDescriptionInterface;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Queries\PageQueries;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Entities\Tools\SlugHistory;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore; use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater; use BookStack\References\ReferenceUpdater;
@@ -25,6 +27,8 @@ class BaseRepo
protected ReferenceStore $referenceStore, protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries, protected PageQueries $pageQueries,
protected BookSorter $bookSorter, protected BookSorter $bookSorter,
protected SlugGenerator $slugGenerator,
protected SlugHistory $slugHistory,
) { ) {
} }
@@ -43,7 +47,7 @@ class BaseRepo
'updated_by' => user()->id, 'updated_by' => user()->id,
'owned_by' => user()->id, 'owned_by' => user()->id,
]); ]);
$entity->refreshSlug(); $this->refreshSlug($entity);
if ($entity instanceof HasDescriptionInterface) { if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input); $this->updateDescription($entity, $input);
@@ -78,7 +82,7 @@ class BaseRepo
$entity->updated_by = user()->id; $entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) { if ($entity->isDirty('name') || empty($entity->slug)) {
$entity->refreshSlug(); $this->refreshSlug($entity);
} }
if ($entity instanceof HasDescriptionInterface) { if ($entity instanceof HasDescriptionInterface) {
@@ -155,4 +159,16 @@ class BaseRepo
$entity->descriptionInfo()->set('', $input['description']); $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);
}
} }

View File

@@ -7,6 +7,7 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Entities\Tools\TrashCan; use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
@@ -21,6 +22,7 @@ class ChapterRepo
protected BaseRepo $baseRepo, protected BaseRepo $baseRepo,
protected EntityQueries $entityQueries, protected EntityQueries $entityQueries,
protected TrashCan $trashCan, protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) { ) {
} }
@@ -97,7 +99,7 @@ class ChapterRepo
} }
return (new DatabaseTransaction(function () use ($chapter, $parent) { return (new DatabaseTransaction(function () use ($chapter, $parent) {
$chapter = $chapter->changeBook($parent->id); $this->parentChanger->changeBook($chapter, $parent->id);
$chapter->rebuildPermissions(); $chapter->rebuildPermissions();
Activity::add(ActivityType::CHAPTER_MOVE, $chapter); Activity::add(ActivityType::CHAPTER_MOVE, $chapter);

View File

@@ -12,6 +12,7 @@ use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\BookContents;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Entities\Tools\PageEditorType; use BookStack\Entities\Tools\PageEditorType;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Entities\Tools\TrashCan; use BookStack\Entities\Tools\TrashCan;
use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\PermissionsException; use BookStack\Exceptions\PermissionsException;
@@ -31,6 +32,7 @@ class PageRepo
protected ReferenceStore $referenceStore, protected ReferenceStore $referenceStore,
protected ReferenceUpdater $referenceUpdater, protected ReferenceUpdater $referenceUpdater,
protected TrashCan $trashCan, protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) { ) {
} }
@@ -242,7 +244,7 @@ class PageRepo
} }
$page->updated_by = user()->id; $page->updated_by = user()->id;
$page->refreshSlug(); $this->baseRepo->refreshSlug($page);
$page->save(); $page->save();
$page->indexForSearch(); $page->indexForSearch();
$this->referenceStore->updateForEntity($page); $this->referenceStore->updateForEntity($page);
@@ -284,7 +286,7 @@ class PageRepo
return (new DatabaseTransaction(function () use ($page, $parent) { return (new DatabaseTransaction(function () use ($page, $parent) {
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id;
$page = $page->changeBook($newBookId); $this->parentChanger->changeBook($page, $newBookId);
$page->rebuildPermissions(); $page->rebuildPermissions();
Activity::add(ActivityType::PAGE_MOVE, $page); Activity::add(ActivityType::PAGE_MOVE, $page);

View File

@@ -17,7 +17,8 @@ class HierarchyTransformer
protected BookRepo $bookRepo, protected BookRepo $bookRepo,
protected BookshelfRepo $shelfRepo, protected BookshelfRepo $shelfRepo,
protected Cloner $cloner, protected Cloner $cloner,
protected TrashCan $trashCan protected TrashCan $trashCan,
protected ParentChanger $parentChanger,
) { ) {
} }
@@ -35,7 +36,7 @@ class HierarchyTransformer
foreach ($chapter->pages as $page) { foreach ($chapter->pages as $page) {
$page->chapter_id = 0; $page->chapter_id = 0;
$page->save(); $page->save();
$page->changeBook($book->id); $this->parentChanger->changeBook($page, $book->id);
} }
$this->trashCan->destroyEntity($chapter); $this->trashCan->destroyEntity($chapter);

View File

@@ -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);
}
}
}
}

View File

@@ -5,12 +5,14 @@ namespace BookStack\Entities\Tools;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\SluggableInterface; use BookStack\App\SluggableInterface;
use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\BookChild;
use BookStack\Entities\Models\Entity;
use BookStack\Users\Models\User;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class SlugGenerator 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. * 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 public function generate(SluggableInterface&Model $model, string $slugSource): string
@@ -23,6 +25,26 @@ class SlugGenerator
return $slug; 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. * Format a name as a URL slug.
*/ */

View File

@@ -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();
}
}

View File

@@ -8,12 +8,14 @@ use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Queries\EntityQueries;
use BookStack\Entities\Tools\ParentChanger;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
class BookSorter class BookSorter
{ {
public function __construct( public function __construct(
protected EntityQueries $queries, protected EntityQueries $queries,
protected ParentChanger $parentChanger,
) { ) {
} }
@@ -155,7 +157,7 @@ class BookSorter
// Action the required changes // Action the required changes
if ($bookChanged) { if ($bookChanged) {
$model = $model->changeBook($newBook->id); $this->parentChanger->changeBook($model, $newBook->id);
} }
if ($model instanceof Page && $chapterChanged) { if ($model instanceof Page && $chapterChanged) {

View File

@@ -11,7 +11,6 @@ use BookStack\Activity\Models\Watch;
use BookStack\Api\ApiToken; use BookStack\Api\ApiToken;
use BookStack\App\Model; use BookStack\App\Model;
use BookStack\App\SluggableInterface; use BookStack\App\SluggableInterface;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\Translation\LocaleDefinition; use BookStack\Translation\LocaleDefinition;
use BookStack\Translation\LocaleManager; use BookStack\Translation\LocaleManager;
@@ -358,14 +357,4 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
{ {
return "({$this->id}) {$this->name}"; return "({$this->id}) {$this->name}";
} }
/**
* {@inheritdoc}
*/
public function refreshSlug(): string
{
$this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
return $this->slug;
}
} }

View File

@@ -5,6 +5,7 @@ namespace BookStack\Users;
use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteException;
use BookStack\Access\UserInviteService; use BookStack\Access\UserInviteService;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\UserUpdateException; use BookStack\Exceptions\UserUpdateException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
@@ -21,7 +22,8 @@ class UserRepo
{ {
public function __construct( public function __construct(
protected UserAvatars $userAvatar, protected UserAvatars $userAvatar,
protected UserInviteService $inviteService protected UserInviteService $inviteService,
protected SlugGenerator $slugGenerator,
) { ) {
} }
@@ -63,7 +65,7 @@ class UserRepo
$user->email_confirmed = $emailConfirmed; $user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? ''; $user->external_auth_id = $data['external_auth_id'] ?? '';
$user->refreshSlug(); $this->slugGenerator->regenerateForUser($user);
$user->save(); $user->save();
if (!empty($data['language'])) { if (!empty($data['language'])) {
@@ -109,7 +111,7 @@ class UserRepo
{ {
if (!empty($data['name'])) { if (!empty($data['name'])) {
$user->name = $data['name']; $user->name = $data['name'];
$user->refreshSlug(); $this->slugGenerator->regenerateForUser($user);
} }
if (!empty($data['email']) && $manageUsersAllowed) { if (!empty($data['email']) && $manageUsersAllowed) {