3 namespace BookStack\Search;
5 use BookStack\Search\Options\ExactSearchOption;
6 use BookStack\Search\Options\FilterSearchOption;
7 use BookStack\Search\Options\SearchOption;
8 use BookStack\Search\Options\TagSearchOption;
9 use BookStack\Search\Options\TermSearchOption;
10 use Illuminate\Http\Request;
14 public SearchOptionSet $searches;
15 public SearchOptionSet $exacts;
16 public SearchOptionSet $tags;
17 public SearchOptionSet $filters;
19 public function __construct()
21 $this->searches = new SearchOptionSet();
22 $this->exacts = new SearchOptionSet();
23 $this->tags = new SearchOptionSet();
24 $this->filters = new SearchOptionSet();
28 * Create a new instance from a search string.
30 public static function fromString(string $search): self
32 $instance = new self();
33 $instance->addOptionsFromString($search);
38 * Create a new instance from a request.
39 * Will look for a classic string term and use that
40 * Otherwise we'll use the details from an advanced search form.
42 public static function fromRequest(Request $request): self
44 if (!$request->has('search') && !$request->has('term')) {
45 return static::fromString('');
48 if ($request->has('term')) {
49 return static::fromString($request->get('term'));
52 $instance = new SearchOptions();
53 $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']);
55 $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
56 $inputExacts = array_filter($inputs['exact'] ?? []);
57 $instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']), TermSearchOption::class);
58 $instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']), ExactSearchOption::class);
59 $instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts, ExactSearchOption::class));
60 $instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []), TagSearchOption::class);
63 foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
64 if (empty($filterVal)) {
67 $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
68 $cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey);
71 if (isset($inputs['types']) && count($inputs['types']) < 4) {
72 $cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'types');
75 $instance->filters = new SearchOptionSet($cleanedFilters);
77 // Parse and merge in extras if provided
78 if (!empty($inputs['extras'])) {
79 $extras = static::fromString($inputs['extras']);
80 $instance->searches = $instance->searches->merge($extras->searches);
81 $instance->exacts = $instance->exacts->merge($extras->exacts);
82 $instance->tags = $instance->tags->merge($extras->tags);
83 $instance->filters = $instance->filters->merge($extras->filters);
90 * Decode a search string and add its contents to this instance.
92 protected function addOptionsFromString(string $searchString): void
94 /** @var array<string, SearchOption[]> $terms */
102 'exacts' => '/-?"((?:\\\\.|[^"\\\\])*)"/',
103 'tags' => '/-?\[(.*?)\]/',
104 'filters' => '/-?\{(.*?)\}/',
108 'exacts' => fn(string $value, bool $negated) => new ExactSearchOption($value, $negated),
109 'tags' => fn(string $value, bool $negated) => new TagSearchOption($value, $negated),
110 'filters' => fn(string $value, bool $negated) => FilterSearchOption::fromContentString($value, $negated),
113 // Parse special terms
114 foreach ($patterns as $termType => $pattern) {
116 preg_match_all($pattern, $searchString, $matches);
117 if (count($matches) > 0) {
118 foreach ($matches[1] as $index => $value) {
119 $negated = str_starts_with($matches[0][$index], '-');
120 $terms[$termType][] = $constructors[$termType]($value, $negated);
122 $searchString = preg_replace($pattern, '', $searchString);
126 // Unescape exacts and backslash escapes
127 foreach ($terms['exacts'] as $exact) {
128 $exact->value = static::decodeEscapes($exact->value);
131 // Parse standard terms
132 $parsedStandardTerms = static::parseStandardTermString($searchString);
133 $this->searches = $this->searches
134 ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class))
136 $this->exacts = $this->exacts
137 ->merge(new SearchOptionSet($terms['exacts']))
138 ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class))
141 // Add tags & filters
142 $this->tags = $this->tags->merge(new SearchOptionSet($terms['tags']));
143 $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
147 * Decode backslash escaping within the input string.
149 protected static function decodeEscapes(string $input): string
154 foreach (str_split($input) as $char) {
158 } else if ($char === '\\') {
169 * Parse a standard search term string into individual search terms and
170 * convert any required terms to exact matches. This is done since some
171 * characters will never be in the standard index, since we use them as
172 * delimiters, and therefore we convert a term to be exact if it
173 * contains one of those delimiter characters.
175 * @return array{terms: array<string>, exacts: array<string>}
177 protected static function parseStandardTermString(string $termString): array
179 $terms = explode(' ', $termString);
180 $indexDelimiters = SearchIndex::$delimiters;
186 foreach ($terms as $searchTerm) {
187 if ($searchTerm === '') {
191 $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
192 $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
199 * Set the value of a specific filter in the search options.
201 public function setFilter(string $filterName, string $filterValue = ''): void
203 $this->filters = $this->filters->merge(
204 new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)])
209 * Encode this instance to a search string.
211 public function toString(): string
214 ...$this->searches->all(),
215 ...$this->exacts->all(),
216 ...$this->tags->all(),
217 ...$this->filters->all(),
220 $parts = array_map(fn(SearchOption $o) => $o->toString(), $options);
222 return implode(' ', $parts);
226 * Get the search options that don't have UI controls provided for.
227 * Provided back as a key => value array with the keys being expected
228 * input names for a search form, and values being the option value.
230 public function getAdditionalOptionsString(): string
234 // Non-[created/updated]-by-me options
235 $userFilters = ['updated_by', 'created_by', 'owned_by'];
236 foreach ($this->filters->all() as $filter) {
237 if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') {
238 $options[] = $filter;
243 array_push($options, ...$this->exacts->negated());
244 array_push($options, ...$this->tags->negated());
245 array_push($options, ...$this->filters->negated());
247 return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options));