]> BookStack Code Mirror - bookstack/blob - app/Search/SearchIndex.php
Search: Updated indexer to handle non-breaking-spaces
[bookstack] / app / Search / SearchIndex.php
1 <?php
2
3 namespace BookStack\Search;
4
5 use BookStack\Activity\Models\Tag;
6 use BookStack\Entities\EntityProvider;
7 use BookStack\Entities\Models\Entity;
8 use BookStack\Entities\Models\Page;
9 use BookStack\Util\HtmlDocument;
10 use DOMNode;
11 use Illuminate\Database\Eloquent\Builder;
12 use Illuminate\Support\Collection;
13
14 class SearchIndex
15 {
16     /**
17      * A list of delimiter characters used to break-up parsed content into terms for indexing.
18      */
19     public static string $delimiters = " \n\t.-,!?:;()[]{}<>`'\"«»";
20
21     /**
22      * A list of delimiter which could be commonly used within a single term and also indicate a break between terms.
23      * The indexer will index the full term with these delimiters, plus the terms split via these delimiters.
24      */
25     public static string $softDelimiters = ".-";
26
27     public function __construct(
28         protected EntityProvider $entityProvider
29     ) {
30     }
31
32     /**
33      * Index the given entity.
34      */
35     public function indexEntity(Entity $entity): void
36     {
37         $this->deleteEntityTerms($entity);
38         $terms = $this->entityToTermDataArray($entity);
39         $this->insertTerms($terms);
40     }
41
42     /**
43      * Index multiple Entities at once.
44      *
45      * @param Entity[] $entities
46      */
47     public function indexEntities(array $entities): void
48     {
49         $terms = [];
50         foreach ($entities as $entity) {
51             $entityTerms = $this->entityToTermDataArray($entity);
52             array_push($terms, ...$entityTerms);
53         }
54
55         $this->insertTerms($terms);
56     }
57
58     /**
59      * Delete and re-index the terms for all entities in the system.
60      * Can take a callback which is used for reporting progress.
61      * Callback receives three arguments:
62      * - An instance of the model being processed
63      * - The number that have been processed so far.
64      * - The total number of that model to be processed.
65      *
66      * @param callable(Entity, int, int):void|null $progressCallback
67      */
68     public function indexAllEntities(?callable $progressCallback = null): void
69     {
70         SearchTerm::query()->truncate();
71
72         foreach ($this->entityProvider->all() as $entityModel) {
73             $indexContentField = $entityModel instanceof Page ? 'html' : 'description';
74             $selectFields = ['id', 'name', $indexContentField];
75             /** @var Builder<Entity> $query */
76             $query = $entityModel->newQuery();
77             $total = $query->withTrashed()->count();
78             $chunkSize = 250;
79             $processed = 0;
80
81             $chunkCallback = function (Collection $entities) use ($progressCallback, &$processed, $total, $chunkSize, $entityModel) {
82                 $this->indexEntities($entities->all());
83                 $processed = min($processed + $chunkSize, $total);
84
85                 if (is_callable($progressCallback)) {
86                     $progressCallback($entityModel, $processed, $total);
87                 }
88             };
89
90             $entityModel->newQuery()
91                 ->select($selectFields)
92                 ->with(['tags:id,name,value,entity_id,entity_type'])
93                 ->chunk($chunkSize, $chunkCallback);
94         }
95     }
96
97     /**
98      * Delete related Entity search terms.
99      */
100     public function deleteEntityTerms(Entity $entity): void
101     {
102         $entity->searchTerms()->delete();
103     }
104
105     /**
106      * Insert the given terms into the database.
107      * Chunks through the given terms to remain within database limits.
108      * @param array[] $terms
109      */
110     protected function insertTerms(array $terms): void
111     {
112         $chunkedTerms = array_chunk($terms, 500);
113         foreach ($chunkedTerms as $termChunk) {
114             SearchTerm::query()->insert($termChunk);
115         }
116     }
117
118     /**
119      * Create a scored term array from the given text, where the keys are the terms
120      * and the values are their scores.
121      *
122      * @returns array<string, int>
123      */
124     protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
125     {
126         $termMap = $this->textToTermCountMap($text);
127
128         foreach ($termMap as $term => $count) {
129             $termMap[$term] = floor($count * $scoreAdjustment);
130         }
131
132         return $termMap;
133     }
134
135     /**
136      * Create a scored term array from the given HTML, where the keys are the terms
137      * and the values are their scores.
138      *
139      * @returns array<string, int>
140      */
141     protected function generateTermScoreMapFromHtml(string $html): array
142     {
143         if (empty($html)) {
144             return [];
145         }
146
147         $scoresByTerm = [];
148         $elementScoreAdjustmentMap = [
149             'h1' => 10,
150             'h2' => 5,
151             'h3' => 4,
152             'h4' => 3,
153             'h5' => 2,
154             'h6' => 1.5,
155         ];
156
157         $html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
158         $doc = new HtmlDocument($html);
159
160         /** @var DOMNode $child */
161         foreach ($doc->getBodyChildren() as $child) {
162             $nodeName = $child->nodeName;
163             $text = trim($child->textContent);
164             $text = str_replace("\u{00A0}", ' ', $text);
165             $termCounts = $this->textToTermCountMap($text);
166             foreach ($termCounts as $term => $count) {
167                 $scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
168                 $scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
169             }
170         }
171
172         return $scoresByTerm;
173     }
174
175     /**
176      * Create a scored term map from the given set of entity tags.
177      *
178      * @param Tag[] $tags
179      *
180      * @returns array<string, int>
181      */
182     protected function generateTermScoreMapFromTags(array $tags): array
183     {
184         $names = [];
185         $values = [];
186
187         foreach ($tags as $tag) {
188             $names[] = $tag->name;
189             $values[] = $tag->value;
190         }
191
192         $nameMap = $this->generateTermScoreMapFromText(implode(' ', $names), 3);
193         $valueMap = $this->generateTermScoreMapFromText(implode(' ', $values), 5);
194
195         return $this->mergeTermScoreMaps($nameMap, $valueMap);
196     }
197
198     /**
199      * For the given text, return an array where the keys are the unique term words
200      * and the values are the frequency of that term.
201      *
202      * @returns array<string, int>
203      */
204     protected function textToTermCountMap(string $text): array
205     {
206         $tokenMap = []; // {TextToken => OccurrenceCount}
207         $softDelims = static::$softDelimiters;
208         $tokenizer = new SearchTextTokenizer($text, static::$delimiters);
209         $extendedToken = '';
210         $extendedLen = 0;
211
212         $token = $tokenizer->next();
213
214         while ($token !== false) {
215             $delim = $tokenizer->previousDelimiter();
216
217             if ($delim && str_contains($softDelims, $delim) && $token !== '') {
218                 $extendedToken .= $delim . $token;
219                 $extendedLen++;
220             } else {
221                 if ($extendedLen > 1) {
222                     $tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
223                 }
224                 $extendedToken = $token;
225                 $extendedLen = 1;
226             }
227
228             if ($token) {
229                 $tokenMap[$token] = ($tokenMap[$token] ?? 0) + 1;
230             }
231
232             $token = $tokenizer->next();
233         }
234
235         if ($extendedLen > 1) {
236             $tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
237         }
238
239         return $tokenMap;
240     }
241
242     /**
243      * For the given entity, Generate an array of term data details.
244      * Is the raw term data, not instances of SearchTerm models.
245      *
246      * @returns array{term: string, score: float, entity_id: int, entity_type: string}[]
247      */
248     protected function entityToTermDataArray(Entity $entity): array
249     {
250         $nameTermsMap = $this->generateTermScoreMapFromText($entity->name, 40 * $entity->searchFactor);
251         $tagTermsMap = $this->generateTermScoreMapFromTags($entity->tags->all());
252
253         if ($entity instanceof Page) {
254             $bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
255         } else {
256             $bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
257         }
258
259         $mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
260
261         $dataArray = [];
262         $entityId = $entity->id;
263         $entityType = $entity->getMorphClass();
264         foreach ($mergedScoreMap as $term => $score) {
265             $dataArray[] = [
266                 'term'        => $term,
267                 'score'       => $score,
268                 'entity_type' => $entityType,
269                 'entity_id'   => $entityId,
270             ];
271         }
272
273         return $dataArray;
274     }
275
276     /**
277      * For the given term data arrays, Merge their contents by term
278      * while combining any scores.
279      *
280      * @param array<string, int>[] ...$scoreMaps
281      *
282      * @returns array<string, int>
283      */
284     protected function mergeTermScoreMaps(...$scoreMaps): array
285     {
286         $mergedMap = [];
287
288         foreach ($scoreMaps as $scoreMap) {
289             foreach ($scoreMap as $term => $score) {
290                 $mergedMap[$term] = ($mergedMap[$term] ?? 0) + $score;
291             }
292         }
293
294         return $mergedMap;
295     }
296 }