mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-12-13 07:42:23 +03:00
Merge pull request #5913 from BookStackApp/slug_history
Slug History Tracking & Usage
This commit is contained in:
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use BookStack\Activity\Models\View;
|
|||||||
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
use BookStack\Activity\Tools\UserEntityWatchOptions;
|
||||||
use BookStack\Entities\Queries\BookQueries;
|
use BookStack\Entities\Queries\BookQueries;
|
||||||
use BookStack\Entities\Queries\BookshelfQueries;
|
use BookStack\Entities\Queries\BookshelfQueries;
|
||||||
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Repos\BookRepo;
|
use BookStack\Entities\Repos\BookRepo;
|
||||||
use BookStack\Entities\Tools\BookContents;
|
use BookStack\Entities\Tools\BookContents;
|
||||||
use BookStack\Entities\Tools\Cloner;
|
use BookStack\Entities\Tools\Cloner;
|
||||||
@@ -31,6 +32,7 @@ class BookController extends Controller
|
|||||||
protected ShelfContext $shelfContext,
|
protected ShelfContext $shelfContext,
|
||||||
protected BookRepo $bookRepo,
|
protected BookRepo $bookRepo,
|
||||||
protected BookQueries $queries,
|
protected BookQueries $queries,
|
||||||
|
protected EntityQueries $entityQueries,
|
||||||
protected BookshelfQueries $shelfQueries,
|
protected BookshelfQueries $shelfQueries,
|
||||||
protected ReferenceFetcher $referenceFetcher,
|
protected ReferenceFetcher $referenceFetcher,
|
||||||
) {
|
) {
|
||||||
@@ -127,7 +129,16 @@ class BookController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
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);
|
$bookChildren = (new BookContents($book))->getTree(true);
|
||||||
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
$bookParentShelves = $book->shelves()->scopes('visible')->get();
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityQueries;
|
|||||||
use BookStack\Activity\Models\View;
|
use BookStack\Activity\Models\View;
|
||||||
use BookStack\Entities\Queries\BookQueries;
|
use BookStack\Entities\Queries\BookQueries;
|
||||||
use BookStack\Entities\Queries\BookshelfQueries;
|
use BookStack\Entities\Queries\BookshelfQueries;
|
||||||
|
use BookStack\Entities\Queries\EntityQueries;
|
||||||
use BookStack\Entities\Repos\BookshelfRepo;
|
use BookStack\Entities\Repos\BookshelfRepo;
|
||||||
use BookStack\Entities\Tools\ShelfContext;
|
use BookStack\Entities\Tools\ShelfContext;
|
||||||
use BookStack\Exceptions\ImageUploadException;
|
use BookStack\Exceptions\ImageUploadException;
|
||||||
@@ -23,6 +24,7 @@ class BookshelfController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected BookshelfRepo $shelfRepo,
|
protected BookshelfRepo $shelfRepo,
|
||||||
protected BookshelfQueries $queries,
|
protected BookshelfQueries $queries,
|
||||||
|
protected EntityQueries $entityQueries,
|
||||||
protected BookQueries $bookQueries,
|
protected BookQueries $bookQueries,
|
||||||
protected ShelfContext $shelfContext,
|
protected ShelfContext $shelfContext,
|
||||||
protected ReferenceFetcher $referenceFetcher,
|
protected ReferenceFetcher $referenceFetcher,
|
||||||
@@ -105,7 +107,16 @@ class BookshelfController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(Request $request, ActivityQueries $activities, string $slug)
|
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);
|
$this->checkOwnablePermission(Permission::BookshelfView, $shelf);
|
||||||
|
|
||||||
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
$listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([
|
||||||
|
|||||||
@@ -77,7 +77,15 @@ class ChapterController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function show(string $bookSlug, string $chapterSlug)
|
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();
|
$sidebarTree = (new BookContents($chapter->book))->getTree();
|
||||||
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
$pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get();
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ use BookStack\Entities\Tools\PageContent;
|
|||||||
use BookStack\Entities\Tools\PageEditActivity;
|
use BookStack\Entities\Tools\PageEditActivity;
|
||||||
use BookStack\Entities\Tools\PageEditorData;
|
use BookStack\Entities\Tools\PageEditorData;
|
||||||
use BookStack\Exceptions\NotFoundException;
|
use BookStack\Exceptions\NotFoundException;
|
||||||
use BookStack\Exceptions\NotifyException;
|
|
||||||
use BookStack\Exceptions\PermissionsException;
|
use BookStack\Exceptions\PermissionsException;
|
||||||
use BookStack\Http\Controller;
|
use BookStack\Http\Controller;
|
||||||
use BookStack\Permissions\Permission;
|
use BookStack\Permissions\Permission;
|
||||||
@@ -140,9 +139,7 @@ class PageController extends Controller
|
|||||||
try {
|
try {
|
||||||
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
$page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug);
|
||||||
} catch (NotFoundException $e) {
|
} catch (NotFoundException $e) {
|
||||||
$revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug);
|
$page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug);
|
||||||
$page = $revision->page ?? null;
|
|
||||||
|
|
||||||
if (is_null($page)) {
|
if (is_null($page)) {
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,34 +16,10 @@ abstract class BookChild extends Entity
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* Get the book this page sits in.
|
* Get the book this page sits in.
|
||||||
|
* @return BelongsTo<Book, $this>
|
||||||
*/
|
*/
|
||||||
public function book(): BelongsTo
|
public function book(): BelongsTo
|
||||||
{
|
{
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
*/
|
*/
|
||||||
@@ -441,6 +430,14 @@ abstract class Entity extends Model implements
|
|||||||
return $this->morphMany(Watch::class, 'watchable');
|
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}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
|||||||
28
app/Entities/Models/SlugHistory.php
Normal file
28
app/Entities/Models/SlugHistory.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace BookStack\Entities\Queries;
|
|||||||
|
|
||||||
use BookStack\Entities\Models\Entity;
|
use BookStack\Entities\Models\Entity;
|
||||||
use BookStack\Entities\Models\EntityTable;
|
use BookStack\Entities\Models\EntityTable;
|
||||||
|
use BookStack\Entities\Tools\SlugHistory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Query\Builder as QueryBuilder;
|
use Illuminate\Database\Query\Builder as QueryBuilder;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use Illuminate\Database\Query\JoinClause;
|
||||||
@@ -18,6 +19,7 @@ class EntityQueries
|
|||||||
public ChapterQueries $chapters,
|
public ChapterQueries $chapters,
|
||||||
public PageQueries $pages,
|
public PageQueries $pages,
|
||||||
public PageRevisionQueries $revisions,
|
public PageRevisionQueries $revisions,
|
||||||
|
protected SlugHistory $slugHistory,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,9 +33,30 @@ class EntityQueries
|
|||||||
$explodedId = explode(':', $identifier);
|
$explodedId = explode(':', $identifier);
|
||||||
$entityType = $explodedId[0];
|
$entityType = $explodedId[0];
|
||||||
$entityId = intval($explodedId[1]);
|
$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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,13 @@ 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
|
||||||
|
{
|
||||||
|
$this->slugHistory->recordForEntity($entity);
|
||||||
|
$this->slugGenerator->regenerateForEntity($entity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
40
app/Entities/Tools/ParentChanger.php
Normal file
40
app/Entities/Tools/ParentChanger.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
97
app/Entities/Tools/SlugHistory.php
Normal file
97
app/Entities/Tools/SlugHistory.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -388,7 +388,7 @@ class TrashCan
|
|||||||
/**
|
/**
|
||||||
* Update entity relations to remove or update outstanding connections.
|
* Update entity relations to remove or update outstanding connections.
|
||||||
*/
|
*/
|
||||||
protected function destroyCommonRelations(Entity $entity)
|
protected function destroyCommonRelations(Entity $entity): void
|
||||||
{
|
{
|
||||||
Activity::removeEntity($entity);
|
Activity::removeEntity($entity);
|
||||||
$entity->views()->delete();
|
$entity->views()->delete();
|
||||||
@@ -402,6 +402,7 @@ class TrashCan
|
|||||||
$entity->watches()->delete();
|
$entity->watches()->delete();
|
||||||
$entity->referencesTo()->delete();
|
$entity->referencesTo()->delete();
|
||||||
$entity->referencesFrom()->delete();
|
$entity->referencesFrom()->delete();
|
||||||
|
$entity->slugHistory()->delete();
|
||||||
|
|
||||||
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) {
|
||||||
$imageService = app()->make(ImageService::class);
|
$imageService = app()->make(ImageService::class);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
29
database/factories/Entities/Models/SlugHistoryFactory.php
Normal file
29
database/factories/Entities/Models/SlugHistoryFactory.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -238,30 +238,6 @@ class BookTest extends TestCase
|
|||||||
$this->assertEquals('list', setting()->getUser($editor, 'books_view_type'));
|
$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()
|
public function test_description_limited_to_specific_html()
|
||||||
{
|
{
|
||||||
$book = $this->entities->book();
|
$book = $this->entities->book();
|
||||||
|
|||||||
@@ -269,28 +269,6 @@ class PageTest 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' => '<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()
|
public function test_page_within_chapter_deletion_returns_to_chapter()
|
||||||
{
|
{
|
||||||
$chapter = $this->entities->chapter();
|
$chapter = $this->entities->chapter();
|
||||||
|
|||||||
212
tests/Entity/SlugTest.php
Normal file
212
tests/Entity/SlugTest.php
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user