From f30b937bb02eea92c078ea9644e3b70bd63974d8 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 12 Nov 2021 22:57:50 +0000 Subject: [PATCH] Added search result preview text highlighting 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 | 173 ++++++++++++++++++ app/Http/Controllers/SearchController.php | 11 +- resources/views/entities/list-item.blade.php | 2 +- 3 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 app/Entities/Tools/SearchResultsFormatter.php diff --git a/app/Entities/Tools/SearchResultsFormatter.php b/app/Entities/Tools/SearchResultsFormatter.php new file mode 100644 index 000000000..aaa5c129d --- /dev/null +++ b/app/Entities/Tools/SearchResultsFormatter.php @@ -0,0 +1,173 @@ +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 + */ + 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 $matchPositions + * @return array + */ + 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 .= '' . e(substr($originalText, $start, $end - $start)) . ''; + $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 diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index d12c23b5a..040c04ece 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -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'], diff --git a/resources/views/entities/list-item.blade.php b/resources/views/entities/list-item.blade.php index c757b0691..aa4f6c1e8 100644 --- a/resources/views/entities/list-item.blade.php +++ b/resources/views/entities/list-item.blade.php @@ -11,7 +11,7 @@ @endif @endif -

{{ $entity->getExcerpt() }}

+

{{ $entity->preview_content ?? $entity->getExcerpt() }}

@if(($showTags ?? false) && $entity->tags->count() > 0)