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 @@ +<?php + +namespace BookStack\Console\Commands; + +use BookStack\Services\SearchService; +use Illuminate\Console\Command; + +class RegenerateSearch extends Command +{ + /** + * The name and signature of the console command. + * + * @var string + */ + protected $signature = 'bookstack:regenerate-search {--database= : The database connection to use.}'; + + /** + * The console command description. + * + * @var string + */ + protected $description = 'Command description'; + + protected $searchService; + + /** + * Create a new command instance. + * + * @param SearchService $searchService + */ + public function __construct(SearchService $searchService) + { + parent::__construct(); + $this->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 @@ -<?php - -namespace BookStack\Console; +<?php namespace BookStack\Console; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -13,10 +11,11 @@ class Kernel extends ConsoleKernel * @var array */ protected $commands = [ - \BookStack\Console\Commands\ClearViews::class, - \BookStack\Console\Commands\ClearActivity::class, - \BookStack\Console\Commands\ClearRevisions::class, - \BookStack\Console\Commands\RegeneratePermissions::class, + Commands\ClearViews::class, + Commands\ClearActivity::class, + Commands\ClearRevisions::class, + Commands\RegeneratePermissions::class, + Commands\RegenerateSearch::class ]; /** diff --git a/app/Entity.php b/app/Entity.php index e8deddf0a..6aeb66481 100644 --- a/app/Entity.php +++ b/app/Entity.php @@ -4,7 +4,7 @@ class Entity extends Ownable { - protected $fieldsToSearch = ['name', 'description']; + public $textField = 'description'; /** * Compares this entity to another given entity. @@ -65,6 +65,15 @@ class Entity extends Ownable return $this->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 @@ <?php namespace BookStack\Http\Controllers; use BookStack\Repos\EntityRepo; +use BookStack\Services\SearchService; use BookStack\Services\ViewService; use Illuminate\Http\Request; @@ -8,16 +9,19 @@ class SearchController extends Controller { protected $entityRepo; protected $viewService; + protected $searchService; /** * SearchController constructor. * @param EntityRepo $entityRepo * @param ViewService $viewService + * @param SearchService $searchService */ - public function __construct(EntityRepo $entityRepo, ViewService $viewService) + public function __construct(EntityRepo $entityRepo, ViewService $viewService, SearchService $searchService) { $this->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 83ef6f350..4a8d32780 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. @@ -105,4 +104,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', "<span class=\"highlight\">\$0</span>", $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', "<span class=\"highlight\">\$0</span>", $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 @@ +<?php namespace BookStack; + +class SearchTerm extends Model +{ + + protected $fillable = ['term', 'entity_id', 'entity_type', 'score']; + public $timestamps = false; + + /** + * Get the entity that this term belongs to + * @return \Illuminate\Database\Eloquent\Relations\MorphTo + */ + public function entity() + { + return $this->morphTo('entity'); + } + +} diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php index 6f27db9b7..cb0b68026 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 @@ +<?php namespace BookStack\Services; + +use BookStack\Book; +use BookStack\Chapter; +use BookStack\Entity; +use BookStack\Page; +use BookStack\SearchTerm; +use Illuminate\Database\Connection; +use Illuminate\Database\Query\Builder; +use Illuminate\Database\Query\JoinClause; +use Illuminate\Support\Collection; + +class SearchService +{ + protected $searchTerm; + protected $book; + protected $chapter; + protected $page; + protected $db; + protected $permissionService; + protected $entities; + + /** + * Acceptable operators to be used in a query + * @var array + */ + protected $queryOperators = ['<=', '>=', '=', '<', '>', '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/config/app.php b/config/app.php index b730e6303..feab6651c 100644 --- a/config/app.php +++ b/config/app.php @@ -100,7 +100,7 @@ return [ | */ - 'log' => 'single', + 'log' => env('APP_LOGGING', 'single'), /* |-------------------------------------------------------------------------- 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 @@ +<?php + +use Illuminate\Support\Facades\Schema; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Migrations\Migration; + +class CreateSearchIndexTable extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::create('search_terms', function (Blueprint $table) { + $table->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..b60facff3 100644 --- a/package.json +++ b/package.json @@ -1,24 +1,44 @@ { "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", - "marked": "^0.3.5", - "moment": "^2.12.0" + "gulp-util": "^3.0.8", + "markdown-it": "^8.3.1", + "markdown-it-task-lists": "^2.0.0", + "moment": "^2.12.0", + "vue": "^2.2.6" }, - "dependencies": { - "clipboard": "^1.5.16" + "browser": { + "vue": "vue/dist/vue.common.js" } } diff --git a/readme.md b/readme.md index 65dcbe7b1..3e269e175 100644 --- a/readme.md +++ b/readme.md @@ -74,7 +74,7 @@ These are the great projects used to help build BookStack: * [Dropzone.js](http://www.dropzonejs.com/) * [ZeroClipboard](http://zeroclipboard.org/) * [TinyColorPicker](http://www.dematte.at/tinyColorPicker/index.html) -* [Marked](https://github.com/chjj/marked) +* [markdown-it](https://github.com/markdown-it/markdown-it) and [markdown-it-task-lists](https://github.com/revin/markdown-it-task-lists) * [Moment.js](http://momentjs.com/) * [BarryVD](https://github.com/barryvdh) * [Debugbar](https://github.com/barryvdh/laravel-debugbar) diff --git a/resources/assets/js/controllers.js b/resources/assets/js/controllers.js index 7df130f3f..65dc50e99 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 537a016f5..f30a09778 100644 --- a/resources/assets/js/directives.js +++ b/resources/assets/js/directives.js @@ -1,8 +1,9 @@ "use strict"; -import DropZone from "dropzone"; -import markdown from "marked"; +const DropZone = require("dropzone"); +const MarkdownIt = require("markdown-it"); +const mdTasksLists = require('markdown-it-task-lists'); -export default function (ngApp, events) { +module.exports = function (ngApp, events) { /** * Common tab controls using simple jQuery functions. @@ -214,18 +215,8 @@ export default function (ngApp, events) { } }]); - let renderer = new markdown.Renderer(); - // Custom markdown checkbox list item - // Attribution: https://github.com/chjj/marked/issues/107#issuecomment-44542001 - renderer.listitem = function(text) { - if (/^\s*\[[x ]\]\s*/.test(text)) { - text = text - .replace(/^\s*\[ \]\s*/, '<input type="checkbox"/>') - .replace(/^\s*\[x\]\s*/, '<input type="checkbox" checked/>'); - return `<li class="checkbox-item">${text}</li>`; - } - return `<li>${text}</li>`; - }; + const md = new MarkdownIt(); + md.use(mdTasksLists, {label: true}); /** * Markdown input @@ -244,20 +235,20 @@ export default function (ngApp, events) { element = element.find('textarea').first(); let content = element.val(); scope.mdModel = content; - scope.mdChange(markdown(content, {renderer: renderer})); + scope.mdChange(md.render(content)); element.on('change input', (event) => { content = element.val(); $timeout(() => { scope.mdModel = content; - scope.mdChange(markdown(content, {renderer: renderer})); + scope.mdChange(md.render(content)); }); }); scope.$on('markdown-update', (event, value) => { element.val(value); scope.mdModel = value; - scope.mdChange(markdown(value)); + scope.mdChange(md.render(value)); }); } 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..f8bdf2e2b 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: [ @@ -68,6 +68,7 @@ export default function() { window.baseUrl('/libs/material-design-iconic-font/css/material-design-iconic-font.min.css') ], body_class: 'page-content', + browser_spellcheck: true, relative_urls: false, remove_script_host: false, document_base_url: window.baseUrl('/'), @@ -213,4 +214,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..4a4724b85 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(); @@ -81,6 +81,12 @@ export default window.setupPageShow = function (pageId) { let $idElem = $(idElem); let color = $('#custom-styles').attr('data-color-light'); $idElem.css('background-color', color).attr('data-highlighted', 'true').smoothScrollTo(); + setTimeout(() => { + $idElem.addClass('anim').addClass('selectFade').css('background-color', ''); + setTimeout(() => { + $idElem.removeClass('selectFade'); + }, 3000); + }, 100); } else { $('.page-content').find(':contains("' + text + '")').smoothScrollTo(); } @@ -151,3 +157,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..467399a66 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; } @@ -126,4 +126,8 @@ animation-duration: 180ms; animation-delay: 0s; animation-timing-function: cubic-bezier(.62, .28, .23, .99); +} + +.anim.selectFade { + transition: background-color ease-in-out 3000ms; } \ No newline at end of file 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/_text.scss b/resources/assets/sass/_text.scss index a74a81647..df717dd8d 100644 --- a/resources/assets/sass/_text.scss +++ b/resources/assets/sass/_text.scss @@ -269,19 +269,31 @@ span.highlight { /* * Lists */ +ul, ol { + overflow: hidden; + p { + margin: 0; + } +} ul { padding-left: $-m * 1.3; list-style: disc; - overflow: hidden; + ul { + list-style: circle; + margin-top: 0; + margin-bottom: 0; + } + label { + margin: 0; + } } ol { list-style: decimal; padding-left: $-m * 2; - overflow: hidden; } -li.checkbox-item { +li.checkbox-item, li.task-list-item { list-style: none; margin-left: - ($-m * 1.3); input[type="checkbox"] { diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss index cd43650ee..60071b9ef 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"; @@ -19,7 +19,11 @@ @import "pages"; @import "comments"; -[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; 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 afe6f7ec1..ec0e17306 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/en/settings.php b/resources/lang/en/settings.php index fa60f99a8..31163e87e 100644 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -120,6 +120,7 @@ return [ 'fr' => 'Français', 'nl' => 'Nederlands', 'pt_BR' => 'Português do Brasil', + 'sk' => 'Slovensky', ] /////////////////////////////////// ]; 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/lang/sk/activities.php b/resources/lang/sk/activities.php new file mode 100644 index 000000000..1d87d3fa3 --- /dev/null +++ b/resources/lang/sk/activities.php @@ -0,0 +1,40 @@ +<?php + +return [ + + /** + * Activity text strings. + * Is used for all the text within activity logs & notifications. + */ + + // Pages + 'page_create' => 'vytvoril stránku', + 'page_create_notification' => 'Stránka úspešne vytvorená', + 'page_update' => 'aktualizoval stránku', + 'page_update_notification' => 'Stránka úspešne aktualizovaná', + 'page_delete' => 'odstránil stránku', + 'page_delete_notification' => 'Stránka úspešne odstránená', + 'page_restore' => 'obnovil stránku', + 'page_restore_notification' => 'Stránka úspešne obnovená', + 'page_move' => 'presunul stránku', + + // Chapters + 'chapter_create' => 'vytvoril kapitolu', + 'chapter_create_notification' => 'Kapitola úspešne vytvorená', + 'chapter_update' => 'aktualizoval kapitolu', + 'chapter_update_notification' => 'Kapitola úspešne aktualizovaná', + 'chapter_delete' => 'odstránil kapitolu', + 'chapter_delete_notification' => 'Kapitola úspešne odstránená', + 'chapter_move' => 'presunul kapitolu', + + // Books + 'book_create' => 'vytvoril knihu', + 'book_create_notification' => 'Kniha úspešne vytvorená', + 'book_update' => 'aktualizoval knihu', + 'book_update_notification' => 'Kniha úspešne aktualizovaná', + 'book_delete' => 'odstránil knihu', + 'book_delete_notification' => 'Kniha úspešne odstránená', + 'book_sort' => 'zoradil knihu', + 'book_sort_notification' => 'Kniha úspešne znovu zoradená', + +]; diff --git a/resources/lang/sk/auth.php b/resources/lang/sk/auth.php new file mode 100644 index 000000000..2fa69ac3e --- /dev/null +++ b/resources/lang/sk/auth.php @@ -0,0 +1,76 @@ +<?php +return [ + /* + |-------------------------------------------------------------------------- + | Authentication Language Lines + |-------------------------------------------------------------------------- + | + | The following language lines are used during authentication for various + | messages that we need to display to the user. You are free to modify + | these language lines according to your application's requirements. + | + */ + 'failed' => 'Tieto údaje nesedia s našimi záznamami.', + 'throttle' => 'Priveľa pokusov o prihlásenie. Skúste znova o :seconds sekúnd.', + + /** + * Login & Register + */ + 'sign_up' => 'Registrácia', + 'log_in' => 'Prihlásenie', + 'log_in_with' => 'Prihlásiť sa cez :socialDriver', + 'sign_up_with' => 'Registrovať sa cez :socialDriver', + 'logout' => 'Odhlásenie', + + 'name' => 'Meno', + 'username' => 'Používateľské meno', + 'email' => 'Email', + 'password' => 'Heslo', + 'password_confirm' => 'Potvrdiť heslo', + 'password_hint' => 'Musí mať viac ako 5 znakov', + 'forgot_password' => 'Zabudli ste heslo?', + 'remember_me' => 'Zapamätať si ma', + 'ldap_email_hint' => 'Zadajte prosím email, ktorý sa má použiť pre tento účet.', + 'create_account' => 'Vytvoriť účet', + 'social_login' => 'Sociálne prihlásenie', + 'social_registration' => 'Sociálna registrácia', + 'social_registration_text' => 'Registrovať sa a prihlásiť sa použitím inej služby.', + + 'register_thanks' => 'Ďakujeme zaregistráciu!', + 'register_confirm' => 'Skontrolujte prosím svoj email a kliknite na potvrdzujúce tlačidlo pre prístup k :appName.', + 'registrations_disabled' => 'Registrácie sú momentálne zablokované', + 'registration_email_domain_invalid' => 'Táto emailová doména nemá prístup k tejto aplikácii', + 'register_success' => 'Ďakujeme za registráciu! Teraz ste registrovaný a prihlásený.', + + + /** + * Password Reset + */ + 'reset_password' => 'Reset hesla', + 'reset_password_send_instructions' => 'Zadajte svoj email nižšie a bude Vám odoslaný email s odkazom pre reset hesla.', + 'reset_password_send_button' => 'Poslať odkaz na reset hesla', + 'reset_password_sent_success' => 'Odkaz na reset hesla bol poslaný na :email.', + 'reset_password_success' => 'Vaše heslo bolo úspešne resetované.', + + 'email_reset_subject' => 'Reset Vášho :appName hesla', + 'email_reset_text' => 'Tento email Ste dostali pretože sme dostali požiadavku na reset hesla pre Váš účet.', + 'email_reset_not_requested' => 'Ak ste nepožiadali o reset hesla, nemusíte nič robiť.', + + + /** + * Email Confirmation + */ + 'email_confirm_subject' => 'Potvrdiť email na :appName', + 'email_confirm_greeting' => 'Ďakujeme za pridanie sa k :appName!', + 'email_confirm_text' => 'Prosím potvrďte Vašu emailovú adresu kliknutím na tlačidlo nižšie:', + 'email_confirm_action' => 'Potvrdiť email', + 'email_confirm_send_error' => 'Je požadované overenie emailu, ale systém nemohol odoslať email. Kontaktujte administrátora by ste sa uistili, že email je nastavený správne.', + 'email_confirm_success' => 'Váš email bol overený!', + 'email_confirm_resent' => 'Potvrdzujúci email bol poslaný znovu, skontrolujte prosím svoju emailovú schránku.', + + 'email_not_confirmed' => 'Emailová adresa nebola overená', + 'email_not_confirmed_text' => 'Vaša emailová adresa nebola zatiaľ overená.', + 'email_not_confirmed_click_link' => 'Prosím, kliknite na odkaz v emaili, ktorý bol poslaný krátko po Vašej registrácii.', + 'email_not_confirmed_resend' => 'Ak nemôžete násť email, môžete znova odoslať overovací email odoslaním doleuvedeného formulára.', + 'email_not_confirmed_resend_button' => 'Znova odoslať overovací email', +]; diff --git a/resources/lang/sk/common.php b/resources/lang/sk/common.php new file mode 100644 index 000000000..100981597 --- /dev/null +++ b/resources/lang/sk/common.php @@ -0,0 +1,58 @@ +<?php +return [ + + /** + * Buttons + */ + 'cancel' => 'Zrušiť', + 'confirm' => 'Potvrdiť', + 'back' => 'Späť', + 'save' => 'Uložiť', + 'continue' => 'Pokračovať', + 'select' => 'Vybrať', + + /** + * Form Labels + */ + 'name' => 'Meno', + 'description' => 'Popis', + 'role' => 'Rola', + + /** + * Actions + */ + 'actions' => 'Akcie', + 'view' => 'Zobraziť', + 'create' => 'Vytvoriť', + 'update' => 'Aktualizovať', + 'edit' => 'Editovať', + 'sort' => 'Zoradiť', + 'move' => 'Presunúť', + 'delete' => 'Zmazať', + 'search' => 'Hľadť', + 'search_clear' => 'Vyčistiť hľadanie', + 'reset' => 'Reset', + 'remove' => 'Odstrániť', + + + /** + * Misc + */ + 'deleted_user' => 'Odstránený používateľ', + 'no_activity' => 'Žiadna aktivita na zobrazenie', + 'no_items' => 'Žiadne položky nie sú dostupné', + 'back_to_top' => 'Späť nahor', + 'toggle_details' => 'Prepnúť detaily', + + /** + * Header + */ + 'view_profile' => 'Zobraziť profil', + 'edit_profile' => 'Upraviť profil', + + /** + * Email Content + */ + 'email_action_help' => 'Ak máte problém klinkúť na tlačidlo ":actionText", skopírujte a vložte URL uvedenú nižšie do Vášho prehliadača:', + 'email_rights' => 'Všetky práva vyhradené', +]; diff --git a/resources/lang/sk/components.php b/resources/lang/sk/components.php new file mode 100644 index 000000000..f4fa92043 --- /dev/null +++ b/resources/lang/sk/components.php @@ -0,0 +1,24 @@ +<?php +return [ + + /** + * Image Manager + */ + 'image_select' => 'Vybrať obrázok', + 'image_all' => 'Všetko', + 'image_all_title' => 'Zobraziť všetky obrázky', + 'image_book_title' => 'Zobraziť obrázky nahrané do tejto knihy', + 'image_page_title' => 'Zobraziť obrázky nahrané do tejto stránky', + 'image_search_hint' => 'Hľadať obrázok podľa názvu', + 'image_uploaded' => 'Nahrané :uploadedDate', + 'image_load_more' => 'Načítať viac', + 'image_image_name' => 'Názov obrázka', + 'image_delete_confirm' => 'Tento obrázok je použitý na stránkach uvedených nižšie, kliknite znova na zmazať pre potvrdenie zmazania tohto obrázka.', + 'image_select_image' => 'Vybrať obrázok', + 'image_dropzone' => 'Presuňte obrázky sem alebo kliknite sem pre nahranie', + 'images_deleted' => 'Obrázky zmazané', + 'image_preview' => 'Náhľad obrázka', + 'image_upload_success' => 'Obrázok úspešne nahraný', + 'image_update_success' => 'Detaily obrázka úspešne aktualizované', + 'image_delete_success' => 'Obrázok úspešne zmazaný' +]; diff --git a/resources/lang/sk/entities.php b/resources/lang/sk/entities.php new file mode 100644 index 000000000..e70864753 --- /dev/null +++ b/resources/lang/sk/entities.php @@ -0,0 +1,226 @@ +<?php +return [ + + /** + * Shared + */ + 'recently_created' => 'Nedávno vytvorené', + 'recently_created_pages' => 'Nedávno vytvorené stránky', + 'recently_updated_pages' => 'Nedávno aktualizované stránky', + 'recently_created_chapters' => 'Nedávno vytvorené kapitoly', + 'recently_created_books' => 'Nedávno vytvorené knihy', + 'recently_update' => 'Nedávno aktualizované', + 'recently_viewed' => 'Nedávno zobrazené', + 'recent_activity' => 'Nedávna aktivita', + 'create_now' => 'Vytvoriť teraz', + 'revisions' => 'Revízie', + 'meta_created' => 'Vytvorené :timeLength', + 'meta_created_name' => 'Vytvorené :timeLength používateľom :user', + 'meta_updated' => 'Aktualizované :timeLength', + 'meta_updated_name' => 'Aktualizované :timeLength používateľom :user', + 'x_pages' => ':count stránok', + 'entity_select' => 'Entita vybraná', + 'images' => 'Obrázky', + 'my_recent_drafts' => 'Moje nedávne koncepty', + 'my_recently_viewed' => 'Nedávno mnou zobrazené', + 'no_pages_viewed' => 'Nepozreli ste si žiadne stránky', + 'no_pages_recently_created' => 'Žiadne stránky neboli nedávno vytvorené', + 'no_pages_recently_updated' => 'Žiadne stránky neboli nedávno aktualizované', + 'export' => 'Export', + 'export_html' => 'Contained Web File', + 'export_pdf' => 'PDF súbor', + 'export_text' => 'Súbor s čistým textom', + + /** + * Permissions and restrictions + */ + 'permissions' => 'Oprávnenia', + 'permissions_intro' => 'Ak budú tieto oprávnenia povolené, budú mať prioritu pred oprávneniami roly.', + 'permissions_enable' => 'Povoliť vlastné oprávnenia', + 'permissions_save' => 'Uložiť oprávnenia', + + /** + * Search + */ + 'search_results' => 'Výsledky hľadania', + 'search_results_page' => 'Výsledky hľadania stránky', + 'search_results_chapter' => 'Výsledky hľadania kapitoly', + 'search_results_book' => 'Výsledky hľadania knihy', + 'search_clear' => 'Vyčistiť hľadanie', + 'search_view_pages' => 'Zobraziť všetky vyhovujúce stránky', + 'search_view_chapters' => 'Zobraziť všetky vyhovujúce kapitoly', + 'search_view_books' => 'Zobraziť všetky vyhovujúce knihy', + 'search_no_pages' => 'Žiadne stránky nevyhovujú tomuto hľadaniu', + 'search_for_term' => 'Hľadať :term', + 'search_page_for_term' => 'Hľadať :term medzi stránkami', + 'search_chapter_for_term' => 'Hľadať :term medzi kapitolami', + 'search_book_for_term' => 'Hľadať :term medzi knihami', + + /** + * Books + */ + 'book' => 'Kniha', + 'books' => 'Knihy', + 'books_empty' => 'Žiadne knihy neboli vytvorené', + 'books_popular' => 'Populárne knihy', + 'books_recent' => 'Nedávne knihy', + 'books_popular_empty' => 'Najpopulárnejšie knihy sa objavia tu.', + 'books_create' => 'Vytvoriť novú knihu', + 'books_delete' => 'Zmazať knihu', + 'books_delete_named' => 'Zmazať knihu :bookName', + 'books_delete_explain' => 'Toto zmaže knihu s názvom \':bookName\', všetky stránky a kapitoly budú odstránené.', + 'books_delete_confirmation' => 'Ste si istý, že chcete zmazať túto knihu?', + 'books_edit' => 'Upraviť knihu', + 'books_edit_named' => 'Upraviť knihu :bookName', + 'books_form_book_name' => 'Názov knihy', + 'books_save' => 'Uložiť knihu', + 'books_permissions' => 'Oprávnenia knihy', + 'books_permissions_updated' => 'Oprávnenia knihy aktualizované', + 'books_empty_contents' => 'Pre túto knihu neboli vytvorené žiadne stránky alebo kapitoly.', + 'books_empty_create_page' => 'Vytvoriť novú stránku', + 'books_empty_or' => 'alebo', + 'books_empty_sort_current_book' => 'Zoradiť aktuálnu knihu', + 'books_empty_add_chapter' => 'Pridať kapitolu', + 'books_permissions_active' => 'Oprávnenia knihy aktívne', + 'books_search_this' => 'Hľadať v tejto knihe', + 'books_navigation' => 'Navigácia knihy', + 'books_sort' => 'Zoradiť obsah knihy', + 'books_sort_named' => 'Zoradiť knihu :bookName', + 'books_sort_show_other' => 'Zobraziť ostatné knihy', + 'books_sort_save' => 'Uložiť nové zoradenie', + + /** + * Chapters + */ + 'chapter' => 'Kapitola', + 'chapters' => 'Kapitoly', + 'chapters_popular' => 'Populárne kapitoly', + 'chapters_new' => 'Nová kapitola', + 'chapters_create' => 'Vytvoriť novú kapitolu', + 'chapters_delete' => 'Zmazať kapitolu', + 'chapters_delete_named' => 'Zmazať kapitolu :chapterName', + 'chapters_delete_explain' => 'Toto zmaže kapitolu menom \':chapterName\', všetky stránky budú ostránené + a pridané priamo do rodičovskej knihy.', + 'chapters_delete_confirm' => 'Ste si istý, že chcete zmazať túto kapitolu?', + 'chapters_edit' => 'Upraviť kapitolu', + 'chapters_edit_named' => 'Upraviť kapitolu :chapterName', + 'chapters_save' => 'Uložiť kapitolu', + 'chapters_move' => 'Presunúť kapitolu', + 'chapters_move_named' => 'Presunúť kapitolu :chapterName', + 'chapter_move_success' => 'Kapitola presunutá do :bookName', + 'chapters_permissions' => 'Oprávnenia kapitoly', + 'chapters_empty' => 'V tejto kapitole nie sú teraz žiadne stránky.', + 'chapters_permissions_active' => 'Oprávnenia kapitoly aktívne', + 'chapters_permissions_success' => 'Oprávnenia kapitoly aktualizované', + + /** + * Pages + */ + 'page' => 'Stránka', + 'pages' => 'Stránky', + 'pages_popular' => 'Populárne stránky', + 'pages_new' => 'Nová stránka', + 'pages_attachments' => 'Prílohy', + 'pages_navigation' => 'Navigácia', + 'pages_delete' => 'Zmazať stránku', + 'pages_delete_named' => 'Zmazať stránku :pageName', + 'pages_delete_draft_named' => 'Zmazať koncept :pageName', + 'pages_delete_draft' => 'Zmazať koncept', + 'pages_delete_success' => 'Stránka zmazaná', + 'pages_delete_draft_success' => 'Koncept stránky zmazaný', + 'pages_delete_confirm' => 'Ste si istý, že chcete zmazať túto stránku?', + 'pages_delete_draft_confirm' => 'Ste si istý, že chcete zmazať tento koncept stránky?', + 'pages_editing_named' => 'Upraviť stránku :pageName', + 'pages_edit_toggle_header' => 'Prepnúť hlavičku', + 'pages_edit_save_draft' => 'Uložiť koncept', + 'pages_edit_draft' => 'Upraviť koncept stránky', + 'pages_editing_draft' => 'Upravuje sa koncept', + 'pages_editing_page' => 'Upravuje sa stránka', + 'pages_edit_draft_save_at' => 'Koncept uložený pod ', + 'pages_edit_delete_draft' => 'Uložiť koncept', + 'pages_edit_discard_draft' => 'Zrušiť koncept', + 'pages_edit_set_changelog' => 'Nastaviť záznam zmien', + 'pages_edit_enter_changelog_desc' => 'Zadajte krátky popis zmien, ktoré ste urobili', + 'pages_edit_enter_changelog' => 'Zadať záznam zmien', + 'pages_save' => 'Uložiť stránku', + 'pages_title' => 'Titulok stránky', + 'pages_name' => 'Názov stránky', + 'pages_md_editor' => 'Editor', + 'pages_md_preview' => 'Náhľad', + 'pages_md_insert_image' => 'Vložiť obrázok', + 'pages_md_insert_link' => 'Vložiť odkaz na entitu', + 'pages_not_in_chapter' => 'Stránka nie je v kapitole', + 'pages_move' => 'Presunúť stránku', + 'pages_move_success' => 'Stránka presunutá do ":parentName"', + 'pages_permissions' => 'Oprávnenia stránky', + 'pages_permissions_success' => 'Oprávnenia stránky aktualizované', + 'pages_revisions' => 'Revízie stránky', + 'pages_revisions_named' => 'Revízie stránky :pageName', + 'pages_revision_named' => 'Revízia stránky :pageName', + 'pages_revisions_created_by' => 'Vytvoril', + 'pages_revisions_date' => 'Dátum revízie', + 'pages_revisions_changelog' => 'Záznam zmien', + 'pages_revisions_changes' => 'Zmeny', + 'pages_revisions_current' => 'Aktuálna verzia', + 'pages_revisions_preview' => 'Náhľad', + 'pages_revisions_restore' => 'Obnoviť', + 'pages_revisions_none' => 'Táto stránka nemá žiadne revízie', + 'pages_copy_link' => 'Kopírovať odkaz', + 'pages_permissions_active' => 'Oprávnienia stránky aktívne', + 'pages_initial_revision' => 'Prvé zverejnenie', + 'pages_initial_name' => 'Nová stránka', + 'pages_editing_draft_notification' => 'Práve upravujete koncept, ktorý bol naposledy uložený :timeDiff.', + 'pages_draft_edited_notification' => 'Táto stránka bola odvtedy upravená. Odporúča sa odstrániť tento koncept.', + 'pages_draft_edit_active' => [ + 'start_a' => ':count používateľov začalo upravovať túto stránku', + 'start_b' => ':userName začal upravovať túto stránku', + 'time_a' => 'odkedy boli stránky naposledy aktualizované', + 'time_b' => 'za posledných :minCount minút', + 'message' => ':start :time. Dávajte pozor aby ste si navzájom neprepísali zmeny!', + ], + 'pages_draft_discarded' => 'Koncept ostránený, aktuálny obsah stránky bol nahraný do editora', + + /** + * Editor sidebar + */ + 'page_tags' => 'Štítky stránok', + 'tag' => 'Štítok', + 'tags' => 'Štítky', + 'tag_value' => 'Hodnota štítku (Voliteľné)', + 'tags_explain' => "Pridajte pár štítkov pre uľahčenie kategorizácie Vášho obsahu. \n Štítku môžete priradiť hodnotu pre ešte lepšiu organizáciu.", + 'tags_add' => 'Pridať ďalší štítok', + 'attachments' => 'Prílohy', + 'attachments_explain' => 'Nahrajte nejaké súbory alebo priložte zopár odkazov pre zobrazenie na Vašej stránke. Budú viditeľné v bočnom paneli.', + 'attachments_explain_instant_save' => 'Zmeny budú okamžite uložené.', + 'attachments_items' => 'Priložené položky', + 'attachments_upload' => 'Nahrať súbor', + 'attachments_link' => 'Priložiť odkaz', + 'attachments_set_link' => 'Nastaviť odkaz', + 'attachments_delete_confirm' => 'Kliknite znova na zmazať pre potvrdenie zmazania prílohy.', + 'attachments_dropzone' => 'Presuňte súbory alebo klinknite sem pre priloženie súboru', + 'attachments_no_files' => 'Žiadne súbory neboli nahrané', + 'attachments_explain_link' => 'Ak nechcete priložiť súbor, môžete priložiť odkaz. Môže to byť odkaz na inú stránku alebo odkaz na súbor v cloude.', + 'attachments_link_name' => 'Názov odkazu', + 'attachment_link' => 'Odkaz na prílohu', + 'attachments_link_url' => 'Odkaz na súbor', + 'attachments_link_url_hint' => 'Url stránky alebo súboru', + 'attach' => 'Priložiť', + 'attachments_edit_file' => 'Upraviť súbor', + 'attachments_edit_file_name' => 'Názov súboru', + 'attachments_edit_drop_upload' => 'Presuňte súbory sem alebo klinknite pre nahranie a prepis', + 'attachments_order_updated' => 'Poradie príloh aktualizované', + 'attachments_updated_success' => 'Detaily prílohy aktualizované', + 'attachments_deleted' => 'Príloha zmazaná', + 'attachments_file_uploaded' => 'Súbor úspešne nahraný', + 'attachments_file_updated' => 'Súbor úspešne aktualizovaný', + 'attachments_link_attached' => 'Odkaz úspešne pripojený k stránke', + + /** + * Profile View + */ + 'profile_user_for_x' => 'Používateľ už :time', + 'profile_created_content' => 'Vytvorený obsah', + 'profile_not_created_pages' => ':userName nevytvoril žiadne stránky', + 'profile_not_created_chapters' => ':userName nevytvoril žiadne kapitoly', + 'profile_not_created_books' => ':userName nevytvoril žiadne knihy', +]; diff --git a/resources/lang/sk/errors.php b/resources/lang/sk/errors.php new file mode 100644 index 000000000..e3420852a --- /dev/null +++ b/resources/lang/sk/errors.php @@ -0,0 +1,70 @@ +<?php + +return [ + + /** + * Error text strings. + */ + + // Permissions + 'permission' => 'Nemáte oprávnenie pre prístup k požadovanej stránke.', + 'permissionJson' => 'Nemáte oprávnenie pre vykonanie požadovaného úkonu.', + + // Auth + 'error_user_exists_different_creds' => 'Používateľ s emailom :email už existuje, ale s inými údajmi.', + 'email_already_confirmed' => 'Email bol už overený, skúste sa prihlásiť.', + 'email_confirmation_invalid' => 'Tento potvrdzujúci token nie je platný alebo už bol použitý, skúste sa prosím registrovať znova.', + 'email_confirmation_expired' => 'Potvrdzujúci token expiroval, bol odoslaný nový potvrdzujúci email.', + 'ldap_fail_anonymous' => 'LDAP access failed using anonymous bind', + 'ldap_fail_authed' => 'LDAP access failed using given dn & password details', + 'ldap_extension_not_installed' => 'LDAP PHP extension not installed', + 'ldap_cannot_connect' => 'Cannot connect to ldap server, Initial connection failed', + 'social_no_action_defined' => 'Nebola definovaná žiadna akcia', + 'social_account_in_use' => 'Tento :socialAccount účet sa už používa, skúste sa prihlásiť pomocou možnosti :socialAccount.', + 'social_account_email_in_use' => 'Email :email sa už používa. Ak už máte účet, môžete pripojiť svoj :socialAccount účet v nastaveniach profilu.', + 'social_account_existing' => 'Tento :socialAccount účet je už spojený s Vaším profilom.', + 'social_account_already_used_existing' => 'Tento :socialAccount účet už používa iný používateľ.', + 'social_account_not_used' => 'Tento :socialAccount účet nie je spojený so žiadnym používateľom. Pripojte ho prosím v nastaveniach Vášho profilu. ', + 'social_account_register_instructions' => 'Ak zatiaľ nemáte účet, môžete sa registrovať pomocou možnosti :socialAccount.', + 'social_driver_not_found' => 'Ovládač socialnych sietí nebol nájdený', + 'social_driver_not_configured' => 'Nastavenia Vášho :socialAccount účtu nie sú správne.', + + // System + 'path_not_writable' => 'Do cesty :filePath sa nedá nahrávať. Uistite sa, že je zapisovateľná serverom.', + 'cannot_get_image_from_url' => 'Nedá sa získať obrázok z :url', + 'cannot_create_thumbs' => 'Server nedokáže vytvoriť náhľady. Skontrolujte prosím, či máte nainštalované GD rozšírenie PHP.', + 'server_upload_limit' => 'Server nedovoľuje nahrávanie súborov s takouto veľkosťou. Skúste prosím menší súbor.', + 'image_upload_error' => 'Pri nahrávaní obrázka nastala chyba', + + // Attachments + 'attachment_page_mismatch' => 'Page mismatch during attachment update', + + // Pages + 'page_draft_autosave_fail' => 'Koncept nemohol byť uložený. Uistite sa, že máte pripojenie k internetu pre uložením tejto stránky', + + // Entities + 'entity_not_found' => 'Entita nenájdená', + 'book_not_found' => 'Kniha nenájdená', + 'page_not_found' => 'Stránka nenájdená', + 'chapter_not_found' => 'Kapitola nenájdená', + 'selected_book_not_found' => 'Vybraná kniha nebola nájdená', + 'selected_book_chapter_not_found' => 'Vybraná kniha alebo kapitola nebola nájdená', + 'guests_cannot_save_drafts' => 'Hosť nemôže ukladať koncepty', + + // Users + 'users_cannot_delete_only_admin' => 'Nemôžete zmazať posledného správcu', + 'users_cannot_delete_guest' => 'Nemôžete zmazať hosťa', + + // Roles + 'role_cannot_be_edited' => 'Táto rola nemôže byť upravovaná', + 'role_system_cannot_be_deleted' => 'Táto rola je systémová rola a nemôže byť zmazaná', + 'role_registration_default_cannot_delete' => 'Táto rola nemôže byť zmazaná, pretože je nastavená ako prednastavená rola pri registrácii', + + // Error pages + '404_page_not_found' => 'Stránka nenájdená', + 'sorry_page_not_found' => 'Prepáčte, stránka ktorú hľadáte nebola nájdená.', + 'return_home' => 'Vrátiť sa domov', + 'error_occurred' => 'Nastala chyba', + 'app_down' => ':appName je momentálne nedostupná', + 'back_soon' => 'Čoskoro bude opäť dostupná.', +]; diff --git a/resources/lang/sk/pagination.php b/resources/lang/sk/pagination.php new file mode 100644 index 000000000..8f844f5f4 --- /dev/null +++ b/resources/lang/sk/pagination.php @@ -0,0 +1,19 @@ +<?php + +return [ + + /* + |-------------------------------------------------------------------------- + | Pagination Language Lines + |-------------------------------------------------------------------------- + | + | The following language lines are used by the paginator library to build + | the simple pagination links. You are free to change them to anything + | you want to customize your views to better match your application. + | + */ + + 'previous' => '« Predchádzajúca', + 'next' => 'Ďalšia »', + +]; diff --git a/resources/lang/sk/passwords.php b/resources/lang/sk/passwords.php new file mode 100644 index 000000000..ff2eb68fa --- /dev/null +++ b/resources/lang/sk/passwords.php @@ -0,0 +1,22 @@ +<?php + +return [ + + /* + |-------------------------------------------------------------------------- + | Password Reminder Language Lines + |-------------------------------------------------------------------------- + | + | The following language lines are the default lines which match reasons + | that are given by the password broker for a password update attempt + | has failed, such as for an invalid token or invalid new password. + | + */ + + 'password' => 'Heslo musí obsahovať aspoň šesť znakov a musí byť rovnaké ako potvrdzujúce.', + 'user' => "Nenašli sme používateľa s takou emailovou adresou.", + 'token' => 'Tento token pre reset hesla je neplatný.', + 'sent' => 'Poslali sme Vám email s odkazom na reset hesla!', + 'reset' => 'Vaše heslo bolo resetované!', + +]; diff --git a/resources/lang/sk/settings.php b/resources/lang/sk/settings.php new file mode 100644 index 000000000..643b4b8ff --- /dev/null +++ b/resources/lang/sk/settings.php @@ -0,0 +1,111 @@ +<?php + +return [ + + /** + * Settings text strings + * Contains all text strings used in the general settings sections of BookStack + * including users and roles. + */ + + 'settings' => 'Nastavenia', + 'settings_save' => 'Uložiť nastavenia', + 'settings_save_success' => 'Nastavenia uložené', + + /** + * App settings + */ + + 'app_settings' => 'Nastavenia aplikácie', + 'app_name' => 'Názov aplikácia', + 'app_name_desc' => 'Tento názov sa zobrazuje v hlavičke a v emailoch.', + 'app_name_header' => 'Zobraziť názov aplikácie v hlavičke?', + 'app_public_viewing' => 'Povoliť verejné zobrazenie?', + 'app_secure_images' => 'Povoliť nahrávanie súborov so zvýšeným zabezpečením?', + 'app_secure_images_desc' => 'Kvôli výkonu sú všetky obrázky verejné. Táto možnosť pridá pred URL obrázka náhodný, ťažko uhádnuteľný reťazec. Aby ste zabránili jednoduchému prístupu, uistite sa, že indexy priečinkov nie sú povolené.', + 'app_editor' => 'Editor stránky', + 'app_editor_desc' => 'Vyberte editor, ktorý bude používaný všetkými používateľmi na editáciu stránok.', + 'app_custom_html' => 'Vlastný HTML obsah hlavičky', + 'app_custom_html_desc' => 'Všetok text pridaný sem bude vložený naspodok <head> sekcie na každej stránke. Môže sa to zísť pri zmene štýlu alebo pre pridanie analytického kódu.', + 'app_logo' => 'Logo aplikácie', + 'app_logo_desc' => 'Tento obrázok by mal mať 43px na výšku. <br>Veľké obrázky budú preškálované na menší rozmer.', + 'app_primary_color' => 'Primárna farba pre aplikáciu', + 'app_primary_color_desc' => 'Toto by mala byť hodnota v hex tvare. <br>Nechajte prázdne ak chcete použiť prednastavenú farbu.', + + /** + * Registration settings + */ + + 'reg_settings' => 'Nastavenia registrácie', + 'reg_allow' => 'Povoliť registráciu?', + 'reg_default_role' => 'Prednastavená používateľská rola po registrácii', + 'reg_confirm_email' => 'Vyžadovať overenie emailu?', + 'reg_confirm_email_desc' => 'Ak je použité obmedzenie domény, potom bude vyžadované overenie emailu a hodnota nižšie bude ignorovaná.', + 'reg_confirm_restrict_domain' => 'Obmedziť registráciu na doménu', + 'reg_confirm_restrict_domain_desc' => 'Zadajte zoznam domén, pre ktoré chcete povoliť registráciu oddelených čiarkou. Používatelia dostanú email kvôli overeniu adresy predtým ako im bude dovolené používať aplikáciu. <br> Používatelia si budú môcť po úspešnej registrácii zmeniť svoju emailovú adresu.', + 'reg_confirm_restrict_domain_placeholder' => 'Nie sú nastavené žiadne obmedzenia', + + /** + * Role settings + */ + + 'roles' => 'Roly', + 'role_user_roles' => 'Používateľské roly', + 'role_create' => 'Vytvoriť novú rolu', + 'role_create_success' => 'Rola úspešne vytvorená', + 'role_delete' => 'Zmazať rolu', + 'role_delete_confirm' => 'Toto zmaže rolu menom \':roleName\'.', + 'role_delete_users_assigned' => 'Túto rolu má priradenú :userCount používateľov. Ak chcete premigrovať používateľov z tejto roly, vyberte novú rolu nižšie.', + 'role_delete_no_migration' => "Nemigrovať používateľov", + 'role_delete_sure' => 'Ste si istý, že chcete zmazať túto rolu?', + 'role_delete_success' => 'Rola úspešne zmazaná', + 'role_edit' => 'Upraviť rolu', + 'role_details' => 'Detaily roly', + 'role_name' => 'Názov roly', + 'role_desc' => 'Krátky popis roly', + 'role_system' => 'Systémové oprávnenia', + 'role_manage_users' => 'Spravovať používateľov', + 'role_manage_roles' => 'Spravovať role a oprávnenia rolí', + 'role_manage_entity_permissions' => 'Spravovať všetky oprávnenia kníh, kapitol a stránok', + 'role_manage_own_entity_permissions' => 'Spravovať oprávnenia vlastných kníh, kapitol a stránok', + 'role_manage_settings' => 'Spravovať nastavenia aplikácie', + 'role_asset' => 'Oprávnenia majetku', + 'role_asset_desc' => 'Tieto oprávnenia regulujú prednastavený prístup k zdroju v systéme. Oprávnenia pre knihy, kapitoly a stránky majú vyššiu prioritu.', + 'role_all' => 'Všetko', + 'role_own' => 'Vlastné', + 'role_controlled_by_asset' => 'Regulované zdrojom, do ktorého sú nahrané', + 'role_save' => 'Uložiť rolu', + 'role_update_success' => 'Roly úspešne aktualizované', + 'role_users' => 'Používatelia s touto rolou', + 'role_users_none' => 'Žiadni používatelia nemajú priradenú túto rolu', + + /** + * Users + */ + + 'users' => 'Používatelia', + 'user_profile' => 'Profil používateľa', + 'users_add_new' => 'Pridať nového používateľa', + 'users_search' => 'Hľadať medzi používateľmi', + 'users_role' => 'Používateľské roly', + 'users_external_auth_id' => 'Externé autentifikačné ID', + 'users_password_warning' => 'Pole nižšie vyplňte iba ak chcete zmeniť heslo:', + 'users_system_public' => 'Tento účet reprezentuje každého hosťovského používateľa, ktorý navštívi Vašu inštanciu. Nedá sa pomocou neho prihlásiť a je priradený automaticky.', + 'users_delete' => 'Zmazať používateľa', + 'users_delete_named' => 'Zmazať používateľa :userName', + 'users_delete_warning' => ' Toto úplne odstráni používateľa menom \':userName\' zo systému.', + 'users_delete_confirm' => 'Ste si istý, že chcete zmazať tohoto používateľa?', + 'users_delete_success' => 'Používateľ úspešne zmazaný', + 'users_edit' => 'Upraviť používateľa', + 'users_edit_profile' => 'Upraviť profil', + 'users_edit_success' => 'Používateľ úspešne upravený', + 'users_avatar' => 'Avatar používateľa', + 'users_avatar_desc' => 'Tento obrázok by mal byť štvorec s rozmerom približne 256px.', + 'users_preferred_language' => 'Preferovaný jazyk', + 'users_social_accounts' => 'Sociálne účty', + 'users_social_accounts_info' => 'Tu si môžete pripojiť iné účty pre rýchlejšie a jednoduchšie prihlásenie. Disconnecting an account here does not previously authorized access. Revoke access from your profile settings on the connected social account.', + 'users_social_connect' => 'Pripojiť účet', + 'users_social_disconnect' => 'Odpojiť účet', + 'users_social_connected' => ':socialAccount účet bol úspešne pripojený k Vášmu profilu.', + 'users_social_disconnected' => ':socialAccount účet bol úspešne odpojený od Vášho profilu.', +]; diff --git a/resources/lang/sk/validation.php b/resources/lang/sk/validation.php new file mode 100644 index 000000000..b365b409d --- /dev/null +++ b/resources/lang/sk/validation.php @@ -0,0 +1,108 @@ +<?php + +return [ + + /* + |-------------------------------------------------------------------------- + | Validation Language Lines + |-------------------------------------------------------------------------- + | + | The following language lines contain the default error messages used by + | the validator class. Some of these rules have multiple versions such + | as the size rules. Feel free to tweak each of these messages here. + | + */ + + 'accepted' => ':attribute musí byť akceptovaný.', + 'active_url' => ':attribute nie je platná URL.', + 'after' => ':attribute musí byť dátum po :date.', + 'alpha' => ':attribute môže obsahovať iba písmená.', + 'alpha_dash' => ':attribute môže obsahovať iba písmená, čísla a pomlčky.', + 'alpha_num' => ':attribute môže obsahovať iba písmená a čísla.', + 'array' => ':attribute musí byť pole.', + 'before' => ':attribute musí byť dátum pred :date.', + 'between' => [ + 'numeric' => ':attribute musí byť medzi :min a :max.', + 'file' => ':attribute musí byť medzi :min a :max kilobajtmi.', + 'string' => ':attribute musí byť medzi :min a :max znakmi.', + 'array' => ':attribute musí byť medzi :min a :max položkami.', + ], + 'boolean' => ':attribute pole musí byť true alebo false.', + 'confirmed' => ':attribute potvrdenie nesedí.', + 'date' => ':attribute nie je platný dátum.', + 'date_format' => ':attribute nesedí s formátom :format.', + 'different' => ':attribute a :other musia byť rozdielne.', + 'digits' => ':attribute musí mať :digits číslic.', + 'digits_between' => ':attribute musí mať medzi :min a :max číslicami.', + 'email' => ':attribute musí byť platná emailová adresa.', + 'filled' => 'Políčko :attribute je povinné.', + 'exists' => 'Vybraný :attribute nie je platný.', + 'image' => ':attribute musí byť obrázok.', + 'in' => 'Vybraný :attribute je neplatný.', + 'integer' => ':attribute musí byť celé číslo.', + 'ip' => ':attribute musí byť platná IP adresa.', + 'max' => [ + 'numeric' => ':attribute nesmie byť väčší ako :max.', + 'file' => ':attribute nesmie byť väčší ako :max kilobajtov.', + 'string' => ':attribute nesmie byť dlhší ako :max znakov.', + 'array' => ':attribute nesmie mať viac ako :max položiek.', + ], + 'mimes' => ':attribute musí byť súbor typu: :values.', + 'min' => [ + 'numeric' => ':attribute musí byť aspoň :min.', + 'file' => ':attribute musí mať aspoň :min kilobajtov.', + 'string' => ':attribute musí mať aspoň :min znakov.', + 'array' => ':attribute musí mať aspoň :min položiek.', + ], + 'not_in' => 'Vybraný :attribute je neplatný.', + 'numeric' => ':attribute musí byť číslo.', + 'regex' => ':attribute formát je neplatný.', + 'required' => 'Políčko :attribute je povinné.', + 'required_if' => 'Políčko :attribute je povinné ak :other je :value.', + 'required_with' => 'Políčko :attribute je povinné ak :values existuje.', + 'required_with_all' => 'Políčko :attribute je povinné ak :values existuje.', + 'required_without' => 'Políčko :attribute je povinné aj :values neexistuje.', + 'required_without_all' => 'Políčko :attribute je povinné ak ani jedno z :values neexistuje.', + 'same' => ':attribute a :other musia byť rovnaké.', + 'size' => [ + 'numeric' => ':attribute musí byť :size.', + 'file' => ':attribute musí mať :size kilobajtov.', + 'string' => ':attribute musí mať :size znakov.', + 'array' => ':attribute musí obsahovať :size položiek.', + ], + 'string' => ':attribute musí byť reťazec.', + 'timezone' => ':attribute musí byť plantá časová zóna.', + 'unique' => ':attribute je už použité.', + 'url' => ':attribute formát je neplatný.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'password-confirm' => [ + 'required_with' => 'Vyžaduje sa potvrdenie hesla', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap attribute place-holders + | with something more reader friendly such as E-Mail Address instead + | of "email". This simply helps us make messages a little cleaner. + | + */ + + 'attributes' => [], + +]; 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 @@ </a> </div> <div class="col-lg-4 col-sm-3 text-center"> - <form action="{{ baseUrl('/search/all') }}" method="GET" class="search-box"> + <form action="{{ baseUrl('/search') }}" method="GET" class="search-box"> <input id="header-search-box-input" type="text" name="term" tabindex="2" value="{{ isset($searchTerm) ? $searchTerm : '' }}"> <button id="header-search-box-button" type="submit" class="text-button"><i class="zmdi zmdi-search"></i></button> </form> 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 @@ </div> - <div class="container" id="book-dashboard" ng-controller="BookShowController" book-id="{{ $book->id }}"> + <div class="container" id="entity-dashboard" entity-id="{{ $book->id }}" entity-type="book"> <div class="row"> <div class="col-md-7"> <h1>{{$book->name}}</h1> - <div class="book-content" ng-show="!searching"> - <p class="text-muted" ng-non-bindable>{{$book->description}}</p> + <div class="book-content" v-if="!searching"> + <p class="text-muted" v-pre>{{$book->description}}</p> - <div class="page-list" ng-non-bindable> + <div class="page-list" v-pre> <hr> @if(count($bookChildren) > 0) @foreach($bookChildren as $childElement) @@ -81,12 +81,12 @@ @include('partials.entity-meta', ['entity' => $book]) </div> </div> - <div class="search-results" ng-cloak ng-show="searching"> - <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> - <div ng-if="!searchResults"> + <div class="search-results" v-cloak v-if="searching"> + <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> + <div v-if="!searchResults"> @include('partials/loading-icon') </div> - <div ng-bind-html="searchResults"></div> + <div v-html="searchResults"></div> </div> @@ -94,6 +94,7 @@ <div class="col-md-4 col-md-offset-1"> <div class="margin-top large"></div> + @if($book->restricted) <p class="text-muted"> @if(userCan('restrictions-manage', $book)) @@ -103,14 +104,16 @@ @endif </p> @endif + <div class="search-box"> - <form ng-submit="searchBook($event)"> - <input ng-model="searchTerm" ng-change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}"> + <form v-on:submit="searchBook"> + <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.books_search_this') }}"> <button type="submit"><i class="zmdi zmdi-search"></i></button> - <button ng-if="searching" ng-click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button> + <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button> </form> </div> - <div class="activity anim fadeIn"> + + <div class="activity"> <h3>{{ trans('entities.recent_activity') }}</h3> @include('partials/activity-list', ['activity' => Activity::entityActivity($book, 20, 0)]) </div> 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 @@ </div> - <div class="container" ng-non-bindable> + <div class="container" id="entity-dashboard" entity-id="{{ $chapter->id }}" entity-type="chapter"> <div class="row"> - <div class="col-md-8"> + <div class="col-md-7"> <h1>{{ $chapter->name }}</h1> - <p class="text-muted">{{ $chapter->description }}</p> + <div class="chapter-content" v-if="!searching"> + <p class="text-muted">{{ $chapter->description }}</p> - @if(count($pages) > 0) - <div class="page-list"> - <hr> - @foreach($pages as $page) - @include('pages/list-item', ['page' => $page]) + @if(count($pages) > 0) + <div class="page-list"> <hr> - @endforeach - </div> - @else - <hr> - <p class="text-muted">{{ trans('entities.chapters_empty') }}</p> - <p> - @if(userCan('page-create', $chapter)) - <a href="{{ $chapter->getUrl('/create-page') }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.books_empty_create_page') }}</a> - @endif - @if(userCan('page-create', $chapter) && userCan('book-update', $book)) - <em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em> - @endif - @if(userCan('book-update', $book)) - <a href="{{ $book->getUrl('/sort') }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.books_empty_sort_current_book') }}</a> - @endif - </p> - <hr> - @endif + @foreach($pages as $page) + @include('pages/list-item', ['page' => $page]) + <hr> + @endforeach + </div> + @else + <hr> + <p class="text-muted">{{ trans('entities.chapters_empty') }}</p> + <p> + @if(userCan('page-create', $chapter)) + <a href="{{ $chapter->getUrl('/create-page') }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.books_empty_create_page') }}</a> + @endif + @if(userCan('page-create', $chapter) && userCan('book-update', $book)) + <em class="text-muted">-{{ trans('entities.books_empty_or') }}-</em> + @endif + @if(userCan('book-update', $book)) + <a href="{{ $book->getUrl('/sort') }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.books_empty_sort_current_book') }}</a> + @endif + </p> + <hr> + @endif - @include('partials.entity-meta', ['entity' => $chapter]) + @include('partials.entity-meta', ['entity' => $chapter]) + </div> + + <div class="search-results" v-cloak v-if="searching"> + <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> + <div v-if="!searchResults"> + @include('partials/loading-icon') + </div> + <div v-html="searchResults"></div> + </div> </div> - <div class="col-md-3 col-md-offset-1"> + <div class="col-md-4 col-md-offset-1"> <div class="margin-top large"></div> @if($book->restricted || $chapter->restricted) <div class="text-muted"> @@ -105,7 +115,16 @@ </div> @endif + <div class="search-box"> + <form v-on:submit="searchBook"> + <input v-model="searchTerm" v-on:change="checkSearchForm()" type="text" name="term" placeholder="{{ trans('entities.chapters_search_this') }}"> + <button type="submit"><i class="zmdi zmdi-search"></i></button> + <button v-if="searching" v-cloak class="text-neg" v-on:click="clearSearch()" type="button"><i class="zmdi zmdi-close"></i></button> + </form> + </div> + @include('pages/sidebar-tree-list', ['book' => $book, 'sidebarTree' => $sidebarTree]) + </div> </div> </div> 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) <div class="tag-display"> - <h6 class="text-muted">Page Tags</h6> + <h6 class="text-muted">{{ trans('entities.page_tags') }}</h6> <table> <tbody> @foreach($page->tags as $tag) <tr class="tag"> - <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search/all?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td> - @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 + <td @if(!$tag->value) colspan="2" @endif><a href="{{ baseUrl('/search?term=%5B' . urlencode($tag->name) .'%5D') }}">{{ $tag->name }}</a></td> + @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 </tr> @endforeach </tbody> 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') + <input type="hidden" name="searchTerm" value="{{$searchTerm}}"> + +<div id="search-system"> + <div class="faded-small toolbar"> <div class="container"> <div class="row"> <div class="col-sm-12 faded"> <div class="breadcrumbs"> - <a href="{{ baseUrl("/search/all?term={$searchTerm}") }}" class="text-button"><i class="zmdi zmdi-search"></i>{{ $searchTerm }}</a> + <a href="{{ baseUrl("/search?term=" . urlencode($searchTerm)) }}" class="text-button"><i class="zmdi zmdi-search"></i>{{ trans('entities.search_for_term', ['term' => $searchTerm]) }}</a> </div> </div> </div> </div> </div> - - <div class="container" ng-non-bindable> - - <h1>{{ trans('entities.search_results') }}</h1> - - <p> - @if(count($pages) > 0) - <a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="text-page"><i class="zmdi zmdi-file-text"></i>{{ trans('entities.search_view_pages') }}</a> - @endif - - @if(count($chapters) > 0) - - <a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="text-chapter"><i class="zmdi zmdi-collection-bookmark"></i>{{ trans('entities.search_view_chapters') }}</a> - @endif - - @if(count($books) > 0) - - <a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="text-book"><i class="zmdi zmdi-book"></i>{{ trans('entities.search_view_books') }}</a> - @endif - </p> + <div class="container" ng-non-bindable id="searchSystem"> <div class="row"> - <div class="col-md-6"> - <h3><a href="{{ baseUrl("/search/pages?term={$searchTerm}") }}" class="no-color">{{ trans('entities.pages') }}</a></h3> - @include('partials/entity-list', ['entities' => $pages, 'style' => 'detailed']) - </div> - <div class="col-md-5 col-md-offset-1"> - @if(count($books) > 0) - <h3><a href="{{ baseUrl("/search/books?term={$searchTerm}") }}" class="no-color">{{ trans('entities.books') }}</a></h3> - @include('partials/entity-list', ['entities' => $books]) - @endif - @if(count($chapters) > 0) - <h3><a href="{{ baseUrl("/search/chapters?term={$searchTerm}") }}" class="no-color">{{ trans('entities.chapters') }}</a></h3> - @include('partials/entity-list', ['entities' => $chapters]) + <div class="col-md-6"> + <h1>{{ trans('entities.search_results') }}</h1> + <h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6> + @include('partials/entity-list', ['entities' => $entities]) + @if ($hasNextPage) + <a href="{{ $nextPageLink }}" class="button">{{ trans('entities.search_more') }}</a> @endif </div> + + <div class="col-md-5 col-md-offset-1"> + <h3>{{ trans('entities.search_filters') }}</h3> + + <form v-on:submit="updateSearch" v-cloak class="v-cloak anim fadeIn"> + <h6 class="text-muted">{{ trans('entities.search_content_type') }}</h6> + <div class="form-group"> + <label class="inline checkbox text-page"><input type="checkbox" v-on:change="typeChange" v-model="search.type.page" value="page">{{ trans('entities.page') }}</label> + <label class="inline checkbox text-chapter"><input type="checkbox" v-on:change="typeChange" v-model="search.type.chapter" value="chapter">{{ trans('entities.chapter') }}</label> + <label class="inline checkbox text-book"><input type="checkbox" v-on:change="typeChange" v-model="search.type.book" value="book">{{ trans('entities.book') }}</label> + </div> + + <h6 class="text-muted">{{ trans('entities.search_exact_matches') }}</h6> + <table cellpadding="0" cellspacing="0" border="0" class="no-style"> + <tr v-for="(term, i) in search.exactTerms"> + <td style="padding: 0 12px 6px 0;"> + <input class="exact-input outline" v-on:input="exactChange" type="text" v-model="search.exactTerms[i]"></td> + <td> + <button type="button" class="text-neg text-button" v-on:click="removeExact(i)"> + <i class="zmdi zmdi-close"></i> + </button> + </td> + </tr> + <tr> + <td colspan="2"> + <button type="button" class="text-button" v-on:click="addExact"> + <i class="zmdi zmdi-plus-circle-o"></i>{{ trans('common.add') }} + </button> + </td> + </tr> + </table> + + <h6 class="text-muted">{{ trans('entities.search_tags') }}</h6> + <table cellpadding="0" cellspacing="0" border="0" class="no-style"> + <tr v-for="(term, i) in search.tagTerms"> + <td style="padding: 0 12px 6px 0;"> + <input class="tag-input outline" v-on:input="tagChange" type="text" v-model="search.tagTerms[i]"></td> + <td> + <button type="button" class="text-neg text-button" v-on:click="removeTag(i)"> + <i class="zmdi zmdi-close"></i> + </button> + </td> + </tr> + <tr> + <td colspan="2"> + <button type="button" class="text-button" v-on:click="addTag"> + <i class="zmdi zmdi-plus-circle-o"></i>{{ trans('common.add') }} + </button> + </td> + </tr> + </table> + + <h6 class="text-muted">Options</h6> + <label class="checkbox"> + <input type="checkbox" v-on:change="optionChange('viewed_by_me')" + v-model="search.option.viewed_by_me" value="page"> + {{ trans('entities.search_viewed_by_me') }} + </label> + <label class="checkbox"> + <input type="checkbox" v-on:change="optionChange('not_viewed_by_me')" + v-model="search.option.not_viewed_by_me" value="page"> + {{ trans('entities.search_not_viewed_by_me') }} + </label> + <label class="checkbox"> + <input type="checkbox" v-on:change="optionChange('is_restricted')" + v-model="search.option.is_restricted" value="page"> + {{ trans('entities.search_permissions_set') }} + </label> + <label class="checkbox"> + <input type="checkbox" v-on:change="optionChange('created_by:me')" + v-model="search.option['created_by:me']" value="page"> + {{ trans('entities.search_created_by_me') }} + </label> + <label class="checkbox"> + <input type="checkbox" v-on:change="optionChange('updated_by:me')" + v-model="search.option['updated_by:me']" value="page"> + {{ trans('entities.search_updated_by_me') }} + </label> + + <h6 class="text-muted">Date Options</h6> + <table cellpadding="0" cellspacing="0" border="0" class="no-style form-table"> + <tr> + <td width="200">{{ trans('entities.search_updated_after') }}</td> + <td width="80"> + <button type="button" class="text-button" v-if="!search.dates.updated_after" + v-on:click="enableDate('updated_after')">{{ trans('entities.search_set_date') }}</button> + + </td> + </tr> + <tr v-if="search.dates.updated_after"> + <td> + <input v-if="search.dates.updated_after" class="tag-input" + v-on:input="dateChange('updated_after')" type="date" v-model="search.dates.updated_after" + pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> + </td> + <td> + <button v-if="search.dates.updated_after" type="button" class="text-neg text-button" + v-on:click="dateRemove('updated_after')"> + <i class="zmdi zmdi-close"></i> + </button> + </td> + </tr> + <tr> + <td>{{ trans('entities.search_updated_before') }}</td> + <td> + <button type="button" class="text-button" v-if="!search.dates.updated_before" + v-on:click="enableDate('updated_before')">{{ trans('entities.search_set_date') }}</button> + + </td> + </tr> + <tr v-if="search.dates.updated_before"> + <td> + <input v-if="search.dates.updated_before" class="tag-input" + v-on:input="dateChange('updated_before')" type="date" v-model="search.dates.updated_before" + pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> + </td> + <td> + <button v-if="search.dates.updated_before" type="button" class="text-neg text-button" + v-on:click="dateRemove('updated_before')"> + <i class="zmdi zmdi-close"></i> + </button> + </td> + </tr> + <tr> + <td>{{ trans('entities.search_created_after') }}</td> + <td> + <button type="button" class="text-button" v-if="!search.dates.created_after" + v-on:click="enableDate('created_after')">{{ trans('entities.search_set_date') }}</button> + + </td> + </tr> + <tr v-if="search.dates.created_after"> + <td> + <input v-if="search.dates.created_after" class="tag-input" + v-on:input="dateChange('created_after')" type="date" v-model="search.dates.created_after" + pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> + </td> + <td> + <button v-if="search.dates.created_after" type="button" class="text-neg text-button" + v-on:click="dateRemove('created_after')"> + <i class="zmdi zmdi-close"></i> + </button> + </td> + </tr> + <tr> + <td>{{ trans('entities.search_created_before') }}</td> + <td> + <button type="button" class="text-button" v-if="!search.dates.created_before" + v-on:click="enableDate('created_before')">{{ trans('entities.search_set_date') }}</button> + + </td> + </tr> + <tr v-if="search.dates.created_before"> + <td> + <input v-if="search.dates.created_before" class="tag-input" + v-on:input="dateChange('created_before')" type="date" v-model="search.dates.created_before" + pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"> + </td> + <td> + <button v-if="search.dates.created_before" type="button" class="text-neg text-button" + v-on:click="dateRemove('created_before')"> + <i class="zmdi zmdi-close"></i> + </button> + </td> + </tr> + </table> + + + <button type="submit" class="button primary">{{ trans('entities.search_update') }}</button> + </form> + + + </div> + </div> </div> - +</div> @stop \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 4b57138e2..2ac212e62 100644 --- a/routes/web.php +++ b/routes/web.php @@ -130,11 +130,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 @@ <?php namespace Tests; -class EntitySearchTest extends BrowserKitTest + +class EntitySearchTest extends TestCase { public function test_page_search() @@ -8,32 +9,35 @@ class EntitySearchTest extends BrowserKitTest $book = \BookStack\Book::all()->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('<p>test</p>', 'term') - ->press('header-search-box-button') - ->see('Search Results') - ->seeStatusCode(200); + $resp = $this->asEditor()->get('/search?term=' . urlencode('<p>test</p>')); + $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