Dan Brown
Committed by GitHub

Merge pull request #340 from BookStackApp/search_system

Implementation of new search system 
Showing 46 changed files with 1525 additions and 586 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 }
......
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 = [];
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('/&quot;.*?&quot;/', $term) || is_numeric($term)) {
172 - $term = str_replace('&quot;', '', $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 } 171 }
190 172
191 - // Ensure at least one exact term matches if in search 173 + /**
192 - if (count($exactTerms) > 0) { 174 + * Return a generalised, common raw query that can be 'unioned' across entities.
193 - $search = $search->where(function ($query) use ($exactTerms) { 175 + * @return string
194 - foreach ($exactTerms as $exactTerm) { 176 + */
195 - foreach ($this->fieldsToSearch as $field) { 177 + public function entityRawQuery(){return '';}
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 -
209 - // Load in relations
210 - if ($this->isA('page')) {
211 - $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy');
212 - } else if ($this->isA('chapter')) {
213 - $search = $search->with('book');
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 -
49 - /**
50 - * Search only the pages in the system.
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 38
58 - $searchTerm = $request->get('term'); 39 + $page = $request->has('page') && is_int(intval($request->get('page'))) ? intval($request->get('page')) : 1;
59 - $paginationAppends = $request->only('term'); 40 + $nextPageLink = baseUrl('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
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 41
69 - /** 42 + $results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
70 - * Search only the chapters in the system. 43 + $hasNextPage = $this->searchService->searchEntities($searchTerm, 'all', $page+1, 20)['count'] > 0;
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 -
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 451
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);
......
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
......
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
......
...@@ -44,4 +44,4 @@ class Translator { ...@@ -44,4 +44,4 @@ class Translator {
44 44
45 } 45 }
46 46
47 -export default Translator 47 +module.exports = Translator;
......
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
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
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&uuml;cksetzen', 46 'search_clear' => 'Suche zur&uuml;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&uuml;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&uuml;chern',
58 49
59 /** 50 /**
60 * Books 51 * Books
......
...@@ -33,6 +33,7 @@ return [ ...@@ -33,6 +33,7 @@ return [
33 'search_clear' => 'Clear Search', 33 'search_clear' => 'Clear Search',
34 'reset' => 'Reset', 34 'reset' => 'Reset',
35 'remove' => 'Remove', 35 'remove' => 'Remove',
36 + 'add' => 'Add',
36 37
37 38
38 /** 39 /**
......
...@@ -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,10 +47,11 @@ ...@@ -47,10 +47,11 @@
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 + <div class="chapter-content" v-if="!searching">
54 <p class="text-muted">{{ $chapter->description }}</p> 55 <p class="text-muted">{{ $chapter->description }}</p>
55 56
56 @if(count($pages) > 0) 57 @if(count($pages) > 0)
...@@ -80,7 +81,16 @@ ...@@ -80,7 +81,16 @@
80 81
81 @include('partials.entity-meta', ['entity' => $chapter]) 82 @include('partials.entity-meta', ['entity' => $chapter])
82 </div> 83 </div>
83 - <div class="col-md-3 col-md-offset-1"> 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>
92 + </div>
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
25 + <div class="col-md-6">
20 <h1>{{ trans('entities.search_results') }}</h1> 26 <h1>{{ trans('entities.search_results') }}</h1>
21 - 27 + <h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6>
22 - <p> 28 + @include('partials/entity-list', ['entities' => $entities])
23 - @if(count($pages) > 0) 29 + @if ($hasNextPage)
24 - <a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.search_view_pages') }}</a> 30 + <a href="{{ $nextPageLink }}" class="button">{{ trans('entities.search_more') }}</a>
25 - @endif
26 -
27 - @if(count($chapters) > 0)
28 - &nbsp; &nbsp;&nbsp;
29 - <a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="text-chapter"><i class="zmdi zmdi-collection-bookmark"></i>{{ trans('entities.search_view_chapters') }}</a>
30 @endif 31 @endif
32 + </div>
31 33
32 - @if(count($books) > 0) 34 + <div class="col-md-5 col-md-offset-1">
33 - &nbsp; &nbsp;&nbsp; 35 + <h3>{{ trans('entities.search_filters') }}</h3>
34 - <a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.search_view_books') }}</a>
35 - @endif
36 - </p>
37 36
38 - <div class="row"> 37 + <form v-on:submit="updateSearch" v-cloak class="v-cloak anim fadeIn">
39 - <div class="col-md-6"> 38 + <h6 class="text-muted">{{ trans('entities.search_content_type') }}</h6>
40 - <h3><a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="no-color">{{ trans('entities.pages') }}</a></h3> 39 + <div class="form-group">
41 - @include('partials/entity-list', ['entities' => $pages, 'style' => 'detailed']) 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>
42 </div> 43 </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 44
49 - @if(count($chapters) > 0) 45 + <h6 class="text-muted">{{ trans('entities.search_exact_matches') }}</h6>
50 - <h3><a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="no-color">{{ trans('entities.chapters') }}</a></h3> 46 + <table cellpadding="0" cellspacing="0" border="0" class="no-style">
51 - @include('partials/entity-list', ['entities' => $chapters]) 47 + <tr v-for="(term, i) in search.exactTerms">
52 - @endif 48 + <td style="padding: 0 12px 6px 0;">
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>
203 +
204 +
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
......