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 /** @var SearchOptionSet<TermSearchOption> */
15 public SearchOptionSet $searches;
16 /** @var SearchOptionSet<ExactSearchOption> */
17 public SearchOptionSet $exacts;
18 /** @var SearchOptionSet<TagSearchOption> */
19 public SearchOptionSet $tags;
20 /** @var SearchOptionSet<FilterSearchOption> */
21 public SearchOptionSet $filters;
23 public function __construct()
25 $this->searches = new SearchOptionSet();
26 $this->exacts = new SearchOptionSet();
27 $this->tags = new SearchOptionSet();
28 $this->filters = new SearchOptionSet();
32 * Create a new instance from a search string.
34 public static function fromString(string $search): self
36 $instance = new self();
37 $instance->addOptionsFromString($search);
42 * Create a new instance from a request.
43 * Will look for a classic string term and use that
44 * Otherwise we'll use the details from an advanced search form.
46 public static function fromRequest(Request $request): self
48 if (!$request->has('search') && !$request->has('term')) {
49 return static::fromString('');
52 if ($request->has('term')) {
53 return static::fromString($request->get('term'));
56 $instance = new SearchOptions();
57 $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']);
59 $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
60 $inputExacts = array_filter($inputs['exact'] ?? []);
61 $instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']), TermSearchOption::class);
62 $instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']), ExactSearchOption::class);
63 $instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts, ExactSearchOption::class));
64 $instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []), TagSearchOption::class);
67 foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
68 if (empty($filterVal)) {
71 $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
72 $cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey);
75 if (isset($inputs['types']) && count($inputs['types']) < 4) {
76 $cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'type');
79 $instance->filters = new SearchOptionSet($cleanedFilters);
81 // Parse and merge in extras if provided
82 if (!empty($inputs['extras'])) {
83 $extras = static::fromString($inputs['extras']);
84 $instance->searches = $instance->searches->merge($extras->searches);
85 $instance->exacts = $instance->exacts->merge($extras->exacts);
86 $instance->tags = $instance->tags->merge($extras->tags);
87 $instance->filters = $instance->filters->merge($extras->filters);
94 * Decode a search string and add its contents to this instance.
96 protected function addOptionsFromString(string $searchString): void
98 /** @var array<string, SearchOption[]> $terms */
106 'exacts' => '/-?"((?:\\\\.|[^"\\\\])*)"/',
107 'tags' => '/-?\[(.*?)\]/',
108 'filters' => '/-?\{(.*?)\}/',
112 'exacts' => fn(string $value, bool $negated) => new ExactSearchOption($value, $negated),
113 'tags' => fn(string $value, bool $negated) => new TagSearchOption($value, $negated),
114 'filters' => fn(string $value, bool $negated) => FilterSearchOption::fromContentString($value, $negated),
117 // Parse special terms
118 foreach ($patterns as $termType => $pattern) {
120 preg_match_all($pattern, $searchString, $matches);
121 if (count($matches) > 0) {
122 foreach ($matches[1] as $index => $value) {
123 $negated = str_starts_with($matches[0][$index], '-');
124 $terms[$termType][] = $constructors[$termType]($value, $negated);
126 $searchString = preg_replace($pattern, '', $searchString);
130 // Unescape exacts and backslash escapes
131 foreach ($terms['exacts'] as $exact) {
132 $exact->value = static::decodeEscapes($exact->value);
135 // Parse standard terms
136 $parsedStandardTerms = static::parseStandardTermString($searchString);
137 $this->searches = $this->searches
138 ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class))
140 $this->exacts = $this->exacts
141 ->merge(new SearchOptionSet($terms['exacts']))
142 ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class))
145 // Add tags & filters
146 $this->tags = $this->tags->merge(new SearchOptionSet($terms['tags']));
147 $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
151 * Decode backslash escaping within the input string.
153 protected static function decodeEscapes(string $input): string
158 foreach (str_split($input) as $char) {
162 } else if ($char === '\\') {
173 * Parse a standard search term string into individual search terms and
174 * convert any required terms to exact matches. This is done since some
175 * characters will never be in the standard index, since we use them as
176 * delimiters, and therefore we convert a term to be exact if it
177 * contains one of those delimiter characters.
179 * @return array{terms: array<string>, exacts: array<string>}
181 protected static function parseStandardTermString(string $termString): array
183 $terms = explode(' ', $termString);
184 $indexDelimiters = implode('', array_diff(str_split(SearchIndex::$delimiters), str_split(SearchIndex::$softDelimiters)));
190 foreach ($terms as $searchTerm) {
191 if ($searchTerm === '') {
195 $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
196 $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
203 * Set the value of a specific filter in the search options.
205 public function setFilter(string $filterName, string $filterValue = ''): void
207 $this->filters = $this->filters->merge(
208 new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)])
213 * Encode this instance to a search string.
215 public function toString(): string
218 ...$this->searches->all(),
219 ...$this->exacts->all(),
220 ...$this->tags->all(),
221 ...$this->filters->all(),
224 $parts = array_map(fn(SearchOption $o) => $o->toString(), $options);
226 return implode(' ', $parts);
230 * Get the search options that don't have UI controls provided for.
231 * Provided back as a key => value array with the keys being expected
232 * input names for a search form, and values being the option value.
234 public function getAdditionalOptionsString(): string
238 // Handle filters without UI support
239 $userFilters = ['updated_by', 'created_by', 'owned_by'];
240 $unsupportedFilters = ['is_template', 'sort_by'];
241 foreach ($this->filters->all() as $filter) {
242 if (in_array($filter->getKey(), $userFilters, true) && $filter->value !== null && $filter->value !== 'me') {
243 $options[] = $filter;
244 } else if (in_array($filter->getKey(), $unsupportedFilters, true)) {
245 $options[] = $filter;
250 array_push($options, ...$this->exacts->negated()->all());
251 array_push($options, ...$this->tags->negated()->all());
252 array_push($options, ...$this->filters->negated()->all());
254 return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options));