Added system to extract model references from HTML content
For the start of a managed cross-linking system.
This commit is contained in:
parent
837fd74bf6
commit
344b3a3615
8 changed files with 288 additions and 0 deletions
103
app/Util/CrossLinking/CrossLinkParser.php
Normal file
103
app/Util/CrossLinking/CrossLinkParser.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
41
tests/Util/CrossLinkParserTest.php
Normal file
41
tests/Util/CrossLinkParserTest.php
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue