Input WYSIWYG: Added reference store & fetch handling

For book, shelves and chapters.
Made much of the existing handling generic to entity types.
Added new MixedEntityListLoader to help load lists somewhat efficiently.
Only manually tested so far.
This commit is contained in:
Dan Brown 2023-12-18 16:23:40 +00:00
parent c622b785a9
commit 307fae39c4
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
21 changed files with 219 additions and 97 deletions

View File

@ -34,7 +34,7 @@ class RegenerateReferencesCommand extends Command
DB::setDefaultConnection($this->option('database')); DB::setDefaultConnection($this->option('database'));
} }
$references->updateForAllPages(); $references->updateForAll();
DB::setDefaultConnection($connection); DB::setDefaultConnection($connection);

View File

@ -138,7 +138,7 @@ class BookController extends Controller
'bookParentShelves' => $bookParentShelves, 'bookParentShelves' => $bookParentShelves,
'watchOptions' => new UserEntityWatchOptions(user(), $book), 'watchOptions' => new UserEntityWatchOptions(user(), $book),
'activity' => $activities->entityActivity($book, 20, 1), 'activity' => $activities->entityActivity($book, 20, 1),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($book), 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book),
]); ]);
} }

View File

@ -125,7 +125,7 @@ class BookshelfController extends Controller
'view' => $view, 'view' => $view,
'activity' => $activities->entityActivity($shelf, 20, 1), 'activity' => $activities->entityActivity($shelf, 20, 1),
'listOptions' => $listOptions, 'listOptions' => $listOptions,
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($shelf), 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($shelf),
]); ]);
} }

View File

@ -86,7 +86,7 @@ class ChapterController extends Controller
'pages' => $pages, 'pages' => $pages,
'next' => $nextPreviousLocator->getNext(), 'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(), 'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($chapter), 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($chapter),
]); ]);
} }

View File

@ -155,7 +155,7 @@ class PageController extends Controller
'watchOptions' => new UserEntityWatchOptions(user(), $page), 'watchOptions' => new UserEntityWatchOptions(user(), $page),
'next' => $nextPreviousLocator->getNext(), 'next' => $nextPreviousLocator->getNext(),
'previous' => $nextPreviousLocator->getPrevious(), 'previous' => $nextPreviousLocator->getPrevious(),
'referenceCount' => $this->referenceFetcher->getPageReferenceCountToEntity($page), 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($page),
]); ]);
} }

View File

@ -28,7 +28,7 @@ class Book extends Entity implements HasCoverImage
use HasFactory; use HasFactory;
use HasHtmlDescription; use HasHtmlDescription;
public $searchFactor = 1.2; public float $searchFactor = 1.2;
protected $fillable = ['name']; protected $fillable = ['name'];
protected $hidden = ['pivot', 'image_id', 'deleted_at']; protected $hidden = ['pivot', 'image_id', 'deleted_at'];

View File

@ -15,7 +15,7 @@ class Bookshelf extends Entity implements HasCoverImage
protected $table = 'bookshelves'; protected $table = 'bookshelves';
public $searchFactor = 1.2; public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'image_id']; protected $fillable = ['name', 'description', 'image_id'];

View File

@ -17,7 +17,7 @@ class Chapter extends BookChild
use HasFactory; use HasFactory;
use HasHtmlDescription; use HasHtmlDescription;
public $searchFactor = 1.2; public float $searchFactor = 1.2;
protected $fillable = ['name', 'description', 'priority']; protected $fillable = ['name', 'description', 'priority'];
protected $hidden = ['pivot', 'deleted_at']; protected $hidden = ['pivot', 'deleted_at'];

View File

@ -57,12 +57,17 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
/** /**
* @var string - Name of property where the main text content is found * @var string - Name of property where the main text content is found
*/ */
public $textField = 'description'; public string $textField = 'description';
/**
* @var string - Name of the property where the main HTML content is found
*/
public string $htmlField = 'description_html';
/** /**
* @var float - Multiplier for search indexing. * @var float - Multiplier for search indexing.
*/ */
public $searchFactor = 1.0; public float $searchFactor = 1.0;
/** /**
* Get the entities that are visible to the current user. * Get the entities that are visible to the current user.

View File

@ -37,7 +37,8 @@ class Page extends BookChild
protected $fillable = ['name', 'priority']; protected $fillable = ['name', 'priority'];
public $textField = 'text'; public string $textField = 'text';
public string $htmlField = 'html';
protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at']; protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at'];

View File

@ -7,6 +7,7 @@ use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage; use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\HasHtmlDescription; use BookStack\Entities\Models\HasHtmlDescription;
use BookStack\Exceptions\ImageUploadException; use BookStack\Exceptions\ImageUploadException;
use BookStack\References\ReferenceStore;
use BookStack\References\ReferenceUpdater; use BookStack\References\ReferenceUpdater;
use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageRepo;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
@ -16,7 +17,8 @@ class BaseRepo
public function __construct( public function __construct(
protected TagRepo $tagRepo, protected TagRepo $tagRepo,
protected ImageRepo $imageRepo, protected ImageRepo $imageRepo,
protected ReferenceUpdater $referenceUpdater protected ReferenceUpdater $referenceUpdater,
protected ReferenceStore $referenceStore,
) { ) {
} }
@ -42,6 +44,7 @@ class BaseRepo
$entity->refresh(); $entity->refresh();
$entity->rebuildPermissions(); $entity->rebuildPermissions();
$entity->indexForSearch(); $entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
} }
/** /**
@ -68,6 +71,7 @@ class BaseRepo
$entity->rebuildPermissions(); $entity->rebuildPermissions();
$entity->indexForSearch(); $entity->indexForSearch();
$this->referenceStore->updateForEntity($entity);
if ($oldUrl !== $entity->getUrl()) { if ($oldUrl !== $entity->getUrl()) {
$this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl); $this->referenceUpdater->updateEntityPageReferences($entity, $oldUrl);

View File

@ -162,7 +162,6 @@ class PageRepo
$this->baseRepo->update($draft, $input); $this->baseRepo->update($draft, $input);
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision')); $this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$this->referenceStore->updateForPage($draft);
$draft->refresh(); $draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft); Activity::add(ActivityType::PAGE_CREATE, $draft);
@ -182,7 +181,6 @@ class PageRepo
$this->updateTemplateStatusAndContentFromInput($page, $input); $this->updateTemplateStatusAndContentFromInput($page, $input);
$this->baseRepo->update($page, $input); $this->baseRepo->update($page, $input);
$this->referenceStore->updateForPage($page);
// Update with new details // Update with new details
$page->revision_count++; $page->revision_count++;
@ -301,7 +299,7 @@ class PageRepo
$page->refreshSlug(); $page->refreshSlug();
$page->save(); $page->save();
$page->indexForSearch(); $page->indexForSearch();
$this->referenceStore->updateForPage($page); $this->referenceStore->updateForEntity($page);
$summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]); $summary = trans('entities.pages_revision_restored_from', ['id' => strval($revisionId), 'summary' => $revision->summary]);
$this->revisionRepo->storeNewForPage($page, $summary); $this->revisionRepo->storeNewForPage($page, $summary);

View File

@ -0,0 +1,103 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\App\Model;
use BookStack\Entities\EntityProvider;
use Illuminate\Database\Eloquent\Relations\Relation;
class MixedEntityListLoader
{
protected array $listAttributes = [
'page' => ['id', 'name', 'slug', 'book_id', 'chapter_id', 'text', 'draft'],
'chapter' => ['id', 'name', 'slug', 'book_id', 'description'],
'book' => ['id', 'name', 'slug', 'description'],
'bookshelf' => ['id', 'name', 'slug', 'description'],
];
public function __construct(
protected EntityProvider $entityProvider
) {
}
/**
* Efficiently load in entities for listing onto the given list
* where entities are set as a relation via the given name.
* This will look for a model id and type via 'name_id' and 'name_type'.
* @param Model[] $relations
*/
public function loadIntoRelations(array $relations, string $relationName): void
{
$idsByType = [];
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
$id = $relation->getAttribute($relationName . '_id');
if (!isset($idsByType[$type])) {
$idsByType[$type] = [];
}
$idsByType[$type][] = $id;
}
$modelMap = $this->idsByTypeToModelMap($idsByType);
foreach ($relations as $relation) {
$type = $relation->getAttribute($relationName . '_type');
$id = $relation->getAttribute($relationName . '_id');
$related = $modelMap[$type][strval($id)] ?? null;
if ($related) {
$relation->setRelation($relationName, $related);
}
}
}
/**
* @param array<string, int[]> $idsByType
* @return array<string, array<int, Model>>
*/
protected function idsByTypeToModelMap(array $idsByType): array
{
$modelMap = [];
foreach ($idsByType as $type => $ids) {
if (!isset($this->listAttributes[$type])) {
continue;
}
$instance = $this->entityProvider->get($type);
$models = $instance->newQuery()
->select($this->listAttributes[$type])
->scopes('visible')
->whereIn('id', $ids)
->with($this->getRelationsToEagerLoad($type))
->get();
if (count($models) > 0) {
$modelMap[$type] = [];
}
foreach ($models as $model) {
$modelMap[$type][strval($model->id)] = $model;
}
}
return $modelMap;
}
protected function getRelationsToEagerLoad(string $type): array
{
$toLoad = [];
$loadVisible = fn (Relation $query) => $query->scopes('visible');
if ($type === 'chapter' || $type === 'page') {
$toLoad['book'] = $loadVisible;
}
if ($type === 'page') {
$toLoad['chapter'] = $loadVisible;
}
return $toLoad;
}
}

View File

@ -10,11 +10,9 @@ use BookStack\Http\Controller;
class ReferenceController extends Controller class ReferenceController extends Controller
{ {
protected ReferenceFetcher $referenceFetcher; public function __construct(
protected ReferenceFetcher $referenceFetcher
public function __construct(ReferenceFetcher $referenceFetcher) ) {
{
$this->referenceFetcher = $referenceFetcher;
} }
/** /**
@ -23,7 +21,7 @@ class ReferenceController extends Controller
public function page(string $bookSlug, string $pageSlug) public function page(string $bookSlug, string $pageSlug)
{ {
$page = Page::getBySlugs($bookSlug, $pageSlug); $page = Page::getBySlugs($bookSlug, $pageSlug);
$references = $this->referenceFetcher->getPageReferencesToEntity($page); $references = $this->referenceFetcher->getReferencesToEntity($page);
return view('pages.references', [ return view('pages.references', [
'page' => $page, 'page' => $page,
@ -37,7 +35,7 @@ class ReferenceController extends Controller
public function chapter(string $bookSlug, string $chapterSlug) public function chapter(string $bookSlug, string $chapterSlug)
{ {
$chapter = Chapter::getBySlugs($bookSlug, $chapterSlug); $chapter = Chapter::getBySlugs($bookSlug, $chapterSlug);
$references = $this->referenceFetcher->getPageReferencesToEntity($chapter); $references = $this->referenceFetcher->getReferencesToEntity($chapter);
return view('chapters.references', [ return view('chapters.references', [
'chapter' => $chapter, 'chapter' => $chapter,
@ -51,7 +49,7 @@ class ReferenceController extends Controller
public function book(string $slug) public function book(string $slug)
{ {
$book = Book::getBySlug($slug); $book = Book::getBySlug($slug);
$references = $this->referenceFetcher->getPageReferencesToEntity($book); $references = $this->referenceFetcher->getReferencesToEntity($book);
return view('books.references', [ return view('books.references', [
'book' => $book, 'book' => $book,
@ -65,7 +63,7 @@ class ReferenceController extends Controller
public function shelf(string $slug) public function shelf(string $slug)
{ {
$shelf = Bookshelf::getBySlug($slug); $shelf = Bookshelf::getBySlug($slug);
$references = $this->referenceFetcher->getPageReferencesToEntity($shelf); $references = $this->referenceFetcher->getReferencesToEntity($shelf);
return view('shelves.references', [ return view('shelves.references', [
'shelf' => $shelf, 'shelf' => $shelf,

View File

@ -3,65 +3,51 @@
namespace BookStack\References; namespace BookStack\References;
use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\Page; use BookStack\Entities\Tools\MixedEntityListLoader;
use BookStack\Permissions\PermissionApplicator; use BookStack\Permissions\PermissionApplicator;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\Relation;
class ReferenceFetcher class ReferenceFetcher
{ {
protected PermissionApplicator $permissions; public function __construct(
protected PermissionApplicator $permissions,
public function __construct(PermissionApplicator $permissions) protected MixedEntityListLoader $mixedEntityListLoader,
{ ) {
$this->permissions = $permissions;
} }
/** /**
* Query and return the page references pointing to the given entity. * Query and return the references pointing to the given entity.
* Loads the commonly required relations while taking permissions into account. * Loads the commonly required relations while taking permissions into account.
*/ */
public function getPageReferencesToEntity(Entity $entity): Collection public function getReferencesToEntity(Entity $entity): Collection
{ {
$baseQuery = $this->queryPageReferencesToEntity($entity) $references = $this->queryReferencesToEntity($entity)->get();
->with([ $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from');
'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; return $references;
} }
/** /**
* Returns the count of page references pointing to the given entity. * Returns the count of references pointing to the given entity.
* Takes permissions into account. * Takes permissions into account.
*/ */
public function getPageReferenceCountToEntity(Entity $entity): int public function getReferenceCountToEntity(Entity $entity): int
{ {
$count = $this->permissions->restrictEntityRelationQuery( return $this->queryReferencesToEntity($entity)->count();
$this->queryPageReferencesToEntity($entity), }
protected function queryReferencesToEntity(Entity $entity): Builder
{
$baseQuery = Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id);
return $this->permissions->restrictEntityRelationQuery(
$baseQuery,
'references', 'references',
'from_id', 'from_id',
'from_type' 'from_type'
)->count(); );
return $count;
}
protected function queryPageReferencesToEntity(Entity $entity): Builder
{
return Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id)
->where('from_type', '=', (new Page())->getMorphClass());
} }
} }

View File

@ -2,60 +2,62 @@
namespace BookStack\References; namespace BookStack\References;
use BookStack\Entities\Models\Page; use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class ReferenceStore class ReferenceStore
{ {
/** public function __construct(
* Update the outgoing references for the given page. protected EntityProvider $entityProvider
*/ ) {
public function updateForPage(Page $page): void
{
$this->updateForPages([$page]);
} }
/** /**
* Update the outgoing references for all pages in the system. * Update the outgoing references for the given entity.
*/ */
public function updateForAllPages(): void public function updateForEntity(Entity $entity): void
{ {
Reference::query() $this->updateForEntities([$entity]);
->where('from_type', '=', (new Page())->getMorphClass())
->delete();
Page::query()->select(['id', 'html'])->chunk(100, function (Collection $pages) {
$this->updateForPages($pages->all());
});
} }
/** /**
* Update the outgoing references for the pages in the given array. * Update the outgoing references for all entities in the system.
*/
public function updateForAll(): void
{
Reference::query()->delete();
foreach ($this->entityProvider->all() as $entity) {
$entity->newQuery()->select(['id', $entity->htmlField])->chunk(100, function (Collection $entities) {
$this->updateForEntities($entities->all());
});
}
}
/**
* Update the outgoing references for the entities in the given array.
* *
* @param Page[] $pages * @param Entity[] $entities
*/ */
protected function updateForPages(array $pages): void protected function updateForEntities(array $entities): void
{ {
if (count($pages) === 0) { if (count($entities) === 0) {
return; return;
} }
$parser = CrossLinkParser::createWithEntityResolvers(); $parser = CrossLinkParser::createWithEntityResolvers();
$references = []; $references = [];
$pageIds = array_map(fn (Page $page) => $page->id, $pages); $this->dropReferencesFromEntities($entities);
Reference::query()
->where('from_type', '=', $pages[0]->getMorphClass())
->whereIn('from_id', $pageIds)
->delete();
foreach ($pages as $page) { foreach ($entities as $entity) {
$models = $parser->extractLinkedModels($page->html); $models = $parser->extractLinkedModels($entity->getAttribute($entity->htmlField));
foreach ($models as $model) { foreach ($models as $model) {
$references[] = [ $references[] = [
'from_id' => $page->id, 'from_id' => $entity->id,
'from_type' => $page->getMorphClass(), 'from_type' => $entity->getMorphClass(),
'to_id' => $model->id, 'to_id' => $model->id,
'to_type' => $model->getMorphClass(), 'to_type' => $model->getMorphClass(),
]; ];
@ -66,4 +68,29 @@ class ReferenceStore
Reference::query()->insert($referenceDataChunk); Reference::query()->insert($referenceDataChunk);
} }
} }
/**
* Delete all the existing references originating from the given entities.
* @param Entity[] $entities
*/
protected function dropReferencesFromEntities(array $entities): void
{
$IdsByType = [];
foreach ($entities as $entity) {
$type = $entity->getMorphClass();
if (!isset($IdsByType[$type])) {
$IdsByType[$type] = [];
}
$IdsByType[$type][] = $entity->id;
}
foreach ($IdsByType as $type => $entityIds) {
Reference::query()
->where('from_type', '=', $type)
->whereIn('from_id', $entityIds)
->delete();
}
}
} }

View File

@ -35,7 +35,7 @@ class ReferenceUpdater
protected function getReferencesToUpdate(Entity $entity): array protected function getReferencesToUpdate(Entity $entity): array
{ {
/** @var Reference[] $references */ /** @var Reference[] $references */
$references = $this->referenceFetcher->getPageReferencesToEntity($entity)->values()->all(); $references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all();
if ($entity instanceof Book) { if ($entity instanceof Book) {
$pages = $entity->pages()->get(['id']); $pages = $entity->pages()->get(['id']);
@ -43,7 +43,7 @@ class ReferenceUpdater
$children = $pages->concat($chapters); $children = $pages->concat($chapters);
foreach ($children as $bookChild) { foreach ($children as $bookChild) {
/** @var Reference[] $childRefs */ /** @var Reference[] $childRefs */
$childRefs = $this->referenceFetcher->getPageReferencesToEntity($bookChild)->values()->all(); $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all();
array_push($references, ...$childRefs); array_push($references, ...$childRefs);
} }
} }

View File

@ -87,7 +87,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references'); $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'regenerate-references');
try { try {
$referenceStore->updateForAllPages(); $referenceStore->updateForAll();
$this->showSuccessNotification(trans('settings.maint_regen_references_success')); $this->showSuccessNotification(trans('settings.maint_regen_references_success'));
} catch (\Exception $exception) { } catch (\Exception $exception) {
$this->showErrorNotification($exception->getMessage()); $this->showErrorNotification($exception->getMessage());

View File

@ -23,7 +23,7 @@ return [
'meta_updated' => 'Updated :timeLength', 'meta_updated' => 'Updated :timeLength',
'meta_updated_name' => 'Updated :timeLength by :user', 'meta_updated_name' => 'Updated :timeLength by :user',
'meta_owned_name' => 'Owned by :user', 'meta_owned_name' => 'Owned by :user',
'meta_reference_page_count' => 'Referenced on :count page|Referenced on :count pages', 'meta_reference_count' => 'Referenced by :count item|Referenced by :count items',
'entity_select' => 'Entity Select', 'entity_select' => 'Entity Select',
'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item', 'entity_select_lack_permission' => 'You don\'t have the required permissions to select this item',
'images' => 'Images', 'images' => 'Images',
@ -409,7 +409,7 @@ return [
// References // References
'references' => 'References', 'references' => 'References',
'references_none' => 'There are no tracked references to this item.', '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.', 'references_to_desc' => 'Listed below is all the known content in the system that links to this item.',
// Watch Options // Watch Options
'watch' => 'Watch', 'watch' => 'Watch',

View File

@ -64,7 +64,7 @@
<a href="{{ $entity->getUrl('/references') }}" class="entity-meta-item"> <a href="{{ $entity->getUrl('/references') }}" class="entity-meta-item">
@icon('reference') @icon('reference')
<div> <div>
{!! trans_choice('entities.meta_reference_page_count', $referenceCount, ['count' => $referenceCount]) !!} {{ trans_choice('entities.meta_reference_count', $referenceCount, ['count' => $referenceCount]) }}
</div> </div>
</a> </a>
@endif @endif

View File

@ -28,7 +28,7 @@
</div> </div>
<div class="book-content"> <div class="book-content">
<p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p> <p class="text-muted">{!! $shelf->descriptionHtml() !!}</p>
@if(count($sortedVisibleShelfBooks) > 0) @if(count($sortedVisibleShelfBooks) > 0)
@if($view === 'list') @if($view === 'list')
<div class="entity-list"> <div class="entity-list">