]> BookStack Code Mirror - bookstack/blob - app/Services/SearchService.php
ae8dd008a86ef81b220b580c59288750414494dd
[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\JoinClause;
10
11 class SearchService
12 {
13
14     protected $searchTerm;
15     protected $book;
16     protected $chapter;
17     protected $page;
18     protected $db;
19     protected $permissionService;
20     protected $entities;
21
22     /**
23      * SearchService constructor.
24      * @param SearchTerm $searchTerm
25      * @param Book $book
26      * @param Chapter $chapter
27      * @param Page $page
28      * @param Connection $db
29      * @param PermissionService $permissionService
30      */
31     public function __construct(SearchTerm $searchTerm, Book $book, Chapter $chapter, Page $page, Connection $db, PermissionService $permissionService)
32     {
33         $this->searchTerm = $searchTerm;
34         $this->book = $book;
35         $this->chapter = $chapter;
36         $this->page = $page;
37         $this->db = $db;
38         $this->entities = [
39             'page' => $this->page,
40             'chapter' => $this->chapter,
41             'book' => $this->book
42         ];
43         $this->permissionService = $permissionService;
44     }
45
46     public function searchEntities($searchString, $entityType = 'all', $page = 0, $count = 20)
47     {
48         // TODO - Add Tag Searches
49         // TODO - Add advanced custom column searches
50         // TODO - Add exact match searches ("")
51         // TODO - Check drafts don't show up in results
52         // TODO - Move search all page to just /search?term=cat
53
54        if ($entityType !== 'all') return $this->searchEntityTable($searchString, $entityType, $page, $count);
55
56        $bookSearch = $this->searchEntityTable($searchString, 'book', $page, $count);
57        $chapterSearch = $this->searchEntityTable($searchString, 'chapter', $page, $count);
58        $pageSearch = $this->searchEntityTable($searchString, 'page', $page, $count);
59        return collect($bookSearch)->merge($chapterSearch)->merge($pageSearch)->sortByDesc('score');
60     }
61
62     public function searchEntityTable($searchString, $entityType = 'page', $page = 0, $count = 20)
63     {
64         $termArray = explode(' ', $searchString);
65
66         $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
67         $subQuery->where(function($query) use ($termArray) {
68             foreach ($termArray as $inputTerm) {
69                 $query->orWhere('term', 'like', $inputTerm .'%');
70             }
71         });
72
73         $entity = $this->getEntity($entityType);
74         $subQuery = $subQuery->groupBy('entity_type', 'entity_id');
75         $entitySelect = $entity->newQuery()->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function(JoinClause $join) {
76             $join->on('id', '=', 'entity_id');
77         })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc')->skip($page * $count)->take($count);
78         $entitySelect->mergeBindings($subQuery);
79         $query = $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view');
80         return $query->get();
81     }
82
83     /**
84      * Get an entity instance via type.
85      * @param $type
86      * @return Entity
87      */
88     protected function getEntity($type)
89     {
90         return $this->entities[strtolower($type)];
91     }
92
93     /**
94      * Index the given entity.
95      * @param Entity $entity
96      */
97     public function indexEntity(Entity $entity)
98     {
99         $this->deleteEntityTerms($entity);
100         $nameTerms = $this->generateTermArrayFromText($entity->name, 5);
101         $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1);
102         $terms = array_merge($nameTerms, $bodyTerms);
103         $entity->searchTerms()->createMany($terms);
104     }
105
106     /**
107      * Index multiple Entities at once
108      * @param Entity[] $entities
109      */
110     protected function indexEntities($entities) {
111         $terms = [];
112         foreach ($entities as $entity) {
113             $nameTerms = $this->generateTermArrayFromText($entity->name, 5);
114             $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1);
115             foreach (array_merge($nameTerms, $bodyTerms) as $term) {
116                 $term['entity_id'] = $entity->id;
117                 $term['entity_type'] = $entity->getMorphClass();
118                 $terms[] = $term;
119             }
120         }
121
122         $chunkedTerms = array_chunk($terms, 500);
123         foreach ($chunkedTerms as $termChunk) {
124             $this->searchTerm->insert($termChunk);
125         }
126     }
127
128     /**
129      * Delete and re-index the terms for all entities in the system.
130      */
131     public function indexAllEntities()
132     {
133         $this->searchTerm->truncate();
134
135         // Chunk through all books
136         $this->book->chunk(1000, function ($books) {
137             $this->indexEntities($books);
138         });
139
140         // Chunk through all chapters
141         $this->chapter->chunk(1000, function ($chapters) {
142             $this->indexEntities($chapters);
143         });
144
145         // Chunk through all pages
146         $this->page->chunk(1000, function ($pages) {
147             $this->indexEntities($pages);
148         });
149     }
150
151     /**
152      * Delete related Entity search terms.
153      * @param Entity $entity
154      */
155     public function deleteEntityTerms(Entity $entity)
156     {
157         $entity->searchTerms()->delete();
158     }
159
160     /**
161      * Create a scored term array from the given text.
162      * @param $text
163      * @param float|int $scoreAdjustment
164      * @return array
165      */
166     protected function generateTermArrayFromText($text, $scoreAdjustment = 1)
167     {
168         $tokenMap = []; // {TextToken => OccurrenceCount}
169         $splitText = explode(' ', $text);
170         foreach ($splitText as $token) {
171             if ($token === '') continue;
172             if (!isset($tokenMap[$token])) $tokenMap[$token] = 0;
173             $tokenMap[$token]++;
174         }
175
176         $terms = [];
177         foreach ($tokenMap as $token => $count) {
178             $terms[] = [
179                 'term' => $token,
180                 'score' => $count * $scoreAdjustment
181             ];
182         }
183         return $terms;
184     }
185
186 }