]> BookStack Code Mirror - bookstack/blob - app/Search/SearchOptions.php
Default templates: Added page picker and working forms
[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 array $searches = [];
10     public array $exacts = [];
11     public array $tags = [];
12     public array $filters = [];
13
14     /**
15      * Create a new instance from a search string.
16      */
17     public static function fromString(string $search): self
18     {
19         $decoded = static::decode($search);
20         $instance = new SearchOptions();
21         foreach ($decoded as $type => $value) {
22             $instance->$type = $value;
23         }
24
25         return $instance;
26     }
27
28     /**
29      * Create a new instance from a request.
30      * Will look for a classic string term and use that
31      * Otherwise we'll use the details from an advanced search form.
32      */
33     public static function fromRequest(Request $request): self
34     {
35         if (!$request->has('search') && !$request->has('term')) {
36             return static::fromString('');
37         }
38
39         if ($request->has('term')) {
40             return static::fromString($request->get('term'));
41         }
42
43         $instance = new SearchOptions();
44         $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
45
46         $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
47         $instance->searches = array_filter($parsedStandardTerms['terms']);
48         $instance->exacts = array_filter($parsedStandardTerms['exacts']);
49
50         array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
51
52         $instance->tags = array_filter($inputs['tags'] ?? []);
53
54         foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
55             if (empty($filterVal)) {
56                 continue;
57             }
58             $instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
59         }
60
61         if (isset($inputs['types']) && count($inputs['types']) < 4) {
62             $instance->filters['type'] = implode('|', $inputs['types']);
63         }
64
65         return $instance;
66     }
67
68     /**
69      * Decode a search string into an array of terms.
70      */
71     protected static function decode(string $searchString): array
72     {
73         $terms = [
74             'searches' => [],
75             'exacts'   => [],
76             'tags'     => [],
77             'filters'  => [],
78         ];
79
80         $patterns = [
81             'exacts'  => '/"((?:\\\\.|[^"\\\\])*)"/',
82             'tags'    => '/\[(.*?)\]/',
83             'filters' => '/\{(.*?)\}/',
84         ];
85
86         // Parse special terms
87         foreach ($patterns as $termType => $pattern) {
88             $matches = [];
89             preg_match_all($pattern, $searchString, $matches);
90             if (count($matches) > 0) {
91                 $terms[$termType] = $matches[1];
92                 $searchString = preg_replace($pattern, '', $searchString);
93             }
94         }
95
96         // Unescape exacts and backslash escapes
97         foreach ($terms['exacts'] as $index => $exact) {
98             $terms['exacts'][$index] = static::decodeEscapes($exact);
99         }
100
101         // Parse standard terms
102         $parsedStandardTerms = static::parseStandardTermString($searchString);
103         array_push($terms['searches'], ...$parsedStandardTerms['terms']);
104         array_push($terms['exacts'], ...$parsedStandardTerms['exacts']);
105
106         // Split filter values out
107         $splitFilters = [];
108         foreach ($terms['filters'] as $filter) {
109             $explodedFilter = explode(':', $filter, 2);
110             $splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
111         }
112         $terms['filters'] = $splitFilters;
113
114         // Filter down terms where required
115         $terms['exacts'] = array_filter($terms['exacts']);
116         $terms['searches'] = array_filter($terms['searches']);
117
118         return $terms;
119     }
120
121     /**
122      * Decode backslash escaping within the input string.
123      */
124     protected static function decodeEscapes(string $input): string
125     {
126         $decoded = "";
127         $escaping = false;
128
129         foreach (str_split($input) as $char) {
130             if ($escaping) {
131                 $decoded .= $char;
132                 $escaping = false;
133             } else if ($char === '\\') {
134                 $escaping = true;
135             } else {
136                 $decoded .= $char;
137             }
138         }
139
140         return $decoded;
141     }
142
143     /**
144      * Parse a standard search term string into individual search terms and
145      * convert any required terms to exact matches. This is done since some
146      * characters will never be in the standard index, since we use them as
147      * delimiters, and therefore we convert a term to be exact if it
148      * contains one of those delimiter characters.
149      *
150      * @return array{terms: array<string>, exacts: array<string>}
151      */
152     protected static function parseStandardTermString(string $termString): array
153     {
154         $terms = explode(' ', $termString);
155         $indexDelimiters = SearchIndex::$delimiters;
156         $parsed = [
157             'terms'  => [],
158             'exacts' => [],
159         ];
160
161         foreach ($terms as $searchTerm) {
162             if ($searchTerm === '') {
163                 continue;
164             }
165
166             $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
167             $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
168         }
169
170         return $parsed;
171     }
172
173     /**
174      * Set the value of a specific filter in the search options.
175      */
176     public function setFilter(string $filterName, string $filterValue = ''): void
177     {
178         $this->filters[$filterName] = $filterValue;
179     }
180
181     /**
182      * Encode this instance to a search string.
183      */
184     public function toString(): string
185     {
186         $parts = $this->searches;
187
188         foreach ($this->exacts as $term) {
189             $escaped = str_replace('\\', '\\\\', $term);
190             $escaped = str_replace('"', '\"', $escaped);
191             $parts[] = '"' . $escaped . '"';
192         }
193
194         foreach ($this->tags as $term) {
195             $parts[] = "[{$term}]";
196         }
197
198         foreach ($this->filters as $filterName => $filterVal) {
199             $parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
200         }
201
202         return implode(' ', $parts);
203     }
204 }