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