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";
+ }
+
}
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";
+ }
+
}
*
* @var string
*/
- protected $signature = 'bookstack:regenerate-permissions';
+ protected $signature = 'bookstack:regenerate-permissions {--database= : The database connection to use.}';
/**
* The console command description.
*/
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');
}
}
--- /dev/null
+<?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');
+ }
+}
-<?php
-
-namespace BookStack\Console;
+<?php namespace BookStack\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as 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
];
/**
class Entity extends Ownable
{
- protected $fieldsToSearch = ['name', 'description'];
+ public $textField = 'description';
/**
* Compares this entity to another given entity.
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.
*/
}
/**
- * 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]);
- }
+ return $this->{$this->textField};
+ }
- // Load in relations
- if ($this->isA('page')) {
- $search = $search->with('book', 'chapter', 'createdBy', 'updatedBy');
- } else if ($this->isA('chapter')) {
- $search = $search->with('book');
- }
+ /**
+ * Return a generalised, common raw query that can be 'unioned' across entities.
+ * @return string
+ */
+ public function entityRawQuery(){return '';}
- return $search->orderBy($orderBy, 'desc');
- }
}
<?php namespace BookStack\Http\Controllers;
use BookStack\Repos\EntityRepo;
+use BookStack\Services\SearchService;
use BookStack\Services\ViewService;
use Illuminate\Http\Request;
{
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();
}
* @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]));
- return view('search/all', [
- 'pages' => $pages,
- 'books' => $books,
- 'chapters' => $chapters,
- 'searchTerm' => $searchTerm
- ]);
- }
- /**
- * 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();
+ $page = $request->has('page') && is_int(intval($request->get('page'))) ? intval($request->get('page')) : 1;
+ $nextPageLink = baseUrl('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
- $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();
+ $results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
+ $hasNextPage = $this->searchService->searchEntities($searchTerm, 'all', $page+1, 20)['count'] > 0;
- $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
+ return view('search/all', [
+ 'entities' => $results['results'],
+ 'totalResults' => $results['total'],
+ 'searchTerm' => $searchTerm,
+ 'hasNextPage' => $hasNextPage,
+ 'nextPageLink' => $nextPageLink
]);
}
+
/**
- * Search only the books in the system.
+ * Searches all entities within a book.
* @param Request $request
- * @return \Illuminate\Http\RedirectResponse|\Illuminate\View\View
+ * @param integer $bookId
+ * @return \Illuminate\View\View
+ * @internal param string $searchTerm
*/
- public function searchBooks(Request $request)
+ public function searchBook(Request $request, $bookId)
{
- 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
- ]);
+ $term = $request->get('term', '');
+ $results = $this->searchService->searchBook($bookId, $term);
+ return view('partials/entity-list', ['entities' => $results]);
}
/**
- * Searches all entities within a book.
+ * Searches all entities within a chapter.
* @param Request $request
- * @param integer $bookId
+ * @param integer $chapterId
* @return \Illuminate\View\View
* @internal param string $searchTerm
*/
- public function searchBook(Request $request, $bookId)
+ public function searchChapter(Request $request, $chapterId)
{
- 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->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.
* Returns the most popular entities if no search is provided.
*/
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);
protected $simpleAttributes = ['name', 'id', 'slug'];
protected $with = ['book'];
-
- protected $fieldsToSearch = ['name', 'text'];
+ public $textField = 'text';
/**
* Converts this page into a simplified array.
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";
+ }
+
}
use BookStack\PageRevision;
use BookStack\Services\AttachmentService;
use BookStack\Services\PermissionService;
+use BookStack\Services\SearchService;
use BookStack\Services\ViewService;
use Carbon\Carbon;
use DOMDocument;
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
* @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;
$this->viewService = $viewService;
$this->permissionService = $permissionService;
$this->tagRepo = $tagRepo;
+ $this->searchService = $searchService;
}
/**
* @param int $count
* @param int $page
* @param bool|callable $additionalQuery
+ * @return Collection
*/
public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
{
* @param int $count
* @param int $page
* @param bool|callable $additionalQuery
+ * @return Collection
*/
public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
{
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') {
* 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)
{
->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.
$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.
$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
$entityModel->updated_by = user()->id;
$entityModel->save();
$this->permissionService->buildJointPermissionsForEntity($entityModel);
+ $this->searchService->indexEntity($entityModel);
return $entityModel;
}
$draftPage->save();
$this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
-
+ $this->searchService->indexEntity($draftPage);
return $draftPage;
}
$this->savePageRevision($page, $input['summary']);
}
+ $this->searchService->indexEntity($page);
+
return $page;
}
$page->text = strip_tags($page->html);
$page->updated_by = user()->id;
$page->save();
+ $this->searchService->indexEntity($page);
return $page;
}
$book->views()->delete();
$book->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($book);
+ $this->searchService->deleteEntityTerms($book);
$book->delete();
}
$chapter->views()->delete();
$chapter->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($chapter);
+ $this->searchService->deleteEntityTerms($chapter);
$chapter->delete();
}
$page->revisions()->delete();
$page->permissions()->delete();
$this->permissionService->deleteJointPermissionsForEntity($page);
+ $this->searchService->deleteEntityTerms($page);
// Delete Attached Files
$attachmentService = app(AttachmentService::class);
--- /dev/null
+<?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');
+ }
+
+}
* @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) {
});
}
});
- $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);
* @param string $entityType
* @param Builder|Entity $query
* @param string $action
- * @return mixed
+ * @return Builder
*/
public function enforceEntityRestrictions($entityType, $query, $action = 'view')
{
}
/**
- * 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
--- /dev/null
+<?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
*/
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)");
}
/**
*/
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)");
}
/**
--- /dev/null
+<?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');
+ }
+}
$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){
$book->pages()->saveMany($pages);
});
- $restrictionService = app(\BookStack\Services\PermissionService::class);
- $restrictionService->buildJointPermissions();
+ app(\BookStack\Services\PermissionService::class)->buildJointPermissions();
+ app(\BookStack\Services\SearchService::class)->indexAllEntities();
}
}
-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
{
"private": true,
"scripts": {
- "build": "gulp --production",
- "dev": "gulp watch",
- "watch": "gulp watch"
+ "build": "gulp build",
+ "production": "gulp build --production",
+ "dev": "gulp",
+ "watch": "gulp"
},
"devDependencies": {
+ "babelify": "^7.3.0",
+ "browserify": "^14.3.0",
+ "envify": "^4.0.0",
+ "gulp": "3.9.1",
+ "gulp-autoprefixer": "3.1.1",
+ "gulp-clean-css": "^3.0.4",
+ "gulp-minify-css": "1.2.4",
+ "gulp-plumber": "1.1.0",
+ "gulp-sass": "3.1.0",
+ "gulp-uglify": "2.1.2",
+ "vinyl-buffer": "^1.0.0",
+ "vinyl-source-stream": "^1.1.0",
+ "watchify": "^3.9.0",
+ "yargs": "^7.1.0"
+ },
+ "dependencies": {
"angular": "^1.5.5",
"angular-animate": "^1.5.5",
"angular-resource": "^1.5.5",
"angular-sanitize": "^1.5.5",
- "angular-ui-sortable": "^0.15.0",
+ "angular-ui-sortable": "^0.17.0",
+ "axios": "^0.16.1",
+ "babel-preset-es2015": "^6.24.1",
+ "clipboard": "^1.5.16",
"dropzone": "^4.0.1",
- "gulp": "^3.9.0",
- "laravel-elixir": "^6.0.0-11",
- "laravel-elixir-browserify-official": "^0.1.3",
+ "gulp-util": "^3.0.8",
"marked": "^0.3.5",
- "moment": "^2.12.0"
+ "moment": "^2.12.0",
+ "vue": "^2.2.6"
},
- "dependencies": {
- "clipboard": "^1.5.16"
+ "browser": {
+ "vue": "vue/dist/vue.common.js"
}
}
"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) {
}]);
-
- 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) {
"use strict";
-import DropZone from "dropzone";
-import markdown from "marked";
+const DropZone = require("dropzone");
+const markdown = require("marked");
-export default function (ngApp, events) {
+module.exports = function (ngApp, events) {
/**
* Common tab controls using simple jQuery functions.
"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');
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);
}
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);
}
// Page specific items
-import "./pages/page-show";
+require("./pages/page-show");
editor.addShortcut('meta+shift+E', '', ['FormatBlock', false, 'code']);
}
-export default function() {
+module.exports = function() {
let settings = {
selector: '#html-editor',
content_css: [
}
};
return settings;
-}
\ No newline at end of file
+};
\ No newline at end of file
"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();
});
};
+
+module.exports = setupPageShow;
\ No newline at end of file
}
-export default Translator
+module.exports = Translator;
--- /dev/null
+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
--- /dev/null
+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
--- /dev/null
+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
.anim.fadeIn {
opacity: 0;
animation-name: fadeIn;
- animation-duration: 160ms;
+ animation-duration: 180ms;
animation-timing-function: ease-in-out;
animation-fill-mode: forwards;
}
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;
transition-property: right, border;
border-left: 0px solid #FFF;
background-color: #FFF;
+ max-width: 320px;
&.fixed {
background-color: #FFF;
z-index: 5;
@import "grid";
@import "blocks";
@import "buttons";
-@import "forms";
@import "tables";
+@import "forms";
@import "animations";
@import "tinymce";
@import "highlightjs";
@import "lists";
@import "pages";
-[v-cloak], [v-show] {display: none;}
+[v-cloak], [v-show] {
+ display: none; opacity: 0;
+ animation-name: none !important;
+}
+
[ng\:cloak], [ng-cloak], .ng-cloak {
display: none !important;
-
-
-
-
-
* 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
'search_clear' => 'Clear Search',
'reset' => 'Reset',
'remove' => 'Remove',
+ 'add' => 'Add',
/**
* 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
'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
* 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
* 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
* 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
* 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
</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>
</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)
@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>
<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))
@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>
</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">
</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>
@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>
@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 id="searchSystem">
- <div class="container" ng-non-bindable>
+ <div class="row">
- <h1>{{ trans('entities.search_results') }}</h1>
+ <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>
- <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
+ <div class="col-md-5 col-md-offset-1">
+ <h3>{{ trans('entities.search_filters') }}</h3>
- @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
+ <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>
- @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>
+ <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 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])
- @endif
</div>
+
</div>
</div>
-
+</div>
@stop
\ No newline at end of file
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');
<?php namespace Tests;
-class EntitySearchTest extends BrowserKitTest
+
+class EntitySearchTest extends TestCase
{
public function test_page_search()
$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_book_search()
+ public function test_searching_accents_and_small_terms()
{
- $book = \BookStack\Book::all()->first();
- $page = $book->pages->last();
- $chapter = $book->chapters->last();
+ $page = $this->newPage(['name' => 'My new test quaffleachits', 'html' => 'some áéííúü¿¡ test content {a2 orange dog']);
+ $this->asEditor();
- $this->asAdmin()
- ->visit('/search/book/' . $book->id . '?term=' . urlencode($page->name))
- ->see($page->name)
+ $accentSearch = $this->get('/search?term=' . urlencode('áéíí'));
+ $accentSearch->assertStatus(200)->assertSee($page->name);
- ->visit('/search/book/' . $book->id . '?term=' . urlencode($chapter->name))
- ->see($chapter->name);
+ $smallSearch = $this->get('/search?term=' . urlencode('{a'));
+ $smallSearch->assertStatus(200)->assertSee($page->name);
}
- public function test_empty_book_search_redirects_back()
+ public function test_book_search()
{
$book = \BookStack\Book::all()->first();
- $this->asAdmin()
- ->visit('/books')
- ->visit('/search/book/' . $book->id . '?term=')
- ->seePageIs('/books');
- }
-
-
- 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);
- }
+ $page = $book->pages->last();
+ $chapter = $book->chapters->last();
- 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);
- }
+ $pageTestResp = $this->asEditor()->get('/search/book/' . $book->id . '?term=' . urlencode($page->name));
+ $pageTestResp->assertSee($page->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']);
+ $chapterTestResp = $this->asEditor()->get('/search/book/' . $book->id . '?term=' . urlencode($chapter->name));
+ $chapterTestResp->assertSee($chapter->name);
}
- public function test_books_search_listing()
+ public function test_chapter_search()
{
- $book = \BookStack\Book::all()->last();
- $this->asAdmin()->visit('/search/books?term=' . $book->name)
- ->see('Book Search Results')->see('.entity-list', $book->name);
- }
+ $chapter = \BookStack\Chapter::has('pages')->first();
+ $page = $chapter->pages[0];
- 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()
$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);
+
+ $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']);
- $this->visit('/search/all?term=%5Banimal%3Dcat%5D')
- ->seeLink($pageA->name)
- ->dontSeeLink($pageB->name);
+ $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);
}
}
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