]> BookStack Code Mirror - bookstack/blob - app/Search/SearchOptions.php
Search: Added structure for search term inputs
[bookstack] / app / Search / SearchOptions.php
1 <?php
2
3 namespace BookStack\Search;
4
5 use Illuminate\Http\Request;
6
7 class SearchOptions
8 {
9     public SearchOptionSet $searches;
10     public SearchOptionSet $exacts;
11     public SearchOptionSet $tags;
12     public SearchOptionSet $filters;
13
14     public function __construct()
15     {
16         $this->searches = new SearchOptionSet();
17         $this->exacts = new SearchOptionSet();
18         $this->tags = new SearchOptionSet();
19         $this->filters = new SearchOptionSet();
20     }
21
22     /**
23      * Create a new instance from a search string.
24      */
25     public static function fromString(string $search): self
26     {
27         $instance = new self();
28         $instance->addOptionsFromString($search);
29         return $instance;
30     }
31
32     /**
33      * Create a new instance from a request.
34      * Will look for a classic string term and use that
35      * Otherwise we'll use the details from an advanced search form.
36      */
37     public static function fromRequest(Request $request): self
38     {
39         if (!$request->has('search') && !$request->has('term')) {
40             return static::fromString('');
41         }
42
43         if ($request->has('term')) {
44             return static::fromString($request->get('term'));
45         }
46
47         $instance = new SearchOptions();
48         $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
49
50         $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
51         $inputExacts = array_filter($inputs['exact'] ?? []);
52         $instance->searches = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['terms']));
53         $instance->exacts = SearchOptionSet::fromValueArray(array_filter($parsedStandardTerms['exacts']));
54         $instance->exacts = $instance->exacts->merge(SearchOptionSet::fromValueArray($inputExacts));
55         $instance->tags = SearchOptionSet::fromValueArray(array_filter($inputs['tags'] ?? []));
56
57         $keyedFilters = [];
58         foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
59             if (empty($filterVal)) {
60                 continue;
61             }
62             $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
63             $keyedFilters[$filterKey] = new SearchOption($cleanedFilterVal);
64         }
65
66         if (isset($inputs['types']) && count($inputs['types']) < 4) {
67             $keyedFilters['type'] = new SearchOption(implode('|', $inputs['types']));
68         }
69
70         $instance->filters = new SearchOptionSet($keyedFilters);
71
72         return $instance;
73     }
74
75     /**
76      * Decode a search string and add its contents to this instance.
77      */
78     protected function addOptionsFromString(string $searchString): void
79     {
80         /** @var array<string, string[]> $terms */
81         $terms = [
82             'exacts'   => [],
83             'tags'     => [],
84             'filters'  => [],
85         ];
86
87         $patterns = [
88             'exacts'  => '/"((?:\\\\.|[^"\\\\])*)"/',
89             'tags'    => '/\[(.*?)\]/',
90             'filters' => '/\{(.*?)\}/',
91         ];
92
93         // Parse special terms
94         foreach ($patterns as $termType => $pattern) {
95             $matches = [];
96             preg_match_all($pattern, $searchString, $matches);
97             if (count($matches) > 0) {
98                 $terms[$termType] = $matches[1];
99                 $searchString = preg_replace($pattern, '', $searchString);
100             }
101         }
102
103         // Unescape exacts and backslash escapes
104         $escapedExacts = array_map(fn(string $term) => static::decodeEscapes($term), $terms['exacts']);
105
106         // Parse standard terms
107         $parsedStandardTerms = static::parseStandardTermString($searchString);
108         $this->searches = $this->searches
109             ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms']))
110             ->filterEmpty();
111         $this->exacts = $this->exacts
112             ->merge(SearchOptionSet::fromValueArray($escapedExacts))
113             ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts']))
114             ->filterEmpty();
115
116         // Add tags
117         $this->tags = $this->tags->merge(SearchOptionSet::fromValueArray($terms['tags']));
118
119         // Split filter values out
120         /** @var array<string, SearchOption> $splitFilters */
121         $splitFilters = [];
122         foreach ($terms['filters'] as $filter) {
123             $explodedFilter = explode(':', $filter, 2);
124             $filterValue = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
125             $splitFilters[$explodedFilter[0]] = new SearchOption($filterValue);
126         }
127         $this->filters = $this->filters->merge(new SearchOptionSet($splitFilters));
128     }
129
130     /**
131      * Decode backslash escaping within the input string.
132      */
133     protected static function decodeEscapes(string $input): string
134     {
135         $decoded = "";
136         $escaping = false;
137
138         foreach (str_split($input) as $char) {
139             if ($escaping) {
140                 $decoded .= $char;
141                 $escaping = false;
142             } else if ($char === '\\') {
143                 $escaping = true;
144             } else {
145                 $decoded .= $char;
146             }
147         }
148
149         return $decoded;
150     }
151
152     /**
153      * Parse a standard search term string into individual search terms and
154      * convert any required terms to exact matches. This is done since some
155      * characters will never be in the standard index, since we use them as
156      * delimiters, and therefore we convert a term to be exact if it
157      * contains one of those delimiter characters.
158      *
159      * @return array{terms: array<string>, exacts: array<string>}
160      */
161     protected static function parseStandardTermString(string $termString): array
162     {
163         $terms = explode(' ', $termString);
164         $indexDelimiters = SearchIndex::$delimiters;
165         $parsed = [
166             'terms'  => [],
167             'exacts' => [],
168         ];
169
170         foreach ($terms as $searchTerm) {
171             if ($searchTerm === '') {
172                 continue;
173             }
174
175             $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
176             $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
177         }
178
179         return $parsed;
180     }
181
182     /**
183      * Set the value of a specific filter in the search options.
184      */
185     public function setFilter(string $filterName, string $filterValue = ''): void
186     {
187         $this->filters = $this->filters->merge(
188             new SearchOptionSet([$filterName => new SearchOption($filterValue)])
189         );
190     }
191
192     /**
193      * Encode this instance to a search string.
194      */
195     public function toString(): string
196     {
197         $parts = $this->searches->toValueArray();
198
199         foreach ($this->exacts->toValueArray() as $term) {
200             $escaped = str_replace('\\', '\\\\', $term);
201             $escaped = str_replace('"', '\"', $escaped);
202             $parts[] = '"' . $escaped . '"';
203         }
204
205         foreach ($this->tags->toValueArray() as $term) {
206             $parts[] = "[{$term}]";
207         }
208
209         foreach ($this->filters->toValueMap() as $filterName => $filterVal) {
210             $parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
211         }
212
213         return implode(' ', $parts);
214     }
215
216     /**
217      * Get the search options that don't have UI controls provided for.
218      * Provided back as a key => value array with the keys being expected
219      * input names for a search form, and values being the option value.
220      *
221      * @return array<string, string>
222      */
223     public function getHiddenInputValuesByFieldName(): array
224     {
225         $options = [];
226
227         // Non-[created/updated]-by-me options
228         $filterMap = $this->filters->toValueMap();
229         foreach (['updated_by', 'created_by', 'owned_by'] as $filter) {
230             $value = $filterMap[$filter] ?? null;
231             if ($value !== null && $value !== 'me') {
232                 $options["filters[$filter]"] = $value;
233             }
234         }
235
236         // TODO - Negated
237
238         return $options;
239     }
240 }