]> BookStack Code Mirror - bookstack/blob - app/Entities/Tools/SearchResultsFormatter.php
aaa5c129da1c0820eddf08b8d08307b3a742d755
[bookstack] / app / Entities / Tools / SearchResultsFormatter.php
1 <?php
2
3 namespace BookStack\Entities\Tools;
4
5 use BookStack\Entities\Models\Entity;
6 use Illuminate\Support\HtmlString;
7
8 class SearchResultsFormatter
9 {
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      * @param Entity[] $results
15      */
16     public function format(array $results, SearchOptions $options): void
17     {
18         foreach ($results as $result) {
19             $this->setSearchPreview($result, $options);
20         }
21     }
22
23     /**
24      * Update the given entity model to set attributes used for previews of the item
25      * primarily within search result lists.
26      */
27     protected function setSearchPreview(Entity $entity, SearchOptions $options)
28     {
29         $textProperty = $entity->textField;
30         $textContent = $entity->$textProperty;
31         $terms = array_merge($options->exacts, $options->searches);
32
33         $matchRefs = $this->getMatchPositions($textContent, $terms);
34         $mergedRefs = $this->sortAndMergeMatchPositions($matchRefs);
35         $content = $this->formatTextUsingMatchPositions($mergedRefs, $textContent);
36
37         $entity->setAttribute('preview_content', new HtmlString($content));
38     }
39
40     /**
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.
44      *
45      * @return array<int, int>
46      */
47     protected function getMatchPositions(string $text, array $terms): array
48     {
49         $matchRefs = [];
50         $text = strtolower($text);
51
52         foreach ($terms as $term) {
53             $offset = 0;
54             $term = strtolower($term);
55             $pos = strpos($text, $term, $offset);
56             while ($pos !== false) {
57                 $end = $pos + strlen($term);
58                 $matchRefs[$pos] = $end;
59                 $offset = $end;
60                 $pos = strpos($text, $term, $offset);
61             }
62         }
63
64         return $matchRefs;
65     }
66
67     /**
68      * Sort the given match positions before merging them where they're
69      * adjacent or where they overlap.
70      *
71      * @param array<int, int> $matchPositions
72      * @return array<int, int>
73      */
74     protected function sortAndMergeMatchPositions(array $matchPositions): array
75     {
76         ksort($matchPositions);
77         $mergedRefs = [];
78         $lastStart = 0;
79         $lastEnd = 0;
80
81         foreach ($matchPositions as $start => $end) {
82             if ($start > $lastEnd) {
83                 $mergedRefs[$start] = $end;
84                 $lastStart = $start;
85                 $lastEnd = $end;
86             } else if ($end > $lastEnd) {
87                 $mergedRefs[$lastStart] = $end;
88                 $lastEnd = $end;
89             }
90         }
91
92         return $mergedRefs;
93     }
94
95     /**
96      * Format the given original text, returning a version where terms are highlighted within.
97      * Returned content is in HTML text format.
98      */
99     protected function formatTextUsingMatchPositions(array $matchPositions, string $originalText): string
100     {
101         $contextRange = 32;
102         $targetLength = 260;
103         $maxEnd = strlen($originalText);
104         $lastEnd = 0;
105         $firstStart = null;
106         $content = '';
107
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);
112
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);
118             }
119
120             // Add ellipsis between results
121             if ($contextStart !== 0 && $contextStart !== $start) {
122                 $content .= ' ...';
123             }
124
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));
129
130             // Update our last end position
131             $lastEnd = $contextEnd;
132
133             // Update the first start position if it's not already been set
134             if (is_null($firstStart)) {
135                 $firstStart = $contextStart;
136             }
137
138             // Stop if we're near our target
139             if (strlen($content) >= $targetLength - 10) {
140                 break;
141             }
142         }
143
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;
148         }
149
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;
155         }
156
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);
163         }
164
165         // Add ellipsis if we're not at the end
166         if ($lastEnd < $maxEnd) {
167             $content .= '...';
168         }
169
170         return $content;
171     }
172
173 }