]> BookStack Code Mirror - bookstack/commitdiff
Added search result preview text highlighting
authorDan Brown <redacted>
Fri, 12 Nov 2021 22:57:50 +0000 (22:57 +0000)
committerDan Brown <redacted>
Fri, 12 Nov 2021 22:57:50 +0000 (22:57 +0000)
Created a new class to manage formatting of content for search results.
Turned out to be quite a complex task. This only does the preview text
so far, not titles or tags.

Not yet tested.

app/Entities/Tools/SearchResultsFormatter.php [new file with mode: 0644]
app/Http/Controllers/SearchController.php
resources/views/entities/list-item.blade.php

diff --git a/app/Entities/Tools/SearchResultsFormatter.php b/app/Entities/Tools/SearchResultsFormatter.php
new file mode 100644 (file)
index 0000000..aaa5c12
--- /dev/null
@@ -0,0 +1,173 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\Models\Entity;
+use Illuminate\Support\HtmlString;
+
+class SearchResultsFormatter
+{
+
+    /**
+     * For the given array of entities, Prepare the models to be shown in search result
+     * output. This sets a series of additional attributes.
+     * @param Entity[] $results
+     */
+    public function format(array $results, SearchOptions $options): void
+    {
+        foreach ($results as $result) {
+            $this->setSearchPreview($result, $options);
+        }
+    }
+
+    /**
+     * Update the given entity model to set attributes used for previews of the item
+     * primarily within search result lists.
+     */
+    protected function setSearchPreview(Entity $entity, SearchOptions $options)
+    {
+        $textProperty = $entity->textField;
+        $textContent = $entity->$textProperty;
+        $terms = array_merge($options->exacts, $options->searches);
+
+        $matchRefs = $this->getMatchPositions($textContent, $terms);
+        $mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
+        $content = $this->formatTextUsingMatchPositions($mergedRefs, $textContent);
+
+        $entity->setAttribute('preview_content', new HtmlString($content));
+    }
+
+    /**
+     * Get positions of the given terms within the given text.
+     * Is in the array format of [int $startIndex => int $endIndex] where the indexes
+     * are positions within the provided text.
+     *
+     * @return array<int, int>
+     */
+    protected function getMatchPositions(string $text, array $terms): array
+    {
+        $matchRefs = [];
+        $text = strtolower($text);
+
+        foreach ($terms as $term) {
+            $offset = 0;
+            $term = strtolower($term);
+            $pos = strpos($text, $term, $offset);
+            while ($pos !== false) {
+                $end = $pos + strlen($term);
+                $matchRefs[$pos] = $end;
+                $offset = $end;
+                $pos = strpos($text, $term, $offset);
+            }
+        }
+
+        return $matchRefs;
+    }
+
+    /**
+     * Sort the given match positions before merging them where they're
+     * adjacent or where they overlap.
+     *
+     * @param array<int, int> $matchPositions
+     * @return array<int, int>
+     */
+    protected function sortAndMergeMatchPositions(array $matchPositions): array
+    {
+        ksort($matchPositions);
+        $mergedRefs = [];
+        $lastStart = 0;
+        $lastEnd = 0;
+
+        foreach ($matchPositions as $start => $end) {
+            if ($start > $lastEnd) {
+                $mergedRefs[$start] = $end;
+                $lastStart = $start;
+                $lastEnd = $end;
+            } else if ($end > $lastEnd) {
+                $mergedRefs[$lastStart] = $end;
+                $lastEnd = $end;
+            }
+        }
+
+        return $mergedRefs;
+    }
+
+    /**
+     * Format the given original text, returning a version where terms are highlighted within.
+     * Returned content is in HTML text format.
+     */
+    protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText): string
+    {
+        $contextRange = 32;
+        $targetLength = 260;
+        $maxEnd = strlen($originalText);
+        $lastEnd = 0;
+        $firstStart = null;
+        $content = '';
+
+        foreach ($matchPositions as $start => $end) {
+            // Get our outer text ranges for the added context we want to show upon the result.
+            $contextStart = max($start - $contextRange, 0, $lastEnd);
+            $contextEnd = min($end + $contextRange, $maxEnd);
+
+            // Adjust the start if we're going to be touching the previous match.
+            $startDiff = $start - $lastEnd;
+            if ($startDiff < 0) {
+                $contextStart = $start;
+                $content = substr($content, 0, strlen($content) + $startDiff);
+            }
+
+            // Add ellipsis between results
+            if ($contextStart !== 0 && $contextStart !== $start) {
+                $content .= ' ...';
+            }
+
+            // Add our content including the bolded matching text
+            $content .= e(substr($originalText, $contextStart, $start - $contextStart));
+            $content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
+            $content .= e(substr($originalText, $end, $contextEnd - $end));
+
+            // Update our last end position
+            $lastEnd = $contextEnd;
+
+            // Update the first start position if it's not already been set
+            if (is_null($firstStart)) {
+                $firstStart = $contextStart;
+            }
+
+            // Stop if we're near our target
+            if (strlen($content) >= $targetLength - 10) {
+                break;
+            }
+        }
+
+        // Just copy out the content if we haven't moved along anywhere.
+        if ($lastEnd === 0) {
+            $content = e(substr($originalText, 0, $targetLength));
+            $lastEnd = $targetLength;
+        }
+
+        // Pad out the end if we're low
+        $remainder = $targetLength - strlen($content);
+        if ($remainder > 10) {
+            $content .= e(substr($originalText, $lastEnd, $remainder));
+            $lastEnd += $remainder;
+        }
+
+        // Pad out the start if we're still low
+        $remainder = $targetLength - strlen($content);
+        $firstStart = $firstStart ?: 0;
+        if ($remainder > 10 && $firstStart !== 0) {
+            $padStart = max(0, $firstStart - $remainder);
+            $content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart,  $firstStart - $padStart)) . substr($content, 4);
+        }
+
+        // Add ellipsis if we're not at the end
+        if ($lastEnd < $maxEnd) {
+            $content .= '...';
+        }
+
+        return $content;
+    }
+
+}
\ No newline at end of file
index d12c23b5a2c4404cb665bec99c5160eeff4e700a..040c04ece0aa0125ac860ae43bfed6e174781070 100644 (file)
@@ -4,8 +4,8 @@ namespace BookStack\Http\Controllers;
 
 use BookStack\Entities\Queries\Popular;
 use BookStack\Entities\Tools\SearchOptions;
+use BookStack\Entities\Tools\SearchResultsFormatter;
 use BookStack\Entities\Tools\SearchRunner;
-use BookStack\Entities\Tools\ShelfContext;
 use BookStack\Entities\Tools\SiblingFetcher;
 use Illuminate\Http\Request;
 
@@ -14,18 +14,14 @@ class SearchController extends Controller
     protected $searchRunner;
     protected $entityContextManager;
 
-    public function __construct(
-        SearchRunner $searchRunner,
-        ShelfContext $entityContextManager
-    ) {
+    public function __construct(SearchRunner $searchRunner) {
         $this->searchRunner = $searchRunner;
-        $this->entityContextManager = $entityContextManager;
     }
 
     /**
      * Searches all entities.
      */
-    public function search(Request $request)
+    public function search(Request $request, SearchResultsFormatter $formatter)
     {
         $searchOpts = SearchOptions::fromRequest($request);
         $fullSearchString = $searchOpts->toString();
@@ -35,6 +31,7 @@ class SearchController extends Controller
         $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
 
         $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
+        $formatter->format($results['results']->all(), $searchOpts);
 
         return view('search.all', [
             'entities'     => $results['results'],
index c757b0691e3f4ab87a7110d5cf01f539c9ef92d1..aa4f6c1e8ec76507cd6808393d9fe04d6762ef32 100644 (file)
@@ -11,7 +11,7 @@
         @endif
     @endif
 
-    <p class="text-muted break-text">{{ $entity->getExcerpt() }}</p>
+    <p class="text-muted break-text">{{ $entity->preview_content ?? $entity->getExcerpt() }}</p>
 </div>
 
 @if(($showTags ?? false) && $entity->tags->count() > 0)