BookStack/app/Search/SearchOptions.php
Dan Brown 177cfd72bf
Search: Added structure for search term inputs
Sets things up to allow more complex terms ready to handle negation.
2024-10-02 17:31:45 +01:00

241 lines
7.8 KiB
PHP

<?php
namespace BookStack\Search;
use Illuminate\Http\Request;
class SearchOptions
{
public SearchOptionSet $searches;
public SearchOptionSet $exacts;
public SearchOptionSet $tags;
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
{
$instance = new self();
$instance->addOptionsFromString($search);
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): self
{
if (!$request->has('search') && !$request->has('term')) {
return static::fromString('');
}
if ($request->has('term')) {
return static::fromString($request->get('term'));
}
$instance = new SearchOptions();
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
$inputExacts = array_filter($inputs['exact'] ?? []);
$instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']));
$instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']));
$instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts));
$instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []));
$keyedFilters = [];
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
if (empty($filterVal)) {
continue;
}
$cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
$keyedFilters[$filterKey] = new SearchOption($cleanedFilterVal);
}
if (isset($inputs['types']) && count($inputs['types']) < 4) {
$keyedFilters['type'] = new SearchOption(implode('|', $inputs['types']));
}
$instance->filters = new SearchOptionSet($keyedFilters);
return $instance;
}
/**
* Decode a search string and add its contents to this instance.
*/
protected function addOptionsFromString(string $searchString): void
{
/** @var array<string, string[]> $terms */
$terms = [
'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);
}
}
// Unescape exacts and backslash escapes
$escapedExacts = array_map(fn(string $term) => static::decodeEscapes($term), $terms['exacts']);
// Parse standard terms
$parsedStandardTerms = static::parseStandardTermString($searchString);
$this->searches = $this->searches
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms']))
->filterEmpty();
$this->exacts = $this->exacts
->merge(SearchOptionSet::fromValueArray($escapedExacts))
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts']))
->filterEmpty();
// Add tags
$this->tags = $this->tags->merge(SearchOptionSet::fromValueArray($terms['tags']));
// Split filter values out
/** @var array<string, SearchOption> $splitFilters */
$splitFilters = [];
foreach ($terms['filters'] as $filter) {
$explodedFilter = explode(':', $filter, 2);
$filterValue = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
$splitFilters[$explodedFilter[0]] = new SearchOption($filterValue);
}
$this->filters = $this->filters->merge(new SearchOptionSet($splitFilters));
}
/**
* 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;
}
}
return $decoded;
}
/**
* Parse a standard search term string into individual search terms and
* 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<string>, exacts: array<string>}
*/
protected static function parseStandardTermString(string $termString): array
{
$terms = explode(' ', $termString);
$indexDelimiters = SearchIndex::$delimiters;
$parsed = [
'terms' => [],
'exacts' => [],
];
foreach ($terms as $searchTerm) {
if ($searchTerm === '') {
continue;
}
$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([$filterName => new SearchOption($filterValue)])
);
}
/**
* Encode this instance to a search string.
*/
public function toString(): string
{
$parts = $this->searches->toValueArray();
foreach ($this->exacts->toValueArray() as $term) {
$escaped = str_replace('\\', '\\\\', $term);
$escaped = str_replace('"', '\"', $escaped);
$parts[] = '"' . $escaped . '"';
}
foreach ($this->tags->toValueArray() as $term) {
$parts[] = "[{$term}]";
}
foreach ($this->filters->toValueMap() as $filterName => $filterVal) {
$parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
}
return implode(' ', $parts);
}
/**
* 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.
*
* @return array<string, string>
*/
public function getHiddenInputValuesByFieldName(): array
{
$options = [];
// Non-[created/updated]-by-me options
$filterMap = $this->filters->toValueMap();
foreach (['updated_by', 'created_by', 'owned_by'] as $filter) {
$value = $filterMap[$filter] ?? null;
if ($value !== null && $value !== 'me') {
$options["filters[$filter]"] = $value;
}
}
// TODO - Negated
return $options;
}
}