Showing
13 changed files
with
374 additions
and
53 deletions
| ... | @@ -56,4 +56,13 @@ class Book extends Entity | ... | @@ -56,4 +56,13 @@ class Book extends Entity |
| 56 | return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description; | 56 | return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description; |
| 57 | } | 57 | } |
| 58 | 58 | ||
| 59 | + /** | ||
| 60 | + * Return a generalised, common raw query that can be 'unioned' across entities. | ||
| 61 | + * @return string | ||
| 62 | + */ | ||
| 63 | + public function entityRawQuery() | ||
| 64 | + { | ||
| 65 | + return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at"; | ||
| 66 | + } | ||
| 67 | + | ||
| 59 | } | 68 | } | ... | ... |
| ... | @@ -51,4 +51,13 @@ class Chapter extends Entity | ... | @@ -51,4 +51,13 @@ class Chapter extends Entity |
| 51 | return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description; | 51 | return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description; |
| 52 | } | 52 | } |
| 53 | 53 | ||
| 54 | + /** | ||
| 55 | + * Return a generalised, common raw query that can be 'unioned' across entities. | ||
| 56 | + * @return string | ||
| 57 | + */ | ||
| 58 | + public function entityRawQuery() | ||
| 59 | + { | ||
| 60 | + return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at"; | ||
| 61 | + } | ||
| 62 | + | ||
| 54 | } | 63 | } | ... | ... |
app/Console/Commands/RegenerateSearch.php
0 → 100644
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +namespace BookStack\Console\Commands; | ||
| 4 | + | ||
| 5 | +use BookStack\Services\SearchService; | ||
| 6 | +use Illuminate\Console\Command; | ||
| 7 | + | ||
| 8 | +class RegenerateSearch extends Command | ||
| 9 | +{ | ||
| 10 | + /** | ||
| 11 | + * The name and signature of the console command. | ||
| 12 | + * | ||
| 13 | + * @var string | ||
| 14 | + */ | ||
| 15 | + protected $signature = 'bookstack:regenerate-search'; | ||
| 16 | + | ||
| 17 | + /** | ||
| 18 | + * The console command description. | ||
| 19 | + * | ||
| 20 | + * @var string | ||
| 21 | + */ | ||
| 22 | + protected $description = 'Command description'; | ||
| 23 | + | ||
| 24 | + protected $searchService; | ||
| 25 | + | ||
| 26 | + /** | ||
| 27 | + * Create a new command instance. | ||
| 28 | + * | ||
| 29 | + * @param SearchService $searchService | ||
| 30 | + */ | ||
| 31 | + public function __construct(SearchService $searchService) | ||
| 32 | + { | ||
| 33 | + parent::__construct(); | ||
| 34 | + $this->searchService = $searchService; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + /** | ||
| 38 | + * Execute the console command. | ||
| 39 | + * | ||
| 40 | + * @return mixed | ||
| 41 | + */ | ||
| 42 | + public function handle() | ||
| 43 | + { | ||
| 44 | + $this->searchService->indexAllEntities(); | ||
| 45 | + } | ||
| 46 | +} |
| 1 | -<?php | 1 | +<?php namespace BookStack\Console; |
| 2 | - | ||
| 3 | -namespace BookStack\Console; | ||
| 4 | 2 | ||
| 5 | use Illuminate\Console\Scheduling\Schedule; | 3 | use Illuminate\Console\Scheduling\Schedule; |
| 6 | use Illuminate\Foundation\Console\Kernel as ConsoleKernel; | 4 | use Illuminate\Foundation\Console\Kernel as ConsoleKernel; |
| ... | @@ -13,10 +11,11 @@ class Kernel extends ConsoleKernel | ... | @@ -13,10 +11,11 @@ class Kernel extends ConsoleKernel |
| 13 | * @var array | 11 | * @var array |
| 14 | */ | 12 | */ |
| 15 | protected $commands = [ | 13 | protected $commands = [ |
| 16 | - \BookStack\Console\Commands\ClearViews::class, | 14 | + Commands\ClearViews::class, |
| 17 | - \BookStack\Console\Commands\ClearActivity::class, | 15 | + Commands\ClearActivity::class, |
| 18 | - \BookStack\Console\Commands\ClearRevisions::class, | 16 | + Commands\ClearRevisions::class, |
| 19 | - \BookStack\Console\Commands\RegeneratePermissions::class, | 17 | + Commands\RegeneratePermissions::class, |
| 18 | + Commands\RegenerateSearch::class | ||
| 20 | ]; | 19 | ]; |
| 21 | 20 | ||
| 22 | /** | 21 | /** | ... | ... |
| ... | @@ -4,7 +4,7 @@ | ... | @@ -4,7 +4,7 @@ |
| 4 | class Entity extends Ownable | 4 | class Entity extends Ownable |
| 5 | { | 5 | { |
| 6 | 6 | ||
| 7 | - protected $fieldsToSearch = ['name', 'description']; | 7 | + protected $textField = 'description'; |
| 8 | 8 | ||
| 9 | /** | 9 | /** |
| 10 | * Compares this entity to another given entity. | 10 | * Compares this entity to another given entity. |
| ... | @@ -66,6 +66,15 @@ class Entity extends Ownable | ... | @@ -66,6 +66,15 @@ class Entity extends Ownable |
| 66 | } | 66 | } |
| 67 | 67 | ||
| 68 | /** | 68 | /** |
| 69 | + * Get the related search terms. | ||
| 70 | + * @return \Illuminate\Database\Eloquent\Relations\MorphMany | ||
| 71 | + */ | ||
| 72 | + public function searchTerms() | ||
| 73 | + { | ||
| 74 | + return $this->morphMany(SearchTerm::class, 'entity'); | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + /** | ||
| 69 | * Get this entities restrictions. | 78 | * Get this entities restrictions. |
| 70 | */ | 79 | */ |
| 71 | public function permissions() | 80 | public function permissions() |
| ... | @@ -153,10 +162,25 @@ class Entity extends Ownable | ... | @@ -153,10 +162,25 @@ class Entity extends Ownable |
| 153 | } | 162 | } |
| 154 | 163 | ||
| 155 | /** | 164 | /** |
| 165 | + * Get the body text of this entity. | ||
| 166 | + * @return mixed | ||
| 167 | + */ | ||
| 168 | + public function getText() | ||
| 169 | + { | ||
| 170 | + return $this->{$this->textField}; | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + /** | ||
| 174 | + * Return a generalised, common raw query that can be 'unioned' across entities. | ||
| 175 | + * @return string | ||
| 176 | + */ | ||
| 177 | + public function entityRawQuery(){return '';} | ||
| 178 | + | ||
| 179 | + /** | ||
| 156 | * Perform a full-text search on this entity. | 180 | * Perform a full-text search on this entity. |
| 157 | - * @param string[] $fieldsToSearch | ||
| 158 | * @param string[] $terms | 181 | * @param string[] $terms |
| 159 | * @param string[] array $wheres | 182 | * @param string[] array $wheres |
| 183 | + * TODO - REMOVE | ||
| 160 | * @return mixed | 184 | * @return mixed |
| 161 | */ | 185 | */ |
| 162 | public function fullTextSearchQuery($terms, $wheres = []) | 186 | public function fullTextSearchQuery($terms, $wheres = []) |
| ... | @@ -178,21 +202,21 @@ class Entity extends Ownable | ... | @@ -178,21 +202,21 @@ class Entity extends Ownable |
| 178 | } | 202 | } |
| 179 | 203 | ||
| 180 | $isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0; | 204 | $isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0; |
| 181 | - | 205 | + $fieldsToSearch = ['name', $this->textField]; |
| 182 | 206 | ||
| 183 | // Perform fulltext search if relevant terms exist. | 207 | // Perform fulltext search if relevant terms exist. |
| 184 | if ($isFuzzy) { | 208 | if ($isFuzzy) { |
| 185 | $termString = implode(' ', $fuzzyTerms); | 209 | $termString = implode(' ', $fuzzyTerms); |
| 186 | - $fields = implode(',', $this->fieldsToSearch); | 210 | + |
| 187 | $search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]); | 211 | $search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]); |
| 188 | - $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]); | 212 | + $search = $search->whereRaw('MATCH(' . implode(',', $fieldsToSearch ). ') AGAINST(? IN BOOLEAN MODE)', [$termString]); |
| 189 | } | 213 | } |
| 190 | 214 | ||
| 191 | // Ensure at least one exact term matches if in search | 215 | // Ensure at least one exact term matches if in search |
| 192 | if (count($exactTerms) > 0) { | 216 | if (count($exactTerms) > 0) { |
| 193 | - $search = $search->where(function ($query) use ($exactTerms) { | 217 | + $search = $search->where(function ($query) use ($exactTerms, $fieldsToSearch) { |
| 194 | foreach ($exactTerms as $exactTerm) { | 218 | foreach ($exactTerms as $exactTerm) { |
| 195 | - foreach ($this->fieldsToSearch as $field) { | 219 | + foreach ($fieldsToSearch as $field) { |
| 196 | $query->orWhere($field, 'like', $exactTerm); | 220 | $query->orWhere($field, 'like', $exactTerm); |
| 197 | } | 221 | } |
| 198 | } | 222 | } | ... | ... |
| 1 | <?php namespace BookStack\Http\Controllers; | 1 | <?php namespace BookStack\Http\Controllers; |
| 2 | 2 | ||
| 3 | use BookStack\Repos\EntityRepo; | 3 | use BookStack\Repos\EntityRepo; |
| 4 | +use BookStack\Services\SearchService; | ||
| 4 | use BookStack\Services\ViewService; | 5 | use BookStack\Services\ViewService; |
| 5 | use Illuminate\Http\Request; | 6 | use Illuminate\Http\Request; |
| 6 | 7 | ||
| ... | @@ -8,16 +9,19 @@ class SearchController extends Controller | ... | @@ -8,16 +9,19 @@ class SearchController extends Controller |
| 8 | { | 9 | { |
| 9 | protected $entityRepo; | 10 | protected $entityRepo; |
| 10 | protected $viewService; | 11 | protected $viewService; |
| 12 | + protected $searchService; | ||
| 11 | 13 | ||
| 12 | /** | 14 | /** |
| 13 | * SearchController constructor. | 15 | * SearchController constructor. |
| 14 | * @param EntityRepo $entityRepo | 16 | * @param EntityRepo $entityRepo |
| 15 | * @param ViewService $viewService | 17 | * @param ViewService $viewService |
| 18 | + * @param SearchService $searchService | ||
| 16 | */ | 19 | */ |
| 17 | - public function __construct(EntityRepo $entityRepo, ViewService $viewService) | 20 | + public function __construct(EntityRepo $entityRepo, ViewService $viewService, SearchService $searchService) |
| 18 | { | 21 | { |
| 19 | $this->entityRepo = $entityRepo; | 22 | $this->entityRepo = $entityRepo; |
| 20 | $this->viewService = $viewService; | 23 | $this->viewService = $viewService; |
| 24 | + $this->searchService = $searchService; | ||
| 21 | parent::__construct(); | 25 | parent::__construct(); |
| 22 | } | 26 | } |
| 23 | 27 | ||
| ... | @@ -33,15 +37,13 @@ class SearchController extends Controller | ... | @@ -33,15 +37,13 @@ class SearchController extends Controller |
| 33 | return redirect()->back(); | 37 | return redirect()->back(); |
| 34 | } | 38 | } |
| 35 | $searchTerm = $request->get('term'); | 39 | $searchTerm = $request->get('term'); |
| 36 | - $paginationAppends = $request->only('term'); | 40 | +// $paginationAppends = $request->only('term'); TODO - Check pagination |
| 37 | - $pages = $this->entityRepo->getBySearch('page', $searchTerm, [], 20, $paginationAppends); | ||
| 38 | - $books = $this->entityRepo->getBySearch('book', $searchTerm, [], 10, $paginationAppends); | ||
| 39 | - $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, [], 10, $paginationAppends); | ||
| 40 | $this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm])); | 41 | $this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm])); |
| 42 | + | ||
| 43 | + $entities = $this->searchService->searchEntities($searchTerm); | ||
| 44 | + | ||
| 41 | return view('search/all', [ | 45 | return view('search/all', [ |
| 42 | - 'pages' => $pages, | 46 | + 'entities' => $entities, |
| 43 | - 'books' => $books, | ||
| 44 | - 'chapters' => $chapters, | ||
| 45 | 'searchTerm' => $searchTerm | 47 | 'searchTerm' => $searchTerm |
| 46 | ]); | 48 | ]); |
| 47 | } | 49 | } | ... | ... |
| ... | @@ -8,8 +8,7 @@ class Page extends Entity | ... | @@ -8,8 +8,7 @@ class Page extends Entity |
| 8 | protected $simpleAttributes = ['name', 'id', 'slug']; | 8 | protected $simpleAttributes = ['name', 'id', 'slug']; |
| 9 | 9 | ||
| 10 | protected $with = ['book']; | 10 | protected $with = ['book']; |
| 11 | - | 11 | + protected $textField = 'text'; |
| 12 | - protected $fieldsToSearch = ['name', 'text']; | ||
| 13 | 12 | ||
| 14 | /** | 13 | /** |
| 15 | * Converts this page into a simplified array. | 14 | * Converts this page into a simplified array. |
| ... | @@ -96,4 +95,14 @@ class Page extends Entity | ... | @@ -96,4 +95,14 @@ class Page extends Entity |
| 96 | return mb_convert_encoding($text, 'UTF-8'); | 95 | return mb_convert_encoding($text, 'UTF-8'); |
| 97 | } | 96 | } |
| 98 | 97 | ||
| 98 | + /** | ||
| 99 | + * Return a generalised, common raw query that can be 'unioned' across entities. | ||
| 100 | + * @param bool $withContent | ||
| 101 | + * @return string | ||
| 102 | + */ | ||
| 103 | + public function entityRawQuery($withContent = false) | ||
| 104 | + { $htmlQuery = $withContent ? 'html' : "'' as html"; | ||
| 105 | + return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at"; | ||
| 106 | + } | ||
| 107 | + | ||
| 99 | } | 108 | } | ... | ... |
| ... | @@ -8,6 +8,7 @@ use BookStack\Page; | ... | @@ -8,6 +8,7 @@ use BookStack\Page; |
| 8 | use BookStack\PageRevision; | 8 | use BookStack\PageRevision; |
| 9 | use BookStack\Services\AttachmentService; | 9 | use BookStack\Services\AttachmentService; |
| 10 | use BookStack\Services\PermissionService; | 10 | use BookStack\Services\PermissionService; |
| 11 | +use BookStack\Services\SearchService; | ||
| 11 | use BookStack\Services\ViewService; | 12 | use BookStack\Services\ViewService; |
| 12 | use Carbon\Carbon; | 13 | use Carbon\Carbon; |
| 13 | use DOMDocument; | 14 | use DOMDocument; |
| ... | @@ -59,13 +60,18 @@ class EntityRepo | ... | @@ -59,13 +60,18 @@ class EntityRepo |
| 59 | protected $tagRepo; | 60 | protected $tagRepo; |
| 60 | 61 | ||
| 61 | /** | 62 | /** |
| 63 | + * @var SearchService | ||
| 64 | + */ | ||
| 65 | + protected $searchService; | ||
| 66 | + | ||
| 67 | + /** | ||
| 62 | * Acceptable operators to be used in a query | 68 | * Acceptable operators to be used in a query |
| 63 | * @var array | 69 | * @var array |
| 64 | */ | 70 | */ |
| 65 | protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; | 71 | protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; |
| 66 | 72 | ||
| 67 | /** | 73 | /** |
| 68 | - * EntityService constructor. | 74 | + * EntityRepo constructor. |
| 69 | * @param Book $book | 75 | * @param Book $book |
| 70 | * @param Chapter $chapter | 76 | * @param Chapter $chapter |
| 71 | * @param Page $page | 77 | * @param Page $page |
| ... | @@ -73,10 +79,12 @@ class EntityRepo | ... | @@ -73,10 +79,12 @@ class EntityRepo |
| 73 | * @param ViewService $viewService | 79 | * @param ViewService $viewService |
| 74 | * @param PermissionService $permissionService | 80 | * @param PermissionService $permissionService |
| 75 | * @param TagRepo $tagRepo | 81 | * @param TagRepo $tagRepo |
| 82 | + * @param SearchService $searchService | ||
| 76 | */ | 83 | */ |
| 77 | public function __construct( | 84 | public function __construct( |
| 78 | Book $book, Chapter $chapter, Page $page, PageRevision $pageRevision, | 85 | Book $book, Chapter $chapter, Page $page, PageRevision $pageRevision, |
| 79 | - ViewService $viewService, PermissionService $permissionService, TagRepo $tagRepo | 86 | + ViewService $viewService, PermissionService $permissionService, |
| 87 | + TagRepo $tagRepo, SearchService $searchService | ||
| 80 | ) | 88 | ) |
| 81 | { | 89 | { |
| 82 | $this->book = $book; | 90 | $this->book = $book; |
| ... | @@ -91,6 +99,7 @@ class EntityRepo | ... | @@ -91,6 +99,7 @@ class EntityRepo |
| 91 | $this->viewService = $viewService; | 99 | $this->viewService = $viewService; |
| 92 | $this->permissionService = $permissionService; | 100 | $this->permissionService = $permissionService; |
| 93 | $this->tagRepo = $tagRepo; | 101 | $this->tagRepo = $tagRepo; |
| 102 | + $this->searchService = $searchService; | ||
| 94 | } | 103 | } |
| 95 | 104 | ||
| 96 | /** | 105 | /** |
| ... | @@ -608,6 +617,7 @@ class EntityRepo | ... | @@ -608,6 +617,7 @@ class EntityRepo |
| 608 | $entity->updated_by = user()->id; | 617 | $entity->updated_by = user()->id; |
| 609 | $isChapter ? $book->chapters()->save($entity) : $entity->save(); | 618 | $isChapter ? $book->chapters()->save($entity) : $entity->save(); |
| 610 | $this->permissionService->buildJointPermissionsForEntity($entity); | 619 | $this->permissionService->buildJointPermissionsForEntity($entity); |
| 620 | + $this->searchService->indexEntity($entity); | ||
| 611 | return $entity; | 621 | return $entity; |
| 612 | } | 622 | } |
| 613 | 623 | ||
| ... | @@ -628,6 +638,7 @@ class EntityRepo | ... | @@ -628,6 +638,7 @@ class EntityRepo |
| 628 | $entityModel->updated_by = user()->id; | 638 | $entityModel->updated_by = user()->id; |
| 629 | $entityModel->save(); | 639 | $entityModel->save(); |
| 630 | $this->permissionService->buildJointPermissionsForEntity($entityModel); | 640 | $this->permissionService->buildJointPermissionsForEntity($entityModel); |
| 641 | + $this->searchService->indexEntity($entityModel); | ||
| 631 | return $entityModel; | 642 | return $entityModel; |
| 632 | } | 643 | } |
| 633 | 644 | ||
| ... | @@ -961,6 +972,8 @@ class EntityRepo | ... | @@ -961,6 +972,8 @@ class EntityRepo |
| 961 | $this->savePageRevision($page, $input['summary']); | 972 | $this->savePageRevision($page, $input['summary']); |
| 962 | } | 973 | } |
| 963 | 974 | ||
| 975 | + $this->searchService->indexEntity($page); | ||
| 976 | + | ||
| 964 | return $page; | 977 | return $page; |
| 965 | } | 978 | } |
| 966 | 979 | ||
| ... | @@ -1064,6 +1077,7 @@ class EntityRepo | ... | @@ -1064,6 +1077,7 @@ class EntityRepo |
| 1064 | $page->text = strip_tags($page->html); | 1077 | $page->text = strip_tags($page->html); |
| 1065 | $page->updated_by = user()->id; | 1078 | $page->updated_by = user()->id; |
| 1066 | $page->save(); | 1079 | $page->save(); |
| 1080 | + $this->searchService->indexEntity($page); | ||
| 1067 | return $page; | 1081 | return $page; |
| 1068 | } | 1082 | } |
| 1069 | 1083 | ||
| ... | @@ -1156,6 +1170,7 @@ class EntityRepo | ... | @@ -1156,6 +1170,7 @@ class EntityRepo |
| 1156 | $book->views()->delete(); | 1170 | $book->views()->delete(); |
| 1157 | $book->permissions()->delete(); | 1171 | $book->permissions()->delete(); |
| 1158 | $this->permissionService->deleteJointPermissionsForEntity($book); | 1172 | $this->permissionService->deleteJointPermissionsForEntity($book); |
| 1173 | + $this->searchService->deleteEntityTerms($book); | ||
| 1159 | $book->delete(); | 1174 | $book->delete(); |
| 1160 | } | 1175 | } |
| 1161 | 1176 | ||
| ... | @@ -1175,6 +1190,7 @@ class EntityRepo | ... | @@ -1175,6 +1190,7 @@ class EntityRepo |
| 1175 | $chapter->views()->delete(); | 1190 | $chapter->views()->delete(); |
| 1176 | $chapter->permissions()->delete(); | 1191 | $chapter->permissions()->delete(); |
| 1177 | $this->permissionService->deleteJointPermissionsForEntity($chapter); | 1192 | $this->permissionService->deleteJointPermissionsForEntity($chapter); |
| 1193 | + $this->searchService->deleteEntityTerms($chapter); | ||
| 1178 | $chapter->delete(); | 1194 | $chapter->delete(); |
| 1179 | } | 1195 | } |
| 1180 | 1196 | ||
| ... | @@ -1190,6 +1206,7 @@ class EntityRepo | ... | @@ -1190,6 +1206,7 @@ class EntityRepo |
| 1190 | $page->revisions()->delete(); | 1206 | $page->revisions()->delete(); |
| 1191 | $page->permissions()->delete(); | 1207 | $page->permissions()->delete(); |
| 1192 | $this->permissionService->deleteJointPermissionsForEntity($page); | 1208 | $this->permissionService->deleteJointPermissionsForEntity($page); |
| 1209 | + $this->searchService->deleteEntityTerms($page); | ||
| 1193 | 1210 | ||
| 1194 | // Delete Attached Files | 1211 | // Delete Attached Files |
| 1195 | $attachmentService = app(AttachmentService::class); | 1212 | $attachmentService = app(AttachmentService::class); | ... | ... |
app/SearchTerm.php
0 → 100644
| 1 | +<?php namespace BookStack; | ||
| 2 | + | ||
| 3 | +use Illuminate\Database\Eloquent\Model; | ||
| 4 | + | ||
| 5 | +class SearchTerm extends Model | ||
| 6 | +{ | ||
| 7 | + | ||
| 8 | + protected $fillable = ['term', 'entity_id', 'entity_type', 'score']; | ||
| 9 | + public $timestamps = false; | ||
| 10 | + | ||
| 11 | + /** | ||
| 12 | + * Get the entity that this term belongs to | ||
| 13 | + * @return \Illuminate\Database\Eloquent\Relations\MorphTo | ||
| 14 | + */ | ||
| 15 | + public function entity() | ||
| 16 | + { | ||
| 17 | + return $this->morphTo('entity'); | ||
| 18 | + } | ||
| 19 | + | ||
| 20 | +} |
| ... | @@ -479,8 +479,7 @@ class PermissionService | ... | @@ -479,8 +479,7 @@ class PermissionService |
| 479 | * @return \Illuminate\Database\Query\Builder | 479 | * @return \Illuminate\Database\Query\Builder |
| 480 | */ | 480 | */ |
| 481 | public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false) { | 481 | public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false) { |
| 482 | - $pageContentSelect = $fetchPageContent ? 'html' : "''"; | 482 | + $pageSelect = $this->db->table('pages')->selectRaw($this->page->entityRawQuery($fetchPageContent))->where('book_id', '=', $book_id)->where(function($query) use ($filterDrafts) { |
| 483 | - $pageSelect = $this->db->table('pages')->selectRaw("'BookStack\\\\Page' as entity_type, id, slug, name, text, {$pageContentSelect} as description, book_id, priority, chapter_id, draft")->where('book_id', '=', $book_id)->where(function($query) use ($filterDrafts) { | ||
| 484 | $query->where('draft', '=', 0); | 483 | $query->where('draft', '=', 0); |
| 485 | if (!$filterDrafts) { | 484 | if (!$filterDrafts) { |
| 486 | $query->orWhere(function($query) { | 485 | $query->orWhere(function($query) { |
| ... | @@ -488,7 +487,7 @@ class PermissionService | ... | @@ -488,7 +487,7 @@ class PermissionService |
| 488 | }); | 487 | }); |
| 489 | } | 488 | } |
| 490 | }); | 489 | }); |
| 491 | - $chapterSelect = $this->db->table('chapters')->selectRaw("'BookStack\\\\Chapter' as entity_type, id, slug, name, '' as text, description, book_id, priority, 0 as chapter_id, 0 as draft")->where('book_id', '=', $book_id); | 490 | + $chapterSelect = $this->db->table('chapters')->selectRaw($this->chapter->entityRawQuery())->where('book_id', '=', $book_id); |
| 492 | $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U")) | 491 | $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U")) |
| 493 | ->mergeBindings($pageSelect)->mergeBindings($chapterSelect); | 492 | ->mergeBindings($pageSelect)->mergeBindings($chapterSelect); |
| 494 | 493 | ||
| ... | @@ -540,7 +539,7 @@ class PermissionService | ... | @@ -540,7 +539,7 @@ class PermissionService |
| 540 | } | 539 | } |
| 541 | 540 | ||
| 542 | /** | 541 | /** |
| 543 | - * Filter items that have entities set a a polymorphic relation. | 542 | + * Filter items that have entities set as a polymorphic relation. |
| 544 | * @param $query | 543 | * @param $query |
| 545 | * @param string $tableName | 544 | * @param string $tableName |
| 546 | * @param string $entityIdColumn | 545 | * @param string $entityIdColumn | ... | ... |
app/Services/SearchService.php
0 → 100644
| 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 | + | ||
| 20 | + /** | ||
| 21 | + * SearchService constructor. | ||
| 22 | + * @param SearchTerm $searchTerm | ||
| 23 | + * @param Book $book | ||
| 24 | + * @param Chapter $chapter | ||
| 25 | + * @param Page $page | ||
| 26 | + * @param Connection $db | ||
| 27 | + */ | ||
| 28 | + public function __construct(SearchTerm $searchTerm, Book $book, Chapter $chapter, Page $page, Connection $db) | ||
| 29 | + { | ||
| 30 | + $this->searchTerm = $searchTerm; | ||
| 31 | + $this->book = $book; | ||
| 32 | + $this->chapter = $chapter; | ||
| 33 | + $this->page = $page; | ||
| 34 | + $this->db = $db; | ||
| 35 | + } | ||
| 36 | + | ||
| 37 | + public function searchEntities($searchString, $entityType = 'all') | ||
| 38 | + { | ||
| 39 | + // TODO - Add Tag Searches | ||
| 40 | + // TODO - Add advanced custom column searches | ||
| 41 | + // TODO - Add exact match searches ("") | ||
| 42 | + | ||
| 43 | + $termArray = explode(' ', $searchString); | ||
| 44 | + | ||
| 45 | + $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score')); | ||
| 46 | + $subQuery->where(function($query) use ($termArray) { | ||
| 47 | + foreach ($termArray as $inputTerm) { | ||
| 48 | + $query->orWhere('term', 'like', $inputTerm .'%'); | ||
| 49 | + } | ||
| 50 | + }); | ||
| 51 | + | ||
| 52 | + $subQuery = $subQuery->groupBy('entity_type', 'entity_id'); | ||
| 53 | + $pageSelect = $this->db->table('pages as e')->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function(JoinClause $join) { | ||
| 54 | + $join->on('e.id', '=', 's.entity_id'); | ||
| 55 | + })->selectRaw('e.*, s.score')->orderBy('score', 'desc'); | ||
| 56 | + $pageSelect->mergeBindings($subQuery); | ||
| 57 | + dd($pageSelect->toSql()); | ||
| 58 | + // TODO - Continue from here | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + /** | ||
| 62 | + * Index the given entity. | ||
| 63 | + * @param Entity $entity | ||
| 64 | + */ | ||
| 65 | + public function indexEntity(Entity $entity) | ||
| 66 | + { | ||
| 67 | + $this->deleteEntityTerms($entity); | ||
| 68 | + $nameTerms = $this->generateTermArrayFromText($entity->name, 5); | ||
| 69 | + $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1); | ||
| 70 | + $terms = array_merge($nameTerms, $bodyTerms); | ||
| 71 | + $entity->searchTerms()->createMany($terms); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + /** | ||
| 75 | + * Index multiple Entities at once | ||
| 76 | + * @param Entity[] $entities | ||
| 77 | + */ | ||
| 78 | + protected function indexEntities($entities) { | ||
| 79 | + $terms = []; | ||
| 80 | + foreach ($entities as $entity) { | ||
| 81 | + $nameTerms = $this->generateTermArrayFromText($entity->name, 5); | ||
| 82 | + $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1); | ||
| 83 | + foreach (array_merge($nameTerms, $bodyTerms) as $term) { | ||
| 84 | + $term['entity_id'] = $entity->id; | ||
| 85 | + $term['entity_type'] = $entity->getMorphClass(); | ||
| 86 | + $terms[] = $term; | ||
| 87 | + } | ||
| 88 | + } | ||
| 89 | + $this->searchTerm->insert($terms); | ||
| 90 | + } | ||
| 91 | + | ||
| 92 | + /** | ||
| 93 | + * Delete and re-index the terms for all entities in the system. | ||
| 94 | + */ | ||
| 95 | + public function indexAllEntities() | ||
| 96 | + { | ||
| 97 | + $this->searchTerm->truncate(); | ||
| 98 | + | ||
| 99 | + // Chunk through all books | ||
| 100 | + $this->book->chunk(500, function ($books) { | ||
| 101 | + $this->indexEntities($books); | ||
| 102 | + }); | ||
| 103 | + | ||
| 104 | + // Chunk through all chapters | ||
| 105 | + $this->chapter->chunk(500, function ($chapters) { | ||
| 106 | + $this->indexEntities($chapters); | ||
| 107 | + }); | ||
| 108 | + | ||
| 109 | + // Chunk through all pages | ||
| 110 | + $this->page->chunk(500, function ($pages) { | ||
| 111 | + $this->indexEntities($pages); | ||
| 112 | + }); | ||
| 113 | + } | ||
| 114 | + | ||
| 115 | + /** | ||
| 116 | + * Delete related Entity search terms. | ||
| 117 | + * @param Entity $entity | ||
| 118 | + */ | ||
| 119 | + public function deleteEntityTerms(Entity $entity) | ||
| 120 | + { | ||
| 121 | + $entity->searchTerms()->delete(); | ||
| 122 | + } | ||
| 123 | + | ||
| 124 | + /** | ||
| 125 | + * Create a scored term array from the given text. | ||
| 126 | + * @param $text | ||
| 127 | + * @param float|int $scoreAdjustment | ||
| 128 | + * @return array | ||
| 129 | + */ | ||
| 130 | + protected function generateTermArrayFromText($text, $scoreAdjustment = 1) | ||
| 131 | + { | ||
| 132 | + $tokenMap = []; // {TextToken => OccurrenceCount} | ||
| 133 | + $splitText = explode(' ', $text); | ||
| 134 | + foreach ($splitText as $token) { | ||
| 135 | + if ($token === '') continue; | ||
| 136 | + if (!isset($tokenMap[$token])) $tokenMap[$token] = 0; | ||
| 137 | + $tokenMap[$token]++; | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + $terms = []; | ||
| 141 | + foreach ($tokenMap as $token => $count) { | ||
| 142 | + $terms[] = [ | ||
| 143 | + 'term' => $token, | ||
| 144 | + 'score' => $count * $scoreAdjustment | ||
| 145 | + ]; | ||
| 146 | + } | ||
| 147 | + return $terms; | ||
| 148 | + } | ||
| 149 | + | ||
| 150 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 1 | +<?php | ||
| 2 | + | ||
| 3 | +use Illuminate\Support\Facades\Schema; | ||
| 4 | +use Illuminate\Database\Schema\Blueprint; | ||
| 5 | +use Illuminate\Database\Migrations\Migration; | ||
| 6 | + | ||
| 7 | +class CreateSearchIndexTable extends Migration | ||
| 8 | +{ | ||
| 9 | + /** | ||
| 10 | + * Run the migrations. | ||
| 11 | + * | ||
| 12 | + * @return void | ||
| 13 | + */ | ||
| 14 | + public function up() | ||
| 15 | + { | ||
| 16 | + Schema::create('search_terms', function (Blueprint $table) { | ||
| 17 | + $table->increments('id'); | ||
| 18 | + $table->string('term', 200); | ||
| 19 | + $table->string('entity_type', 100); | ||
| 20 | + $table->integer('entity_id'); | ||
| 21 | + $table->integer('score'); | ||
| 22 | + | ||
| 23 | + $table->index('term'); | ||
| 24 | + $table->index('entity_type'); | ||
| 25 | + $table->index(['entity_type', 'entity_id']); | ||
| 26 | + $table->index('score'); | ||
| 27 | + }); | ||
| 28 | + | ||
| 29 | + // TODO - Drop old fulltext indexes | ||
| 30 | + | ||
| 31 | + app(\BookStack\Services\SearchService::class)->indexAllEntities(); | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + /** | ||
| 35 | + * Reverse the migrations. | ||
| 36 | + * | ||
| 37 | + * @return void | ||
| 38 | + */ | ||
| 39 | + public function down() | ||
| 40 | + { | ||
| 41 | + Schema::dropIfExists('search_terms'); | ||
| 42 | + } | ||
| 43 | +} |
| ... | @@ -20,36 +20,30 @@ | ... | @@ -20,36 +20,30 @@ |
| 20 | <h1>{{ trans('entities.search_results') }}</h1> | 20 | <h1>{{ trans('entities.search_results') }}</h1> |
| 21 | 21 | ||
| 22 | <p> | 22 | <p> |
| 23 | - @if(count($pages) > 0) | 23 | + {{--TODO - Remove these pages--}} |
| 24 | - <a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.search_view_pages') }}</a> | 24 | + Remove these links (Commented out) |
| 25 | - @endif | 25 | + {{--@if(count($pages) > 0)--}} |
| 26 | - | 26 | + {{--<a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.search_view_pages') }}</a>--}} |
| 27 | - @if(count($chapters) > 0) | 27 | + {{--@endif--}} |
| 28 | - | 28 | + |
| 29 | - <a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="text-chapter"><i class="zmdi zmdi-collection-bookmark"></i>{{ trans('entities.search_view_chapters') }}</a> | 29 | + {{--@if(count($chapters) > 0)--}} |
| 30 | - @endif | 30 | + {{-- --}} |
| 31 | - | 31 | + {{--<a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="text-chapter"><i class="zmdi zmdi-collection-bookmark"></i>{{ trans('entities.search_view_chapters') }}</a>--}} |
| 32 | - @if(count($books) > 0) | 32 | + {{--@endif--}} |
| 33 | - | 33 | + |
| 34 | - <a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.search_view_books') }}</a> | 34 | + {{--@if(count($books) > 0)--}} |
| 35 | - @endif | 35 | + {{-- --}} |
| 36 | + {{--<a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.search_view_books') }}</a>--}} | ||
| 37 | + {{--@endif--}} | ||
| 36 | </p> | 38 | </p> |
| 37 | 39 | ||
| 38 | <div class="row"> | 40 | <div class="row"> |
| 39 | <div class="col-md-6"> | 41 | <div class="col-md-6"> |
| 40 | <h3><a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="no-color">{{ trans('entities.pages') }}</a></h3> | 42 | <h3><a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="no-color">{{ trans('entities.pages') }}</a></h3> |
| 41 | - @include('partials/entity-list', ['entities' => $pages, 'style' => 'detailed']) | 43 | + @include('partials/entity-list', ['entities' => $entities, 'style' => 'detailed']) |
| 42 | </div> | 44 | </div> |
| 43 | <div class="col-md-5 col-md-offset-1"> | 45 | <div class="col-md-5 col-md-offset-1"> |
| 44 | - @if(count($books) > 0) | 46 | + Sidebar filter controls |
| 45 | - <h3><a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="no-color">{{ trans('entities.books') }}</a></h3> | ||
| 46 | - @include('partials/entity-list', ['entities' => $books]) | ||
| 47 | - @endif | ||
| 48 | - | ||
| 49 | - @if(count($chapters) > 0) | ||
| 50 | - <h3><a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="no-color">{{ trans('entities.chapters') }}</a></h3> | ||
| 51 | - @include('partials/entity-list', ['entities' => $chapters]) | ||
| 52 | - @endif | ||
| 53 | </div> | 47 | </div> |
| 54 | </div> | 48 | </div> |
| 55 | 49 | ... | ... |
-
Please register or sign in to post a comment