]> BookStack Code Mirror - bookstack/blob - app/Search/SearchIndex.php
Remove Dumps
[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 DOMDocument;
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     public function __construct(
22         protected EntityProvider $entityProvider
23     ) {
24     }
25
26     /**
27      * Index the given entity.
28      */
29     public function indexEntity(Entity $entity): void
30     {
31         $this->deleteEntityTerms($entity);
32         $terms = $this->entityToTermDataArray($entity);
33         SearchTerm::query()->insert($terms);
34     }
35
36     /**
37      * Index multiple Entities at once.
38      *
39      * @param Entity[] $entities
40      */
41     public function indexEntities(array $entities): void
42     {
43         $terms = [];
44         foreach ($entities as $entity) {
45             $entityTerms = $this->entityToTermDataArray($entity);
46             array_push($terms, ...$entityTerms);
47         }
48
49         $chunkedTerms = array_chunk($terms, 500);
50         foreach ($chunkedTerms as $termChunk) {
51             SearchTerm::query()->insert($termChunk);
52         }
53     }
54
55     /**
56      * Delete and re-index the terms for all entities in the system.
57      * Can take a callback which is used for reporting progress.
58      * Callback receives three arguments:
59      * - An instance of the model being processed
60      * - The number that have been processed so far.
61      * - The total number of that model to be processed.
62      *
63      * @param callable(Entity, int, int):void|null $progressCallback
64      */
65     public function indexAllEntities(?callable $progressCallback = null): void
66     {
67         SearchTerm::query()->truncate();
68
69         foreach ($this->entityProvider->all() as $entityModel) {
70             $indexContentField = $entityModel instanceof Page ? 'html' : 'description';
71             $selectFields = ['id', 'name', $indexContentField];
72             /** @var Builder<Entity> $query */
73             $query = $entityModel->newQuery();
74             $total = $query->withTrashed()->count();
75             $chunkSize = 250;
76             $processed = 0;
77
78             $chunkCallback = function (Collection $entities) use ($progressCallback, &$processed, $total, $chunkSize, $entityModel) {
79                 $this->indexEntities($entities->all());
80                 $processed = min($processed + $chunkSize, $total);
81
82                 if (is_callable($progressCallback)) {
83                     $progressCallback($entityModel, $processed, $total);
84                 }
85             };
86
87             $entityModel->newQuery()
88                 ->select($selectFields)
89                 ->with(['tags:id,name,value,entity_id,entity_type'])
90                 ->chunk($chunkSize, $chunkCallback);
91         }
92     }
93
94     /**
95      * Delete related Entity search terms.
96      */
97     public function deleteEntityTerms(Entity $entity): void
98     {
99         $entity->searchTerms()->delete();
100     }
101
102     /**
103      * Create a scored term array from the given text, where the keys are the terms
104      * and the values are their scores.
105      *
106      * @returns array<string, int>
107      */
108     protected function generateTermScoreMapFromText(string $text, float $scoreAdjustment = 1): array
109     {
110         $termMap = $this->textToTermCountMap($text);
111
112         foreach ($termMap as $term => $count) {
113             $termMap[$term] = floor($count * $scoreAdjustment);
114         }
115
116         return $termMap;
117     }
118
119     /**
120      * Create a scored term array from the given HTML, where the keys are the terms
121      * and the values are their scores.
122      *
123      * @returns array<string, int>
124      */
125     protected function generateTermScoreMapFromHtml(string $html): array
126     {
127         if (empty($html)) {
128             return [];
129         }
130
131         $scoresByTerm = [];
132         $elementScoreAdjustmentMap = [
133             'h1' => 10,
134             'h2' => 5,
135             'h3' => 4,
136             'h4' => 3,
137             'h5' => 2,
138             'h6' => 1.5,
139         ];
140
141         $html = '<?xml encoding="utf-8" ?><body>' . $html . '</body>';
142         $html = str_ireplace(['<br>', '<br />', '<br/>'], "\n", $html);
143
144         libxml_use_internal_errors(true);
145         $doc = new DOMDocument();
146         $doc->loadHTML($html);
147
148         $topElems = $doc->documentElement->childNodes->item(0)->childNodes;
149         /** @var DOMNode $child */
150         foreach ($topElems as $child) {
151             $nodeName = $child->nodeName;
152             $termCounts = $this->textToTermCountMap(trim($child->textContent));
153             foreach ($termCounts as $term => $count) {
154                 $scoreChange = $count * ($elementScoreAdjustmentMap[$nodeName] ?? 1);
155                 $scoresByTerm[$term] = ($scoresByTerm[$term] ?? 0) + $scoreChange;
156             }
157         }
158
159         return $scoresByTerm;
160     }
161
162     /**
163      * Create a scored term map from the given set of entity tags.
164      *
165      * @param Tag[] $tags
166      *
167      * @returns array<string, int>
168      */
169     protected function generateTermScoreMapFromTags(array $tags): array
170     {
171         $scoreMap = [];
172         $names = [];
173         $values = [];
174
175         foreach ($tags as $tag) {
176             $names[] = $tag->name;
177             $values[] = $tag->value;
178         }
179
180         $nameMap = $this->generateTermScoreMapFromText(implode(' ', $names), 3);
181         $valueMap = $this->generateTermScoreMapFromText(implode(' ', $values), 5);
182
183         return $this->mergeTermScoreMaps($nameMap, $valueMap);
184     }
185
186     /**
187      * For the given text, return an array where the keys are the unique term words
188      * and the values are the frequency of that term.
189      *
190      * @returns array<string, int>
191      */
192     protected function textToTermCountMap(string $text): array
193     {
194         $tokenMap = []; // {TextToken => OccurrenceCount}
195         $splitChars = static::$delimiters;
196         $token = strtok($text, $splitChars);
197
198         while ($token !== false) {
199             if (!isset($tokenMap[$token])) {
200                 $tokenMap[$token] = 0;
201             }
202             $tokenMap[$token]++;
203             $token = strtok($splitChars);
204         }
205
206         return $tokenMap;
207     }
208
209     /**
210      * For the given entity, Generate an array of term data details.
211      * Is the raw term data, not instances of SearchTerm models.
212      *
213      * @returns array{term: string, score: float, entity_id: int, entity_type: string}[]
214      */
215     protected function entityToTermDataArray(Entity $entity): array
216     {
217         $nameTermsMap = $this->generateTermScoreMapFromText($entity->name, 40 * $entity->searchFactor);
218         $tagTermsMap = $this->generateTermScoreMapFromTags($entity->tags->all());
219
220         if ($entity instanceof Page) {
221             $bodyTermsMap = $this->generateTermScoreMapFromHtml($entity->html);
222         } else {
223             $bodyTermsMap = $this->generateTermScoreMapFromText($entity->getAttribute('description') ?? '', $entity->searchFactor);
224         }
225
226         $mergedScoreMap = $this->mergeTermScoreMaps($nameTermsMap, $bodyTermsMap, $tagTermsMap);
227
228         $dataArray = [];
229         $entityId = $entity->id;
230         $entityType = $entity->getMorphClass();
231         foreach ($mergedScoreMap as $term => $score) {
232             $dataArray[] = [
233                 'term'        => $term,
234                 'score'       => $score,
235                 'entity_type' => $entityType,
236                 'entity_id'   => $entityId,
237             ];
238         }
239
240         return $dataArray;
241     }
242
243     /**
244      * For the given term data arrays, Merge their contents by term
245      * while combining any scores.
246      *
247      * @param array<string, int>[] ...$scoreMaps
248      *
249      * @returns array<string, int>
250      */
251     protected function mergeTermScoreMaps(...$scoreMaps): array
252     {
253         $mergedMap = [];
254
255         foreach ($scoreMaps as $scoreMap) {
256             foreach ($scoreMap as $term => $score) {
257                 $mergedMap[$term] = ($mergedMap[$term] ?? 0) + $score;
258             }
259         }
260
261         return $mergedMap;
262     }
263 }