]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #3656 from BookStackApp/x_linking
authorDan Brown <redacted>
Mon, 29 Aug 2022 16:45:05 +0000 (17:45 +0100)
committerGitHub <redacted>
Mon, 29 Aug 2022 16:45:05 +0000 (17:45 +0100)
Link reference tracking & updating

48 files changed:
.env.example.complete
app/Config/app.php
app/Console/Commands/RegenerateCommentContent.php
app/Console/Commands/RegeneratePermissions.php
app/Console/Commands/RegenerateReferences.php [new file with mode: 0644]
app/Entities/Models/BookChild.php
app/Entities/Models/Entity.php
app/Entities/Repos/BaseRepo.php
app/Entities/Repos/PageRepo.php
app/Entities/Repos/RevisionRepo.php [new file with mode: 0644]
app/Entities/Tools/TrashCan.php
app/Http/Controllers/BookController.php
app/Http/Controllers/BookshelfController.php
app/Http/Controllers/ChapterController.php
app/Http/Controllers/MaintenanceController.php
app/Http/Controllers/PageController.php
app/Http/Controllers/ReferenceController.php [new file with mode: 0644]
app/References/CrossLinkParser.php [new file with mode: 0644]
app/References/ModelResolvers/BookLinkModelResolver.php [new file with mode: 0644]
app/References/ModelResolvers/BookshelfLinkModelResolver.php [new file with mode: 0644]
app/References/ModelResolvers/ChapterLinkModelResolver.php [new file with mode: 0644]
app/References/ModelResolvers/CrossLinkModelResolver.php [new file with mode: 0644]
app/References/ModelResolvers/PageLinkModelResolver.php [new file with mode: 0644]
app/References/ModelResolvers/PagePermalinkModelResolver.php [new file with mode: 0644]
app/References/Reference.php [new file with mode: 0644]
app/References/ReferenceFetcher.php [new file with mode: 0644]
app/References/ReferenceStore.php [new file with mode: 0644]
app/References/ReferenceUpdater.php [new file with mode: 0644]
database/migrations/2022_08_17_092941_create_references_table.php [new file with mode: 0644]
resources/icons/popular.svg
resources/icons/reference.svg [new file with mode: 0644]
resources/lang/en/entities.php
resources/lang/en/settings.php
resources/views/books/references.blade.php [new file with mode: 0644]
resources/views/chapters/references.blade.php [new file with mode: 0644]
resources/views/entities/meta.blade.php
resources/views/entities/references.blade.php [new file with mode: 0644]
resources/views/pages/references.blade.php [new file with mode: 0644]
resources/views/settings/maintenance.blade.php
resources/views/shelves/references.blade.php [new file with mode: 0644]
routes/web.php
tests/Commands/RegenerateReferencesCommandTest.php [new file with mode: 0644]
tests/References/CrossLinkParserTest.php [new file with mode: 0644]
tests/References/ReferencesTest.php [new file with mode: 0644]
tests/Settings/RecycleBinTest.php [moved from tests/RecycleBinTest.php with 99% similarity]
tests/Settings/RegenerateReferencesTest.php [new file with mode: 0644]
tests/Settings/TestEmailTest.php [moved from tests/TestEmailTest.php with 98% similarity]
tests/TestCase.php

index c097af4f664606e5edaf439a35a13f318a800bf6..a0eef5cab897de52b1f485a2c1007eba87ae0fa5 100644 (file)
@@ -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
index 53d399abec46977f3a6f6917cc5514f8758859c3..e28ebe611a17297115a005a7e436524f2fcdd21f 100644 (file)
@@ -22,7 +22,7 @@ return [
     // 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
index 587a5edb310825db0c19c0a71031cb6801cc62d0..9da48fb0e513b100d7d55e0a98afc108a94ffb09 100644 (file)
@@ -5,6 +5,7 @@ namespace BookStack\Console\Commands;
 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 @@ class RegenerateCommentContent extends Command
      */
     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 @@ class RegenerateCommentContent extends Command
             }
         });
 
-        \DB::setDefaultConnection($connection);
+        DB::setDefaultConnection($connection);
         $this->comment('Comment HTML content has been regenerated');
+        return 0;
     }
 }
index 3396a445f0f531b4680302a6fae931dd5db1e0b0..74f96fd427010b8fb734672409db794badabf4b7 100644 (file)
@@ -50,5 +50,6 @@ class RegeneratePermissions extends Command
 
         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 (file)
index 0000000..805db22
--- /dev/null
@@ -0,0 +1,58 @@
+<?php
+
+namespace BookStack\Console\Commands;
+
+use BookStack\References\ReferenceStore;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class RegenerateReferences extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'bookstack:regenerate-references {--database= : The database connection to use.}';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = 'Regenerate all the cross-item model reference index';
+
+    protected ReferenceStore $references;
+
+    /**
+     * Create a new command instance.
+     *
+     * @return void
+     */
+    public function __construct(ReferenceStore $references)
+    {
+        $this->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;
+    }
+}
index e1ba0b6f708d75a18f5cb38dc38262e7396d61c0..3b1ac1bab74d68f1db67a0e3ede27788a6a2b1f3 100644 (file)
@@ -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 @@ abstract class BookChild extends Entity
      */
     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
index ffb9b9c7d6d91674c8d3d0d7ad9f391cfe3f0ba0..26a52073e016358f4f604c47eb3b2f0699fc88dd 100644 (file)
@@ -18,6 +18,7 @@ use BookStack\Interfaces\Loggable;
 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 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
         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'.
index 39b901383529effbb3b453d24a75336b24f9a666..cfde7fe1c578eb6876b1e77237c86e74bb584493 100644 (file)
@@ -6,6 +6,7 @@ use BookStack\Actions\TagRepo;
 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 @@ class BaseRepo
      */
     public function update(Entity $entity, array $input)
     {
+        $oldUrl = $entity->getUrl();
+
         $entity->fill($input);
         $entity->updated_by = user()->id;
 
@@ -64,6 +69,10 @@ class BaseRepo
 
         $entity->rebuildPermissions();
         $entity->indexForSearch();
+
+        if ($oldUrl !== $entity->getUrl()) {
+            $this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);
+        }
     }
 
     /**
index 60f1d1b01ec5c62320e9947cacb61d027572132d..c80cbdb149e60b9d1f758038a94e793f62f4104e 100644 (file)
@@ -16,20 +16,32 @@ use BookStack\Exceptions\MoveOperationException;
 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 @@ class PageRepo
      */
     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 @@ class PageRepo
      */
     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 @@ class PageRepo
     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 @@ class PageRepo
      */
     public function getUserDraft(Page $page): ?PageRevision
     {
-        $revision = $this->getUserDraftQuery($page)->first();
-
-        return $revision;
+        return $this->revisionRepo->getLatestDraftForCurrentUser($page);
     }
 
     /**
@@ -134,11 +135,11 @@ class PageRepo
     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 @@ class PageRepo
         $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 @@ class PageRepo
 
         $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 @@ class PageRepo
         $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 @@ class PageRepo
         }
     }
 
-    /**
-     * 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 @@ class PageRepo
         }
 
         // 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 @@ class PageRepo
      */
     public function restoreRevision(Page $page, int $revisionId): Page
     {
+        $oldUrl = $page->getUrl();
         $page->revision_count++;
 
         /** @var PageRevision $revision */
@@ -332,9 +308,14 @@ class PageRepo
         $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 @@ class PageRepo
         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 @@ class PageRepo
 
         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 (file)
index 0000000..76d1d85
--- /dev/null
@@ -0,0 +1,131 @@
+<?php
+
+namespace BookStack\Entities\Repos;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Models\PageRevision;
+use Illuminate\Database\Eloquent\Builder;
+
+class RevisionRepo
+{
+    /**
+     * Get a revision by its stored book and page slug values.
+     */
+    public function getBySlugs(string $bookSlug, string $pageSlug): ?PageRevision
+    {
+        /** @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();
+
+        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
index abec2e2d57a0fb1a67f3fa08a97e7ea58c050d8a..7341a032816ac5a10b08b39277d1cf6cc3b2c0e7 100644 (file)
@@ -376,6 +376,8 @@ class TrashCan
         $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);
index c5b6d0bf6def5ee529769aba77de0c8e42194ad1..a041267bbdf87df17e4711a903200db2001ccb81 100644 (file)
@@ -15,19 +15,22 @@ use BookStack\Entities\Tools\ShelfContext;
 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 @@ class BookController extends Controller
         $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 @@ class BookController extends Controller
 
         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 @@ class BookController extends Controller
             'bookChildren'      => $bookChildren,
             'bookParentShelves' => $bookParentShelves,
             'activity'          => $activities->entityActivity($book, 20, 1),
+            'referenceCount'    => $this->referenceFetcher->getPageReferenceCountToEntity($book),
         ]);
     }
 
index ccbeb6484b0db0720f4d55cf872d74e810d1d6d6..2143b876a517dc10dd8df31f09101af8ef97e90e 100644 (file)
@@ -10,6 +10,7 @@ use BookStack\Entities\Tools\PermissionsUpdater;
 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 @@ class BookshelfController extends Controller
             'activity'                => $activities->entityActivity($shelf, 20, 1),
             'order'                   => $order,
             'sort'                    => $sort,
+            'referenceCount'          => $this->referenceFetcher->getPageReferenceCountToEntity($shelf),
         ]);
     }
 
index 60eb523800fc369edb694db27e0bcdb065825e15..735c760be2d059f255709c9f22c071fd1d80811c 100644 (file)
@@ -13,20 +13,21 @@ use BookStack\Entities\Tools\PermissionsUpdater;
 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 @@ class ChapterController extends Controller
         $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),
         ]);
     }
 
index f13266d7c6150c77b255f49e7f9131ea8ceb98a1..8bfefb7acb95f1fe4c7d509f4e7084ba14e4da05 100644 (file)
@@ -5,6 +5,7 @@ namespace BookStack\Http\Controllers;
 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 @@ class MaintenanceController extends Controller
             $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');
     }
 }
index 268dce0573a9c51a0beacb264c7dc9ca14435481..748468b211fd1939e8d211861936dec3afca1078 100644 (file)
@@ -14,6 +14,7 @@ use BookStack\Entities\Tools\PageEditorData;
 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 @@ use Throwable;
 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 @@ class PageController extends Controller
             '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 (file)
index 0000000..07b1432
--- /dev/null
@@ -0,0 +1,77 @@
+<?php
+
+namespace BookStack\Http\Controllers;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Page;
+use BookStack\References\ReferenceFetcher;
+
+class ReferenceController extends Controller
+{
+    protected ReferenceFetcher $referenceFetcher;
+
+    public function __construct(ReferenceFetcher $referenceFetcher)
+    {
+        $this->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 (file)
index 0000000..1bf1c7d
--- /dev/null
@@ -0,0 +1,103 @@
+<?php
+
+namespace BookStack\References;
+
+use BookStack\Model;
+use BookStack\References\ModelResolvers\BookLinkModelResolver;
+use BookStack\References\ModelResolvers\BookshelfLinkModelResolver;
+use BookStack\References\ModelResolvers\ChapterLinkModelResolver;
+use BookStack\References\ModelResolvers\CrossLinkModelResolver;
+use BookStack\References\ModelResolvers\PageLinkModelResolver;
+use BookStack\References\ModelResolvers\PagePermalinkModelResolver;
+use DOMDocument;
+use DOMXPath;
+
+class CrossLinkParser
+{
+    /**
+     * @var CrossLinkModelResolver[]
+     */
+    protected array $modelResolvers;
+
+    public function __construct(array $modelResolvers)
+    {
+        $this->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 = '<body>' . $html . '</body>';
+        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 (file)
index 0000000..459b136
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace BookStack\References\ModelResolvers;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Model;
+
+class BookLinkModelResolver implements CrossLinkModelResolver
+{
+    public function resolve(string $link): ?Model
+    {
+        $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '([#?\/]|$)/';
+        $matches = [];
+        $match = preg_match($pattern, $link, $matches);
+        if (!$match) {
+            return null;
+        }
+
+        $bookSlug = $matches[1];
+
+        /** @var ?Book $model */
+        $model = Book::query()->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 (file)
index 0000000..7d16366
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace BookStack\References\ModelResolvers;
+
+use BookStack\Entities\Models\Bookshelf;
+use BookStack\Model;
+
+class BookshelfLinkModelResolver implements CrossLinkModelResolver
+{
+    public function resolve(string $link): ?Model
+    {
+        $pattern = '/^' . preg_quote(url('/shelves'), '/') . '\/([\w-]+)' . '([#?\/]|$)/';
+        $matches = [];
+        $match = preg_match($pattern, $link, $matches);
+        if (!$match) {
+            return null;
+        }
+
+        $shelfSlug = $matches[1];
+
+        /** @var ?Bookshelf $model */
+        $model = Bookshelf::query()->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 (file)
index 0000000..fbe75c4
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace BookStack\References\ModelResolvers;
+
+use BookStack\Entities\Models\Chapter;
+use BookStack\Model;
+
+class ChapterLinkModelResolver implements CrossLinkModelResolver
+{
+    public function resolve(string $link): ?Model
+    {
+        $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/chapter\/' . '([\w-]+)' . '([#?\/]|$)/';
+        $matches = [];
+        $match = preg_match($pattern, $link, $matches);
+        if (!$match) {
+            return null;
+        }
+
+        $bookSlug = $matches[1];
+        $chapterSlug = $matches[2];
+
+        /** @var ?Chapter $model */
+        $model = Chapter::query()->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 (file)
index 0000000..5cfd020
--- /dev/null
@@ -0,0 +1,13 @@
+<?php
+
+namespace BookStack\References\ModelResolvers;
+
+use BookStack\Model;
+
+interface CrossLinkModelResolver
+{
+    /**
+     * Resolve the given href link value to a model.
+     */
+    public function resolve(string $link): ?Model;
+}
\ No newline at end of file
diff --git a/app/References/ModelResolvers/PageLinkModelResolver.php b/app/References/ModelResolvers/PageLinkModelResolver.php
new file mode 100644 (file)
index 0000000..ead17e0
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace BookStack\References\ModelResolvers;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Model;
+
+class PageLinkModelResolver implements CrossLinkModelResolver
+{
+    public function resolve(string $link): ?Model
+    {
+        $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/page\/' . '([\w-]+)' . '([#?\/]|$)/';
+        $matches = [];
+        $match = preg_match($pattern, $link, $matches);
+        if (!$match) {
+            return null;
+        }
+
+        $bookSlug = $matches[1];
+        $pageSlug = $matches[2];
+
+        /** @var ?Page $model */
+        $model = Page::query()->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 (file)
index 0000000..d59d419
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace BookStack\References\ModelResolvers;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Model;
+
+class PagePermalinkModelResolver implements CrossLinkModelResolver
+{
+    public function resolve(string $link): ?Model
+    {
+        $pattern = '/^' . preg_quote(url('/link'), '/') . '\/(\d+)/';
+        $matches = [];
+        $match = preg_match($pattern, $link, $matches);
+        if (!$match) {
+            return null;
+        }
+
+        $id = intval($matches[1]);
+        /** @var ?Page $model */
+        $model = Page::query()->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 (file)
index 0000000..5a490b5
--- /dev/null
@@ -0,0 +1,28 @@
+<?php
+
+namespace BookStack\References;
+
+use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\MorphTo;
+
+/**
+ * @property int $from_id
+ * @property string $from_type
+ * @property int $to_id
+ * @property string $to_type
+ */
+class Reference extends Model
+{
+    public $timestamps = false;
+
+    public function from(): MorphTo
+    {
+        return $this->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 (file)
index 0000000..fef2744
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+
+namespace BookStack\References;
+
+use BookStack\Auth\Permissions\PermissionApplicator;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Relations\Relation;
+
+class ReferenceFetcher
+{
+    protected PermissionApplicator $permissions;
+
+    public function __construct(PermissionApplicator $permissions)
+    {
+        $this->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 (file)
index 0000000..f6e3c04
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+
+namespace BookStack\References;
+
+use BookStack\Entities\Models\Page;
+use Illuminate\Database\Eloquent\Collection;
+
+class ReferenceStore
+{
+
+    /**
+     * Update the outgoing references for the given page.
+     */
+    public function updateForPage(Page $page): void
+    {
+        $this->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 (file)
index 0000000..15619bc
--- /dev/null
@@ -0,0 +1,94 @@
+<?php
+
+namespace BookStack\References;
+
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\RevisionRepo;
+use DOMDocument;
+use DOMXPath;
+
+class ReferenceUpdater
+{
+    protected ReferenceFetcher $referenceFetcher;
+    protected RevisionRepo $revisionRepo;
+
+    public function __construct(ReferenceFetcher $referenceFetcher, RevisionRepo $revisionRepo)
+    {
+        $this->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 = '<body>' . $html . '</body>';
+        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 (file)
index 0000000..443bce5
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class CreateReferencesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('references', function (Blueprint $table) {
+            $table->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');
+    }
+}
index ba1f918a51274fb920c068bcb9c0259966e4820a..2ac44f151d747c6a9b3813d6940c422c29871c4d 100644 (file)
@@ -1,4 +1,3 @@
 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
     <path d="M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
-    <path d="M0 0h24v24H0z" fill="none"/>
 </svg>
\ No newline at end of file
diff --git a/resources/icons/reference.svg b/resources/icons/reference.svg
new file mode 100644 (file)
index 0000000..560ec5f
--- /dev/null
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
+ <path d="m24 44.15c-0.4 0-0.79167-0.06667-1.175-0.2-0.38333-0.13333-0.70833-0.33333-0.975-0.6l-17.2-17.2c-0.26667-0.26667-0.46667-0.59167-0.6-0.975s-0.2-0.775-0.2-1.175 0.066667-0.79167 0.2-1.175c0.13333-0.38333 0.33333-0.70833 0.6-0.975l17.2-17.2c0.26667-0.26667 0.59167-0.46667 0.975-0.6s0.775-0.2 1.175-0.2 0.79167 0.066667 1.175 0.2c0.38333 0.13333 0.70833 0.33333 0.975 0.6l17.2 17.2c0.26667 0.26667 0.46667 0.59167 0.6 0.975s0.2 0.775 0.2 1.175-0.06667 0.79167-0.2 1.175c-0.13333 0.38333-0.33333 0.70833-0.6 0.975l-17.2 17.2c-0.26667 0.26667-0.59167 0.46667-0.975 0.6s-0.775 0.2-1.175 0.2zm4.05-18.65-5.15 5.15c-0.3 0.3-0.44167 0.65-0.425 1.05 0.01667 0.4 0.175 0.75 0.475 1.05s0.65833 0.45 1.075 0.45 0.775-0.15 1.075-0.45l7.7-7.7c0.3-0.3 0.45-0.65 0.45-1.05s-0.15-0.75-0.45-1.05l-7.75-7.75c-0.3-0.3-0.65-0.45-1.05-0.45s-0.75 0.15-1.05 0.45-0.45 0.65833-0.45 1.075 0.15 0.775 0.45 1.075l5.1 5.15h-12.4c-0.43333 0-0.79167 0.14167-1.075 0.425s-0.425 0.64167-0.425 1.075 0.14167 0.79167 0.425 1.075 0.64167 0.425 1.075 0.425z"/>
+</svg>
index db1e8027b0bcbbad46eae95c540ebe8dd55cb3bc..07d4b625d8d7f0f1d66795bb63f790eb4eb35fb6 100644 (file)
@@ -23,6 +23,7 @@ return [
     '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 @@ return [
     '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 @@ return [
     '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.',
 ];
index 3bfe70bc4cd556934240e8a78d2cc2d7ba41e542..9dbd96c5af30b95731d44a313ee79f3f31226307 100755 (executable)
@@ -89,6 +89,10 @@ return [
     '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 (file)
index 0000000..2468ed1
--- /dev/null
@@ -0,0 +1,20 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <div class="my-s">
+            @include('entities.breadcrumbs', ['crumbs' => [
+                $book,
+                $book->getUrl('/references') => [
+                    'text' => trans('entities.references'),
+                    'icon' => 'reference',
+                ]
+            ]])
+        </div>
+
+        @include('entities.references', ['references' => $references])
+    </div>
+
+@stop
diff --git a/resources/views/chapters/references.blade.php b/resources/views/chapters/references.blade.php
new file mode 100644 (file)
index 0000000..7241c2b
--- /dev/null
@@ -0,0 +1,21 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <div class="my-s">
+            @include('entities.breadcrumbs', ['crumbs' => [
+                $chapter->book,
+                $chapter,
+                $chapter->getUrl('/references') => [
+                    'text' => trans('entities.references'),
+                    'icon' => 'reference',
+                ]
+            ]])
+        </div>
+
+        @include('entities.references', ['references' => $references])
+    </div>
+
+@stop
index 83ff2376220afe9dce33ff85904bc1ba5a515320..ac91eeed35f4c5b0a30432f70a031d684946630d 100644 (file)
             <span title="{{ $entity->updated_at->toDayDateTimeString() }}">{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }}</span>
         </div>
     @endif
+
+    @if($referenceCount ?? 0)
+        <a href="{{ $entity->getUrl('/references') }}" class="entity-meta-item">
+            @icon('reference')
+            <div>
+                {!! trans_choice('entities.meta_reference_page_count', $referenceCount, ['count' => $referenceCount]) !!}
+            </div>
+        </a>
+    @endif
 </div>
\ No newline at end of file
diff --git a/resources/views/entities/references.blade.php b/resources/views/entities/references.blade.php
new file mode 100644 (file)
index 0000000..db9e167
--- /dev/null
@@ -0,0 +1,13 @@
+<main class="card content-wrap">
+    <h1 class="list-heading">{{ trans('entities.references') }}</h1>
+    <p>{{ trans('entities.references_to_desc') }}</p>
+
+    @if(count($references) > 0)
+        <div class="book-contents">
+            @include('entities.list', ['entities' => $references->pluck('from'), 'showPath' => true])
+        </div>
+    @else
+        <p class="text-muted italic">{{ trans('entities.references_none') }}</p>
+    @endif
+
+</main>
\ No newline at end of file
diff --git a/resources/views/pages/references.blade.php b/resources/views/pages/references.blade.php
new file mode 100644 (file)
index 0000000..42ae707
--- /dev/null
@@ -0,0 +1,22 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <div class="my-s">
+            @include('entities.breadcrumbs', ['crumbs' => [
+                $page->book,
+                $page->chapter,
+                $page,
+                $page->getUrl('/references') => [
+                    'text' => trans('entities.references'),
+                    'icon' => 'reference',
+                ]
+            ]])
+        </div>
+
+        @include('entities.references', ['references' => $references])
+    </div>
+
+@stop
index a2a9ebc8181fa89c767d48068f3242e2555380bd..7ee966e00594ac42ae3e11c1b98af06584026776 100644 (file)
 
     <div id="image-cleanup" class="card content-wrap auto-height">
         <h2 class="list-heading">{{ trans('settings.maint_image_cleanup') }}</h2>
-        <div class="grid half gap-xl">
+        <div class="grid left-focus gap-xl">
             <div>
                 <p class="small text-muted">{{ trans('settings.maint_image_cleanup_desc') }}</p>
+                <p class="small text-muted italic">{{ trans('settings.maint_timeout_command_note') }}</p>
             </div>
             <div>
                 <form method="POST" action="{{ url('/settings/maintenance/cleanup-images') }}">
@@ -55,7 +56,7 @@
 
     <div id="send-test-email" class="card content-wrap auto-height">
         <h2 class="list-heading">{{ trans('settings.maint_send_test_email') }}</h2>
-        <div class="grid half gap-xl">
+        <div class="grid left-focus gap-xl">
             <div>
                 <p class="small text-muted">{{ trans('settings.maint_send_test_email_desc') }}</p>
             </div>
         </div>
     </div>
 
+    <div id="regenerate-references" class="card content-wrap auto-height">
+        <h2 class="list-heading">{{ trans('settings.maint_regen_references') }}</h2>
+        <div class="grid left-focus gap-xl">
+            <div>
+                <p class="small text-muted">{{ trans('settings.maint_regen_references_desc') }}</p>
+                <p class="small text-muted italic">{{ trans('settings.maint_timeout_command_note') }}</p>
+            </div>
+            <div>
+                <form method="POST" action="{{ url('/settings/maintenance/regenerate-references') }}">
+                    {!! csrf_field()  !!}
+                    <button class="button outline">{{ trans('settings.maint_regen_references') }}</button>
+                </form>
+            </div>
+        </div>
+    </div>
+
 </div>
 @stop
diff --git a/resources/views/shelves/references.blade.php b/resources/views/shelves/references.blade.php
new file mode 100644 (file)
index 0000000..7336c07
--- /dev/null
@@ -0,0 +1,20 @@
+@extends('layouts.simple')
+
+@section('body')
+
+    <div class="container small">
+
+        <div class="my-s">
+            @include('entities.breadcrumbs', ['crumbs' => [
+                $shelf,
+                $shelf->getUrl('/references') => [
+                    'text' => trans('entities.references'),
+                    'icon' => 'reference',
+                ]
+            ]])
+        </div>
+
+        @include('entities.references', ['references' => $references])
+    </div>
+
+@stop
index 00841365a47df8aa40263952c32621602ce041fc..26d4b6f133b39618f6836d42e6235770633f9141 100644 (file)
@@ -20,6 +20,7 @@ use BookStack\Http\Controllers\PageExportController;
 use BookStack\Http\Controllers\PageRevisionController;
 use BookStack\Http\Controllers\PageTemplateController;
 use BookStack\Http\Controllers\RecycleBinController;
+use BookStack\Http\Controllers\ReferenceController;
 use BookStack\Http\Controllers\RoleController;
 use BookStack\Http\Controllers\SearchController;
 use BookStack\Http\Controllers\SettingController;
@@ -63,6 +64,7 @@ Route::middleware('auth')->group(function () {
     Route::get('/shelves/{slug}/permissions', [BookshelfController::class, 'showPermissions']);
     Route::put('/shelves/{slug}/permissions', [BookshelfController::class, 'permissions']);
     Route::post('/shelves/{slug}/copy-permissions', [BookshelfController::class, 'copyPermissions']);
+    Route::get('/shelves/{slug}/references', [ReferenceController::class, 'shelf']);
 
     // Book Creation
     Route::get('/shelves/{shelfSlug}/create-book', [BookController::class, 'create']);
@@ -85,6 +87,7 @@ Route::middleware('auth')->group(function () {
     Route::post('/books/{bookSlug}/convert-to-shelf', [BookController::class, 'convertToShelf']);
     Route::get('/books/{bookSlug}/sort', [BookSortController::class, 'show']);
     Route::put('/books/{bookSlug}/sort', [BookSortController::class, 'update']);
+    Route::get('/books/{slug}/references', [ReferenceController::class, 'book']);
     Route::get('/books/{bookSlug}/export/html', [BookExportController::class, 'html']);
     Route::get('/books/{bookSlug}/export/pdf', [BookExportController::class, 'pdf']);
     Route::get('/books/{bookSlug}/export/markdown', [BookExportController::class, 'markdown']);
@@ -110,6 +113,7 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/draft/{pageId}/delete', [PageController::class, 'showDeleteDraft']);
     Route::get('/books/{bookSlug}/page/{pageSlug}/permissions', [PageController::class, 'showPermissions']);
     Route::put('/books/{bookSlug}/page/{pageSlug}/permissions', [PageController::class, 'permissions']);
+    Route::get('/books/{bookSlug}/page/{pageSlug}/references', [ReferenceController::class, 'page']);
     Route::put('/books/{bookSlug}/page/{pageSlug}', [PageController::class, 'update']);
     Route::delete('/books/{bookSlug}/page/{pageSlug}', [PageController::class, 'destroy']);
     Route::delete('/books/{bookSlug}/draft/{pageId}', [PageController::class, 'destroyDraft']);
@@ -140,6 +144,7 @@ Route::middleware('auth')->group(function () {
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/markdown', [ChapterExportController::class, 'markdown']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/export/plaintext', [ChapterExportController::class, 'plainText']);
     Route::put('/books/{bookSlug}/chapter/{chapterSlug}/permissions', [ChapterController::class, 'permissions']);
+    Route::get('/books/{bookSlug}/chapter/{chapterSlug}/references', [ReferenceController::class, 'chapter']);
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/delete', [ChapterController::class, 'showDelete']);
     Route::delete('/books/{bookSlug}/chapter/{chapterSlug}', [ChapterController::class, 'destroy']);
 
@@ -213,6 +218,7 @@ Route::middleware('auth')->group(function () {
     Route::get('/settings/maintenance', [MaintenanceController::class, 'index']);
     Route::delete('/settings/maintenance/cleanup-images', [MaintenanceController::class, 'cleanupImages']);
     Route::post('/settings/maintenance/send-test-email', [MaintenanceController::class, 'sendTestEmail']);
+    Route::post('/settings/maintenance/regenerate-references', [MaintenanceController::class, 'regenerateReferences']);
 
     // Recycle Bin
     Route::get('/settings/recycle-bin', [RecycleBinController::class, 'index']);
diff --git a/tests/Commands/RegenerateReferencesCommandTest.php b/tests/Commands/RegenerateReferencesCommandTest.php
new file mode 100644 (file)
index 0000000..8906474
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+namespace Tests\Commands;
+
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
+
+class RegenerateReferencesCommandTest extends TestCase
+{
+    public function test_regenerate_references_command()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+        $book = $page->book;
+
+        $page->html = '<a href="' . $book->getUrl() . '">Book Link</a>';
+        $page->save();
+
+        DB::table('references')->delete();
+
+        $this->artisan('bookstack:regenerate-references')
+            ->assertExitCode(0);
+
+        $this->assertDatabaseHas('references', [
+            'from_id' => $page->id,
+            'from_type' => $page->getMorphClass(),
+            'to_id' => $book->id,
+            'to_type' => $book->getMorphClass(),
+        ]);
+    }
+}
diff --git a/tests/References/CrossLinkParserTest.php b/tests/References/CrossLinkParserTest.php
new file mode 100644 (file)
index 0000000..42d78cb
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+
+namespace Tests\References;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Page;
+use BookStack\References\CrossLinkParser;
+use Tests\TestCase;
+
+class CrossLinkParserTest extends TestCase
+{
+
+    public function test_instance_with_entity_resolvers_matches_entity_links()
+    {
+        $entities = $this->getEachEntityType();
+        $otherPage = Page::query()->where('id', '!=', $entities['page']->id)->first();
+
+        $html = '
+<a href="' . url('/link/' . $otherPage->id) . '#cat">Page Permalink</a>
+<a href="' . $entities['page'] ->getUrl(). '?a=b">Page Link</a>
+<a href="' . $entities['chapter']->getUrl() . '?cat=mouse#donkey">Chapter Link</a>
+<a href="' . $entities['book']->getUrl() . '/edit">Book Link</a>
+<a href="' . $entities['bookshelf']->getUrl() . '/edit?cat=happy#hello">Shelf Link</a>
+<a href="' . url('/settings') . '">Settings Link</a>
+        ';
+
+        $parser = CrossLinkParser::createWithEntityResolvers();
+        $results = $parser->extractLinkedModels($html);
+
+        $this->assertCount(5, $results);
+        $this->assertEquals(get_class($otherPage), get_class($results[0]));
+        $this->assertEquals($otherPage->id, $results[0]->id);
+        $this->assertEquals(get_class($entities['page']), get_class($results[1]));
+        $this->assertEquals($entities['page']->id, $results[1]->id);
+        $this->assertEquals(get_class($entities['chapter']), get_class($results[2]));
+        $this->assertEquals($entities['chapter']->id, $results[2]->id);
+        $this->assertEquals(get_class($entities['book']), get_class($results[3]));
+        $this->assertEquals($entities['book']->id, $results[3]->id);
+        $this->assertEquals(get_class($entities['bookshelf']), get_class($results[4]));
+        $this->assertEquals($entities['bookshelf']->id, $results[4]->id);
+    }
+
+    public function test_similar_page_and_book_reference_links_dont_conflict()
+    {
+        $page = Page::query()->first();
+        $book = $page->book;
+
+        $html = '
+<a href="' . $page->getUrl() . '">Page Link</a>
+<a href="' . $book->getUrl() . '">Book Link</a>
+        ';
+
+        $parser = CrossLinkParser::createWithEntityResolvers();
+        $results = $parser->extractLinkedModels($html);
+
+        $this->assertCount(2, $results);
+        $this->assertEquals(get_class($page), get_class($results[0]));
+        $this->assertEquals($page->id, $results[0]->id);
+        $this->assertEquals(get_class($book), get_class($results[1]));
+        $this->assertEquals($book->id, $results[1]->id);
+    }
+}
diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php
new file mode 100644 (file)
index 0000000..82cd166
--- /dev/null
@@ -0,0 +1,188 @@
+<?php
+
+namespace Tests\References;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Model;
+use BookStack\References\Reference;
+use Tests\TestCase;
+
+class ReferencesTest extends TestCase
+{
+
+    public function test_references_created_on_page_update()
+    {
+        /** @var Page $pageA */
+        /** @var Page $pageB */
+        $pageA = Page::query()->first();
+        $pageB = Page::query()->where('id', '!=', $pageA->id)->first();
+
+        $this->assertDatabaseMissing('references', ['from_id' => $pageA->id, 'from_type' => $pageA->getMorphClass()]);
+
+        $this->asEditor()->put($pageA->getUrl(), [
+            'name' => 'Reference test',
+            'html' => '<a href="' . $pageB->getUrl() . '">Testing</a>'
+        ]);
+
+        $this->assertDatabaseHas('references', [
+            'from_id' => $pageA->id,
+            'from_type' => $pageA->getMorphClass(),
+            'to_id' => $pageB->id,
+            'to_type' => $pageB->getMorphClass(),
+        ]);
+    }
+
+    public function test_references_deleted_on_entity_delete()
+    {
+        /** @var Page $pageA */
+        /** @var Page $pageB */
+        $pageA = Page::query()->first();
+        $pageB = Page::query()->where('id', '!=', $pageA->id)->first();
+
+        $this->createReference($pageA, $pageB);
+        $this->createReference($pageB, $pageA);
+
+        $this->assertDatabaseHas('references', ['from_id' => $pageA->id, 'from_type' => $pageA->getMorphClass()]);
+        $this->assertDatabaseHas('references', ['to_id' => $pageA->id, 'to_type' => $pageA->getMorphClass()]);
+
+        app(PageRepo::class)->destroy($pageA);
+        app(TrashCan::class)->empty();
+
+        $this->assertDatabaseMissing('references', ['from_id' => $pageA->id, 'from_type' => $pageA->getMorphClass()]);
+        $this->assertDatabaseMissing('references', ['to_id' => $pageA->id, 'to_type' => $pageA->getMorphClass()]);
+    }
+
+    public function test_references_to_count_visible_on_entity_show_view()
+    {
+        $entities = $this->getEachEntityType();
+        /** @var Page $otherPage */
+        $otherPage = Page::query()->where('id', '!=', $entities['page']->id)->first();
+
+        $this->asEditor();
+        foreach ($entities as $entity) {
+            $this->createReference($entities['page'], $entity);
+        }
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl());
+            $resp->assertSee('Referenced on 1 page');
+            $resp->assertDontSee('Referenced on 1 pages');
+        }
+
+        $this->createReference($otherPage, $entities['page']);
+        $resp = $this->get($entities['page']->getUrl());
+        $resp->assertSee('Referenced on 2 pages');
+    }
+
+    public function test_references_to_visible_on_references_page()
+    {
+        $entities = $this->getEachEntityType();
+        $this->asEditor();
+        foreach ($entities as $entity) {
+            $this->createReference($entities['page'], $entity);
+        }
+
+        foreach ($entities as $entity) {
+            $resp = $this->get($entity->getUrl('/references'));
+            $resp->assertSee('References');
+            $resp->assertSee($entities['page']->name);
+            $resp->assertDontSee('There are no tracked references');
+        }
+    }
+
+    public function test_reference_not_visible_if_view_permission_does_not_permit()
+    {
+        /** @var Page $page */
+        /** @var Page $pageB */
+        $page = Page::query()->first();
+        $pageB = Page::query()->where('id', '!=', $page->id)->first();
+        $this->createReference($pageB, $page);
+
+        $this->setEntityRestrictions($pageB);
+
+        $this->asEditor()->get($page->getUrl('/references'))->assertDontSee($pageB->name);
+        $this->asAdmin()->get($page->getUrl('/references'))->assertSee($pageB->name);
+    }
+
+    public function test_reference_page_shows_empty_state_with_no_references()
+    {
+        /** @var Page $page */
+        $page = Page::query()->first();
+
+        $this->asEditor()
+            ->get($page->getUrl('/references'))
+            ->assertSee('There are no tracked references');
+    }
+
+    public function test_pages_leading_to_entity_updated_on_url_change()
+    {
+        /** @var Page $pageA */
+        /** @var Page $pageB */
+        /** @var Book $book */
+        $pageA = Page::query()->first();
+        $pageB = Page::query()->where('id', '!=', $pageA->id)->first();
+        $book = Book::query()->first();
+
+        foreach ([$pageA, $pageB] as $page) {
+            $page->html = '<a href="' . $book->getUrl() . '">Link</a>';
+            $page->save();
+            $this->createReference($page, $book);
+        }
+
+        $this->asEditor()->put($book->getUrl(), [
+            'name' => 'my updated book slugaroo',
+        ]);
+
+        foreach ([$pageA, $pageB] as $page) {
+            $page->refresh();
+            $this->assertStringContainsString('href="http://localhost/books/my-updated-book-slugaroo"', $page->html);
+            $this->assertDatabaseHas('page_revisions', [
+                'page_id' => $page->id,
+                'summary' => 'System auto-update of internal links'
+            ]);
+        }
+    }
+
+    public function test_markdown_links_leading_to_entity_updated_on_url_change()
+    {
+        /** @var Page $page */
+        /** @var Book $book */
+        $page = Page::query()->first();
+        $book = Book::query()->first();
+
+        $bookUrl = $book->getUrl();
+        $markdown = '
+        [An awesome link](' . $bookUrl . ')
+        [An awesome link with query & hash](' . $bookUrl . '?test=yes#cats)
+        [An awesome link with path](' . $bookUrl . '/an/extra/trail)
+        [An awesome link with title](' . $bookUrl . ' "title")
+        [ref]: ' . $bookUrl . '?test=yes#dogs
+        [ref_without_space]:' . $bookUrl . '
+        [ref_with_title]: ' . $bookUrl . ' "title"';
+        $page->markdown = $markdown;
+        $page->save();
+        $this->createReference($page, $book);
+
+        $this->asEditor()->put($book->getUrl(), [
+            'name' => 'my updated book slugadoo',
+        ]);
+
+        $page->refresh();
+        $expected = str_replace($bookUrl, 'http://localhost/books/my-updated-book-slugadoo', $markdown);
+        $this->assertEquals($expected, $page->markdown);
+    }
+
+    protected function createReference(Model $from, Model $to)
+    {
+        (new Reference())->forceFill([
+            'from_type' => $from->getMorphClass(),
+            'from_id' => $from->id,
+            'to_type' => $to->getMorphClass(),
+            'to_id' => $to->id,
+        ])->save();
+    }
+
+}
\ No newline at end of file
similarity index 99%
rename from tests/RecycleBinTest.php
rename to tests/Settings/RecycleBinTest.php
index 0e05243380f52cd2838d15b8889aa5a420f06b32..465c1aaade5e8859d6e7c1b8da0fb18c541dd221 100644 (file)
@@ -1,6 +1,6 @@
 <?php
 
-namespace Tests;
+namespace Tests\Settings;
 
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
@@ -10,6 +10,7 @@ use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Models\Page;
 use Illuminate\Support\Carbon;
 use Illuminate\Support\Facades\DB;
+use Tests\TestCase;
 
 class RecycleBinTest extends TestCase
 {
diff --git a/tests/Settings/RegenerateReferencesTest.php b/tests/Settings/RegenerateReferencesTest.php
new file mode 100644 (file)
index 0000000..0f31220
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+
+namespace Tests\Settings;
+
+use BookStack\Actions\ActivityType;
+use BookStack\References\ReferenceStore;
+use Tests\TestCase;
+
+class RegenerateReferencesTest extends TestCase
+{
+    public function test_option_visible_on_maintenance_page()
+    {
+        $pageView = $this->asAdmin()->get('/settings/maintenance');
+        $formCssSelector = 'form[action$="/settings/maintenance/regenerate-references"]';
+        $html = $this->withHtml($pageView);
+        $html->assertElementExists('#regenerate-references');
+        $html->assertElementExists($formCssSelector);
+        $html->assertElementContains($formCssSelector . ' button', 'Regenerate References');
+    }
+
+    public function test_action_runs_reference_regen()
+    {
+        $this->mock(ReferenceStore::class)
+            ->shouldReceive('updateForAllPages')
+            ->once();
+
+        $resp = $this->asAdmin()->post('/settings/maintenance/regenerate-references');
+        $resp->assertRedirect('/settings/maintenance#regenerate-references');
+        $this->assertSessionHas('success', 'Reference index has been regenerated!');
+        $this->assertActivityExists(ActivityType::MAINTENANCE_ACTION_RUN, null, 'regenerate-references');
+    }
+
+    public function test_settings_manage_permission_required()
+    {
+        $editor = $this->getEditor();
+        $resp = $this->actingAs($editor)->post('/settings/maintenance/regenerate-references');
+        $this->assertPermissionError($resp);
+
+        $this->giveUserPermissions($editor, ['settings-manage']);
+
+        $resp = $this->actingAs($editor)->post('/settings/maintenance/regenerate-references');
+        $this->assertNotPermissionError($resp);
+    }
+
+    public function test_action_failed_shown_as_error_notification()
+    {
+        $this->mock(ReferenceStore::class)
+            ->shouldReceive('updateForAllPages')
+            ->andThrow(\Exception::class, 'A badger stopped the task');
+
+        $resp = $this->asAdmin()->post('/settings/maintenance/regenerate-references');
+        $resp->assertRedirect('/settings/maintenance#regenerate-references');
+        $this->assertSessionError('A badger stopped the task');
+    }
+}
similarity index 98%
rename from tests/TestEmailTest.php
rename to tests/Settings/TestEmailTest.php
index 97f98225d4fe8b82920a2eb4b5d1cfc14f17c9fe..31c51158f8b87645f8be30c2e6328f65048b5cce 100644 (file)
@@ -1,10 +1,11 @@
 <?php
 
-namespace Tests;
+namespace Tests\Settings;
 
 use BookStack\Notifications\TestEmail;
 use Illuminate\Contracts\Notifications\Dispatcher;
 use Illuminate\Support\Facades\Notification;
+use Tests\TestCase;
 
 class TestEmailTest extends TestCase
 {
index 92ae33a4ebeeef1729ea4934cb8d5e9882788aeb..3ca7638c8e92c8a4d6071e024fb282f1c4e0cfa9 100644 (file)
@@ -430,7 +430,7 @@ abstract class TestCase extends BaseTestCase
     }
 
     /**
-     * @return Entity[]
+     * @return array{page: Page, chapter: Chapter, book: Book, bookshelf: Bookshelf}
      */
     protected function getEachEntityType(): array
     {