]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/SearchResultsFormatter.php
a30c960032daff8cf07570ab0bf6e400c1342de2
[bookstack] / app / Entities / Tools / SearchResultsFormatter.php
1 <?php
2
3 namespace BookStack\Entities\Tools;
4
5 use BookStack\Actions\Tag;
6 use BookStack\Entities\Models\Entity;
7 use Illuminate\Support\HtmlString;
8
9 class SearchResultsFormatter
10 {
11     /**
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      *
15      * @param Entity[] $results
16      */
17     public function format(array $results, SearchOptions $options): void
18     {
19         foreach ($results as $result) {
20             $this->setSearchPreview($result, $options);
21         }
22     }
23
24     /**
25      * Update the given entity model to set attributes used for previews of the item
26      * primarily within search result lists.
27      */
28     protected function setSearchPreview(Entity $entity, SearchOptions $options)
29     {
30         $textProperty = $entity->textField;
31         $textContent = $entity->$textProperty;
32         $terms = array_merge($options->exacts, $options->searches);
33
34         $originalContentByNewAttribute = [
35             'preview_name'    => $entity->name,
36             'preview_content' => $textContent,
37         ];
38
39         foreach ($originalContentByNewAttribute as $attributeName => $content) {
40             $targetLength = ($attributeName === 'preview_name') ? 0 : 260;
41             $matchRefs = $this->getMatchPositions($content, $terms);
42             $mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
43             $formatted = $this->formatTextUsingMatchPositions($mergedRefs, $content, $targetLength);
44             $entity->setAttribute($attributeName, new HtmlString($formatted));
45         }
46
47         $tags = $entity->relationLoaded('tags') ? $entity->tags->all() : [];
48         $this->highlightTagsContainingTerms($tags, $terms);
49     }
50
51     /**
52      * Highlight tags which match the given terms.
53      *
54      * @param Tag[]    $tags
55      * @param string[] $terms
56      */
57     protected function highlightTagsContainingTerms(array $tags, array $terms): void
58     {
59         foreach ($tags as $tag) {
60             $tagName = strtolower($tag->name);
61             $tagValue = strtolower($tag->value);
62
63             foreach ($terms as $term) {
64                 $termLower = strtolower($term);
65
66                 if (strpos($tagName, $termLower) !== false) {
67                     $tag->setAttribute('highlight_name', true);
68                 }
69
70                 if (strpos($tagValue, $termLower) !== false) {
71                     $tag->setAttribute('highlight_value', true);
72                 }
73             }
74         }
75     }
76
77     /**
78      * Get positions of the given terms within the given text.
79      * Is in the array format of [int $startIndex => int $endIndex] where the indexes
80      * are positions within the provided text.
81      *
82      * @return array<int, int>
83      */
84     protected function getMatchPositions(string $text, array $terms): array
85     {
86         $matchRefs = [];
87         $text = strtolower($text);
88
89         foreach ($terms as $term) {
90             $offset = 0;
91             $term = strtolower($term);
92             $pos = strpos($text, $term, $offset);
93             while ($pos !== false) {
94                 $end = $pos + strlen($term);
95                 $matchRefs[$pos] = $end;
96                 $offset = $end;
97                 $pos = strpos($text, $term, $offset);
98             }
99         }
100
101         return $matchRefs;
102     }
103
104     /**
105      * Sort the given match positions before merging them where they're
106      * adjacent or where they overlap.
107      *
108      * @param array<int, int> $matchPositions
109      *
110      * @return array<int, int>
111      */
112     protected function sortAndMergeMatchPositions(array $matchPositions): array
113     {
114         ksort($matchPositions);
115         $mergedRefs = [];
116         $lastStart = 0;
117         $lastEnd = 0;
118
119         foreach ($matchPositions as $start => $end) {
120             if ($start > $lastEnd) {
121                 $mergedRefs[$start] = $end;
122                 $lastStart = $start;
123                 $lastEnd = $end;
124             } elseif ($end > $lastEnd) {
125                 $mergedRefs[$lastStart] = $end;
126                 $lastEnd = $end;
127             }
128         }
129
130         return $mergedRefs;
131     }
132
133     /**
134      * Format the given original text, returning a version where terms are highlighted within.
135      * Returned content is in HTML text format.
136      * A given $targetLength of 0 asserts no target length limit.
137      *
138      * This is a complex function but written to be relatively efficient, going through the term matches in order
139      * so that we're only doing a one-time loop through of the matches. There is no further searching
140      * done within here.
141      */
142     protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText, int $targetLength): string
143     {
144         $contextRange = 32;
145         $maxEnd = strlen($originalText);
146         $lastEnd = 0;
147         $firstStart = null;
148         $fetchAll = ($targetLength === 0);
149         $content = '';
150         $contentTextLength = 0;
151
152         if ($fetchAll) {
153             $targetLength = $maxEnd * 2;
154         }
155
156         foreach ($matchPositions as $start => $end) {
157             // Get our outer text ranges for the added context we want to show upon the result.
158             $contextStart = max($start - $contextRange, 0, $lastEnd);
159             $contextEnd = min($end + $contextRange, $maxEnd);
160
161             // Adjust the start if we're going to be touching the previous match.
162             $startDiff = $start - $lastEnd;
163             if ($startDiff < 0) {
164                 $contextStart = $start;
165                 // Trims off '$startDiff' number of characters to bring it back to the start
166                 // if this current match zone.
167                 $content = substr($content, 0, strlen($content) + $startDiff);
168                 $contentTextLength += $startDiff;
169             }
170
171             // Add ellipsis between results
172             if (!$fetchAll && $contextStart !== 0 && $contextStart !== $start) {
173                 $content .= ' ...';
174                 $contentTextLength += 4;
175             } else if ($fetchAll) {
176                 // Or fill in gap since the previous match
177                 $fillLength = $contextStart - $lastEnd;
178                 $content .= e(substr($originalText, $lastEnd, $fillLength));
179                 $contentTextLength += $fillLength;
180             }
181
182             // Add our content including the bolded matching text
183             $content .= e(substr($originalText, $contextStart, $start - $contextStart));
184             $contentTextLength += $start - $contextStart;
185             $content .= '<strong>' . e(substr($originalText, $start, $end - $start)) . '</strong>';
186             $contentTextLength += $end - $start;
187             $content .= e(substr($originalText, $end, $contextEnd - $end));
188             $contentTextLength += $contextEnd - $end;
189
190             // Update our last end position
191             $lastEnd = $contextEnd;
192
193             // Update the first start position if it's not already been set
194             if (is_null($firstStart)) {
195                 $firstStart = $contextStart;
196             }
197
198             // Stop if we're near our target
199             if ($contentTextLength >= $targetLength - 10) {
200                 break;
201             }
202         }
203
204         // Just copy out the content if we haven't moved along anywhere.
205         if ($lastEnd === 0) {
206             $content = e(substr($originalText, 0, $targetLength));
207             $contentTextLength = $targetLength;
208             $lastEnd = $targetLength;
209         }
210
211         // Pad out the end if we're low
212         $remainder = $targetLength - $contentTextLength;
213         if ($remainder > 10) {
214             $padEndLength = min($maxEnd - $lastEnd, $remainder);
215             $content .= e(substr($originalText, $lastEnd, $padEndLength));
216             $lastEnd += $padEndLength;
217             $contentTextLength += $padEndLength;
218         }
219
220         // Pad out the start if we're still low
221         $remainder = $targetLength - $contentTextLength;
222         $firstStart = $firstStart ?: 0;
223         if (!$fetchAll && $remainder > 10 && $firstStart !== 0) {
224             $padStart = max(0, $firstStart - $remainder);
225             $content = ($padStart === 0 ? '' : '...') . e(substr($originalText, $padStart, $firstStart - $padStart)) . substr($content, 4);
226         }
227
228         // Add ellipsis if we're not at the end
229         if ($lastEnd < $maxEnd) {
230             $content .= '...';
231         }
232
233         return $content;
234     }
235 }