]> BookStack Code Mirror - bookstack/blob - app/Search/SearchOptions.php
Searching: Added custom tokenizer that considers soft delimiters.
[bookstack] / app / Search / SearchOptions.php
1 <?php
2
3 namespace BookStack\Search;
4
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;
11
12 class SearchOptions
13 {
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;
22
23     public function __construct()
24     {
25         $this->searches = new SearchOptionSet();
26         $this->exacts = new SearchOptionSet();
27         $this->tags = new SearchOptionSet();
28         $this->filters = new SearchOptionSet();
29     }
30
31     /**
32      * Create a new instance from a search string.
33      */
34     public static function fromString(string $search): self
35     {
36         $instance = new self();
37         $instance->addOptionsFromString($search);
38         return $instance;
39     }
40
41     /**
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.
45      */
46     public static function fromRequest(Request $request): self
47     {
48         if (!$request->has('search') && !$request->has('term')) {
49             return static::fromString('');
50         }
51
52         if ($request->has('term')) {
53             return static::fromString($request->get('term'));
54         }
55
56         $instance = new SearchOptions();
57         $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']);
58
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);
65
66         $cleanedFilters = [];
67         foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
68             if (empty($filterVal)) {
69                 continue;
70             }
71             $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
72             $cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey);
73         }
74
75         if (isset($inputs['types']) && count($inputs['types']) < 4) {
76             $cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'type');
77         }
78
79         $instance->filters = new SearchOptionSet($cleanedFilters);
80
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);
88         }
89
90         return $instance;
91     }
92
93     /**
94      * Decode a search string and add its contents to this instance.
95      */
96     protected function addOptionsFromString(string $searchString): void
97     {
98         /** @var array<string, SearchOption[]> $terms */
99         $terms = [
100             'exacts'   => [],
101             'tags'     => [],
102             'filters'  => [],
103         ];
104
105         $patterns = [
106             'exacts'  => '/-?"((?:\\\\.|[^"\\\\])*)"/',
107             'tags'    => '/-?\[(.*?)\]/',
108             'filters' => '/-?\{(.*?)\}/',
109         ];
110
111         $constructors = [
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),
115         ];
116
117         // Parse special terms
118         foreach ($patterns as $termType => $pattern) {
119             $matches = [];
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);
125                 }
126                 $searchString = preg_replace($pattern, '', $searchString);
127             }
128         }
129
130         // Unescape exacts and backslash escapes
131         foreach ($terms['exacts'] as $exact) {
132             $exact->value = static::decodeEscapes($exact->value);
133         }
134
135         // Parse standard terms
136         $parsedStandardTerms = static::parseStandardTermString($searchString);
137         $this->searches = $this->searches
138             ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class))
139             ->filterEmpty();
140         $this->exacts = $this->exacts
141             ->merge(new SearchOptionSet($terms['exacts']))
142             ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class))
143             ->filterEmpty();
144
145         // Add tags & filters
146         $this->tags = $this->tags->merge(new SearchOptionSet($terms['tags']));
147         $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
148     }
149
150     /**
151      * Decode backslash escaping within the input string.
152      */
153     protected static function decodeEscapes(string $input): string
154     {
155         $decoded = "";
156         $escaping = false;
157
158         foreach (str_split($input) as $char) {
159             if ($escaping) {
160                 $decoded .= $char;
161                 $escaping = false;
162             } else if ($char === '\\') {
163                 $escaping = true;
164             } else {
165                 $decoded .= $char;
166             }
167         }
168
169         return $decoded;
170     }
171
172     /**
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.
178      *
179      * @return array{terms: array<string>, exacts: array<string>}
180      */
181     protected static function parseStandardTermString(string $termString): array
182     {
183         $terms = explode(' ', $termString);
184         $indexDelimiters = implode('', array_diff(str_split(SearchIndex::$delimiters), str_split(SearchIndex::$softDelimiters)));
185         $parsed = [
186             'terms'  => [],
187             'exacts' => [],
188         ];
189
190         foreach ($terms as $searchTerm) {
191             if ($searchTerm === '') {
192                 continue;
193             }
194
195             $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
196             $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
197         }
198
199         return $parsed;
200     }
201
202     /**
203      * Set the value of a specific filter in the search options.
204      */
205     public function setFilter(string $filterName, string $filterValue = ''): void
206     {
207         $this->filters = $this->filters->merge(
208             new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)])
209         );
210     }
211
212     /**
213      * Encode this instance to a search string.
214      */
215     public function toString(): string
216     {
217         $options = [
218             ...$this->searches->all(),
219             ...$this->exacts->all(),
220             ...$this->tags->all(),
221             ...$this->filters->all(),
222         ];
223
224         $parts = array_map(fn(SearchOption $o) => $o->toString(), $options);
225
226         return implode(' ', $parts);
227     }
228
229     /**
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.
233      */
234     public function getAdditionalOptionsString(): string
235     {
236         $options = [];
237
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;
246             }
247         }
248
249         // Negated items
250         array_push($options, ...$this->exacts->negated()->all());
251         array_push($options, ...$this->tags->negated()->all());
252         array_push($options, ...$this->filters->negated()->all());
253
254         return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options));
255     }
256 }