]> BookStack Code Mirror - bookstack/blob - app/Services/SearchService.php
Update .env.example
[bookstack] / app / Services / SearchService.php
1 <?php namespace BookStack\Services;
2
3 use BookStack\Book;
4 use BookStack\Chapter;
5 use BookStack\Entity;
6 use BookStack\Page;
7 use BookStack\SearchTerm;
8 use Illuminate\Database\Connection;
9 use Illuminate\Database\Query\Builder;
10 use Illuminate\Database\Query\JoinClause;
11 use Illuminate\Support\Collection;
12
13 class SearchService
14 {
15     protected $searchTerm;
16     protected $book;
17     protected $chapter;
18     protected $page;
19     protected $db;
20     protected $permissionService;
21     protected $entities;
22
23     /**
24      * Acceptable operators to be used in a query
25      * @var array
26      */
27     protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
28
29     /**
30      * SearchService constructor.
31      * @param SearchTerm $searchTerm
32      * @param Book $book
33      * @param Chapter $chapter
34      * @param Page $page
35      * @param Connection $db
36      * @param PermissionService $permissionService
37      */
38     public function __construct(SearchTerm $searchTerm, Book $book, Chapter $chapter, Page $page, Connection $db, PermissionService $permissionService)
39     {
40         $this->searchTerm = $searchTerm;
41         $this->book = $book;
42         $this->chapter = $chapter;
43         $this->page = $page;
44         $this->db = $db;
45         $this->entities = [
46             'page' => $this->page,
47             'chapter' => $this->chapter,
48             'book' => $this->book
49         ];
50         $this->permissionService = $permissionService;
51     }
52
53     /**
54      * Set the database connection
55      * @param Connection $connection
56      */
57     public function setConnection(Connection $connection)
58     {
59         $this->db = $connection;
60     }
61
62     /**
63      * Search all entities in the system.
64      * @param string $searchString
65      * @param string $entityType
66      * @param int $page
67      * @param int $count
68      * @return array[int, Collection];
69      */
70     public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20)
71     {
72         $terms = $this->parseSearchString($searchString);
73         $entityTypes = array_keys($this->entities);
74         $entityTypesToSearch = $entityTypes;
75         $results = collect();
76
77         if ($entityType !== 'all') {
78             $entityTypesToSearch = $entityType;
79         } else if (isset($terms['filters']['type'])) {
80             $entityTypesToSearch = explode('|', $terms['filters']['type']);
81         }
82
83         $total = 0;
84
85         foreach ($entityTypesToSearch as $entityType) {
86             if (!in_array($entityType, $entityTypes)) {
87                 continue;
88             }
89             $search = $this->searchEntityTable($terms, $entityType, $page, $count);
90             $total += $this->searchEntityTable($terms, $entityType, $page, $count, true);
91             $results = $results->merge($search);
92         }
93
94         return [
95             'total' => $total,
96             'count' => count($results),
97             'results' => $results->sortByDesc('score')->values()
98         ];
99     }
100
101
102     /**
103      * Search a book for entities
104      * @param integer $bookId
105      * @param string $searchString
106      * @return Collection
107      */
108     public function searchBook($bookId, $searchString)
109     {
110         $terms = $this->parseSearchString($searchString);
111         $entityTypes = ['page', 'chapter'];
112         $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
113
114         $results = collect();
115         foreach ($entityTypesToSearch as $entityType) {
116             if (!in_array($entityType, $entityTypes)) {
117                 continue;
118             }
119             $search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
120             $results = $results->merge($search);
121         }
122         return $results->sortByDesc('score')->take(20);
123     }
124
125     /**
126      * Search a book for entities
127      * @param integer $chapterId
128      * @param string $searchString
129      * @return Collection
130      */
131     public function searchChapter($chapterId, $searchString)
132     {
133         $terms = $this->parseSearchString($searchString);
134         $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
135         return $pages->sortByDesc('score');
136     }
137
138     /**
139      * Search across a particular entity type.
140      * @param array $terms
141      * @param string $entityType
142      * @param int $page
143      * @param int $count
144      * @param bool $getCount Return the total count of the search
145      * @return \Illuminate\Database\Eloquent\Collection|int|static[]
146      */
147     public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $getCount = false)
148     {
149         $query = $this->buildEntitySearchQuery($terms, $entityType);
150         if ($getCount) {
151             return $query->count();
152         }
153
154         $query = $query->skip(($page-1) * $count)->take($count);
155         return $query->get();
156     }
157
158     /**
159      * Create a search query for an entity
160      * @param array $terms
161      * @param string $entityType
162      * @return \Illuminate\Database\Eloquent\Builder
163      */
164     protected function buildEntitySearchQuery($terms, $entityType = 'page')
165     {
166         $entity = $this->getEntity($entityType);
167         $entitySelect = $entity->newQuery();
168
169         // Handle normal search terms
170         if (count($terms['search']) > 0) {
171             $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
172             $subQuery->where('entity_type', '=', 'BookStack\\' . ucfirst($entityType));
173             $subQuery->where(function (Builder $query) use ($terms) {
174                 foreach ($terms['search'] as $inputTerm) {
175                     $query->orWhere('term', 'like', $inputTerm .'%');
176                 }
177             })->groupBy('entity_type', 'entity_id');
178             $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function (JoinClause $join) {
179                 $join->on('id', '=', 'entity_id');
180             })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
181             $entitySelect->mergeBindings($subQuery);
182         }
183
184         // Handle exact term matching
185         if (count($terms['exact']) > 0) {
186             $entitySelect->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($terms, $entity) {
187                 foreach ($terms['exact'] as $inputTerm) {
188                     $query->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($inputTerm, $entity) {
189                         $query->where('name', 'like', '%'.$inputTerm .'%')
190                             ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
191                     });
192                 }
193             });
194         }
195
196         // Handle tag searches
197         foreach ($terms['tags'] as $inputTerm) {
198             $this->applyTagSearch($entitySelect, $inputTerm);
199         }
200
201         // Handle filters
202         foreach ($terms['filters'] as $filterTerm => $filterValue) {
203             $functionName = camel_case('filter_' . $filterTerm);
204             if (method_exists($this, $functionName)) {
205                 $this->$functionName($entitySelect, $entity, $filterValue);
206             }
207         }
208
209         return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view');
210     }
211
212
213     /**
214      * Parse a search string into components.
215      * @param $searchString
216      * @return array
217      */
218     protected function parseSearchString($searchString)
219     {
220         $terms = [
221             'search' => [],
222             'exact' => [],
223             'tags' => [],
224             'filters' => []
225         ];
226
227         $patterns = [
228             'exact' => '/"(.*?)"/',
229             'tags' => '/\[(.*?)\]/',
230             'filters' => '/\{(.*?)\}/'
231         ];
232
233         // Parse special terms
234         foreach ($patterns as $termType => $pattern) {
235             $matches = [];
236             preg_match_all($pattern, $searchString, $matches);
237             if (count($matches) > 0) {
238                 $terms[$termType] = $matches[1];
239                 $searchString = preg_replace($pattern, '', $searchString);
240             }
241         }
242
243         // Parse standard terms
244         foreach (explode(' ', trim($searchString)) as $searchTerm) {
245             if ($searchTerm !== '') {
246                 $terms['search'][] = $searchTerm;
247             }
248         }
249
250         // Split filter values out
251         $splitFilters = [];
252         foreach ($terms['filters'] as $filter) {
253             $explodedFilter = explode(':', $filter, 2);
254             $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
255         }
256         $terms['filters'] = $splitFilters;
257
258         return $terms;
259     }
260
261     /**
262      * Get the available query operators as a regex escaped list.
263      * @return mixed
264      */
265     protected function getRegexEscapedOperators()
266     {
267         $escapedOperators = [];
268         foreach ($this->queryOperators as $operator) {
269             $escapedOperators[] = preg_quote($operator);
270         }
271         return join('|', $escapedOperators);
272     }
273
274     /**
275      * Apply a tag search term onto a entity query.
276      * @param \Illuminate\Database\Eloquent\Builder $query
277      * @param string $tagTerm
278      * @return mixed
279      */
280     protected function applyTagSearch(\Illuminate\Database\Eloquent\Builder $query, $tagTerm)
281     {
282         preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
283         $query->whereHas('tags', function (\Illuminate\Database\Eloquent\Builder $query) use ($tagSplit) {
284             $tagName = $tagSplit[1];
285             $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : '';
286             $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : '';
287             $validOperator = in_array($tagOperator, $this->queryOperators);
288             if (!empty($tagOperator) && !empty($tagValue) && $validOperator) {
289                 if (!empty($tagName)) {
290                     $query->where('name', '=', $tagName);
291                 }
292                 if (is_numeric($tagValue) && $tagOperator !== 'like') {
293                     // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will
294                     // search the value as a string which prevents being able to do number-based operations
295                     // on the tag values. We ensure it has a numeric value and then cast it just to be sure.
296                     $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'");
297                     $query->whereRaw("value ${tagOperator} ${tagValue}");
298                 } else {
299                     $query->where('value', $tagOperator, $tagValue);
300                 }
301             } else {
302                 $query->where('name', '=', $tagName);
303             }
304         });
305         return $query;
306     }
307
308     /**
309      * Get an entity instance via type.
310      * @param $type
311      * @return Entity
312      */
313     protected function getEntity($type)
314     {
315         return $this->entities[strtolower($type)];
316     }
317
318     /**
319      * Index the given entity.
320      * @param Entity $entity
321      */
322     public function indexEntity(Entity $entity)
323     {
324         $this->deleteEntityTerms($entity);
325         $nameTerms = $this->generateTermArrayFromText($entity->name, 5);
326         $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1);
327         $terms = array_merge($nameTerms, $bodyTerms);
328         foreach ($terms as $index => $term) {
329             $terms[$index]['entity_type'] = $entity->getMorphClass();
330             $terms[$index]['entity_id'] = $entity->id;
331         }
332         $this->searchTerm->newQuery()->insert($terms);
333     }
334
335     /**
336      * Index multiple Entities at once
337      * @param Entity[] $entities
338      */
339     protected function indexEntities($entities)
340     {
341         $terms = [];
342         foreach ($entities as $entity) {
343             $nameTerms = $this->generateTermArrayFromText($entity->name, 5);
344             $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1);
345             foreach (array_merge($nameTerms, $bodyTerms) as $term) {
346                 $term['entity_id'] = $entity->id;
347                 $term['entity_type'] = $entity->getMorphClass();
348                 $terms[] = $term;
349             }
350         }
351
352         $chunkedTerms = array_chunk($terms, 500);
353         foreach ($chunkedTerms as $termChunk) {
354             $this->searchTerm->newQuery()->insert($termChunk);
355         }
356     }
357
358     /**
359      * Delete and re-index the terms for all entities in the system.
360      */
361     public function indexAllEntities()
362     {
363         $this->searchTerm->truncate();
364
365         // Chunk through all books
366         $this->book->chunk(1000, function ($books) {
367             $this->indexEntities($books);
368         });
369
370         // Chunk through all chapters
371         $this->chapter->chunk(1000, function ($chapters) {
372             $this->indexEntities($chapters);
373         });
374
375         // Chunk through all pages
376         $this->page->chunk(1000, function ($pages) {
377             $this->indexEntities($pages);
378         });
379     }
380
381     /**
382      * Delete related Entity search terms.
383      * @param Entity $entity
384      */
385     public function deleteEntityTerms(Entity $entity)
386     {
387         $entity->searchTerms()->delete();
388     }
389
390     /**
391      * Create a scored term array from the given text.
392      * @param $text
393      * @param float|int $scoreAdjustment
394      * @return array
395      */
396     protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
397     {
398         $tokenMap = []; // {TextToken => OccurrenceCount}
399         $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
400         $token = strtok($text, $splitChars);
401
402         while ($token !== false) {
403             if (!isset($tokenMap[$token])) {
404                 $tokenMap[$token] = 0;
405             }
406             $tokenMap[$token]++;
407             $token = strtok($splitChars);
408         }
409
410         $terms = [];
411         foreach ($tokenMap as $token => $count) {
412             $terms[] = [
413                 'term' => $token,
414                 'score' => $count * $scoreAdjustment
415             ];
416         }
417         return $terms;
418     }
419
420
421
422
423     /**
424      * Custom entity search filters
425      */
426
427     protected function filterUpdatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
428     {
429         try {
430             $date = date_create($input);
431         } catch (\Exception $e) {
432             return;
433         }
434         $query->where('updated_at', '>=', $date);
435     }
436
437     protected function filterUpdatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
438     {
439         try {
440             $date = date_create($input);
441         } catch (\Exception $e) {
442             return;
443         }
444         $query->where('updated_at', '<', $date);
445     }
446
447     protected function filterCreatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
448     {
449         try {
450             $date = date_create($input);
451         } catch (\Exception $e) {
452             return;
453         }
454         $query->where('created_at', '>=', $date);
455     }
456
457     protected function filterCreatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
458     {
459         try {
460             $date = date_create($input);
461         } catch (\Exception $e) {
462             return;
463         }
464         $query->where('created_at', '<', $date);
465     }
466
467     protected function filterCreatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
468     {
469         if (!is_numeric($input) && $input !== 'me') {
470             return;
471         }
472         if ($input === 'me') {
473             $input = user()->id;
474         }
475         $query->where('created_by', '=', $input);
476     }
477
478     protected function filterUpdatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
479     {
480         if (!is_numeric($input) && $input !== 'me') {
481             return;
482         }
483         if ($input === 'me') {
484             $input = user()->id;
485         }
486         $query->where('updated_by', '=', $input);
487     }
488
489     protected function filterInName(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
490     {
491         $query->where('name', 'like', '%' .$input. '%');
492     }
493
494     protected function filterInTitle(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
495     {
496         $this->filterInName($query, $model, $input);
497     }
498
499     protected function filterInBody(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
500     {
501         $query->where($model->textField, 'like', '%' .$input. '%');
502     }
503
504     protected function filterIsRestricted(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
505     {
506         $query->where('restricted', '=', true);
507     }
508
509     protected function filterViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
510     {
511         $query->whereHas('views', function ($query) {
512             $query->where('user_id', '=', user()->id);
513         });
514     }
515
516     protected function filterNotViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
517     {
518         $query->whereDoesntHave('views', function ($query) {
519             $query->where('user_id', '=', user()->id);
520         });
521     }
522
523     protected function filterSortBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
524     {
525         $functionName = camel_case('sort_by_' . $input);
526         if (method_exists($this, $functionName)) {
527             $this->$functionName($query, $model);
528         }
529     }
530
531
532     /**
533      * Sorting filter options
534      */
535
536     protected function sortByLastCommented(\Illuminate\Database\Eloquent\Builder $query, Entity $model)
537     {
538         $commentsTable = $this->db->getTablePrefix() . 'comments';
539         $morphClass = str_replace('\\', '\\\\', $model->getMorphClass());
540         $commentQuery = $this->db->raw('(SELECT c1.entity_id, c1.entity_type, c1.created_at as last_commented FROM '.$commentsTable.' c1 LEFT JOIN '.$commentsTable.' c2 ON (c1.entity_id = c2.entity_id AND c1.entity_type = c2.entity_type AND c1.created_at < c2.created_at) WHERE c1.entity_type = \''. $morphClass .'\' AND c2.created_at IS NULL) as comments');
541
542         $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');
543     }
544 }