*/ 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 { $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', 'extras']); $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? ''); $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; } $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal; $cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey); } if (isset($inputs['types']) && count($inputs['types']) < 4) { $cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'types'); } $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 and add its contents to this instance. */ protected function addOptionsFromString(string $searchString): void { /** @var array $terms */ $terms = [ 'exacts' => [], 'tags' => [], 'filters' => [], ]; $patterns = [ '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 foreach ($patterns as $termType => $pattern) { $matches = []; preg_match_all($pattern, $searchString, $matches); if (count($matches) > 0) { 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); $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; } } 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, exacts: array} */ 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([new FilterSearchOption($filterValue, $filterName)]) ); } /** * Encode this instance to a search string. */ public function toString(): string { $options = [ ...$this->searches->all(), ...$this->exacts->all(), ...$this->tags->all(), ...$this->filters->all(), ]; $parts = array_map(fn(SearchOption $o) => $o->toString(), $options); 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. */ public function getAdditionalOptionsString(): string { $options = []; // Non-[created/updated]-by-me options $userFilters = ['updated_by', 'created_by', 'owned_by']; foreach ($this->filters->all() as $filter) { if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') { $options[] = $filter; } } // 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)); } }