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