Converted search filters to not be vue based

This commit is contained in:
Dan Brown 2020-06-27 13:29:00 +01:00
parent 76d02cd472
commit 715dee2d0e
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
15 changed files with 399 additions and 475 deletions

View File

@ -0,0 +1,137 @@
<?php namespace BookStack\Entities;
use Illuminate\Http\Request;
class SearchOptions
{
/**
* @var array
*/
public $searches = [];
/**
* @var array
*/
public $exacts = [];
/**
* @var array
*/
public $tags = [];
/**
* @var array
*/
public $filters = [];
/**
* Create a new instance from a search string.
*/
public static function fromString(string $search): SearchOptions
{
$decoded = static::decode($search);
$instance = new static();
foreach ($decoded as $type => $value) {
$instance->$type = $value;
}
return $instance;
}
/**
* Create a new instance from a request.
* Will look for a classic string term and use that
* Otherwise we'll use the details from an advanced search form.
*/
public static function fromRequest(Request $request): SearchOptions
{
if ($request->has('term')) {
return static::fromString($request->get('term'));
}
$instance = new static();
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$instance->searches = explode(' ', $inputs['search'] ?? []);
$instance->exacts = array_filter($inputs['exact'] ?? []);
$instance->tags = array_filter($inputs['tags'] ?? []);
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
if (empty($filterVal)) {
continue;
}
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
}
if (isset($inputs['types']) && count($inputs['types']) < 4) {
$instance->filters['type'] = implode('|', $inputs['types']);
}
return $instance;
}
/**
* Decode a search string into an array of terms.
*/
protected static function decode(string $searchString): array
{
$terms = [
'searches' => [],
'exacts' => [],
'tags' => [],
'filters' => []
];
$patterns = [
'exacts' => '/"(.*?)"/',
'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['searches'][] = $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;
}
/**
* Encode this instance to a search string.
*/
public function toString(): string
{
$string = implode(' ', $this->searches ?? []);
foreach ($this->exacts as $term) {
$string .= ' "' . $term . '"';
}
foreach ($this->tags as $term) {
$string .= " [{$term}]";
}
foreach ($this->filters as $filterName => $filterVal) {
$string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
}
return $string;
}
}

View File

@ -39,10 +39,6 @@ class SearchService
/** /**
* SearchService constructor. * SearchService constructor.
* @param SearchTerm $searchTerm
* @param EntityProvider $entityProvider
* @param Connection $db
* @param PermissionService $permissionService
*/ */
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService) public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
{ {
@ -54,7 +50,6 @@ class SearchService
/** /**
* Set the database connection * Set the database connection
* @param Connection $connection
*/ */
public function setConnection(Connection $connection) public function setConnection(Connection $connection)
{ {
@ -63,23 +58,18 @@ class SearchService
/** /**
* Search all entities in the system. * Search all entities in the system.
* @param string $searchString * The provided count is for each entity to search,
* @param string $entityType * Total returned could can be larger and not guaranteed.
* @param int $page
* @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed.
* @param string $action
* @return array[int, Collection];
*/ */
public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view') public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
{ {
$terms = $this->parseSearchString($searchString);
$entityTypes = array_keys($this->entityProvider->all()); $entityTypes = array_keys($this->entityProvider->all());
$entityTypesToSearch = $entityTypes; $entityTypesToSearch = $entityTypes;
if ($entityType !== 'all') { if ($entityType !== 'all') {
$entityTypesToSearch = $entityType; $entityTypesToSearch = $entityType;
} else if (isset($terms['filters']['type'])) { } else if (isset($searchOpts->filters['type'])) {
$entityTypesToSearch = explode('|', $terms['filters']['type']); $entityTypesToSearch = explode('|', $searchOpts->filters['type']);
} }
$results = collect(); $results = collect();
@ -90,8 +80,8 @@ class SearchService
if (!in_array($entityType, $entityTypes)) { if (!in_array($entityType, $entityTypes)) {
continue; continue;
} }
$search = $this->searchEntityTable($terms, $entityType, $page, $count, $action); $search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true); $entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
if ($entityTotal > $page * $count) { if ($entityTotal > $page * $count) {
$hasMore = true; $hasMore = true;
} }
@ -103,29 +93,26 @@ class SearchService
'total' => $total, 'total' => $total,
'count' => count($results), 'count' => count($results),
'has_more' => $hasMore, 'has_more' => $hasMore,
'results' => $results->sortByDesc('score')->values() 'results' => $results->sortByDesc('score')->values(),
]; ];
} }
/** /**
* Search a book for entities * Search a book for entities
* @param integer $bookId
* @param string $searchString
* @return Collection
*/ */
public function searchBook($bookId, $searchString) public function searchBook(int $bookId, string $searchString): Collection
{ {
$terms = $this->parseSearchString($searchString); $opts = SearchOptions::fromString($searchString);
$entityTypes = ['page', 'chapter']; $entityTypes = ['page', 'chapter'];
$entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes; $entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
$results = collect(); $results = collect();
foreach ($entityTypesToSearch as $entityType) { foreach ($entityTypesToSearch as $entityType) {
if (!in_array($entityType, $entityTypes)) { if (!in_array($entityType, $entityTypes)) {
continue; continue;
} }
$search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get(); $search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$results = $results->merge($search); $results = $results->merge($search);
} }
return $results->sortByDesc('score')->take(20); return $results->sortByDesc('score')->take(20);
@ -133,30 +120,23 @@ class SearchService
/** /**
* Search a book for entities * Search a book for entities
* @param integer $chapterId
* @param string $searchString
* @return Collection
*/ */
public function searchChapter($chapterId, $searchString) public function searchChapter(int $chapterId, string $searchString): Collection
{ {
$terms = $this->parseSearchString($searchString); $opts = SearchOptions::fromString($searchString);
$pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get(); $pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
return $pages->sortByDesc('score'); return $pages->sortByDesc('score');
} }
/** /**
* Search across a particular entity type. * Search across a particular entity type.
* @param array $terms * Setting getCount = true will return the total
* @param string $entityType * matching instead of the items themselves.
* @param int $page
* @param int $count
* @param string $action
* @param bool $getCount Return the total count of the search
* @return \Illuminate\Database\Eloquent\Collection|int|static[] * @return \Illuminate\Database\Eloquent\Collection|int|static[]
*/ */
public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false) public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
{ {
$query = $this->buildEntitySearchQuery($terms, $entityType, $action); $query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
if ($getCount) { if ($getCount) {
return $query->count(); return $query->count();
} }
@ -167,22 +147,18 @@ class SearchService
/** /**
* Create a search query for an entity * Create a search query for an entity
* @param array $terms
* @param string $entityType
* @param string $action
* @return EloquentBuilder
*/ */
protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view') protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
{ {
$entity = $this->entityProvider->get($entityType); $entity = $this->entityProvider->get($entityType);
$entitySelect = $entity->newQuery(); $entitySelect = $entity->newQuery();
// Handle normal search terms // Handle normal search terms
if (count($terms['search']) > 0) { if (count($searchOpts->searches) > 0) {
$subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score')); $subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
$subQuery->where('entity_type', '=', $entity->getMorphClass()); $subQuery->where('entity_type', '=', $entity->getMorphClass());
$subQuery->where(function (Builder $query) use ($terms) { $subQuery->where(function (Builder $query) use ($searchOpts) {
foreach ($terms['search'] as $inputTerm) { foreach ($searchOpts->searches as $inputTerm) {
$query->orWhere('term', 'like', $inputTerm .'%'); $query->orWhere('term', 'like', $inputTerm .'%');
} }
})->groupBy('entity_type', 'entity_id'); })->groupBy('entity_type', 'entity_id');
@ -193,9 +169,9 @@ class SearchService
} }
// Handle exact term matching // Handle exact term matching
if (count($terms['exact']) > 0) { if (count($searchOpts->exacts) > 0) {
$entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) { $entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
foreach ($terms['exact'] as $inputTerm) { foreach ($searchOpts->exacts as $inputTerm) {
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) { $query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
$query->where('name', 'like', '%'.$inputTerm .'%') $query->where('name', 'like', '%'.$inputTerm .'%')
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%'); ->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
@ -205,12 +181,12 @@ class SearchService
} }
// Handle tag searches // Handle tag searches
foreach ($terms['tags'] as $inputTerm) { foreach ($searchOpts->tags as $inputTerm) {
$this->applyTagSearch($entitySelect, $inputTerm); $this->applyTagSearch($entitySelect, $inputTerm);
} }
// Handle filters // Handle filters
foreach ($terms['filters'] as $filterTerm => $filterValue) { foreach ($searchOpts->filters as $filterTerm => $filterValue) {
$functionName = Str::camel('filter_' . $filterTerm); $functionName = Str::camel('filter_' . $filterTerm);
if (method_exists($this, $functionName)) { if (method_exists($this, $functionName)) {
$this->$functionName($entitySelect, $entity, $filterValue); $this->$functionName($entitySelect, $entity, $filterValue);
@ -220,60 +196,10 @@ class SearchService
return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action); return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
} }
/**
* 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. * Get the available query operators as a regex escaped list.
* @return mixed
*/ */
protected function getRegexEscapedOperators() protected function getRegexEscapedOperators(): string
{ {
$escapedOperators = []; $escapedOperators = [];
foreach ($this->queryOperators as $operator) { foreach ($this->queryOperators as $operator) {
@ -284,11 +210,8 @@ class SearchService
/** /**
* Apply a tag search term onto a entity query. * Apply a tag search term onto a entity query.
* @param EloquentBuilder $query
* @param string $tagTerm
* @return mixed
*/ */
protected function applyTagSearch(EloquentBuilder $query, $tagTerm) protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
{ {
preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit); preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) { $query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
@ -318,7 +241,6 @@ class SearchService
/** /**
* Index the given entity. * Index the given entity.
* @param Entity $entity
*/ */
public function indexEntity(Entity $entity) public function indexEntity(Entity $entity)
{ {

View File

@ -6,6 +6,7 @@ use BookStack\Entities\Bookshelf;
use BookStack\Entities\Entity; use BookStack\Entities\Entity;
use BookStack\Entities\Managers\EntityContext; use BookStack\Entities\Managers\EntityContext;
use BookStack\Entities\SearchService; use BookStack\Entities\SearchService;
use BookStack\Entities\SearchOptions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class SearchController extends Controller class SearchController extends Controller
@ -33,20 +34,22 @@ class SearchController extends Controller
*/ */
public function search(Request $request) public function search(Request $request)
{ {
$searchTerm = $request->get('term'); $searchOpts = SearchOptions::fromRequest($request);
$this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm])); $fullSearchString = $searchOpts->toString();
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
$page = intval($request->get('page', '0')) ?: 1; $page = intval($request->get('page', '0')) ?: 1;
$nextPageLink = url('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1)); $nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page+1));
$results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20); $results = $this->searchService->searchEntities($searchOpts, 'all', $page, 20);
return view('search.all', [ return view('search.all', [
'entities' => $results['results'], 'entities' => $results['results'],
'totalResults' => $results['total'], 'totalResults' => $results['total'],
'searchTerm' => $searchTerm, 'searchTerm' => $fullSearchString,
'hasNextPage' => $results['has_more'], 'hasNextPage' => $results['has_more'],
'nextPageLink' => $nextPageLink 'nextPageLink' => $nextPageLink,
'options' => $searchOpts,
]); ]);
} }
@ -84,7 +87,7 @@ class SearchController extends Controller
// Search for entities otherwise show most popular // Search for entities otherwise show most popular
if ($searchTerm !== false) { if ($searchTerm !== false) {
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}'; $searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
$entities = $this->searchService->searchEntities($searchTerm, 'all', 1, 20, $permission)['results']; $entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
} else { } else {
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission); $entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
} }

View File

@ -0,0 +1,31 @@
import {onChildEvent} from "../services/dom";
/**
* AddRemoveRows
* Allows easy row add/remove controls onto a table.
* Needs a model row to use when adding a new row.
* @extends {Component}
*/
class AddRemoveRows {
setup() {
this.modelRow = this.$refs.model;
this.addButton = this.$refs.add;
this.removeSelector = this.$opts.removeSelector;
this.setupListeners();
}
setupListeners() {
this.addButton.addEventListener('click', e => {
const clone = this.modelRow.cloneNode(true);
clone.classList.remove('hidden');
this.modelRow.parentNode.insertBefore(clone, this.modelRow);
});
onChildEvent(this.$el, this.removeSelector, 'click', (e) => {
const row = e.target.closest('tr');
row.remove();
});
}
}
export default AddRemoveRows;

View File

@ -0,0 +1,28 @@
import {onSelect} from "../services/dom";
class OptionalInput {
setup() {
this.removeButton = this.$refs.remove;
this.showButton = this.$refs.show;
this.input = this.$refs.input;
this.setupListeners();
}
setupListeners() {
onSelect(this.removeButton, () => {
this.input.value = '';
this.input.classList.add('hidden');
this.removeButton.classList.add('hidden');
this.showButton.classList.remove('hidden');
});
onSelect(this.showButton, () => {
this.input.classList.remove('hidden');
this.removeButton.classList.remove('hidden');
this.showButton.classList.add('hidden');
});
}
}
export default OptionalInput;

View File

@ -1,193 +0,0 @@
import * as Dates from "../services/dates";
let data = {
terms: '',
termString : '',
search: {
type: {
page: true,
chapter: true,
book: true,
bookshelf: 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 => term.trim() !== '').map(term => `"${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 = type.bookshelf = 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);
type.bookshelf = (splitTypes.indexOf('bookshelf') !== -1);
},
typeChange() {
let typeFilter = /{\s?type:\s?(.*?)\s?}/;
let type = this.search.type;
if (type.page === type.chapter === type.book === type.bookshelf) {
this.termString = this.termString.replace(typeFilter, '');
return;
}
let selectedTypes = Object.keys(type).filter(type => 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 = window.baseUrl('/search?term=' + encodeURIComponent(this.termString));
},
enableDate(optionName) {
this.search.dates[optionName.toLowerCase()] = Dates.getCurrentDay();
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);
}
export default {
data, computed, methods, created
};

View File

@ -4,7 +4,6 @@ function exists(id) {
return document.getElementById(id) !== null; return document.getElementById(id) !== null;
} }
import searchSystem from "./search";
import entityDashboard from "./entity-dashboard"; import entityDashboard from "./entity-dashboard";
import codeEditor from "./code-editor"; import codeEditor from "./code-editor";
import imageManager from "./image-manager"; import imageManager from "./image-manager";
@ -13,7 +12,6 @@ import attachmentManager from "./attachment-manager";
import pageEditor from "./page-editor"; import pageEditor from "./page-editor";
let vueMapping = { let vueMapping = {
'search-system': searchSystem,
'entity-dashboard': entityDashboard, 'entity-dashboard': entityDashboard,
'code-editor': codeEditor, 'code-editor': codeEditor,
'image-manager': imageManager, 'image-manager': imageManager,

View File

@ -47,7 +47,8 @@ return [
'search_no_pages' => 'No pages matched this search', 'search_no_pages' => 'No pages matched this search',
'search_for_term' => 'Search for :term', 'search_for_term' => 'Search for :term',
'search_more' => 'More Results', 'search_more' => 'More Results',
'search_filters' => 'Search Filters', 'search_advanced' => 'Advanced Search',
'search_terms' => 'Search Terms',
'search_content_type' => 'Content Type', 'search_content_type' => 'Content Type',
'search_exact_matches' => 'Exact Matches', 'search_exact_matches' => 'Exact Matches',
'search_tags' => 'Tag Searches', 'search_tags' => 'Tag Searches',

View File

@ -141,7 +141,7 @@ body.flexbox {
} }
.hidden { .hidden {
display: none; display: none !important;
} }
.float { .float {

View File

@ -1,186 +1,62 @@
@extends('simple-layout') @extends('simple-layout')
@section('body') @section('body')
<input type="hidden" name="searchTerm" value="{{$searchTerm}}"> <div class="container mt-xl" id="search-system">
<div class="container" id="search-system">
<div class="my-s">
&nbsp;
</div>
<div class="grid right-focus reverse-collapse gap-xl"> <div class="grid right-focus reverse-collapse gap-xl">
<div> <div>
<div> <div>
<h5>{{ trans('entities.search_filters') }}</h5> <h5>{{ trans('entities.search_advanced') }}</h5>
<form v-on:submit="updateSearch" v-cloak class="v-cloak anim fadeIn"> <form method="get" action="{{ url('/search') }}">
<h6 class="text-muted">{{ trans('entities.search_content_type') }}</h6> <h6>{{ trans('entities.search_terms') }}</h6>
<input type="text" name="search" value="{{ implode(' ', $options->searches) }}">
<h6>{{ trans('entities.search_content_type') }}</h6>
<div class="form-group"> <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> <?php
$types = explode('|', $options->filters['type'] ?? '');
$hasTypes = $types[0] !== '';
?>
@include('search.form.type-filter', ['checked' => !$hasTypes || in_array('page', $types), 'entity' => 'page', 'transKey' => 'page'])
@include('search.form.type-filter', ['checked' => !$hasTypes || in_array('chapter', $types), 'entity' => 'chapter', 'transKey' => 'chapter'])
<br> <br>
<label class="inline checkbox text-book"><input type="checkbox" v-on:change="typeChange" v-model="search.type.book" value="book">{{ trans('entities.book') }}</label> @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('book', $types), 'entity' => 'book', 'transKey' => 'book'])
<label class="inline checkbox text-bookshelf"><input type="checkbox" v-on:change="typeChange" v-model="search.type.bookshelf" value="bookshelf">{{ trans('entities.shelf') }}</label> @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('bookshelf', $types), 'entity' => 'bookshelf', 'transKey' => 'shelf'])
</div> </div>
<h6 class="text-muted">{{ trans('entities.search_exact_matches') }}</h6> <h6>{{ trans('entities.search_exact_matches') }}</h6>
<table cellpadding="0" cellspacing="0" border="0" class="no-style"> @include('search.form.term-list', ['type' => 'exact', 'currentList' => $options->exacts])
<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)">
@icon('close')
</button>
</td>
</tr>
<tr>
<td colspan="2">
<button type="button" class="text-button" v-on:click="addExact">
@icon('add-circle'){{ trans('common.add') }}
</button>
</td>
</tr>
</table>
<h6 class="text-muted">{{ trans('entities.search_tags') }}</h6> <h6>{{ trans('entities.search_tags') }}</h6>
<table cellpadding="0" cellspacing="0" border="0" class="no-style"> @include('search.form.term-list', ['type' => 'tags', 'currentList' => $options->tags])
<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)">
@icon('close')
</button>
</td>
</tr>
<tr>
<td colspan="2">
<button type="button" class="text-button" v-on:click="addTag">
@icon('add-circle'){{ trans('common.add') }}
</button>
</td>
</tr>
</table>
@if(signedInUser()) @if(signedInUser())
<h6 class="text-muted">{{ trans('entities.search_options') }}</h6> <h6>{{ trans('entities.search_options') }}</h6>
<label class="checkbox">
<input type="checkbox" v-on:change="optionChange('viewed_by_me')" @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'viewed_by_me', 'value' => null])
v-model="search.option.viewed_by_me" value="page">
{{ trans('entities.search_viewed_by_me') }} {{ trans('entities.search_viewed_by_me') }}
</label> @endcomponent
<label class="checkbox"> @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'not_viewed_by_me', 'value' => null])
<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') }} {{ trans('entities.search_not_viewed_by_me') }}
</label> @endcomponent
<label class="checkbox"> @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'is_restricted', 'value' => null])
<input type="checkbox" v-on:change="optionChange('is_restricted')"
v-model="search.option.is_restricted" value="page">
{{ trans('entities.search_permissions_set') }} {{ trans('entities.search_permissions_set') }}
</label> @endcomponent
<label class="checkbox"> @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'created_by', 'value' => 'me'])
<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') }} {{ trans('entities.search_created_by_me') }}
</label> @endcomponent
<label class="checkbox"> @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'updated_by', 'value' => 'me'])
<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') }} {{ trans('entities.search_updated_by_me') }}
</label> @endcomponent
@endif @endif
<h6 class="text-muted">{{ trans('entities.search_date_options') }}</h6> <h6>{{ trans('entities.search_date_options') }}</h6>
<table cellpadding="0" cellspacing="0" border="0" class="no-style form-table"> @include('search.form.date-filter', ['name' => 'updated_after', 'filters' => $options->filters])
<tr> @include('search.form.date-filter', ['name' => 'updated_before', 'filters' => $options->filters])
<td width="200">{{ trans('entities.search_updated_after') }}</td> @include('search.form.date-filter', ['name' => 'created_after', 'filters' => $options->filters])
<td width="80"> @include('search.form.date-filter', ['name' => 'created_before', 'filters' => $options->filters])
<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')">
@icon('close')
</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')">
@icon('close')
</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')">
@icon('close')
</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')">
@icon('close')
</button>
</td>
</tr>
</table>
<button type="submit" class="button">{{ trans('entities.search_update') }}</button> <button type="submit" class="button">{{ trans('entities.search_update') }}</button>
</form> </form>
@ -188,17 +64,19 @@
</div> </div>
</div> </div>
<div> <div>
<div v-pre class="card content-wrap"> <div class="card content-wrap">
<h1 class="list-heading">{{ trans('entities.search_results') }}</h1> <h1 class="list-heading">{{ trans('entities.search_results') }}</h1>
<form action="{{ url('/search') }}" method="GET" class="search-box flexible hide-over-l"> <form action="{{ url('/search') }}" method="GET" class="search-box flexible hide-over-l">
<input value="{{$searchTerm}}" type="text" name="term" placeholder="{{ trans('common.search') }}"> <input value="{{$searchTerm}}" type="text" name="term" placeholder="{{ trans('common.search') }}">
<button type="submit">@icon('search')</button> <button type="submit">@icon('search')</button>
<button v-if="searching" v-cloak class="search-box-cancel text-neg" v-on:click="clearSearch" type="button">@icon('close')</button>
</form> </form>
<h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6> <h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6>
<div class="book-contents"> <div class="book-contents">
@include('partials.entity-list', ['entities' => $entities, 'showPath' => true]) @include('partials.entity-list', ['entities' => $entities, 'showPath' => true])
</div> </div>
@if($hasNextPage) @if($hasNextPage)
<div class="text-right mt-m"> <div class="text-right mt-m">
<a href="{{ $nextPageLink }}" class="button outline">{{ trans('entities.search_more') }}</a> <a href="{{ $nextPageLink }}" class="button outline">{{ trans('entities.search_more') }}</a>

View File

@ -0,0 +1,12 @@
{{--
$filters - Array of search filter values
$name - Name of filter to limit use.
$value - Value of filter to use
--}}
<label class="checkbox">
<input type="checkbox"
name="filters[{{ $name }}]"
@if (isset($filters[$name]) && (!$value || ($value && $value === $filters[$name]))) checked="checked" @endif
value="{{ $value ?: 'true' }}">
{{ $slot }}
</label>

View File

@ -0,0 +1,29 @@
{{--
@filters - Active search filters
@name - Name of filter
--}}
<table class="no-style form-table mb-xs">
<tr>
<td width="200">{{ trans('entities.search_' . $name) }}</td>
<td width="80"></td>
</tr>
<tr component="optional-input">
<td>
<button type="button" refs="optional-input@show"
class="text-button {{ ($filters[$name] ?? false) ? 'hidden' : '' }}">{{ trans('entities.search_set_date') }}</button>
<input class="tag-input {{ ($filters[$name] ?? false) ? '' : 'hidden' }}"
refs="optional-input@input"
value="{{ $filters[$name] ?? '' }}"
type="date"
name="filters[{{ $name }}]"
pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
</td>
<td>
<button type="button"
refs="optional-input@remove"
class="text-neg text-button {{ ($filters[$name] ?? false) ? '' : 'hidden' }}">
@icon('close')
</button>
</td>
</tr>
</table>

View File

@ -0,0 +1,25 @@
{{--
@type - Type of term (exact, tag)
@currentList
--}}
<table component="add-remove-rows"
option:add-remove-rows:remove-selector="button.text-neg"
class="no-style">
@foreach(array_merge($currentList, ['']) as $term)
<tr @if(empty($term)) class="hidden" refs="add-remove-rows@model" @endif>
<td class="pb-s pr-m">
<input class="exact-input outline" type="text" name="{{$type}}[]" value="{{ $term }}">
</td>
<td>
<button type="button" class="text-neg text-button">@icon('close')</button>
</td>
</tr>
@endforeach
<tr>
<td colspan="2">
<button refs="add-remove-rows@add" type="button" class="text-button">
@icon('add-circle'){{ trans('common.add') }}
</button>
</td>
</tr>
</table>

View File

@ -0,0 +1,10 @@
{{--
@checked - If the option should be pre-checked
@entity - Entity Name
@transKey - Translation Key
--}}
<label class="inline checkbox text-{{$entity}}">
<input type="checkbox" name="types[]"
@if($checked) checked @endif
value="{{$entity}}">{{ trans('entities.' . $transKey) }}
</label>

View File

@ -0,0 +1,43 @@
<?php namespace Tests\Entity;
use BookStack\Entities\SearchOptions;
use Tests\TestCase;
class SearchOptionsTest extends TestCase
{
public function test_from_string_parses_a_search_string_properly()
{
$options = SearchOptions::fromString('cat "dog" [tag=good] {is_tree}');
$this->assertEquals(['cat'], $options->searches);
$this->assertEquals(['dog'], $options->exacts);
$this->assertEquals(['tag=good'], $options->tags);
$this->assertEquals(['is_tree' => ''], $options->filters);
}
public function test_to_string_includes_all_items_in_the_correct_format()
{
$expected = 'cat "dog" [tag=good] {is_tree}';
$options = new SearchOptions;
$options->searches = ['cat'];
$options->exacts = ['dog'];
$options->tags = ['tag=good'];
$options->filters = ['is_tree' => ''];
$output = $options->toString();
foreach (explode(' ', $expected) as $term) {
$this->assertStringContainsString($term, $output);
}
}
public function test_correct_filter_values_are_set_from_string()
{
$opts = SearchOptions::fromString('{is_tree} {name:dan} {cat:happy}');
$this->assertEquals([
'is_tree' => '',
'name' => 'dan',
'cat' => 'happy',
], $opts->filters);
}
}