From 344b3a3615f1f39f92ebae9c791dccc423baf61d Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 16 Aug 2022 13:23:53 +0100 Subject: [PATCH 01/13] Added system to extract model references from HTML content For the start of a managed cross-linking system. --- app/Util/CrossLinking/CrossLinkParser.php | 103 ++++++++++++++++++ .../ModelResolvers/BookLinkModelResolver.php | 26 +++++ .../BookshelfLinkModelResolver.php | 26 +++++ .../ChapterLinkModelResolver.php | 27 +++++ .../ModelResolvers/CrossLinkModelResolver.php | 13 +++ .../ModelResolvers/PageLinkModelResolver.php | 27 +++++ .../PagePermalinkModelResolver.php | 25 +++++ tests/Util/CrossLinkParserTest.php | 41 +++++++ 8 files changed, 288 insertions(+) create mode 100644 app/Util/CrossLinking/CrossLinkParser.php create mode 100644 app/Util/CrossLinking/ModelResolvers/BookLinkModelResolver.php create mode 100644 app/Util/CrossLinking/ModelResolvers/BookshelfLinkModelResolver.php create mode 100644 app/Util/CrossLinking/ModelResolvers/ChapterLinkModelResolver.php create mode 100644 app/Util/CrossLinking/ModelResolvers/CrossLinkModelResolver.php create mode 100644 app/Util/CrossLinking/ModelResolvers/PageLinkModelResolver.php create mode 100644 app/Util/CrossLinking/ModelResolvers/PagePermalinkModelResolver.php create mode 100644 tests/Util/CrossLinkParserTest.php diff --git a/app/Util/CrossLinking/CrossLinkParser.php b/app/Util/CrossLinking/CrossLinkParser.php new file mode 100644 index 000000000..774024d52 --- /dev/null +++ b/app/Util/CrossLinking/CrossLinkParser.php @@ -0,0 +1,103 @@ +modelResolvers = $modelResolvers; + } + + /** + * Extract any found models within the given HTML content. + * + * @returns 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 static([ + new PagePermalinkModelResolver(), + new PageLinkModelResolver(), + new ChapterLinkModelResolver(), + new BookLinkModelResolver(), + new BookshelfLinkModelResolver(), + ]); + } + +} \ No newline at end of file diff --git a/app/Util/CrossLinking/ModelResolvers/BookLinkModelResolver.php b/app/Util/CrossLinking/ModelResolvers/BookLinkModelResolver.php new file mode 100644 index 000000000..f2ee284cd --- /dev/null +++ b/app/Util/CrossLinking/ModelResolvers/BookLinkModelResolver.php @@ -0,0 +1,26 @@ +where('slug', '=', $bookSlug)->first(); + + return $model; + } +} \ No newline at end of file diff --git a/app/Util/CrossLinking/ModelResolvers/BookshelfLinkModelResolver.php b/app/Util/CrossLinking/ModelResolvers/BookshelfLinkModelResolver.php new file mode 100644 index 000000000..53cb89e3f --- /dev/null +++ b/app/Util/CrossLinking/ModelResolvers/BookshelfLinkModelResolver.php @@ -0,0 +1,26 @@ +where('slug', '=', $shelfSlug)->first(); + + return $model; + } +} \ No newline at end of file diff --git a/app/Util/CrossLinking/ModelResolvers/ChapterLinkModelResolver.php b/app/Util/CrossLinking/ModelResolvers/ChapterLinkModelResolver.php new file mode 100644 index 000000000..55afd183c --- /dev/null +++ b/app/Util/CrossLinking/ModelResolvers/ChapterLinkModelResolver.php @@ -0,0 +1,27 @@ +whereSlugs($bookSlug, $chapterSlug)->first(); + + return $model; + } +} \ No newline at end of file diff --git a/app/Util/CrossLinking/ModelResolvers/CrossLinkModelResolver.php b/app/Util/CrossLinking/ModelResolvers/CrossLinkModelResolver.php new file mode 100644 index 000000000..073764c66 --- /dev/null +++ b/app/Util/CrossLinking/ModelResolvers/CrossLinkModelResolver.php @@ -0,0 +1,13 @@ +whereSlugs($bookSlug, $pageSlug)->first(); + + return $model; + } +} \ No newline at end of file diff --git a/app/Util/CrossLinking/ModelResolvers/PagePermalinkModelResolver.php b/app/Util/CrossLinking/ModelResolvers/PagePermalinkModelResolver.php new file mode 100644 index 000000000..9b31f5013 --- /dev/null +++ b/app/Util/CrossLinking/ModelResolvers/PagePermalinkModelResolver.php @@ -0,0 +1,25 @@ +find($id); + + return $model; + } +} \ No newline at end of file diff --git a/tests/Util/CrossLinkParserTest.php b/tests/Util/CrossLinkParserTest.php new file mode 100644 index 000000000..f8ad59db2 --- /dev/null +++ b/tests/Util/CrossLinkParserTest.php @@ -0,0 +1,41 @@ +getEachEntityType(); + $otherPage = Page::query()->where('id', '!=', $entities['page']->id)->first(); + + $html = ' +Page Permalink +Page Link +Chapter Link +Book Link +Shelf Link +Settings Link + '; + + $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); + } +} From 5d29d0cc7bb47cad7a662f1e6afc10cb97fe3ddd Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Aug 2022 14:39:53 +0100 Subject: [PATCH 02/13] Added reference storage system, and command to re-index Also re-named/orgranized some files for this, to make them "References" specific instead of a subset of "Util". --- .../Commands/RegenerateCommentContent.php | 8 ++- .../Commands/RegeneratePermissions.php | 1 + app/Console/Commands/RegenerateReferences.php | 58 +++++++++++++++ app/Entities/Models/Entity.php | 17 +++++ .../CrossLinkParser.php | 16 ++--- .../ModelResolvers/BookLinkModelResolver.php | 4 +- .../BookshelfLinkModelResolver.php | 4 +- .../ChapterLinkModelResolver.php | 4 +- .../ModelResolvers/CrossLinkModelResolver.php | 2 +- .../ModelResolvers/PageLinkModelResolver.php | 4 +- .../PagePermalinkModelResolver.php | 2 +- app/References/Reference.php | 26 +++++++ app/References/ReferenceService.php | 71 +++++++++++++++++++ ...2_08_17_092941_create_references_table.php | 34 +++++++++ .../CrossLinkParserTest.php | 25 ++++++- 15 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 app/Console/Commands/RegenerateReferences.php rename app/{Util/CrossLinking => References}/CrossLinkParser.php (82%) rename app/{Util/CrossLinking => References}/ModelResolvers/BookLinkModelResolver.php (87%) rename app/{Util/CrossLinking => References}/ModelResolvers/BookshelfLinkModelResolver.php (88%) rename app/{Util/CrossLinking => References}/ModelResolvers/ChapterLinkModelResolver.php (85%) rename app/{Util/CrossLinking => References}/ModelResolvers/CrossLinkModelResolver.php (77%) rename app/{Util/CrossLinking => References}/ModelResolvers/PageLinkModelResolver.php (85%) rename app/{Util/CrossLinking => References}/ModelResolvers/PagePermalinkModelResolver.php (90%) create mode 100644 app/References/Reference.php create mode 100644 app/References/ReferenceService.php create mode 100644 database/migrations/2022_08_17_092941_create_references_table.php rename tests/{Util => References}/CrossLinkParserTest.php (67%) diff --git a/app/Console/Commands/RegenerateCommentContent.php b/app/Console/Commands/RegenerateCommentContent.php index 587a5edb3..9da48fb0e 100644 --- a/app/Console/Commands/RegenerateCommentContent.php +++ b/app/Console/Commands/RegenerateCommentContent.php @@ -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; } } diff --git a/app/Console/Commands/RegeneratePermissions.php b/app/Console/Commands/RegeneratePermissions.php index 3396a445f..74f96fd42 100644 --- a/app/Console/Commands/RegeneratePermissions.php +++ b/app/Console/Commands/RegeneratePermissions.php @@ -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 index 000000000..93450c5ea --- /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/Entity.php b/app/Entities/Models/Entity.php index ffb9b9c7d..26a52073e 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -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'. diff --git a/app/Util/CrossLinking/CrossLinkParser.php b/app/References/CrossLinkParser.php similarity index 82% rename from app/Util/CrossLinking/CrossLinkParser.php rename to app/References/CrossLinkParser.php index 774024d52..22925884a 100644 --- a/app/Util/CrossLinking/CrossLinkParser.php +++ b/app/References/CrossLinkParser.php @@ -1,14 +1,14 @@ morphTo('from'); + } + + public function to(): MorphTo + { + return $this->morphTo('to'); + } +} diff --git a/app/References/ReferenceService.php b/app/References/ReferenceService.php new file mode 100644 index 000000000..7a1cf2fed --- /dev/null +++ b/app/References/ReferenceService.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()) + ->truncate(); + + 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/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 000000000..443bce551 --- /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/tests/Util/CrossLinkParserTest.php b/tests/References/CrossLinkParserTest.php similarity index 67% rename from tests/Util/CrossLinkParserTest.php rename to tests/References/CrossLinkParserTest.php index f8ad59db2..42d78cb0a 100644 --- a/tests/Util/CrossLinkParserTest.php +++ b/tests/References/CrossLinkParserTest.php @@ -1,9 +1,10 @@ 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 = ' +Page Link +Book Link + '; + + $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); + } } From 3290ab3ac939cfdbc374163bd34566f93d9d8df6 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Aug 2022 16:59:23 +0100 Subject: [PATCH 03/13] Added regenerate-references command test Also updated model resolvers to only fetch model ID, to prevent bringing back way more data from database than desired. --- .../ModelResolvers/BookLinkModelResolver.php | 2 +- .../BookshelfLinkModelResolver.php | 2 +- .../ChapterLinkModelResolver.php | 2 +- .../ModelResolvers/PageLinkModelResolver.php | 2 +- .../PagePermalinkModelResolver.php | 2 +- app/References/ReferenceService.php | 2 +- .../RegenerateReferencesCommandTest.php | 32 +++++++++++++++++++ 7 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 tests/Commands/RegenerateReferencesCommandTest.php diff --git a/app/References/ModelResolvers/BookLinkModelResolver.php b/app/References/ModelResolvers/BookLinkModelResolver.php index f33d97b84..459b13644 100644 --- a/app/References/ModelResolvers/BookLinkModelResolver.php +++ b/app/References/ModelResolvers/BookLinkModelResolver.php @@ -19,7 +19,7 @@ class BookLinkModelResolver implements CrossLinkModelResolver $bookSlug = $matches[1]; /** @var ?Book $model */ - $model = Book::query()->where('slug', '=', $bookSlug)->first(); + $model = Book::query()->where('slug', '=', $bookSlug)->first(['id']); return $model; } diff --git a/app/References/ModelResolvers/BookshelfLinkModelResolver.php b/app/References/ModelResolvers/BookshelfLinkModelResolver.php index ca5b8ca5f..7d1636689 100644 --- a/app/References/ModelResolvers/BookshelfLinkModelResolver.php +++ b/app/References/ModelResolvers/BookshelfLinkModelResolver.php @@ -19,7 +19,7 @@ class BookshelfLinkModelResolver implements CrossLinkModelResolver $shelfSlug = $matches[1]; /** @var ?Bookshelf $model */ - $model = Bookshelf::query()->where('slug', '=', $shelfSlug)->first(); + $model = Bookshelf::query()->where('slug', '=', $shelfSlug)->first(['id']); return $model; } diff --git a/app/References/ModelResolvers/ChapterLinkModelResolver.php b/app/References/ModelResolvers/ChapterLinkModelResolver.php index e15dba258..fbe75c4f6 100644 --- a/app/References/ModelResolvers/ChapterLinkModelResolver.php +++ b/app/References/ModelResolvers/ChapterLinkModelResolver.php @@ -20,7 +20,7 @@ class ChapterLinkModelResolver implements CrossLinkModelResolver $chapterSlug = $matches[2]; /** @var ?Chapter $model */ - $model = Chapter::query()->whereSlugs($bookSlug, $chapterSlug)->first(); + $model = Chapter::query()->whereSlugs($bookSlug, $chapterSlug)->first(['id']); return $model; } diff --git a/app/References/ModelResolvers/PageLinkModelResolver.php b/app/References/ModelResolvers/PageLinkModelResolver.php index f22f2734b..ead17e0a9 100644 --- a/app/References/ModelResolvers/PageLinkModelResolver.php +++ b/app/References/ModelResolvers/PageLinkModelResolver.php @@ -20,7 +20,7 @@ class PageLinkModelResolver implements CrossLinkModelResolver $pageSlug = $matches[2]; /** @var ?Page $model */ - $model = Page::query()->whereSlugs($bookSlug, $pageSlug)->first(); + $model = Page::query()->whereSlugs($bookSlug, $pageSlug)->first(['id']); return $model; } diff --git a/app/References/ModelResolvers/PagePermalinkModelResolver.php b/app/References/ModelResolvers/PagePermalinkModelResolver.php index 45396d54a..d59d41925 100644 --- a/app/References/ModelResolvers/PagePermalinkModelResolver.php +++ b/app/References/ModelResolvers/PagePermalinkModelResolver.php @@ -18,7 +18,7 @@ class PagePermalinkModelResolver implements CrossLinkModelResolver $id = intval($matches[1]); /** @var ?Page $model */ - $model = Page::query()->find($id); + $model = Page::query()->find($id, ['id']); return $model; } diff --git a/app/References/ReferenceService.php b/app/References/ReferenceService.php index 7a1cf2fed..fd7f74ae1 100644 --- a/app/References/ReferenceService.php +++ b/app/References/ReferenceService.php @@ -23,7 +23,7 @@ class ReferenceService { Reference::query() ->where('from_type', '=', (new Page())->getMorphClass()) - ->truncate(); + ->delete(); Page::query()->select(['id', 'html'])->chunk(100, function(Collection $pages) { $this->updateForPages($pages->all()); diff --git a/tests/Commands/RegenerateReferencesCommandTest.php b/tests/Commands/RegenerateReferencesCommandTest.php new file mode 100644 index 000000000..8906474af --- /dev/null +++ b/tests/Commands/RegenerateReferencesCommandTest.php @@ -0,0 +1,32 @@ +first(); + $book = $page->book; + + $page->html = 'Book Link'; + $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(), + ]); + } +} From bbe504c559dc8b540ce4c56d23423bdf10d69e5a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Wed, 17 Aug 2022 17:37:27 +0100 Subject: [PATCH 04/13] Added reference handling on page actions Page update/create/restore/clone/delete. Added a couple of tests to cover a couple of those. --- app/Entities/Repos/PageRepo.php | 13 ++++-- app/Entities/Tools/TrashCan.php | 2 + app/References/Reference.php | 2 + tests/References/ReferencesTest.php | 67 +++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/References/ReferencesTest.php diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 60f1d1b01..09c664edc 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -16,20 +16,23 @@ use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; +use BookStack\References\ReferenceService; use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; class PageRepo { - protected $baseRepo; + protected BaseRepo $baseRepo; + protected ReferenceService $references; /** * PageRepo constructor. */ - public function __construct(BaseRepo $baseRepo) + public function __construct(BaseRepo $baseRepo, ReferenceService $references) { $this->baseRepo = $baseRepo; + $this->references = $references; } /** @@ -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(); @@ -170,6 +173,7 @@ class PageRepo $this->savePageRevision($draft, trans('entities.pages_initial_revision')); $draft->indexForSearch(); + $this->references->updateForPage($draft); $draft->refresh(); Activity::add(ActivityType::PAGE_CREATE, $draft); @@ -189,6 +193,7 @@ class PageRepo $this->updateTemplateStatusAndContentFromInput($page, $input); $this->baseRepo->update($page, $input); + $this->references->updateForPage($page); // Update with new details $page->revision_count++; @@ -332,6 +337,7 @@ class PageRepo $page->refreshSlug(); $page->save(); $page->indexForSearch(); + $this->references->updateForPage($page); $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); $this->savePageRevision($page, $summary); @@ -430,6 +436,7 @@ class PageRepo ->skip(intval($revisionLimit)) ->take(10) ->get(['id']); + if ($revisionsToDelete->count() > 0) { PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); } diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index abec2e2d5..7341a0328 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -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); diff --git a/app/References/Reference.php b/app/References/Reference.php index a2a7bda10..5a490b5b5 100644 --- a/app/References/Reference.php +++ b/app/References/Reference.php @@ -14,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; */ class Reference extends Model { + public $timestamps = false; + public function from(): MorphTo { return $this->morphTo('from'); diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php new file mode 100644 index 000000000..1285f5916 --- /dev/null +++ b/tests/References/ReferencesTest.php @@ -0,0 +1,67 @@ +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' => 'Testing' + ]); + + $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()]); + } + + 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 From d5465726e2ef5f0cf129943abd3701bbc643cb28 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 19 Aug 2022 13:14:43 +0100 Subject: [PATCH 05/13] Added inbound references listing for pages --- app/Http/Controllers/ReferenceController.php | 47 ++++++++++++++++++++ resources/icons/popular.svg | 1 - resources/icons/reference.svg | 3 ++ resources/lang/en/entities.php | 5 +++ resources/views/pages/references.blade.php | 34 ++++++++++++++ routes/web.php | 2 + 6 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/ReferenceController.php create mode 100644 resources/icons/reference.svg create mode 100644 resources/views/pages/references.blade.php diff --git a/app/Http/Controllers/ReferenceController.php b/app/Http/Controllers/ReferenceController.php new file mode 100644 index 000000000..bed2c5f30 --- /dev/null +++ b/app/Http/Controllers/ReferenceController.php @@ -0,0 +1,47 @@ +permissions = $permissions; + } + + /** + * Display the references to a given page. + */ + public function page(string $bookSlug, string $pageSlug) + { + /** @var Page $page */ + $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail(); + + $baseQuery = $page->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 view('pages.references', [ + 'page' => $page, + 'references' => $references, + ]); + } +} diff --git a/resources/icons/popular.svg b/resources/icons/popular.svg index ba1f918a5..2ac44f151 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 000000000..560ec5f37 --- /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 db1e8027b..a92b465b8 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -369,4 +369,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.', ]; diff --git a/resources/views/pages/references.blade.php b/resources/views/pages/references.blade.php new file mode 100644 index 000000000..3f35a1629 --- /dev/null +++ b/resources/views/pages/references.blade.php @@ -0,0 +1,34 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('entities.breadcrumbs', ['crumbs' => [ + $page->book, + $page->chapter, + $page, + $page->getUrl('/references') => [ + 'text' => trans('entities.references'), + 'icon' => 'reference', + ] + ]]) +
+ +
+

{{ trans('entities.references') }}

+

{{ trans('entities.references_to_desc') }}

+ + @if(count($references) > 0) +
+ @include('entities.list', ['entities' => $references->pluck('from'), 'showPath' => true]) +
+ @else +

{{ trans('entities.references_none') }}

+ @endif + +
+
+ +@stop diff --git a/routes/web.php b/routes/web.php index 00841365a..a16960283 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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; @@ -110,6 +111,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']); From d198332d3c7866d5eb807b9e07a4b95fd67b97c2 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 19 Aug 2022 22:40:44 +0100 Subject: [PATCH 06/13] Rolled out reference pages to all entities, added testing Including testing to check permissions applied to listed references. --- app/Http/Controllers/ReferenceController.php | 68 +++++++++++++++++-- resources/views/books/references.blade.php | 20 ++++++ resources/views/chapters/references.blade.php | 21 ++++++ resources/views/entities/references.blade.php | 13 ++++ resources/views/pages/references.blade.php | 14 +--- resources/views/shelves/references.blade.php | 20 ++++++ routes/web.php | 3 + tests/References/ReferencesTest.php | 40 +++++++++++ tests/TestCase.php | 2 +- 9 files changed, 182 insertions(+), 19 deletions(-) create mode 100644 resources/views/books/references.blade.php create mode 100644 resources/views/chapters/references.blade.php create mode 100644 resources/views/entities/references.blade.php create mode 100644 resources/views/shelves/references.blade.php diff --git a/app/Http/Controllers/ReferenceController.php b/app/Http/Controllers/ReferenceController.php index bed2c5f30..3af4feb06 100644 --- a/app/Http/Controllers/ReferenceController.php +++ b/app/Http/Controllers/ReferenceController.php @@ -3,7 +3,12 @@ namespace BookStack\Http\Controllers; use BookStack\Auth\Permissions\PermissionApplicator; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Bookshelf; +use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\Relation; class ReferenceController extends Controller @@ -23,8 +28,64 @@ class ReferenceController extends Controller { /** @var Page $page */ $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail(); + $references = $this->getEntityReferences($page); - $baseQuery = $page->referencesTo() + 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->getEntityReferences($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->getEntityReferences($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->getEntityReferences($shelf); + + return view('shelves.references', [ + 'shelf' => $shelf, + 'references' => $references, + ]); + } + + /** + * Query the references for the given entities. + * Loads the commonly required relations while taking permissions into account. + */ + protected function getEntityReferences(Entity $entity): Collection + { + $baseQuery = $entity->referencesTo() ->where('from_type', '=', (new Page())->getMorphClass()) ->with([ 'from' => fn(Relation $query) => $query->select(Page::$listAttributes), @@ -39,9 +100,6 @@ class ReferenceController extends Controller 'from_type' )->get(); - return view('pages.references', [ - 'page' => $page, - 'references' => $references, - ]); + return $references; } } diff --git a/resources/views/books/references.blade.php b/resources/views/books/references.blade.php new file mode 100644 index 000000000..2468ed111 --- /dev/null +++ b/resources/views/books/references.blade.php @@ -0,0 +1,20 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('entities.breadcrumbs', ['crumbs' => [ + $book, + $book->getUrl('/references') => [ + 'text' => trans('entities.references'), + 'icon' => 'reference', + ] + ]]) +
+ + @include('entities.references', ['references' => $references]) +
+ +@stop diff --git a/resources/views/chapters/references.blade.php b/resources/views/chapters/references.blade.php new file mode 100644 index 000000000..7241c2b55 --- /dev/null +++ b/resources/views/chapters/references.blade.php @@ -0,0 +1,21 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('entities.breadcrumbs', ['crumbs' => [ + $chapter->book, + $chapter, + $chapter->getUrl('/references') => [ + 'text' => trans('entities.references'), + 'icon' => 'reference', + ] + ]]) +
+ + @include('entities.references', ['references' => $references]) +
+ +@stop diff --git a/resources/views/entities/references.blade.php b/resources/views/entities/references.blade.php new file mode 100644 index 000000000..db9e167aa --- /dev/null +++ b/resources/views/entities/references.blade.php @@ -0,0 +1,13 @@ +
+

{{ trans('entities.references') }}

+

{{ trans('entities.references_to_desc') }}

+ + @if(count($references) > 0) +
+ @include('entities.list', ['entities' => $references->pluck('from'), 'showPath' => true]) +
+ @else +

{{ trans('entities.references_none') }}

+ @endif + +
\ No newline at end of file diff --git a/resources/views/pages/references.blade.php b/resources/views/pages/references.blade.php index 3f35a1629..42ae7076f 100644 --- a/resources/views/pages/references.blade.php +++ b/resources/views/pages/references.blade.php @@ -16,19 +16,7 @@ ]]) -
-

{{ trans('entities.references') }}

-

{{ trans('entities.references_to_desc') }}

- - @if(count($references) > 0) -
- @include('entities.list', ['entities' => $references->pluck('from'), 'showPath' => true]) -
- @else -

{{ trans('entities.references_none') }}

- @endif - -
+ @include('entities.references', ['references' => $references]) @stop diff --git a/resources/views/shelves/references.blade.php b/resources/views/shelves/references.blade.php new file mode 100644 index 000000000..7336c07af --- /dev/null +++ b/resources/views/shelves/references.blade.php @@ -0,0 +1,20 @@ +@extends('layouts.simple') + +@section('body') + +
+ +
+ @include('entities.breadcrumbs', ['crumbs' => [ + $shelf, + $shelf->getUrl('/references') => [ + 'text' => trans('entities.references'), + 'icon' => 'reference', + ] + ]]) +
+ + @include('entities.references', ['references' => $references]) +
+ +@stop diff --git a/routes/web.php b/routes/web.php index a16960283..dc46821cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -64,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']); @@ -86,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']); @@ -142,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']); diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index 1285f5916..20829b6b4 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -54,6 +54,46 @@ class ReferencesTest extends TestCase $this->assertDatabaseMissing('references', ['to_id' => $pageA->id, 'to_type' => $pageA->getMorphClass()]); } + 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'); + } + protected function createReference(Model $from, Model $to) { (new Reference())->forceFill([ diff --git a/tests/TestCase.php b/tests/TestCase.php index 92ae33a4e..3ca7638c8 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -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 { From f634b4ea5767e6d823088c512872f9114322f7d0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 20 Aug 2022 12:07:38 +0100 Subject: [PATCH 07/13] Added entity meta link to reference page Not totally happy with implementation as is requires extra service to be injected to core controllers, but does the job. Included test to cover. Updated some controller properties to be typed while there. --- app/Console/Commands/RegenerateReferences.php | 6 +- app/Entities/Repos/PageRepo.php | 6 +- app/Http/Controllers/BookController.php | 16 +++-- app/Http/Controllers/BookshelfController.php | 6 +- app/Http/Controllers/ChapterController.php | 26 ++++---- app/Http/Controllers/PageController.php | 6 +- app/Http/Controllers/ReferenceController.php | 44 +++---------- app/References/ReferenceFetcher.php | 62 +++++++++++++++++++ ...eferenceService.php => ReferenceStore.php} | 2 +- resources/lang/en/entities.php | 1 + resources/views/entities/meta.blade.php | 9 +++ tests/References/ReferencesTest.php | 22 +++++++ 12 files changed, 143 insertions(+), 63 deletions(-) create mode 100644 app/References/ReferenceFetcher.php rename app/References/{ReferenceService.php => ReferenceStore.php} (98%) diff --git a/app/Console/Commands/RegenerateReferences.php b/app/Console/Commands/RegenerateReferences.php index 93450c5ea..805db2207 100644 --- a/app/Console/Commands/RegenerateReferences.php +++ b/app/Console/Commands/RegenerateReferences.php @@ -2,7 +2,7 @@ namespace BookStack\Console\Commands; -use BookStack\References\ReferenceService; +use BookStack\References\ReferenceStore; use Illuminate\Console\Command; use Illuminate\Support\Facades\DB; @@ -22,14 +22,14 @@ class RegenerateReferences extends Command */ protected $description = 'Regenerate all the cross-item model reference index'; - protected ReferenceService $references; + protected ReferenceStore $references; /** * Create a new command instance. * * @return void */ - public function __construct(ReferenceService $references) + public function __construct(ReferenceStore $references) { $this->references = $references; parent::__construct(); diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 09c664edc..40d1e6e53 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -16,7 +16,7 @@ use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; -use BookStack\References\ReferenceService; +use BookStack\References\ReferenceStore; use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; @@ -24,12 +24,12 @@ use Illuminate\Pagination\LengthAwarePaginator; class PageRepo { protected BaseRepo $baseRepo; - protected ReferenceService $references; + protected ReferenceStore $references; /** * PageRepo constructor. */ - public function __construct(BaseRepo $baseRepo, ReferenceService $references) + public function __construct(BaseRepo $baseRepo, ReferenceStore $references) { $this->baseRepo = $baseRepo; $this->references = $references; diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index c5b6d0bf6..a041267bb 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -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), ]); } diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index ccbeb6484..2143b876a 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -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), ]); } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 60eb52380..735c760be 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -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), ]); } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 268dce057..748468b21 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -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 index 3af4feb06..07b143223 100644 --- a/app/Http/Controllers/ReferenceController.php +++ b/app/Http/Controllers/ReferenceController.php @@ -2,23 +2,19 @@ namespace BookStack\Http\Controllers; -use BookStack\Auth\Permissions\PermissionApplicator; use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; -use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; -use Illuminate\Database\Eloquent\Collection; -use Illuminate\Database\Eloquent\Relations\Relation; +use BookStack\References\ReferenceFetcher; class ReferenceController extends Controller { + protected ReferenceFetcher $referenceFetcher; - protected PermissionApplicator $permissions; - - public function __construct(PermissionApplicator $permissions) + public function __construct(ReferenceFetcher $referenceFetcher) { - $this->permissions = $permissions; + $this->referenceFetcher = $referenceFetcher; } /** @@ -28,7 +24,7 @@ class ReferenceController extends Controller { /** @var Page $page */ $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->firstOrFail(); - $references = $this->getEntityReferences($page); + $references = $this->referenceFetcher->getPageReferencesToEntity($page); return view('pages.references', [ 'page' => $page, @@ -43,7 +39,7 @@ class ReferenceController extends Controller { /** @var Chapter $chapter */ $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail(); - $references = $this->getEntityReferences($chapter); + $references = $this->referenceFetcher->getPageReferencesToEntity($chapter); return view('chapters.references', [ 'chapter' => $chapter, @@ -57,7 +53,7 @@ class ReferenceController extends Controller public function book(string $slug) { $book = Book::visible()->where('slug', '=', $slug)->firstOrFail(); - $references = $this->getEntityReferences($book); + $references = $this->referenceFetcher->getPageReferencesToEntity($book); return view('books.references', [ 'book' => $book, @@ -71,35 +67,11 @@ class ReferenceController extends Controller public function shelf(string $slug) { $shelf = Bookshelf::visible()->where('slug', '=', $slug)->firstOrFail(); - $references = $this->getEntityReferences($shelf); + $references = $this->referenceFetcher->getPageReferencesToEntity($shelf); return view('shelves.references', [ 'shelf' => $shelf, 'references' => $references, ]); } - - /** - * Query the references for the given entities. - * Loads the commonly required relations while taking permissions into account. - */ - protected function getEntityReferences(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; - } } diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php new file mode 100644 index 000000000..fef2744d7 --- /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/ReferenceService.php b/app/References/ReferenceStore.php similarity index 98% rename from app/References/ReferenceService.php rename to app/References/ReferenceStore.php index fd7f74ae1..f6e3c04a3 100644 --- a/app/References/ReferenceService.php +++ b/app/References/ReferenceStore.php @@ -5,7 +5,7 @@ namespace BookStack\References; use BookStack\Entities\Models\Page; use Illuminate\Database\Eloquent\Collection; -class ReferenceService +class ReferenceStore { /** diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index a92b465b8..527665f88 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -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', diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php index 83ff23762..ac91eeed3 100644 --- a/resources/views/entities/meta.blade.php +++ b/resources/views/entities/meta.blade.php @@ -59,4 +59,13 @@ {{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->diffForHumans()]) }} @endif + + @if($referenceCount ?? 0) + + @icon('reference') +
+ {!! trans_choice('entities.meta_reference_page_count', $referenceCount, ['count' => $referenceCount]) !!} +
+
+ @endif \ No newline at end of file diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index 20829b6b4..9ae226bb7 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -54,6 +54,28 @@ class ReferencesTest extends TestCase $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(); From 26ccb7b644d339da156f96ff53486a1ef500a61f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 20 Aug 2022 21:09:07 +0100 Subject: [PATCH 08/13] Started work on reference on-change-updates Refactored out revision-specific actions within PageRepo for organisition and re-use for cross-linking work. --- app/Entities/Repos/PageRepo.php | 112 +++-------------------- app/Entities/Repos/RevisionRepo.php | 131 +++++++++++++++++++++++++++ app/References/CrossLinkReplacer.php | 47 ++++++++++ 3 files changed, 189 insertions(+), 101 deletions(-) create mode 100644 app/Entities/Repos/RevisionRepo.php create mode 100644 app/References/CrossLinkReplacer.php diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 40d1e6e53..e491d6070 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -18,20 +18,21 @@ use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; use BookStack\References\ReferenceStore; use Exception; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; class PageRepo { protected BaseRepo $baseRepo; + protected RevisionRepo $revisionRepo; protected ReferenceStore $references; /** * PageRepo constructor. */ - public function __construct(BaseRepo $baseRepo, ReferenceStore $references) + public function __construct(BaseRepo $baseRepo, RevisionRepo $revisionRepo, ReferenceStore $references) { $this->baseRepo = $baseRepo; + $this->revisionRepo = $revisionRepo; $this->references = $references; } @@ -42,6 +43,7 @@ class PageRepo */ public function getById(int $id, array $relations = ['book']): Page { + /** @var Page $page */ $page = Page::visible()->with($relations)->find($id); if (!$page) { @@ -73,17 +75,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; } @@ -126,9 +118,7 @@ class PageRepo */ public function getUserDraft(Page $page): ?PageRevision { - $revision = $this->getUserDraftQuery($page)->first(); - - return $revision; + return $this->revisionRepo->getLatestDraftForCurrentUser($page); } /** @@ -171,7 +161,7 @@ class PageRepo $draft->refreshSlug(); $draft->save(); - $this->savePageRevision($draft, trans('entities.pages_initial_revision')); + $this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision')); $draft->indexForSearch(); $this->references->updateForPage($draft); $draft->refresh(); @@ -200,7 +190,7 @@ class PageRepo $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'] ?? ''); @@ -208,7 +198,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); @@ -244,32 +234,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. */ @@ -285,7 +249,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'])) { @@ -340,7 +304,7 @@ class PageRepo $this->references->updateForPage($page); $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); - $this->savePageRevision($page, $summary); + $this->revisionRepo->storeNewForPage($page, $summary); Activity::add(ActivityType::PAGE_RESTORE, $page); Activity::add(ActivityType::REVISION_RESTORE, $revision); @@ -399,49 +363,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. */ @@ -457,15 +378,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 index 000000000..76d1d8553 --- /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/References/CrossLinkReplacer.php b/app/References/CrossLinkReplacer.php new file mode 100644 index 000000000..5581fe33f --- /dev/null +++ b/app/References/CrossLinkReplacer.php @@ -0,0 +1,47 @@ +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 = '';// TODO - Update HTML content + $markdown = '';// TODO - Update markdown content + + $page->html = $html; + $page->markdown = $markdown; + $page->revision_count++; + $page->save(); + + $summary = ''; // TODO - Get default summary from translations + $this->revisionRepo->storeNewForPage($page, $summary); + } +} \ No newline at end of file From 0dbf08453fcb79efc9aae21f5fa55f4547083456 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 21 Aug 2022 11:29:34 +0100 Subject: [PATCH 09/13] Built out cross link replacer, not yet tested --- app/References/CrossLinkReplacer.php | 53 ++++++++++++++++++++++++++-- resources/lang/en/entities.php | 1 + 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/app/References/CrossLinkReplacer.php b/app/References/CrossLinkReplacer.php index 5581fe33f..2df87fc83 100644 --- a/app/References/CrossLinkReplacer.php +++ b/app/References/CrossLinkReplacer.php @@ -5,6 +5,8 @@ namespace BookStack\References; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\RevisionRepo; +use DOMDocument; +use DOMXPath; class CrossLinkReplacer { @@ -33,15 +35,60 @@ class CrossLinkReplacer protected function updateReferencesWithinPage(Page $page, string $oldLink, string $newLink) { $page = (clone $page)->refresh(); - $html = '';// TODO - Update HTML content - $markdown = '';// TODO - Update markdown content + $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 = ''; // TODO - Get default summary from translations + $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/resources/lang/en/entities.php b/resources/lang/en/entities.php index 527665f88..07d4b625d 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -249,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.', From b86ee6d252b301a94796043f333abdb6603c304c Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 21 Aug 2022 18:05:19 +0100 Subject: [PATCH 10/13] Rolled out reference link updating logic usage Added test to cover updating of content on reference url change --- app/Entities/Models/BookChild.php | 7 +++ app/Entities/Repos/BaseRepo.php | 11 +++- app/Entities/Repos/PageRepo.php | 33 +++++++---- ...sLinkReplacer.php => ReferenceUpdater.php} | 6 +- tests/References/ReferencesTest.php | 59 +++++++++++++++++++ 5 files changed, 101 insertions(+), 15 deletions(-) rename app/References/{CrossLinkReplacer.php => ReferenceUpdater.php} (95%) diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index e1ba0b6f7..3b1ac1bab 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 @@ 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 diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 39b901383..cfde7fe1c 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -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); + } } /** diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index e491d6070..c80cbdb14 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -17,6 +17,7 @@ use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\PermissionsException; use BookStack\Facades\Activity; use BookStack\References\ReferenceStore; +use BookStack\References\ReferenceUpdater; use Exception; use Illuminate\Pagination\LengthAwarePaginator; @@ -24,16 +25,23 @@ class PageRepo { protected BaseRepo $baseRepo; protected RevisionRepo $revisionRepo; - protected ReferenceStore $references; + protected ReferenceStore $referenceStore; + protected ReferenceUpdater $referenceUpdater; /** * PageRepo constructor. */ - public function __construct(BaseRepo $baseRepo, RevisionRepo $revisionRepo, ReferenceStore $references) + public function __construct( + BaseRepo $baseRepo, + RevisionRepo $revisionRepo, + ReferenceStore $referenceStore, + ReferenceUpdater $referenceUpdater + ) { $this->baseRepo = $baseRepo; $this->revisionRepo = $revisionRepo; - $this->references = $references; + $this->referenceStore = $referenceStore; + $this->referenceUpdater = $referenceUpdater; } /** @@ -127,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) { @@ -158,12 +166,10 @@ class PageRepo $draft->draft = false; $draft->revision_count = 1; $draft->priority = $this->getNewPriority($draft); - $draft->refreshSlug(); $draft->save(); $this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision')); - $draft->indexForSearch(); - $this->references->updateForPage($draft); + $this->referenceStore->updateForPage($draft); $draft->refresh(); Activity::add(ActivityType::PAGE_CREATE, $draft); @@ -183,7 +189,7 @@ class PageRepo $this->updateTemplateStatusAndContentFromInput($page, $input); $this->baseRepo->update($page, $input); - $this->references->updateForPage($page); + $this->referenceStore->updateForPage($page); // Update with new details $page->revision_count++; @@ -283,6 +289,7 @@ class PageRepo */ public function restoreRevision(Page $page, int $revisionId): Page { + $oldUrl = $page->getUrl(); $page->revision_count++; /** @var PageRevision $revision */ @@ -301,11 +308,15 @@ class PageRepo $page->refreshSlug(); $page->save(); $page->indexForSearch(); - $this->references->updateForPage($page); + $this->referenceStore->updateForPage($page); $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->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); diff --git a/app/References/CrossLinkReplacer.php b/app/References/ReferenceUpdater.php similarity index 95% rename from app/References/CrossLinkReplacer.php rename to app/References/ReferenceUpdater.php index 2df87fc83..15619bc31 100644 --- a/app/References/CrossLinkReplacer.php +++ b/app/References/ReferenceUpdater.php @@ -8,7 +8,7 @@ use BookStack\Entities\Repos\RevisionRepo; use DOMDocument; use DOMXPath; -class CrossLinkReplacer +class ReferenceUpdater { protected ReferenceFetcher $referenceFetcher; protected RevisionRepo $revisionRepo; @@ -53,10 +53,10 @@ class CrossLinkReplacer return $markdown; } - $commonLinkRegex = '/(\[.*?\]\()' . preg_quote($oldLink) . '(.*?\))/i'; + $commonLinkRegex = '/(\[.*?\]\()' . preg_quote($oldLink, '/') . '(.*?\))/i'; $markdown = preg_replace($commonLinkRegex, '$1' . $newLink . '$2', $markdown); - $referenceLinkRegex = '/(\[.*?\]:\s?)' . preg_quote($oldLink) . '(.*?)($|\s)/i'; + $referenceLinkRegex = '/(\[.*?\]:\s?)' . preg_quote($oldLink, '/') . '(.*?)($|\s)/i'; $markdown = preg_replace($referenceLinkRegex, '$1' . $newLink . '$2$3', $markdown); return $markdown; diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index 9ae226bb7..82cd16680 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -2,6 +2,7 @@ namespace Tests\References; +use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Tools\TrashCan; @@ -116,6 +117,64 @@ class ReferencesTest extends TestCase ->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 = 'Link'; + $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 d134639eca88830d6ce6a9ba816adc9e7bed4183 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 23 Aug 2022 16:31:34 +0100 Subject: [PATCH 11/13] Doubled default revision limit Due to potential increase of revision entries due to auto-changes. --- .env.example.complete | 2 +- app/Config/app.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index 45b1c7a86..7a0e3cf25 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -295,7 +295,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 53d399abe..e28ebe611 100644 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -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 From 6edf2c155dd24c4876fc15481ffd74bf3b2e4d05 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 29 Aug 2022 17:30:26 +0100 Subject: [PATCH 12/13] Added maintenance action to regenerate references --- .../Controllers/MaintenanceController.php | 21 ++++++- resources/lang/en/settings.php | 4 ++ .../views/settings/maintenance.blade.php | 21 ++++++- routes/web.php | 1 + tests/{ => Settings}/RecycleBinTest.php | 3 +- tests/Settings/RegenerateReferencesTest.php | 55 +++++++++++++++++++ tests/{ => Settings}/TestEmailTest.php | 3 +- 7 files changed, 103 insertions(+), 5 deletions(-) rename tests/{ => Settings}/RecycleBinTest.php (99%) create mode 100644 tests/Settings/RegenerateReferencesTest.php rename tests/{ => Settings}/TestEmailTest.php (98%) diff --git a/app/Http/Controllers/MaintenanceController.php b/app/Http/Controllers/MaintenanceController.php index f13266d7c..8bfefb7ac 100644 --- a/app/Http/Controllers/MaintenanceController.php +++ b/app/Http/Controllers/MaintenanceController.php @@ -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'); } } diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 3bfe70bc4..9dbd96c5a 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -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/settings/maintenance.blade.php b/resources/views/settings/maintenance.blade.php index a2a9ebc81..7ee966e00 100644 --- a/resources/views/settings/maintenance.blade.php +++ b/resources/views/settings/maintenance.blade.php @@ -25,9 +25,10 @@

{{ trans('settings.maint_image_cleanup') }}

-
+

{{ trans('settings.maint_image_cleanup_desc') }}

+

{{ trans('settings.maint_timeout_command_note') }}

@@ -55,7 +56,7 @@

{{ trans('settings.maint_send_test_email') }}

-
+

{{ trans('settings.maint_send_test_email_desc') }}

@@ -68,5 +69,21 @@
+
+

{{ trans('settings.maint_regen_references') }}

+
+
+

{{ trans('settings.maint_regen_references_desc') }}

+

{{ trans('settings.maint_timeout_command_note') }}

+
+
+ + {!! csrf_field() !!} + + +
+
+
+
@stop diff --git a/routes/web.php b/routes/web.php index dc46821cb..26d4b6f13 100644 --- a/routes/web.php +++ b/routes/web.php @@ -218,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/RecycleBinTest.php b/tests/Settings/RecycleBinTest.php similarity index 99% rename from tests/RecycleBinTest.php rename to tests/Settings/RecycleBinTest.php index 0e0524338..465c1aaad 100644 --- a/tests/RecycleBinTest.php +++ b/tests/Settings/RecycleBinTest.php @@ -1,6 +1,6 @@ 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'); + } +} diff --git a/tests/TestEmailTest.php b/tests/Settings/TestEmailTest.php similarity index 98% rename from tests/TestEmailTest.php rename to tests/Settings/TestEmailTest.php index 97f98225d..31c51158f 100644 --- a/tests/TestEmailTest.php +++ b/tests/Settings/TestEmailTest.php @@ -1,10 +1,11 @@ Date: Mon, 29 Aug 2022 17:39:50 +0100 Subject: [PATCH 13/13] Fixed phpstan wanring about usage of static --- app/References/CrossLinkParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/References/CrossLinkParser.php b/app/References/CrossLinkParser.php index 22925884a..1bf1c7d37 100644 --- a/app/References/CrossLinkParser.php +++ b/app/References/CrossLinkParser.php @@ -91,7 +91,7 @@ class CrossLinkParser */ public static function createWithEntityResolvers(): self { - return new static([ + return new self([ new PagePermalinkModelResolver(), new PageLinkModelResolver(), new ChapterLinkModelResolver(),