3 namespace BookStack\Search;
5 use Illuminate\Http\Request;
9 public SearchOptionSet $searches;
10 public SearchOptionSet $exacts;
11 public SearchOptionSet $tags;
12 public SearchOptionSet $filters;
14 public function __construct()
16 $this->searches = new SearchOptionSet();
17 $this->exacts = new SearchOptionSet();
18 $this->tags = new SearchOptionSet();
19 $this->filters = new SearchOptionSet();
23 * Create a new instance from a search string.
25 public static function fromString(string $search): self
27 $instance = new self();
28 $instance->addOptionsFromString($search);
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.
37 public static function fromRequest(Request $request): self
39 if (!$request->has('search') && !$request->has('term')) {
40 return static::fromString('');
43 if ($request->has('term')) {
44 return static::fromString($request->get('term'));
47 $instance = new SearchOptions();
48 $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
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'] ?? []));
58 foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
59 if (empty($filterVal)) {
62 $cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
63 $keyedFilters[$filterKey] = new SearchOption($cleanedFilterVal);
66 if (isset($inputs['types']) && count($inputs['types']) < 4) {
67 $keyedFilters['type'] = new SearchOption(implode('|', $inputs['types']));
70 $instance->filters = new SearchOptionSet($keyedFilters);
76 * Decode a search string and add its contents to this instance.
78 protected function addOptionsFromString(string $searchString): void
80 /** @var array<string, string[]> $terms */
88 'exacts' => '/"((?:\\\\.|[^"\\\\])*)"/',
89 'tags' => '/\[(.*?)\]/',
90 'filters' => '/\{(.*?)\}/',
93 // Parse special terms
94 foreach ($patterns as $termType => $pattern) {
96 preg_match_all($pattern, $searchString, $matches);
97 if (count($matches) > 0) {
98 $terms[$termType] = $matches[1];
99 $searchString = preg_replace($pattern, '', $searchString);
103 // Unescape exacts and backslash escapes
104 $escapedExacts = array_map(fn(string $term) => static::decodeEscapes($term), $terms['exacts']);
106 // Parse standard terms
107 $parsedStandardTerms = static::parseStandardTermString($searchString);
108 $this->searches = $this->searches
109 ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms']))
111 $this->exacts = $this->exacts
112 ->merge(SearchOptionSet::fromValueArray($escapedExacts))
113 ->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts']))
117 $this->tags = $this->tags->merge(SearchOptionSet::fromValueArray($terms['tags']));
119 // Split filter values out
120 /** @var array<string, SearchOption> $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);
127 $this->filters = $this->filters->merge(new SearchOptionSet($splitFilters));
131 * Decode backslash escaping within the input string.
133 protected static function decodeEscapes(string $input): string
138 foreach (str_split($input) as $char) {
142 } else if ($char === '\\') {
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.
159 * @return array{terms: array<string>, exacts: array<string>}
161 protected static function parseStandardTermString(string $termString): array
163 $terms = explode(' ', $termString);
164 $indexDelimiters = SearchIndex::$delimiters;
170 foreach ($terms as $searchTerm) {
171 if ($searchTerm === '') {
175 $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
176 $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
183 * Set the value of a specific filter in the search options.
185 public function setFilter(string $filterName, string $filterValue = ''): void
187 $this->filters = $this->filters->merge(
188 new SearchOptionSet([$filterName => new SearchOption($filterValue)])
193 * Encode this instance to a search string.
195 public function toString(): string
197 $parts = $this->searches->toValueArray();
199 foreach ($this->exacts->toValueArray() as $term) {
200 $escaped = str_replace('\\', '\\\\', $term);
201 $escaped = str_replace('"', '\"', $escaped);
202 $parts[] = '"' . $escaped . '"';
205 foreach ($this->tags->toValueArray() as $term) {
206 $parts[] = "[{$term}]";
209 foreach ($this->filters->toValueMap() as $filterName => $filterVal) {
210 $parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
213 return implode(' ', $parts);
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.
221 * @return array<string, string>
223 public function getHiddenInputValuesByFieldName(): array
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;