3 namespace BookStack\Entities\Tools;
5 use BookStack\Entities\Models\Entity;
6 use Illuminate\Support\HtmlString;
8 class SearchResultsFormatter
12 * For the given array of entities, Prepare the models to be shown in search result
13 * output. This sets a series of additional attributes.
14 * @param Entity[] $results
16 public function format(array $results, SearchOptions $options): void
18 foreach ($results as $result) {
19 $this->setSearchPreview($result, $options);
24 * Update the given entity model to set attributes used for previews of the item
25 * primarily within search result lists.
27 protected function setSearchPreview(Entity $entity, SearchOptions $options)
29 $textProperty = $entity->textField;
30 $textContent = $entity->$textProperty;
31 $terms = array_merge($options->exacts, $options->searches);
33 $matchRefs = $this->getMatchPositions($textContent, $terms);
34 $mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
35 $content = $this->formatTextUsingMatchPositions($mergedRefs, $textContent);
37 $entity->setAttribute('preview_content', new HtmlString($content));
41 * Get positions of the given terms within the given text.
42 * Is in the array format of [int $startIndex => int $endIndex] where the indexes
43 * are positions within the provided text.
45 * @return array<int, int>
47 protected function getMatchPositions(string $text, array $terms): array
50 $text = strtolower($text);
52 foreach ($terms as $term) {
54 $term = strtolower($term);
55 $pos = strpos($text, $term, $offset);
56 while ($pos !== false) {
57 $end = $pos + strlen($term);
58 $matchRefs[$pos] = $end;
60 $pos = strpos($text, $term, $offset);
68 * Sort the given match positions before merging them where they're
69 * adjacent or where they overlap.
71 * @param array<int, int> $matchPositions
72 * @return array<int, int>
74 protected function sortAndMergeMatchPositions(array $matchPositions): array
76 ksort($matchPositions);
81 foreach ($matchPositions as $start => $end) {
82 if ($start > $lastEnd) {
83 $mergedRefs[$start] = $end;
86 } else if ($end > $lastEnd) {
87 $mergedRefs[$lastStart] = $end;
96 * Format the given original text, returning a version where terms are highlighted within.
97 * Returned content is in HTML text format.
99 protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText): string
103 $maxEnd = strlen($originalText);
108 foreach ($matchPositions as $start => $end) {
109 // Get our outer text ranges for the added context we want to show upon the result.
110 $contextStart = max($start - $contextRange, 0, $lastEnd);
111 $contextEnd = min($end + $contextRange, $maxEnd);
113 // Adjust the start if we're going to be touching the previous match.
114 $startDiff = $start - $lastEnd;
115 if ($startDiff < 0) {
116 $contextStart = $start;
117 $content = substr($content, 0, strlen($content) + $startDiff);
120 // Add ellipsis between results
121 if ($contextStart !== 0 && $contextStart !== $start) {
125 // Add our content including the bolded matching text
126 $content .= e(substr($originalText, $contextStart, $start - $contextStart));
127 $content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
128 $content .= e(substr($originalText, $end, $contextEnd - $end));
130 // Update our last end position
131 $lastEnd = $contextEnd;
133 // Update the first start position if it's not already been set
134 if (is_null($firstStart)) {
135 $firstStart = $contextStart;
138 // Stop if we're near our target
139 if (strlen($content) >= $targetLength - 10) {
144 // Just copy out the content if we haven't moved along anywhere.
145 if ($lastEnd === 0) {
146 $content = e(substr($originalText, 0, $targetLength));
147 $lastEnd = $targetLength;
150 // Pad out the end if we're low
151 $remainder = $targetLength - strlen($content);
152 if ($remainder > 10) {
153 $content .= e(substr($originalText, $lastEnd, $remainder));
154 $lastEnd += $remainder;
157 // Pad out the start if we're still low
158 $remainder = $targetLength - strlen($content);
159 $firstStart = $firstStart ?: 0;
160 if ($remainder > 10 && $firstStart !== 0) {
161 $padStart = max(0, $firstStart - $remainder);
162 $content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
165 // Add ellipsis if we're not at the end
166 if ($lastEnd < $maxEnd) {