1 <?php namespace BookStack\Entities;
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;
11 protected $searchTerm;
17 protected $permissionService;
25 * Acceptable operators to be used in a query
28 protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!='];
31 * SearchService constructor.
32 * @param SearchTerm $searchTerm
33 * @param Bookshelf $bookshelf
34 * @param \BookStack\Entities\Book $book
35 * @param \BookStack\Entities\Chapter $chapter
37 * @param Connection $db
38 * @param PermissionService $permissionService
40 public function __construct(SearchTerm $searchTerm, Bookshelf $bookshelf, Book $book, Chapter $chapter, Page $page, Connection $db, PermissionService $permissionService)
42 $this->searchTerm = $searchTerm;
43 $this->bookshelf = $bookshelf;
45 $this->chapter = $chapter;
49 'bookshelf' => $this->bookshelf,
50 'page' => $this->page,
51 'chapter' => $this->chapter,
54 $this->permissionService = $permissionService;
58 * Set the database connection
59 * @param Connection $connection
61 public function setConnection(Connection $connection)
63 $this->db = $connection;
67 * Search all entities in the system.
68 * @param string $searchString
69 * @param string $entityType
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];
75 public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view')
77 $terms = $this->parseSearchString($searchString);
78 $entityTypes = array_keys($this->entities);
79 $entityTypesToSearch = $entityTypes;
81 if ($entityType !== 'all') {
82 $entityTypesToSearch = $entityType;
83 } else if (isset($terms['filters']['type'])) {
84 $entityTypesToSearch = explode('|', $terms['filters']['type']);
91 foreach ($entityTypesToSearch as $entityType) {
92 if (!in_array($entityType, $entityTypes)) {
95 $search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
96 $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
97 if ($entityTotal > $page * $count) {
100 $total += $entityTotal;
101 $results = $results->merge($search);
106 'count' => count($results),
107 'has_more' => $hasMore,
108 'results' => $results->sortByDesc('score')->values()
114 * Search a book for entities
115 * @param integer $bookId
116 * @param string $searchString
119 public function searchBook($bookId, $searchString)
121 $terms = $this->parseSearchString($searchString);
122 $entityTypes = ['page', 'chapter'];
123 $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
125 $results = collect();
126 foreach ($entityTypesToSearch as $entityType) {
127 if (!in_array($entityType, $entityTypes)) {
130 $search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
131 $results = $results->merge($search);
133 return $results->sortByDesc('score')->take(20);
137 * Search a book for entities
138 * @param integer $chapterId
139 * @param string $searchString
142 public function searchChapter($chapterId, $searchString)
144 $terms = $this->parseSearchString($searchString);
145 $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
146 return $pages->sortByDesc('score');
150 * Search across a particular entity type.
151 * @param array $terms
152 * @param string $entityType
155 * @param string $action
156 * @param bool $getCount Return the total count of the search
157 * @return \Illuminate\Database\Eloquent\Collection|int|static[]
159 public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false)
161 $query = $this->buildEntitySearchQuery($terms, $entityType, $action);
163 return $query->count();
166 $query = $query->skip(($page-1) * $count)->take($count);
167 return $query->get();
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
177 protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view')
179 $entity = $this->getEntity($entityType);
180 $entitySelect = $entity->newQuery();
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 .'%');
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);
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 .'%');
209 // Handle tag searches
210 foreach ($terms['tags'] as $inputTerm) {
211 $this->applyTagSearch($entitySelect, $inputTerm);
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);
222 return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
227 * Parse a search string into components.
228 * @param $searchString
231 protected function parseSearchString($searchString)
241 'exact' => '/"(.*?)"/',
242 'tags' => '/\[(.*?)\]/',
243 'filters' => '/\{(.*?)\}/'
246 // Parse special terms
247 foreach ($patterns as $termType => $pattern) {
249 preg_match_all($pattern, $searchString, $matches);
250 if (count($matches) > 0) {
251 $terms[$termType] = $matches[1];
252 $searchString = preg_replace($pattern, '', $searchString);
256 // Parse standard terms
257 foreach (explode(' ', trim($searchString)) as $searchTerm) {
258 if ($searchTerm !== '') {
259 $terms['search'][] = $searchTerm;
263 // Split filter values out
265 foreach ($terms['filters'] as $filter) {
266 $explodedFilter = explode(':', $filter, 2);
267 $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
269 $terms['filters'] = $splitFilters;
275 * Get the available query operators as a regex escaped list.
278 protected function getRegexEscapedOperators()
280 $escapedOperators = [];
281 foreach ($this->queryOperators as $operator) {
282 $escapedOperators[] = preg_quote($operator);
284 return join('|', $escapedOperators);
288 * Apply a tag search term onto a entity query.
289 * @param \Illuminate\Database\Eloquent\Builder $query
290 * @param string $tagTerm
293 protected function applyTagSearch(\Illuminate\Database\Eloquent\Builder $query, $tagTerm)
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);
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}");
312 $query->where('value', $tagOperator, $tagValue);
315 $query->where('name', '=', $tagName);
322 * Get an entity instance via type.
326 protected function getEntity($type)
328 return $this->entities[strtolower($type)];
332 * Index the given entity.
333 * @param Entity $entity
335 public function indexEntity(Entity $entity)
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;
345 $this->searchTerm->newQuery()->insert($terms);
349 * Index multiple Entities at once
350 * @param \BookStack\Entities\Entity[] $entities
352 protected function indexEntities($entities)
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();
365 $chunkedTerms = array_chunk($terms, 500);
366 foreach ($chunkedTerms as $termChunk) {
367 $this->searchTerm->newQuery()->insert($termChunk);
372 * Delete and re-index the terms for all entities in the system.
374 public function indexAllEntities()
376 $this->searchTerm->truncate();
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);
387 * Delete related Entity search terms.
388 * @param Entity $entity
390 public function deleteEntityTerms(Entity $entity)
392 $entity->searchTerms()->delete();
396 * Create a scored term array from the given text.
398 * @param float|int $scoreAdjustment
401 protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
403 $tokenMap = []; // {TextToken => OccurrenceCount}
404 $splitChars = " \n\t.,!?:;()[]{}<>`'\"";
405 $token = strtok($text, $splitChars);
407 while ($token !== false) {
408 if (!isset($tokenMap[$token])) {
409 $tokenMap[$token] = 0;
412 $token = strtok($splitChars);
416 foreach ($tokenMap as $token => $count) {
419 'score' => $count * $scoreAdjustment
429 * Custom entity search filters
432 protected function filterUpdatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
435 $date = date_create($input);
436 } catch (\Exception $e) {
439 $query->where('updated_at', '>=', $date);
442 protected function filterUpdatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
445 $date = date_create($input);
446 } catch (\Exception $e) {
449 $query->where('updated_at', '<', $date);
452 protected function filterCreatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
455 $date = date_create($input);
456 } catch (\Exception $e) {
459 $query->where('created_at', '>=', $date);
462 protected function filterCreatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
465 $date = date_create($input);
466 } catch (\Exception $e) {
469 $query->where('created_at', '<', $date);
472 protected function filterCreatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
474 if (!is_numeric($input) && $input !== 'me') {
477 if ($input === 'me') {
480 $query->where('created_by', '=', $input);
483 protected function filterUpdatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
485 if (!is_numeric($input) && $input !== 'me') {
488 if ($input === 'me') {
491 $query->where('updated_by', '=', $input);
494 protected function filterInName(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
496 $query->where('name', 'like', '%' .$input. '%');
499 protected function filterInTitle(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
501 $this->filterInName($query, $model, $input);
504 protected function filterInBody(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
506 $query->where($model->textField, 'like', '%' .$input. '%');
509 protected function filterIsRestricted(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
511 $query->where('restricted', '=', true);
514 protected function filterViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
516 $query->whereHas('views', function ($query) {
517 $query->where('user_id', '=', user()->id);
521 protected function filterNotViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
523 $query->whereDoesntHave('views', function ($query) {
524 $query->where('user_id', '=', user()->id);
528 protected function filterSortBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input)
530 $functionName = camel_case('sort_by_' . $input);
531 if (method_exists($this, $functionName)) {
532 $this->$functionName($query, $model);
538 * Sorting filter options
541 protected function sortByLastCommented(\Illuminate\Database\Eloquent\Builder $query, Entity $model)
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');
547 $query->join($commentQuery, $model->getTable() . '.id', '=', 'comments.entity_id')->orderBy('last_commented', 'desc');