Committed by
GitHub
Merge pull request #340 from BookStackApp/search_system
Implementation of new search system
Showing
46 changed files
with
1552 additions
and
613 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 | } | ... | ... |
| ... | @@ -12,7 +12,7 @@ class RegeneratePermissions extends Command | ... | @@ -12,7 +12,7 @@ class RegeneratePermissions extends Command |
| 12 | * | 12 | * |
| 13 | * @var string | 13 | * @var string |
| 14 | */ | 14 | */ |
| 15 | - protected $signature = 'bookstack:regenerate-permissions'; | 15 | + protected $signature = 'bookstack:regenerate-permissions {--database= : The database connection to use.}'; |
| 16 | 16 | ||
| 17 | /** | 17 | /** |
| 18 | * The console command description. | 18 | * The console command description. |
| ... | @@ -46,7 +46,14 @@ class RegeneratePermissions extends Command | ... | @@ -46,7 +46,14 @@ class RegeneratePermissions extends Command |
| 46 | */ | 46 | */ |
| 47 | public function handle() | 47 | public function handle() |
| 48 | { | 48 | { |
| 49 | + $connection = \DB::getDefaultConnection(); | ||
| 50 | + if ($this->option('database') !== null) { | ||
| 51 | + \DB::setDefaultConnection($this->option('database')); | ||
| 52 | + } | ||
| 53 | + | ||
| 49 | $this->permissionService->buildJointPermissions(); | 54 | $this->permissionService->buildJointPermissions(); |
| 55 | + | ||
| 56 | + \DB::setDefaultConnection($connection); | ||
| 50 | $this->comment('Permissions regenerated'); | 57 | $this->comment('Permissions regenerated'); |
| 51 | } | 58 | } |
| 52 | } | 59 | } | ... | ... |
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 {--database= : The database connection to use.}'; | ||
| 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 | + $connection = \DB::getDefaultConnection(); | ||
| 45 | + if ($this->option('database') !== null) { | ||
| 46 | + \DB::setDefaultConnection($this->option('database')); | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + $this->searchService->indexAllEntities(); | ||
| 50 | + \DB::setDefaultConnection($connection); | ||
| 51 | + $this->comment('Search index regenerated'); | ||
| 52 | + } | ||
| 53 | +} |
| 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 | + public $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,67 +162,19 @@ class Entity extends Ownable | ... | @@ -153,67 +162,19 @@ class Entity extends Ownable |
| 153 | } | 162 | } |
| 154 | 163 | ||
| 155 | /** | 164 | /** |
| 156 | - * Perform a full-text search on this entity. | 165 | + * Get the body text of this entity. |
| 157 | - * @param string[] $fieldsToSearch | ||
| 158 | - * @param string[] $terms | ||
| 159 | - * @param string[] array $wheres | ||
| 160 | * @return mixed | 166 | * @return mixed |
| 161 | */ | 167 | */ |
| 162 | - public function fullTextSearchQuery($terms, $wheres = []) | 168 | + public function getText() |
| 163 | { | 169 | { |
| 164 | - $exactTerms = []; | 170 | + return $this->{$this->textField}; |
| 165 | - $fuzzyTerms = []; | 171 | + } |
| 166 | - $search = static::newQuery(); | ||
| 167 | - | ||
| 168 | - foreach ($terms as $key => $term) { | ||
| 169 | - $term = htmlentities($term, ENT_QUOTES); | ||
| 170 | - $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term); | ||
| 171 | - if (preg_match('/".*?"/', $term) || is_numeric($term)) { | ||
| 172 | - $term = str_replace('"', '', $term); | ||
| 173 | - $exactTerms[] = '%' . $term . '%'; | ||
| 174 | - } else { | ||
| 175 | - $term = '' . $term . '*'; | ||
| 176 | - if ($term !== '*') $fuzzyTerms[] = $term; | ||
| 177 | - } | ||
| 178 | - } | ||
| 179 | - | ||
| 180 | - $isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0; | ||
| 181 | - | ||
| 182 | - | ||
| 183 | - // Perform fulltext search if relevant terms exist. | ||
| 184 | - if ($isFuzzy) { | ||
| 185 | - $termString = implode(' ', $fuzzyTerms); | ||
| 186 | - $fields = implode(',', $this->fieldsToSearch); | ||
| 187 | - $search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]); | ||
| 188 | - $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]); | ||
| 189 | - } | ||
| 190 | - | ||
| 191 | - // Ensure at least one exact term matches if in search | ||
| 192 | - if (count($exactTerms) > 0) { | ||
| 193 | - $search = $search->where(function ($query) use ($exactTerms) { | ||
| 194 | - foreach ($exactTerms as $exactTerm) { | ||
| 195 | - foreach ($this->fieldsToSearch as $field) { | ||
| 196 | - $query->orWhere($field, 'like', $exactTerm); | ||
| 197 | - } | ||
| 198 | - } | ||
| 199 | - }); | ||
| 200 | - } | ||
| 201 | - | ||
| 202 | - $orderBy = $isFuzzy ? 'title_relevance' : 'updated_at'; | ||
| 203 | - | ||
| 204 | - // Add additional where terms | ||
| 205 | - foreach ($wheres as $whereTerm) { | ||
| 206 | - $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]); | ||
| 207 | - } | ||
| 208 | 172 | ||
| 209 | - // Load in relations | 173 | + /** |
| 210 | - if ($this->isA('page')) { | 174 | + * Return a generalised, common raw query that can be 'unioned' across entities. |
| 211 | - $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy'); | 175 | + * @return string |
| 212 | - } else if ($this->isA('chapter')) { | 176 | + */ |
| 213 | - $search = $search->with('book'); | 177 | + public function entityRawQuery(){return '';} |
| 214 | - } | ||
| 215 | 178 | ||
| 216 | - return $search->orderBy($orderBy, 'desc'); | ||
| 217 | - } | ||
| 218 | 179 | ||
| 219 | } | 180 | } | ... | ... |
| 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 | ||
| ... | @@ -27,105 +31,55 @@ class SearchController extends Controller | ... | @@ -27,105 +31,55 @@ class SearchController extends Controller |
| 27 | * @return \Illuminate\View\View | 31 | * @return \Illuminate\View\View |
| 28 | * @internal param string $searchTerm | 32 | * @internal param string $searchTerm |
| 29 | */ | 33 | */ |
| 30 | - public function searchAll(Request $request) | 34 | + public function search(Request $request) |
| 31 | { | 35 | { |
| 32 | - if (!$request->has('term')) { | ||
| 33 | - return redirect()->back(); | ||
| 34 | - } | ||
| 35 | $searchTerm = $request->get('term'); | 36 | $searchTerm = $request->get('term'); |
| 36 | - $paginationAppends = $request->only('term'); | ||
| 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])); | 37 | $this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm])); |
| 41 | - return view('search/all', [ | ||
| 42 | - 'pages' => $pages, | ||
| 43 | - 'books' => $books, | ||
| 44 | - 'chapters' => $chapters, | ||
| 45 | - 'searchTerm' => $searchTerm | ||
| 46 | - ]); | ||
| 47 | - } | ||
| 48 | 38 | ||
| 49 | - /** | 39 | + $page = $request->has('page') && is_int(intval($request->get('page'))) ? intval($request->get('page')) : 1; |
| 50 | - * Search only the pages in the system. | 40 | + $nextPageLink = baseUrl('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1)); |
| 51 | - * @param Request $request | ||
| 52 | - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View | ||
| 53 | - */ | ||
| 54 | - public function searchPages(Request $request) | ||
| 55 | - { | ||
| 56 | - if (!$request->has('term')) return redirect()->back(); | ||
| 57 | 41 | ||
| 58 | - $searchTerm = $request->get('term'); | 42 | + $results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20); |
| 59 | - $paginationAppends = $request->only('term'); | 43 | + $hasNextPage = $this->searchService->searchEntities($searchTerm, 'all', $page+1, 20)['count'] > 0; |
| 60 | - $pages = $this->entityRepo->getBySearch('page', $searchTerm, [], 20, $paginationAppends); | ||
| 61 | - $this->setPageTitle(trans('entities.search_page_for_term', ['term' => $searchTerm])); | ||
| 62 | - return view('search/entity-search-list', [ | ||
| 63 | - 'entities' => $pages, | ||
| 64 | - 'title' => trans('entities.search_results_page'), | ||
| 65 | - 'searchTerm' => $searchTerm | ||
| 66 | - ]); | ||
| 67 | - } | ||
| 68 | - | ||
| 69 | - /** | ||
| 70 | - * Search only the chapters in the system. | ||
| 71 | - * @param Request $request | ||
| 72 | - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View | ||
| 73 | - */ | ||
| 74 | - public function searchChapters(Request $request) | ||
| 75 | - { | ||
| 76 | - if (!$request->has('term')) return redirect()->back(); | ||
| 77 | 44 | ||
| 78 | - $searchTerm = $request->get('term'); | 45 | + return view('search/all', [ |
| 79 | - $paginationAppends = $request->only('term'); | 46 | + 'entities' => $results['results'], |
| 80 | - $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, [], 20, $paginationAppends); | 47 | + 'totalResults' => $results['total'], |
| 81 | - $this->setPageTitle(trans('entities.search_chapter_for_term', ['term' => $searchTerm])); | 48 | + 'searchTerm' => $searchTerm, |
| 82 | - return view('search/entity-search-list', [ | 49 | + 'hasNextPage' => $hasNextPage, |
| 83 | - 'entities' => $chapters, | 50 | + 'nextPageLink' => $nextPageLink |
| 84 | - 'title' => trans('entities.search_results_chapter'), | ||
| 85 | - 'searchTerm' => $searchTerm | ||
| 86 | ]); | 51 | ]); |
| 87 | } | 52 | } |
| 88 | 53 | ||
| 54 | + | ||
| 89 | /** | 55 | /** |
| 90 | - * Search only the books in the system. | 56 | + * Searches all entities within a book. |
| 91 | * @param Request $request | 57 | * @param Request $request |
| 92 | - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View | 58 | + * @param integer $bookId |
| 59 | + * @return \Illuminate\View\View | ||
| 60 | + * @internal param string $searchTerm | ||
| 93 | */ | 61 | */ |
| 94 | - public function searchBooks(Request $request) | 62 | + public function searchBook(Request $request, $bookId) |
| 95 | { | 63 | { |
| 96 | - if (!$request->has('term')) return redirect()->back(); | 64 | + $term = $request->get('term', ''); |
| 97 | - | 65 | + $results = $this->searchService->searchBook($bookId, $term); |
| 98 | - $searchTerm = $request->get('term'); | 66 | + return view('partials/entity-list', ['entities' => $results]); |
| 99 | - $paginationAppends = $request->only('term'); | ||
| 100 | - $books = $this->entityRepo->getBySearch('book', $searchTerm, [], 20, $paginationAppends); | ||
| 101 | - $this->setPageTitle(trans('entities.search_book_for_term', ['term' => $searchTerm])); | ||
| 102 | - return view('search/entity-search-list', [ | ||
| 103 | - 'entities' => $books, | ||
| 104 | - 'title' => trans('entities.search_results_book'), | ||
| 105 | - 'searchTerm' => $searchTerm | ||
| 106 | - ]); | ||
| 107 | } | 67 | } |
| 108 | 68 | ||
| 109 | /** | 69 | /** |
| 110 | - * Searches all entities within a book. | 70 | + * Searches all entities within a chapter. |
| 111 | * @param Request $request | 71 | * @param Request $request |
| 112 | - * @param integer $bookId | 72 | + * @param integer $chapterId |
| 113 | * @return \Illuminate\View\View | 73 | * @return \Illuminate\View\View |
| 114 | * @internal param string $searchTerm | 74 | * @internal param string $searchTerm |
| 115 | */ | 75 | */ |
| 116 | - public function searchBook(Request $request, $bookId) | 76 | + public function searchChapter(Request $request, $chapterId) |
| 117 | { | 77 | { |
| 118 | - if (!$request->has('term')) { | 78 | + $term = $request->get('term', ''); |
| 119 | - return redirect()->back(); | 79 | + $results = $this->searchService->searchChapter($chapterId, $term); |
| 120 | - } | 80 | + return view('partials/entity-list', ['entities' => $results]); |
| 121 | - $searchTerm = $request->get('term'); | ||
| 122 | - $searchWhereTerms = [['book_id', '=', $bookId]]; | ||
| 123 | - $pages = $this->entityRepo->getBySearch('page', $searchTerm, $searchWhereTerms); | ||
| 124 | - $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, $searchWhereTerms); | ||
| 125 | - return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]); | ||
| 126 | } | 81 | } |
| 127 | 82 | ||
| 128 | - | ||
| 129 | /** | 83 | /** |
| 130 | * Search for a list of entities and return a partial HTML response of matching entities. | 84 | * Search for a list of entities and return a partial HTML response of matching entities. |
| 131 | * Returns the most popular entities if no search is provided. | 85 | * Returns the most popular entities if no search is provided. |
| ... | @@ -134,18 +88,13 @@ class SearchController extends Controller | ... | @@ -134,18 +88,13 @@ class SearchController extends Controller |
| 134 | */ | 88 | */ |
| 135 | public function searchEntitiesAjax(Request $request) | 89 | public function searchEntitiesAjax(Request $request) |
| 136 | { | 90 | { |
| 137 | - $entities = collect(); | ||
| 138 | $entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']); | 91 | $entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']); |
| 139 | $searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false; | 92 | $searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false; |
| 140 | 93 | ||
| 141 | // Search for entities otherwise show most popular | 94 | // Search for entities otherwise show most popular |
| 142 | if ($searchTerm !== false) { | 95 | if ($searchTerm !== false) { |
| 143 | - foreach (['page', 'chapter', 'book'] as $entityType) { | 96 | + $searchTerm .= ' {type:'. implode('|', $entityTypes->toArray()) .'}'; |
| 144 | - if ($entityTypes->contains($entityType)) { | 97 | + $entities = $this->searchService->searchEntities($searchTerm)['results']; |
| 145 | - $entities = $entities->merge($this->entityRepo->getBySearch($entityType, $searchTerm)->items()); | ||
| 146 | - } | ||
| 147 | - } | ||
| 148 | - $entities = $entities->sortByDesc('title_relevance'); | ||
| 149 | } else { | 98 | } else { |
| 150 | $entityNames = $entityTypes->map(function ($type) { | 99 | $entityNames = $entityTypes->map(function ($type) { |
| 151 | return 'BookStack\\' . ucfirst($type); | 100 | return 'BookStack\\' . ucfirst($type); | ... | ... |
| ... | @@ -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 | + public $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,12 @@ class EntityRepo | ... | @@ -59,13 +60,12 @@ class EntityRepo |
| 59 | protected $tagRepo; | 60 | protected $tagRepo; |
| 60 | 61 | ||
| 61 | /** | 62 | /** |
| 62 | - * Acceptable operators to be used in a query | 63 | + * @var SearchService |
| 63 | - * @var array | ||
| 64 | */ | 64 | */ |
| 65 | - protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; | 65 | + protected $searchService; |
| 66 | 66 | ||
| 67 | /** | 67 | /** |
| 68 | - * EntityService constructor. | 68 | + * EntityRepo constructor. |
| 69 | * @param Book $book | 69 | * @param Book $book |
| 70 | * @param Chapter $chapter | 70 | * @param Chapter $chapter |
| 71 | * @param Page $page | 71 | * @param Page $page |
| ... | @@ -73,10 +73,12 @@ class EntityRepo | ... | @@ -73,10 +73,12 @@ class EntityRepo |
| 73 | * @param ViewService $viewService | 73 | * @param ViewService $viewService |
| 74 | * @param PermissionService $permissionService | 74 | * @param PermissionService $permissionService |
| 75 | * @param TagRepo $tagRepo | 75 | * @param TagRepo $tagRepo |
| 76 | + * @param SearchService $searchService | ||
| 76 | */ | 77 | */ |
| 77 | public function __construct( | 78 | public function __construct( |
| 78 | Book $book, Chapter $chapter, Page $page, PageRevision $pageRevision, | 79 | Book $book, Chapter $chapter, Page $page, PageRevision $pageRevision, |
| 79 | - ViewService $viewService, PermissionService $permissionService, TagRepo $tagRepo | 80 | + ViewService $viewService, PermissionService $permissionService, |
| 81 | + TagRepo $tagRepo, SearchService $searchService | ||
| 80 | ) | 82 | ) |
| 81 | { | 83 | { |
| 82 | $this->book = $book; | 84 | $this->book = $book; |
| ... | @@ -91,6 +93,7 @@ class EntityRepo | ... | @@ -91,6 +93,7 @@ class EntityRepo |
| 91 | $this->viewService = $viewService; | 93 | $this->viewService = $viewService; |
| 92 | $this->permissionService = $permissionService; | 94 | $this->permissionService = $permissionService; |
| 93 | $this->tagRepo = $tagRepo; | 95 | $this->tagRepo = $tagRepo; |
| 96 | + $this->searchService = $searchService; | ||
| 94 | } | 97 | } |
| 95 | 98 | ||
| 96 | /** | 99 | /** |
| ... | @@ -216,6 +219,7 @@ class EntityRepo | ... | @@ -216,6 +219,7 @@ class EntityRepo |
| 216 | * @param int $count | 219 | * @param int $count |
| 217 | * @param int $page | 220 | * @param int $page |
| 218 | * @param bool|callable $additionalQuery | 221 | * @param bool|callable $additionalQuery |
| 222 | + * @return Collection | ||
| 219 | */ | 223 | */ |
| 220 | public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false) | 224 | public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false) |
| 221 | { | 225 | { |
| ... | @@ -234,6 +238,7 @@ class EntityRepo | ... | @@ -234,6 +238,7 @@ class EntityRepo |
| 234 | * @param int $count | 238 | * @param int $count |
| 235 | * @param int $page | 239 | * @param int $page |
| 236 | * @param bool|callable $additionalQuery | 240 | * @param bool|callable $additionalQuery |
| 241 | + * @return Collection | ||
| 237 | */ | 242 | */ |
| 238 | public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false) | 243 | public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false) |
| 239 | { | 244 | { |
| ... | @@ -327,7 +332,7 @@ class EntityRepo | ... | @@ -327,7 +332,7 @@ class EntityRepo |
| 327 | if ($rawEntity->entity_type === 'BookStack\\Page') { | 332 | if ($rawEntity->entity_type === 'BookStack\\Page') { |
| 328 | $entities[$index] = $this->page->newFromBuilder($rawEntity); | 333 | $entities[$index] = $this->page->newFromBuilder($rawEntity); |
| 329 | if ($renderPages) { | 334 | if ($renderPages) { |
| 330 | - $entities[$index]->html = $rawEntity->description; | 335 | + $entities[$index]->html = $rawEntity->html; |
| 331 | $entities[$index]->html = $this->renderPage($entities[$index]); | 336 | $entities[$index]->html = $this->renderPage($entities[$index]); |
| 332 | }; | 337 | }; |
| 333 | } else if ($rawEntity->entity_type === 'BookStack\\Chapter') { | 338 | } else if ($rawEntity->entity_type === 'BookStack\\Chapter') { |
| ... | @@ -354,6 +359,7 @@ class EntityRepo | ... | @@ -354,6 +359,7 @@ class EntityRepo |
| 354 | * Get the child items for a chapter sorted by priority but | 359 | * Get the child items for a chapter sorted by priority but |
| 355 | * with draft items floated to the top. | 360 | * with draft items floated to the top. |
| 356 | * @param Chapter $chapter | 361 | * @param Chapter $chapter |
| 362 | + * @return \Illuminate\Database\Eloquent\Collection|static[] | ||
| 357 | */ | 363 | */ |
| 358 | public function getChapterChildren(Chapter $chapter) | 364 | public function getChapterChildren(Chapter $chapter) |
| 359 | { | 365 | { |
| ... | @@ -361,56 +367,6 @@ class EntityRepo | ... | @@ -361,56 +367,6 @@ class EntityRepo |
| 361 | ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get(); | 367 | ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get(); |
| 362 | } | 368 | } |
| 363 | 369 | ||
| 364 | - /** | ||
| 365 | - * Search entities of a type via a given query. | ||
| 366 | - * @param string $type | ||
| 367 | - * @param string $term | ||
| 368 | - * @param array $whereTerms | ||
| 369 | - * @param int $count | ||
| 370 | - * @param array $paginationAppends | ||
| 371 | - * @return mixed | ||
| 372 | - */ | ||
| 373 | - public function getBySearch($type, $term, $whereTerms = [], $count = 20, $paginationAppends = []) | ||
| 374 | - { | ||
| 375 | - $terms = $this->prepareSearchTerms($term); | ||
| 376 | - $q = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type)->fullTextSearchQuery($terms, $whereTerms)); | ||
| 377 | - $q = $this->addAdvancedSearchQueries($q, $term); | ||
| 378 | - $entities = $q->paginate($count)->appends($paginationAppends); | ||
| 379 | - $words = join('|', explode(' ', preg_quote(trim($term), '/'))); | ||
| 380 | - | ||
| 381 | - // Highlight page content | ||
| 382 | - if ($type === 'page') { | ||
| 383 | - //lookahead/behind assertions ensures cut between words | ||
| 384 | - $s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words | ||
| 385 | - | ||
| 386 | - foreach ($entities as $page) { | ||
| 387 | - preg_match_all('#(?<=[' . $s . ']).{1,30}((' . $words . ').{1,30})+(?=[' . $s . '])#uis', $page->text, $matches, PREG_SET_ORDER); | ||
| 388 | - //delimiter between occurrences | ||
| 389 | - $results = []; | ||
| 390 | - foreach ($matches as $line) { | ||
| 391 | - $results[] = htmlspecialchars($line[0], 0, 'UTF-8'); | ||
| 392 | - } | ||
| 393 | - $matchLimit = 6; | ||
| 394 | - if (count($results) > $matchLimit) $results = array_slice($results, 0, $matchLimit); | ||
| 395 | - $result = join('... ', $results); | ||
| 396 | - | ||
| 397 | - //highlight | ||
| 398 | - $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $result); | ||
| 399 | - if (strlen($result) < 5) $result = $page->getExcerpt(80); | ||
| 400 | - | ||
| 401 | - $page->searchSnippet = $result; | ||
| 402 | - } | ||
| 403 | - return $entities; | ||
| 404 | - } | ||
| 405 | - | ||
| 406 | - // Highlight chapter/book content | ||
| 407 | - foreach ($entities as $entity) { | ||
| 408 | - //highlight | ||
| 409 | - $result = preg_replace('#' . $words . '#iu', "<span class=\"highlight\">\$0</span>", $entity->getExcerpt(100)); | ||
| 410 | - $entity->searchSnippet = $result; | ||
| 411 | - } | ||
| 412 | - return $entities; | ||
| 413 | - } | ||
| 414 | 370 | ||
| 415 | /** | 371 | /** |
| 416 | * Get the next sequential priority for a new child element in the given book. | 372 | * Get the next sequential priority for a new child element in the given book. |
| ... | @@ -492,104 +448,7 @@ class EntityRepo | ... | @@ -492,104 +448,7 @@ class EntityRepo |
| 492 | $this->permissionService->buildJointPermissionsForEntity($entity); | 448 | $this->permissionService->buildJointPermissionsForEntity($entity); |
| 493 | } | 449 | } |
| 494 | 450 | ||
| 495 | - /** | ||
| 496 | - * Prepare a string of search terms by turning | ||
| 497 | - * it into an array of terms. | ||
| 498 | - * Keeps quoted terms together. | ||
| 499 | - * @param $termString | ||
| 500 | - * @return array | ||
| 501 | - */ | ||
| 502 | - public function prepareSearchTerms($termString) | ||
| 503 | - { | ||
| 504 | - $termString = $this->cleanSearchTermString($termString); | ||
| 505 | - preg_match_all('/(".*?")/', $termString, $matches); | ||
| 506 | - $terms = []; | ||
| 507 | - if (count($matches[1]) > 0) { | ||
| 508 | - foreach ($matches[1] as $match) { | ||
| 509 | - $terms[] = $match; | ||
| 510 | - } | ||
| 511 | - $termString = trim(preg_replace('/"(.*?)"/', '', $termString)); | ||
| 512 | - } | ||
| 513 | - if (!empty($termString)) $terms = array_merge($terms, explode(' ', $termString)); | ||
| 514 | - return $terms; | ||
| 515 | - } | ||
| 516 | - | ||
| 517 | - /** | ||
| 518 | - * Removes any special search notation that should not | ||
| 519 | - * be used in a full-text search. | ||
| 520 | - * @param $termString | ||
| 521 | - * @return mixed | ||
| 522 | - */ | ||
| 523 | - protected function cleanSearchTermString($termString) | ||
| 524 | - { | ||
| 525 | - // Strip tag searches | ||
| 526 | - $termString = preg_replace('/\[.*?\]/', '', $termString); | ||
| 527 | - // Reduced multiple spacing into single spacing | ||
| 528 | - $termString = preg_replace("/\s{2,}/", " ", $termString); | ||
| 529 | - return $termString; | ||
| 530 | - } | ||
| 531 | - | ||
| 532 | - /** | ||
| 533 | - * Get the available query operators as a regex escaped list. | ||
| 534 | - * @return mixed | ||
| 535 | - */ | ||
| 536 | - protected function getRegexEscapedOperators() | ||
| 537 | - { | ||
| 538 | - $escapedOperators = []; | ||
| 539 | - foreach ($this->queryOperators as $operator) { | ||
| 540 | - $escapedOperators[] = preg_quote($operator); | ||
| 541 | - } | ||
| 542 | - return join('|', $escapedOperators); | ||
| 543 | - } | ||
| 544 | 451 | ||
| 545 | - /** | ||
| 546 | - * Parses advanced search notations and adds them to the db query. | ||
| 547 | - * @param $query | ||
| 548 | - * @param $termString | ||
| 549 | - * @return mixed | ||
| 550 | - */ | ||
| 551 | - protected function addAdvancedSearchQueries($query, $termString) | ||
| 552 | - { | ||
| 553 | - $escapedOperators = $this->getRegexEscapedOperators(); | ||
| 554 | - // Look for tag searches | ||
| 555 | - preg_match_all("/\[(.*?)((${escapedOperators})(.*?))?\]/", $termString, $tags); | ||
| 556 | - if (count($tags[0]) > 0) { | ||
| 557 | - $this->applyTagSearches($query, $tags); | ||
| 558 | - } | ||
| 559 | - | ||
| 560 | - return $query; | ||
| 561 | - } | ||
| 562 | - | ||
| 563 | - /** | ||
| 564 | - * Apply extracted tag search terms onto a entity query. | ||
| 565 | - * @param $query | ||
| 566 | - * @param $tags | ||
| 567 | - * @return mixed | ||
| 568 | - */ | ||
| 569 | - protected function applyTagSearches($query, $tags) { | ||
| 570 | - $query->where(function($query) use ($tags) { | ||
| 571 | - foreach ($tags[1] as $index => $tagName) { | ||
| 572 | - $query->whereHas('tags', function($query) use ($tags, $index, $tagName) { | ||
| 573 | - $tagOperator = $tags[3][$index]; | ||
| 574 | - $tagValue = $tags[4][$index]; | ||
| 575 | - if (!empty($tagOperator) && !empty($tagValue) && in_array($tagOperator, $this->queryOperators)) { | ||
| 576 | - if (is_numeric($tagValue) && $tagOperator !== 'like') { | ||
| 577 | - // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will | ||
| 578 | - // search the value as a string which prevents being able to do number-based operations | ||
| 579 | - // on the tag values. We ensure it has a numeric value and then cast it just to be sure. | ||
| 580 | - $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'"); | ||
| 581 | - $query->where('name', '=', $tagName)->whereRaw("value ${tagOperator} ${tagValue}"); | ||
| 582 | - } else { | ||
| 583 | - $query->where('name', '=', $tagName)->where('value', $tagOperator, $tagValue); | ||
| 584 | - } | ||
| 585 | - } else { | ||
| 586 | - $query->where('name', '=', $tagName); | ||
| 587 | - } | ||
| 588 | - }); | ||
| 589 | - } | ||
| 590 | - }); | ||
| 591 | - return $query; | ||
| 592 | - } | ||
| 593 | 452 | ||
| 594 | /** | 453 | /** |
| 595 | * Create a new entity from request input. | 454 | * Create a new entity from request input. |
| ... | @@ -608,12 +467,13 @@ class EntityRepo | ... | @@ -608,12 +467,13 @@ class EntityRepo |
| 608 | $entity->updated_by = user()->id; | 467 | $entity->updated_by = user()->id; |
| 609 | $isChapter ? $book->chapters()->save($entity) : $entity->save(); | 468 | $isChapter ? $book->chapters()->save($entity) : $entity->save(); |
| 610 | $this->permissionService->buildJointPermissionsForEntity($entity); | 469 | $this->permissionService->buildJointPermissionsForEntity($entity); |
| 470 | + $this->searchService->indexEntity($entity); | ||
| 611 | return $entity; | 471 | return $entity; |
| 612 | } | 472 | } |
| 613 | 473 | ||
| 614 | /** | 474 | /** |
| 615 | * Update entity details from request input. | 475 | * Update entity details from request input. |
| 616 | - * Use for books and chapters | 476 | + * Used for books and chapters |
| 617 | * @param string $type | 477 | * @param string $type |
| 618 | * @param Entity $entityModel | 478 | * @param Entity $entityModel |
| 619 | * @param array $input | 479 | * @param array $input |
| ... | @@ -628,6 +488,7 @@ class EntityRepo | ... | @@ -628,6 +488,7 @@ class EntityRepo |
| 628 | $entityModel->updated_by = user()->id; | 488 | $entityModel->updated_by = user()->id; |
| 629 | $entityModel->save(); | 489 | $entityModel->save(); |
| 630 | $this->permissionService->buildJointPermissionsForEntity($entityModel); | 490 | $this->permissionService->buildJointPermissionsForEntity($entityModel); |
| 491 | + $this->searchService->indexEntity($entityModel); | ||
| 631 | return $entityModel; | 492 | return $entityModel; |
| 632 | } | 493 | } |
| 633 | 494 | ||
| ... | @@ -711,7 +572,7 @@ class EntityRepo | ... | @@ -711,7 +572,7 @@ class EntityRepo |
| 711 | 572 | ||
| 712 | $draftPage->save(); | 573 | $draftPage->save(); |
| 713 | $this->savePageRevision($draftPage, trans('entities.pages_initial_revision')); | 574 | $this->savePageRevision($draftPage, trans('entities.pages_initial_revision')); |
| 714 | - | 575 | + $this->searchService->indexEntity($draftPage); |
| 715 | return $draftPage; | 576 | return $draftPage; |
| 716 | } | 577 | } |
| 717 | 578 | ||
| ... | @@ -961,6 +822,8 @@ class EntityRepo | ... | @@ -961,6 +822,8 @@ class EntityRepo |
| 961 | $this->savePageRevision($page, $input['summary']); | 822 | $this->savePageRevision($page, $input['summary']); |
| 962 | } | 823 | } |
| 963 | 824 | ||
| 825 | + $this->searchService->indexEntity($page); | ||
| 826 | + | ||
| 964 | return $page; | 827 | return $page; |
| 965 | } | 828 | } |
| 966 | 829 | ||
| ... | @@ -1064,6 +927,7 @@ class EntityRepo | ... | @@ -1064,6 +927,7 @@ class EntityRepo |
| 1064 | $page->text = strip_tags($page->html); | 927 | $page->text = strip_tags($page->html); |
| 1065 | $page->updated_by = user()->id; | 928 | $page->updated_by = user()->id; |
| 1066 | $page->save(); | 929 | $page->save(); |
| 930 | + $this->searchService->indexEntity($page); | ||
| 1067 | return $page; | 931 | return $page; |
| 1068 | } | 932 | } |
| 1069 | 933 | ||
| ... | @@ -1156,6 +1020,7 @@ class EntityRepo | ... | @@ -1156,6 +1020,7 @@ class EntityRepo |
| 1156 | $book->views()->delete(); | 1020 | $book->views()->delete(); |
| 1157 | $book->permissions()->delete(); | 1021 | $book->permissions()->delete(); |
| 1158 | $this->permissionService->deleteJointPermissionsForEntity($book); | 1022 | $this->permissionService->deleteJointPermissionsForEntity($book); |
| 1023 | + $this->searchService->deleteEntityTerms($book); | ||
| 1159 | $book->delete(); | 1024 | $book->delete(); |
| 1160 | } | 1025 | } |
| 1161 | 1026 | ||
| ... | @@ -1175,6 +1040,7 @@ class EntityRepo | ... | @@ -1175,6 +1040,7 @@ class EntityRepo |
| 1175 | $chapter->views()->delete(); | 1040 | $chapter->views()->delete(); |
| 1176 | $chapter->permissions()->delete(); | 1041 | $chapter->permissions()->delete(); |
| 1177 | $this->permissionService->deleteJointPermissionsForEntity($chapter); | 1042 | $this->permissionService->deleteJointPermissionsForEntity($chapter); |
| 1043 | + $this->searchService->deleteEntityTerms($chapter); | ||
| 1178 | $chapter->delete(); | 1044 | $chapter->delete(); |
| 1179 | } | 1045 | } |
| 1180 | 1046 | ||
| ... | @@ -1190,6 +1056,7 @@ class EntityRepo | ... | @@ -1190,6 +1056,7 @@ class EntityRepo |
| 1190 | $page->revisions()->delete(); | 1056 | $page->revisions()->delete(); |
| 1191 | $page->permissions()->delete(); | 1057 | $page->permissions()->delete(); |
| 1192 | $this->permissionService->deleteJointPermissionsForEntity($page); | 1058 | $this->permissionService->deleteJointPermissionsForEntity($page); |
| 1059 | + $this->searchService->deleteEntityTerms($page); | ||
| 1193 | 1060 | ||
| 1194 | // Delete Attached Files | 1061 | // Delete Attached Files |
| 1195 | $attachmentService = app(AttachmentService::class); | 1062 | $attachmentService = app(AttachmentService::class); | ... | ... |
app/SearchTerm.php
0 → 100644
| 1 | +<?php namespace BookStack; | ||
| 2 | + | ||
| 3 | +class SearchTerm extends Model | ||
| 4 | +{ | ||
| 5 | + | ||
| 6 | + protected $fillable = ['term', 'entity_id', 'entity_type', 'score']; | ||
| 7 | + public $timestamps = false; | ||
| 8 | + | ||
| 9 | + /** | ||
| 10 | + * Get the entity that this term belongs to | ||
| 11 | + * @return \Illuminate\Database\Eloquent\Relations\MorphTo | ||
| 12 | + */ | ||
| 13 | + public function entity() | ||
| 14 | + { | ||
| 15 | + return $this->morphTo('entity'); | ||
| 16 | + } | ||
| 17 | + | ||
| 18 | +} |
| ... | @@ -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 | ||
| ... | @@ -514,7 +513,7 @@ class PermissionService | ... | @@ -514,7 +513,7 @@ class PermissionService |
| 514 | * @param string $entityType | 513 | * @param string $entityType |
| 515 | * @param Builder|Entity $query | 514 | * @param Builder|Entity $query |
| 516 | * @param string $action | 515 | * @param string $action |
| 517 | - * @return mixed | 516 | + * @return Builder |
| 518 | */ | 517 | */ |
| 519 | public function enforceEntityRestrictions($entityType, $query, $action = 'view') | 518 | public function enforceEntityRestrictions($entityType, $query, $action = 'view') |
| 520 | { | 519 | { |
| ... | @@ -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\Builder; | ||
| 10 | +use Illuminate\Database\Query\JoinClause; | ||
| 11 | +use Illuminate\Support\Collection; | ||
| 12 | + | ||
| 13 | +class SearchService | ||
| 14 | +{ | ||
| 15 | + protected $searchTerm; | ||
| 16 | + protected $book; | ||
| 17 | + protected $chapter; | ||
| 18 | + protected $page; | ||
| 19 | + protected $db; | ||
| 20 | + protected $permissionService; | ||
| 21 | + protected $entities; | ||
| 22 | + | ||
| 23 | + /** | ||
| 24 | + * Acceptable operators to be used in a query | ||
| 25 | + * @var array | ||
| 26 | + */ | ||
| 27 | + protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; | ||
| 28 | + | ||
| 29 | + /** | ||
| 30 | + * SearchService constructor. | ||
| 31 | + * @param SearchTerm $searchTerm | ||
| 32 | + * @param Book $book | ||
| 33 | + * @param Chapter $chapter | ||
| 34 | + * @param Page $page | ||
| 35 | + * @param Connection $db | ||
| 36 | + * @param PermissionService $permissionService | ||
| 37 | + */ | ||
| 38 | + public function __construct(SearchTerm $searchTerm, Book $book, Chapter $chapter, Page $page, Connection $db, PermissionService $permissionService) | ||
| 39 | + { | ||
| 40 | + $this->searchTerm = $searchTerm; | ||
| 41 | + $this->book = $book; | ||
| 42 | + $this->chapter = $chapter; | ||
| 43 | + $this->page = $page; | ||
| 44 | + $this->db = $db; | ||
| 45 | + $this->entities = [ | ||
| 46 | + 'page' => $this->page, | ||
| 47 | + 'chapter' => $this->chapter, | ||
| 48 | + 'book' => $this->book | ||
| 49 | + ]; | ||
| 50 | + $this->permissionService = $permissionService; | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + /** | ||
| 54 | + * Search all entities in the system. | ||
| 55 | + * @param string $searchString | ||
| 56 | + * @param string $entityType | ||
| 57 | + * @param int $page | ||
| 58 | + * @param int $count | ||
| 59 | + * @return array[int, Collection]; | ||
| 60 | + */ | ||
| 61 | + public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20) | ||
| 62 | + { | ||
| 63 | + $terms = $this->parseSearchString($searchString); | ||
| 64 | + $entityTypes = array_keys($this->entities); | ||
| 65 | + $entityTypesToSearch = $entityTypes; | ||
| 66 | + $results = collect(); | ||
| 67 | + | ||
| 68 | + if ($entityType !== 'all') { | ||
| 69 | + $entityTypesToSearch = $entityType; | ||
| 70 | + } else if (isset($terms['filters']['type'])) { | ||
| 71 | + $entityTypesToSearch = explode('|', $terms['filters']['type']); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + $total = 0; | ||
| 75 | + | ||
| 76 | + foreach ($entityTypesToSearch as $entityType) { | ||
| 77 | + if (!in_array($entityType, $entityTypes)) continue; | ||
| 78 | + $search = $this->searchEntityTable($terms, $entityType, $page, $count); | ||
| 79 | + $total += $this->searchEntityTable($terms, $entityType, $page, $count, true); | ||
| 80 | + $results = $results->merge($search); | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + return [ | ||
| 84 | + 'total' => $total, | ||
| 85 | + 'count' => count($results), | ||
| 86 | + 'results' => $results->sortByDesc('score') | ||
| 87 | + ]; | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + | ||
| 91 | + /** | ||
| 92 | + * Search a book for entities | ||
| 93 | + * @param integer $bookId | ||
| 94 | + * @param string $searchString | ||
| 95 | + * @return Collection | ||
| 96 | + */ | ||
| 97 | + public function searchBook($bookId, $searchString) | ||
| 98 | + { | ||
| 99 | + $terms = $this->parseSearchString($searchString); | ||
| 100 | + $entityTypes = ['page', 'chapter']; | ||
| 101 | + $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes; | ||
| 102 | + | ||
| 103 | + $results = collect(); | ||
| 104 | + foreach ($entityTypesToSearch as $entityType) { | ||
| 105 | + if (!in_array($entityType, $entityTypes)) continue; | ||
| 106 | + $search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get(); | ||
| 107 | + $results = $results->merge($search); | ||
| 108 | + } | ||
| 109 | + return $results->sortByDesc('score')->take(20); | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + /** | ||
| 113 | + * Search a book for entities | ||
| 114 | + * @param integer $chapterId | ||
| 115 | + * @param string $searchString | ||
| 116 | + * @return Collection | ||
| 117 | + */ | ||
| 118 | + public function searchChapter($chapterId, $searchString) | ||
| 119 | + { | ||
| 120 | + $terms = $this->parseSearchString($searchString); | ||
| 121 | + $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get(); | ||
| 122 | + return $pages->sortByDesc('score'); | ||
| 123 | + } | ||
| 124 | + | ||
| 125 | + /** | ||
| 126 | + * Search across a particular entity type. | ||
| 127 | + * @param array $terms | ||
| 128 | + * @param string $entityType | ||
| 129 | + * @param int $page | ||
| 130 | + * @param int $count | ||
| 131 | + * @param bool $getCount Return the total count of the search | ||
| 132 | + * @return \Illuminate\Database\Eloquent\Collection|int|static[] | ||
| 133 | + */ | ||
| 134 | + public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $getCount = false) | ||
| 135 | + { | ||
| 136 | + $query = $this->buildEntitySearchQuery($terms, $entityType); | ||
| 137 | + if ($getCount) return $query->count(); | ||
| 138 | + | ||
| 139 | + $query = $query->skip(($page-1) * $count)->take($count); | ||
| 140 | + return $query->get(); | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + /** | ||
| 144 | + * Create a search query for an entity | ||
| 145 | + * @param array $terms | ||
| 146 | + * @param string $entityType | ||
| 147 | + * @return \Illuminate\Database\Eloquent\Builder | ||
| 148 | + */ | ||
| 149 | + protected function buildEntitySearchQuery($terms, $entityType = 'page') | ||
| 150 | + { | ||
| 151 | + $entity = $this->getEntity($entityType); | ||
| 152 | + $entitySelect = $entity->newQuery(); | ||
| 153 | + | ||
| 154 | + // Handle normal search terms | ||
| 155 | + if (count($terms['search']) > 0) { | ||
| 156 | + $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score')); | ||
| 157 | + $subQuery->where(function(Builder $query) use ($terms) { | ||
| 158 | + foreach ($terms['search'] as $inputTerm) { | ||
| 159 | + $query->orWhere('term', 'like', $inputTerm .'%'); | ||
| 160 | + } | ||
| 161 | + })->groupBy('entity_type', 'entity_id'); | ||
| 162 | + $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function(JoinClause $join) { | ||
| 163 | + $join->on('id', '=', 'entity_id'); | ||
| 164 | + })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc'); | ||
| 165 | + $entitySelect->mergeBindings($subQuery); | ||
| 166 | + } | ||
| 167 | + | ||
| 168 | + // Handle exact term matching | ||
| 169 | + if (count($terms['exact']) > 0) { | ||
| 170 | + $entitySelect->where(function(\Illuminate\Database\Eloquent\Builder $query) use ($terms, $entity) { | ||
| 171 | + foreach ($terms['exact'] as $inputTerm) { | ||
| 172 | + $query->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($inputTerm, $entity) { | ||
| 173 | + $query->where('name', 'like', '%'.$inputTerm .'%') | ||
| 174 | + ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%'); | ||
| 175 | + }); | ||
| 176 | + } | ||
| 177 | + }); | ||
| 178 | + } | ||
| 179 | + | ||
| 180 | + // Handle tag searches | ||
| 181 | + foreach ($terms['tags'] as $inputTerm) { | ||
| 182 | + $this->applyTagSearch($entitySelect, $inputTerm); | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + // Handle filters | ||
| 186 | + foreach ($terms['filters'] as $filterTerm => $filterValue) { | ||
| 187 | + $functionName = camel_case('filter_' . $filterTerm); | ||
| 188 | + if (method_exists($this, $functionName)) $this->$functionName($entitySelect, $entity, $filterValue); | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view'); | ||
| 192 | + } | ||
| 193 | + | ||
| 194 | + | ||
| 195 | + /** | ||
| 196 | + * Parse a search string into components. | ||
| 197 | + * @param $searchString | ||
| 198 | + * @return array | ||
| 199 | + */ | ||
| 200 | + protected function parseSearchString($searchString) | ||
| 201 | + { | ||
| 202 | + $terms = [ | ||
| 203 | + 'search' => [], | ||
| 204 | + 'exact' => [], | ||
| 205 | + 'tags' => [], | ||
| 206 | + 'filters' => [] | ||
| 207 | + ]; | ||
| 208 | + | ||
| 209 | + $patterns = [ | ||
| 210 | + 'exact' => '/"(.*?)"/', | ||
| 211 | + 'tags' => '/\[(.*?)\]/', | ||
| 212 | + 'filters' => '/\{(.*?)\}/' | ||
| 213 | + ]; | ||
| 214 | + | ||
| 215 | + // Parse special terms | ||
| 216 | + foreach ($patterns as $termType => $pattern) { | ||
| 217 | + $matches = []; | ||
| 218 | + preg_match_all($pattern, $searchString, $matches); | ||
| 219 | + if (count($matches) > 0) { | ||
| 220 | + $terms[$termType] = $matches[1]; | ||
| 221 | + $searchString = preg_replace($pattern, '', $searchString); | ||
| 222 | + } | ||
| 223 | + } | ||
| 224 | + | ||
| 225 | + // Parse standard terms | ||
| 226 | + foreach (explode(' ', trim($searchString)) as $searchTerm) { | ||
| 227 | + if ($searchTerm !== '') $terms['search'][] = $searchTerm; | ||
| 228 | + } | ||
| 229 | + | ||
| 230 | + // Split filter values out | ||
| 231 | + $splitFilters = []; | ||
| 232 | + foreach ($terms['filters'] as $filter) { | ||
| 233 | + $explodedFilter = explode(':', $filter, 2); | ||
| 234 | + $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : ''; | ||
| 235 | + } | ||
| 236 | + $terms['filters'] = $splitFilters; | ||
| 237 | + | ||
| 238 | + return $terms; | ||
| 239 | + } | ||
| 240 | + | ||
| 241 | + /** | ||
| 242 | + * Get the available query operators as a regex escaped list. | ||
| 243 | + * @return mixed | ||
| 244 | + */ | ||
| 245 | + protected function getRegexEscapedOperators() | ||
| 246 | + { | ||
| 247 | + $escapedOperators = []; | ||
| 248 | + foreach ($this->queryOperators as $operator) { | ||
| 249 | + $escapedOperators[] = preg_quote($operator); | ||
| 250 | + } | ||
| 251 | + return join('|', $escapedOperators); | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + /** | ||
| 255 | + * Apply a tag search term onto a entity query. | ||
| 256 | + * @param \Illuminate\Database\Eloquent\Builder $query | ||
| 257 | + * @param string $tagTerm | ||
| 258 | + * @return mixed | ||
| 259 | + */ | ||
| 260 | + protected function applyTagSearch(\Illuminate\Database\Eloquent\Builder $query, $tagTerm) { | ||
| 261 | + preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit); | ||
| 262 | + $query->whereHas('tags', function(\Illuminate\Database\Eloquent\Builder $query) use ($tagSplit) { | ||
| 263 | + $tagName = $tagSplit[1]; | ||
| 264 | + $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : ''; | ||
| 265 | + $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : ''; | ||
| 266 | + $validOperator = in_array($tagOperator, $this->queryOperators); | ||
| 267 | + if (!empty($tagOperator) && !empty($tagValue) && $validOperator) { | ||
| 268 | + if (!empty($tagName)) $query->where('name', '=', $tagName); | ||
| 269 | + if (is_numeric($tagValue) && $tagOperator !== 'like') { | ||
| 270 | + // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will | ||
| 271 | + // search the value as a string which prevents being able to do number-based operations | ||
| 272 | + // on the tag values. We ensure it has a numeric value and then cast it just to be sure. | ||
| 273 | + $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'"); | ||
| 274 | + $query->whereRaw("value ${tagOperator} ${tagValue}"); | ||
| 275 | + } else { | ||
| 276 | + $query->where('value', $tagOperator, $tagValue); | ||
| 277 | + } | ||
| 278 | + } else { | ||
| 279 | + $query->where('name', '=', $tagName); | ||
| 280 | + } | ||
| 281 | + }); | ||
| 282 | + return $query; | ||
| 283 | + } | ||
| 284 | + | ||
| 285 | + /** | ||
| 286 | + * Get an entity instance via type. | ||
| 287 | + * @param $type | ||
| 288 | + * @return Entity | ||
| 289 | + */ | ||
| 290 | + protected function getEntity($type) | ||
| 291 | + { | ||
| 292 | + return $this->entities[strtolower($type)]; | ||
| 293 | + } | ||
| 294 | + | ||
| 295 | + /** | ||
| 296 | + * Index the given entity. | ||
| 297 | + * @param Entity $entity | ||
| 298 | + */ | ||
| 299 | + public function indexEntity(Entity $entity) | ||
| 300 | + { | ||
| 301 | + $this->deleteEntityTerms($entity); | ||
| 302 | + $nameTerms = $this->generateTermArrayFromText($entity->name, 5); | ||
| 303 | + $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1); | ||
| 304 | + $terms = array_merge($nameTerms, $bodyTerms); | ||
| 305 | + foreach ($terms as $index => $term) { | ||
| 306 | + $terms[$index]['entity_type'] = $entity->getMorphClass(); | ||
| 307 | + $terms[$index]['entity_id'] = $entity->id; | ||
| 308 | + } | ||
| 309 | + $this->searchTerm->newQuery()->insert($terms); | ||
| 310 | + } | ||
| 311 | + | ||
| 312 | + /** | ||
| 313 | + * Index multiple Entities at once | ||
| 314 | + * @param Entity[] $entities | ||
| 315 | + */ | ||
| 316 | + protected function indexEntities($entities) { | ||
| 317 | + $terms = []; | ||
| 318 | + foreach ($entities as $entity) { | ||
| 319 | + $nameTerms = $this->generateTermArrayFromText($entity->name, 5); | ||
| 320 | + $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1); | ||
| 321 | + foreach (array_merge($nameTerms, $bodyTerms) as $term) { | ||
| 322 | + $term['entity_id'] = $entity->id; | ||
| 323 | + $term['entity_type'] = $entity->getMorphClass(); | ||
| 324 | + $terms[] = $term; | ||
| 325 | + } | ||
| 326 | + } | ||
| 327 | + | ||
| 328 | + $chunkedTerms = array_chunk($terms, 500); | ||
| 329 | + foreach ($chunkedTerms as $termChunk) { | ||
| 330 | + $this->searchTerm->newQuery()->insert($termChunk); | ||
| 331 | + } | ||
| 332 | + } | ||
| 333 | + | ||
| 334 | + /** | ||
| 335 | + * Delete and re-index the terms for all entities in the system. | ||
| 336 | + */ | ||
| 337 | + public function indexAllEntities() | ||
| 338 | + { | ||
| 339 | + $this->searchTerm->truncate(); | ||
| 340 | + | ||
| 341 | + // Chunk through all books | ||
| 342 | + $this->book->chunk(1000, function ($books) { | ||
| 343 | + $this->indexEntities($books); | ||
| 344 | + }); | ||
| 345 | + | ||
| 346 | + // Chunk through all chapters | ||
| 347 | + $this->chapter->chunk(1000, function ($chapters) { | ||
| 348 | + $this->indexEntities($chapters); | ||
| 349 | + }); | ||
| 350 | + | ||
| 351 | + // Chunk through all pages | ||
| 352 | + $this->page->chunk(1000, function ($pages) { | ||
| 353 | + $this->indexEntities($pages); | ||
| 354 | + }); | ||
| 355 | + } | ||
| 356 | + | ||
| 357 | + /** | ||
| 358 | + * Delete related Entity search terms. | ||
| 359 | + * @param Entity $entity | ||
| 360 | + */ | ||
| 361 | + public function deleteEntityTerms(Entity $entity) | ||
| 362 | + { | ||
| 363 | + $entity->searchTerms()->delete(); | ||
| 364 | + } | ||
| 365 | + | ||
| 366 | + /** | ||
| 367 | + * Create a scored term array from the given text. | ||
| 368 | + * @param $text | ||
| 369 | + * @param float|int $scoreAdjustment | ||
| 370 | + * @return array | ||
| 371 | + */ | ||
| 372 | + protected function generateTermArrayFromText($text, $scoreAdjustment = 1) | ||
| 373 | + { | ||
| 374 | + $tokenMap = []; // {TextToken => OccurrenceCount} | ||
| 375 | + $splitText = explode(' ', $text); | ||
| 376 | + foreach ($splitText as $token) { | ||
| 377 | + if ($token === '') continue; | ||
| 378 | + if (!isset($tokenMap[$token])) $tokenMap[$token] = 0; | ||
| 379 | + $tokenMap[$token]++; | ||
| 380 | + } | ||
| 381 | + | ||
| 382 | + $terms = []; | ||
| 383 | + foreach ($tokenMap as $token => $count) { | ||
| 384 | + $terms[] = [ | ||
| 385 | + 'term' => $token, | ||
| 386 | + 'score' => $count * $scoreAdjustment | ||
| 387 | + ]; | ||
| 388 | + } | ||
| 389 | + return $terms; | ||
| 390 | + } | ||
| 391 | + | ||
| 392 | + | ||
| 393 | + | ||
| 394 | + | ||
| 395 | + /** | ||
| 396 | + * Custom entity search filters | ||
| 397 | + */ | ||
| 398 | + | ||
| 399 | + protected function filterUpdatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 400 | + { | ||
| 401 | + try { $date = date_create($input); | ||
| 402 | + } catch (\Exception $e) {return;} | ||
| 403 | + $query->where('updated_at', '>=', $date); | ||
| 404 | + } | ||
| 405 | + | ||
| 406 | + protected function filterUpdatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 407 | + { | ||
| 408 | + try { $date = date_create($input); | ||
| 409 | + } catch (\Exception $e) {return;} | ||
| 410 | + $query->where('updated_at', '<', $date); | ||
| 411 | + } | ||
| 412 | + | ||
| 413 | + protected function filterCreatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 414 | + { | ||
| 415 | + try { $date = date_create($input); | ||
| 416 | + } catch (\Exception $e) {return;} | ||
| 417 | + $query->where('created_at', '>=', $date); | ||
| 418 | + } | ||
| 419 | + | ||
| 420 | + protected function filterCreatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 421 | + { | ||
| 422 | + try { $date = date_create($input); | ||
| 423 | + } catch (\Exception $e) {return;} | ||
| 424 | + $query->where('created_at', '<', $date); | ||
| 425 | + } | ||
| 426 | + | ||
| 427 | + protected function filterCreatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 428 | + { | ||
| 429 | + if (!is_numeric($input) && $input !== 'me') return; | ||
| 430 | + if ($input === 'me') $input = user()->id; | ||
| 431 | + $query->where('created_by', '=', $input); | ||
| 432 | + } | ||
| 433 | + | ||
| 434 | + protected function filterUpdatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 435 | + { | ||
| 436 | + if (!is_numeric($input) && $input !== 'me') return; | ||
| 437 | + if ($input === 'me') $input = user()->id; | ||
| 438 | + $query->where('updated_by', '=', $input); | ||
| 439 | + } | ||
| 440 | + | ||
| 441 | + protected function filterInName(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 442 | + { | ||
| 443 | + $query->where('name', 'like', '%' .$input. '%'); | ||
| 444 | + } | ||
| 445 | + | ||
| 446 | + protected function filterInTitle(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) {$this->filterInName($query, $model, $input);} | ||
| 447 | + | ||
| 448 | + protected function filterInBody(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 449 | + { | ||
| 450 | + $query->where($model->textField, 'like', '%' .$input. '%'); | ||
| 451 | + } | ||
| 452 | + | ||
| 453 | + protected function filterIsRestricted(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 454 | + { | ||
| 455 | + $query->where('restricted', '=', true); | ||
| 456 | + } | ||
| 457 | + | ||
| 458 | + protected function filterViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 459 | + { | ||
| 460 | + $query->whereHas('views', function($query) { | ||
| 461 | + $query->where('user_id', '=', user()->id); | ||
| 462 | + }); | ||
| 463 | + } | ||
| 464 | + | ||
| 465 | + protected function filterNotViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) | ||
| 466 | + { | ||
| 467 | + $query->whereDoesntHave('views', function($query) { | ||
| 468 | + $query->where('user_id', '=', user()->id); | ||
| 469 | + }); | ||
| 470 | + } | ||
| 471 | + | ||
| 472 | +} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -12,9 +12,10 @@ class AddSearchIndexes extends Migration | ... | @@ -12,9 +12,10 @@ class AddSearchIndexes extends Migration |
| 12 | */ | 12 | */ |
| 13 | public function up() | 13 | public function up() |
| 14 | { | 14 | { |
| 15 | - DB::statement('ALTER TABLE pages ADD FULLTEXT search(name, text)'); | 15 | + $prefix = DB::getTablePrefix(); |
| 16 | - DB::statement('ALTER TABLE books ADD FULLTEXT search(name, description)'); | 16 | + DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT search(name, text)"); |
| 17 | - DB::statement('ALTER TABLE chapters ADD FULLTEXT search(name, description)'); | 17 | + DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT search(name, description)"); |
| 18 | + DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT search(name, description)"); | ||
| 18 | } | 19 | } |
| 19 | 20 | ||
| 20 | /** | 21 | /** | ... | ... |
| ... | @@ -12,9 +12,10 @@ class FulltextWeighting extends Migration | ... | @@ -12,9 +12,10 @@ class FulltextWeighting extends Migration |
| 12 | */ | 12 | */ |
| 13 | public function up() | 13 | public function up() |
| 14 | { | 14 | { |
| 15 | - DB::statement('ALTER TABLE pages ADD FULLTEXT name_search(name)'); | 15 | + $prefix = DB::getTablePrefix(); |
| 16 | - DB::statement('ALTER TABLE books ADD FULLTEXT name_search(name)'); | 16 | + DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT name_search(name)"); |
| 17 | - DB::statement('ALTER TABLE chapters ADD FULLTEXT name_search(name)'); | 17 | + DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT name_search(name)"); |
| 18 | + DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT name_search(name)"); | ||
| 18 | } | 19 | } |
| 19 | 20 | ||
| 20 | /** | 21 | /** | ... | ... |
| 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 | + // Drop search indexes | ||
| 30 | + Schema::table('pages', function(Blueprint $table) { | ||
| 31 | + $table->dropIndex('search'); | ||
| 32 | + $table->dropIndex('name_search'); | ||
| 33 | + }); | ||
| 34 | + Schema::table('books', function(Blueprint $table) { | ||
| 35 | + $table->dropIndex('search'); | ||
| 36 | + $table->dropIndex('name_search'); | ||
| 37 | + }); | ||
| 38 | + Schema::table('chapters', function(Blueprint $table) { | ||
| 39 | + $table->dropIndex('search'); | ||
| 40 | + $table->dropIndex('name_search'); | ||
| 41 | + }); | ||
| 42 | + | ||
| 43 | + app(\BookStack\Services\SearchService::class)->indexAllEntities(); | ||
| 44 | + } | ||
| 45 | + | ||
| 46 | + /** | ||
| 47 | + * Reverse the migrations. | ||
| 48 | + * | ||
| 49 | + * @return void | ||
| 50 | + */ | ||
| 51 | + public function down() | ||
| 52 | + { | ||
| 53 | + $prefix = DB::getTablePrefix(); | ||
| 54 | + DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT search(name, text)"); | ||
| 55 | + DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT search(name, description)"); | ||
| 56 | + DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT search(name, description)"); | ||
| 57 | + DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT name_search(name)"); | ||
| 58 | + DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT name_search(name)"); | ||
| 59 | + DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT name_search(name)"); | ||
| 60 | + | ||
| 61 | + Schema::dropIfExists('search_terms'); | ||
| 62 | + } | ||
| 63 | +} |
| ... | @@ -16,7 +16,7 @@ class DummyContentSeeder extends Seeder | ... | @@ -16,7 +16,7 @@ class DummyContentSeeder extends Seeder |
| 16 | $user->attachRole($role); | 16 | $user->attachRole($role); |
| 17 | 17 | ||
| 18 | 18 | ||
| 19 | - $books = factory(\BookStack\Book::class, 20)->create(['created_by' => $user->id, 'updated_by' => $user->id]) | 19 | + factory(\BookStack\Book::class, 20)->create(['created_by' => $user->id, 'updated_by' => $user->id]) |
| 20 | ->each(function($book) use ($user) { | 20 | ->each(function($book) use ($user) { |
| 21 | $chapters = factory(\BookStack\Chapter::class, 5)->create(['created_by' => $user->id, 'updated_by' => $user->id]) | 21 | $chapters = factory(\BookStack\Chapter::class, 5)->create(['created_by' => $user->id, 'updated_by' => $user->id]) |
| 22 | ->each(function($chapter) use ($user, $book){ | 22 | ->each(function($chapter) use ($user, $book){ |
| ... | @@ -28,7 +28,7 @@ class DummyContentSeeder extends Seeder | ... | @@ -28,7 +28,7 @@ class DummyContentSeeder extends Seeder |
| 28 | $book->pages()->saveMany($pages); | 28 | $book->pages()->saveMany($pages); |
| 29 | }); | 29 | }); |
| 30 | 30 | ||
| 31 | - $restrictionService = app(\BookStack\Services\PermissionService::class); | 31 | + app(\BookStack\Services\PermissionService::class)->buildJointPermissions(); |
| 32 | - $restrictionService->buildJointPermissions(); | 32 | + app(\BookStack\Services\SearchService::class)->indexAllEntities(); |
| 33 | } | 33 | } |
| 34 | } | 34 | } | ... | ... |
| 1 | -var elixir = require('laravel-elixir'); | 1 | +const argv = require('yargs').argv; |
| 2 | +const gulp = require('gulp'), | ||
| 3 | + plumber = require('gulp-plumber'); | ||
| 4 | +const autoprefixer = require('gulp-autoprefixer'); | ||
| 5 | +const uglify = require('gulp-uglify'); | ||
| 6 | +const minifycss = require('gulp-clean-css'); | ||
| 7 | +const sass = require('gulp-sass'); | ||
| 8 | +const browserify = require("browserify"); | ||
| 9 | +const source = require('vinyl-source-stream'); | ||
| 10 | +const buffer = require('vinyl-buffer'); | ||
| 11 | +const babelify = require("babelify"); | ||
| 12 | +const watchify = require("watchify"); | ||
| 13 | +const envify = require("envify"); | ||
| 14 | +const gutil = require("gulp-util"); | ||
| 2 | 15 | ||
| 3 | -elixir(mix => { | 16 | +if (argv.production) process.env.NODE_ENV = 'production'; |
| 4 | - mix.sass('styles.scss'); | 17 | + |
| 5 | - mix.sass('print-styles.scss'); | 18 | +gulp.task('styles', () => { |
| 6 | - mix.sass('export-styles.scss'); | 19 | + let chain = gulp.src(['resources/assets/sass/**/*.scss']) |
| 7 | - mix.browserify('global.js', './public/js/common.js'); | 20 | + .pipe(plumber({ |
| 21 | + errorHandler: function (error) { | ||
| 22 | + console.log(error.message); | ||
| 23 | + this.emit('end'); | ||
| 24 | + }})) | ||
| 25 | + .pipe(sass()) | ||
| 26 | + .pipe(autoprefixer('last 2 versions')); | ||
| 27 | + if (argv.production) chain = chain.pipe(minifycss()); | ||
| 28 | + return chain.pipe(gulp.dest('public/css/')); | ||
| 8 | }); | 29 | }); |
| 30 | + | ||
| 31 | + | ||
| 32 | +function scriptTask(watch=false) { | ||
| 33 | + | ||
| 34 | + let props = { | ||
| 35 | + basedir: 'resources/assets/js', | ||
| 36 | + debug: true, | ||
| 37 | + entries: ['global.js'] | ||
| 38 | + }; | ||
| 39 | + | ||
| 40 | + let bundler = watch ? watchify(browserify(props), { poll: true }) : browserify(props); | ||
| 41 | + bundler.transform(envify, {global: true}).transform(babelify, {presets: ['es2015']}); | ||
| 42 | + function rebundle() { | ||
| 43 | + let stream = bundler.bundle(); | ||
| 44 | + stream = stream.pipe(source('common.js')); | ||
| 45 | + if (argv.production) stream = stream.pipe(buffer()).pipe(uglify()); | ||
| 46 | + return stream.pipe(gulp.dest('public/js/')); | ||
| 47 | + } | ||
| 48 | + bundler.on('update', function() { | ||
| 49 | + rebundle(); | ||
| 50 | + gutil.log('Rebundle...'); | ||
| 51 | + }); | ||
| 52 | + bundler.on('log', gutil.log); | ||
| 53 | + return rebundle(); | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +gulp.task('scripts', () => {scriptTask(false)}); | ||
| 57 | +gulp.task('scripts-watch', () => {scriptTask(true)}); | ||
| 58 | + | ||
| 59 | +gulp.task('default', ['styles', 'scripts-watch'], () => { | ||
| 60 | + gulp.watch("resources/assets/sass/**/*.scss", ['styles']); | ||
| 61 | +}); | ||
| 62 | + | ||
| 63 | +gulp.task('build', ['styles', 'scripts']); | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| 1 | { | 1 | { |
| 2 | "private": true, | 2 | "private": true, |
| 3 | "scripts": { | 3 | "scripts": { |
| 4 | - "build": "gulp --production", | 4 | + "build": "gulp build", |
| 5 | - "dev": "gulp watch", | 5 | + "production": "gulp build --production", |
| 6 | - "watch": "gulp watch" | 6 | + "dev": "gulp", |
| 7 | + "watch": "gulp" | ||
| 7 | }, | 8 | }, |
| 8 | "devDependencies": { | 9 | "devDependencies": { |
| 10 | + "babelify": "^7.3.0", | ||
| 11 | + "browserify": "^14.3.0", | ||
| 12 | + "envify": "^4.0.0", | ||
| 13 | + "gulp": "3.9.1", | ||
| 14 | + "gulp-autoprefixer": "3.1.1", | ||
| 15 | + "gulp-clean-css": "^3.0.4", | ||
| 16 | + "gulp-minify-css": "1.2.4", | ||
| 17 | + "gulp-plumber": "1.1.0", | ||
| 18 | + "gulp-sass": "3.1.0", | ||
| 19 | + "gulp-uglify": "2.1.2", | ||
| 20 | + "vinyl-buffer": "^1.0.0", | ||
| 21 | + "vinyl-source-stream": "^1.1.0", | ||
| 22 | + "watchify": "^3.9.0", | ||
| 23 | + "yargs": "^7.1.0" | ||
| 24 | + }, | ||
| 25 | + "dependencies": { | ||
| 9 | "angular": "^1.5.5", | 26 | "angular": "^1.5.5", |
| 10 | "angular-animate": "^1.5.5", | 27 | "angular-animate": "^1.5.5", |
| 11 | "angular-resource": "^1.5.5", | 28 | "angular-resource": "^1.5.5", |
| 12 | "angular-sanitize": "^1.5.5", | 29 | "angular-sanitize": "^1.5.5", |
| 13 | - "angular-ui-sortable": "^0.15.0", | 30 | + "angular-ui-sortable": "^0.17.0", |
| 31 | + "axios": "^0.16.1", | ||
| 32 | + "babel-preset-es2015": "^6.24.1", | ||
| 33 | + "clipboard": "^1.5.16", | ||
| 14 | "dropzone": "^4.0.1", | 34 | "dropzone": "^4.0.1", |
| 15 | - "gulp": "^3.9.0", | 35 | + "gulp-util": "^3.0.8", |
| 16 | - "laravel-elixir": "^6.0.0-11", | ||
| 17 | - "laravel-elixir-browserify-official": "^0.1.3", | ||
| 18 | "marked": "^0.3.5", | 36 | "marked": "^0.3.5", |
| 19 | - "moment": "^2.12.0" | 37 | + "moment": "^2.12.0", |
| 38 | + "vue": "^2.2.6" | ||
| 20 | }, | 39 | }, |
| 21 | - "dependencies": { | 40 | + "browser": { |
| 22 | - "clipboard": "^1.5.16" | 41 | + "vue": "vue/dist/vue.common.js" |
| 23 | } | 42 | } |
| 24 | } | 43 | } | ... | ... |
| 1 | "use strict"; | 1 | "use strict"; |
| 2 | 2 | ||
| 3 | -import moment from 'moment'; | 3 | +const moment = require('moment'); |
| 4 | -import 'moment/locale/en-gb'; | 4 | +require('moment/locale/en-gb'); |
| 5 | -import editorOptions from "./pages/page-form"; | 5 | +const editorOptions = require("./pages/page-form"); |
| 6 | 6 | ||
| 7 | moment.locale('en-gb'); | 7 | moment.locale('en-gb'); |
| 8 | 8 | ||
| 9 | -export default function (ngApp, events) { | 9 | +module.exports = function (ngApp, events) { |
| 10 | 10 | ||
| 11 | ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService', | 11 | ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService', |
| 12 | function ($scope, $attrs, $http, $timeout, imageManagerService) { | 12 | function ($scope, $attrs, $http, $timeout, imageManagerService) { |
| ... | @@ -259,39 +259,6 @@ export default function (ngApp, events) { | ... | @@ -259,39 +259,6 @@ export default function (ngApp, events) { |
| 259 | 259 | ||
| 260 | }]); | 260 | }]); |
| 261 | 261 | ||
| 262 | - | ||
| 263 | - ngApp.controller('BookShowController', ['$scope', '$http', '$attrs', '$sce', function ($scope, $http, $attrs, $sce) { | ||
| 264 | - $scope.searching = false; | ||
| 265 | - $scope.searchTerm = ''; | ||
| 266 | - $scope.searchResults = ''; | ||
| 267 | - | ||
| 268 | - $scope.searchBook = function (e) { | ||
| 269 | - e.preventDefault(); | ||
| 270 | - let term = $scope.searchTerm; | ||
| 271 | - if (term.length == 0) return; | ||
| 272 | - $scope.searching = true; | ||
| 273 | - $scope.searchResults = ''; | ||
| 274 | - let searchUrl = window.baseUrl('/search/book/' + $attrs.bookId); | ||
| 275 | - searchUrl += '?term=' + encodeURIComponent(term); | ||
| 276 | - $http.get(searchUrl).then((response) => { | ||
| 277 | - $scope.searchResults = $sce.trustAsHtml(response.data); | ||
| 278 | - }); | ||
| 279 | - }; | ||
| 280 | - | ||
| 281 | - $scope.checkSearchForm = function () { | ||
| 282 | - if ($scope.searchTerm.length < 1) { | ||
| 283 | - $scope.searching = false; | ||
| 284 | - } | ||
| 285 | - }; | ||
| 286 | - | ||
| 287 | - $scope.clearSearch = function () { | ||
| 288 | - $scope.searching = false; | ||
| 289 | - $scope.searchTerm = ''; | ||
| 290 | - }; | ||
| 291 | - | ||
| 292 | - }]); | ||
| 293 | - | ||
| 294 | - | ||
| 295 | ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce', | 262 | ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce', |
| 296 | function ($scope, $http, $attrs, $interval, $timeout, $sce) { | 263 | function ($scope, $http, $attrs, $interval, $timeout, $sce) { |
| 297 | 264 | ... | ... |
| 1 | "use strict"; | 1 | "use strict"; |
| 2 | -import DropZone from "dropzone"; | 2 | +const DropZone = require("dropzone"); |
| 3 | -import markdown from "marked"; | 3 | +const markdown = require("marked"); |
| 4 | 4 | ||
| 5 | -export default function (ngApp, events) { | 5 | +module.exports = function (ngApp, events) { |
| 6 | 6 | ||
| 7 | /** | 7 | /** |
| 8 | * Common tab controls using simple jQuery functions. | 8 | * Common tab controls using simple jQuery functions. | ... | ... |
| 1 | "use strict"; | 1 | "use strict"; |
| 2 | 2 | ||
| 3 | -// AngularJS - Create application and load components | ||
| 4 | -import angular from "angular"; | ||
| 5 | -import "angular-resource"; | ||
| 6 | -import "angular-animate"; | ||
| 7 | -import "angular-sanitize"; | ||
| 8 | -import "angular-ui-sortable"; | ||
| 9 | - | ||
| 10 | // Url retrieval function | 3 | // Url retrieval function |
| 11 | window.baseUrl = function(path) { | 4 | window.baseUrl = function(path) { |
| 12 | let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content'); | 5 | let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content'); |
| ... | @@ -15,11 +8,33 @@ window.baseUrl = function(path) { | ... | @@ -15,11 +8,33 @@ window.baseUrl = function(path) { |
| 15 | return basePath + '/' + path; | 8 | return basePath + '/' + path; |
| 16 | }; | 9 | }; |
| 17 | 10 | ||
| 11 | +const Vue = require("vue"); | ||
| 12 | +const axios = require("axios"); | ||
| 13 | + | ||
| 14 | +let axiosInstance = axios.create({ | ||
| 15 | + headers: { | ||
| 16 | + 'X-CSRF-TOKEN': document.querySelector('meta[name=token]').getAttribute('content'), | ||
| 17 | + 'baseURL': window.baseUrl('') | ||
| 18 | + } | ||
| 19 | +}); | ||
| 20 | + | ||
| 21 | +Vue.prototype.$http = axiosInstance; | ||
| 22 | + | ||
| 23 | +require("./vues/vues"); | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +// AngularJS - Create application and load components | ||
| 27 | +const angular = require("angular"); | ||
| 28 | +require("angular-resource"); | ||
| 29 | +require("angular-animate"); | ||
| 30 | +require("angular-sanitize"); | ||
| 31 | +require("angular-ui-sortable"); | ||
| 32 | + | ||
| 18 | let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']); | 33 | let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']); |
| 19 | 34 | ||
| 20 | // Translation setup | 35 | // Translation setup |
| 21 | // Creates a global function with name 'trans' to be used in the same way as Laravel's translation system | 36 | // Creates a global function with name 'trans' to be used in the same way as Laravel's translation system |
| 22 | -import Translations from "./translations" | 37 | +const Translations = require("./translations"); |
| 23 | let translator = new Translations(window.translations); | 38 | let translator = new Translations(window.translations); |
| 24 | window.trans = translator.get.bind(translator); | 39 | window.trans = translator.get.bind(translator); |
| 25 | 40 | ||
| ... | @@ -47,11 +62,12 @@ class EventManager { | ... | @@ -47,11 +62,12 @@ class EventManager { |
| 47 | } | 62 | } |
| 48 | 63 | ||
| 49 | window.Events = new EventManager(); | 64 | window.Events = new EventManager(); |
| 65 | +Vue.prototype.$events = window.Events; | ||
| 50 | 66 | ||
| 51 | // Load in angular specific items | 67 | // Load in angular specific items |
| 52 | -import Services from './services'; | 68 | +const Services = require('./services'); |
| 53 | -import Directives from './directives'; | 69 | +const Directives = require('./directives'); |
| 54 | -import Controllers from './controllers'; | 70 | +const Controllers = require('./controllers'); |
| 55 | Services(ngApp, window.Events); | 71 | Services(ngApp, window.Events); |
| 56 | Directives(ngApp, window.Events); | 72 | Directives(ngApp, window.Events); |
| 57 | Controllers(ngApp, window.Events); | 73 | Controllers(ngApp, window.Events); |
| ... | @@ -154,4 +170,4 @@ if(navigator.userAgent.indexOf('MSIE')!==-1 | ... | @@ -154,4 +170,4 @@ if(navigator.userAgent.indexOf('MSIE')!==-1 |
| 154 | } | 170 | } |
| 155 | 171 | ||
| 156 | // Page specific items | 172 | // Page specific items |
| 157 | -import "./pages/page-show"; | 173 | +require("./pages/page-show"); | ... | ... |
| ... | @@ -60,7 +60,7 @@ function registerEditorShortcuts(editor) { | ... | @@ -60,7 +60,7 @@ function registerEditorShortcuts(editor) { |
| 60 | editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']); | 60 | editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']); |
| 61 | } | 61 | } |
| 62 | 62 | ||
| 63 | -export default function() { | 63 | +module.exports = function() { |
| 64 | let settings = { | 64 | let settings = { |
| 65 | selector: '#html-editor', | 65 | selector: '#html-editor', |
| 66 | content_css: [ | 66 | content_css: [ |
| ... | @@ -213,4 +213,4 @@ export default function() { | ... | @@ -213,4 +213,4 @@ export default function() { |
| 213 | } | 213 | } |
| 214 | }; | 214 | }; |
| 215 | return settings; | 215 | return settings; |
| 216 | -} | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| 216 | +}; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| 1 | "use strict"; | 1 | "use strict"; |
| 2 | // Configure ZeroClipboard | 2 | // Configure ZeroClipboard |
| 3 | -import Clipboard from "clipboard"; | 3 | +const Clipboard = require("clipboard"); |
| 4 | 4 | ||
| 5 | -export default window.setupPageShow = function (pageId) { | 5 | +let setupPageShow = window.setupPageShow = function (pageId) { |
| 6 | 6 | ||
| 7 | // Set up pointer | 7 | // Set up pointer |
| 8 | let $pointer = $('#pointer').detach(); | 8 | let $pointer = $('#pointer').detach(); |
| ... | @@ -151,3 +151,5 @@ export default window.setupPageShow = function (pageId) { | ... | @@ -151,3 +151,5 @@ export default window.setupPageShow = function (pageId) { |
| 151 | }); | 151 | }); |
| 152 | 152 | ||
| 153 | }; | 153 | }; |
| 154 | + | ||
| 155 | +module.exports = setupPageShow; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
resources/assets/js/vues/entity-search.js
0 → 100644
| 1 | +let data = { | ||
| 2 | + id: null, | ||
| 3 | + type: '', | ||
| 4 | + searching: false, | ||
| 5 | + searchTerm: '', | ||
| 6 | + searchResults: '', | ||
| 7 | +}; | ||
| 8 | + | ||
| 9 | +let computed = { | ||
| 10 | + | ||
| 11 | +}; | ||
| 12 | + | ||
| 13 | +let methods = { | ||
| 14 | + | ||
| 15 | + searchBook() { | ||
| 16 | + if (this.searchTerm.trim().length === 0) return; | ||
| 17 | + this.searching = true; | ||
| 18 | + this.searchResults = ''; | ||
| 19 | + let url = window.baseUrl(`/search/${this.type}/${this.id}`); | ||
| 20 | + url += `?term=${encodeURIComponent(this.searchTerm)}`; | ||
| 21 | + this.$http.get(url).then(resp => { | ||
| 22 | + this.searchResults = resp.data; | ||
| 23 | + }); | ||
| 24 | + }, | ||
| 25 | + | ||
| 26 | + checkSearchForm() { | ||
| 27 | + this.searching = this.searchTerm > 0; | ||
| 28 | + }, | ||
| 29 | + | ||
| 30 | + clearSearch() { | ||
| 31 | + this.searching = false; | ||
| 32 | + this.searchTerm = ''; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | +}; | ||
| 36 | + | ||
| 37 | +function mounted() { | ||
| 38 | + this.id = Number(this.$el.getAttribute('entity-id')); | ||
| 39 | + this.type = this.$el.getAttribute('entity-type'); | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +module.exports = { | ||
| 43 | + data, computed, methods, mounted | ||
| 44 | +}; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
resources/assets/js/vues/search.js
0 → 100644
| 1 | +const moment = require('moment'); | ||
| 2 | + | ||
| 3 | +let data = { | ||
| 4 | + terms: '', | ||
| 5 | + termString : '', | ||
| 6 | + search: { | ||
| 7 | + type: { | ||
| 8 | + page: true, | ||
| 9 | + chapter: true, | ||
| 10 | + book: true | ||
| 11 | + }, | ||
| 12 | + exactTerms: [], | ||
| 13 | + tagTerms: [], | ||
| 14 | + option: {}, | ||
| 15 | + dates: { | ||
| 16 | + updated_after: false, | ||
| 17 | + updated_before: false, | ||
| 18 | + created_after: false, | ||
| 19 | + created_before: false, | ||
| 20 | + } | ||
| 21 | + } | ||
| 22 | +}; | ||
| 23 | + | ||
| 24 | +let computed = { | ||
| 25 | + | ||
| 26 | +}; | ||
| 27 | + | ||
| 28 | +let methods = { | ||
| 29 | + | ||
| 30 | + appendTerm(term) { | ||
| 31 | + this.termString += ' ' + term; | ||
| 32 | + this.termString = this.termString.replace(/\s{2,}/g, ' '); | ||
| 33 | + this.termString = this.termString.replace(/^\s+/, ''); | ||
| 34 | + this.termString = this.termString.replace(/\s+$/, ''); | ||
| 35 | + }, | ||
| 36 | + | ||
| 37 | + exactParse(searchString) { | ||
| 38 | + this.search.exactTerms = []; | ||
| 39 | + let exactFilter = /"(.+?)"/g; | ||
| 40 | + let matches; | ||
| 41 | + while ((matches = exactFilter.exec(searchString)) !== null) { | ||
| 42 | + this.search.exactTerms.push(matches[1]); | ||
| 43 | + } | ||
| 44 | + }, | ||
| 45 | + | ||
| 46 | + exactChange() { | ||
| 47 | + let exactFilter = /"(.+?)"/g; | ||
| 48 | + this.termString = this.termString.replace(exactFilter, ''); | ||
| 49 | + let matchesTerm = this.search.exactTerms.filter(term => { | ||
| 50 | + return term.trim() !== ''; | ||
| 51 | + }).map(term => { | ||
| 52 | + return `"${term}"` | ||
| 53 | + }).join(' '); | ||
| 54 | + this.appendTerm(matchesTerm); | ||
| 55 | + }, | ||
| 56 | + | ||
| 57 | + addExact() { | ||
| 58 | + this.search.exactTerms.push(''); | ||
| 59 | + setTimeout(() => { | ||
| 60 | + let exactInputs = document.querySelectorAll('.exact-input'); | ||
| 61 | + exactInputs[exactInputs.length - 1].focus(); | ||
| 62 | + }, 100); | ||
| 63 | + }, | ||
| 64 | + | ||
| 65 | + removeExact(index) { | ||
| 66 | + this.search.exactTerms.splice(index, 1); | ||
| 67 | + this.exactChange(); | ||
| 68 | + }, | ||
| 69 | + | ||
| 70 | + tagParse(searchString) { | ||
| 71 | + this.search.tagTerms = []; | ||
| 72 | + let tagFilter = /\[(.+?)\]/g; | ||
| 73 | + let matches; | ||
| 74 | + while ((matches = tagFilter.exec(searchString)) !== null) { | ||
| 75 | + this.search.tagTerms.push(matches[1]); | ||
| 76 | + } | ||
| 77 | + }, | ||
| 78 | + | ||
| 79 | + tagChange() { | ||
| 80 | + let tagFilter = /\[(.+?)\]/g; | ||
| 81 | + this.termString = this.termString.replace(tagFilter, ''); | ||
| 82 | + let matchesTerm = this.search.tagTerms.filter(term => { | ||
| 83 | + return term.trim() !== ''; | ||
| 84 | + }).map(term => { | ||
| 85 | + return `[${term}]` | ||
| 86 | + }).join(' '); | ||
| 87 | + this.appendTerm(matchesTerm); | ||
| 88 | + }, | ||
| 89 | + | ||
| 90 | + addTag() { | ||
| 91 | + this.search.tagTerms.push(''); | ||
| 92 | + setTimeout(() => { | ||
| 93 | + let tagInputs = document.querySelectorAll('.tag-input'); | ||
| 94 | + tagInputs[tagInputs.length - 1].focus(); | ||
| 95 | + }, 100); | ||
| 96 | + }, | ||
| 97 | + | ||
| 98 | + removeTag(index) { | ||
| 99 | + this.search.tagTerms.splice(index, 1); | ||
| 100 | + this.tagChange(); | ||
| 101 | + }, | ||
| 102 | + | ||
| 103 | + typeParse(searchString) { | ||
| 104 | + let typeFilter = /{\s?type:\s?(.*?)\s?}/; | ||
| 105 | + let match = searchString.match(typeFilter); | ||
| 106 | + let type = this.search.type; | ||
| 107 | + if (!match) { | ||
| 108 | + type.page = type.book = type.chapter = true; | ||
| 109 | + return; | ||
| 110 | + } | ||
| 111 | + let splitTypes = match[1].replace(/ /g, '').split('|'); | ||
| 112 | + type.page = (splitTypes.indexOf('page') !== -1); | ||
| 113 | + type.chapter = (splitTypes.indexOf('chapter') !== -1); | ||
| 114 | + type.book = (splitTypes.indexOf('book') !== -1); | ||
| 115 | + }, | ||
| 116 | + | ||
| 117 | + typeChange() { | ||
| 118 | + let typeFilter = /{\s?type:\s?(.*?)\s?}/; | ||
| 119 | + let type = this.search.type; | ||
| 120 | + if (type.page === type.chapter && type.page === type.book) { | ||
| 121 | + this.termString = this.termString.replace(typeFilter, ''); | ||
| 122 | + return; | ||
| 123 | + } | ||
| 124 | + let selectedTypes = Object.keys(type).filter(type => {return this.search.type[type];}).join('|'); | ||
| 125 | + let typeTerm = '{type:'+selectedTypes+'}'; | ||
| 126 | + if (this.termString.match(typeFilter)) { | ||
| 127 | + this.termString = this.termString.replace(typeFilter, typeTerm); | ||
| 128 | + return; | ||
| 129 | + } | ||
| 130 | + this.appendTerm(typeTerm); | ||
| 131 | + }, | ||
| 132 | + | ||
| 133 | + optionParse(searchString) { | ||
| 134 | + let optionFilter = /{([a-z_\-:]+?)}/gi; | ||
| 135 | + let matches; | ||
| 136 | + while ((matches = optionFilter.exec(searchString)) !== null) { | ||
| 137 | + this.search.option[matches[1].toLowerCase()] = true; | ||
| 138 | + } | ||
| 139 | + }, | ||
| 140 | + | ||
| 141 | + optionChange(optionName) { | ||
| 142 | + let isChecked = this.search.option[optionName]; | ||
| 143 | + if (isChecked) { | ||
| 144 | + this.appendTerm(`{${optionName}}`); | ||
| 145 | + } else { | ||
| 146 | + this.termString = this.termString.replace(`{${optionName}}`, ''); | ||
| 147 | + } | ||
| 148 | + }, | ||
| 149 | + | ||
| 150 | + updateSearch(e) { | ||
| 151 | + e.preventDefault(); | ||
| 152 | + window.location = '/search?term=' + encodeURIComponent(this.termString); | ||
| 153 | + }, | ||
| 154 | + | ||
| 155 | + enableDate(optionName) { | ||
| 156 | + this.search.dates[optionName.toLowerCase()] = moment().format('YYYY-MM-DD'); | ||
| 157 | + this.dateChange(optionName); | ||
| 158 | + }, | ||
| 159 | + | ||
| 160 | + dateParse(searchString) { | ||
| 161 | + let dateFilter = /{([a-z_\-]+?):([a-z_\-0-9]+?)}/gi; | ||
| 162 | + let dateTags = Object.keys(this.search.dates); | ||
| 163 | + let matches; | ||
| 164 | + while ((matches = dateFilter.exec(searchString)) !== null) { | ||
| 165 | + if (dateTags.indexOf(matches[1]) === -1) continue; | ||
| 166 | + this.search.dates[matches[1].toLowerCase()] = matches[2]; | ||
| 167 | + } | ||
| 168 | + }, | ||
| 169 | + | ||
| 170 | + dateChange(optionName) { | ||
| 171 | + let dateFilter = new RegExp('{\\s?'+optionName+'\\s?:([a-z_\\-0-9]+?)}', 'gi'); | ||
| 172 | + this.termString = this.termString.replace(dateFilter, ''); | ||
| 173 | + if (!this.search.dates[optionName]) return; | ||
| 174 | + this.appendTerm(`{${optionName}:${this.search.dates[optionName]}}`); | ||
| 175 | + }, | ||
| 176 | + | ||
| 177 | + dateRemove(optionName) { | ||
| 178 | + this.search.dates[optionName] = false; | ||
| 179 | + this.dateChange(optionName); | ||
| 180 | + } | ||
| 181 | + | ||
| 182 | +}; | ||
| 183 | + | ||
| 184 | +function created() { | ||
| 185 | + this.termString = document.querySelector('[name=searchTerm]').value; | ||
| 186 | + this.typeParse(this.termString); | ||
| 187 | + this.exactParse(this.termString); | ||
| 188 | + this.tagParse(this.termString); | ||
| 189 | + this.optionParse(this.termString); | ||
| 190 | + this.dateParse(this.termString); | ||
| 191 | +} | ||
| 192 | + | ||
| 193 | +module.exports = { | ||
| 194 | + data, computed, methods, created | ||
| 195 | +}; | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
resources/assets/js/vues/vues.js
0 → 100644
| 1 | +const Vue = require("vue"); | ||
| 2 | + | ||
| 3 | +function exists(id) { | ||
| 4 | + return document.getElementById(id) !== null; | ||
| 5 | +} | ||
| 6 | + | ||
| 7 | +let vueMapping = { | ||
| 8 | + 'search-system': require('./search'), | ||
| 9 | + 'entity-dashboard': require('./entity-search'), | ||
| 10 | +}; | ||
| 11 | + | ||
| 12 | +Object.keys(vueMapping).forEach(id => { | ||
| 13 | + if (exists(id)) { | ||
| 14 | + let config = vueMapping[id]; | ||
| 15 | + config.el = '#' + id; | ||
| 16 | + new Vue(config); | ||
| 17 | + } | ||
| 18 | +}); | ||
| ... | \ No newline at end of file | ... | \ No newline at end of file |
| ... | @@ -2,7 +2,7 @@ | ... | @@ -2,7 +2,7 @@ |
| 2 | .anim.fadeIn { | 2 | .anim.fadeIn { |
| 3 | opacity: 0; | 3 | opacity: 0; |
| 4 | animation-name: fadeIn; | 4 | animation-name: fadeIn; |
| 5 | - animation-duration: 160ms; | 5 | + animation-duration: 180ms; |
| 6 | animation-timing-function: ease-in-out; | 6 | animation-timing-function: ease-in-out; |
| 7 | animation-fill-mode: forwards; | 7 | animation-fill-mode: forwards; |
| 8 | } | 8 | } | ... | ... |
| ... | @@ -98,19 +98,36 @@ label { | ... | @@ -98,19 +98,36 @@ label { |
| 98 | 98 | ||
| 99 | label.radio, label.checkbox { | 99 | label.radio, label.checkbox { |
| 100 | font-weight: 400; | 100 | font-weight: 400; |
| 101 | + user-select: none; | ||
| 101 | input[type="radio"], input[type="checkbox"] { | 102 | input[type="radio"], input[type="checkbox"] { |
| 102 | margin-right: $-xs; | 103 | margin-right: $-xs; |
| 103 | } | 104 | } |
| 104 | } | 105 | } |
| 105 | 106 | ||
| 107 | +label.inline.checkbox { | ||
| 108 | + margin-right: $-m; | ||
| 109 | +} | ||
| 110 | + | ||
| 106 | label + p.small { | 111 | label + p.small { |
| 107 | margin-bottom: 0.8em; | 112 | margin-bottom: 0.8em; |
| 108 | } | 113 | } |
| 109 | 114 | ||
| 110 | -input[type="text"], input[type="number"], input[type="email"], input[type="search"], input[type="url"], input[type="password"], select, textarea { | 115 | +table.form-table { |
| 116 | + max-width: 100%; | ||
| 117 | + td { | ||
| 118 | + overflow: hidden; | ||
| 119 | + padding: $-xxs/2 0; | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +input[type="text"], input[type="number"], input[type="email"], input[type="date"], input[type="search"], input[type="url"], input[type="password"], select, textarea { | ||
| 111 | @extend .input-base; | 124 | @extend .input-base; |
| 112 | } | 125 | } |
| 113 | 126 | ||
| 127 | +input[type=date] { | ||
| 128 | + width: 190px; | ||
| 129 | +} | ||
| 130 | + | ||
| 114 | .toggle-switch { | 131 | .toggle-switch { |
| 115 | display: inline-block; | 132 | display: inline-block; |
| 116 | background-color: #BBB; | 133 | background-color: #BBB; | ... | ... |
| ... | @@ -109,6 +109,7 @@ | ... | @@ -109,6 +109,7 @@ |
| 109 | transition-property: right, border; | 109 | transition-property: right, border; |
| 110 | border-left: 0px solid #FFF; | 110 | border-left: 0px solid #FFF; |
| 111 | background-color: #FFF; | 111 | background-color: #FFF; |
| 112 | + max-width: 320px; | ||
| 112 | &.fixed { | 113 | &.fixed { |
| 113 | background-color: #FFF; | 114 | background-color: #FFF; |
| 114 | z-index: 5; | 115 | z-index: 5; | ... | ... |
| ... | @@ -7,8 +7,8 @@ | ... | @@ -7,8 +7,8 @@ |
| 7 | @import "grid"; | 7 | @import "grid"; |
| 8 | @import "blocks"; | 8 | @import "blocks"; |
| 9 | @import "buttons"; | 9 | @import "buttons"; |
| 10 | -@import "forms"; | ||
| 11 | @import "tables"; | 10 | @import "tables"; |
| 11 | +@import "forms"; | ||
| 12 | @import "animations"; | 12 | @import "animations"; |
| 13 | @import "tinymce"; | 13 | @import "tinymce"; |
| 14 | @import "highlightjs"; | 14 | @import "highlightjs"; |
| ... | @@ -17,7 +17,11 @@ | ... | @@ -17,7 +17,11 @@ |
| 17 | @import "lists"; | 17 | @import "lists"; |
| 18 | @import "pages"; | 18 | @import "pages"; |
| 19 | 19 | ||
| 20 | -[v-cloak], [v-show] {display: none;} | 20 | +[v-cloak], [v-show] { |
| 21 | + display: none; opacity: 0; | ||
| 22 | + animation-name: none !important; | ||
| 23 | +} | ||
| 24 | + | ||
| 21 | 25 | ||
| 22 | [ng\:cloak], [ng-cloak], .ng-cloak { | 26 | [ng\:cloak], [ng-cloak], .ng-cloak { |
| 23 | display: none !important; | 27 | display: none !important; |
| ... | @@ -272,8 +276,3 @@ $btt-size: 40px; | ... | @@ -272,8 +276,3 @@ $btt-size: 40px; |
| 272 | 276 | ||
| 273 | 277 | ||
| 274 | 278 | ||
| 275 | - | ||
| 276 | - | ||
| 277 | - | ||
| 278 | - | ||
| 279 | - | ... | ... |
| ... | @@ -43,18 +43,9 @@ return [ | ... | @@ -43,18 +43,9 @@ return [ |
| 43 | * Search | 43 | * Search |
| 44 | */ | 44 | */ |
| 45 | 'search_results' => 'Suchergebnisse', | 45 | 'search_results' => 'Suchergebnisse', |
| 46 | - 'search_results_page' => 'Seiten-Suchergebnisse', | ||
| 47 | - 'search_results_chapter' => 'Kapitel-Suchergebnisse', | ||
| 48 | - 'search_results_book' => 'Buch-Suchergebnisse', | ||
| 49 | 'search_clear' => 'Suche zurücksetzen', | 46 | 'search_clear' => 'Suche zurücksetzen', |
| 50 | - 'search_view_pages' => 'Zeige alle passenden Seiten', | ||
| 51 | - 'search_view_chapters' => 'Zeige alle passenden Kapitel', | ||
| 52 | - 'search_view_books' => 'Zeige alle passenden Bücher', | ||
| 53 | 'search_no_pages' => 'Es wurden keine passenden Suchergebnisse gefunden', | 47 | 'search_no_pages' => 'Es wurden keine passenden Suchergebnisse gefunden', |
| 54 | 'search_for_term' => 'Suche nach :term', | 48 | 'search_for_term' => 'Suche nach :term', |
| 55 | - 'search_page_for_term' => 'Suche nach :term in Seiten', | ||
| 56 | - 'search_chapter_for_term' => 'Suche nach :term in Kapiteln', | ||
| 57 | - 'search_book_for_term' => 'Suche nach :term in Büchern', | ||
| 58 | 49 | ||
| 59 | /** | 50 | /** |
| 60 | * Books | 51 | * Books | ... | ... |
| ... | @@ -43,18 +43,26 @@ return [ | ... | @@ -43,18 +43,26 @@ return [ |
| 43 | * Search | 43 | * Search |
| 44 | */ | 44 | */ |
| 45 | 'search_results' => 'Search Results', | 45 | 'search_results' => 'Search Results', |
| 46 | - 'search_results_page' => 'Page Search Results', | 46 | + 'search_total_results_found' => ':count result found|:count total results found', |
| 47 | - 'search_results_chapter' => 'Chapter Search Results', | ||
| 48 | - 'search_results_book' => 'Book Search Results', | ||
| 49 | 'search_clear' => 'Clear Search', | 47 | 'search_clear' => 'Clear Search', |
| 50 | - 'search_view_pages' => 'View all matches pages', | ||
| 51 | - 'search_view_chapters' => 'View all matches chapters', | ||
| 52 | - 'search_view_books' => 'View all matches books', | ||
| 53 | 'search_no_pages' => 'No pages matched this search', | 48 | 'search_no_pages' => 'No pages matched this search', |
| 54 | 'search_for_term' => 'Search for :term', | 49 | 'search_for_term' => 'Search for :term', |
| 55 | - 'search_page_for_term' => 'Page search for :term', | 50 | + 'search_more' => 'More Results', |
| 56 | - 'search_chapter_for_term' => 'Chapter search for :term', | 51 | + 'search_filters' => 'Search Filters', |
| 57 | - 'search_book_for_term' => 'Books search for :term', | 52 | + 'search_content_type' => 'Content Type', |
| 53 | + 'search_exact_matches' => 'Exact Matches', | ||
| 54 | + 'search_tags' => 'Tag Searches', | ||
| 55 | + 'search_viewed_by_me' => 'Viewed by me', | ||
| 56 | + 'search_not_viewed_by_me' => 'Not viewed by me', | ||
| 57 | + 'search_permissions_set' => 'Permissions set', | ||
| 58 | + 'search_created_by_me' => 'Created by me', | ||
| 59 | + 'search_updated_by_me' => 'Updated by me', | ||
| 60 | + 'search_updated_before' => 'Updated before', | ||
| 61 | + 'search_updated_after' => 'Updated after', | ||
| 62 | + 'search_created_before' => 'Created before', | ||
| 63 | + 'search_created_after' => 'Created after', | ||
| 64 | + 'search_set_date' => 'Set Date', | ||
| 65 | + 'search_update' => 'Update Search', | ||
| 58 | 66 | ||
| 59 | /** | 67 | /** |
| 60 | * Books | 68 | * Books |
| ... | @@ -112,6 +120,7 @@ return [ | ... | @@ -112,6 +120,7 @@ return [ |
| 112 | 'chapters_empty' => 'No pages are currently in this chapter.', | 120 | 'chapters_empty' => 'No pages are currently in this chapter.', |
| 113 | 'chapters_permissions_active' => 'Chapter Permissions Active', | 121 | 'chapters_permissions_active' => 'Chapter Permissions Active', |
| 114 | 'chapters_permissions_success' => 'Chapter Permissions Updated', | 122 | 'chapters_permissions_success' => 'Chapter Permissions Updated', |
| 123 | + 'chapters_search_this' => 'Search this chapter', | ||
| 115 | 124 | ||
| 116 | /** | 125 | /** |
| 117 | * Pages | 126 | * Pages | ... | ... |
| ... | @@ -43,18 +43,9 @@ return [ | ... | @@ -43,18 +43,9 @@ return [ |
| 43 | * Search | 43 | * Search |
| 44 | */ | 44 | */ |
| 45 | 'search_results' => 'Buscar resultados', | 45 | 'search_results' => 'Buscar resultados', |
| 46 | - 'search_results_page' => 'resultados de búsqueda en página', | ||
| 47 | - 'search_results_chapter' => 'Resultados de búsqueda en capítulo ', | ||
| 48 | - 'search_results_book' => 'Resultados de búsqueda en libro', | ||
| 49 | 'search_clear' => 'Limpiar resultados', | 46 | 'search_clear' => 'Limpiar resultados', |
| 50 | - 'search_view_pages' => 'Ver todas las páginas que concuerdan', | ||
| 51 | - 'search_view_chapters' => 'Ver todos los capítulos que concuerdan', | ||
| 52 | - 'search_view_books' => 'Ver todos los libros que concuerdan', | ||
| 53 | 'search_no_pages' => 'Ninguna página encontrada para la búsqueda', | 47 | 'search_no_pages' => 'Ninguna página encontrada para la búsqueda', |
| 54 | 'search_for_term' => 'Busqueda por :term', | 48 | 'search_for_term' => 'Busqueda por :term', |
| 55 | - 'search_page_for_term' => 'Búsqueda de página por :term', | ||
| 56 | - 'search_chapter_for_term' => 'Búsqueda por capítulo de :term', | ||
| 57 | - 'search_book_for_term' => 'Búsqueda en libro de :term', | ||
| 58 | 49 | ||
| 59 | /** | 50 | /** |
| 60 | * Books | 51 | * Books | ... | ... |
| ... | @@ -43,18 +43,9 @@ return [ | ... | @@ -43,18 +43,9 @@ return [ |
| 43 | * Search | 43 | * Search |
| 44 | */ | 44 | */ |
| 45 | 'search_results' => 'Résultats de recherche', | 45 | 'search_results' => 'Résultats de recherche', |
| 46 | - 'search_results_page' => 'Résultats de recherche des pages', | ||
| 47 | - 'search_results_chapter' => 'Résultats de recherche des chapitres', | ||
| 48 | - 'search_results_book' => 'Résultats de recherche des livres', | ||
| 49 | 'search_clear' => 'Réinitialiser la recherche', | 46 | 'search_clear' => 'Réinitialiser la recherche', |
| 50 | - 'search_view_pages' => 'Voir toutes les pages correspondantes', | ||
| 51 | - 'search_view_chapters' => 'Voir tous les chapitres correspondants', | ||
| 52 | - 'search_view_books' => 'Voir tous les livres correspondants', | ||
| 53 | 'search_no_pages' => 'Aucune page correspondant à cette recherche', | 47 | 'search_no_pages' => 'Aucune page correspondant à cette recherche', |
| 54 | 'search_for_term' => 'recherche pour :term', | 48 | 'search_for_term' => 'recherche pour :term', |
| 55 | - 'search_page_for_term' => 'Recherche de page pour :term', | ||
| 56 | - 'search_chapter_for_term' => 'Recherche de chapitre pour :term', | ||
| 57 | - 'search_book_for_term' => 'Recherche de livres pour :term', | ||
| 58 | 49 | ||
| 59 | /** | 50 | /** |
| 60 | * Books | 51 | * Books | ... | ... |
| ... | @@ -43,18 +43,9 @@ return [ | ... | @@ -43,18 +43,9 @@ return [ |
| 43 | * Search | 43 | * Search |
| 44 | */ | 44 | */ |
| 45 | 'search_results' => 'Zoekresultaten', | 45 | 'search_results' => 'Zoekresultaten', |
| 46 | - 'search_results_page' => 'Pagina Zoekresultaten', | ||
| 47 | - 'search_results_chapter' => 'Hoofdstuk Zoekresultaten', | ||
| 48 | - 'search_results_book' => 'Boek Zoekresultaten', | ||
| 49 | 'search_clear' => 'Zoekopdracht wissen', | 46 | 'search_clear' => 'Zoekopdracht wissen', |
| 50 | - 'search_view_pages' => 'Bekijk alle gevonden pagina\'s', | ||
| 51 | - 'search_view_chapters' => 'Bekijk alle gevonden hoofdstukken', | ||
| 52 | - 'search_view_books' => 'Bekijk alle gevonden boeken', | ||
| 53 | 'search_no_pages' => 'Er zijn geen pagina\'s gevonden', | 47 | 'search_no_pages' => 'Er zijn geen pagina\'s gevonden', |
| 54 | 'search_for_term' => 'Zoeken op :term', | 48 | 'search_for_term' => 'Zoeken op :term', |
| 55 | - 'search_page_for_term' => 'Pagina doorzoeken op :term', | ||
| 56 | - 'search_chapter_for_term' => 'Hoofdstuk doorzoeken op :term', | ||
| 57 | - 'search_book_for_term' => 'Boeken doorzoeken op :term', | ||
| 58 | 49 | ||
| 59 | /** | 50 | /** |
| 60 | * Books | 51 | * Books | ... | ... |
| ... | @@ -43,18 +43,9 @@ return [ | ... | @@ -43,18 +43,9 @@ return [ |
| 43 | * Search | 43 | * Search |
| 44 | */ | 44 | */ |
| 45 | 'search_results' => 'Resultado(s) da Pesquisa', | 45 | 'search_results' => 'Resultado(s) da Pesquisa', |
| 46 | - 'search_results_page' => 'Resultado(s) de Pesquisa de Página', | ||
| 47 | - 'search_results_chapter' => 'Resultado(s) de Pesquisa de Capítulo', | ||
| 48 | - 'search_results_book' => 'Resultado(s) de Pesquisa de Livro', | ||
| 49 | 'search_clear' => 'Limpar Pesquisa', | 46 | 'search_clear' => 'Limpar Pesquisa', |
| 50 | - 'search_view_pages' => 'Visualizar todas as páginas correspondentes', | ||
| 51 | - 'search_view_chapters' => 'Visualizar todos os capítulos correspondentes', | ||
| 52 | - 'search_view_books' => 'Visualizar todos os livros correspondentes', | ||
| 53 | 'search_no_pages' => 'Nenhuma página corresponde à pesquisa', | 47 | 'search_no_pages' => 'Nenhuma página corresponde à pesquisa', |
| 54 | 'search_for_term' => 'Pesquisar por :term', | 48 | 'search_for_term' => 'Pesquisar por :term', |
| 55 | - 'search_page_for_term' => 'Pesquisar Página por :term', | ||
| 56 | - 'search_chapter_for_term' => 'Pesquisar Capítulo por :term', | ||
| 57 | - 'search_book_for_term' => 'Pesquisar Livros por :term', | ||
| 58 | 49 | ||
| 59 | /** | 50 | /** |
| 60 | * Books | 51 | * Books | ... | ... |
| ... | @@ -47,7 +47,7 @@ | ... | @@ -47,7 +47,7 @@ |
| 47 | </a> | 47 | </a> |
| 48 | </div> | 48 | </div> |
| 49 | <div class="col-lg-4 col-sm-3 text-center"> | 49 | <div class="col-lg-4 col-sm-3 text-center"> |
| 50 | - <form action="{{ baseUrl('/search/all') }}" method="GET" class="search-box"> | 50 | + <form action="{{ baseUrl('/search') }}" method="GET" class="search-box"> |
| 51 | <input id="header-search-box-input" type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}"> | 51 | <input id="header-search-box-input" type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}"> |
| 52 | <button id="header-search-box-button" type="submit" class="text-button"><i class="zmdi zmdi-search"></i></button> | 52 | <button id="header-search-box-button" type="submit" class="text-button"><i class="zmdi zmdi-search"></i></button> |
| 53 | </form> | 53 | </form> | ... | ... |
| ... | @@ -50,15 +50,15 @@ | ... | @@ -50,15 +50,15 @@ |
| 50 | </div> | 50 | </div> |
| 51 | 51 | ||
| 52 | 52 | ||
| 53 | - <div class="container" id="book-dashboard" ng-controller="BookShowController" book-id="{{ $book->id }}"> | 53 | + <div class="container" id="entity-dashboard" entity-id="{{ $book->id }}" entity-type="book"> |
| 54 | <div class="row"> | 54 | <div class="row"> |
| 55 | <div class="col-md-7"> | 55 | <div class="col-md-7"> |
| 56 | 56 | ||
| 57 | <h1>{{$book->name}}</h1> | 57 | <h1>{{$book->name}}</h1> |
| 58 | - <div class="book-content" ng-show="!searching"> | 58 | + <div class="book-content" v-if="!searching"> |
| 59 | - <p class="text-muted" ng-non-bindable>{{$book->description}}</p> | 59 | + <p class="text-muted" v-pre>{{$book->description}}</p> |
| 60 | 60 | ||
| 61 | - <div class="page-list" ng-non-bindable> | 61 | + <div class="page-list" v-pre> |
| 62 | <hr> | 62 | <hr> |
| 63 | @if(count($bookChildren) > 0) | 63 | @if(count($bookChildren) > 0) |
| 64 | @foreach($bookChildren as $childElement) | 64 | @foreach($bookChildren as $childElement) |
| ... | @@ -81,12 +81,12 @@ | ... | @@ -81,12 +81,12 @@ |
| 81 | @include('partials.entity-meta', ['entity' => $book]) | 81 | @include('partials.entity-meta', ['entity' => $book]) |
| 82 | </div> | 82 | </div> |
| 83 | </div> | 83 | </div> |
| 84 | - <div class="search-results" ng-cloak ng-show="searching"> | 84 | + <div class="search-results" v-cloak v-if="searching"> |
| 85 | - <h3 class="text-muted">{{ trans('entities.search_results') }} <a ng-if="searching" ng-click="clearSearch()" class="text-small"><i class="zmdi zmdi-close"></i>{{ trans('entities.search_clear') }}</a></h3> | 85 | + <h3 class="text-muted">{{ trans('entities.search_results') }} <a v-if="searching" v-on:click="clearSearch()" class="text-small"><i class="zmdi zmdi-close"></i>{{ trans('entities.search_clear') }}</a></h3> |
| 86 | - <div ng-if="!searchResults"> | 86 | + <div v-if="!searchResults"> |
| 87 | @include('partials/loading-icon') | 87 | @include('partials/loading-icon') |
| 88 | </div> | 88 | </div> |
| 89 | - <div ng-bind-html="searchResults"></div> | 89 | + <div v-html="searchResults"></div> |
| 90 | </div> | 90 | </div> |
| 91 | 91 | ||
| 92 | 92 | ||
| ... | @@ -94,6 +94,7 @@ | ... | @@ -94,6 +94,7 @@ |
| 94 | 94 | ||
| 95 | <div class="col-md-4 col-md-offset-1"> | 95 | <div class="col-md-4 col-md-offset-1"> |
| 96 | <div class="margin-top large"></div> | 96 | <div class="margin-top large"></div> |
| 97 | + | ||
| 97 | @if($book->restricted) | 98 | @if($book->restricted) |
| 98 | <p class="text-muted"> | 99 | <p class="text-muted"> |
| 99 | @if(userCan('restrictions-manage', $book)) | 100 | @if(userCan('restrictions-manage', $book)) |
| ... | @@ -103,14 +104,16 @@ | ... | @@ -103,14 +104,16 @@ |
| 103 | @endif | 104 | @endif |
| 104 | </p> | 105 | </p> |
| 105 | @endif | 106 | @endif |
| 107 | + | ||
| 106 | <div class="search-box"> | 108 | <div class="search-box"> |
| 107 | - <form ng-submit="searchBook($event)"> | 109 | + <form v-on:submit="searchBook"> |
| 108 | - <input ng-model="searchTerm" ng-change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}"> | 110 | + <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}"> |
| 109 | <button type="submit"><i class="zmdi zmdi-search"></i></button> | 111 | <button type="submit"><i class="zmdi zmdi-search"></i></button> |
| 110 | - <button ng-if="searching" ng-click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button> | 112 | + <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button> |
| 111 | </form> | 113 | </form> |
| 112 | </div> | 114 | </div> |
| 113 | - <div class="activity anim fadeIn"> | 115 | + |
| 116 | + <div class="activity"> | ||
| 114 | <h3>{{ trans('entities.recent_activity') }}</h3> | 117 | <h3>{{ trans('entities.recent_activity') }}</h3> |
| 115 | @include('partials/activity-list', ['activity' => Activity::entityActivity($book, 20, 0)]) | 118 | @include('partials/activity-list', ['activity' => Activity::entityActivity($book, 20, 0)]) |
| 116 | </div> | 119 | </div> | ... | ... |
| ... | @@ -47,40 +47,50 @@ | ... | @@ -47,40 +47,50 @@ |
| 47 | </div> | 47 | </div> |
| 48 | 48 | ||
| 49 | 49 | ||
| 50 | - <div class="container" ng-non-bindable> | 50 | + <div class="container" id="entity-dashboard" entity-id="{{ $chapter->id }}" entity-type="chapter"> |
| 51 | <div class="row"> | 51 | <div class="row"> |
| 52 | - <div class="col-md-8"> | 52 | + <div class="col-md-7"> |
| 53 | <h1>{{ $chapter->name }}</h1> | 53 | <h1>{{ $chapter->name }}</h1> |
| 54 | - <p class="text-muted">{{ $chapter->description }}</p> | 54 | + <div class="chapter-content" v-if="!searching"> |
| 55 | + <p class="text-muted">{{ $chapter->description }}</p> | ||
| 55 | 56 | ||
| 56 | - @if(count($pages) > 0) | 57 | + @if(count($pages) > 0) |
| 57 | - <div class="page-list"> | 58 | + <div class="page-list"> |
| 58 | - <hr> | ||
| 59 | - @foreach($pages as $page) | ||
| 60 | - @include('pages/list-item', ['page' => $page]) | ||
| 61 | <hr> | 59 | <hr> |
| 62 | - @endforeach | 60 | + @foreach($pages as $page) |
| 63 | - </div> | 61 | + @include('pages/list-item', ['page' => $page]) |
| 64 | - @else | 62 | + <hr> |
| 65 | - <hr> | 63 | + @endforeach |
| 66 | - <p class="text-muted">{{ trans('entities.chapters_empty') }}</p> | 64 | + </div> |
| 67 | - <p> | 65 | + @else |
| 68 | - @if(userCan('page-create', $chapter)) | 66 | + <hr> |
| 69 | - <a href="{{ $chapter->getUrl('/create-page') }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.books_empty_create_page') }}</a> | 67 | + <p class="text-muted">{{ trans('entities.chapters_empty') }}</p> |
| 70 | - @endif | 68 | + <p> |
| 71 | - @if(userCan('page-create', $chapter) && userCan('book-update', $book)) | 69 | + @if(userCan('page-create', $chapter)) |
| 72 | - <em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em> | 70 | + <a href="{{ $chapter->getUrl('/create-page') }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.books_empty_create_page') }}</a> |
| 73 | - @endif | 71 | + @endif |
| 74 | - @if(userCan('book-update', $book)) | 72 | + @if(userCan('page-create', $chapter) && userCan('book-update', $book)) |
| 75 | - <a href="{{ $book->getUrl('/sort') }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.books_empty_sort_current_book') }}</a> | 73 | + <em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em> |
| 76 | - @endif | 74 | + @endif |
| 77 | - </p> | 75 | + @if(userCan('book-update', $book)) |
| 78 | - <hr> | 76 | + <a href="{{ $book->getUrl('/sort') }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.books_empty_sort_current_book') }}</a> |
| 79 | - @endif | 77 | + @endif |
| 78 | + </p> | ||
| 79 | + <hr> | ||
| 80 | + @endif | ||
| 80 | 81 | ||
| 81 | - @include('partials.entity-meta', ['entity' => $chapter]) | 82 | + @include('partials.entity-meta', ['entity' => $chapter]) |
| 83 | + </div> | ||
| 84 | + | ||
| 85 | + <div class="search-results" v-cloak v-if="searching"> | ||
| 86 | + <h3 class="text-muted">{{ trans('entities.search_results') }} <a v-if="searching" v-on:click="clearSearch()" class="text-small"><i class="zmdi zmdi-close"></i>{{ trans('entities.search_clear') }}</a></h3> | ||
| 87 | + <div v-if="!searchResults"> | ||
| 88 | + @include('partials/loading-icon') | ||
| 89 | + </div> | ||
| 90 | + <div v-html="searchResults"></div> | ||
| 91 | + </div> | ||
| 82 | </div> | 92 | </div> |
| 83 | - <div class="col-md-3 col-md-offset-1"> | 93 | + <div class="col-md-4 col-md-offset-1"> |
| 84 | <div class="margin-top large"></div> | 94 | <div class="margin-top large"></div> |
| 85 | @if($book->restricted || $chapter->restricted) | 95 | @if($book->restricted || $chapter->restricted) |
| 86 | <div class="text-muted"> | 96 | <div class="text-muted"> |
| ... | @@ -105,7 +115,16 @@ | ... | @@ -105,7 +115,16 @@ |
| 105 | </div> | 115 | </div> |
| 106 | @endif | 116 | @endif |
| 107 | 117 | ||
| 118 | + <div class="search-box"> | ||
| 119 | + <form v-on:submit="searchBook"> | ||
| 120 | + <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.chapters_search_this') }}"> | ||
| 121 | + <button type="submit"><i class="zmdi zmdi-search"></i></button> | ||
| 122 | + <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button> | ||
| 123 | + </form> | ||
| 124 | + </div> | ||
| 125 | + | ||
| 108 | @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree]) | 126 | @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree]) |
| 127 | + | ||
| 109 | </div> | 128 | </div> |
| 110 | </div> | 129 | </div> |
| 111 | </div> | 130 | </div> | ... | ... |
| ... | @@ -3,13 +3,13 @@ | ... | @@ -3,13 +3,13 @@ |
| 3 | 3 | ||
| 4 | @if(isset($page) && $page->tags->count() > 0) | 4 | @if(isset($page) && $page->tags->count() > 0) |
| 5 | <div class="tag-display"> | 5 | <div class="tag-display"> |
| 6 | - <h6 class="text-muted">Page Tags</h6> | 6 | + <h6 class="text-muted">{{ trans('entities.page_tags') }}</h6> |
| 7 | <table> | 7 | <table> |
| 8 | <tbody> | 8 | <tbody> |
| 9 | @foreach($page->tags as $tag) | 9 | @foreach($page->tags as $tag) |
| 10 | <tr class="tag"> | 10 | <tr class="tag"> |
| 11 | - <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td> | 11 | + <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td> |
| 12 | - @if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif | 12 | + @if($tag->value) <td class="tag-value"><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%3D' . urlencode($tag->value) . '%5D') }}">{{$tag->value}}</a></td> @endif |
| 13 | </tr> | 13 | </tr> |
| 14 | @endforeach | 14 | @endforeach |
| 15 | </tbody> | 15 | </tbody> | ... | ... |
| ... | @@ -2,59 +2,212 @@ | ... | @@ -2,59 +2,212 @@ |
| 2 | 2 | ||
| 3 | @section('content') | 3 | @section('content') |
| 4 | 4 | ||
| 5 | + <input type="hidden" name="searchTerm" value="{{$searchTerm}}"> | ||
| 6 | + | ||
| 7 | +<div id="search-system"> | ||
| 8 | + | ||
| 5 | <div class="faded-small toolbar"> | 9 | <div class="faded-small toolbar"> |
| 6 | <div class="container"> | 10 | <div class="container"> |
| 7 | <div class="row"> | 11 | <div class="row"> |
| 8 | <div class="col-sm-12 faded"> | 12 | <div class="col-sm-12 faded"> |
| 9 | <div class="breadcrumbs"> | 13 | <div class="breadcrumbs"> |
| 10 | - <a href="{{ baseUrl("/search/all?term={$searchTerm}") }}" class="text-button"><i class="zmdi zmdi-search"></i>{{ $searchTerm }}</a> | 14 | + <a href="{{ baseUrl("/search?term=" . urlencode($searchTerm)) }}" class="text-button"><i class="zmdi zmdi-search"></i>{{ trans('entities.search_for_term', ['term' => $searchTerm]) }}</a> |
| 11 | </div> | 15 | </div> |
| 12 | </div> | 16 | </div> |
| 13 | </div> | 17 | </div> |
| 14 | </div> | 18 | </div> |
| 15 | </div> | 19 | </div> |
| 16 | 20 | ||
| 21 | + <div class="container" ng-non-bindable id="searchSystem"> | ||
| 17 | 22 | ||
| 18 | - <div class="container" ng-non-bindable> | 23 | + <div class="row"> |
| 19 | 24 | ||
| 20 | - <h1>{{ trans('entities.search_results') }}</h1> | 25 | + <div class="col-md-6"> |
| 26 | + <h1>{{ trans('entities.search_results') }}</h1> | ||
| 27 | + <h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6> | ||
| 28 | + @include('partials/entity-list', ['entities' => $entities]) | ||
| 29 | + @if ($hasNextPage) | ||
| 30 | + <a href="{{ $nextPageLink }}" class="button">{{ trans('entities.search_more') }}</a> | ||
| 31 | + @endif | ||
| 32 | + </div> | ||
| 21 | 33 | ||
| 22 | - <p> | 34 | + <div class="col-md-5 col-md-offset-1"> |
| 23 | - @if(count($pages) > 0) | 35 | + <h3>{{ trans('entities.search_filters') }}</h3> |
| 24 | - <a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.search_view_pages') }}</a> | ||
| 25 | - @endif | ||
| 26 | 36 | ||
| 27 | - @if(count($chapters) > 0) | 37 | + <form v-on:submit="updateSearch" v-cloak class="v-cloak anim fadeIn"> |
| 28 | - | 38 | + <h6 class="text-muted">{{ trans('entities.search_content_type') }}</h6> |
| 29 | - <a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="text-chapter"><i class="zmdi zmdi-collection-bookmark"></i>{{ trans('entities.search_view_chapters') }}</a> | 39 | + <div class="form-group"> |
| 30 | - @endif | 40 | + <label class="inline checkbox text-page"><input type="checkbox" v-on:change="typeChange" v-model="search.type.page" value="page">{{ trans('entities.page') }}</label> |
| 41 | + <label class="inline checkbox text-chapter"><input type="checkbox" v-on:change="typeChange" v-model="search.type.chapter" value="chapter">{{ trans('entities.chapter') }}</label> | ||
| 42 | + <label class="inline checkbox text-book"><input type="checkbox" v-on:change="typeChange" v-model="search.type.book" value="book">{{ trans('entities.book') }}</label> | ||
| 43 | + </div> | ||
| 31 | 44 | ||
| 32 | - @if(count($books) > 0) | 45 | + <h6 class="text-muted">{{ trans('entities.search_exact_matches') }}</h6> |
| 33 | - | 46 | + <table cellpadding="0" cellspacing="0" border="0" class="no-style"> |
| 34 | - <a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.search_view_books') }}</a> | 47 | + <tr v-for="(term, i) in search.exactTerms"> |
| 35 | - @endif | 48 | + <td style="padding: 0 12px 6px 0;"> |
| 36 | - </p> | 49 | + <input class="exact-input outline" v-on:input="exactChange" type="text" v-model="search.exactTerms[i]"></td> |
| 50 | + <td> | ||
| 51 | + <button type="button" class="text-neg text-button" v-on:click="removeExact(i)"> | ||
| 52 | + <i class="zmdi zmdi-close"></i> | ||
| 53 | + </button> | ||
| 54 | + </td> | ||
| 55 | + </tr> | ||
| 56 | + <tr> | ||
| 57 | + <td colspan="2"> | ||
| 58 | + <button type="button" class="text-button" v-on:click="addExact"> | ||
| 59 | + <i class="zmdi zmdi-plus-circle-o"></i>{{ trans('common.add') }} | ||
| 60 | + </button> | ||
| 61 | + </td> | ||
| 62 | + </tr> | ||
| 63 | + </table> | ||
| 64 | + | ||
| 65 | + <h6 class="text-muted">{{ trans('entities.search_tags') }}</h6> | ||
| 66 | + <table cellpadding="0" cellspacing="0" border="0" class="no-style"> | ||
| 67 | + <tr v-for="(term, i) in search.tagTerms"> | ||
| 68 | + <td style="padding: 0 12px 6px 0;"> | ||
| 69 | + <input class="tag-input outline" v-on:input="tagChange" type="text" v-model="search.tagTerms[i]"></td> | ||
| 70 | + <td> | ||
| 71 | + <button type="button" class="text-neg text-button" v-on:click="removeTag(i)"> | ||
| 72 | + <i class="zmdi zmdi-close"></i> | ||
| 73 | + </button> | ||
| 74 | + </td> | ||
| 75 | + </tr> | ||
| 76 | + <tr> | ||
| 77 | + <td colspan="2"> | ||
| 78 | + <button type="button" class="text-button" v-on:click="addTag"> | ||
| 79 | + <i class="zmdi zmdi-plus-circle-o"></i>{{ trans('common.add') }} | ||
| 80 | + </button> | ||
| 81 | + </td> | ||
| 82 | + </tr> | ||
| 83 | + </table> | ||
| 84 | + | ||
| 85 | + <h6 class="text-muted">Options</h6> | ||
| 86 | + <label class="checkbox"> | ||
| 87 | + <input type="checkbox" v-on:change="optionChange('viewed_by_me')" | ||
| 88 | + v-model="search.option.viewed_by_me" value="page"> | ||
| 89 | + {{ trans('entities.search_viewed_by_me') }} | ||
| 90 | + </label> | ||
| 91 | + <label class="checkbox"> | ||
| 92 | + <input type="checkbox" v-on:change="optionChange('not_viewed_by_me')" | ||
| 93 | + v-model="search.option.not_viewed_by_me" value="page"> | ||
| 94 | + {{ trans('entities.search_not_viewed_by_me') }} | ||
| 95 | + </label> | ||
| 96 | + <label class="checkbox"> | ||
| 97 | + <input type="checkbox" v-on:change="optionChange('is_restricted')" | ||
| 98 | + v-model="search.option.is_restricted" value="page"> | ||
| 99 | + {{ trans('entities.search_permissions_set') }} | ||
| 100 | + </label> | ||
| 101 | + <label class="checkbox"> | ||
| 102 | + <input type="checkbox" v-on:change="optionChange('created_by:me')" | ||
| 103 | + v-model="search.option['created_by:me']" value="page"> | ||
| 104 | + {{ trans('entities.search_created_by_me') }} | ||
| 105 | + </label> | ||
| 106 | + <label class="checkbox"> | ||
| 107 | + <input type="checkbox" v-on:change="optionChange('updated_by:me')" | ||
| 108 | + v-model="search.option['updated_by:me']" value="page"> | ||
| 109 | + {{ trans('entities.search_updated_by_me') }} | ||
| 110 | + </label> | ||
| 111 | + | ||
| 112 | + <h6 class="text-muted">Date Options</h6> | ||
| 113 | + <table cellpadding="0" cellspacing="0" border="0" class="no-style form-table"> | ||
| 114 | + <tr> | ||
| 115 | + <td width="200">{{ trans('entities.search_updated_after') }}</td> | ||
| 116 | + <td width="80"> | ||
| 117 | + <button type="button" class="text-button" v-if="!search.dates.updated_after" | ||
| 118 | + v-on:click="enableDate('updated_after')">{{ trans('entities.search_set_date') }}</button> | ||
| 119 | + | ||
| 120 | + </td> | ||
| 121 | + </tr> | ||
| 122 | + <tr v-if="search.dates.updated_after"> | ||
| 123 | + <td> | ||
| 124 | + <input v-if="search.dates.updated_after" class="tag-input" | ||
| 125 | + v-on:input="dateChange('updated_after')" type="date" v-model="search.dates.updated_after" | ||
| 126 | + pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> | ||
| 127 | + </td> | ||
| 128 | + <td> | ||
| 129 | + <button v-if="search.dates.updated_after" type="button" class="text-neg text-button" | ||
| 130 | + v-on:click="dateRemove('updated_after')"> | ||
| 131 | + <i class="zmdi zmdi-close"></i> | ||
| 132 | + </button> | ||
| 133 | + </td> | ||
| 134 | + </tr> | ||
| 135 | + <tr> | ||
| 136 | + <td>{{ trans('entities.search_updated_before') }}</td> | ||
| 137 | + <td> | ||
| 138 | + <button type="button" class="text-button" v-if="!search.dates.updated_before" | ||
| 139 | + v-on:click="enableDate('updated_before')">{{ trans('entities.search_set_date') }}</button> | ||
| 140 | + | ||
| 141 | + </td> | ||
| 142 | + </tr> | ||
| 143 | + <tr v-if="search.dates.updated_before"> | ||
| 144 | + <td> | ||
| 145 | + <input v-if="search.dates.updated_before" class="tag-input" | ||
| 146 | + v-on:input="dateChange('updated_before')" type="date" v-model="search.dates.updated_before" | ||
| 147 | + pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> | ||
| 148 | + </td> | ||
| 149 | + <td> | ||
| 150 | + <button v-if="search.dates.updated_before" type="button" class="text-neg text-button" | ||
| 151 | + v-on:click="dateRemove('updated_before')"> | ||
| 152 | + <i class="zmdi zmdi-close"></i> | ||
| 153 | + </button> | ||
| 154 | + </td> | ||
| 155 | + </tr> | ||
| 156 | + <tr> | ||
| 157 | + <td>{{ trans('entities.search_created_after') }}</td> | ||
| 158 | + <td> | ||
| 159 | + <button type="button" class="text-button" v-if="!search.dates.created_after" | ||
| 160 | + v-on:click="enableDate('created_after')">{{ trans('entities.search_set_date') }}</button> | ||
| 161 | + | ||
| 162 | + </td> | ||
| 163 | + </tr> | ||
| 164 | + <tr v-if="search.dates.created_after"> | ||
| 165 | + <td> | ||
| 166 | + <input v-if="search.dates.created_after" class="tag-input" | ||
| 167 | + v-on:input="dateChange('created_after')" type="date" v-model="search.dates.created_after" | ||
| 168 | + pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> | ||
| 169 | + </td> | ||
| 170 | + <td> | ||
| 171 | + <button v-if="search.dates.created_after" type="button" class="text-neg text-button" | ||
| 172 | + v-on:click="dateRemove('created_after')"> | ||
| 173 | + <i class="zmdi zmdi-close"></i> | ||
| 174 | + </button> | ||
| 175 | + </td> | ||
| 176 | + </tr> | ||
| 177 | + <tr> | ||
| 178 | + <td>{{ trans('entities.search_created_before') }}</td> | ||
| 179 | + <td> | ||
| 180 | + <button type="button" class="text-button" v-if="!search.dates.created_before" | ||
| 181 | + v-on:click="enableDate('created_before')">{{ trans('entities.search_set_date') }}</button> | ||
| 182 | + | ||
| 183 | + </td> | ||
| 184 | + </tr> | ||
| 185 | + <tr v-if="search.dates.created_before"> | ||
| 186 | + <td> | ||
| 187 | + <input v-if="search.dates.created_before" class="tag-input" | ||
| 188 | + v-on:input="dateChange('created_before')" type="date" v-model="search.dates.created_before" | ||
| 189 | + pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> | ||
| 190 | + </td> | ||
| 191 | + <td> | ||
| 192 | + <button v-if="search.dates.created_before" type="button" class="text-neg text-button" | ||
| 193 | + v-on:click="dateRemove('created_before')"> | ||
| 194 | + <i class="zmdi zmdi-close"></i> | ||
| 195 | + </button> | ||
| 196 | + </td> | ||
| 197 | + </tr> | ||
| 198 | + </table> | ||
| 199 | + | ||
| 200 | + | ||
| 201 | + <button type="submit" class="button primary">{{ trans('entities.search_update') }}</button> | ||
| 202 | + </form> | ||
| 37 | 203 | ||
| 38 | - <div class="row"> | ||
| 39 | - <div class="col-md-6"> | ||
| 40 | - <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']) | ||
| 42 | - </div> | ||
| 43 | - <div class="col-md-5 col-md-offset-1"> | ||
| 44 | - @if(count($books) > 0) | ||
| 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 | 204 | ||
| 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> | 205 | </div> |
| 206 | + | ||
| 54 | </div> | 207 | </div> |
| 55 | 208 | ||
| 56 | 209 | ||
| 57 | </div> | 210 | </div> |
| 58 | - | 211 | +</div> |
| 59 | 212 | ||
| 60 | @stop | 213 | @stop |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
| ... | @@ -123,11 +123,9 @@ Route::group(['middleware' => 'auth'], function () { | ... | @@ -123,11 +123,9 @@ Route::group(['middleware' => 'auth'], function () { |
| 123 | Route::get('/link/{id}', 'PageController@redirectFromLink'); | 123 | Route::get('/link/{id}', 'PageController@redirectFromLink'); |
| 124 | 124 | ||
| 125 | // Search | 125 | // Search |
| 126 | - Route::get('/search/all', 'SearchController@searchAll'); | 126 | + Route::get('/search', 'SearchController@search'); |
| 127 | - Route::get('/search/pages', 'SearchController@searchPages'); | ||
| 128 | - Route::get('/search/books', 'SearchController@searchBooks'); | ||
| 129 | - Route::get('/search/chapters', 'SearchController@searchChapters'); | ||
| 130 | Route::get('/search/book/{bookId}', 'SearchController@searchBook'); | 127 | Route::get('/search/book/{bookId}', 'SearchController@searchBook'); |
| 128 | + Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter'); | ||
| 131 | 129 | ||
| 132 | // Other Pages | 130 | // Other Pages |
| 133 | Route::get('/', 'HomeController@index'); | 131 | Route::get('/', 'HomeController@index'); | ... | ... |
| 1 | <?php namespace Tests; | 1 | <?php namespace Tests; |
| 2 | 2 | ||
| 3 | -class EntitySearchTest extends BrowserKitTest | 3 | + |
| 4 | +class EntitySearchTest extends TestCase | ||
| 4 | { | 5 | { |
| 5 | 6 | ||
| 6 | public function test_page_search() | 7 | public function test_page_search() |
| ... | @@ -8,91 +9,57 @@ class EntitySearchTest extends BrowserKitTest | ... | @@ -8,91 +9,57 @@ class EntitySearchTest extends BrowserKitTest |
| 8 | $book = \BookStack\Book::all()->first(); | 9 | $book = \BookStack\Book::all()->first(); |
| 9 | $page = $book->pages->first(); | 10 | $page = $book->pages->first(); |
| 10 | 11 | ||
| 11 | - $this->asAdmin() | 12 | + $search = $this->asEditor()->get('/search?term=' . urlencode($page->name)); |
| 12 | - ->visit('/') | 13 | + $search->assertSee('Search Results'); |
| 13 | - ->type($page->name, 'term') | 14 | + $search->assertSee($page->name); |
| 14 | - ->press('header-search-box-button') | ||
| 15 | - ->see('Search Results') | ||
| 16 | - ->seeInElement('.entity-list', $page->name) | ||
| 17 | - ->clickInElement('.entity-list', $page->name) | ||
| 18 | - ->seePageIs($page->getUrl()); | ||
| 19 | } | 15 | } |
| 20 | 16 | ||
| 21 | public function test_invalid_page_search() | 17 | public function test_invalid_page_search() |
| 22 | { | 18 | { |
| 23 | - $this->asAdmin() | 19 | + $resp = $this->asEditor()->get('/search?term=' . urlencode('<p>test</p>')); |
| 24 | - ->visit('/') | 20 | + $resp->assertSee('Search Results'); |
| 25 | - ->type('<p>test</p>', 'term') | 21 | + $resp->assertStatus(200); |
| 26 | - ->press('header-search-box-button') | 22 | + $this->get('/search?term=cat+-')->assertStatus(200); |
| 27 | - ->see('Search Results') | ||
| 28 | - ->seeStatusCode(200); | ||
| 29 | } | 23 | } |
| 30 | 24 | ||
| 31 | - public function test_empty_search_redirects_back() | 25 | + public function test_empty_search_shows_search_page() |
| 32 | { | 26 | { |
| 33 | - $this->asAdmin() | 27 | + $res = $this->asEditor()->get('/search'); |
| 34 | - ->visit('/') | 28 | + $res->assertStatus(200); |
| 35 | - ->visit('/search/all') | ||
| 36 | - ->seePageIs('/'); | ||
| 37 | } | 29 | } |
| 38 | 30 | ||
| 39 | - public function test_book_search() | 31 | + public function test_searching_accents_and_small_terms() |
| 40 | { | 32 | { |
| 41 | - $book = \BookStack\Book::all()->first(); | 33 | + $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'some áéííúü¿¡ test content {a2 orange dog']); |
| 42 | - $page = $book->pages->last(); | 34 | + $this->asEditor(); |
| 43 | - $chapter = $book->chapters->last(); | ||
| 44 | 35 | ||
| 45 | - $this->asAdmin() | 36 | + $accentSearch = $this->get('/search?term=' . urlencode('áéíí')); |
| 46 | - ->visit('/search/book/' . $book->id . '?term=' . urlencode($page->name)) | 37 | + $accentSearch->assertStatus(200)->assertSee($page->name); |
| 47 | - ->see($page->name) | ||
| 48 | 38 | ||
| 49 | - ->visit('/search/book/' . $book->id . '?term=' . urlencode($chapter->name)) | 39 | + $smallSearch = $this->get('/search?term=' . urlencode('{a')); |
| 50 | - ->see($chapter->name); | 40 | + $smallSearch->assertStatus(200)->assertSee($page->name); |
| 51 | } | 41 | } |
| 52 | 42 | ||
| 53 | - public function test_empty_book_search_redirects_back() | 43 | + public function test_book_search() |
| 54 | { | 44 | { |
| 55 | $book = \BookStack\Book::all()->first(); | 45 | $book = \BookStack\Book::all()->first(); |
| 56 | - $this->asAdmin() | 46 | + $page = $book->pages->last(); |
| 57 | - ->visit('/books') | 47 | + $chapter = $book->chapters->last(); |
| 58 | - ->visit('/search/book/' . $book->id . '?term=') | ||
| 59 | - ->seePageIs('/books'); | ||
| 60 | - } | ||
| 61 | - | ||
| 62 | - | ||
| 63 | - public function test_pages_search_listing() | ||
| 64 | - { | ||
| 65 | - $page = \BookStack\Page::all()->last(); | ||
| 66 | - $this->asAdmin()->visit('/search/pages?term=' . $page->name) | ||
| 67 | - ->see('Page Search Results')->see('.entity-list', $page->name); | ||
| 68 | - } | ||
| 69 | 48 | ||
| 70 | - public function test_chapters_search_listing() | 49 | + $pageTestResp = $this->asEditor()->get('/search/book/' . $book->id . '?term=' . urlencode($page->name)); |
| 71 | - { | 50 | + $pageTestResp->assertSee($page->name); |
| 72 | - $chapter = \BookStack\Chapter::all()->last(); | ||
| 73 | - $this->asAdmin()->visit('/search/chapters?term=' . $chapter->name) | ||
| 74 | - ->see('Chapter Search Results')->seeInElement('.entity-list', $chapter->name); | ||
| 75 | - } | ||
| 76 | 51 | ||
| 77 | - public function test_search_quote_term_preparation() | 52 | + $chapterTestResp = $this->asEditor()->get('/search/book/' . $book->id . '?term=' . urlencode($chapter->name)); |
| 78 | - { | 53 | + $chapterTestResp->assertSee($chapter->name); |
| 79 | - $termString = '"192" cat "dog hat"'; | ||
| 80 | - $repo = $this->app[\BookStack\Repos\EntityRepo::class]; | ||
| 81 | - $preparedTerms = $repo->prepareSearchTerms($termString); | ||
| 82 | - $this->assertTrue($preparedTerms === ['"192"','"dog hat"', 'cat']); | ||
| 83 | } | 54 | } |
| 84 | 55 | ||
| 85 | - public function test_books_search_listing() | 56 | + public function test_chapter_search() |
| 86 | { | 57 | { |
| 87 | - $book = \BookStack\Book::all()->last(); | 58 | + $chapter = \BookStack\Chapter::has('pages')->first(); |
| 88 | - $this->asAdmin()->visit('/search/books?term=' . $book->name) | 59 | + $page = $chapter->pages[0]; |
| 89 | - ->see('Book Search Results')->see('.entity-list', $book->name); | ||
| 90 | - } | ||
| 91 | 60 | ||
| 92 | - public function test_searching_hypen_doesnt_break() | 61 | + $pageTestResp = $this->asEditor()->get('/search/chapter/' . $chapter->id . '?term=' . urlencode($page->name)); |
| 93 | - { | 62 | + $pageTestResp->assertSee($page->name); |
| 94 | - $this->visit('/search/all?term=cat+-') | ||
| 95 | - ->seeStatusCode(200); | ||
| 96 | } | 63 | } |
| 97 | 64 | ||
| 98 | public function test_tag_search() | 65 | public function test_tag_search() |
| ... | @@ -114,27 +81,99 @@ class EntitySearchTest extends BrowserKitTest | ... | @@ -114,27 +81,99 @@ class EntitySearchTest extends BrowserKitTest |
| 114 | $pageB = \BookStack\Page::all()->last(); | 81 | $pageB = \BookStack\Page::all()->last(); |
| 115 | $pageB->tags()->create(['name' => 'animal', 'value' => 'dog']); | 82 | $pageB->tags()->create(['name' => 'animal', 'value' => 'dog']); |
| 116 | 83 | ||
| 117 | - $this->asAdmin()->visit('/search/all?term=%5Banimal%5D') | 84 | + $this->asEditor(); |
| 118 | - ->seeLink($pageA->name) | 85 | + $tNameSearch = $this->get('/search?term=%5Banimal%5D'); |
| 119 | - ->seeLink($pageB->name); | 86 | + $tNameSearch->assertSee($pageA->name)->assertSee($pageB->name); |
| 120 | 87 | ||
| 121 | - $this->visit('/search/all?term=%5Bcolor%5D') | 88 | + $tNameSearch2 = $this->get('/search?term=%5Bcolor%5D'); |
| 122 | - ->seeLink($pageA->name) | 89 | + $tNameSearch2->assertSee($pageA->name)->assertDontSee($pageB->name); |
| 123 | - ->dontSeeLink($pageB->name); | 90 | + |
| 91 | + $tNameValSearch = $this->get('/search?term=%5Banimal%3Dcat%5D'); | ||
| 92 | + $tNameValSearch->assertSee($pageA->name)->assertDontSee($pageB->name); | ||
| 93 | + } | ||
| 94 | + | ||
| 95 | + public function test_exact_searches() | ||
| 96 | + { | ||
| 97 | + $page = $this->newPage(['name' => 'My new test page', 'html' => 'this is a story about an orange donkey']); | ||
| 124 | 98 | ||
| 125 | - $this->visit('/search/all?term=%5Banimal%3Dcat%5D') | 99 | + $exactSearchA = $this->asEditor()->get('/search?term=' . urlencode('"story about an orange"')); |
| 126 | - ->seeLink($pageA->name) | 100 | + $exactSearchA->assertStatus(200)->assertSee($page->name); |
| 127 | - ->dontSeeLink($pageB->name); | ||
| 128 | 101 | ||
| 102 | + $exactSearchB = $this->asEditor()->get('/search?term=' . urlencode('"story not about an orange"')); | ||
| 103 | + $exactSearchB->assertStatus(200)->assertDontSee($page->name); | ||
| 104 | + } | ||
| 105 | + | ||
| 106 | + public function test_search_filters() | ||
| 107 | + { | ||
| 108 | + $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'this is about an orange donkey danzorbhsing']); | ||
| 109 | + $this->asEditor(); | ||
| 110 | + $editorId = $this->getEditor()->id; | ||
| 111 | + | ||
| 112 | + // Viewed filter searches | ||
| 113 | + $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertSee($page->name); | ||
| 114 | + $this->get('/search?term=' . urlencode('danzorbhsing {viewed_by_me}'))->assertDontSee($page->name); | ||
| 115 | + $this->get($page->getUrl()); | ||
| 116 | + $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertDontSee($page->name); | ||
| 117 | + $this->get('/search?term=' . urlencode('danzorbhsing {viewed_by_me}'))->assertSee($page->name); | ||
| 118 | + | ||
| 119 | + // User filters | ||
| 120 | + $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertDontSee($page->name); | ||
| 121 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name); | ||
| 122 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorId.'}'))->assertDontSee($page->name); | ||
| 123 | + $page->created_by = $editorId; | ||
| 124 | + $page->save(); | ||
| 125 | + $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertSee($page->name); | ||
| 126 | + $this->get('/search?term=' . urlencode('danzorbhsing {created_by:'.$editorId.'}'))->assertSee($page->name); | ||
| 127 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name); | ||
| 128 | + $page->updated_by = $editorId; | ||
| 129 | + $page->save(); | ||
| 130 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertSee($page->name); | ||
| 131 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorId.'}'))->assertSee($page->name); | ||
| 132 | + | ||
| 133 | + // Content filters | ||
| 134 | + $this->get('/search?term=' . urlencode('{in_name:danzorbhsing}'))->assertDontSee($page->name); | ||
| 135 | + $this->get('/search?term=' . urlencode('{in_body:danzorbhsing}'))->assertSee($page->name); | ||
| 136 | + $this->get('/search?term=' . urlencode('{in_name:test quaffleachits}'))->assertSee($page->name); | ||
| 137 | + $this->get('/search?term=' . urlencode('{in_body:test quaffleachits}'))->assertDontSee($page->name); | ||
| 138 | + | ||
| 139 | + // Restricted filter | ||
| 140 | + $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertDontSee($page->name); | ||
| 141 | + $page->restricted = true; | ||
| 142 | + $page->save(); | ||
| 143 | + $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertSee($page->name); | ||
| 144 | + | ||
| 145 | + // Date filters | ||
| 146 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_after:2037-01-01}'))->assertDontSee($page->name); | ||
| 147 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_before:2037-01-01}'))->assertSee($page->name); | ||
| 148 | + $page->updated_at = '2037-02-01'; | ||
| 149 | + $page->save(); | ||
| 150 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_after:2037-01-01}'))->assertSee($page->name); | ||
| 151 | + $this->get('/search?term=' . urlencode('danzorbhsing {updated_before:2037-01-01}'))->assertDontSee($page->name); | ||
| 152 | + | ||
| 153 | + $this->get('/search?term=' . urlencode('danzorbhsing {created_after:2037-01-01}'))->assertDontSee($page->name); | ||
| 154 | + $this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertSee($page->name); | ||
| 155 | + $page->created_at = '2037-02-01'; | ||
| 156 | + $page->save(); | ||
| 157 | + $this->get('/search?term=' . urlencode('danzorbhsing {created_after:2037-01-01}'))->assertSee($page->name); | ||
| 158 | + $this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertDontSee($page->name); | ||
| 129 | } | 159 | } |
| 130 | 160 | ||
| 131 | public function test_ajax_entity_search() | 161 | public function test_ajax_entity_search() |
| 132 | { | 162 | { |
| 133 | $page = \BookStack\Page::all()->last(); | 163 | $page = \BookStack\Page::all()->last(); |
| 134 | $notVisitedPage = \BookStack\Page::first(); | 164 | $notVisitedPage = \BookStack\Page::first(); |
| 135 | - $this->visit($page->getUrl()); | 165 | + |
| 136 | - $this->asAdmin()->visit('/ajax/search/entities?term=' . $page->name)->see('.entity-list', $page->name); | 166 | + // Visit the page to make popular |
| 137 | - $this->asAdmin()->visit('/ajax/search/entities?types=book&term=' . $page->name)->dontSee('.entity-list', $page->name); | 167 | + $this->asEditor()->get($page->getUrl()); |
| 138 | - $this->asAdmin()->visit('/ajax/search/entities')->see('.entity-list', $page->name)->dontSee($notVisitedPage->name); | 168 | + |
| 169 | + $normalSearch = $this->get('/ajax/search/entities?term=' . urlencode($page->name)); | ||
| 170 | + $normalSearch->assertSee($page->name); | ||
| 171 | + | ||
| 172 | + $bookSearch = $this->get('/ajax/search/entities?types=book&term=' . urlencode($page->name)); | ||
| 173 | + $bookSearch->assertDontSee($page->name); | ||
| 174 | + | ||
| 175 | + $defaultListTest = $this->get('/ajax/search/entities'); | ||
| 176 | + $defaultListTest->assertSee($page->name); | ||
| 177 | + $defaultListTest->assertDontSee($notVisitedPage->name); | ||
| 139 | } | 178 | } |
| 140 | } | 179 | } | ... | ... |
| ... | @@ -76,4 +76,16 @@ abstract class TestCase extends BaseTestCase | ... | @@ -76,4 +76,16 @@ abstract class TestCase extends BaseTestCase |
| 76 | public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) { | 76 | public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) { |
| 77 | return $this->app[EntityRepo::class]->createFromInput('chapter', $input, $book); | 77 | return $this->app[EntityRepo::class]->createFromInput('chapter', $input, $book); |
| 78 | } | 78 | } |
| 79 | + | ||
| 80 | + /** | ||
| 81 | + * Create and return a new test page | ||
| 82 | + * @param array $input | ||
| 83 | + * @return Chapter | ||
| 84 | + */ | ||
| 85 | + public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) { | ||
| 86 | + $book = Book::first(); | ||
| 87 | + $entityRepo = $this->app[EntityRepo::class]; | ||
| 88 | + $draftPage = $entityRepo->getDraftPage($book); | ||
| 89 | + return $entityRepo->publishPageDraft($draftPage, $input); | ||
| 90 | + } | ||
| 79 | } | 91 | } |
| ... | \ No newline at end of file | ... | \ No newline at end of file | ... | ... |
-
Please register or sign in to post a comment