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