X-Git-Url: http://source.bookstackapp.com/bookstack/blobdiff_plain/9135a85de4eef32a91c7a3ee0aa405ed454e5a4c..refs/pull/5681/head:/app/Search/SearchOptions.php diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index 0bf9c3116..bf527d9c3 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -2,26 +2,39 @@ namespace BookStack\Search; +use BookStack\Search\Options\ExactSearchOption; +use BookStack\Search\Options\FilterSearchOption; +use BookStack\Search\Options\SearchOption; +use BookStack\Search\Options\TagSearchOption; +use BookStack\Search\Options\TermSearchOption; use Illuminate\Http\Request; class SearchOptions { - public array $searches = []; - public array $exacts = []; - public array $tags = []; - public array $filters = []; + /** @var SearchOptionSet */ + public SearchOptionSet $searches; + /** @var SearchOptionSet */ + public SearchOptionSet $exacts; + /** @var SearchOptionSet */ + public SearchOptionSet $tags; + /** @var SearchOptionSet */ + public SearchOptionSet $filters; + + public function __construct() + { + $this->searches = new SearchOptionSet(); + $this->exacts = new SearchOptionSet(); + $this->tags = new SearchOptionSet(); + $this->filters = new SearchOptionSet(); + } /** * Create a new instance from a search string. */ public static function fromString(string $search): self { - $decoded = static::decode($search); - $instance = new SearchOptions(); - foreach ($decoded as $type => $value) { - $instance->$type = $value; - } - + $instance = new self(); + $instance->addOptionsFromString($search); return $instance; } @@ -41,46 +54,64 @@ class SearchOptions } $instance = new SearchOptions(); - $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']); + $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']); $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? ''); - $instance->searches = $parsedStandardTerms['terms']; - $instance->exacts = $parsedStandardTerms['exacts']; - - array_push($instance->exacts, ...array_filter($inputs['exact'] ?? [])); - - $instance->tags = array_filter($inputs['tags'] ?? []); + $inputExacts = array_filter($inputs['exact'] ?? []); + $instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']), TermSearchOption::class); + $instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']), ExactSearchOption::class); + $instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts, ExactSearchOption::class)); + $instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []), TagSearchOption::class); + $cleanedFilters = []; foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) { if (empty($filterVal)) { continue; } - $instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal; + $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal; + $cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey); } if (isset($inputs['types']) && count($inputs['types']) < 4) { - $instance->filters['type'] = implode('|', $inputs['types']); + $cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'type'); + } + + $instance->filters = new SearchOptionSet($cleanedFilters); + + // Parse and merge in extras if provided + if (!empty($inputs['extras'])) { + $extras = static::fromString($inputs['extras']); + $instance->searches = $instance->searches->merge($extras->searches); + $instance->exacts = $instance->exacts->merge($extras->exacts); + $instance->tags = $instance->tags->merge($extras->tags); + $instance->filters = $instance->filters->merge($extras->filters); } return $instance; } /** - * Decode a search string into an array of terms. + * Decode a search string and add its contents to this instance. */ - protected static function decode(string $searchString): array + protected function addOptionsFromString(string $searchString): void { + /** @var array $terms */ $terms = [ - 'searches' => [], 'exacts' => [], 'tags' => [], 'filters' => [], ]; $patterns = [ - 'exacts' => '/"(.*?)"/', - 'tags' => '/\[(.*?)\]/', - 'filters' => '/\{(.*?)\}/', + 'exacts' => '/-?"((?:\\\\.|[^"\\\\])*)"/', + 'tags' => '/-?\[(.*?)\]/', + 'filters' => '/-?\{(.*?)\}/', + ]; + + $constructors = [ + 'exacts' => fn(string $value, bool $negated) => new ExactSearchOption($value, $negated), + 'tags' => fn(string $value, bool $negated) => new TagSearchOption($value, $negated), + 'filters' => fn(string $value, bool $negated) => FilterSearchOption::fromContentString($value, $negated), ]; // Parse special terms @@ -88,37 +119,69 @@ class SearchOptions $matches = []; preg_match_all($pattern, $searchString, $matches); if (count($matches) > 0) { - $terms[$termType] = $matches[1]; + foreach ($matches[1] as $index => $value) { + $negated = str_starts_with($matches[0][$index], '-'); + $terms[$termType][] = $constructors[$termType]($value, $negated); + } $searchString = preg_replace($pattern, '', $searchString); } } + // Unescape exacts and backslash escapes + foreach ($terms['exacts'] as $exact) { + $exact->value = static::decodeEscapes($exact->value); + } + // Parse standard terms $parsedStandardTerms = static::parseStandardTermString($searchString); - array_push($terms['searches'], ...$parsedStandardTerms['terms']); - array_push($terms['exacts'], ...$parsedStandardTerms['exacts']); - - // Split filter values out - $splitFilters = []; - foreach ($terms['filters'] as $filter) { - $explodedFilter = explode(':', $filter, 2); - $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : ''; + $this->searches = $this->searches + ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class)) + ->filterEmpty(); + $this->exacts = $this->exacts + ->merge(new SearchOptionSet($terms['exacts'])) + ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class)) + ->filterEmpty(); + + // Add tags & filters + $this->tags = $this->tags->merge(new SearchOptionSet($terms['tags'])); + $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters'])); + } + + /** + * Decode backslash escaping within the input string. + */ + protected static function decodeEscapes(string $input): string + { + $decoded = ""; + $escaping = false; + + foreach (str_split($input) as $char) { + if ($escaping) { + $decoded .= $char; + $escaping = false; + } else if ($char === '\\') { + $escaping = true; + } else { + $decoded .= $char; + } } - $terms['filters'] = $splitFilters; - return $terms; + return $decoded; } /** * Parse a standard search term string into individual search terms and - * extract any exact terms searches to be made. + * convert any required terms to exact matches. This is done since some + * characters will never be in the standard index, since we use them as + * delimiters, and therefore we convert a term to be exact if it + * contains one of those delimiter characters. * * @return array{terms: array, exacts: array} */ protected static function parseStandardTermString(string $termString): array { $terms = explode(' ', $termString); - $indexDelimiters = SearchIndex::$delimiters; + $indexDelimiters = implode('', array_diff(str_split(SearchIndex::$delimiters), str_split(SearchIndex::$softDelimiters))); $parsed = [ 'terms' => [], 'exacts' => [], @@ -129,32 +192,65 @@ class SearchOptions continue; } - $parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts'; - $parsed[$parsedList][] = $searchTerm; + $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false); + $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm; } return $parsed; } + /** + * Set the value of a specific filter in the search options. + */ + public function setFilter(string $filterName, string $filterValue = ''): void + { + $this->filters = $this->filters->merge( + new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)]) + ); + } + /** * Encode this instance to a search string. */ public function toString(): string { - $string = implode(' ', $this->searches ?? []); + $options = [ + ...$this->searches->all(), + ...$this->exacts->all(), + ...$this->tags->all(), + ...$this->filters->all(), + ]; - foreach ($this->exacts as $term) { - $string .= ' "' . $term . '"'; - } + $parts = array_map(fn(SearchOption $o) => $o->toString(), $options); - foreach ($this->tags as $term) { - $string .= " [{$term}]"; - } + return implode(' ', $parts); + } - foreach ($this->filters as $filterName => $filterVal) { - $string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}'; + /** + * Get the search options that don't have UI controls provided for. + * Provided back as a key => value array with the keys being expected + * input names for a search form, and values being the option value. + */ + public function getAdditionalOptionsString(): string + { + $options = []; + + // Handle filters without UI support + $userFilters = ['updated_by', 'created_by', 'owned_by']; + $unsupportedFilters = ['is_template', 'sort_by']; + foreach ($this->filters->all() as $filter) { + if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') { + $options[] = $filter; + } else if (in_array($filter->getKey(), $unsupportedFilters, true)) { + $options[] = $filter; + } } - return $string; + // Negated items + array_push($options, ...$this->exacts->negated()->all()); + array_push($options, ...$this->tags->negated()->all()); + array_push($options, ...$this->filters->negated()->all()); + + return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options)); } }