]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/SearchResultsFormatter.php
2898520abddd5c7349abd01db7cdf6b862576411
[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             $matchRefs = $this->getMatchPositions($content, $terms);
41             $mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
42             $formatted = $this->formatTextUsingMatchPositions($mergedRefs, $content);
43             $entity->setAttribute($attributeName, new HtmlString($formatted));
44         }
45
46         $tags = $entity->relationLoaded('tags') ? $entity->tags->all() : [];
47         $this->highlightTagsContainingTerms($tags, $terms);
48     }
49
50     /**
51      * Highlight tags which match the given terms.
52      *
53      * @param Tag[]    $tags
54      * @param string[] $terms
55      */
56     protected function highlightTagsContainingTerms(array $tags, array $terms): void
57     {
58         foreach ($tags as $tag) {
59             $tagName = strtolower($tag->name);
60             $tagValue = strtolower($tag->value);
61
62             foreach ($terms as $term) {
63                 $termLower = strtolower($term);
64
65                 if (strpos($tagName, $termLower) !== false) {
66                     $tag->setAttribute('highlight_name', true);
67                 }
68
69                 if (strpos($tagValue, $termLower) !== false) {
70                     $tag->setAttribute('highlight_value', true);
71                 }
72             }
73         }
74     }
75
76     /**
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.
80      *
81      * @return array<int, int>
82      */
83     protected function getMatchPositions(string $text, array $terms): array
84     {
85         $matchRefs = [];
86         $text = strtolower($text);
87
88         foreach ($terms as $term) {
89             $offset = 0;
90             $term = strtolower($term);
91             $pos = strpos($text, $term, $offset);
92             while ($pos !== false) {
93                 $end = $pos + strlen($term);
94                 $matchRefs[$pos] = $end;
95                 $offset = $end;
96                 $pos = strpos($text, $term, $offset);
97             }
98         }
99
100         return $matchRefs;
101     }
102
103     /**
104      * Sort the given match positions before merging them where they're
105      * adjacent or where they overlap.
106      *
107      * @param array<int, int> $matchPositions
108      *
109      * @return array<int, int>
110      */
111     protected function sortAndMergeMatchPositions(array $matchPositions): array
112     {
113         ksort($matchPositions);
114         $mergedRefs = [];
115         $lastStart = 0;
116         $lastEnd = 0;
117
118         foreach ($matchPositions as $start => $end) {
119             if ($start > $lastEnd) {
120                 $mergedRefs[$start] = $end;
121                 $lastStart = $start;
122                 $lastEnd = $end;
123             } elseif ($end > $lastEnd) {
124                 $mergedRefs[$lastStart] = $end;
125                 $lastEnd = $end;
126             }
127         }
128
129         return $mergedRefs;
130     }
131
132     /**
133      * Format the given original text, returning a version where terms are highlighted within.
134      * Returned content is in HTML text format.
135      */
136     protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText): string
137     {
138         $contextRange = 32;
139         $targetLength = 260;
140         $maxEnd = strlen($originalText);
141         $lastEnd = 0;
142         $firstStart = null;
143         $content = '';
144
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);
149
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);
155             }
156
157             // Add ellipsis between results
158             if ($contextStart !== 0 && $contextStart !== $start) {
159                 $content .= ' ...';
160             }
161
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));
166
167             // Update our last end position
168             $lastEnd = $contextEnd;
169
170             // Update the first start position if it's not already been set
171             if (is_null($firstStart)) {
172                 $firstStart = $contextStart;
173             }
174
175             // Stop if we're near our target
176             if (strlen($content) >= $targetLength - 10) {
177                 break;
178             }
179         }
180
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;
185         }
186
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;
192         }
193
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);
200         }
201
202         // Add ellipsis if we're not at the end
203         if ($lastEnd < $maxEnd) {
204             $content .= '...';
205         }
206
207         return $content;
208     }
209 }