Added system to extract model references from HTML content

For the start of a managed cross-linking system.
This commit is contained in:
Dan Brown 2022-08-16 13:23:53 +01:00
parent 837fd74bf6
commit 344b3a3615
No known key found for this signature in database
GPG key ID: 46D9F943C24A2EF9
8 changed files with 288 additions and 0 deletions

View file

@ -0,0 +1,103 @@
<?php
namespace BookStack\Util\CrossLinking;
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 DOMDocument;
use DOMXPath;
class CrossLinkParser
{
/**
* @var CrossLinkModelResolver[]
*/
protected array $modelResolvers;
public function __construct(array $modelResolvers)
{
$this->modelResolvers = $modelResolvers;
}
/**
* Extract any found models within the given HTML content.
*
* @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 = '<body>' . $html . '</body>';
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$anchors = $xPath->query('//a[@href]');
/** @var \DOMElement $anchor */
foreach ($anchors as $anchor) {
$links[] = $anchor->getAttribute('href');
}
return $links;
}
/**
* Attempt to resolve the given link to a model using the instance model resolvers.
*/
protected function linkToModel(string $link): ?Model
{
foreach ($this->modelResolvers as $resolver) {
$model = $resolver->resolve($link);
if (!is_null($model)) {
return $model;
}
}
return null;
}
/**
* Create a new instance with a pre-defined set of model resolvers, specifically for the
* default set of entities within BookStack.
*/
public static function createWithEntityResolvers(): self
{
return new static([
new PagePermalinkModelResolver(),
new PageLinkModelResolver(),
new ChapterLinkModelResolver(),
new BookLinkModelResolver(),
new BookshelfLinkModelResolver(),
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace BookStack\Util\CrossLinking\ModelResolvers;
use BookStack\Entities\Models\Book;
use BookStack\Model;
class BookLinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '[#?\/$]/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$bookSlug = $matches[1];
/** @var ?Book $model */
$model = Book::query()->where('slug', '=', $bookSlug)->first();
return $model;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace BookStack\Util\CrossLinking\ModelResolvers;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Model;
class BookshelfLinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/shelves'), '/') . '\/([\w-]+)' . '[#?\/$]/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$shelfSlug = $matches[1];
/** @var ?Bookshelf $model */
$model = Bookshelf::query()->where('slug', '=', $shelfSlug)->first();
return $model;
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace BookStack\Util\CrossLinking\ModelResolvers;
use BookStack\Entities\Models\Chapter;
use BookStack\Model;
class ChapterLinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/chapter\/' . '([\w-]+)' . '[#?\/$]/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$bookSlug = $matches[1];
$chapterSlug = $matches[2];
/** @var ?Chapter $model */
$model = Chapter::query()->whereSlugs($bookSlug, $chapterSlug)->first();
return $model;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace BookStack\Util\CrossLinking\ModelResolvers;
use BookStack\Model;
interface CrossLinkModelResolver
{
/**
* Resolve the given href link value to a model.
*/
public function resolve(string $link): ?Model;
}

View file

@ -0,0 +1,27 @@
<?php
namespace BookStack\Util\CrossLinking\ModelResolvers;
use BookStack\Entities\Models\Page;
use BookStack\Model;
class PageLinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/books'), '/') . '\/([\w-]+)' . '\/page\/' . '([\w-]+)' . '[#?\/$]/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$bookSlug = $matches[1];
$pageSlug = $matches[2];
/** @var ?Page $model */
$model = Page::query()->whereSlugs($bookSlug, $pageSlug)->first();
return $model;
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace BookStack\Util\CrossLinking\ModelResolvers;
use BookStack\Entities\Models\Page;
use BookStack\Model;
class PagePermalinkModelResolver implements CrossLinkModelResolver
{
public function resolve(string $link): ?Model
{
$pattern = '/^' . preg_quote(url('/link'), '/') . '\/(\d+)/';
$matches = [];
$match = preg_match($pattern, $link, $matches);
if (!$match) {
return null;
}
$id = intval($matches[1]);
/** @var ?Page $model */
$model = Page::query()->find($id);
return $model;
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace Tests\Util;
use BookStack\Entities\Models\Page;
use BookStack\Util\CrossLinking\CrossLinkParser;
use Tests\TestCase;
class CrossLinkParserTest extends TestCase
{
public function test_instance_with_entity_resolvers_matches_entity_links()
{
$entities = $this->getEachEntityType();
$otherPage = Page::query()->where('id', '!=', $entities['page']->id)->first();
$html = '
<a href="' . url('/link/' . $otherPage->id) . '#cat">Page Permalink</a>
<a href="' . $entities['page'] ->getUrl(). '?a=b">Page Link</a>
<a href="' . $entities['chapter']->getUrl() . '?cat=mouse#donkey">Chapter Link</a>
<a href="' . $entities['book']->getUrl() . '/edit">Book Link</a>
<a href="' . $entities['bookshelf']->getUrl() . '/edit?cat=happy#hello">Shelf Link</a>
<a href="' . url('/settings') . '">Settings Link</a>
';
$parser = CrossLinkParser::createWithEntityResolvers();
$results = $parser->extractLinkedModels($html);
$this->assertCount(5, $results);
$this->assertEquals(get_class($otherPage), get_class($results[0]));
$this->assertEquals($otherPage->id, $results[0]->id);
$this->assertEquals(get_class($entities['page']), get_class($results[1]));
$this->assertEquals($entities['page']->id, $results[1]->id);
$this->assertEquals(get_class($entities['chapter']), get_class($results[2]));
$this->assertEquals($entities['chapter']->id, $results[2]->id);
$this->assertEquals(get_class($entities['book']), get_class($results[3]));
$this->assertEquals($entities['book']->id, $results[3]->id);
$this->assertEquals(get_class($entities['bookshelf']), get_class($results[4]));
$this->assertEquals($entities['bookshelf']->id, $results[4]->id);
}
}