{{ trans('entities.references') }}
+{{ trans('entities.references_to_desc') }}
+ + @if(count($references) > 0) +{{ trans('entities.references_none') }}
+ @endif + +diff --git a/.env.example.complete b/.env.example.complete index c097af4f664..a0eef5cab89 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -299,7 +299,7 @@ APP_DEFAULT_DARK_MODE=false # Page revision limit # Number of page revisions to keep in the system before deleting old revisions. # If set to 'false' a limit will not be enforced. -REVISION_LIMIT=50 +REVISION_LIMIT=100 # Recycle Bin Lifetime # The number of days that content will remain in the recycle bin before diff --git a/app/Config/app.php b/app/Config/app.php index 53d399abec4..e28ebe611a1 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -22,7 +22,7 @@ // The number of revisions to keep in the database. // Once this limit is reached older revisions will be deleted. // If set to false then a limit will not be enforced. - 'revision_limit' => env('REVISION_LIMIT', 50), + 'revision_limit' => env('REVISION_LIMIT', 100), // The number of days that content will remain in the recycle bin before // being considered for auto-removal. It is not a guarantee that content will diff --git a/app/Console/Commands/RegenerateCommentContent.php b/app/Console/Commands/RegenerateCommentContent.php index 587a5edb310..9da48fb0e51 100644 --- a/app/Console/Commands/RegenerateCommentContent.php +++ b/app/Console/Commands/RegenerateCommentContent.php @@ -5,6 +5,7 @@ use BookStack\Actions\Comment; use BookStack\Actions\CommentRepo; use Illuminate\Console\Command; +use Illuminate\Support\Facades\DB; class RegenerateCommentContent extends Command { @@ -43,9 +44,9 @@ public function __construct(CommentRepo $commentRepo) */ public function handle() { - $connection = \DB::getDefaultConnection(); + $connection = DB::getDefaultConnection(); if ($this->option('database') !== null) { - \DB::setDefaultConnection($this->option('database')); + DB::setDefaultConnection($this->option('database')); } Comment::query()->chunk(100, function ($comments) { @@ -55,7 +56,8 @@ public function handle() } }); - \DB::setDefaultConnection($connection); + DB::setDefaultConnection($connection); $this->comment('Comment HTML content has been regenerated'); + return 0; } } diff --git a/app/Console/Commands/RegeneratePermissions.php b/app/Console/Commands/RegeneratePermissions.php index 3396a445f0f..74f96fd4270 100644 --- a/app/Console/Commands/RegeneratePermissions.php +++ b/app/Console/Commands/RegeneratePermissions.php @@ -50,5 +50,6 @@ public function handle() DB::setDefaultConnection($connection); $this->comment('Permissions regenerated'); + return 0; } } diff --git a/app/Console/Commands/RegenerateReferences.php b/app/Console/Commands/RegenerateReferences.php new file mode 100644 index 00000000000..805db2207c3 --- /dev/null +++ b/app/Console/Commands/RegenerateReferences.php @@ -0,0 +1,58 @@ +references = $references; + parent::__construct(); + } + + /** + * Execute the console command. + * + * @return int + */ + public function handle() + { + $connection = DB::getDefaultConnection(); + + if ($this->option('database')) { + DB::setDefaultConnection($this->option('database')); + } + + $this->references->updateForAllPages(); + + DB::setDefaultConnection($connection); + + $this->comment('References have been regenerated'); + return 0; + } +} diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index e1ba0b6f708..3b1ac1bab74 100644 --- a/app/Entities/Models/BookChild.php +++ b/app/Entities/Models/BookChild.php @@ -2,6 +2,7 @@ namespace BookStack\Entities\Models; +use BookStack\References\ReferenceUpdater; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -57,9 +58,15 @@ public function book(): BelongsTo */ public function changeBook(int $newBookId): Entity { + $oldUrl = $this->getUrl(); $this->book_id = $newBookId; $this->refreshSlug(); $this->save(); + + if ($oldUrl !== $this->getUrl()) { + app()->make(ReferenceUpdater::class)->updateEntityPageReferences($this, $oldUrl); + } + $this->refresh(); // Update all child pages if a chapter diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index ffb9b9c7d6d..26a52073e01 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -18,6 +18,7 @@ use BookStack\Interfaces\Sluggable; use BookStack\Interfaces\Viewable; use BookStack\Model; +use BookStack\References\Reference; use BookStack\Search\SearchIndex; use BookStack\Search\SearchTerm; use BookStack\Traits\HasCreatorAndUpdater; @@ -203,6 +204,22 @@ public function deletions(): MorphMany return $this->morphMany(Deletion::class, 'deletable'); } + /** + * Get the references pointing from this entity to other items. + */ + public function referencesFrom(): MorphMany + { + return $this->morphMany(Reference::class, 'from'); + } + + /** + * Get the references pointing to this entity from other items. + */ + public function referencesTo(): MorphMany + { + return $this->morphMany(Reference::class, 'to'); + } + /** * Check if this instance or class is a certain type of entity. * Examples of $type are 'page', 'book', 'chapter'. diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 39b90138352..cfde7fe1c57 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -6,6 +6,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\HasCoverImage; use BookStack\Exceptions\ImageUploadException; +use BookStack\References\ReferenceUpdater; use BookStack\Uploads\ImageRepo; use Illuminate\Http\UploadedFile; @@ -13,11 +14,13 @@ class BaseRepo { protected TagRepo $tagRepo; protected ImageRepo $imageRepo; + protected ReferenceUpdater $referenceUpdater; - public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo) + public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo, ReferenceUpdater $referenceUpdater) { $this->tagRepo = $tagRepo; $this->imageRepo = $imageRepo; + $this->referenceUpdater = $referenceUpdater; } /** @@ -48,6 +51,8 @@ public function create(Entity $entity, array $input) */ public function update(Entity $entity, array $input) { + $oldUrl = $entity->getUrl(); + $entity->fill($input); $entity->updated_by = user()->id; @@ -64,6 +69,10 @@ public function update(Entity $entity, array $input) $entity->rebuildPermissions(); $entity->indexForSearch(); + + if ($oldUrl !== $entity->getUrl()) { + $this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl); + } } /** diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 60f1d1b01ec..c80cbdb149e 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -16,20 +16,32 @@ use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; +use BookStack\References\ReferenceStore; +use BookStack\References\ReferenceUpdater; use Exception; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; class PageRepo { - protected $baseRepo; + protected BaseRepo $baseRepo; + protected RevisionRepo $revisionRepo; + protected ReferenceStore $referenceStore; + protected ReferenceUpdater $referenceUpdater; /** * PageRepo constructor. */ - public function __construct(BaseRepo $baseRepo) + public function __construct( + BaseRepo $baseRepo, + RevisionRepo $revisionRepo, + ReferenceStore $referenceStore, + ReferenceUpdater $referenceUpdater + ) { $this->baseRepo = $baseRepo; + $this->revisionRepo = $revisionRepo; + $this->referenceStore = $referenceStore; + $this->referenceUpdater = $referenceUpdater; } /** @@ -39,6 +51,7 @@ public function __construct(BaseRepo $baseRepo) */ public function getById(int $id, array $relations = ['book']): Page { + /** @var Page $page */ $page = Page::visible()->with($relations)->find($id); if (!$page) { @@ -70,17 +83,7 @@ public function getBySlug(string $bookSlug, string $pageSlug): Page */ public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page { - /** @var ?PageRevision $revision */ - $revision = PageRevision::query() - ->whereHas('page', function (Builder $query) { - $query->scopes('visible'); - }) - ->where('slug', '=', $pageSlug) - ->where('type', '=', 'version') - ->where('book_slug', '=', $bookSlug) - ->orderBy('created_at', 'desc') - ->with('page') - ->first(); + $revision = $this->revisionRepo->getBySlugs($bookSlug, $pageSlug); return $revision->page ?? null; } @@ -112,7 +115,7 @@ public function getTemplates(int $count = 10, int $page = 1, string $search = '' public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity { if ($chapterSlug !== null) { - return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail(); + return Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail(); } return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail(); @@ -123,9 +126,7 @@ public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null) */ public function getUserDraft(Page $page): ?PageRevision { - $revision = $this->getUserDraftQuery($page)->first(); - - return $revision; + return $this->revisionRepo->getLatestDraftForCurrentUser($page); } /** @@ -134,11 +135,11 @@ public function getUserDraft(Page $page): ?PageRevision public function getNewDraftPage(Entity $parent) { $page = (new Page())->forceFill([ - 'name' => trans('entities.pages_initial_name'), + 'name' => trans('entities.pages_initial_name'), 'created_by' => user()->id, - 'owned_by' => user()->id, + 'owned_by' => user()->id, 'updated_by' => user()->id, - 'draft' => true, + 'draft' => true, ]); if ($parent instanceof Chapter) { @@ -165,11 +166,10 @@ public function publishDraft(Page $draft, array $input): Page $draft->draft = false; $draft->revision_count = 1; $draft->priority = $this->getNewPriority($draft); - $draft->refreshSlug(); $draft->save(); - $this->savePageRevision($draft, trans('entities.pages_initial_revision')); - $draft->indexForSearch(); + $this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision')); + $this->referenceStore->updateForPage($draft); $draft->refresh(); Activity::add(ActivityType::PAGE_CREATE, $draft); @@ -189,13 +189,14 @@ public function update(Page $page, array $input): Page $this->updateTemplateStatusAndContentFromInput($page, $input); $this->baseRepo->update($page, $input); + $this->referenceStore->updateForPage($page); // Update with new details $page->revision_count++; $page->save(); // Remove all update drafts for this user & page. - $this->getUserDraftQuery($page)->delete(); + $this->revisionRepo->deleteDraftsForCurrentUser($page); // Save a revision after updating $summary = trim($input['summary'] ?? ''); @@ -203,7 +204,7 @@ public function update(Page $page, array $input): Page $nameChanged = isset($input['name']) && $input['name'] !== $oldName; $markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown; if ($htmlChanged || $nameChanged || $markdownChanged || $summary) { - $this->savePageRevision($page, $summary); + $this->revisionRepo->storeNewForPage($page, $summary); } Activity::add(ActivityType::PAGE_UPDATE, $page); @@ -239,32 +240,6 @@ protected function updateTemplateStatusAndContentFromInput(Page $page, array $in } } - /** - * Saves a page revision into the system. - */ - protected function savePageRevision(Page $page, string $summary = null): PageRevision - { - $revision = new PageRevision(); - - $revision->name = $page->name; - $revision->html = $page->html; - $revision->markdown = $page->markdown; - $revision->text = $page->text; - $revision->page_id = $page->id; - $revision->slug = $page->slug; - $revision->book_slug = $page->book->slug; - $revision->created_by = user()->id; - $revision->created_at = $page->updated_at; - $revision->type = 'version'; - $revision->summary = $summary; - $revision->revision_number = $page->revision_count; - $revision->save(); - - $this->deleteOldRevisions($page); - - return $revision; - } - /** * Save a page update draft. */ @@ -280,7 +255,7 @@ public function updatePageDraft(Page $page, array $input) } // Otherwise, save the data to a revision - $draft = $this->getPageRevisionToUpdate($page); + $draft = $this->revisionRepo->getNewDraftForCurrentUser($page); $draft->fill($input); if (!empty($input['markdown'])) { @@ -314,6 +289,7 @@ public function destroy(Page $page) */ public function restoreRevision(Page $page, int $revisionId): Page { + $oldUrl = $page->getUrl(); $page->revision_count++; /** @var PageRevision $revision */ @@ -332,9 +308,14 @@ public function restoreRevision(Page $page, int $revisionId): Page $page->refreshSlug(); $page->save(); $page->indexForSearch(); + $this->referenceStore->updateForPage($page); $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); - $this->savePageRevision($page, $summary); + $this->revisionRepo->storeNewForPage($page, $summary); + + if ($oldUrl !== $page->getUrl()) { + $this->referenceUpdater->updateEntityPageReferences($page, $oldUrl); + } Activity::add(ActivityType::PAGE_RESTORE, $page); Activity::add(ActivityType::REVISION_RESTORE, $revision); @@ -393,48 +374,6 @@ public function findParentByIdentifier(string $identifier): ?Entity return $parentClass::visible()->where('id', '=', $entityId)->first(); } - /** - * Get a page revision to update for the given page. - * Checks for an existing revisions before providing a fresh one. - */ - protected function getPageRevisionToUpdate(Page $page): PageRevision - { - $drafts = $this->getUserDraftQuery($page)->get(); - if ($drafts->count() > 0) { - return $drafts->first(); - } - - $draft = new PageRevision(); - $draft->page_id = $page->id; - $draft->slug = $page->slug; - $draft->book_slug = $page->book->slug; - $draft->created_by = user()->id; - $draft->type = 'update_draft'; - - return $draft; - } - - /** - * Delete old revisions, for the given page, from the system. - */ - protected function deleteOldRevisions(Page $page) - { - $revisionLimit = config('app.revision_limit'); - if ($revisionLimit === false) { - return; - } - - $revisionsToDelete = PageRevision::query() - ->where('page_id', '=', $page->id) - ->orderBy('created_at', 'desc') - ->skip(intval($revisionLimit)) - ->take(10) - ->get(['id']); - if ($revisionsToDelete->count() > 0) { - PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); - } - } - /** * Get a new priority for a page. */ @@ -450,15 +389,4 @@ protected function getNewPriority(Page $page): int return (new BookContents($page->book))->getLastPriority() + 1; } - - /** - * Get the query to find the user's draft copies of the given page. - */ - protected function getUserDraftQuery(Page $page) - { - return PageRevision::query()->where('created_by', '=', user()->id) - ->where('type', 'update_draft') - ->where('page_id', '=', $page->id) - ->orderBy('created_at', 'desc'); - } } diff --git a/app/Entities/Repos/RevisionRepo.php b/app/Entities/Repos/RevisionRepo.php new file mode 100644 index 00000000000..76d1d855324 --- /dev/null +++ b/app/Entities/Repos/RevisionRepo.php @@ -0,0 +1,131 @@ +whereHas('page', function (Builder $query) { + $query->scopes('visible'); + }) + ->where('slug', '=', $pageSlug) + ->where('type', '=', 'version') + ->where('book_slug', '=', $bookSlug) + ->orderBy('created_at', 'desc') + ->with('page') + ->first(); + + return $revision; + } + + /** + * Get the latest draft revision, for the given page, belonging to the current user. + */ + public function getLatestDraftForCurrentUser(Page $page): ?PageRevision + { + /** @var ?PageRevision $revision */ + $revision = $this->queryForCurrentUserDraft($page->id)->first(); + + return $revision; + } + + /** + * Delete all drafts revisions, for the given page, belonging to the current user. + */ + public function deleteDraftsForCurrentUser(Page $page): void + { + $this->queryForCurrentUserDraft($page->id)->delete(); + } + + /** + * Get a user update_draft page revision to update for the given page. + * Checks for an existing revisions before providing a fresh one. + */ + public function getNewDraftForCurrentUser(Page $page): PageRevision + { + $draft = $this->getLatestDraftForCurrentUser($page); + + if ($draft) { + return $draft; + } + + $draft = new PageRevision(); + $draft->page_id = $page->id; + $draft->slug = $page->slug; + $draft->book_slug = $page->book->slug; + $draft->created_by = user()->id; + $draft->type = 'update_draft'; + + return $draft; + } + + /** + * Store a new revision in the system for the given page. + */ + public function storeNewForPage(Page $page, string $summary = null): PageRevision + { + $revision = new PageRevision(); + + $revision->name = $page->name; + $revision->html = $page->html; + $revision->markdown = $page->markdown; + $revision->text = $page->text; + $revision->page_id = $page->id; + $revision->slug = $page->slug; + $revision->book_slug = $page->book->slug; + $revision->created_by = user()->id; + $revision->created_at = $page->updated_at; + $revision->type = 'version'; + $revision->summary = $summary; + $revision->revision_number = $page->revision_count; + $revision->save(); + + $this->deleteOldRevisions($page); + + return $revision; + } + + /** + * Delete old revisions, for the given page, from the system. + */ + protected function deleteOldRevisions(Page $page) + { + $revisionLimit = config('app.revision_limit'); + if ($revisionLimit === false) { + return; + } + + $revisionsToDelete = PageRevision::query() + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc') + ->skip(intval($revisionLimit)) + ->take(10) + ->get(['id']); + + if ($revisionsToDelete->count() > 0) { + PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); + } + } + + /** + * Query update draft revisions for the current user. + */ + protected function queryForCurrentUserDraft(int $pageId): Builder + { + return PageRevision::query() + ->where('created_by', '=', user()->id) + ->where('type', 'update_draft') + ->where('page_id', '=', $pageId) + ->orderBy('created_at', 'desc'); + } +} \ No newline at end of file diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index abec2e2d57a..7341a032816 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -376,6 +376,8 @@ protected function destroyCommonRelations(Entity $entity) $entity->searchTerms()->delete(); $entity->deletions()->delete(); $entity->favourites()->delete(); + $entity->referencesTo()->delete(); + $entity->referencesFrom()->delete(); if ($entity instanceof HasCoverImage && $entity->cover()->exists()) { $imageService = app()->make(ImageService::class); diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index c5b6d0bf6de..a041267bbdf 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -15,19 +15,22 @@ use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\NotFoundException; use BookStack\Facades\Activity; +use BookStack\References\ReferenceFetcher; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; class BookController extends Controller { - protected $bookRepo; - protected $entityContextManager; + protected BookRepo $bookRepo; + protected ShelfContext $shelfContext; + protected ReferenceFetcher $referenceFetcher; - public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo) + public function __construct(ShelfContext $entityContextManager, BookRepo $bookRepo, ReferenceFetcher $referenceFetcher) { $this->bookRepo = $bookRepo; - $this->entityContextManager = $entityContextManager; + $this->shelfContext = $entityContextManager; + $this->referenceFetcher = $referenceFetcher; } /** @@ -44,7 +47,7 @@ public function index() $popular = $this->bookRepo->getPopular(4); $new = $this->bookRepo->getRecentlyCreated(4); - $this->entityContextManager->clearShelfContext(); + $this->shelfContext->clearShelfContext(); $this->setPageTitle(trans('entities.books')); @@ -122,7 +125,7 @@ public function show(Request $request, ActivityQueries $activities, string $slug View::incrementFor($book); if ($request->has('shelf')) { - $this->entityContextManager->setShelfContext(intval($request->get('shelf'))); + $this->shelfContext->setShelfContext(intval($request->get('shelf'))); } $this->setPageTitle($book->getShortName()); @@ -133,6 +136,7 @@ public function show(Request $request, ActivityQueries $activities, string $slug 'bookChildren' => $bookChildren, 'bookParentShelves' => $bookParentShelves, 'activity' => $activities->entityActivity($book, 20, 1), + 'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book), ]); } diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index ccbeb6484b0..2143b876a51 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -10,6 +10,7 @@ use BookStack\Entities\Tools\ShelfContext; use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\NotFoundException; +use BookStack\References\ReferenceFetcher; use Exception; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -18,11 +19,13 @@ class BookshelfController extends Controller { protected BookshelfRepo $shelfRepo; protected ShelfContext $shelfContext; + protected ReferenceFetcher $referenceFetcher; - public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext) + public function __construct(BookshelfRepo $shelfRepo, ShelfContext $shelfContext, ReferenceFetcher $referenceFetcher) { $this->shelfRepo = $shelfRepo; $this->shelfContext = $shelfContext; + $this->referenceFetcher = $referenceFetcher; } /** @@ -124,6 +127,7 @@ public function show(ActivityQueries $activities, string $slug) 'activity' => $activities->entityActivity($shelf, 20, 1), 'order' => $order, 'sort' => $sort, + 'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf), ]); } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 60eb523800f..735c760be2d 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -13,20 +13,21 @@ use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\PermissionsException; +use BookStack\References\ReferenceFetcher; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; class ChapterController extends Controller { - protected $chapterRepo; + protected ChapterRepo $chapterRepo; + protected ReferenceFetcher $referenceFetcher; - /** - * ChapterController constructor. - */ - public function __construct(ChapterRepo $chapterRepo) + + public function __construct(ChapterRepo $chapterRepo, ReferenceFetcher $referenceFetcher) { $this->chapterRepo = $chapterRepo; + $this->referenceFetcher = $referenceFetcher; } /** @@ -77,13 +78,14 @@ public function show(string $bookSlug, string $chapterSlug) $this->setPageTitle($chapter->getShortName()); return view('chapters.show', [ - 'book' => $chapter->book, - 'chapter' => $chapter, - 'current' => $chapter, - 'sidebarTree' => $sidebarTree, - 'pages' => $pages, - 'next' => $nextPreviousLocator->getNext(), - 'previous' => $nextPreviousLocator->getPrevious(), + 'book' => $chapter->book, + 'chapter' => $chapter, + 'current' => $chapter, + 'sidebarTree' => $sidebarTree, + 'pages' => $pages, + 'next' => $nextPreviousLocator->getNext(), + 'previous' => $nextPreviousLocator->getPrevious(), + 'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter), ]); } diff --git a/app/Http/Controllers/MaintenanceController.php b/app/Http/Controllers/MaintenanceController.php index f13266d7c61..8bfefb7acb9 100644 --- a/app/Http/Controllers/MaintenanceController.php +++ b/app/Http/Controllers/MaintenanceController.php @@ -5,6 +5,7 @@ use BookStack\Actions\ActivityType; use BookStack\Entities\Tools\TrashCan; use BookStack\Notifications\TestEmail; +use BookStack\References\ReferenceStore; use BookStack\Uploads\ImageService; use Illuminate\Http\Request; @@ -74,6 +75,24 @@ public function sendTestEmail() $this->showErrorNotification($errorMessage); } - return redirect('/settings/maintenance#image-cleanup')->withInput(); + return redirect('/settings/maintenance#image-cleanup'); + } + + /** + * Action to regenerate the reference index in the system. + */ + public function regenerateReferences(ReferenceStore $referenceStore) + { + $this->checkPermission('settings-manage'); + $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references'); + + try { + $referenceStore->updateForAllPages(); + $this->showSuccessNotification(trans('settings.maint_regen_references_success')); + } catch (\Exception $exception) { + $this->showErrorNotification($exception->getMessage()); + } + + return redirect('/settings/maintenance#regenerate-references'); } } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 268dce0573a..748468b211f 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -14,6 +14,7 @@ use BookStack\Entities\Tools\PermissionsUpdater; use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\PermissionsException; +use BookStack\References\ReferenceFetcher; use Exception; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Http\Request; @@ -23,13 +24,15 @@ class PageController extends Controller { protected PageRepo $pageRepo; + protected ReferenceFetcher $referenceFetcher; /** * PageController constructor. */ - public function __construct(PageRepo $pageRepo) + public function __construct(PageRepo $pageRepo, ReferenceFetcher $referenceFetcher) { $this->pageRepo = $pageRepo; + $this->referenceFetcher = $referenceFetcher; } /** @@ -160,6 +163,7 @@ public function show(string $bookSlug, string $pageSlug) 'pageNav' => $pageNav, 'next' => $nextPreviousLocator->getNext(), 'previous' => $nextPreviousLocator->getPrevious(), + 'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page), ]); } diff --git a/app/Http/Controllers/ReferenceController.php b/app/Http/Controllers/ReferenceController.php new file mode 100644 index 00000000000..07b14322358 --- /dev/null +++ b/app/Http/Controllers/ReferenceController.php @@ -0,0 +1,77 @@ +referenceFetcher = $referenceFetcher; + } + + /** + * Display the references to a given page. + */ + public function page(string $bookSlug, string $pageSlug) + { + /** @var Page $page */ + $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail(); + $references = $this->referenceFetcher->getPageReferencesToEntity($page); + + return view('pages.references', [ + 'page' => $page, + 'references' => $references, + ]); + } + + /** + * Display the references to a given chapter. + */ + public function chapter(string $bookSlug, string $chapterSlug) + { + /** @var Chapter $chapter */ + $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail(); + $references = $this->referenceFetcher->getPageReferencesToEntity($chapter); + + return view('chapters.references', [ + 'chapter' => $chapter, + 'references' => $references, + ]); + } + + /** + * Display the references to a given book. + */ + public function book(string $slug) + { + $book = Book::visible()->where('slug', '=', $slug)->firstOrFail(); + $references = $this->referenceFetcher->getPageReferencesToEntity($book); + + return view('books.references', [ + 'book' => $book, + 'references' => $references, + ]); + } + + /** + * Display the references to a given shelf. + */ + public function shelf(string $slug) + { + $shelf = Bookshelf::visible()->where('slug', '=', $slug)->firstOrFail(); + $references = $this->referenceFetcher->getPageReferencesToEntity($shelf); + + return view('shelves.references', [ + 'shelf' => $shelf, + 'references' => $references, + ]); + } +} diff --git a/app/References/CrossLinkParser.php b/app/References/CrossLinkParser.php new file mode 100644 index 00000000000..1bf1c7d37d9 --- /dev/null +++ b/app/References/CrossLinkParser.php @@ -0,0 +1,103 @@ +modelResolvers = $modelResolvers; + } + + /** + * Extract any found models within the given HTML content. + * + * @return Model[] + */ + public function extractLinkedModels(string $html): array + { + $models = []; + + $links = $this->getLinksFromContent($html); + + foreach ($links as $link) { + $model = $this->linkToModel($link); + if (!is_null($model)) { + $models[get_class($model) . ':' . $model->id] = $model; + } + } + + return array_values($models); + } + + /** + * Get a list of href values from the given document. + * + * @returns string[] + */ + protected function getLinksFromContent(string $html): array + { + $links = []; + + $html = '
' . $html . ''; + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + + $xPath = new DOMXPath($doc); + $anchors = $xPath->query('//a[@href]'); + + /** @var \DOMElement $anchor */ + foreach ($anchors as $anchor) { + $links[] = $anchor->getAttribute('href'); + } + + return $links; + } + + /** + * Attempt to resolve the given link to a model using the instance model resolvers. + */ + protected function linkToModel(string $link): ?Model + { + foreach ($this->modelResolvers as $resolver) { + $model = $resolver->resolve($link); + if (!is_null($model)) { + return $model; + } + } + + return null; + } + + /** + * Create a new instance with a pre-defined set of model resolvers, specifically for the + * default set of entities within BookStack. + */ + public static function createWithEntityResolvers(): self + { + return new self([ + new PagePermalinkModelResolver(), + new PageLinkModelResolver(), + new ChapterLinkModelResolver(), + new BookLinkModelResolver(), + new BookshelfLinkModelResolver(), + ]); + } + +} \ No newline at end of file diff --git a/app/References/ModelResolvers/BookLinkModelResolver.php b/app/References/ModelResolvers/BookLinkModelResolver.php new file mode 100644 index 00000000000..459b13644e9 --- /dev/null +++ b/app/References/ModelResolvers/BookLinkModelResolver.php @@ -0,0 +1,26 @@ +where('slug', '=', $bookSlug)->first(['id']); + + return $model; + } +} \ No newline at end of file diff --git a/app/References/ModelResolvers/BookshelfLinkModelResolver.php b/app/References/ModelResolvers/BookshelfLinkModelResolver.php new file mode 100644 index 00000000000..7d163668989 --- /dev/null +++ b/app/References/ModelResolvers/BookshelfLinkModelResolver.php @@ -0,0 +1,26 @@ +where('slug', '=', $shelfSlug)->first(['id']); + + return $model; + } +} \ No newline at end of file diff --git a/app/References/ModelResolvers/ChapterLinkModelResolver.php b/app/References/ModelResolvers/ChapterLinkModelResolver.php new file mode 100644 index 00000000000..fbe75c4f644 --- /dev/null +++ b/app/References/ModelResolvers/ChapterLinkModelResolver.php @@ -0,0 +1,27 @@ +whereSlugs($bookSlug, $chapterSlug)->first(['id']); + + return $model; + } +} \ No newline at end of file diff --git a/app/References/ModelResolvers/CrossLinkModelResolver.php b/app/References/ModelResolvers/CrossLinkModelResolver.php new file mode 100644 index 00000000000..5cfd0206008 --- /dev/null +++ b/app/References/ModelResolvers/CrossLinkModelResolver.php @@ -0,0 +1,13 @@ +whereSlugs($bookSlug, $pageSlug)->first(['id']); + + return $model; + } +} \ No newline at end of file diff --git a/app/References/ModelResolvers/PagePermalinkModelResolver.php b/app/References/ModelResolvers/PagePermalinkModelResolver.php new file mode 100644 index 00000000000..d59d41925d8 --- /dev/null +++ b/app/References/ModelResolvers/PagePermalinkModelResolver.php @@ -0,0 +1,25 @@ +find($id, ['id']); + + return $model; + } +} \ No newline at end of file diff --git a/app/References/Reference.php b/app/References/Reference.php new file mode 100644 index 00000000000..5a490b5b52c --- /dev/null +++ b/app/References/Reference.php @@ -0,0 +1,28 @@ +morphTo('from'); + } + + public function to(): MorphTo + { + return $this->morphTo('to'); + } +} diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php new file mode 100644 index 00000000000..fef2744d7dd --- /dev/null +++ b/app/References/ReferenceFetcher.php @@ -0,0 +1,62 @@ +permissions = $permissions; + } + + /** + * Query and return the page references pointing to the given entity. + * Loads the commonly required relations while taking permissions into account. + */ + public function getPageReferencesToEntity(Entity $entity): Collection + { + $baseQuery = $entity->referencesTo() + ->where('from_type', '=', (new Page())->getMorphClass()) + ->with([ + 'from' => fn(Relation $query) => $query->select(Page::$listAttributes), + 'from.book' => fn(Relation $query) => $query->scopes('visible'), + 'from.chapter' => fn(Relation $query) => $query->scopes('visible') + ]); + + $references = $this->permissions->restrictEntityRelationQuery( + $baseQuery, + 'references', + 'from_id', + 'from_type' + )->get(); + + return $references; + } + + /** + * Returns the count of page references pointing to the given entity. + * Takes permissions into account. + */ + public function getPageReferenceCountToEntity(Entity $entity): int + { + $baseQuery = $entity->referencesTo() + ->where('from_type', '=', (new Page())->getMorphClass()); + + $count = $this->permissions->restrictEntityRelationQuery( + $baseQuery, + 'references', + 'from_id', + 'from_type' + )->count(); + + return $count; + } +} \ No newline at end of file diff --git a/app/References/ReferenceStore.php b/app/References/ReferenceStore.php new file mode 100644 index 00000000000..f6e3c04a386 --- /dev/null +++ b/app/References/ReferenceStore.php @@ -0,0 +1,71 @@ +updateForPages([$page]); + } + + /** + * Update the outgoing references for all pages in the system. + */ + public function updateForAllPages(): void + { + Reference::query() + ->where('from_type', '=', (new Page())->getMorphClass()) + ->delete(); + + Page::query()->select(['id', 'html'])->chunk(100, function(Collection $pages) { + $this->updateForPages($pages->all()); + }); + } + + /** + * Update the outgoing references for the pages in the given array. + * + * @param Page[] $pages + */ + protected function updateForPages(array $pages): void + { + if (count($pages) === 0) { + return; + } + + $parser = CrossLinkParser::createWithEntityResolvers(); + $references = []; + + $pageIds = array_map(fn(Page $page) => $page->id, $pages); + Reference::query() + ->where('from_type', '=', $pages[0]->getMorphClass()) + ->whereIn('from_id', $pageIds) + ->delete(); + + foreach ($pages as $page) { + $models = $parser->extractLinkedModels($page->html); + + foreach ($models as $model) { + $references[] = [ + 'from_id' => $page->id, + 'from_type' => $page->getMorphClass(), + 'to_id' => $model->id, + 'to_type' => $model->getMorphClass(), + ]; + } + } + + foreach (array_chunk($references, 1000) as $referenceDataChunk) { + Reference::query()->insert($referenceDataChunk); + } + } + +} \ No newline at end of file diff --git a/app/References/ReferenceUpdater.php b/app/References/ReferenceUpdater.php new file mode 100644 index 00000000000..15619bc31c8 --- /dev/null +++ b/app/References/ReferenceUpdater.php @@ -0,0 +1,94 @@ +referenceFetcher = $referenceFetcher; + $this->revisionRepo = $revisionRepo; + } + + public function updateEntityPageReferences(Entity $entity, string $oldLink) + { + $references = $this->referenceFetcher->getPageReferencesToEntity($entity); + $newLink = $entity->getUrl(); + + /** @var Reference $reference */ + foreach ($references as $reference) { + /** @var Page $page */ + $page = $reference->from; + $this->updateReferencesWithinPage($page, $oldLink, $newLink); + } + } + + protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink) + { + $page = (clone $page)->refresh(); + $html = $this->updateLinksInHtml($page->html, $oldLink, $newLink); + $markdown = $this->updateLinksInMarkdown($page->markdown, $oldLink, $newLink); + + $page->html = $html; + $page->markdown = $markdown; + $page->revision_count++; + $page->save(); + + $summary = trans('entities.pages_references_update_revision'); + $this->revisionRepo->storeNewForPage($page, $summary); + } + + protected function updateLinksInMarkdown(string $markdown, string $oldLink, string $newLink): string + { + if (empty($markdown)) { + return $markdown; + } + + $commonLinkRegex = '/(\[.*?\]\()' . preg_quote($oldLink, '/') . '(.*?\))/i'; + $markdown = preg_replace($commonLinkRegex, '$1' . $newLink . '$2', $markdown); + + $referenceLinkRegex = '/(\[.*?\]:\s?)' . preg_quote($oldLink, '/') . '(.*?)($|\s)/i'; + $markdown = preg_replace($referenceLinkRegex, '$1' . $newLink . '$2$3', $markdown); + + return $markdown; + } + + protected function updateLinksInHtml(string $html, string $oldLink, string $newLink): string + { + if (empty($html)) { + return $html; + } + + $html = '' . $html . ''; + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + + $xPath = new DOMXPath($doc); + $anchors = $xPath->query('//a[@href]'); + + /** @var \DOMElement $anchor */ + foreach ($anchors as $anchor) { + $link = $anchor->getAttribute('href'); + $updated = str_ireplace($oldLink, $newLink, $link); + $anchor->setAttribute('href', $updated); + } + + $html = ''; + $topElems = $doc->documentElement->childNodes->item(0)->childNodes; + foreach ($topElems as $child) { + $html .= $doc->saveHTML($child); + } + + return $html; + } +} \ No newline at end of file diff --git a/database/migrations/2022_08_17_092941_create_references_table.php b/database/migrations/2022_08_17_092941_create_references_table.php new file mode 100644 index 00000000000..443bce55174 --- /dev/null +++ b/database/migrations/2022_08_17_092941_create_references_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedInteger('from_id')->index(); + $table->string('from_type', 25)->index(); + $table->unsignedInteger('to_id')->index(); + $table->string('to_type', 25)->index(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('references'); + } +} diff --git a/resources/icons/popular.svg b/resources/icons/popular.svg index ba1f918a512..2ac44f151d7 100644 --- a/resources/icons/popular.svg +++ b/resources/icons/popular.svg @@ -1,4 +1,3 @@ \ No newline at end of file diff --git a/resources/icons/reference.svg b/resources/icons/reference.svg new file mode 100644 index 00000000000..560ec5f374d --- /dev/null +++ b/resources/icons/reference.svg @@ -0,0 +1,3 @@ + diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index db1e8027b0b..07d4b625d8d 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -23,6 +23,7 @@ 'meta_updated' => 'Updated :timeLength', 'meta_updated_name' => 'Updated :timeLength by :user', 'meta_owned_name' => 'Owned by :user', + 'meta_reference_page_count' => 'Referenced on 1 page|Referenced on :count pages', 'entity_select' => 'Entity Select', 'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item', 'images' => 'Images', @@ -248,6 +249,7 @@ 'pages_edit_content_link' => 'Edit Content', 'pages_permissions_active' => 'Page Permissions Active', 'pages_initial_revision' => 'Initial publish', + 'pages_references_update_revision' => 'System auto-update of internal links', 'pages_initial_name' => 'New Page', 'pages_editing_draft_notification' => 'You are currently editing a draft that was last saved :timeDiff.', 'pages_draft_edited_notification' => 'This page has been updated by since that time. It is recommended that you discard this draft.', @@ -369,4 +371,9 @@ 'convert_to_book_desc' => 'You can convert this chapter to a new book with the same contents. Any permissions set on this chapter will be copied to the new book but any inherited permissions, from the parent book, will not be copied which could lead to a change of access control.', 'convert_chapter' => 'Convert Chapter', 'convert_chapter_confirm' => 'Are you sure you want to convert this chapter?', + + // References + 'references' => 'References', + 'references_none' => 'There are no tracked references to this item.', + 'references_to_desc' => 'Shown below are all the known pages in the system that link to this item.', ]; diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 3bfe70bc4cd..9dbd96c5af3 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -89,6 +89,10 @@ 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', 'maint_recycle_bin_desc' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.', 'maint_recycle_bin_open' => 'Open Recycle Bin', + 'maint_regen_references' => 'Regenerate References', + 'maint_regen_references_desc' => 'This action will rebuild the cross-item reference index within the database. This is usually handled automatically but this action can be useful to index old content or content added via unofficial methods.', + 'maint_regen_references_success' => 'Reference index has been regenerated!', + 'maint_timeout_command_note' => 'Note: This action can take time to run, which can lead to timeout issues in some web environments. As an alternative, this action be performed using a terminal command.', // Recycle Bin 'recycle_bin' => 'Recycle Bin', diff --git a/resources/views/books/references.blade.php b/resources/views/books/references.blade.php new file mode 100644 index 00000000000..2468ed1112e --- /dev/null +++ b/resources/views/books/references.blade.php @@ -0,0 +1,20 @@ +@extends('layouts.simple') + +@section('body') + +{{ trans('entities.references_to_desc') }}
+ + @if(count($references) > 0) +{{ trans('entities.references_none') }}
+ @endif + +{{ trans('settings.maint_image_cleanup_desc') }}
+{{ trans('settings.maint_timeout_command_note') }}