mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-08-27 04:32:14 +00:00
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.
This commit is contained in:
parent
7d0724e288
commit
f30b937bb0
173
app/Entities/Tools/SearchResultsFormatter.php
Normal file
173
app/Entities/Tools/SearchResultsFormatter.php
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -4,8 +4,8 @@ namespace BookStack\Http\Controllers;
|
|||||||
|
|
||||||
use BookStack\Entities\Queries\Popular;
|
use BookStack\Entities\Queries\Popular;
|
||||||
use BookStack\Entities\Tools\SearchOptions;
|
use BookStack\Entities\Tools\SearchOptions;
|
||||||
|
use BookStack\Entities\Tools\SearchResultsFormatter;
|
||||||
use BookStack\Entities\Tools\SearchRunner;
|
use BookStack\Entities\Tools\SearchRunner;
|
||||||
use BookStack\Entities\Tools\ShelfContext;
|
|
||||||
use BookStack\Entities\Tools\SiblingFetcher;
|
use BookStack\Entities\Tools\SiblingFetcher;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
@ -14,18 +14,14 @@ class SearchController extends Controller
|
|||||||
protected $searchRunner;
|
protected $searchRunner;
|
||||||
protected $entityContextManager;
|
protected $entityContextManager;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(SearchRunner $searchRunner) {
|
||||||
SearchRunner $searchRunner,
|
|
||||||
ShelfContext $entityContextManager
|
|
||||||
) {
|
|
||||||
$this->searchRunner = $searchRunner;
|
$this->searchRunner = $searchRunner;
|
||||||
$this->entityContextManager = $entityContextManager;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches all entities.
|
* Searches all entities.
|
||||||
*/
|
*/
|
||||||
public function search(Request $request)
|
public function search(Request $request, SearchResultsFormatter $formatter)
|
||||||
{
|
{
|
||||||
$searchOpts = SearchOptions::fromRequest($request);
|
$searchOpts = SearchOptions::fromRequest($request);
|
||||||
$fullSearchString = $searchOpts->toString();
|
$fullSearchString = $searchOpts->toString();
|
||||||
@ -35,6 +31,7 @@ class SearchController extends Controller
|
|||||||
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
|
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page + 1));
|
||||||
|
|
||||||
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
|
$results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, 20);
|
||||||
|
$formatter->format($results['results']->all(), $searchOpts);
|
||||||
|
|
||||||
return view('search.all', [
|
return view('search.all', [
|
||||||
'entities' => $results['results'],
|
'entities' => $results['results'],
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p class="text-muted break-text">{{ $entity->getExcerpt() }}</p>
|
<p class="text-muted break-text">{{ $entity->preview_content ?? $entity->getExcerpt() }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(($showTags ?? false) && $entity->tags->count() > 0)
|
@if(($showTags ?? false) && $entity->tags->count() > 0)
|
||||||
|
Loading…
Reference in New Issue
Block a user