]> BookStack Code Mirror - bookstack/blob - app/Services/SearchService.php
2df02bc3e254d780817fafb52e294eb2103d18e3
[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
16     protected $searchTerm;
17     protected $book;
18     protected $chapter;
19     protected $page;
20     protected $db;
21     protected $permissionService;
22     protected $entities;
23
24     /**
25      * SearchService constructor.
26      * @param SearchTerm $searchTerm
27      * @param Book $book
28      * @param Chapter $chapter
29      * @param Page $page
30      * @param Connection $db
31      * @param PermissionService $permissionService
32      */
33     public function __construct(SearchTerm $searchTerm, Book $book, Chapter $chapter, Page $page, Connection $db, PermissionService $permissionService)
34     {
35         $this->searchTerm = $searchTerm;
36         $this->book = $book;
37         $this->chapter = $chapter;
38         $this->page = $page;
39         $this->db = $db;
40         $this->entities = [
41             'page' => $this->page,
42             'chapter' => $this->chapter,
43             'book' => $this->book
44         ];
45         $this->permissionService = $permissionService;
46     }
47
48     /**
49      * Search all entities in the system.
50      * @param $searchString
51      * @param string $entityType
52      * @param int $page
53      * @param int $count
54      * @return Collection
55      */
56     public function searchEntities($searchString, $entityType = 'all', $page = 0, $count = 20)
57     {
58         // TODO - Add Tag Searches
59         // TODO - Add advanced custom column searches
60         // TODO - Check drafts don't show up in results
61         // TODO - Move search all page to just /search?term=cat
62
63        if ($entityType !== 'all') return $this->searchEntityTable($searchString, $entityType, $page, $count);
64
65        $bookSearch = $this->searchEntityTable($searchString, 'book', $page, $count);
66        $chapterSearch = $this->searchEntityTable($searchString, 'chapter', $page, $count);
67        $pageSearch = $this->searchEntityTable($searchString, 'page', $page, $count);
68        return collect($bookSearch)->merge($chapterSearch)->merge($pageSearch)->sortByDesc('score');
69     }
70
71     /**
72      * Search across a particular entity type.
73      * @param string $searchString
74      * @param string $entityType
75      * @param int $page
76      * @param int $count
77      * @return \Illuminate\Database\Eloquent\Collection|static[]
78      */
79     public function searchEntityTable($searchString, $entityType = 'page', $page = 0, $count = 20)
80     {
81         $searchTerms = $this->parseSearchString($searchString);
82
83         $entity = $this->getEntity($entityType);
84         $entitySelect = $entity->newQuery();
85
86         // Handle normal search terms
87         if (count($searchTerms['search']) > 0) {
88             $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
89             $subQuery->where(function(Builder $query) use ($searchTerms) {
90                 foreach ($searchTerms['search'] as $inputTerm) {
91                     $query->orWhere('term', 'like', $inputTerm .'%');
92                 }
93             })->groupBy('entity_type', 'entity_id');
94             $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function(JoinClause $join) {
95                 $join->on('id', '=', 'entity_id');
96             })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc');
97             $entitySelect->mergeBindings($subQuery);
98         }
99
100         // Handle exact term matching
101         if (count($searchTerms['exact']) > 0) {
102             $entitySelect->where(function(\Illuminate\Database\Eloquent\Builder $query) use ($searchTerms, $entity) {
103                 foreach ($searchTerms['exact'] as $inputTerm) {
104                     $query->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($inputTerm, $entity) {
105                         $query->where('name', 'like', '%'.$inputTerm .'%')
106                             ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
107                     });
108                 }
109             });
110         }
111
112         $entitySelect->skip($page * $count)->take($count);
113         $query = $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view');
114         return $query->get();
115     }
116
117
118     /**
119      * Parse a search string into components.
120      * @param $searchString
121      * @return array
122      */
123     public function parseSearchString($searchString)
124     {
125         $terms = [
126             'search' => [],
127             'exact' => [],
128             'tags' => [],
129             'filters' => []
130         ];
131
132         $patterns = [
133             'exact' => '/"(.*?)"/',
134             'tags' => '/\[(.*?)\]/',
135             'filters' => '/\{(.*?)\}/'
136         ];
137
138         foreach ($patterns as $termType => $pattern) {
139             $matches = [];
140             preg_match_all($pattern, $searchString, $matches);
141             if (count($matches) > 0) {
142                 $terms[$termType] = $matches[1];
143                 $searchString = preg_replace($pattern, '', $searchString);
144             }
145         }
146
147         foreach (explode(' ', trim($searchString)) as $searchTerm) {
148             if ($searchTerm !== '') $terms['search'][] = $searchTerm;
149         }
150
151         return $terms;
152     }
153
154     /**
155      * Get an entity instance via type.
156      * @param $type
157      * @return Entity
158      */
159     protected function getEntity($type)
160     {
161         return $this->entities[strtolower($type)];
162     }
163
164     /**
165      * Index the given entity.
166      * @param Entity $entity
167      */
168     public function indexEntity(Entity $entity)
169     {
170         $this->deleteEntityTerms($entity);
171         $nameTerms = $this->generateTermArrayFromText($entity->name, 5);
172         $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1);
173         $terms = array_merge($nameTerms, $bodyTerms);
174         foreach ($terms as $index => $term) {
175             $terms[$index]['entity_type'] = $entity->getMorphClass();
176             $terms[$index]['entity_id'] = $entity->id;
177         }
178         $this->searchTerm->newQuery()->insert($terms);
179     }
180
181     /**
182      * Index multiple Entities at once
183      * @param Entity[] $entities
184      */
185     protected function indexEntities($entities) {
186         $terms = [];
187         foreach ($entities as $entity) {
188             $nameTerms = $this->generateTermArrayFromText($entity->name, 5);
189             $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1);
190             foreach (array_merge($nameTerms, $bodyTerms) as $term) {
191                 $term['entity_id'] = $entity->id;
192                 $term['entity_type'] = $entity->getMorphClass();
193                 $terms[] = $term;
194             }
195         }
196
197         $chunkedTerms = array_chunk($terms, 500);
198         foreach ($chunkedTerms as $termChunk) {
199             $this->searchTerm->newQuery()->insert($termChunk);
200         }
201     }
202
203     /**
204      * Delete and re-index the terms for all entities in the system.
205      */
206     public function indexAllEntities()
207     {
208         $this->searchTerm->truncate();
209
210         // Chunk through all books
211         $this->book->chunk(1000, function ($books) {
212             $this->indexEntities($books);
213         });
214
215         // Chunk through all chapters
216         $this->chapter->chunk(1000, function ($chapters) {
217             $this->indexEntities($chapters);
218         });
219
220         // Chunk through all pages
221         $this->page->chunk(1000, function ($pages) {
222             $this->indexEntities($pages);
223         });
224     }
225
226     /**
227      * Delete related Entity search terms.
228      * @param Entity $entity
229      */
230     public function deleteEntityTerms(Entity $entity)
231     {
232         $entity->searchTerms()->delete();
233     }
234
235     /**
236      * Create a scored term array from the given text.
237      * @param $text
238      * @param float|int $scoreAdjustment
239      * @return array
240      */
241     protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
242     {
243         $tokenMap = []; // {TextToken => OccurrenceCount}
244         $splitText = explode(' ', $text);
245         foreach ($splitText as $token) {
246             if ($token === '') continue;
247             if (!isset($tokenMap[$token])) $tokenMap[$token] = 0;
248             $tokenMap[$token]++;
249         }
250
251         $terms = [];
252         foreach ($tokenMap as $token => $count) {
253             $terms[] = [
254                 'term' => $token,
255                 'score' => $count * $scoreAdjustment
256             ];
257         }
258         return $terms;
259     }
260
261 }