Skip to content

Commit 93c677a

Browse files
committed
Searching: Added negation support to UI and term handling
Updated/added tests to cover. Support for actual search queries still remains.
1 parent 177cfd7 commit 93c677a

File tree

11 files changed

+252
-96
lines changed

11 files changed

+252
-96
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
namespace BookStack\Search\Options;
4+
5+
class ExactSearchOption extends SearchOption
6+
{
7+
public function toString(): string
8+
{
9+
$escaped = str_replace('\\', '\\\\', $this->value);
10+
$escaped = str_replace('"', '\"', $escaped);
11+
return ($this->negated ? '-' : '') . '"' . $escaped . '"';
12+
}
13+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace BookStack\Search\Options;
4+
5+
class FilterSearchOption extends SearchOption
6+
{
7+
protected string $name;
8+
9+
public function __construct(
10+
string $value,
11+
string $name,
12+
bool $negated = false,
13+
) {
14+
parent::__construct($value, $negated);
15+
$this->name = $name;
16+
}
17+
18+
public function toString(): string
19+
{
20+
$valueText = ($this->value ? ':' . $this->value : '');
21+
$filterBrace = '{' . $this->name . $valueText . '}';
22+
return ($this->negated ? '-' : '') . $filterBrace;
23+
}
24+
25+
public function getKey(): string
26+
{
27+
return $this->name;
28+
}
29+
30+
public static function fromContentString(string $value, bool $negated = false): self
31+
{
32+
$explodedFilter = explode(':', $value, 2);
33+
$filterValue = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
34+
$filterName = $explodedFilter[0];
35+
return new self($filterValue, $filterName, $negated);
36+
}
37+
}

app/Search/Options/SearchOption.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace BookStack\Search\Options;
4+
5+
abstract class SearchOption
6+
{
7+
public function __construct(
8+
public string $value,
9+
public bool $negated = false,
10+
) {
11+
}
12+
13+
/**
14+
* Get the key used for this option when used in a map.
15+
* Null indicates to use the index of the containing array.
16+
*/
17+
public function getKey(): string|null
18+
{
19+
return null;
20+
}
21+
22+
/**
23+
* Get the search string representation for this search option.
24+
*/
25+
abstract public function toString(): string;
26+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace BookStack\Search\Options;
4+
5+
class TagSearchOption extends SearchOption
6+
{
7+
public function toString(): string
8+
{
9+
return ($this->negated ? '-' : '') . "[{$this->value}]";
10+
}
11+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace BookStack\Search\Options;
4+
5+
class TermSearchOption extends SearchOption
6+
{
7+
public function toString(): string
8+
{
9+
return $this->value;
10+
}
11+
}

app/Search/SearchOption.php

Lines changed: 0 additions & 12 deletions
This file was deleted.

app/Search/SearchOptionSet.php

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22

33
namespace BookStack\Search;
44

5+
use BookStack\Search\Options\SearchOption;
6+
57
class SearchOptionSet
68
{
79
/**
810
* @var SearchOption[]
911
*/
10-
public array $options = [];
12+
protected array $options = [];
1113

1214
public function __construct(array $options = [])
1315
{
@@ -22,7 +24,8 @@ public function toValueArray(): array
2224
public function toValueMap(): array
2325
{
2426
$map = [];
25-
foreach ($this->options as $key => $option) {
27+
foreach ($this->options as $index => $option) {
28+
$key = $option->getKey() ?? $index;
2629
$map[$key] = $option->value;
2730
}
2831
return $map;
@@ -35,22 +38,32 @@ public function merge(SearchOptionSet $set): self
3538

3639
public function filterEmpty(): self
3740
{
38-
$filteredOptions = array_filter($this->options, fn (SearchOption $option) => !empty($option->value));
41+
$filteredOptions = array_values(array_filter($this->options, fn (SearchOption $option) => !empty($option->value)));
3942
return new self($filteredOptions);
4043
}
4144

42-
public static function fromValueArray(array $values): self
45+
/**
46+
* @param class-string<SearchOption> $class
47+
*/
48+
public static function fromValueArray(array $values, string $class): self
4349
{
44-
$options = array_map(fn($val) => new SearchOption($val), $values);
50+
$options = array_map(fn($val) => new $class($val), $values);
4551
return new self($options);
4652
}
4753

48-
public static function fromMapArray(array $values): self
54+
/**
55+
* @return SearchOption[]
56+
*/
57+
public function all(): array
4958
{
50-
$options = [];
51-
foreach ($values as $key => $value) {
52-
$options[$key] = new SearchOption($value);
53-
}
54-
return new self($options);
59+
return $this->options;
60+
}
61+
62+
/**
63+
* @return SearchOption[]
64+
*/
65+
public function negated(): array
66+
{
67+
return array_values(array_filter($this->options, fn (SearchOption $option) => $option->negated));
5568
}
5669
}

app/Search/SearchOptions.php

Lines changed: 64 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
namespace BookStack\Search;
44

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;
510
use Illuminate\Http\Request;
611

712
class SearchOptions
@@ -45,29 +50,38 @@ public static function fromRequest(Request $request): self
4550
}
4651

4752
$instance = new SearchOptions();
48-
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
53+
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags', 'extras']);
4954

5055
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
5156
$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'] ?? []));
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);
5661

57-
$keyedFilters = [];
62+
$cleanedFilters = [];
5863
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
5964
if (empty($filterVal)) {
6065
continue;
6166
}
6267
$cleanedFilterVal = $filterVal === 'true' ? '' : $filterVal;
63-
$keyedFilters[$filterKey] = new SearchOption($cleanedFilterVal);
68+
$cleanedFilters[] = new FilterSearchOption($cleanedFilterVal, $filterKey);
6469
}
6570

6671
if (isset($inputs['types']) && count($inputs['types']) < 4) {
67-
$keyedFilters['type'] = new SearchOption(implode('|', $inputs['types']));
72+
$cleanedFilters[] = new FilterSearchOption(implode('|', $inputs['types']), 'types');
6873
}
6974

70-
$instance->filters = new SearchOptionSet($keyedFilters);
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+
}
7185

7286
return $instance;
7387
}
@@ -77,54 +91,56 @@ public static function fromRequest(Request $request): self
7791
*/
7892
protected function addOptionsFromString(string $searchString): void
7993
{
80-
/** @var array<string, string[]> $terms */
94+
/** @var array<string, SearchOption[]> $terms */
8195
$terms = [
8296
'exacts' => [],
8397
'tags' => [],
8498
'filters' => [],
8599
];
86100

87101
$patterns = [
88-
'exacts' => '/"((?:\\\\.|[^"\\\\])*)"/',
89-
'tags' => '/\[(.*?)\]/',
90-
'filters' => '/\{(.*?)\}/',
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),
91111
];
92112

93113
// Parse special terms
94114
foreach ($patterns as $termType => $pattern) {
95115
$matches = [];
96116
preg_match_all($pattern, $searchString, $matches);
97117
if (count($matches) > 0) {
98-
$terms[$termType] = $matches[1];
118+
foreach ($matches[1] as $index => $value) {
119+
$negated = str_starts_with($matches[0][$index], '-');
120+
$terms[$termType][] = $constructors[$termType]($value, $negated);
121+
}
99122
$searchString = preg_replace($pattern, '', $searchString);
100123
}
101124
}
102125

103126
// Unescape exacts and backslash escapes
104-
$escapedExacts = array_map(fn(string $term) => static::decodeEscapes($term), $terms['exacts']);
127+
foreach ($terms['exacts'] as $exact) {
128+
$exact->value = static::decodeEscapes($exact->value);
129+
}
105130

106131
// Parse standard terms
107132
$parsedStandardTerms = static::parseStandardTermString($searchString);
108133
$this->searches = $this->searches
109-
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms']))
134+
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['terms'], TermSearchOption::class))
110135
->filterEmpty();
111136
$this->exacts = $this->exacts
112-
->merge(SearchOptionSet::fromValueArray($escapedExacts))
113-
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts']))
137+
->merge(new SearchOptionSet($terms['exacts']))
138+
->merge(SearchOptionSet::fromValueArray($parsedStandardTerms['exacts'], ExactSearchOption::class))
114139
->filterEmpty();
115140

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));
141+
// Add tags & filters
142+
$this->tags = $this->tags->merge(new SearchOptionSet($terms['tags']));
143+
$this->filters = $this->filters->merge(new SearchOptionSet($terms['filters']));
128144
}
129145

130146
/**
@@ -185,7 +201,7 @@ protected static function parseStandardTermString(string $termString): array
185201
public function setFilter(string $filterName, string $filterValue = ''): void
186202
{
187203
$this->filters = $this->filters->merge(
188-
new SearchOptionSet([$filterName => new SearchOption($filterValue)])
204+
new SearchOptionSet([new FilterSearchOption($filterValue, $filterName)])
189205
);
190206
}
191207

@@ -194,21 +210,14 @@ public function setFilter(string $filterName, string $filterValue = ''): void
194210
*/
195211
public function toString(): string
196212
{
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-
}
213+
$options = [
214+
...$this->searches->all(),
215+
...$this->exacts->all(),
216+
...$this->tags->all(),
217+
...$this->filters->all(),
218+
];
208219

209-
foreach ($this->filters->toValueMap() as $filterName => $filterVal) {
210-
$parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
211-
}
220+
$parts = array_map(fn(SearchOption $o) => $o->toString(), $options);
212221

213222
return implode(' ', $parts);
214223
}
@@ -217,24 +226,24 @@ public function toString(): string
217226
* Get the search options that don't have UI controls provided for.
218227
* Provided back as a key => value array with the keys being expected
219228
* input names for a search form, and values being the option value.
220-
*
221-
* @return array<string, string>
222229
*/
223-
public function getHiddenInputValuesByFieldName(): array
230+
public function getAdditionalOptionsString(): string
224231
{
225232
$options = [];
226233

227234
// 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;
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;
233239
}
234240
}
235241

236-
// TODO - Negated
242+
// Negated items
243+
array_push($options, ...$this->exacts->negated());
244+
array_push($options, ...$this->tags->negated());
245+
array_push($options, ...$this->filters->negated());
237246

238-
return $options;
247+
return implode(' ', array_map(fn(SearchOption $o) => $o->toString(), $options));
239248
}
240249
}

0 commit comments

Comments
 (0)