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