diff --git a/app/Book.php b/app/Book.php index 91f74ca64..06c00945d 100644 --- a/app/Book.php +++ b/app/Book.php @@ -56,4 +56,13 @@ class Book extends Entity return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description; } + /** + * Return a generalised, common raw query that can be 'unioned' across entities. + * @return string + */ + public function entityRawQuery() + { + 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"; + } + } diff --git a/app/Chapter.php b/app/Chapter.php index dc23f5ebd..b08cb913a 100644 --- a/app/Chapter.php +++ b/app/Chapter.php @@ -51,4 +51,13 @@ class Chapter extends Entity return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description; } + /** + * Return a generalised, common raw query that can be 'unioned' across entities. + * @return string + */ + public function entityRawQuery() + { + 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"; + } + } diff --git a/app/Console/Commands/RegeneratePermissions.php b/app/Console/Commands/RegeneratePermissions.php index 966ee4a82..1dc25f9aa 100644 --- a/app/Console/Commands/RegeneratePermissions.php +++ b/app/Console/Commands/RegeneratePermissions.php @@ -12,7 +12,7 @@ class RegeneratePermissions extends Command * * @var string */ - protected $signature = 'bookstack:regenerate-permissions'; + protected $signature = 'bookstack:regenerate-permissions {--database= : The database connection to use.}'; /** * The console command description. @@ -46,7 +46,14 @@ class RegeneratePermissions extends Command */ public function handle() { + $connection = \DB::getDefaultConnection(); + if ($this->option('database') !== null) { + \DB::setDefaultConnection($this->option('database')); + } + $this->permissionService->buildJointPermissions(); + + \DB::setDefaultConnection($connection); $this->comment('Permissions regenerated'); } } diff --git a/app/Console/Commands/RegenerateSearch.php b/app/Console/Commands/RegenerateSearch.php new file mode 100644 index 000000000..35ecd46c0 --- /dev/null +++ b/app/Console/Commands/RegenerateSearch.php @@ -0,0 +1,53 @@ +searchService = $searchService; + } + + /** + * Execute the console command. + * + * @return mixed + */ + public function handle() + { + $connection = \DB::getDefaultConnection(); + if ($this->option('database') !== null) { + \DB::setDefaultConnection($this->option('database')); + } + + $this->searchService->indexAllEntities(); + \DB::setDefaultConnection($connection); + $this->comment('Search index regenerated'); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 0112e72ca..4fa0b3c80 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -1,6 +1,4 @@ -morphMany(Tag::class, 'entity')->orderBy('order', 'asc'); } + /** + * Get the related search terms. + * @return \Illuminate\Database\Eloquent\Relations\MorphMany + */ + public function searchTerms() + { + return $this->morphMany(SearchTerm::class, 'entity'); + } + /** * Get this entities restrictions. */ @@ -153,67 +162,19 @@ class Entity extends Ownable } /** - * Perform a full-text search on this entity. - * @param string[] $fieldsToSearch - * @param string[] $terms - * @param string[] array $wheres + * Get the body text of this entity. * @return mixed */ - public function fullTextSearchQuery($terms, $wheres = []) + public function getText() { - $exactTerms = []; - $fuzzyTerms = []; - $search = static::newQuery(); - - foreach ($terms as $key => $term) { - $term = htmlentities($term, ENT_QUOTES); - $term = preg_replace('/[+\-><\(\)~*\"@]+/', ' ', $term); - if (preg_match('/".*?"/', $term) || is_numeric($term)) { - $term = str_replace('"', '', $term); - $exactTerms[] = '%' . $term . '%'; - } else { - $term = '' . $term . '*'; - if ($term !== '*') $fuzzyTerms[] = $term; - } - } - - $isFuzzy = count($exactTerms) === 0 && count($fuzzyTerms) > 0; - - - // Perform fulltext search if relevant terms exist. - if ($isFuzzy) { - $termString = implode(' ', $fuzzyTerms); - $fields = implode(',', $this->fieldsToSearch); - $search = $search->selectRaw('*, MATCH(name) AGAINST(? IN BOOLEAN MODE) AS title_relevance', [$termString]); - $search = $search->whereRaw('MATCH(' . $fields . ') AGAINST(? IN BOOLEAN MODE)', [$termString]); - } - - // Ensure at least one exact term matches if in search - if (count($exactTerms) > 0) { - $search = $search->where(function ($query) use ($exactTerms) { - foreach ($exactTerms as $exactTerm) { - foreach ($this->fieldsToSearch as $field) { - $query->orWhere($field, 'like', $exactTerm); - } - } - }); - } - - $orderBy = $isFuzzy ? 'title_relevance' : 'updated_at'; - - // Add additional where terms - foreach ($wheres as $whereTerm) { - $search->where($whereTerm[0], $whereTerm[1], $whereTerm[2]); - } - - // Load in relations - if ($this->isA('page')) { - $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy'); - } else if ($this->isA('chapter')) { - $search = $search->with('book'); - } - - return $search->orderBy($orderBy, 'desc'); + return $this->{$this->textField}; } + /** + * Return a generalised, common raw query that can be 'unioned' across entities. + * @return string + */ + public function entityRawQuery(){return '';} + + } diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 37aaccece..bf8165afe 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -1,6 +1,7 @@ entityRepo = $entityRepo; $this->viewService = $viewService; + $this->searchService = $searchService; parent::__construct(); } @@ -27,84 +31,26 @@ class SearchController extends Controller * @return \Illuminate\View\View * @internal param string $searchTerm */ - public function searchAll(Request $request) + public function search(Request $request) { - if (!$request->has('term')) { - return redirect()->back(); - } $searchTerm = $request->get('term'); - $paginationAppends = $request->only('term'); - $pages = $this->entityRepo->getBySearch('page', $searchTerm, [], 20, $paginationAppends); - $books = $this->entityRepo->getBySearch('book', $searchTerm, [], 10, $paginationAppends); - $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, [], 10, $paginationAppends); $this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm])); + + $page = $request->has('page') && is_int(intval($request->get('page'))) ? intval($request->get('page')) : 1; + $nextPageLink = baseUrl('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1)); + + $results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20); + $hasNextPage = $this->searchService->searchEntities($searchTerm, 'all', $page+1, 20)['count'] > 0; + return view('search/all', [ - 'pages' => $pages, - 'books' => $books, - 'chapters' => $chapters, - 'searchTerm' => $searchTerm + 'entities' => $results['results'], + 'totalResults' => $results['total'], + 'searchTerm' => $searchTerm, + 'hasNextPage' => $hasNextPage, + 'nextPageLink' => $nextPageLink ]); } - /** - * Search only the pages in the system. - * @param Request $request - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View - */ - public function searchPages(Request $request) - { - if (!$request->has('term')) return redirect()->back(); - - $searchTerm = $request->get('term'); - $paginationAppends = $request->only('term'); - $pages = $this->entityRepo->getBySearch('page', $searchTerm, [], 20, $paginationAppends); - $this->setPageTitle(trans('entities.search_page_for_term', ['term' => $searchTerm])); - return view('search/entity-search-list', [ - 'entities' => $pages, - 'title' => trans('entities.search_results_page'), - 'searchTerm' => $searchTerm - ]); - } - - /** - * Search only the chapters in the system. - * @param Request $request - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View - */ - public function searchChapters(Request $request) - { - if (!$request->has('term')) return redirect()->back(); - - $searchTerm = $request->get('term'); - $paginationAppends = $request->only('term'); - $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, [], 20, $paginationAppends); - $this->setPageTitle(trans('entities.search_chapter_for_term', ['term' => $searchTerm])); - return view('search/entity-search-list', [ - 'entities' => $chapters, - 'title' => trans('entities.search_results_chapter'), - 'searchTerm' => $searchTerm - ]); - } - - /** - * Search only the books in the system. - * @param Request $request - * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View - */ - public function searchBooks(Request $request) - { - if (!$request->has('term')) return redirect()->back(); - - $searchTerm = $request->get('term'); - $paginationAppends = $request->only('term'); - $books = $this->entityRepo->getBySearch('book', $searchTerm, [], 20, $paginationAppends); - $this->setPageTitle(trans('entities.search_book_for_term', ['term' => $searchTerm])); - return view('search/entity-search-list', [ - 'entities' => $books, - 'title' => trans('entities.search_results_book'), - 'searchTerm' => $searchTerm - ]); - } /** * Searches all entities within a book. @@ -115,16 +61,24 @@ class SearchController extends Controller */ public function searchBook(Request $request, $bookId) { - if (!$request->has('term')) { - return redirect()->back(); - } - $searchTerm = $request->get('term'); - $searchWhereTerms = [['book_id', '=', $bookId]]; - $pages = $this->entityRepo->getBySearch('page', $searchTerm, $searchWhereTerms); - $chapters = $this->entityRepo->getBySearch('chapter', $searchTerm, $searchWhereTerms); - return view('search/book', ['pages' => $pages, 'chapters' => $chapters, 'searchTerm' => $searchTerm]); + $term = $request->get('term', ''); + $results = $this->searchService->searchBook($bookId, $term); + return view('partials/entity-list', ['entities' => $results]); } + /** + * Searches all entities within a chapter. + * @param Request $request + * @param integer $chapterId + * @return \Illuminate\View\View + * @internal param string $searchTerm + */ + public function searchChapter(Request $request, $chapterId) + { + $term = $request->get('term', ''); + $results = $this->searchService->searchChapter($chapterId, $term); + return view('partials/entity-list', ['entities' => $results]); + } /** * Search for a list of entities and return a partial HTML response of matching entities. @@ -134,18 +88,13 @@ class SearchController extends Controller */ public function searchEntitiesAjax(Request $request) { - $entities = collect(); $entityTypes = $request->has('types') ? collect(explode(',', $request->get('types'))) : collect(['page', 'chapter', 'book']); $searchTerm = ($request->has('term') && trim($request->get('term')) !== '') ? $request->get('term') : false; // Search for entities otherwise show most popular if ($searchTerm !== false) { - foreach (['page', 'chapter', 'book'] as $entityType) { - if ($entityTypes->contains($entityType)) { - $entities = $entities->merge($this->entityRepo->getBySearch($entityType, $searchTerm)->items()); - } - } - $entities = $entities->sortByDesc('title_relevance'); + $searchTerm .= ' {type:'. implode('|', $entityTypes->toArray()) .'}'; + $entities = $this->searchService->searchEntities($searchTerm)['results']; } else { $entityNames = $entityTypes->map(function ($type) { return 'BookStack\\' . ucfirst($type); diff --git a/app/Page.php b/app/Page.php index b24e7778a..c9823e7e4 100644 --- a/app/Page.php +++ b/app/Page.php @@ -8,8 +8,7 @@ class Page extends Entity protected $simpleAttributes = ['name', 'id', 'slug']; protected $with = ['book']; - - protected $fieldsToSearch = ['name', 'text']; + public $textField = 'text'; /** * Converts this page into a simplified array. @@ -96,4 +95,14 @@ class Page extends Entity return mb_convert_encoding($text, 'UTF-8'); } + /** + * Return a generalised, common raw query that can be 'unioned' across entities. + * @param bool $withContent + * @return string + */ + public function entityRawQuery($withContent = false) + { $htmlQuery = $withContent ? 'html' : "'' as html"; + 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"; + } + } diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php index 8f4b533ff..449e3aa7d 100644 --- a/app/Repos/EntityRepo.php +++ b/app/Repos/EntityRepo.php @@ -8,6 +8,7 @@ use BookStack\Page; use BookStack\PageRevision; use BookStack\Services\AttachmentService; use BookStack\Services\PermissionService; +use BookStack\Services\SearchService; use BookStack\Services\ViewService; use Carbon\Carbon; use DOMDocument; @@ -59,13 +60,12 @@ class EntityRepo protected $tagRepo; /** - * Acceptable operators to be used in a query - * @var array + * @var SearchService */ - protected $queryOperators = ['<=', '>=', '=', '<', '>', 'like', '!=']; + protected $searchService; /** - * EntityService constructor. + * EntityRepo constructor. * @param Book $book * @param Chapter $chapter * @param Page $page @@ -73,10 +73,12 @@ class EntityRepo * @param ViewService $viewService * @param PermissionService $permissionService * @param TagRepo $tagRepo + * @param SearchService $searchService */ public function __construct( Book $book, Chapter $chapter, Page $page, PageRevision $pageRevision, - ViewService $viewService, PermissionService $permissionService, TagRepo $tagRepo + ViewService $viewService, PermissionService $permissionService, + TagRepo $tagRepo, SearchService $searchService ) { $this->book = $book; @@ -91,6 +93,7 @@ class EntityRepo $this->viewService = $viewService; $this->permissionService = $permissionService; $this->tagRepo = $tagRepo; + $this->searchService = $searchService; } /** @@ -216,6 +219,7 @@ class EntityRepo * @param int $count * @param int $page * @param bool|callable $additionalQuery + * @return Collection */ public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false) { @@ -234,6 +238,7 @@ class EntityRepo * @param int $count * @param int $page * @param bool|callable $additionalQuery + * @return Collection */ public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false) { @@ -327,7 +332,7 @@ class EntityRepo if ($rawEntity->entity_type === 'BookStack\\Page') { $entities[$index] = $this->page->newFromBuilder($rawEntity); if ($renderPages) { - $entities[$index]->html = $rawEntity->description; + $entities[$index]->html = $rawEntity->html; $entities[$index]->html = $this->renderPage($entities[$index]); }; } else if ($rawEntity->entity_type === 'BookStack\\Chapter') { @@ -354,6 +359,7 @@ class EntityRepo * Get the child items for a chapter sorted by priority but * with draft items floated to the top. * @param Chapter $chapter + * @return \Illuminate\Database\Eloquent\Collection|static[] */ public function getChapterChildren(Chapter $chapter) { @@ -361,56 +367,6 @@ class EntityRepo ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get(); } - /** - * Search entities of a type via a given query. - * @param string $type - * @param string $term - * @param array $whereTerms - * @param int $count - * @param array $paginationAppends - * @return mixed - */ - public function getBySearch($type, $term, $whereTerms = [], $count = 20, $paginationAppends = []) - { - $terms = $this->prepareSearchTerms($term); - $q = $this->permissionService->enforceEntityRestrictions($type, $this->getEntity($type)->fullTextSearchQuery($terms, $whereTerms)); - $q = $this->addAdvancedSearchQueries($q, $term); - $entities = $q->paginate($count)->appends($paginationAppends); - $words = join('|', explode(' ', preg_quote(trim($term), '/'))); - - // Highlight page content - if ($type === 'page') { - //lookahead/behind assertions ensures cut between words - $s = '\s\x00-/:-@\[-`{-~'; //character set for start/end of words - - foreach ($entities as $page) { - preg_match_all('#(?<=[' . $s . ']).{1,30}((' . $words . ').{1,30})+(?=[' . $s . '])#uis', $page->text, $matches, PREG_SET_ORDER); - //delimiter between occurrences - $results = []; - foreach ($matches as $line) { - $results[] = htmlspecialchars($line[0], 0, 'UTF-8'); - } - $matchLimit = 6; - if (count($results) > $matchLimit) $results = array_slice($results, 0, $matchLimit); - $result = join('... ', $results); - - //highlight - $result = preg_replace('#' . $words . '#iu', "\$0", $result); - if (strlen($result) < 5) $result = $page->getExcerpt(80); - - $page->searchSnippet = $result; - } - return $entities; - } - - // Highlight chapter/book content - foreach ($entities as $entity) { - //highlight - $result = preg_replace('#' . $words . '#iu', "\$0", $entity->getExcerpt(100)); - $entity->searchSnippet = $result; - } - return $entities; - } /** * Get the next sequential priority for a new child element in the given book. @@ -492,104 +448,7 @@ class EntityRepo $this->permissionService->buildJointPermissionsForEntity($entity); } - /** - * Prepare a string of search terms by turning - * it into an array of terms. - * Keeps quoted terms together. - * @param $termString - * @return array - */ - public function prepareSearchTerms($termString) - { - $termString = $this->cleanSearchTermString($termString); - preg_match_all('/(".*?")/', $termString, $matches); - $terms = []; - if (count($matches[1]) > 0) { - foreach ($matches[1] as $match) { - $terms[] = $match; - } - $termString = trim(preg_replace('/"(.*?)"/', '', $termString)); - } - if (!empty($termString)) $terms = array_merge($terms, explode(' ', $termString)); - return $terms; - } - /** - * Removes any special search notation that should not - * be used in a full-text search. - * @param $termString - * @return mixed - */ - protected function cleanSearchTermString($termString) - { - // Strip tag searches - $termString = preg_replace('/\[.*?\]/', '', $termString); - // Reduced multiple spacing into single spacing - $termString = preg_replace("/\s{2,}/", " ", $termString); - return $termString; - } - - /** - * Get the available query operators as a regex escaped list. - * @return mixed - */ - protected function getRegexEscapedOperators() - { - $escapedOperators = []; - foreach ($this->queryOperators as $operator) { - $escapedOperators[] = preg_quote($operator); - } - return join('|', $escapedOperators); - } - - /** - * Parses advanced search notations and adds them to the db query. - * @param $query - * @param $termString - * @return mixed - */ - protected function addAdvancedSearchQueries($query, $termString) - { - $escapedOperators = $this->getRegexEscapedOperators(); - // Look for tag searches - preg_match_all("/\[(.*?)((${escapedOperators})(.*?))?\]/", $termString, $tags); - if (count($tags[0]) > 0) { - $this->applyTagSearches($query, $tags); - } - - return $query; - } - - /** - * Apply extracted tag search terms onto a entity query. - * @param $query - * @param $tags - * @return mixed - */ - protected function applyTagSearches($query, $tags) { - $query->where(function($query) use ($tags) { - foreach ($tags[1] as $index => $tagName) { - $query->whereHas('tags', function($query) use ($tags, $index, $tagName) { - $tagOperator = $tags[3][$index]; - $tagValue = $tags[4][$index]; - if (!empty($tagOperator) && !empty($tagValue) && in_array($tagOperator, $this->queryOperators)) { - if (is_numeric($tagValue) && $tagOperator !== 'like') { - // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will - // search the value as a string which prevents being able to do number-based operations - // on the tag values. We ensure it has a numeric value and then cast it just to be sure. - $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'"); - $query->where('name', '=', $tagName)->whereRaw("value ${tagOperator} ${tagValue}"); - } else { - $query->where('name', '=', $tagName)->where('value', $tagOperator, $tagValue); - } - } else { - $query->where('name', '=', $tagName); - } - }); - } - }); - return $query; - } /** * Create a new entity from request input. @@ -608,12 +467,13 @@ class EntityRepo $entity->updated_by = user()->id; $isChapter ? $book->chapters()->save($entity) : $entity->save(); $this->permissionService->buildJointPermissionsForEntity($entity); + $this->searchService->indexEntity($entity); return $entity; } /** * Update entity details from request input. - * Use for books and chapters + * Used for books and chapters * @param string $type * @param Entity $entityModel * @param array $input @@ -628,6 +488,7 @@ class EntityRepo $entityModel->updated_by = user()->id; $entityModel->save(); $this->permissionService->buildJointPermissionsForEntity($entityModel); + $this->searchService->indexEntity($entityModel); return $entityModel; } @@ -711,7 +572,7 @@ class EntityRepo $draftPage->save(); $this->savePageRevision($draftPage, trans('entities.pages_initial_revision')); - + $this->searchService->indexEntity($draftPage); return $draftPage; } @@ -961,6 +822,8 @@ class EntityRepo $this->savePageRevision($page, $input['summary']); } + $this->searchService->indexEntity($page); + return $page; } @@ -1064,6 +927,7 @@ class EntityRepo $page->text = strip_tags($page->html); $page->updated_by = user()->id; $page->save(); + $this->searchService->indexEntity($page); return $page; } @@ -1156,6 +1020,7 @@ class EntityRepo $book->views()->delete(); $book->permissions()->delete(); $this->permissionService->deleteJointPermissionsForEntity($book); + $this->searchService->deleteEntityTerms($book); $book->delete(); } @@ -1175,6 +1040,7 @@ class EntityRepo $chapter->views()->delete(); $chapter->permissions()->delete(); $this->permissionService->deleteJointPermissionsForEntity($chapter); + $this->searchService->deleteEntityTerms($chapter); $chapter->delete(); } @@ -1190,6 +1056,7 @@ class EntityRepo $page->revisions()->delete(); $page->permissions()->delete(); $this->permissionService->deleteJointPermissionsForEntity($page); + $this->searchService->deleteEntityTerms($page); // Delete Attached Files $attachmentService = app(AttachmentService::class); diff --git a/app/SearchTerm.php b/app/SearchTerm.php new file mode 100644 index 000000000..50df34021 --- /dev/null +++ b/app/SearchTerm.php @@ -0,0 +1,18 @@ +morphTo('entity'); + } + +} diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 8b47e1246..1e75308a0 100644 --- a/app/Services/PermissionService.php +++ b/app/Services/PermissionService.php @@ -479,8 +479,7 @@ class PermissionService * @return \Illuminate\Database\Query\Builder */ public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false) { - $pageContentSelect = $fetchPageContent ? 'html' : "''"; - $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) { + $pageSelect = $this->db->table('pages')->selectRaw($this->page->entityRawQuery($fetchPageContent))->where('book_id', '=', $book_id)->where(function($query) use ($filterDrafts) { $query->where('draft', '=', 0); if (!$filterDrafts) { $query->orWhere(function($query) { @@ -488,7 +487,7 @@ class PermissionService }); } }); - $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); + $chapterSelect = $this->db->table('chapters')->selectRaw($this->chapter->entityRawQuery())->where('book_id', '=', $book_id); $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U")) ->mergeBindings($pageSelect)->mergeBindings($chapterSelect); @@ -514,7 +513,7 @@ class PermissionService * @param string $entityType * @param Builder|Entity $query * @param string $action - * @return mixed + * @return Builder */ public function enforceEntityRestrictions($entityType, $query, $action = 'view') { @@ -540,7 +539,7 @@ class PermissionService } /** - * Filter items that have entities set a a polymorphic relation. + * Filter items that have entities set as a polymorphic relation. * @param $query * @param string $tableName * @param string $entityIdColumn diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 000000000..a3186e8f4 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,472 @@ +=', '=', '<', '>', 'like', '!=']; + + /** + * SearchService constructor. + * @param SearchTerm $searchTerm + * @param Book $book + * @param Chapter $chapter + * @param Page $page + * @param Connection $db + * @param PermissionService $permissionService + */ + public function __construct(SearchTerm $searchTerm, Book $book, Chapter $chapter, Page $page, Connection $db, PermissionService $permissionService) + { + $this->searchTerm = $searchTerm; + $this->book = $book; + $this->chapter = $chapter; + $this->page = $page; + $this->db = $db; + $this->entities = [ + 'page' => $this->page, + 'chapter' => $this->chapter, + 'book' => $this->book + ]; + $this->permissionService = $permissionService; + } + + /** + * Search all entities in the system. + * @param string $searchString + * @param string $entityType + * @param int $page + * @param int $count + * @return array[int, Collection]; + */ + public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20) + { + $terms = $this->parseSearchString($searchString); + $entityTypes = array_keys($this->entities); + $entityTypesToSearch = $entityTypes; + $results = collect(); + + if ($entityType !== 'all') { + $entityTypesToSearch = $entityType; + } else if (isset($terms['filters']['type'])) { + $entityTypesToSearch = explode('|', $terms['filters']['type']); + } + + $total = 0; + + foreach ($entityTypesToSearch as $entityType) { + if (!in_array($entityType, $entityTypes)) continue; + $search = $this->searchEntityTable($terms, $entityType, $page, $count); + $total += $this->searchEntityTable($terms, $entityType, $page, $count, true); + $results = $results->merge($search); + } + + return [ + 'total' => $total, + 'count' => count($results), + 'results' => $results->sortByDesc('score') + ]; + } + + + /** + * Search a book for entities + * @param integer $bookId + * @param string $searchString + * @return Collection + */ + public function searchBook($bookId, $searchString) + { + $terms = $this->parseSearchString($searchString); + $entityTypes = ['page', 'chapter']; + $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes; + + $results = collect(); + foreach ($entityTypesToSearch as $entityType) { + if (!in_array($entityType, $entityTypes)) continue; + $search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get(); + $results = $results->merge($search); + } + return $results->sortByDesc('score')->take(20); + } + + /** + * Search a book for entities + * @param integer $chapterId + * @param string $searchString + * @return Collection + */ + public function searchChapter($chapterId, $searchString) + { + $terms = $this->parseSearchString($searchString); + $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get(); + return $pages->sortByDesc('score'); + } + + /** + * Search across a particular entity type. + * @param array $terms + * @param string $entityType + * @param int $page + * @param int $count + * @param bool $getCount Return the total count of the search + * @return \Illuminate\Database\Eloquent\Collection|int|static[] + */ + public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $getCount = false) + { + $query = $this->buildEntitySearchQuery($terms, $entityType); + if ($getCount) return $query->count(); + + $query = $query->skip(($page-1) * $count)->take($count); + return $query->get(); + } + + /** + * Create a search query for an entity + * @param array $terms + * @param string $entityType + * @return \Illuminate\Database\Eloquent\Builder + */ + protected function buildEntitySearchQuery($terms, $entityType = 'page') + { + $entity = $this->getEntity($entityType); + $entitySelect = $entity->newQuery(); + + // Handle normal search terms + if (count($terms['search']) > 0) { + $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score')); + $subQuery->where(function(Builder $query) use ($terms) { + foreach ($terms['search'] as $inputTerm) { + $query->orWhere('term', 'like', $inputTerm .'%'); + } + })->groupBy('entity_type', 'entity_id'); + $entitySelect->join(\DB::raw('(' . $subQuery->toSql() . ') as s'), function(JoinClause $join) { + $join->on('id', '=', 'entity_id'); + })->selectRaw($entity->getTable().'.*, s.score')->orderBy('score', 'desc'); + $entitySelect->mergeBindings($subQuery); + } + + // Handle exact term matching + if (count($terms['exact']) > 0) { + $entitySelect->where(function(\Illuminate\Database\Eloquent\Builder $query) use ($terms, $entity) { + foreach ($terms['exact'] as $inputTerm) { + $query->where(function (\Illuminate\Database\Eloquent\Builder $query) use ($inputTerm, $entity) { + $query->where('name', 'like', '%'.$inputTerm .'%') + ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%'); + }); + } + }); + } + + // Handle tag searches + foreach ($terms['tags'] as $inputTerm) { + $this->applyTagSearch($entitySelect, $inputTerm); + } + + // Handle filters + foreach ($terms['filters'] as $filterTerm => $filterValue) { + $functionName = camel_case('filter_' . $filterTerm); + if (method_exists($this, $functionName)) $this->$functionName($entitySelect, $entity, $filterValue); + } + + return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, 'view'); + } + + + /** + * Parse a search string into components. + * @param $searchString + * @return array + */ + protected function parseSearchString($searchString) + { + $terms = [ + 'search' => [], + 'exact' => [], + 'tags' => [], + 'filters' => [] + ]; + + $patterns = [ + 'exact' => '/"(.*?)"/', + 'tags' => '/\[(.*?)\]/', + 'filters' => '/\{(.*?)\}/' + ]; + + // Parse special terms + foreach ($patterns as $termType => $pattern) { + $matches = []; + preg_match_all($pattern, $searchString, $matches); + if (count($matches) > 0) { + $terms[$termType] = $matches[1]; + $searchString = preg_replace($pattern, '', $searchString); + } + } + + // Parse standard terms + foreach (explode(' ', trim($searchString)) as $searchTerm) { + if ($searchTerm !== '') $terms['search'][] = $searchTerm; + } + + // Split filter values out + $splitFilters = []; + foreach ($terms['filters'] as $filter) { + $explodedFilter = explode(':', $filter, 2); + $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : ''; + } + $terms['filters'] = $splitFilters; + + return $terms; + } + + /** + * Get the available query operators as a regex escaped list. + * @return mixed + */ + protected function getRegexEscapedOperators() + { + $escapedOperators = []; + foreach ($this->queryOperators as $operator) { + $escapedOperators[] = preg_quote($operator); + } + return join('|', $escapedOperators); + } + + /** + * Apply a tag search term onto a entity query. + * @param \Illuminate\Database\Eloquent\Builder $query + * @param string $tagTerm + * @return mixed + */ + protected function applyTagSearch(\Illuminate\Database\Eloquent\Builder $query, $tagTerm) { + preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit); + $query->whereHas('tags', function(\Illuminate\Database\Eloquent\Builder $query) use ($tagSplit) { + $tagName = $tagSplit[1]; + $tagOperator = count($tagSplit) > 2 ? $tagSplit[3] : ''; + $tagValue = count($tagSplit) > 3 ? $tagSplit[4] : ''; + $validOperator = in_array($tagOperator, $this->queryOperators); + if (!empty($tagOperator) && !empty($tagValue) && $validOperator) { + if (!empty($tagName)) $query->where('name', '=', $tagName); + if (is_numeric($tagValue) && $tagOperator !== 'like') { + // We have to do a raw sql query for this since otherwise PDO will quote the value and MySQL will + // search the value as a string which prevents being able to do number-based operations + // on the tag values. We ensure it has a numeric value and then cast it just to be sure. + $tagValue = (float) trim($query->getConnection()->getPdo()->quote($tagValue), "'"); + $query->whereRaw("value ${tagOperator} ${tagValue}"); + } else { + $query->where('value', $tagOperator, $tagValue); + } + } else { + $query->where('name', '=', $tagName); + } + }); + return $query; + } + + /** + * Get an entity instance via type. + * @param $type + * @return Entity + */ + protected function getEntity($type) + { + return $this->entities[strtolower($type)]; + } + + /** + * Index the given entity. + * @param Entity $entity + */ + public function indexEntity(Entity $entity) + { + $this->deleteEntityTerms($entity); + $nameTerms = $this->generateTermArrayFromText($entity->name, 5); + $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1); + $terms = array_merge($nameTerms, $bodyTerms); + foreach ($terms as $index => $term) { + $terms[$index]['entity_type'] = $entity->getMorphClass(); + $terms[$index]['entity_id'] = $entity->id; + } + $this->searchTerm->newQuery()->insert($terms); + } + + /** + * Index multiple Entities at once + * @param Entity[] $entities + */ + protected function indexEntities($entities) { + $terms = []; + foreach ($entities as $entity) { + $nameTerms = $this->generateTermArrayFromText($entity->name, 5); + $bodyTerms = $this->generateTermArrayFromText($entity->getText(), 1); + foreach (array_merge($nameTerms, $bodyTerms) as $term) { + $term['entity_id'] = $entity->id; + $term['entity_type'] = $entity->getMorphClass(); + $terms[] = $term; + } + } + + $chunkedTerms = array_chunk($terms, 500); + foreach ($chunkedTerms as $termChunk) { + $this->searchTerm->newQuery()->insert($termChunk); + } + } + + /** + * Delete and re-index the terms for all entities in the system. + */ + public function indexAllEntities() + { + $this->searchTerm->truncate(); + + // Chunk through all books + $this->book->chunk(1000, function ($books) { + $this->indexEntities($books); + }); + + // Chunk through all chapters + $this->chapter->chunk(1000, function ($chapters) { + $this->indexEntities($chapters); + }); + + // Chunk through all pages + $this->page->chunk(1000, function ($pages) { + $this->indexEntities($pages); + }); + } + + /** + * Delete related Entity search terms. + * @param Entity $entity + */ + public function deleteEntityTerms(Entity $entity) + { + $entity->searchTerms()->delete(); + } + + /** + * Create a scored term array from the given text. + * @param $text + * @param float|int $scoreAdjustment + * @return array + */ + protected function generateTermArrayFromText($text, $scoreAdjustment = 1) + { + $tokenMap = []; // {TextToken => OccurrenceCount} + $splitText = explode(' ', $text); + foreach ($splitText as $token) { + if ($token === '') continue; + if (!isset($tokenMap[$token])) $tokenMap[$token] = 0; + $tokenMap[$token]++; + } + + $terms = []; + foreach ($tokenMap as $token => $count) { + $terms[] = [ + 'term' => $token, + 'score' => $count * $scoreAdjustment + ]; + } + return $terms; + } + + + + + /** + * Custom entity search filters + */ + + protected function filterUpdatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + try { $date = date_create($input); + } catch (\Exception $e) {return;} + $query->where('updated_at', '>=', $date); + } + + protected function filterUpdatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + try { $date = date_create($input); + } catch (\Exception $e) {return;} + $query->where('updated_at', '<', $date); + } + + protected function filterCreatedAfter(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + try { $date = date_create($input); + } catch (\Exception $e) {return;} + $query->where('created_at', '>=', $date); + } + + protected function filterCreatedBefore(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + try { $date = date_create($input); + } catch (\Exception $e) {return;} + $query->where('created_at', '<', $date); + } + + protected function filterCreatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + if (!is_numeric($input) && $input !== 'me') return; + if ($input === 'me') $input = user()->id; + $query->where('created_by', '=', $input); + } + + protected function filterUpdatedBy(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + if (!is_numeric($input) && $input !== 'me') return; + if ($input === 'me') $input = user()->id; + $query->where('updated_by', '=', $input); + } + + protected function filterInName(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + $query->where('name', 'like', '%' .$input. '%'); + } + + protected function filterInTitle(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) {$this->filterInName($query, $model, $input);} + + protected function filterInBody(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + $query->where($model->textField, 'like', '%' .$input. '%'); + } + + protected function filterIsRestricted(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + $query->where('restricted', '=', true); + } + + protected function filterViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + $query->whereHas('views', function($query) { + $query->where('user_id', '=', user()->id); + }); + } + + protected function filterNotViewedByMe(\Illuminate\Database\Eloquent\Builder $query, Entity $model, $input) + { + $query->whereDoesntHave('views', function($query) { + $query->where('user_id', '=', user()->id); + }); + } + +} \ No newline at end of file diff --git a/database/migrations/2015_08_31_175240_add_search_indexes.php b/database/migrations/2015_08_31_175240_add_search_indexes.php index 99e5a28f0..127f69d28 100644 --- a/database/migrations/2015_08_31_175240_add_search_indexes.php +++ b/database/migrations/2015_08_31_175240_add_search_indexes.php @@ -12,9 +12,10 @@ class AddSearchIndexes extends Migration */ public function up() { - DB::statement('ALTER TABLE pages ADD FULLTEXT search(name, text)'); - DB::statement('ALTER TABLE books ADD FULLTEXT search(name, description)'); - DB::statement('ALTER TABLE chapters ADD FULLTEXT search(name, description)'); + $prefix = DB::getTablePrefix(); + DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT search(name, text)"); + DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT search(name, description)"); + DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT search(name, description)"); } /** diff --git a/database/migrations/2015_12_05_145049_fulltext_weighting.php b/database/migrations/2015_12_05_145049_fulltext_weighting.php index cef43f604..998131387 100644 --- a/database/migrations/2015_12_05_145049_fulltext_weighting.php +++ b/database/migrations/2015_12_05_145049_fulltext_weighting.php @@ -12,9 +12,10 @@ class FulltextWeighting extends Migration */ public function up() { - DB::statement('ALTER TABLE pages ADD FULLTEXT name_search(name)'); - DB::statement('ALTER TABLE books ADD FULLTEXT name_search(name)'); - DB::statement('ALTER TABLE chapters ADD FULLTEXT name_search(name)'); + $prefix = DB::getTablePrefix(); + DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT name_search(name)"); + DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT name_search(name)"); + DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT name_search(name)"); } /** diff --git a/database/migrations/2017_03_19_091553_create_search_index_table.php b/database/migrations/2017_03_19_091553_create_search_index_table.php new file mode 100644 index 000000000..32c6a09e1 --- /dev/null +++ b/database/migrations/2017_03_19_091553_create_search_index_table.php @@ -0,0 +1,63 @@ +increments('id'); + $table->string('term', 200); + $table->string('entity_type', 100); + $table->integer('entity_id'); + $table->integer('score'); + + $table->index('term'); + $table->index('entity_type'); + $table->index(['entity_type', 'entity_id']); + $table->index('score'); + }); + + // Drop search indexes + Schema::table('pages', function(Blueprint $table) { + $table->dropIndex('search'); + $table->dropIndex('name_search'); + }); + Schema::table('books', function(Blueprint $table) { + $table->dropIndex('search'); + $table->dropIndex('name_search'); + }); + Schema::table('chapters', function(Blueprint $table) { + $table->dropIndex('search'); + $table->dropIndex('name_search'); + }); + + app(\BookStack\Services\SearchService::class)->indexAllEntities(); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + $prefix = DB::getTablePrefix(); + DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT search(name, text)"); + DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT search(name, description)"); + DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT search(name, description)"); + DB::statement("ALTER TABLE {$prefix}pages ADD FULLTEXT name_search(name)"); + DB::statement("ALTER TABLE {$prefix}books ADD FULLTEXT name_search(name)"); + DB::statement("ALTER TABLE {$prefix}chapters ADD FULLTEXT name_search(name)"); + + Schema::dropIfExists('search_terms'); + } +} diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php index efcda4220..6f6b3ddc5 100644 --- a/database/seeds/DummyContentSeeder.php +++ b/database/seeds/DummyContentSeeder.php @@ -16,7 +16,7 @@ class DummyContentSeeder extends Seeder $user->attachRole($role); - $books = factory(\BookStack\Book::class, 20)->create(['created_by' => $user->id, 'updated_by' => $user->id]) + factory(\BookStack\Book::class, 20)->create(['created_by' => $user->id, 'updated_by' => $user->id]) ->each(function($book) use ($user) { $chapters = factory(\BookStack\Chapter::class, 5)->create(['created_by' => $user->id, 'updated_by' => $user->id]) ->each(function($chapter) use ($user, $book){ @@ -28,7 +28,7 @@ class DummyContentSeeder extends Seeder $book->pages()->saveMany($pages); }); - $restrictionService = app(\BookStack\Services\PermissionService::class); - $restrictionService->buildJointPermissions(); + app(\BookStack\Services\PermissionService::class)->buildJointPermissions(); + app(\BookStack\Services\SearchService::class)->indexAllEntities(); } } diff --git a/gulpfile.js b/gulpfile.js index 9d789d9b4..b72bb366d 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -1,8 +1,63 @@ -var elixir = require('laravel-elixir'); +const argv = require('yargs').argv; +const gulp = require('gulp'), + plumber = require('gulp-plumber'); +const autoprefixer = require('gulp-autoprefixer'); +const uglify = require('gulp-uglify'); +const minifycss = require('gulp-clean-css'); +const sass = require('gulp-sass'); +const browserify = require("browserify"); +const source = require('vinyl-source-stream'); +const buffer = require('vinyl-buffer'); +const babelify = require("babelify"); +const watchify = require("watchify"); +const envify = require("envify"); +const gutil = require("gulp-util"); -elixir(mix => { - mix.sass('styles.scss'); - mix.sass('print-styles.scss'); - mix.sass('export-styles.scss'); - mix.browserify('global.js', './public/js/common.js'); +if (argv.production) process.env.NODE_ENV = 'production'; + +gulp.task('styles', () => { + let chain = gulp.src(['resources/assets/sass/**/*.scss']) + .pipe(plumber({ + errorHandler: function (error) { + console.log(error.message); + this.emit('end'); + }})) + .pipe(sass()) + .pipe(autoprefixer('last 2 versions')); + if (argv.production) chain = chain.pipe(minifycss()); + return chain.pipe(gulp.dest('public/css/')); }); + + +function scriptTask(watch=false) { + + let props = { + basedir: 'resources/assets/js', + debug: true, + entries: ['global.js'] + }; + + let bundler = watch ? watchify(browserify(props), { poll: true }) : browserify(props); + bundler.transform(envify, {global: true}).transform(babelify, {presets: ['es2015']}); + function rebundle() { + let stream = bundler.bundle(); + stream = stream.pipe(source('common.js')); + if (argv.production) stream = stream.pipe(buffer()).pipe(uglify()); + return stream.pipe(gulp.dest('public/js/')); + } + bundler.on('update', function() { + rebundle(); + gutil.log('Rebundle...'); + }); + bundler.on('log', gutil.log); + return rebundle(); +} + +gulp.task('scripts', () => {scriptTask(false)}); +gulp.task('scripts-watch', () => {scriptTask(true)}); + +gulp.task('default', ['styles', 'scripts-watch'], () => { + gulp.watch("resources/assets/sass/**/*.scss", ['styles']); +}); + +gulp.task('build', ['styles', 'scripts']); \ No newline at end of file diff --git a/package.json b/package.json index b0805c918..9f2ce4c1a 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,43 @@ { "private": true, "scripts": { - "build": "gulp --production", - "dev": "gulp watch", - "watch": "gulp watch" + "build": "gulp build", + "production": "gulp build --production", + "dev": "gulp", + "watch": "gulp" }, "devDependencies": { + "babelify": "^7.3.0", + "browserify": "^14.3.0", + "envify": "^4.0.0", + "gulp": "3.9.1", + "gulp-autoprefixer": "3.1.1", + "gulp-clean-css": "^3.0.4", + "gulp-minify-css": "1.2.4", + "gulp-plumber": "1.1.0", + "gulp-sass": "3.1.0", + "gulp-uglify": "2.1.2", + "vinyl-buffer": "^1.0.0", + "vinyl-source-stream": "^1.1.0", + "watchify": "^3.9.0", + "yargs": "^7.1.0" + }, + "dependencies": { "angular": "^1.5.5", "angular-animate": "^1.5.5", "angular-resource": "^1.5.5", "angular-sanitize": "^1.5.5", - "angular-ui-sortable": "^0.15.0", + "angular-ui-sortable": "^0.17.0", + "axios": "^0.16.1", + "babel-preset-es2015": "^6.24.1", + "clipboard": "^1.5.16", "dropzone": "^4.0.1", - "gulp": "^3.9.0", - "laravel-elixir": "^6.0.0-11", - "laravel-elixir-browserify-official": "^0.1.3", + "gulp-util": "^3.0.8", "marked": "^0.3.5", - "moment": "^2.12.0" + "moment": "^2.12.0", + "vue": "^2.2.6" }, - "dependencies": { - "clipboard": "^1.5.16" + "browser": { + "vue": "vue/dist/vue.common.js" } } diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 0d57b09ad..6a88aa811 100644 --- a/resources/assets/js/controllers.js +++ b/resources/assets/js/controllers.js @@ -1,12 +1,12 @@ "use strict"; -import moment from 'moment'; -import 'moment/locale/en-gb'; -import editorOptions from "./pages/page-form"; +const moment = require('moment'); +require('moment/locale/en-gb'); +const editorOptions = require("./pages/page-form"); moment.locale('en-gb'); -export default function (ngApp, events) { +module.exports = function (ngApp, events) { ngApp.controller('ImageManagerController', ['$scope', '$attrs', '$http', '$timeout', 'imageManagerService', function ($scope, $attrs, $http, $timeout, imageManagerService) { @@ -259,39 +259,6 @@ export default function (ngApp, events) { }]); - - ngApp.controller('BookShowController', ['$scope', '$http', '$attrs', '$sce', function ($scope, $http, $attrs, $sce) { - $scope.searching = false; - $scope.searchTerm = ''; - $scope.searchResults = ''; - - $scope.searchBook = function (e) { - e.preventDefault(); - let term = $scope.searchTerm; - if (term.length == 0) return; - $scope.searching = true; - $scope.searchResults = ''; - let searchUrl = window.baseUrl('/search/book/' + $attrs.bookId); - searchUrl += '?term=' + encodeURIComponent(term); - $http.get(searchUrl).then((response) => { - $scope.searchResults = $sce.trustAsHtml(response.data); - }); - }; - - $scope.checkSearchForm = function () { - if ($scope.searchTerm.length < 1) { - $scope.searching = false; - } - }; - - $scope.clearSearch = function () { - $scope.searching = false; - $scope.searchTerm = ''; - }; - - }]); - - ngApp.controller('PageEditController', ['$scope', '$http', '$attrs', '$interval', '$timeout', '$sce', function ($scope, $http, $attrs, $interval, $timeout, $sce) { diff --git a/resources/assets/js/directives.js b/resources/assets/js/directives.js index 10458e753..19badcac8 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -1,8 +1,8 @@ "use strict"; -import DropZone from "dropzone"; -import markdown from "marked"; +const DropZone = require("dropzone"); +const markdown = require("marked"); -export default function (ngApp, events) { +module.exports = function (ngApp, events) { /** * Common tab controls using simple jQuery functions. diff --git a/resources/assets/js/global.js b/resources/assets/js/global.js index 650919f85..dc6802e12 100644 --- a/resources/assets/js/global.js +++ b/resources/assets/js/global.js @@ -1,12 +1,5 @@ "use strict"; -// AngularJS - Create application and load components -import angular from "angular"; -import "angular-resource"; -import "angular-animate"; -import "angular-sanitize"; -import "angular-ui-sortable"; - // Url retrieval function window.baseUrl = function(path) { let basePath = document.querySelector('meta[name="base-url"]').getAttribute('content'); @@ -15,11 +8,33 @@ window.baseUrl = function(path) { return basePath + '/' + path; }; +const Vue = require("vue"); +const axios = require("axios"); + +let axiosInstance = axios.create({ + headers: { + 'X-CSRF-TOKEN': document.querySelector('meta[name=token]').getAttribute('content'), + 'baseURL': window.baseUrl('') + } +}); + +Vue.prototype.$http = axiosInstance; + +require("./vues/vues"); + + +// AngularJS - Create application and load components +const angular = require("angular"); +require("angular-resource"); +require("angular-animate"); +require("angular-sanitize"); +require("angular-ui-sortable"); + let ngApp = angular.module('bookStack', ['ngResource', 'ngAnimate', 'ngSanitize', 'ui.sortable']); // Translation setup // Creates a global function with name 'trans' to be used in the same way as Laravel's translation system -import Translations from "./translations" +const Translations = require("./translations"); let translator = new Translations(window.translations); window.trans = translator.get.bind(translator); @@ -47,11 +62,12 @@ class EventManager { } window.Events = new EventManager(); +Vue.prototype.$events = window.Events; // Load in angular specific items -import Services from './services'; -import Directives from './directives'; -import Controllers from './controllers'; +const Services = require('./services'); +const Directives = require('./directives'); +const Controllers = require('./controllers'); Services(ngApp, window.Events); Directives(ngApp, window.Events); Controllers(ngApp, window.Events); @@ -154,4 +170,4 @@ if(navigator.userAgent.indexOf('MSIE')!==-1 } // Page specific items -import "./pages/page-show"; +require("./pages/page-show"); diff --git a/resources/assets/js/pages/page-form.js b/resources/assets/js/pages/page-form.js index 0f44b3d09..b5a0a2998 100644 --- a/resources/assets/js/pages/page-form.js +++ b/resources/assets/js/pages/page-form.js @@ -60,7 +60,7 @@ function registerEditorShortcuts(editor) { editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']); } -export default function() { +module.exports = function() { let settings = { selector: '#html-editor', content_css: [ @@ -213,4 +213,4 @@ export default function() { } }; return settings; -} \ No newline at end of file +}; \ No newline at end of file diff --git a/resources/assets/js/pages/page-show.js b/resources/assets/js/pages/page-show.js index 0f45e1987..cc6296434 100644 --- a/resources/assets/js/pages/page-show.js +++ b/resources/assets/js/pages/page-show.js @@ -1,8 +1,8 @@ "use strict"; // Configure ZeroClipboard -import Clipboard from "clipboard"; +const Clipboard = require("clipboard"); -export default window.setupPageShow = function (pageId) { +let setupPageShow = window.setupPageShow = function (pageId) { // Set up pointer let $pointer = $('#pointer').detach(); @@ -151,3 +151,5 @@ export default window.setupPageShow = function (pageId) { }); }; + +module.exports = setupPageShow; \ No newline at end of file diff --git a/resources/assets/js/translations.js b/resources/assets/js/translations.js index 306c696b6..ca6a7bd29 100644 --- a/resources/assets/js/translations.js +++ b/resources/assets/js/translations.js @@ -44,4 +44,4 @@ class Translator { } -export default Translator +module.exports = Translator; diff --git a/resources/assets/js/vues/entity-search.js b/resources/assets/js/vues/entity-search.js new file mode 100644 index 000000000..7266bf33d --- /dev/null +++ b/resources/assets/js/vues/entity-search.js @@ -0,0 +1,44 @@ +let data = { + id: null, + type: '', + searching: false, + searchTerm: '', + searchResults: '', +}; + +let computed = { + +}; + +let methods = { + + searchBook() { + if (this.searchTerm.trim().length === 0) return; + this.searching = true; + this.searchResults = ''; + let url = window.baseUrl(`/search/${this.type}/${this.id}`); + url += `?term=${encodeURIComponent(this.searchTerm)}`; + this.$http.get(url).then(resp => { + this.searchResults = resp.data; + }); + }, + + checkSearchForm() { + this.searching = this.searchTerm > 0; + }, + + clearSearch() { + this.searching = false; + this.searchTerm = ''; + } + +}; + +function mounted() { + this.id = Number(this.$el.getAttribute('entity-id')); + this.type = this.$el.getAttribute('entity-type'); +} + +module.exports = { + data, computed, methods, mounted +}; \ No newline at end of file diff --git a/resources/assets/js/vues/search.js b/resources/assets/js/vues/search.js new file mode 100644 index 000000000..515ca3bc9 --- /dev/null +++ b/resources/assets/js/vues/search.js @@ -0,0 +1,195 @@ +const moment = require('moment'); + +let data = { + terms: '', + termString : '', + search: { + type: { + page: true, + chapter: true, + book: true + }, + exactTerms: [], + tagTerms: [], + option: {}, + dates: { + updated_after: false, + updated_before: false, + created_after: false, + created_before: false, + } + } +}; + +let computed = { + +}; + +let methods = { + + appendTerm(term) { + this.termString += ' ' + term; + this.termString = this.termString.replace(/\s{2,}/g, ' '); + this.termString = this.termString.replace(/^\s+/, ''); + this.termString = this.termString.replace(/\s+$/, ''); + }, + + exactParse(searchString) { + this.search.exactTerms = []; + let exactFilter = /"(.+?)"/g; + let matches; + while ((matches = exactFilter.exec(searchString)) !== null) { + this.search.exactTerms.push(matches[1]); + } + }, + + exactChange() { + let exactFilter = /"(.+?)"/g; + this.termString = this.termString.replace(exactFilter, ''); + let matchesTerm = this.search.exactTerms.filter(term => { + return term.trim() !== ''; + }).map(term => { + return `"${term}"` + }).join(' '); + this.appendTerm(matchesTerm); + }, + + addExact() { + this.search.exactTerms.push(''); + setTimeout(() => { + let exactInputs = document.querySelectorAll('.exact-input'); + exactInputs[exactInputs.length - 1].focus(); + }, 100); + }, + + removeExact(index) { + this.search.exactTerms.splice(index, 1); + this.exactChange(); + }, + + tagParse(searchString) { + this.search.tagTerms = []; + let tagFilter = /\[(.+?)\]/g; + let matches; + while ((matches = tagFilter.exec(searchString)) !== null) { + this.search.tagTerms.push(matches[1]); + } + }, + + tagChange() { + let tagFilter = /\[(.+?)\]/g; + this.termString = this.termString.replace(tagFilter, ''); + let matchesTerm = this.search.tagTerms.filter(term => { + return term.trim() !== ''; + }).map(term => { + return `[${term}]` + }).join(' '); + this.appendTerm(matchesTerm); + }, + + addTag() { + this.search.tagTerms.push(''); + setTimeout(() => { + let tagInputs = document.querySelectorAll('.tag-input'); + tagInputs[tagInputs.length - 1].focus(); + }, 100); + }, + + removeTag(index) { + this.search.tagTerms.splice(index, 1); + this.tagChange(); + }, + + typeParse(searchString) { + let typeFilter = /{\s?type:\s?(.*?)\s?}/; + let match = searchString.match(typeFilter); + let type = this.search.type; + if (!match) { + type.page = type.book = type.chapter = true; + return; + } + let splitTypes = match[1].replace(/ /g, '').split('|'); + type.page = (splitTypes.indexOf('page') !== -1); + type.chapter = (splitTypes.indexOf('chapter') !== -1); + type.book = (splitTypes.indexOf('book') !== -1); + }, + + typeChange() { + let typeFilter = /{\s?type:\s?(.*?)\s?}/; + let type = this.search.type; + if (type.page === type.chapter && type.page === type.book) { + this.termString = this.termString.replace(typeFilter, ''); + return; + } + let selectedTypes = Object.keys(type).filter(type => {return this.search.type[type];}).join('|'); + let typeTerm = '{type:'+selectedTypes+'}'; + if (this.termString.match(typeFilter)) { + this.termString = this.termString.replace(typeFilter, typeTerm); + return; + } + this.appendTerm(typeTerm); + }, + + optionParse(searchString) { + let optionFilter = /{([a-z_\-:]+?)}/gi; + let matches; + while ((matches = optionFilter.exec(searchString)) !== null) { + this.search.option[matches[1].toLowerCase()] = true; + } + }, + + optionChange(optionName) { + let isChecked = this.search.option[optionName]; + if (isChecked) { + this.appendTerm(`{${optionName}}`); + } else { + this.termString = this.termString.replace(`{${optionName}}`, ''); + } + }, + + updateSearch(e) { + e.preventDefault(); + window.location = '/search?term=' + encodeURIComponent(this.termString); + }, + + enableDate(optionName) { + this.search.dates[optionName.toLowerCase()] = moment().format('YYYY-MM-DD'); + this.dateChange(optionName); + }, + + dateParse(searchString) { + let dateFilter = /{([a-z_\-]+?):([a-z_\-0-9]+?)}/gi; + let dateTags = Object.keys(this.search.dates); + let matches; + while ((matches = dateFilter.exec(searchString)) !== null) { + if (dateTags.indexOf(matches[1]) === -1) continue; + this.search.dates[matches[1].toLowerCase()] = matches[2]; + } + }, + + dateChange(optionName) { + let dateFilter = new RegExp('{\\s?'+optionName+'\\s?:([a-z_\\-0-9]+?)}', 'gi'); + this.termString = this.termString.replace(dateFilter, ''); + if (!this.search.dates[optionName]) return; + this.appendTerm(`{${optionName}:${this.search.dates[optionName]}}`); + }, + + dateRemove(optionName) { + this.search.dates[optionName] = false; + this.dateChange(optionName); + } + +}; + +function created() { + this.termString = document.querySelector('[name=searchTerm]').value; + this.typeParse(this.termString); + this.exactParse(this.termString); + this.tagParse(this.termString); + this.optionParse(this.termString); + this.dateParse(this.termString); +} + +module.exports = { + data, computed, methods, created +}; \ No newline at end of file diff --git a/resources/assets/js/vues/vues.js b/resources/assets/js/vues/vues.js new file mode 100644 index 000000000..8cc1dd656 --- /dev/null +++ b/resources/assets/js/vues/vues.js @@ -0,0 +1,18 @@ +const Vue = require("vue"); + +function exists(id) { + return document.getElementById(id) !== null; +} + +let vueMapping = { + 'search-system': require('./search'), + 'entity-dashboard': require('./entity-search'), +}; + +Object.keys(vueMapping).forEach(id => { + if (exists(id)) { + let config = vueMapping[id]; + config.el = '#' + id; + new Vue(config); + } +}); \ No newline at end of file diff --git a/resources/assets/sass/_animations.scss b/resources/assets/sass/_animations.scss index 582d718c8..afcf01cff 100644 --- a/resources/assets/sass/_animations.scss +++ b/resources/assets/sass/_animations.scss @@ -2,7 +2,7 @@ .anim.fadeIn { opacity: 0; animation-name: fadeIn; - animation-duration: 160ms; + animation-duration: 180ms; animation-timing-function: ease-in-out; animation-fill-mode: forwards; } diff --git a/resources/assets/sass/_forms.scss b/resources/assets/sass/_forms.scss index 7e6b800d2..1fc812896 100644 --- a/resources/assets/sass/_forms.scss +++ b/resources/assets/sass/_forms.scss @@ -98,19 +98,36 @@ label { label.radio, label.checkbox { font-weight: 400; + user-select: none; input[type="radio"], input[type="checkbox"] { margin-right: $-xs; } } +label.inline.checkbox { + margin-right: $-m; +} + label + p.small { margin-bottom: 0.8em; } -input[type="text"], input[type="number"], input[type="email"], input[type="search"], input[type="url"], input[type="password"], select, textarea { +table.form-table { + max-width: 100%; + td { + overflow: hidden; + padding: $-xxs/2 0; + } +} + +input[type="text"], input[type="number"], input[type="email"], input[type="date"], input[type="search"], input[type="url"], input[type="password"], select, textarea { @extend .input-base; } +input[type=date] { + width: 190px; +} + .toggle-switch { display: inline-block; background-color: #BBB; diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss index 6acc47468..051268926 100644 --- a/resources/assets/sass/_lists.scss +++ b/resources/assets/sass/_lists.scss @@ -109,6 +109,7 @@ transition-property: right, border; border-left: 0px solid #FFF; background-color: #FFF; + max-width: 320px; &.fixed { background-color: #FFF; z-index: 5; diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index 967aba76b..50c3a50b2 100644 --- a/resources/assets/sass/styles.scss +++ b/resources/assets/sass/styles.scss @@ -7,8 +7,8 @@ @import "grid"; @import "blocks"; @import "buttons"; -@import "forms"; @import "tables"; +@import "forms"; @import "animations"; @import "tinymce"; @import "highlightjs"; @@ -17,7 +17,11 @@ @import "lists"; @import "pages"; -[v-cloak], [v-show] {display: none;} +[v-cloak], [v-show] { + display: none; opacity: 0; + animation-name: none !important; +} + [ng\:cloak], [ng-cloak], .ng-cloak { display: none !important; @@ -272,8 +276,3 @@ $btt-size: 40px; - - - - - diff --git a/resources/lang/de/entities.php b/resources/lang/de/entities.php index 2859e4ec5..c9feb8497 100644 --- a/resources/lang/de/entities.php +++ b/resources/lang/de/entities.php @@ -43,18 +43,9 @@ return [ * Search */ 'search_results' => 'Suchergebnisse', - 'search_results_page' => 'Seiten-Suchergebnisse', - 'search_results_chapter' => 'Kapitel-Suchergebnisse', - 'search_results_book' => 'Buch-Suchergebnisse', 'search_clear' => 'Suche zurücksetzen', - 'search_view_pages' => 'Zeige alle passenden Seiten', - 'search_view_chapters' => 'Zeige alle passenden Kapitel', - 'search_view_books' => 'Zeige alle passenden Bücher', 'search_no_pages' => 'Es wurden keine passenden Suchergebnisse gefunden', 'search_for_term' => 'Suche nach :term', - 'search_page_for_term' => 'Suche nach :term in Seiten', - 'search_chapter_for_term' => 'Suche nach :term in Kapiteln', - 'search_book_for_term' => 'Suche nach :term in Büchern', /** * Books diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index 31ef42e97..e1d74c95e 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -33,6 +33,7 @@ return [ 'search_clear' => 'Clear Search', 'reset' => 'Reset', 'remove' => 'Remove', + 'add' => 'Add', /** diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php index f54134718..8644f7a4a 100644 --- a/resources/lang/en/entities.php +++ b/resources/lang/en/entities.php @@ -43,18 +43,26 @@ return [ * Search */ 'search_results' => 'Search Results', - 'search_results_page' => 'Page Search Results', - 'search_results_chapter' => 'Chapter Search Results', - 'search_results_book' => 'Book Search Results', + 'search_total_results_found' => ':count result found|:count total results found', 'search_clear' => 'Clear Search', - 'search_view_pages' => 'View all matches pages', - 'search_view_chapters' => 'View all matches chapters', - 'search_view_books' => 'View all matches books', 'search_no_pages' => 'No pages matched this search', 'search_for_term' => 'Search for :term', - 'search_page_for_term' => 'Page search for :term', - 'search_chapter_for_term' => 'Chapter search for :term', - 'search_book_for_term' => 'Books search for :term', + 'search_more' => 'More Results', + 'search_filters' => 'Search Filters', + 'search_content_type' => 'Content Type', + 'search_exact_matches' => 'Exact Matches', + 'search_tags' => 'Tag Searches', + 'search_viewed_by_me' => 'Viewed by me', + 'search_not_viewed_by_me' => 'Not viewed by me', + 'search_permissions_set' => 'Permissions set', + 'search_created_by_me' => 'Created by me', + 'search_updated_by_me' => 'Updated by me', + 'search_updated_before' => 'Updated before', + 'search_updated_after' => 'Updated after', + 'search_created_before' => 'Created before', + 'search_created_after' => 'Created after', + 'search_set_date' => 'Set Date', + 'search_update' => 'Update Search', /** * Books @@ -112,6 +120,7 @@ return [ 'chapters_empty' => 'No pages are currently in this chapter.', 'chapters_permissions_active' => 'Chapter Permissions Active', 'chapters_permissions_success' => 'Chapter Permissions Updated', + 'chapters_search_this' => 'Search this chapter', /** * Pages diff --git a/resources/lang/es/entities.php b/resources/lang/es/entities.php index 14e952f1a..b03366da6 100644 --- a/resources/lang/es/entities.php +++ b/resources/lang/es/entities.php @@ -43,18 +43,9 @@ return [ * Search */ 'search_results' => 'Buscar resultados', - 'search_results_page' => 'resultados de búsqueda en página', - 'search_results_chapter' => 'Resultados de búsqueda en capítulo ', - 'search_results_book' => 'Resultados de búsqueda en libro', 'search_clear' => 'Limpiar resultados', - 'search_view_pages' => 'Ver todas las páginas que concuerdan', - 'search_view_chapters' => 'Ver todos los capítulos que concuerdan', - 'search_view_books' => 'Ver todos los libros que concuerdan', 'search_no_pages' => 'Ninguna página encontrada para la búsqueda', 'search_for_term' => 'Busqueda por :term', - 'search_page_for_term' => 'Búsqueda de página por :term', - 'search_chapter_for_term' => 'Búsqueda por capítulo de :term', - 'search_book_for_term' => 'Búsqueda en libro de :term', /** * Books diff --git a/resources/lang/fr/entities.php b/resources/lang/fr/entities.php index cfd206b91..5562fb0fd 100644 --- a/resources/lang/fr/entities.php +++ b/resources/lang/fr/entities.php @@ -43,18 +43,9 @@ return [ * Search */ 'search_results' => 'Résultats de recherche', - 'search_results_page' => 'Résultats de recherche des pages', - 'search_results_chapter' => 'Résultats de recherche des chapitres', - 'search_results_book' => 'Résultats de recherche des livres', 'search_clear' => 'Réinitialiser la recherche', - 'search_view_pages' => 'Voir toutes les pages correspondantes', - 'search_view_chapters' => 'Voir tous les chapitres correspondants', - 'search_view_books' => 'Voir tous les livres correspondants', 'search_no_pages' => 'Aucune page correspondant à cette recherche', 'search_for_term' => 'recherche pour :term', - 'search_page_for_term' => 'Recherche de page pour :term', - 'search_chapter_for_term' => 'Recherche de chapitre pour :term', - 'search_book_for_term' => 'Recherche de livres pour :term', /** * Books diff --git a/resources/lang/nl/entities.php b/resources/lang/nl/entities.php index 610116c8b..d6975e130 100644 --- a/resources/lang/nl/entities.php +++ b/resources/lang/nl/entities.php @@ -43,18 +43,9 @@ return [ * Search */ 'search_results' => 'Zoekresultaten', - 'search_results_page' => 'Pagina Zoekresultaten', - 'search_results_chapter' => 'Hoofdstuk Zoekresultaten', - 'search_results_book' => 'Boek Zoekresultaten', 'search_clear' => 'Zoekopdracht wissen', - 'search_view_pages' => 'Bekijk alle gevonden pagina\'s', - 'search_view_chapters' => 'Bekijk alle gevonden hoofdstukken', - 'search_view_books' => 'Bekijk alle gevonden boeken', 'search_no_pages' => 'Er zijn geen pagina\'s gevonden', 'search_for_term' => 'Zoeken op :term', - 'search_page_for_term' => 'Pagina doorzoeken op :term', - 'search_chapter_for_term' => 'Hoofdstuk doorzoeken op :term', - 'search_book_for_term' => 'Boeken doorzoeken op :term', /** * Books diff --git a/resources/lang/pt_BR/entities.php b/resources/lang/pt_BR/entities.php index 922342424..5a965fe62 100644 --- a/resources/lang/pt_BR/entities.php +++ b/resources/lang/pt_BR/entities.php @@ -43,18 +43,9 @@ return [ * Search */ 'search_results' => 'Resultado(s) da Pesquisa', - 'search_results_page' => 'Resultado(s) de Pesquisa de Página', - 'search_results_chapter' => 'Resultado(s) de Pesquisa de Capítulo', - 'search_results_book' => 'Resultado(s) de Pesquisa de Livro', 'search_clear' => 'Limpar Pesquisa', - 'search_view_pages' => 'Visualizar todas as páginas correspondentes', - 'search_view_chapters' => 'Visualizar todos os capítulos correspondentes', - 'search_view_books' => 'Visualizar todos os livros correspondentes', 'search_no_pages' => 'Nenhuma página corresponde à pesquisa', 'search_for_term' => 'Pesquisar por :term', - 'search_page_for_term' => 'Pesquisar Página por :term', - 'search_chapter_for_term' => 'Pesquisar Capítulo por :term', - 'search_book_for_term' => 'Pesquisar Livros por :term', /** * Books diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php index 4287014c2..95a9d72b0 100644 --- a/resources/views/base.blade.php +++ b/resources/views/base.blade.php @@ -47,7 +47,7 @@
- diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index f5e08b2f6..adfec4525 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -50,15 +50,15 @@
-
+

{{$book->name}}

-
-

{{$book->description}}

+
+

{{$book->description}}

-
+

@if(count($bookChildren) > 0) @foreach($bookChildren as $childElement) @@ -81,12 +81,12 @@ @include('partials.entity-meta', ['entity' => $book])
-
-

{{ trans('entities.search_results') }} {{ trans('entities.search_clear') }}

-
+
+

{{ trans('entities.search_results') }} {{ trans('entities.search_clear') }}

+
@include('partials/loading-icon')
-
+
@@ -94,6 +94,7 @@
+ @if($book->restricted)

@if(userCan('restrictions-manage', $book)) @@ -103,14 +104,16 @@ @endif

@endif + -
+ +

{{ trans('entities.recent_activity') }}

@include('partials/activity-list', ['activity' => Activity::entityActivity($book, 20, 0)])
diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index 28c34eef2..d4126cbcc 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -47,40 +47,50 @@
-
+
-
+

{{ $chapter->name }}

-

{{ $chapter->description }}

+
+

{{ $chapter->description }}

- @if(count($pages) > 0) -
-
- @foreach($pages as $page) - @include('pages/list-item', ['page' => $page]) + @if(count($pages) > 0) +

- @endforeach -
- @else -
-

{{ trans('entities.chapters_empty') }}

-

- @if(userCan('page-create', $chapter)) - {{ trans('entities.books_empty_create_page') }} - @endif - @if(userCan('page-create', $chapter) && userCan('book-update', $book)) -   -{{ trans('entities.books_empty_or') }}-    - @endif - @if(userCan('book-update', $book)) - {{ trans('entities.books_empty_sort_current_book') }} - @endif -

-
- @endif + @foreach($pages as $page) + @include('pages/list-item', ['page' => $page]) +
+ @endforeach +
+ @else +
+

{{ trans('entities.chapters_empty') }}

+

+ @if(userCan('page-create', $chapter)) + {{ trans('entities.books_empty_create_page') }} + @endif + @if(userCan('page-create', $chapter) && userCan('book-update', $book)) +   -{{ trans('entities.books_empty_or') }}-    + @endif + @if(userCan('book-update', $book)) + {{ trans('entities.books_empty_sort_current_book') }} + @endif +

+
+ @endif - @include('partials.entity-meta', ['entity' => $chapter]) + @include('partials.entity-meta', ['entity' => $chapter]) +
+ +
+

{{ trans('entities.search_results') }} {{ trans('entities.search_clear') }}

+
+ @include('partials/loading-icon') +
+
+
-
+
@if($book->restricted || $chapter->restricted)
@@ -105,7 +115,16 @@
@endif + + @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree]) +
diff --git a/resources/views/pages/sidebar-tree-list.blade.php b/resources/views/pages/sidebar-tree-list.blade.php index f366e9e9b..faae6420a 100644 --- a/resources/views/pages/sidebar-tree-list.blade.php +++ b/resources/views/pages/sidebar-tree-list.blade.php @@ -3,13 +3,13 @@ @if(isset($page) && $page->tags->count() > 0)
-
Page Tags
+
{{ trans('entities.page_tags') }}
@foreach($page->tags as $tag) - - @if($tag->value) @endif + + @if($tag->value) @endif @endforeach diff --git a/resources/views/search/all.blade.php b/resources/views/search/all.blade.php index d4053752f..1029b65fa 100644 --- a/resources/views/search/all.blade.php +++ b/resources/views/search/all.blade.php @@ -2,59 +2,212 @@ @section('content') + + +
+ - -
- -

{{ trans('entities.search_results') }}

- -

- @if(count($pages) > 0) - {{ trans('entities.search_view_pages') }} - @endif - - @if(count($chapters) > 0) -      - {{ trans('entities.search_view_chapters') }} - @endif - - @if(count($books) > 0) -      - {{ trans('entities.search_view_books') }} - @endif -

+
-
-

{{ trans('entities.pages') }}

- @include('partials/entity-list', ['entities' => $pages, 'style' => 'detailed']) -
-
- @if(count($books) > 0) -

{{ trans('entities.books') }}

- @include('partials/entity-list', ['entities' => $books]) - @endif - @if(count($chapters) > 0) -

{{ trans('entities.chapters') }}

- @include('partials/entity-list', ['entities' => $chapters]) +
+

{{ trans('entities.search_results') }}

+
{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}
+ @include('partials/entity-list', ['entities' => $entities]) + @if ($hasNextPage) + {{ trans('entities.search_more') }} @endif
+ +
+

{{ trans('entities.search_filters') }}

+ +
+
{{ trans('entities.search_content_type') }}
+
+ + + +
+ +
{{ trans('entities.search_exact_matches') }}
+
value) colspan="2" @endif>{{ $tag->name }}{{$tag->value}}value) colspan="2" @endif>{{ $tag->name }}{{$tag->value}}
+ + + + + + + +
+ + +
+ +
+ +
{{ trans('entities.search_tags') }}
+ + + + + + + + +
+ + +
+ +
+ +
Options
+ + + + + + +
Date Options
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ trans('entities.search_updated_after') }} + + +
+ + + +
{{ trans('entities.search_updated_before') }} + + +
+ + + +
{{ trans('entities.search_created_after') }} + + +
+ + + +
{{ trans('entities.search_created_before') }} + + +
+ + + +
+ + + + + + +
+
- +
@stop \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 8259a633b..8ecfd9465 100644 --- a/routes/web.php +++ b/routes/web.php @@ -123,11 +123,9 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/link/{id}', 'PageController@redirectFromLink'); // Search - Route::get('/search/all', 'SearchController@searchAll'); - Route::get('/search/pages', 'SearchController@searchPages'); - Route::get('/search/books', 'SearchController@searchBooks'); - Route::get('/search/chapters', 'SearchController@searchChapters'); + Route::get('/search', 'SearchController@search'); Route::get('/search/book/{bookId}', 'SearchController@searchBook'); + Route::get('/search/chapter/{bookId}', 'SearchController@searchChapter'); // Other Pages Route::get('/', 'HomeController@index'); diff --git a/tests/Entity/EntitySearchTest.php b/tests/Entity/EntitySearchTest.php index 4ef8d46fb..9f77972c4 100644 --- a/tests/Entity/EntitySearchTest.php +++ b/tests/Entity/EntitySearchTest.php @@ -1,6 +1,7 @@ first(); $page = $book->pages->first(); - $this->asAdmin() - ->visit('/') - ->type($page->name, 'term') - ->press('header-search-box-button') - ->see('Search Results') - ->seeInElement('.entity-list', $page->name) - ->clickInElement('.entity-list', $page->name) - ->seePageIs($page->getUrl()); + $search = $this->asEditor()->get('/search?term=' . urlencode($page->name)); + $search->assertSee('Search Results'); + $search->assertSee($page->name); } public function test_invalid_page_search() { - $this->asAdmin() - ->visit('/') - ->type('

test

', 'term') - ->press('header-search-box-button') - ->see('Search Results') - ->seeStatusCode(200); + $resp = $this->asEditor()->get('/search?term=' . urlencode('

test

')); + $resp->assertSee('Search Results'); + $resp->assertStatus(200); + $this->get('/search?term=cat+-')->assertStatus(200); } - public function test_empty_search_redirects_back() + public function test_empty_search_shows_search_page() { - $this->asAdmin() - ->visit('/') - ->visit('/search/all') - ->seePageIs('/'); + $res = $this->asEditor()->get('/search'); + $res->assertStatus(200); + } + + public function test_searching_accents_and_small_terms() + { + $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'some áéííúü¿¡ test content {a2 orange dog']); + $this->asEditor(); + + $accentSearch = $this->get('/search?term=' . urlencode('áéíí')); + $accentSearch->assertStatus(200)->assertSee($page->name); + + $smallSearch = $this->get('/search?term=' . urlencode('{a')); + $smallSearch->assertStatus(200)->assertSee($page->name); } public function test_book_search() @@ -42,57 +46,20 @@ class EntitySearchTest extends BrowserKitTest $page = $book->pages->last(); $chapter = $book->chapters->last(); - $this->asAdmin() - ->visit('/search/book/' . $book->id . '?term=' . urlencode($page->name)) - ->see($page->name) + $pageTestResp = $this->asEditor()->get('/search/book/' . $book->id . '?term=' . urlencode($page->name)); + $pageTestResp->assertSee($page->name); - ->visit('/search/book/' . $book->id . '?term=' . urlencode($chapter->name)) - ->see($chapter->name); + $chapterTestResp = $this->asEditor()->get('/search/book/' . $book->id . '?term=' . urlencode($chapter->name)); + $chapterTestResp->assertSee($chapter->name); } - public function test_empty_book_search_redirects_back() + public function test_chapter_search() { - $book = \BookStack\Book::all()->first(); - $this->asAdmin() - ->visit('/books') - ->visit('/search/book/' . $book->id . '?term=') - ->seePageIs('/books'); - } + $chapter = \BookStack\Chapter::has('pages')->first(); + $page = $chapter->pages[0]; - - public function test_pages_search_listing() - { - $page = \BookStack\Page::all()->last(); - $this->asAdmin()->visit('/search/pages?term=' . $page->name) - ->see('Page Search Results')->see('.entity-list', $page->name); - } - - public function test_chapters_search_listing() - { - $chapter = \BookStack\Chapter::all()->last(); - $this->asAdmin()->visit('/search/chapters?term=' . $chapter->name) - ->see('Chapter Search Results')->seeInElement('.entity-list', $chapter->name); - } - - public function test_search_quote_term_preparation() - { - $termString = '"192" cat "dog hat"'; - $repo = $this->app[\BookStack\Repos\EntityRepo::class]; - $preparedTerms = $repo->prepareSearchTerms($termString); - $this->assertTrue($preparedTerms === ['"192"','"dog hat"', 'cat']); - } - - public function test_books_search_listing() - { - $book = \BookStack\Book::all()->last(); - $this->asAdmin()->visit('/search/books?term=' . $book->name) - ->see('Book Search Results')->see('.entity-list', $book->name); - } - - public function test_searching_hypen_doesnt_break() - { - $this->visit('/search/all?term=cat+-') - ->seeStatusCode(200); + $pageTestResp = $this->asEditor()->get('/search/chapter/' . $chapter->id . '?term=' . urlencode($page->name)); + $pageTestResp->assertSee($page->name); } public function test_tag_search() @@ -114,27 +81,99 @@ class EntitySearchTest extends BrowserKitTest $pageB = \BookStack\Page::all()->last(); $pageB->tags()->create(['name' => 'animal', 'value' => 'dog']); - $this->asAdmin()->visit('/search/all?term=%5Banimal%5D') - ->seeLink($pageA->name) - ->seeLink($pageB->name); + $this->asEditor(); + $tNameSearch = $this->get('/search?term=%5Banimal%5D'); + $tNameSearch->assertSee($pageA->name)->assertSee($pageB->name); - $this->visit('/search/all?term=%5Bcolor%5D') - ->seeLink($pageA->name) - ->dontSeeLink($pageB->name); + $tNameSearch2 = $this->get('/search?term=%5Bcolor%5D'); + $tNameSearch2->assertSee($pageA->name)->assertDontSee($pageB->name); - $this->visit('/search/all?term=%5Banimal%3Dcat%5D') - ->seeLink($pageA->name) - ->dontSeeLink($pageB->name); + $tNameValSearch = $this->get('/search?term=%5Banimal%3Dcat%5D'); + $tNameValSearch->assertSee($pageA->name)->assertDontSee($pageB->name); + } + public function test_exact_searches() + { + $page = $this->newPage(['name' => 'My new test page', 'html' => 'this is a story about an orange donkey']); + + $exactSearchA = $this->asEditor()->get('/search?term=' . urlencode('"story about an orange"')); + $exactSearchA->assertStatus(200)->assertSee($page->name); + + $exactSearchB = $this->asEditor()->get('/search?term=' . urlencode('"story not about an orange"')); + $exactSearchB->assertStatus(200)->assertDontSee($page->name); + } + + public function test_search_filters() + { + $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'this is about an orange donkey danzorbhsing']); + $this->asEditor(); + $editorId = $this->getEditor()->id; + + // Viewed filter searches + $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {viewed_by_me}'))->assertDontSee($page->name); + $this->get($page->getUrl()); + $this->get('/search?term=' . urlencode('danzorbhsing {not_viewed_by_me}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {viewed_by_me}'))->assertSee($page->name); + + // User filters + $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorId.'}'))->assertDontSee($page->name); + $page->created_by = $editorId; + $page->save(); + $this->get('/search?term=' . urlencode('danzorbhsing {created_by:me}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {created_by:'.$editorId.'}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertDontSee($page->name); + $page->updated_by = $editorId; + $page->save(); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:me}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_by:'.$editorId.'}'))->assertSee($page->name); + + // Content filters + $this->get('/search?term=' . urlencode('{in_name:danzorbhsing}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('{in_body:danzorbhsing}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('{in_name:test quaffleachits}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('{in_body:test quaffleachits}'))->assertDontSee($page->name); + + // Restricted filter + $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertDontSee($page->name); + $page->restricted = true; + $page->save(); + $this->get('/search?term=' . urlencode('danzorbhsing {is_restricted}'))->assertSee($page->name); + + // Date filters + $this->get('/search?term=' . urlencode('danzorbhsing {updated_after:2037-01-01}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_before:2037-01-01}'))->assertSee($page->name); + $page->updated_at = '2037-02-01'; + $page->save(); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_after:2037-01-01}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {updated_before:2037-01-01}'))->assertDontSee($page->name); + + $this->get('/search?term=' . urlencode('danzorbhsing {created_after:2037-01-01}'))->assertDontSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertSee($page->name); + $page->created_at = '2037-02-01'; + $page->save(); + $this->get('/search?term=' . urlencode('danzorbhsing {created_after:2037-01-01}'))->assertSee($page->name); + $this->get('/search?term=' . urlencode('danzorbhsing {created_before:2037-01-01}'))->assertDontSee($page->name); } public function test_ajax_entity_search() { $page = \BookStack\Page::all()->last(); $notVisitedPage = \BookStack\Page::first(); - $this->visit($page->getUrl()); - $this->asAdmin()->visit('/ajax/search/entities?term=' . $page->name)->see('.entity-list', $page->name); - $this->asAdmin()->visit('/ajax/search/entities?types=book&term=' . $page->name)->dontSee('.entity-list', $page->name); - $this->asAdmin()->visit('/ajax/search/entities')->see('.entity-list', $page->name)->dontSee($notVisitedPage->name); + + // Visit the page to make popular + $this->asEditor()->get($page->getUrl()); + + $normalSearch = $this->get('/ajax/search/entities?term=' . urlencode($page->name)); + $normalSearch->assertSee($page->name); + + $bookSearch = $this->get('/ajax/search/entities?types=book&term=' . urlencode($page->name)); + $bookSearch->assertDontSee($page->name); + + $defaultListTest = $this->get('/ajax/search/entities'); + $defaultListTest->assertSee($page->name); + $defaultListTest->assertDontSee($notVisitedPage->name); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index f3f36ca1c..b008080d9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -76,4 +76,16 @@ abstract class TestCase extends BaseTestCase public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) { return $this->app[EntityRepo::class]->createFromInput('chapter', $input, $book); } + + /** + * Create and return a new test page + * @param array $input + * @return Chapter + */ + public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) { + $book = Book::first(); + $entityRepo = $this->app[EntityRepo::class]; + $draftPage = $entityRepo->getDraftPage($book); + return $entityRepo->publishPageDraft($draftPage, $input); + } } \ No newline at end of file