/**
* 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;
}
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;
protected ShelfContext $shelfContext,
protected BookRepo $bookRepo,
protected BookQueries $queries,
+ protected EntityQueries $entityQueries,
protected BookshelfQueries $shelfQueries,
protected ReferenceFetcher $referenceFetcher,
) {
*/
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();
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;
public function __construct(
protected BookshelfRepo $shelfRepo,
protected BookshelfQueries $queries,
+ protected EntityQueries $entityQueries,
protected BookQueries $bookQueries,
protected ShelfContext $shelfContext,
protected ReferenceFetcher $referenceFetcher,
*/
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([
*/
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();
use BookStack\Entities\Tools\PageEditActivity;
use BookStack\Entities\Tools\PageEditorData;
use BookStack\Exceptions\NotFoundException;
-use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use BookStack\Http\Controller;
use BookStack\Permissions\Permission;
try {
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
} catch (NotFoundException $e) {
- $revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
- $page = $revision->page ?? null;
-
+ $page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
if (is_null($page)) {
throw $e;
}
namespace BookStack\Entities\Models;
-use BookStack\References\ReferenceUpdater;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
{
/**
* Get the book this page sits in.
+ * @return BelongsTo<Book, $this>
*/
public function book(): BelongsTo
{
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;
- }
}
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;
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}
*/
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}
*/
--- /dev/null
+<?php
+
+namespace BookStack\Entities\Models;
+
+use BookStack\App\Model;
+use BookStack\Permissions\Models\JointPermission;
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
+
+/**
+ * @property int $id
+ * @property int $sluggable_id
+ * @property string $sluggable_type
+ * @property string $slug
+ * @property ?string $parent_slug
+ */
+class SlugHistory extends Model
+{
+ use HasFactory;
+
+ protected $table = 'slug_history';
+
+ public function jointPermissions(): HasMany
+ {
+ return $this->hasMany(JointPermission::class, 'entity_id', 'sluggable_id')
+ ->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type');
+ }
+}
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\EntityTable;
+use BookStack\Entities\Tools\SlugHistory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\Builder as QueryBuilder;
use Illuminate\Database\Query\JoinClause;
public ChapterQueries $chapters,
public PageQueries $pages,
public PageRevisionQueries $revisions,
+ protected SlugHistory $slugHistory,
) {
}
$explodedId = explode(':', $identifier);
$entityType = $explodedId[0];
$entityId = intval($explodedId[1]);
- $queries = $this->getQueriesForType($entityType);
- return $queries->findVisibleById($entityId);
+ return $this->findVisibleById($entityType, $entityId);
+ }
+
+ /**
+ * Find an entity by its ID.
+ */
+ public function findVisibleById(string $type, int $id): ?Entity
+ {
+ $queries = $this->getQueriesForType($type);
+ return $queries->findVisibleById($id);
+ }
+
+ /**
+ * Find an entity by looking up old slugs in the slug history.
+ */
+ public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity
+ {
+ $id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug);
+ if ($id === null) {
+ return null;
+ }
+
+ return $this->findVisibleById($type, $id);
}
/**
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;
protected ReferenceStore $referenceStore,
protected PageQueries $pageQueries,
protected BookSorter $bookSorter,
+ protected SlugGenerator $slugGenerator,
+ protected SlugHistory $slugHistory,
) {
}
'updated_by' => user()->id,
'owned_by' => user()->id,
]);
- $entity->refreshSlug();
+ $this->refreshSlug($entity);
if ($entity instanceof HasDescriptionInterface) {
$this->updateDescription($entity, $input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name') || empty($entity->slug)) {
- $entity->refreshSlug();
+ $this->refreshSlug($entity);
}
if ($entity instanceof HasDescriptionInterface) {
$entity->descriptionInfo()->set('', $input['description']);
}
}
+
+ /**
+ * Refresh the slug for the given entity.
+ */
+ public function refreshSlug(Entity $entity): void
+ {
+ $this->slugHistory->recordForEntity($entity);
+ $this->slugGenerator->regenerateForEntity($entity);
+ }
}
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;
protected BaseRepo $baseRepo,
protected EntityQueries $entityQueries,
protected TrashCan $trashCan,
+ protected ParentChanger $parentChanger,
) {
}
}
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);
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;
protected ReferenceStore $referenceStore,
protected ReferenceUpdater $referenceUpdater,
protected TrashCan $trashCan,
+ protected ParentChanger $parentChanger,
) {
}
}
$page->updated_by = user()->id;
- $page->refreshSlug();
+ $this->baseRepo->refreshSlug($page);
$page->save();
$page->indexForSearch();
$this->referenceStore->updateForEntity($page);
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);
protected BookRepo $bookRepo,
protected BookshelfRepo $shelfRepo,
protected Cloner $cloner,
- protected TrashCan $trashCan
+ protected TrashCan $trashCan,
+ protected ParentChanger $parentChanger,
) {
}
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);
--- /dev/null
+<?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);
+ }
+ }
+ }
+}
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
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.
*/
--- /dev/null
+<?php
+
+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
+{
+ public function __construct(
+ protected PermissionApplicator $permissions,
+ ) {
+ }
+
+ /**
+ * Record the current slugs for the given entity.
+ */
+ public function recordForEntity(Entity $entity): void
+ {
+ if (!$entity->id || !$entity->slug) {
+ return;
+ }
+
+ $parentSlug = null;
+ if ($entity instanceof BookChild) {
+ $parentSlug = $entity->book()->first()?->slug;
+ }
+
+ $latest = $this->getLatestEntryForEntity($entity);
+ if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) {
+ return;
+ }
+
+ $info = [
+ 'sluggable_type' => $entity->getMorphClass(),
+ 'sluggable_id' => $entity->id,
+ 'slug' => $entity->slug,
+ 'parent_slug' => $parentSlug,
+ ];
+
+ $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
+ );
+ }
+
+ /**
+ * Find the latest visible entry for an entity which uses the given slug(s) in the history.
+ */
+ public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int
+ {
+ $query = SlugHistoryModel::query()
+ ->where('sluggable_type', '=', $type)
+ ->where('slug', '=', $slug);
+
+ if ($parentSlug) {
+ $query->where('parent_slug', '=', $parentSlug);
+ }
+
+ $query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type');
+
+ /** @var SlugHistoryModel|null $result */
+ $result = $query->orderBy('created_at', 'desc')->first();
+
+ return $result?->sluggable_id;
+ }
+
+ protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null
+ {
+ return SlugHistoryModel::query()
+ ->where('sluggable_type', '=', $entity->getMorphClass())
+ ->where('sluggable_id', '=', $entity->id)
+ ->orderBy('created_at', 'desc')
+ ->first();
+ }
+}
/**
* 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();
$entity->watches()->delete();
$entity->referencesTo()->delete();
$entity->referencesFrom()->delete();
+ $entity->slugHistory()->delete();
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
$imageService = app()->make(ImageService::class);
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,
) {
}
// Action the required changes
if ($bookChanged) {
- $model = $model->changeBook($newBook->id);
+ $this->parentChanger->changeBook($model, $newBook->id);
}
if ($model instanceof Page && $chapterChanged) {
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;
{
return "({$this->id}) {$this->name}";
}
-
- /**
- * {@inheritdoc}
- */
- public function refreshSlug(): string
- {
- $this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name);
-
- return $this->slug;
- }
}
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;
{
public function __construct(
protected UserAvatars $userAvatar,
- protected UserInviteService $inviteService
+ protected UserInviteService $inviteService,
+ protected SlugGenerator $slugGenerator,
) {
}
$user->email_confirmed = $emailConfirmed;
$user->external_auth_id = $data['external_auth_id'] ?? '';
- $user->refreshSlug();
+ $this->slugGenerator->regenerateForUser($user);
$user->save();
if (!empty($data['language'])) {
{
if (!empty($data['name'])) {
$user->name = $data['name'];
- $user->refreshSlug();
+ $this->slugGenerator->regenerateForUser($user);
}
if (!empty($data['email']) && $manageUsersAllowed) {
--- /dev/null
+<?php
+
+namespace Database\Factories\Entities\Models;
+
+use BookStack\Entities\Models\Book;
+use Illuminate\Database\Eloquent\Factories\Factory;
+
+/**
+ * @extends \Illuminate\Database\Eloquent\Factories\Factory<\BookStack\Entities\Models\SlugHistory>
+ */
+class SlugHistoryFactory extends Factory
+{
+ protected $model = \BookStack\Entities\Models\SlugHistory::class;
+
+ /**
+ * Define the model's default state.
+ *
+ * @return array<string, mixed>
+ */
+ public function definition(): array
+ {
+ return [
+ 'sluggable_id' => Book::factory(),
+ 'sluggable_type' => 'book',
+ 'slug' => $this->faker->slug(),
+ 'parent_slug' => null,
+ ];
+ }
+}
--- /dev/null
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+ /**
+ * Run the migrations.
+ */
+ public function up(): void
+ {
+ // Create the table for storing slug history
+ Schema::create('slug_history', function (Blueprint $table) {
+ $table->increments('id');
+ $table->string('sluggable_type', 10)->index();
+ $table->unsignedBigInteger('sluggable_id')->index();
+ $table->string('slug')->index();
+ $table->string('parent_slug')->nullable()->index();
+ $table->timestamps();
+ });
+
+ // Migrate in slugs from page revisions
+ $revisionSlugQuery = DB::table('page_revisions')
+ ->select([
+ DB::raw('\'page\' as sluggable_type'),
+ 'page_id as sluggable_id',
+ 'slug',
+ 'book_slug as parent_slug',
+ DB::raw('min(created_at) as created_at'),
+ DB::raw('min(updated_at) as updated_at'),
+ ])
+ ->where('type', '=', 'version')
+ ->groupBy(['sluggable_id', 'slug', 'parent_slug']);
+
+ DB::table('slug_history')->insertUsing(
+ ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'],
+ $revisionSlugQuery,
+ );
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('slug_history');
+ }
+};
$this->assertEquals('list', setting()->getUser($editor, 'books_view_type'));
}
- public function test_slug_multi_byte_url_safe()
- {
- $book = $this->entities->newBook([
- 'name' => 'информация',
- ]);
-
- $this->assertEquals('informaciia', $book->slug);
-
- $book = $this->entities->newBook([
- 'name' => '¿Qué?',
- ]);
-
- $this->assertEquals('que', $book->slug);
- }
-
- public function test_slug_format()
- {
- $book = $this->entities->newBook([
- 'name' => 'PartA / PartB / PartC',
- ]);
-
- $this->assertEquals('parta-partb-partc', $book->slug);
- }
-
public function test_description_limited_to_specific_html()
{
$book = $this->entities->book();
]);
}
- 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' => '<p></p>',
- ]);
-
- $page->refresh();
- $pageUrl = $page->getUrl();
-
- $this->put($pageUrl, [
- 'name' => 'super test page',
- 'html' => '<p></p>',
- ]);
-
- $this->get($pageUrl)
- ->assertRedirect("/books/{$page->book->slug}/page/super-test-page");
- }
-
public function test_page_within_chapter_deletion_returns_to_chapter()
{
$chapter = $this->entities->chapter();
--- /dev/null
+<?php
+
+namespace Tests\Entity;
+
+use BookStack\Entities\Models\SlugHistory;
+use Tests\TestCase;
+
+class SlugTest extends TestCase
+{
+ public function test_slug_multi_byte_url_safe()
+ {
+ $book = $this->entities->newBook([
+ 'name' => 'информация',
+ ]);
+
+ $this->assertEquals('informaciia', $book->slug);
+
+ $book = $this->entities->newBook([
+ 'name' => '¿Qué?',
+ ]);
+
+ $this->assertEquals('que', $book->slug);
+ }
+
+ public function test_slug_format()
+ {
+ $book = $this->entities->newBook([
+ 'name' => 'PartA / PartB / PartC',
+ ]);
+
+ $this->assertEquals('parta-partb-partc', $book->slug);
+ }
+
+ public function test_old_page_slugs_redirect_to_new_pages()
+ {
+ $page = $this->entities->page();
+ $pageUrl = $page->getUrl();
+
+ $this->asAdmin()->put($pageUrl, [
+ 'name' => 'super test page',
+ 'html' => '<p></p>',
+ ]);
+
+ $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->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_slug_lookup_controlled_by_permissions()
+ {
+ $editor = $this->users->editor();
+ $pageA = $this->entities->page();
+ $pageB = $this->entities->page();
+
+ SlugHistory::factory()->create(['sluggable_id' => $pageA->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()]);
+ SlugHistory::factory()->create(['sluggable_id' => $pageB->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()->subDay()]);
+
+ // Defaults to latest where visible
+ $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl());
+
+ $this->permissions->disableEntityInheritedPermissions($pageA);
+
+ // Falls back to other entry where the latest is not visible
+ $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageB->getUrl());
+
+ // Original still accessible where permissions allow
+ $this->asAdmin()->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl());
+ }
+
+ public function test_slugs_recorded_in_history_on_page_update()
+ {
+ $page = $this->entities->page();
+ $this->asAdmin()->put($page->getUrl(), [
+ 'name' => 'new slug',
+ 'html' => '<p></p>',
+ ]);
+
+ $oldSlug = $page->slug;
+ $page->refresh();
+ $this->assertNotEquals($oldSlug, $page->slug);
+
+ $this->assertDatabaseHas('slug_history', [
+ 'sluggable_id' => $page->id,
+ 'sluggable_type' => 'page',
+ 'slug' => $oldSlug,
+ 'parent_slug' => $page->book->slug,
+ ]);
+ }
+
+ public function test_slugs_recorded_in_history_on_chapter_update()
+ {
+ $chapter = $this->entities->chapter();
+ $this->asAdmin()->put($chapter->getUrl(), [
+ 'name' => 'new slug',
+ ]);
+
+ $oldSlug = $chapter->slug;
+ $chapter->refresh();
+ $this->assertNotEquals($oldSlug, $chapter->slug);
+
+ $this->assertDatabaseHas('slug_history', [
+ 'sluggable_id' => $chapter->id,
+ 'sluggable_type' => 'chapter',
+ 'slug' => $oldSlug,
+ 'parent_slug' => $chapter->book->slug,
+ ]);
+ }
+
+ public function test_slugs_recorded_in_history_on_book_update()
+ {
+ $book = $this->entities->book();
+ $this->asAdmin()->put($book->getUrl(), [
+ 'name' => 'new slug',
+ ]);
+
+ $oldSlug = $book->slug;
+ $book->refresh();
+ $this->assertNotEquals($oldSlug, $book->slug);
+
+ $this->assertDatabaseHas('slug_history', [
+ 'sluggable_id' => $book->id,
+ 'sluggable_type' => 'book',
+ 'slug' => $oldSlug,
+ 'parent_slug' => null,
+ ]);
+ }
+
+ public function test_slugs_recorded_in_history_on_shelf_update()
+ {
+ $shelf = $this->entities->shelf();
+ $this->asAdmin()->put($shelf->getUrl(), [
+ 'name' => 'new slug',
+ ]);
+
+ $oldSlug = $shelf->slug;
+ $shelf->refresh();
+ $this->assertNotEquals($oldSlug, $shelf->slug);
+
+ $this->assertDatabaseHas('slug_history', [
+ 'sluggable_id' => $shelf->id,
+ 'sluggable_type' => 'bookshelf',
+ 'slug' => $oldSlug,
+ 'parent_slug' => null,
+ ]);
+ }
+}