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 @@ +<?php + +namespace BookStack\Console\Commands; + +use BookStack\References\ReferenceService; +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 ReferenceService $references; + + /** + * Create a new command instance. + * + * @return void + */ + public function __construct(ReferenceService $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; + } +} 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 @@ <?php -namespace BookStack\Util\CrossLinking; +namespace BookStack\References; use BookStack\Model; -use BookStack\Util\CrossLinking\ModelResolvers\BookLinkModelResolver; -use BookStack\Util\CrossLinking\ModelResolvers\BookshelfLinkModelResolver; -use BookStack\Util\CrossLinking\ModelResolvers\ChapterLinkModelResolver; -use BookStack\Util\CrossLinking\ModelResolvers\CrossLinkModelResolver; -use BookStack\Util\CrossLinking\ModelResolvers\PageLinkModelResolver; -use BookStack\Util\CrossLinking\ModelResolvers\PagePermalinkModelResolver; +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; @@ -27,7 +27,7 @@ class CrossLinkParser /** * Extract any found models within the given HTML content. * - * @returns Model[] + * @return Model[] */ public function extractLinkedModels(string $html): array { diff --git a/app/Util/CrossLinking/ModelResolvers/BookLinkModelResolver.php b/app/References/ModelResolvers/BookLinkModelResolver.php similarity index 88% rename from app/Util/CrossLinking/ModelResolvers/BookLinkModelResolver.php rename to app/References/ModelResolvers/BookLinkModelResolver.php index f2ee284cd..f33d97b84 100644 --- a/app/Util/CrossLinking/ModelResolvers/BookLinkModelResolver.php +++ b/app/References/ModelResolvers/BookLinkModelResolver.php @@ -1,6 +1,6 @@ <?php -namespace BookStack\Util\CrossLinking\ModelResolvers; +namespace BookStack\References\ModelResolvers; use BookStack\Entities\Models\Book; use BookStack\Model; @@ -9,7 +9,7 @@ class BookLinkModelResolver implements CrossLinkModelResolver { public function resolve(string $link): ?Model { - $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '[#?\/$]/'; + $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '([#?\/]|$)/'; $matches = []; $match = preg_match($pattern, $link, $matches); if (!$match) { diff --git a/app/Util/CrossLinking/ModelResolvers/BookshelfLinkModelResolver.php b/app/References/ModelResolvers/BookshelfLinkModelResolver.php similarity index 88% rename from app/Util/CrossLinking/ModelResolvers/BookshelfLinkModelResolver.php rename to app/References/ModelResolvers/BookshelfLinkModelResolver.php index 53cb89e3f..ca5b8ca5f 100644 --- a/app/Util/CrossLinking/ModelResolvers/BookshelfLinkModelResolver.php +++ b/app/References/ModelResolvers/BookshelfLinkModelResolver.php @@ -1,6 +1,6 @@ <?php -namespace BookStack\Util\CrossLinking\ModelResolvers; +namespace BookStack\References\ModelResolvers; use BookStack\Entities\Models\Bookshelf; use BookStack\Model; @@ -9,7 +9,7 @@ class BookshelfLinkModelResolver implements CrossLinkModelResolver { public function resolve(string $link): ?Model { - $pattern = '/^' . preg_quote(url('/shelves'), '/') . '\/([\w-]+)' . '[#?\/$]/'; + $pattern = '/^' . preg_quote(url('/shelves'), '/') . '\/([\w-]+)' . '([#?\/]|$)/'; $matches = []; $match = preg_match($pattern, $link, $matches); if (!$match) { diff --git a/app/Util/CrossLinking/ModelResolvers/ChapterLinkModelResolver.php b/app/References/ModelResolvers/ChapterLinkModelResolver.php similarity index 85% rename from app/Util/CrossLinking/ModelResolvers/ChapterLinkModelResolver.php rename to app/References/ModelResolvers/ChapterLinkModelResolver.php index 55afd183c..e15dba258 100644 --- a/app/Util/CrossLinking/ModelResolvers/ChapterLinkModelResolver.php +++ b/app/References/ModelResolvers/ChapterLinkModelResolver.php @@ -1,6 +1,6 @@ <?php -namespace BookStack\Util\CrossLinking\ModelResolvers; +namespace BookStack\References\ModelResolvers; use BookStack\Entities\Models\Chapter; use BookStack\Model; @@ -9,7 +9,7 @@ class ChapterLinkModelResolver implements CrossLinkModelResolver { public function resolve(string $link): ?Model { - $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/chapter\/' . '([\w-]+)' . '[#?\/$]/'; + $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/chapter\/' . '([\w-]+)' . '([#?\/]|$)/'; $matches = []; $match = preg_match($pattern, $link, $matches); if (!$match) { diff --git a/app/Util/CrossLinking/ModelResolvers/CrossLinkModelResolver.php b/app/References/ModelResolvers/CrossLinkModelResolver.php similarity index 77% rename from app/Util/CrossLinking/ModelResolvers/CrossLinkModelResolver.php rename to app/References/ModelResolvers/CrossLinkModelResolver.php index 073764c66..5cfd02060 100644 --- a/app/Util/CrossLinking/ModelResolvers/CrossLinkModelResolver.php +++ b/app/References/ModelResolvers/CrossLinkModelResolver.php @@ -1,6 +1,6 @@ <?php -namespace BookStack\Util\CrossLinking\ModelResolvers; +namespace BookStack\References\ModelResolvers; use BookStack\Model; diff --git a/app/Util/CrossLinking/ModelResolvers/PageLinkModelResolver.php b/app/References/ModelResolvers/PageLinkModelResolver.php similarity index 85% rename from app/Util/CrossLinking/ModelResolvers/PageLinkModelResolver.php rename to app/References/ModelResolvers/PageLinkModelResolver.php index a5fea978a..f22f2734b 100644 --- a/app/Util/CrossLinking/ModelResolvers/PageLinkModelResolver.php +++ b/app/References/ModelResolvers/PageLinkModelResolver.php @@ -1,6 +1,6 @@ <?php -namespace BookStack\Util\CrossLinking\ModelResolvers; +namespace BookStack\References\ModelResolvers; use BookStack\Entities\Models\Page; use BookStack\Model; @@ -9,7 +9,7 @@ class PageLinkModelResolver implements CrossLinkModelResolver { public function resolve(string $link): ?Model { - $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/page\/' . '([\w-]+)' . '[#?\/$]/'; + $pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/page\/' . '([\w-]+)' . '([#?\/]|$)/'; $matches = []; $match = preg_match($pattern, $link, $matches); if (!$match) { diff --git a/app/Util/CrossLinking/ModelResolvers/PagePermalinkModelResolver.php b/app/References/ModelResolvers/PagePermalinkModelResolver.php similarity index 90% rename from app/Util/CrossLinking/ModelResolvers/PagePermalinkModelResolver.php rename to app/References/ModelResolvers/PagePermalinkModelResolver.php index 9b31f5013..45396d54a 100644 --- a/app/Util/CrossLinking/ModelResolvers/PagePermalinkModelResolver.php +++ b/app/References/ModelResolvers/PagePermalinkModelResolver.php @@ -1,6 +1,6 @@ <?php -namespace BookStack\Util\CrossLinking\ModelResolvers; +namespace BookStack\References\ModelResolvers; use BookStack\Entities\Models\Page; use BookStack\Model; diff --git a/app/References/Reference.php b/app/References/Reference.php new file mode 100644 index 000000000..a2a7bda10 --- /dev/null +++ b/app/References/Reference.php @@ -0,0 +1,26 @@ +<?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 function from(): MorphTo + { + return $this->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 @@ +<?php + +namespace BookStack\References; + +use BookStack\Entities\Models\Page; +use Illuminate\Database\Eloquent\Collection; + +class ReferenceService +{ + + /** + * 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()) + ->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 @@ +<?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'); + } +} 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 @@ <?php -namespace Tests\Util; +namespace Tests\References; +use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Page; -use BookStack\Util\CrossLinking\CrossLinkParser; +use BookStack\References\CrossLinkParser; use Tests\TestCase; class CrossLinkParserTest extends TestCase @@ -38,4 +39,24 @@ class CrossLinkParserTest extends TestCase $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); + } }