--- /dev/null
+<?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;
+ }
+
+}
\ No newline at end of file
/**
* 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)
{
/**
* Set the database connection
- * @param Connection $connection
*/
public function setConnection(Connection $connection)
{
/**
* Search all entities in the system.
- * @param string $searchString
- * @param string $entityType
- * @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];
+ * The provided count is for each entity to search,
+ * Total returned could can be larger and not guaranteed.
*/
- 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());
$entityTypesToSearch = $entityTypes;
if ($entityType !== 'all') {
$entityTypesToSearch = $entityType;
- } else if (isset($terms['filters']['type'])) {
- $entityTypesToSearch = explode('|', $terms['filters']['type']);
+ } else if (isset($searchOpts->filters['type'])) {
+ $entityTypesToSearch = explode('|', $searchOpts->filters['type']);
}
$results = collect();
if (!in_array($entityType, $entityTypes)) {
continue;
}
- $search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
- $entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
+ $search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
+ $entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
if ($entityTotal > $page * $count) {
$hasMore = true;
}
'total' => $total,
'count' => count($results),
'has_more' => $hasMore,
- 'results' => $results->sortByDesc('score')->values()
+ 'results' => $results->sortByDesc('score')->values(),
];
}
/**
* 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'];
- $entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
+ $entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->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();
+ $search = $this->buildEntitySearchQuery($opts, $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)
+ public function searchChapter(int $chapterId, string $searchString): Collection
{
- $terms = $this->parseSearchString($searchString);
- $pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
+ $opts = SearchOptions::fromString($searchString);
+ $pages = $this->buildEntitySearchQuery($opts, '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 string $action
- * @param bool $getCount Return the total count of the search
+ * Setting getCount = true will return the total
+ * matching instead of the items themselves.
* @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) {
return $query->count();
}
/**
* 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);
$entitySelect = $entity->newQuery();
// 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->where('entity_type', '=', $entity->getMorphClass());
- $subQuery->where(function (Builder $query) use ($terms) {
- foreach ($terms['search'] as $inputTerm) {
+ $subQuery->where(function (Builder $query) use ($searchOpts) {
+ foreach ($searchOpts->searches as $inputTerm) {
$query->orWhere('term', 'like', $inputTerm .'%');
}
})->groupBy('entity_type', 'entity_id');
}
// Handle exact term matching
- if (count($terms['exact']) > 0) {
- $entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
- foreach ($terms['exact'] as $inputTerm) {
+ if (count($searchOpts->exacts) > 0) {
+ $entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
+ foreach ($searchOpts->exacts as $inputTerm) {
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
$query->where('name', 'like', '%'.$inputTerm .'%')
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
}
// Handle tag searches
- foreach ($terms['tags'] as $inputTerm) {
+ foreach ($searchOpts->tags as $inputTerm) {
$this->applyTagSearch($entitySelect, $inputTerm);
}
// Handle filters
- foreach ($terms['filters'] as $filterTerm => $filterValue) {
+ foreach ($searchOpts->filters as $filterTerm => $filterValue) {
$functionName = Str::camel('filter_' . $filterTerm);
if (method_exists($this, $functionName)) {
$this->$functionName($entitySelect, $entity, $filterValue);
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.
- * @return mixed
*/
- protected function getRegexEscapedOperators()
+ protected function getRegexEscapedOperators(): string
{
$escapedOperators = [];
foreach ($this->queryOperators as $operator) {
/**
* 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);
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
/**
* Index the given entity.
- * @param Entity $entity
*/
public function indexEntity(Entity $entity)
{
use BookStack\Entities\Entity;
use BookStack\Entities\Managers\EntityContext;
use BookStack\Entities\SearchService;
+use BookStack\Entities\SearchOptions;
use Illuminate\Http\Request;
class SearchController extends Controller
*/
public function search(Request $request)
{
- $searchTerm = $request->get('term');
- $this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
+ $searchOpts = SearchOptions::fromRequest($request);
+ $fullSearchString = $searchOpts->toString();
+ $this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
$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', [
'entities' => $results['results'],
'totalResults' => $results['total'],
- 'searchTerm' => $searchTerm,
+ 'searchTerm' => $fullSearchString,
'hasNextPage' => $results['has_more'],
- 'nextPageLink' => $nextPageLink
+ 'nextPageLink' => $nextPageLink,
+ 'options' => $searchOpts,
]);
}
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
$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 {
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
}
--- /dev/null
+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;
\ No newline at end of file
--- /dev/null
+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;
\ No newline at end of file
+++ /dev/null
-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
-};
return document.getElementById(id) !== null;
}
-import searchSystem from "./search";
import entityDashboard from "./entity-dashboard";
import codeEditor from "./code-editor";
import imageManager from "./image-manager";
import pageEditor from "./page-editor";
let vueMapping = {
- 'search-system': searchSystem,
'entity-dashboard': entityDashboard,
'code-editor': codeEditor,
'image-manager': imageManager,
'search_no_pages' => 'No pages matched this search',
'search_for_term' => 'Search for :term',
'search_more' => 'More Results',
- 'search_filters' => 'Search Filters',
+ 'search_advanced' => 'Advanced Search',
+ 'search_terms' => 'Search Terms',
'search_content_type' => 'Content Type',
'search_exact_matches' => 'Exact Matches',
'search_tags' => 'Tag Searches',
}
.hidden {
- display: none;
+ display: none !important;
}
.float {
@extends('simple-layout')
@section('body')
- <input type="hidden" name="searchTerm" value="{{$searchTerm}}">
-
- <div class="container" id="search-system">
-
- <div class="my-s">
-
- </div>
+ <div class="container mt-xl" id="search-system">
<div class="grid right-focus reverse-collapse gap-xl">
<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">
- <h6 class="text-muted">{{ trans('entities.search_content_type') }}</h6>
+ <form method="get" action="{{ url('/search') }}">
+ <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">
- <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>
- <label class="inline checkbox text-book"><input type="checkbox" v-on:change="typeChange" v-model="search.type.book" value="book">{{ trans('entities.book') }}</label>
- <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('book', $types), 'entity' => 'book', 'transKey' => 'book'])
+ @include('search.form.type-filter', ['checked' => !$hasTypes || in_array('bookshelf', $types), 'entity' => 'bookshelf', 'transKey' => 'shelf'])
</div>
- <h6 class="text-muted">{{ trans('entities.search_exact_matches') }}</h6>
- <table cellpadding="0" cellspacing="0" border="0" class="no-style">
- <tr v-for="(term, i) in search.exactTerms">
- <td style="padding: 0 12px 6px 0;">
- <input class="exact-input outline" v-on:input="exactChange" type="text" v-model="search.exactTerms[i]"></td>
- <td>
- <button type="button" class="text-neg text-button" v-on:click="removeExact(i)">
- @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>
- <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)">
- @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>
+ <h6>{{ trans('entities.search_exact_matches') }}</h6>
+ @include('search.form.term-list', ['type' => 'exact', 'currentList' => $options->exacts])
+
+ <h6>{{ trans('entities.search_tags') }}</h6>
+ @include('search.form.term-list', ['type' => 'tags', 'currentList' => $options->tags])
@if(signedInUser())
- <h6 class="text-muted">{{ trans('entities.search_options') }}</h6>
- <label class="checkbox">
- <input type="checkbox" v-on:change="optionChange('viewed_by_me')"
- v-model="search.option.viewed_by_me" value="page">
+ <h6>{{ trans('entities.search_options') }}</h6>
+
+ @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'viewed_by_me', 'value' => null])
{{ 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">
+ @endcomponent
+ @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'not_viewed_by_me', 'value' => null])
{{ 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">
+ @endcomponent
+ @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'is_restricted', 'value' => null])
{{ 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">
+ @endcomponent
+ @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'created_by', 'value' => 'me'])
{{ 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">
+ @endcomponent
+ @component('search.form.boolean-filter', ['filters' => $options->filters, 'name' => 'updated_by', 'value' => 'me'])
{{ trans('entities.search_updated_by_me') }}
- </label>
+ @endcomponent
@endif
- <h6 class="text-muted">{{ trans('entities.search_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')">
- @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>
-
+ <h6>{{ trans('entities.search_date_options') }}</h6>
+ @include('search.form.date-filter', ['name' => 'updated_after', 'filters' => $options->filters])
+ @include('search.form.date-filter', ['name' => 'updated_before', 'filters' => $options->filters])
+ @include('search.form.date-filter', ['name' => 'created_after', 'filters' => $options->filters])
+ @include('search.form.date-filter', ['name' => 'created_before', 'filters' => $options->filters])
<button type="submit" class="button">{{ trans('entities.search_update') }}</button>
</form>
</div>
</div>
<div>
- <div v-pre class="card content-wrap">
+ <div class="card content-wrap">
<h1 class="list-heading">{{ trans('entities.search_results') }}</h1>
+
<form action="{{ url('/search') }}" method="GET" class="search-box flexible hide-over-l">
<input value="{{$searchTerm}}" type="text" name="term" placeholder="{{ trans('common.search') }}">
<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>
+
<h6 class="text-muted">{{ trans_choice('entities.search_total_results_found', $totalResults, ['count' => $totalResults]) }}</h6>
<div class="book-contents">
@include('partials.entity-list', ['entities' => $entities, 'showPath' => true])
</div>
+
@if($hasNextPage)
<div class="text-right mt-m">
<a href="{{ $nextPageLink }}" class="button outline">{{ trans('entities.search_more') }}</a>
--- /dev/null
+{{--
+$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>
\ No newline at end of file
--- /dev/null
+{{--
+@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>
\ No newline at end of file
--- /dev/null
+{{--
+@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>
\ No newline at end of file
--- /dev/null
+{{--
+@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>
\ No newline at end of file
--- /dev/null
+<?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);
+ }
+}