]> BookStack Code Mirror - bookstack/commitdiff
Added system to extract model references from HTML content
authorDan Brown <redacted>
Tue, 16 Aug 2022 12:23:53 +0000 (13:23 +0100)
committerDan Brown <redacted>
Tue, 16 Aug 2022 12:23:53 +0000 (13:23 +0100)
For the start of a managed cross-linking system.

app/Util/CrossLinking/CrossLinkParser.php [new file with mode: 0644]
app/Util/CrossLinking/ModelResolvers/BookLinkModelResolver.php [new file with mode: 0644]
app/Util/CrossLinking/ModelResolvers/BookshelfLinkModelResolver.php [new file with mode: 0644]
app/Util/CrossLinking/ModelResolvers/ChapterLinkModelResolver.php [new file with mode: 0644]
app/Util/CrossLinking/ModelResolvers/CrossLinkModelResolver.php [new file with mode: 0644]
app/Util/CrossLinking/ModelResolvers/PageLinkModelResolver.php [new file with mode: 0644]
app/Util/CrossLinking/ModelResolvers/PagePermalinkModelResolver.php [new file with mode: 0644]
tests/Util/CrossLinkParserTest.php [new file with mode: 0644]

diff --git a/app/Util/CrossLinking/CrossLinkParser.php b/app/Util/CrossLinking/CrossLinkParser.php
new file mode 100644 (file)
index 0000000..774024d
--- /dev/null
@@ -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(),
+        ]);
+    }
+
+}
\ 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 (file)
index 0000000..f2ee284
--- /dev/null
@@ -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;
+    }
+}
\ 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 (file)
index 0000000..53cb89e
--- /dev/null
@@ -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;
+    }
+}
\ 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 (file)
index 0000000..55afd18
--- /dev/null
@@ -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;
+    }
+}
\ 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 (file)
index 0000000..073764c
--- /dev/null
@@ -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;
+}
\ No newline at end of file
diff --git a/app/Util/CrossLinking/ModelResolvers/PageLinkModelResolver.php b/app/Util/CrossLinking/ModelResolvers/PageLinkModelResolver.php
new file mode 100644 (file)
index 0000000..a5fea97
--- /dev/null
@@ -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;
+    }
+}
\ 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 (file)
index 0000000..9b31f50
--- /dev/null
@@ -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;
+    }
+}
\ No newline at end of file
diff --git a/tests/Util/CrossLinkParserTest.php b/tests/Util/CrossLinkParserTest.php
new file mode 100644 (file)
index 0000000..f8ad59d
--- /dev/null
@@ -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);
+    }
+}