3 namespace BookStack\Entities\Tools;
5 use BookStack\Actions\Tag;
6 use BookStack\Entities\Models\Entity;
7 use Illuminate\Support\HtmlString;
9 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.
15 * @param Entity[] $results
17 public function format(array $results, SearchOptions $options): void
19 foreach ($results as $result) {
20 $this->setSearchPreview($result, $options);
25 * Update the given entity model to set attributes used for previews of the item
26 * primarily within search result lists.
28 protected function setSearchPreview(Entity $entity, SearchOptions $options)
30 $textProperty = $entity->textField;
31 $textContent = $entity->$textProperty;
32 $terms = array_merge($options->exacts, $options->searches);
34 $originalContentByNewAttribute = [
35 'preview_name' => $entity->name,
36 'preview_content' => $textContent,
39 foreach ($originalContentByNewAttribute as $attributeName => $content) {
40 $matchRefs = $this->getMatchPositions($content, $terms);
41 $mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
42 $formatted = $this->formatTextUsingMatchPositions($mergedRefs, $content);
43 $entity->setAttribute($attributeName, new HtmlString($formatted));
46 $tags = $entity->relationLoaded('tags') ? $entity->tags->all() : [];
47 $this->highlightTagsContainingTerms($tags, $terms);
51 * Highlight tags which match the given terms.
54 * @param string[] $terms
56 protected function highlightTagsContainingTerms(array $tags, array $terms): void
58 foreach ($tags as $tag) {
59 $tagName = strtolower($tag->name);
60 $tagValue = strtolower($tag->value);
62 foreach ($terms as $term) {
63 $termLower = strtolower($term);
65 if (strpos($tagName, $termLower) !== false) {
66 $tag->setAttribute('highlight_name', true);
69 if (strpos($tagValue, $termLower) !== false) {
70 $tag->setAttribute('highlight_value', true);
77 * Get positions of the given terms within the given text.
78 * Is in the array format of [int $startIndex => int $endIndex] where the indexes
79 * are positions within the provided text.
81 * @return array<int, int>
83 protected function getMatchPositions(string $text, array $terms): array
86 $text = strtolower($text);
88 foreach ($terms as $term) {
90 $term = strtolower($term);
91 $pos = strpos($text, $term, $offset);
92 while ($pos !== false) {
93 $end = $pos + strlen($term);
94 $matchRefs[$pos] = $end;
96 $pos = strpos($text, $term, $offset);
104 * Sort the given match positions before merging them where they're
105 * adjacent or where they overlap.
107 * @param array<int, int> $matchPositions
109 * @return array<int, int>
111 protected function sortAndMergeMatchPositions(array $matchPositions): array
113 ksort($matchPositions);
118 foreach ($matchPositions as $start => $end) {
119 if ($start > $lastEnd) {
120 $mergedRefs[$start] = $end;
123 } elseif ($end > $lastEnd) {
124 $mergedRefs[$lastStart] = $end;
133 * Format the given original text, returning a version where terms are highlighted within.
134 * Returned content is in HTML text format.
136 protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText): string
140 $maxEnd = strlen($originalText);
145 foreach ($matchPositions as $start => $end) {
146 // Get our outer text ranges for the added context we want to show upon the result.
147 $contextStart = max($start - $contextRange, 0, $lastEnd);
148 $contextEnd = min($end + $contextRange, $maxEnd);
150 // Adjust the start if we're going to be touching the previous match.
151 $startDiff = $start - $lastEnd;
152 if ($startDiff < 0) {
153 $contextStart = $start;
154 $content = substr($content, 0, strlen($content) + $startDiff);
157 // Add ellipsis between results
158 if ($contextStart !== 0 && $contextStart !== $start) {
162 // Add our content including the bolded matching text
163 $content .= e(substr($originalText, $contextStart, $start - $contextStart));
164 $content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
165 $content .= e(substr($originalText, $end, $contextEnd - $end));
167 // Update our last end position
168 $lastEnd = $contextEnd;
170 // Update the first start position if it's not already been set
171 if (is_null($firstStart)) {
172 $firstStart = $contextStart;
175 // Stop if we're near our target
176 if (strlen($content) >= $targetLength - 10) {
181 // Just copy out the content if we haven't moved along anywhere.
182 if ($lastEnd === 0) {
183 $content = e(substr($originalText, 0, $targetLength));
184 $lastEnd = $targetLength;
187 // Pad out the end if we're low
188 $remainder = $targetLength - strlen($content);
189 if ($remainder > 10) {
190 $content .= e(substr($originalText, $lastEnd, $remainder));
191 $lastEnd += $remainder;
194 // Pad out the start if we're still low
195 $remainder = $targetLength - strlen($content);
196 $firstStart = $firstStart ?: 0;
197 if ($remainder > 10 && $firstStart !== 0) {
198 $padStart = max(0, $firstStart - $remainder);
199 $content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
202 // Add ellipsis if we're not at the end
203 if ($lastEnd < $maxEnd) {