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