]> BookStack Code Mirror - bookstack/blob - app/Search/SearchOptions.php
Searching: Added negation support to UI and term handling
[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     public SearchOptionSet $searches;
15     public SearchOptionSet $exacts;
16     public SearchOptionSet $tags;
17     public SearchOptionSet $filters;
18
19     public function __construct()
20     {
21         $this->searches = new SearchOptionSet();
22         $this->exacts = new SearchOptionSet();
23         $this->tags = new SearchOptionSet();
24         $this->filters = new SearchOptionSet();
25     }
26
27     /**
28      * Create a new instance from a search string.
29      */
30     public static function fromString(string $search): self
31     {
32         $instance = new self();
33         $instance->addOptionsFromString($search);
34         return $instance;
35     }
36
37     /**
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.
41      */
42     public static function fromRequest(Request $request): self
43     {
44         if (!$request->has('search') && !$request->has('term')) {
45             return static::fromString('');
46         }
47
48         if ($request->has('term')) {
49             return static::fromString($request->get('term'));
50         }
51
52         $instance = new SearchOptions();
53         $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']);
54
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);
61
62         $cleanedFilters = [];
63         foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
64             if (empty($filterVal)) {
65                 continue;
66             }
67             $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
68             $cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey);
69         }
70
71         if (isset($inputs['types']) && count($inputs['types']) < 4) {
72             $cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'types');
73         }
74
75         $instance->filters = new SearchOptionSet($cleanedFilters);
76
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);
84         }
85
86         return $instance;
87     }
88
89     /**
90      * Decode a search string and add its contents to this instance.
91      */
92     protected function addOptionsFromString(string $searchString): void
93     {
94         /** @var array<string, SearchOption[]> $terms */
95         $terms = [
96             'exacts'   => [],
97             'tags'     => [],
98             'filters'  => [],
99         ];
100
101         $patterns = [
102             'exacts'  => '/-?"((?:\\\\.|[^"\\\\])*)"/',
103             'tags'    => '/-?\[(.*?)\]/',
104             'filters' => '/-?\{(.*?)\}/',
105         ];
106
107         $constructors = [
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),
111         ];
112
113         // Parse special terms
114         foreach ($patterns as $termType => $pattern) {
115             $matches = [];
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);
121                 }
122                 $searchString = preg_replace($pattern, '', $searchString);
123             }
124         }
125
126         // Unescape exacts and backslash escapes
127         foreach ($terms['exacts'] as $exact) {
128             $exact->value = static::decodeEscapes($exact->value);
129         }
130
131         // Parse standard terms
132         $parsedStandardTerms = static::parseStandardTermString($searchString);
133         $this->searches = $this->searches
134             ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class))
135             ->filterEmpty();
136         $this->exacts = $this->exacts
137             ->merge(new SearchOptionSet($terms['exacts']))
138             ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class))
139             ->filterEmpty();
140
141         // Add tags & filters
142         $this->tags = $this->tags->merge(new SearchOptionSet($terms['tags']));
143         $this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
144     }
145
146     /**
147      * Decode backslash escaping within the input string.
148      */
149     protected static function decodeEscapes(string $input): string
150     {
151         $decoded = "";
152         $escaping = false;
153
154         foreach (str_split($input) as $char) {
155             if ($escaping) {
156                 $decoded .= $char;
157                 $escaping = false;
158             } else if ($char === '\\') {
159                 $escaping = true;
160             } else {
161                 $decoded .= $char;
162             }
163         }
164
165         return $decoded;
166     }
167
168     /**
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.
174      *
175      * @return array{terms: array<string>, exacts: array<string>}
176      */
177     protected static function parseStandardTermString(string $termString): array
178     {
179         $terms = explode(' ', $termString);
180         $indexDelimiters = SearchIndex::$delimiters;
181         $parsed = [
182             'terms'  => [],
183             'exacts' => [],
184         ];
185
186         foreach ($terms as $searchTerm) {
187             if ($searchTerm === '') {
188                 continue;
189             }
190
191             $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
192             $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
193         }
194
195         return $parsed;
196     }
197
198     /**
199      * Set the value of a specific filter in the search options.
200      */
201     public function setFilter(string $filterName, string $filterValue = ''): void
202     {
203         $this->filters = $this->filters->merge(
204             new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)])
205         );
206     }
207
208     /**
209      * Encode this instance to a search string.
210      */
211     public function toString(): string
212     {
213         $options = [
214             ...$this->searches->all(),
215             ...$this->exacts->all(),
216             ...$this->tags->all(),
217             ...$this->filters->all(),
218         ];
219
220         $parts = array_map(fn(SearchOption $o) => $o->toString(), $options);
221
222         return implode(' ', $parts);
223     }
224
225     /**
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.
229      */
230     public function getAdditionalOptionsString(): string
231     {
232         $options = [];
233
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;
239             }
240         }
241
242         // Negated items
243         array_push($options, ...$this->exacts->negated());
244         array_push($options, ...$this->tags->negated());
245         array_push($options, ...$this->filters->negated());
246
247         return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options));
248     }
249 }