diff --git a/.env.example b/.env.example index e2e0b18720..358fec2d52 100644 --- a/.env.example +++ b/.env.example @@ -325,6 +325,12 @@ USE_RUNNING_BALANCE=false # FIREFLY_III_LAYOUT=v1 +# +# Which Query Parser implementation to use for the Search Engine and Rules +# 'new' is experimental, 'legacy' is the classic one +# +QUERY_PARSER_IMPLEMENTATION=legacy + # # Please make sure this URL matches the external URL of your Firefly III installation. # It is used to validate specific requests and to generate URLs in emails. diff --git a/app/Http/Controllers/Rule/CreateController.php b/app/Http/Controllers/Rule/CreateController.php index 9729dfe015..ab2e59b46c 100644 --- a/app/Http/Controllers/Rule/CreateController.php +++ b/app/Http/Controllers/Rule/CreateController.php @@ -89,16 +89,28 @@ class CreateController extends Controller // build triggers from query, if present. $query = (string) $request->get('from_query'); if ('' !== $query) { - $search = app(SearchInterface::class); + $search = app(SearchInterface::class); $search->parseQuery($query); - $words = $search->getWordsAsString(); - $operators = $search->getOperators()->toArray(); - if ('' !== $words) { - session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => $words])); - $operators[] = [ - 'type' => 'description_contains', - 'value' => $words, - ]; + $words = $search->getWords(); + $excludedWords = $search->getExcludedWords(); + $operators = $search->getOperators()->toArray(); + if (count($words) > 0) { + session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $words)])); + foreach($words as $word) { + $operators[] = [ + 'type' => 'description_contains', + 'value' => $word, + ]; + } + } + if (count($excludedWords) > 0) { + session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $excludedWords)])); + foreach($excludedWords as $excludedWord) { + $operators[] = [ + 'type' => '-description_contains', + 'value' => $excludedWord, + ]; + } } $oldTriggers = $this->parseFromOperators($operators); } diff --git a/app/Http/Controllers/Rule/EditController.php b/app/Http/Controllers/Rule/EditController.php index a00df8ef4c..4c4e04b6fc 100644 --- a/app/Http/Controllers/Rule/EditController.php +++ b/app/Http/Controllers/Rule/EditController.php @@ -87,11 +87,26 @@ class EditController extends Controller if ('' !== $query) { $search = app(SearchInterface::class); $search->parseQuery($query); - $words = $search->getWordsAsString(); + $words = $search->getWords(); + $excludedWords = $search->getExcludedWords(); $operators = $search->getOperators()->toArray(); - if ('' !== $words) { - session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => $words])); - $operators[] = ['type' => 'description_contains', 'value' => $words]; + if (count($words) > 0) { + session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $words)])); + foreach($words as $word) { + $operators[] = [ + 'type' => 'description_contains', + 'value' => $word, + ]; + } + } + if (count($excludedWords) > 0) { + session()->flash('warning', trans('firefly.rule_from_search_words', ['string' => implode('', $excludedWords)])); + foreach($excludedWords as $excludedWord) { + $operators[] = [ + 'type' => '-description_contains', + 'value' => $excludedWord, + ]; + } } $oldTriggers = $this->parseFromOperators($operators); } diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 6bf2204c3b..ceab9169c5 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -83,12 +83,13 @@ class SearchController extends Controller $searcher->parseQuery($fullQuery); // words from query and operators: - $query = $searcher->getWordsAsString(); + $words = $searcher->getWords(); + $excludedWords = $searcher->getExcludedWords(); $operators = $searcher->getOperators(); $invalidOperators = $searcher->getInvalidOperators(); $subTitle = (string) trans('breadcrumbs.search_result', ['query' => $fullQuery]); - return view('search.index', compact('query', 'operators', 'page', 'rule', 'fullQuery', 'subTitle', 'ruleId', 'ruleChanged', 'invalidOperators')); + return view('search.index', compact('words', 'excludedWords', 'operators', 'page', 'rule', 'fullQuery', 'subTitle', 'ruleId', 'ruleChanged', 'invalidOperators')); } /** diff --git a/app/Providers/SearchServiceProvider.php b/app/Providers/SearchServiceProvider.php index 0bb2b1056b..98b5a0f850 100644 --- a/app/Providers/SearchServiceProvider.php +++ b/app/Providers/SearchServiceProvider.php @@ -23,7 +23,10 @@ declare(strict_types=1); namespace FireflyIII\Providers; +use FireflyIII\Support\Search\QueryParser\GdbotsQueryParser; use FireflyIII\Support\Search\OperatorQuerySearch; +use FireflyIII\Support\Search\QueryParser\QueryParser; +use FireflyIII\Support\Search\QueryParser\QueryParserInterface; use FireflyIII\Support\Search\SearchInterface; use Illuminate\Foundation\Application; use Illuminate\Support\ServiceProvider; @@ -43,6 +46,18 @@ class SearchServiceProvider extends ServiceProvider */ public function register(): void { + $this->app->bind( + QueryParserInterface::class, + static function (): GdbotsQueryParser|QueryParser { + $implementation = config('search.query_parser'); + + return match($implementation) { + 'new' => app(QueryParser::class), + default => app(GdbotsQueryParser::class), + }; + } + ); + $this->app->bind( SearchInterface::class, static function (Application $app) { diff --git a/app/Support/Search/OperatorQuerySearch.php b/app/Support/Search/OperatorQuerySearch.php index 53fce8cef9..8dfda1e1b4 100644 --- a/app/Support/Search/OperatorQuerySearch.php +++ b/app/Support/Search/OperatorQuerySearch.php @@ -39,22 +39,14 @@ use FireflyIII\Repositories\Budget\BudgetRepositoryInterface; use FireflyIII\Repositories\Category\CategoryRepositoryInterface; use FireflyIII\Repositories\Tag\TagRepositoryInterface; use FireflyIII\Repositories\UserGroups\Currency\CurrencyRepositoryInterface; +use FireflyIII\Support\Search\QueryParser\QueryParserInterface; +use FireflyIII\Support\Search\QueryParser\Node; +use FireflyIII\Support\Search\QueryParser\FieldNode; +use FireflyIII\Support\Search\QueryParser\StringNode; +use FireflyIII\Support\Search\QueryParser\NodeGroup; + use FireflyIII\Support\ParseDateString; use FireflyIII\User; -use Gdbots\QueryParser\Enum\BoolOperator; -use Gdbots\QueryParser\Node\Date; -use Gdbots\QueryParser\Node\Emoji; -use Gdbots\QueryParser\Node\Emoticon; -use Gdbots\QueryParser\Node\Field; -use Gdbots\QueryParser\Node\Hashtag; -use Gdbots\QueryParser\Node\Mention; -use Gdbots\QueryParser\Node\Node; -use Gdbots\QueryParser\Node\Numbr; -use Gdbots\QueryParser\Node\Phrase; -use Gdbots\QueryParser\Node\Subquery; -use Gdbots\QueryParser\Node\Url; -use Gdbots\QueryParser\Node\Word; -use Gdbots\QueryParser\QueryParser; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -131,6 +123,16 @@ class OperatorQuerySearch implements SearchInterface return implode(' ', $this->words); } + public function getWords(): array + { + return $this->words; + } + + public function getExcludedWords(): array + { + return $this->prohibitedWords; + } + /** * @throws FireflyException */ @@ -145,10 +147,11 @@ class OperatorQuerySearch implements SearchInterface public function parseQuery(string $query): void { app('log')->debug(sprintf('Now in parseQuery(%s)', $query)); - $parser = new QueryParser(); + $parser = app(QueryParserInterface::class); + app('log')->debug(sprintf('Using %s as implementation for QueryParserInterface', get_class($parser))); try { - $query1 = $parser->parse($query); + $parsedQuery = $parser->parse($query); } catch (\LogicException|\TypeError $e) { app('log')->error($e->getMessage()); app('log')->error(sprintf('Could not parse search: "%s".', $query)); @@ -156,10 +159,8 @@ class OperatorQuerySearch implements SearchInterface throw new FireflyException(sprintf('Invalid search value "%s". See the logs.', e($query)), 0, $e); } - app('log')->debug(sprintf('Found %d node(s)', count($query1->getNodes()))); - foreach ($query1->getNodes() as $searchNode) { - $this->handleSearchNode($searchNode); - } + app('log')->debug(sprintf('Found %d node(s) at top-level', count($parsedQuery->getNodes()))); + $this->handleSearchNode($parsedQuery, $parsedQuery->isProhibited(false)); // add missing information $this->collector->withBillInformation(); @@ -173,81 +174,93 @@ class OperatorQuerySearch implements SearchInterface * * @SuppressWarnings("PHPMD.CyclomaticComplexity") */ - private function handleSearchNode(Node $searchNode): void + private function handleSearchNode(Node $node, $flipProhibitedFlag): void { - $class = get_class($searchNode); - app('log')->debug(sprintf('Now in handleSearchNode(%s)', $class)); + app('log')->debug(sprintf('Now in handleSearchNode(%s)', get_class($node))); + + switch (true) { + case $node instanceof StringNode: + $this->handleStringNode($node, $flipProhibitedFlag); + break; + + case $node instanceof FieldNode: + $this->handleFieldNode($node, $flipProhibitedFlag); + break; + + case $node instanceof NodeGroup: + $this->handleNodeGroup($node, $flipProhibitedFlag); + break; - switch ($class) { default: - app('log')->error(sprintf('Cannot handle node %s', $class)); + app('log')->error(sprintf('Cannot handle node %s', get_class($node))); + throw new FireflyException(sprintf('Firefly III search can\'t handle "%s"-nodes', get_class($node))); + } + } - throw new FireflyException(sprintf('Firefly III search can\'t handle "%s"-nodes', $class)); + private function handleNodeGroup(NodeGroup $node, $flipProhibitedFlag): void + { + $prohibited = $node->isProhibited($flipProhibitedFlag); - case Subquery::class: - // loop all notes in subquery: - foreach ($searchNode->getNodes() as $subNode) { // @phpstan-ignore-line PHPStan thinks getNodes() does not exist but it does. - $this->handleSearchNode($subNode); // let's hope it's not too recursive - } + foreach ($node->getNodes() as $subNode) { + $this->handleSearchNode($subNode, $prohibited); + } + } - break; - case Word::class: - case Phrase::class: - case Numbr::class: - case Url::class: - case Date::class: - case Hashtag::class: - case Emoticon::class: - case Emoji::class: - case Mention::class: - $allWords = (string) $searchNode->getValue(); - app('log')->debug(sprintf('Add words "%s" to search string, because Node class is "%s"', $allWords, $class)); - $this->words[] = $allWords; - break; + private function handleStringNode(StringNode $node, $flipProhibitedFlag): void + { + $string = (string) $node->getValue(); - case Field::class: - app('log')->debug(sprintf('Now handle Node class %s', $class)); + $prohibited = $node->isProhibited($flipProhibitedFlag); - /** @var Field $searchNode */ - // used to search for x:y - $operator = strtolower($searchNode->getValue()); - $value = $searchNode->getNode()->getValue(); - $prohibited = BoolOperator::PROHIBITED === $searchNode->getBoolOperator(); - $context = config(sprintf('search.operators.%s.needs_context', $operator)); + if($prohibited) { + app('log')->debug(sprintf('Exclude string "%s" from search string', $string)); + $this->prohibitedWords[] = $string; + } else { + app('log')->debug(sprintf('Add string "%s" to search string', $string)); + $this->words[] = $string; + } + } - // is an operator that needs no context, and value is false, then prohibited = true. - if ('false' === $value && in_array($operator, $this->validOperators, true) && false === $context && !$prohibited) { - $prohibited = true; - $value = 'true'; - } - // if the operator is prohibited, but the value is false, do an uno reverse - if ('false' === $value && $prohibited && in_array($operator, $this->validOperators, true) && false === $context) { - $prohibited = false; - $value = 'true'; - } + /** + * @throws FireflyException + */ + private function handleFieldNode(FieldNode $node, $flipProhibitedFlag): void + { + $operator = strtolower($node->getOperator()); + $value = $node->getValue(); + $prohibited = $node->isProhibited($flipProhibitedFlag); - // must be valid operator: - if ( - in_array($operator, $this->validOperators, true) - && $this->updateCollector($operator, (string) $value, $prohibited)) { - $this->operators->push( - [ - 'type' => self::getRootOperator($operator), - 'value' => (string) $value, - 'prohibited' => $prohibited, - ] - ); - app('log')->debug(sprintf('Added operator type "%s"', $operator)); - } - if (!in_array($operator, $this->validOperators, true)) { - app('log')->debug(sprintf('Added INVALID operator type "%s"', $operator)); - $this->invalidOperators[] = [ - 'type' => $operator, - 'value' => (string) $value, - ]; - } + $context = config(sprintf('search.operators.%s.needs_context', $operator)); + + // is an operator that needs no context, and value is false, then prohibited = true. + if ('false' === $value && in_array($operator, $this->validOperators, true) && false === $context && !$prohibited) { + $prohibited = true; + $value = 'true'; + } + // if the operator is prohibited, but the value is false, do an uno reverse + if ('false' === $value && $prohibited && in_array($operator, $this->validOperators, true) && false === $context) { + $prohibited = false; + $value = 'true'; + } + + // must be valid operator: + if (in_array($operator, $this->validOperators, true)) { + if ($this->updateCollector($operator, (string)$value, $prohibited)) { + $this->operators->push([ + 'type' => self::getRootOperator($operator), + 'value' => (string)$value, + 'prohibited' => $prohibited, + ]); + app('log')->debug(sprintf('Added operator type "%s"', $operator)); + } + } else { + app('log')->debug(sprintf('Added INVALID operator type "%s"', $operator)); + $this->invalidOperators[] = [ + 'type' => $operator, + 'value' => (string)$value, + ]; } } @@ -2766,7 +2779,7 @@ class OperatorQuerySearch implements SearchInterface public function searchTransactions(): LengthAwarePaginator { $this->parseTagInstructions(); - if (0 === count($this->getWords()) && 0 === count($this->getOperators())) { + if (0 === count($this->getWords()) && 0 === count($this->getExcludedWords()) && 0 === count($this->getOperators())) { return new LengthAwarePaginator([], 0, 5, 1); } @@ -2818,11 +2831,6 @@ class OperatorQuerySearch implements SearchInterface } } - public function getWords(): array - { - return $this->words; - } - public function setDate(Carbon $date): void { $this->date = $date; diff --git a/app/Support/Search/QueryParser/FieldNode.php b/app/Support/Search/QueryParser/FieldNode.php new file mode 100644 index 0000000000..5005d5fe57 --- /dev/null +++ b/app/Support/Search/QueryParser/FieldNode.php @@ -0,0 +1,31 @@ +operator = $operator; + $this->value = $value; + $this->prohibited = $prohibited; + } + + public function getOperator(): string + { + return $this->operator; + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/app/Support/Search/QueryParser/GdbotsQueryParser.php b/app/Support/Search/QueryParser/GdbotsQueryParser.php new file mode 100644 index 0000000000..c580b694be --- /dev/null +++ b/app/Support/Search/QueryParser/GdbotsQueryParser.php @@ -0,0 +1,81 @@ +parser = new BaseQueryParser(); + } + + /** + * @return NodeGroup + * @throws FireflyException + */ + public function parse(string $query): NodeGroup + { + try { + $result = $this->parser->parse($query); + $nodes = array_map( + fn(GdbotsNode\Node $node) => $this->convertNode($node), + $result->getNodes() + ); + return new NodeGroup($nodes); + } catch (\LogicException|\TypeError $e) { + fwrite(STDERR, "Setting up GdbotsQueryParserTest\n"); + dd('Creating GdbotsQueryParser'); + app('log')->error($e->getMessage()); + app('log')->error(sprintf('Could not parse search: "%s".', $query)); + + throw new FireflyException(sprintf('Invalid search value "%s". See the logs.', e($query)), 0, $e); + } + } + + private function convertNode(GdbotsNode\Node $node): Node + { + switch (true) { + case $node instanceof GdbotsNode\Word: + return new StringNode($node->getValue()); + + case $node instanceof GdbotsNode\Field: + return new FieldNode( + $node->getValue(), + (string) $node->getNode()->getValue(), + BoolOperator::PROHIBITED === $node->getBoolOperator() + ); + + case $node instanceof GdbotsNode\Subquery: + return new NodeGroup( + array_map( + fn(GdbotsNode\Node $subNode) => $this->convertNode($subNode), + $node->getNodes() + ) + ); + + case $node instanceof GdbotsNode\Phrase: + case $node instanceof GdbotsNode\Numbr: + case $node instanceof GdbotsNode\Date: + case $node instanceof GdbotsNode\Url: + case $node instanceof GdbotsNode\Hashtag: + case $node instanceof GdbotsNode\Mention: + case $node instanceof GdbotsNode\Emoticon: + case $node instanceof GdbotsNode\Emoji: + return new StringNode((string) $node->getValue()); + + default: + throw new FireflyException( + sprintf('Unsupported node type: %s', get_class($node)) + ); + } + } +} diff --git a/app/Support/Search/QueryParser/Node.php b/app/Support/Search/QueryParser/Node.php new file mode 100644 index 0000000000..e7ae29611b --- /dev/null +++ b/app/Support/Search/QueryParser/Node.php @@ -0,0 +1,31 @@ +prohibited; + } else { + return $this->prohibited; + } + + } +} diff --git a/app/Support/Search/QueryParser/NodeGroup.php b/app/Support/Search/QueryParser/NodeGroup.php new file mode 100644 index 0000000000..8cf801dbbc --- /dev/null +++ b/app/Support/Search/QueryParser/NodeGroup.php @@ -0,0 +1,34 @@ +nodes = $nodes; + $this->prohibited = $prohibited; + } + + /** + * @return Node[] + */ + public function getNodes(): array + { + return $this->nodes; + } +} diff --git a/app/Support/Search/QueryParser/QueryParser.php b/app/Support/Search/QueryParser/QueryParser.php new file mode 100644 index 0000000000..f1e951f5ce --- /dev/null +++ b/app/Support/Search/QueryParser/QueryParser.php @@ -0,0 +1,177 @@ +query = $query; + $this->position = 0; + return $this->buildNodeGroup(false); + } + + /** @return NodeGroup */ + private function buildNodeGroup(bool $isSubquery, bool $prohibited = false): NodeGroup + { + $nodes = []; + $nodeResult = $this->buildNextNode($isSubquery); + + while ($nodeResult->node !== null) { + $nodes[] = $nodeResult->node; + if($nodeResult->isSubqueryEnd) { + break; + } + $nodeResult = $this->buildNextNode($isSubquery); + } + + return new NodeGroup($nodes, $prohibited); + } + + private function buildNextNode(bool $isSubquery): NodeResult + { + $tokenUnderConstruction = ''; + $inQuotes = false; + $fieldName = ''; + $prohibited = false; + + while ($this->position < strlen($this->query)) { + $char = $this->query[$this->position]; + + // If we're in a quoted string, we treat all characters except another quote as ordinary characters + if ($inQuotes) { + if ($char !== '"') { + $tokenUnderConstruction .= $char; + $this->position++; + continue; + } else { + $this->position++; + return new NodeResult( + $this->createNode($tokenUnderConstruction, $fieldName, $prohibited), + false + ); + } + } + + switch ($char) { + case '-': + if ($tokenUnderConstruction === '') { + // A minus sign at the beginning of a token indicates prohibition + $prohibited = true; + } else { + // In any other location, it's just a normal character + $tokenUnderConstruction .= $char; + } + break; + + case '"': + if ($tokenUnderConstruction === '') { + // A quote sign at the beginning of a token indicates the start of a quoted string + $inQuotes = true; + } else { + // In any other location, it's just a normal character + $tokenUnderConstruction .= $char; + } + break; + + case '(': + if ($tokenUnderConstruction === '') { + // A left parentheses at the beginning of a token indicates the start of a subquery + $this->position++; + return new NodeResult($this->buildNodeGroup(true, $prohibited), + false + ); + } else { + // In any other location, it's just a normal character + $tokenUnderConstruction .= $char; + } + break; + + case ')': + // A right parentheses while in a subquery means the subquery ended, + // thus also signaling the end of any node currently being built + if ($isSubquery) { + $this->position++; + return new NodeResult( + $tokenUnderConstruction !== '' + ? $this->createNode($tokenUnderConstruction, $fieldName, $prohibited) + : null, + true + ); + } + // In any other location, it's just a normal character + $tokenUnderConstruction .= $char; + break; + + + case ':': + if ($tokenUnderConstruction !== '') { + // If we meet a colon with a left-hand side string, we know we're in a field and are about to set up the value + $fieldName = $tokenUnderConstruction; + $tokenUnderConstruction = ''; + } else { + // In any other location, it's just a normal character + $tokenUnderConstruction .= $char; + } + break; + + case ' ': + // A space indicates the end of a token construction if non-empty, otherwise it's just ignored + if ($tokenUnderConstruction !== '') { + $this->position++; + return new NodeResult( + $this->createNode($tokenUnderConstruction, $fieldName, $prohibited), + false + ); + } + break; + + default: + $tokenUnderConstruction .= $char; + } + + $this->position++; + } + + $finalNode = $tokenUnderConstruction !== '' || $fieldName !== '' + ? $this->createNode($tokenUnderConstruction, $fieldName, $prohibited) + : null; + + return new NodeResult($finalNode, true); + } + + private function createNode(string $token, string $fieldName, bool $prohibited): Node + { + if (strlen($fieldName) > 0) { + return new FieldNode(trim($fieldName), trim($token), $prohibited); + } + return new StringNode(trim($token), $prohibited); + } +} diff --git a/app/Support/Search/QueryParser/QueryParserInterface.php b/app/Support/Search/QueryParser/QueryParserInterface.php new file mode 100644 index 0000000000..00383af0ff --- /dev/null +++ b/app/Support/Search/QueryParser/QueryParserInterface.php @@ -0,0 +1,13 @@ +value = $value; + $this->prohibited = $prohibited; + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/app/Support/Search/SearchInterface.php b/app/Support/Search/SearchInterface.php index b9197e9daf..3c9a78e19e 100644 --- a/app/Support/Search/SearchInterface.php +++ b/app/Support/Search/SearchInterface.php @@ -38,8 +38,10 @@ interface SearchInterface public function getModifiers(): Collection; public function getOperators(): Collection; + public function getWords(): array; public function getWordsAsString(): string; + public function getExcludedWords(): array; public function hasModifiers(): bool; diff --git a/config/search.php b/config/search.php index 304cd080a0..249fabd363 100644 --- a/config/search.php +++ b/config/search.php @@ -253,4 +253,8 @@ return [ 'destination_balance_lt' => ['alias' => false, 'needs_context' => true], 'destination_balance_is' => ['alias' => false, 'needs_context' => true], ], + /** + * Which query parser to use - 'new' or 'legacy' + */ + 'query_parser' => env('QUERY_PARSER_IMPLEMENTATION', 'legacy'), ]; diff --git a/public/v1/css/firefly.css b/public/v1/css/firefly.css index 2c93945e76..3b546ab095 100644 --- a/public/v1/css/firefly.css +++ b/public/v1/css/firefly.css @@ -258,6 +258,11 @@ span.twitter-typeahead { top: 46px !important; } +.search-word { + white-space: pre; + background-color: #f5f5f5; +} + /* .twitter-typeahead { width:100%; diff --git a/resources/lang/en_US/firefly.php b/resources/lang/en_US/firefly.php index 09db945ef0..7d56ff3837 100644 --- a/resources/lang/en_US/firefly.php +++ b/resources/lang/en_US/firefly.php @@ -329,7 +329,9 @@ return [ 'search_query' => 'Query', 'search_found_transactions' => 'Firefly III found :count transaction in :time seconds.|Firefly III found :count transactions in :time seconds.', 'search_found_more_transactions' => 'Firefly III found more than :count transactions in :time seconds.', - 'search_for_query' => 'Firefly III is searching for transactions with all of these words in them: :query', + 'search_for_overview' => 'Firefly III is searching for transactions that fulfill all of the following conditions:', + 'search_for_query' => 'All of these words must be present: :query', + 'search_for_excluded_words' => 'None of these words may be present: :excluded_words', 'invalid_operators_list' => 'These search parameters are not valid and have been ignored.', // old @@ -729,7 +731,6 @@ return [ // Ignore this comment // END - 'modifiers_applies_are' => 'The following modifiers are applied to the search as well:', 'general_search_error' => 'An error occurred while searching. Please check the log files for more information.', 'search_box' => 'Search', 'search_box_intro' => 'Welcome to the search function of Firefly III. Enter your search query in the box. Make sure you check out the help file because the search is pretty advanced.', diff --git a/resources/views/search/index.twig b/resources/views/search/index.twig index b48dfd069e..07c5b20866 100644 --- a/resources/views/search/index.twig +++ b/resources/views/search/index.twig @@ -38,11 +38,38 @@ {% endif %} - {% if '' != query %} -

- {{ trans('firefly.search_for_query', {query: query|escape})|raw }} -

+

+ {{ trans('firefly.search_for_overview') |raw }} +

+ {% if invalidOperators|length > 0 %}

{{ trans('firefly.invalid_operators_list') }}

@@ -52,25 +79,11 @@ {% endfor %} {% endif %} - - {% if operators|length > 0 %} -

{{ trans('firefly.modifiers_applies_are') }}

- - {% endif %} - {% if query or operators|length > 0 %} + {% if query|length > 0 or excludedWords|length > 0 or operators|length > 0 %}
{% endif %} - {% if query == "" and operators|length == 0 %} + {% if query|length == 0 and excludedWords|length == 0 and operators|length == 0 %}
diff --git a/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php b/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php new file mode 100644 index 0000000000..f324a8661a --- /dev/null +++ b/tests/unit/Support/Search/QueryParser/AbstractQueryParserInterfaceParseQueryTest.php @@ -0,0 +1,197 @@ + [ + 'query' => '', + 'expected' => new NodeGroup([]) + ], + 'simple word' => [ + 'query' => 'groceries', + 'expected' => new NodeGroup([new StringNode('groceries')]) + ], + 'prohibited word' => [ + 'query' => '-groceries', + 'expected' => new NodeGroup([new StringNode('groceries', true)]) + ], + 'prohibited field' => [ + 'query' => '-amount:100', + 'expected' => new NodeGroup([new FieldNode('amount', '100', true)]) + ], + 'quoted word' => [ + 'query' => '"test phrase"', + 'expected' => new NodeGroup([new StringNode('test phrase')]) + ], + 'prohibited quoted word' => [ + 'query' => '-"test phrase"', + 'expected' => new NodeGroup([new StringNode('test phrase', true)]) + ], + 'multiple words' => [ + 'query' => 'groceries shopping market', + 'expected' => new NodeGroup([ + new StringNode('groceries'), + new StringNode('shopping'), + new StringNode('market') + ]) + ], + 'field operator' => [ + 'query' => 'amount:100', + 'expected' => new NodeGroup([new FieldNode('amount', '100')]) + ], + 'quoted field value with single space' => [ + 'query' => 'description:"test phrase"', + 'expected' => new NodeGroup([new FieldNode('description', 'test phrase')]) + ], + 'multiple fields' => [ + 'query' => 'amount:100 category:food', + 'expected' => new NodeGroup([ + new FieldNode('amount', '100'), + new FieldNode('category', 'food') + ]) + ], + 'simple subquery' => [ + 'query' => '(amount:100 category:food)', + 'expected' => new NodeGroup([ + new NodeGroup([ + new FieldNode('amount', '100'), + new FieldNode('category', 'food') + ]) + ]) + ], + 'prohibited subquery' => [ + 'query' => '-(amount:100 category:food)', + 'expected' => new NodeGroup([ + new NodeGroup([ + new FieldNode('amount', '100'), + new FieldNode('category', 'food') + ], true) + ]) + ], + 'nested subquery' => [ + 'query' => '(amount:100 (description:"test" category:food))', + 'expected' => new NodeGroup([ + new NodeGroup([ + new FieldNode('amount', '100'), + new NodeGroup([ + new FieldNode('description', 'test'), + new FieldNode('category', 'food') + ]) + ]) + ]) + ], + 'mixed words and operators' => [ + 'query' => 'groceries amount:50 shopping', + 'expected' => new NodeGroup([ + new StringNode('groceries'), + new FieldNode('amount', '50'), + new StringNode('shopping') + ]) + ], + 'subquery after field value' => [ + 'query' => 'amount:100 (description:"market" category:food)', + 'expected' => new NodeGroup([ + new FieldNode('amount', '100'), + new NodeGroup([ + new FieldNode('description', 'market'), + new FieldNode('category', 'food') + ]) + ]) + ], + 'word followed by subquery' => [ + 'query' => 'groceries (amount:100 description_contains:"test")', + 'expected' => new NodeGroup([ + new StringNode('groceries'), + new NodeGroup([ + new FieldNode('amount', '100'), + new FieldNode('description_contains', 'test') + ]) + ]) + ], + 'nested subquery with prohibited field' => [ + 'query' => '(amount:100 (description_contains:"test payment" -has_attachments:true))', + 'expected' => new NodeGroup([ + new NodeGroup([ + new FieldNode('amount', '100'), + new NodeGroup([ + new FieldNode('description_contains', 'test payment'), + new FieldNode('has_attachments', 'true', true) + ]) + ]) + ]) + ], + 'complex nested subqueries' => [ + 'query' => 'shopping (amount:50 market (-category:food word description:"test phrase" (has_notes:true)))', + 'expected' => new NodeGroup([ + new StringNode('shopping'), + new NodeGroup([ + new FieldNode('amount', '50'), + new StringNode('market'), + new NodeGroup([ + new FieldNode('category', 'food', true), + new StringNode('word'), + new FieldNode('description', 'test phrase'), + new NodeGroup([ + new FieldNode('has_notes', 'true') + ]) + ]) + ]) + ]) + ], + 'word with multiple spaces' => [ + 'query' => '"multiple spaces"', + 'expected' => new NodeGroup([new StringNode('multiple spaces')]) + ], + 'field with multiple spaces in value' => [ + 'query' => 'description:"multiple spaces here"', + 'expected' => new NodeGroup([new FieldNode('description', 'multiple spaces here')]) + ], + 'unmatched right parenthesis in word' => [ + 'query' => 'test)word', + 'expected' => new NodeGroup([new StringNode('test)word')]) + ], + 'unmatched right parenthesis in field' => [ + 'query' => 'description:test)phrase', + 'expected' => new NodeGroup([new FieldNode('description', 'test)phrase')]) + ], + 'subquery followed by word' => [ + 'query' => '(amount:100 category:food) shopping', + 'expected' => new NodeGroup([ + new NodeGroup([ + new FieldNode('amount', '100'), + new FieldNode('category', 'food') + ]), + new StringNode('shopping') + ]) + ] + ]; + } + + /** + * @dataProvider queryDataProvider + * @param string $query The query string to parse + * @param Node $expected The expected parse result + */ + public function testQueryParsing(string $query, Node $expected): void + { + $actual = $this->createParser()->parse($query); + + $this->assertEquals($expected, $actual); + + } +} diff --git a/tests/unit/Support/Search/QueryParser/GdbotsQueryParserParseQueryTest.php b/tests/unit/Support/Search/QueryParser/GdbotsQueryParserParseQueryTest.php new file mode 100644 index 0000000000..c5880fe98c --- /dev/null +++ b/tests/unit/Support/Search/QueryParser/GdbotsQueryParserParseQueryTest.php @@ -0,0 +1,23 @@ +