]> BookStack Code Mirror - bookstack/blob - app/Search/SearchIndex.php
36f71f6ccc7759cd09d3de29e8464a187c1507a6
[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             $termCounts = $this->textToTermCountMap(trim($child->textContent));
164             foreach ($termCounts as $term => $count) {
165                 $scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
166                 $scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
167             }
168         }
169
170         return $scoresByTerm;
171     }
172
173     /**
174      * Create a scored term map from the given set of entity tags.
175      *
176      * @param Tag[] $tags
177      *
178      * @returns array<string, int>
179      */
180     protected function generateTermScoreMapFromTags(array $tags): array
181     {
182         $names = [];
183         $values = [];
184
185         foreach ($tags as $tag) {
186             $names[] = $tag->name;
187             $values[] = $tag->value;
188         }
189
190         $nameMap = $this->generateTermScoreMapFromText(implode(' ', $names), 3);
191         $valueMap = $this->generateTermScoreMapFromText(implode(' ', $values), 5);
192
193         return $this->mergeTermScoreMaps($nameMap, $valueMap);
194     }
195
196     /**
197      * For the given text, return an array where the keys are the unique term words
198      * and the values are the frequency of that term.
199      *
200      * @returns array<string, int>
201      */
202     protected function textToTermCountMap(string $text): array
203     {
204         $tokenMap = []; // {TextToken => OccurrenceCount}
205         $softDelims = static::$softDelimiters;
206         $tokenizer = new SearchTextTokenizer($text, static::$delimiters);
207         $extendedToken = '';
208         $extendedLen = 0;
209
210         $token = $tokenizer->next();
211
212         while ($token !== false) {
213             $delim = $tokenizer->previousDelimiter();
214
215             if ($delim && str_contains($softDelims, $delim) && $token !== '') {
216                 $extendedToken .= $delim . $token;
217                 $extendedLen++;
218             } else {
219                 if ($extendedLen > 1) {
220                     $tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
221                 }
222                 $extendedToken = $token;
223                 $extendedLen = 1;
224             }
225
226             if ($token) {
227                 $tokenMap[$token] = ($tokenMap[$token] ?? 0) + 1;
228             }
229
230             $token = $tokenizer->next();
231         }
232
233         if ($extendedLen > 1) {
234             $tokenMap[$extendedToken] = ($tokenMap[$extendedToken] ?? 0) + 1;
235         }
236
237         return $tokenMap;
238     }
239
240     /**
241      * For the given entity, Generate an array of term data details.
242      * Is the raw term data, not instances of SearchTerm models.
243      *
244      * @returns array{term: string, score: float, entity_id: int, entity_type: string}[]
245      */
246     protected function entityToTermDataArray(Entity $entity): array
247     {
248         $nameTermsMap = $this->generateTermScoreMapFromText($entity->name, 40 * $entity->searchFactor);
249         $tagTermsMap = $this->generateTermScoreMapFromTags($entity->tags->all());
250
251         if ($entity instanceof Page) {
252             $bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
253         } else {
254             $bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
255         }
256
257         $mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
258
259         $dataArray = [];
260         $entityId = $entity->id;
261         $entityType = $entity->getMorphClass();
262         foreach ($mergedScoreMap as $term => $score) {
263             $dataArray[] = [
264                 'term'        => $term,
265                 'score'       => $score,
266                 'entity_type' => $entityType,
267                 'entity_id'   => $entityId,
268             ];
269         }
270
271         return $dataArray;
272     }
273
274     /**
275      * For the given term data arrays, Merge their contents by term
276      * while combining any scores.
277      *
278      * @param array<string, int>[] ...$scoreMaps
279      *
280      * @returns array<string, int>
281      */
282     protected function mergeTermScoreMaps(...$scoreMaps): array
283     {
284         $mergedMap = [];
285
286         foreach ($scoreMaps as $scoreMap) {
287             foreach ($scoreMap as $term => $score) {
288                 $mergedMap[$term] = ($mergedMap[$term] ?? 0) + $score;
289             }
290         }
291
292         return $mergedMap;
293     }
294 }