diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index 0bf9c3116..af146d5fd 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -44,8 +44,8 @@ class SearchOptions $inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']); $parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? ''); - $instance->searches = $parsedStandardTerms['terms']; - $instance->exacts = $parsedStandardTerms['exacts']; + $instance->searches = array_filter($parsedStandardTerms['terms']); + $instance->exacts = array_filter($parsedStandardTerms['exacts']); array_push($instance->exacts, ...array_filter($inputs['exact'] ?? [])); @@ -78,7 +78,7 @@ class SearchOptions ]; $patterns = [ - 'exacts' => '/"(.*?)"/', + 'exacts' => '/"(.*?)(? '/\[(.*?)\]/', 'filters' => '/\{(.*?)\}/', ]; @@ -93,6 +93,11 @@ class SearchOptions } } + // Unescape exacts + foreach ($terms['exacts'] as $index => $exact) { + $terms['exacts'][$index] = str_replace('\"', '"', $exact); + } + // Parse standard terms $parsedStandardTerms = static::parseStandardTermString($searchString); array_push($terms['searches'], ...$parsedStandardTerms['terms']); @@ -106,12 +111,19 @@ class SearchOptions } $terms['filters'] = $splitFilters; + // Filter down terms where required + $terms['exacts'] = array_filter($terms['exacts']); + $terms['searches'] = array_filter($terms['searches']); + return $terms; } /** * Parse a standard search term string into individual search terms and - * extract any exact terms searches to be made. + * convert any required terms to exact matches. This is done since some + * characters will never be in the standard index, since we use them as + * delimiters, and therefore we convert a term to be exact if it + * contains one of those delimiter characters. * * @return array{terms: array, exacts: array} */ @@ -129,8 +141,8 @@ class SearchOptions continue; } - $parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts'; - $parsed[$parsedList][] = $searchTerm; + $becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false); + $parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm; } return $parsed; @@ -141,20 +153,21 @@ class SearchOptions */ public function toString(): string { - $string = implode(' ', $this->searches ?? []); + $parts = $this->searches; foreach ($this->exacts as $term) { - $string .= ' "' . $term . '"'; + $escaped = str_replace('"', '\"', $term); + $parts[] = '"' . $escaped . '"'; } foreach ($this->tags as $term) { - $string .= " [{$term}]"; + $parts[] = "[{$term}]"; } foreach ($this->filters as $filterName => $filterVal) { - $string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}'; + $parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}'; } - return $string; + return implode(' ', $parts); } } diff --git a/tests/Entity/SearchOptionsTest.php b/tests/Entity/SearchOptionsTest.php index cac9c67f1..8bc9d02e4 100644 --- a/tests/Entity/SearchOptionsTest.php +++ b/tests/Entity/SearchOptionsTest.php @@ -3,6 +3,7 @@ namespace Tests\Entity; use BookStack\Search\SearchOptions; +use Illuminate\Http\Request; use Tests\TestCase; class SearchOptionsTest extends TestCase @@ -17,6 +18,13 @@ class SearchOptionsTest extends TestCase $this->assertEquals(['is_tree' => ''], $options->filters); } + public function test_from_string_properly_parses_escaped_quotes() + { + $options = SearchOptions::fromString('"\"cat\"" surprise "\"\"" "\"donkey" "\""'); + + $this->assertEquals(['"cat"', '""', '"donkey', '"'], $options->exacts); + } + public function test_to_string_includes_all_items_in_the_correct_format() { $expected = 'cat "dog" [tag=good] {is_tree}'; @@ -32,6 +40,15 @@ class SearchOptionsTest extends TestCase } } + public function test_to_string_escapes_quotes_as_expected() + { + $options = new SearchOptions(); + $options->exacts = ['"cat"', '""', '"donkey', '"']; + + $output = $options->toString(); + $this->assertEquals('"\"cat\"" "\"\"" "\"donkey" "\""', $output); + } + public function test_correct_filter_values_are_set_from_string() { $opts = SearchOptions::fromString('{is_tree} {name:dan} {cat:happy}'); @@ -42,4 +59,22 @@ class SearchOptionsTest extends TestCase 'cat' => 'happy', ], $opts->filters); } + public function test_it_cannot_parse_out_empty_exacts() + { + $options = SearchOptions::fromString('"" test ""'); + + $this->assertEmpty($options->exacts); + $this->assertCount(1, $options->searches); + } + + public function test_from_request_properly_parses_exacts_from_search_terms() + { + $request = new Request([ + 'search' => 'biscuits "cheese" "" "baked beans"' + ]); + + $options = SearchOptions::fromRequest($request); + $this->assertEquals(["biscuits"], $options->searches); + $this->assertEquals(['"cheese"', '""', '"baked', 'beans"'], $options->exacts); + } }