diff options
| author | 2025-12-10 22:41:45 +0100 | |
|---|---|---|
| committer | 2025-12-10 22:41:45 +0100 | |
| commit | 394411677ea9b3e5bb520c39db6e39f751c35e28 (patch) | |
| tree | db95d211685ad295b147203390f57b1877dd45f6 | |
| parent | e85d8053511b12de9296b6acd3e963b3a6e82242 (diff) | |
Add functions to modify a search expression (#8293)
* Allows easier modifications of the search expression.
* Add proper `__toString()` instead of just returning the raw input string. Allows in particular showing the result of the actual parsing of the raw input string in the UI.
Needed for https://github.com/FreshRSS/FreshRSS/pull/8294
| -rw-r--r-- | app/Controllers/indexController.php | 2 | ||||
| -rw-r--r-- | app/Models/BooleanSearch.php | 100 | ||||
| -rw-r--r-- | app/Models/Search.php | 289 | ||||
| -rw-r--r-- | app/layout/header.phtml | 2 | ||||
| -rw-r--r-- | tests/app/Models/SearchTest.php | 165 |
5 files changed, 554 insertions, 4 deletions
diff --git a/app/Controllers/indexController.php b/app/Controllers/indexController.php index fa46c3f3a..d914e4eef 100644 --- a/app/Controllers/indexController.php +++ b/app/Controllers/indexController.php @@ -104,7 +104,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController { $this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title(); $title = FreshRSS_Context::$name; - $search = Minz_Request::paramString('search'); + $search = FreshRSS_Context::$search->__toString(); if ($search !== '') { $title = '“' . $search . '”'; } diff --git a/app/Models/BooleanSearch.php b/app/Models/BooleanSearch.php index 720cbf78e..68885cde8 100644 --- a/app/Models/BooleanSearch.php +++ b/app/Models/BooleanSearch.php @@ -52,6 +52,12 @@ class FreshRSS_BooleanSearch implements \Stringable { $this->parseParentheses($input, $level) || $this->parseOrSegments($input); } + public function __clone() { + foreach ($this->searches as $key => $search) { + $this->searches[$key] = clone $search; + } + } + /** * Parse the user queries (saved searches) by name and expand them in the input string. */ @@ -431,9 +437,101 @@ class FreshRSS_BooleanSearch implements \Stringable { $this->searches[] = $search; } + /** + * Modify the first compatible search of the Boolean expression, or add it at the beginning. + * Useful to modify some search parameters. + * @return FreshRSS_BooleanSearch a new instance, modified. + */ + public function enforce(FreshRSS_Search $search): self { + $result = clone $this; + $result->raw_input = ''; + + if (count($result->searches) === 1 && $result->searches[0] instanceof FreshRSS_Search) { + $result->searches[0] = $result->searches[0]->enforce($search); + return $result; + } + if (count($result->searches) === 2) { + foreach ($result->searches as $booleanSearch) { + if (!($booleanSearch instanceof FreshRSS_BooleanSearch)) { + break; + } + if ($booleanSearch->operator() === 'AND') { + if (count($booleanSearch->searches) === 1 && $booleanSearch->searches[0] instanceof FreshRSS_Search && + $booleanSearch->searches[0]->hasSameOperators($search)) { + $booleanSearch->searches[0] = $search; + return $result; + } + } + } + } + + if (count($result->searches) > 1 || (count($result->searches) > 0 && $result->searches[0] instanceof FreshRSS_Search)) { + // Wrap the existing searches in a new BooleanSearch if needed + $wrap = new FreshRSS_BooleanSearch(''); + foreach ($result->searches as $existingSearch) { + $wrap->add($existingSearch); + } + if (count($wrap->searches) > 0) { + $result->searches = [$wrap]; + } + } + array_unshift($result->searches, $search); + return $result; + } + + /** + * Remove the first compatible search of the Boolean expression, if any. + * Useful to modify some search parameters. + * @return FreshRSS_BooleanSearch a new instance, modified. + */ + public function remove(FreshRSS_Search $search): self { + $result = clone $this; + $result->raw_input = ''; + + if (count($result->searches) === 1 && $result->searches[0] instanceof FreshRSS_Search) { + $result->searches[0] = $result->searches[0]->remove($search); + return $result; + } + if (count($result->searches) === 2) { + foreach ($result->searches as $booleanSearch) { + if (!($booleanSearch instanceof FreshRSS_BooleanSearch)) { + break; + } + if ($booleanSearch->operator() === 'AND') { + if (count($booleanSearch->searches) === 1 && $booleanSearch->searches[0] instanceof FreshRSS_Search && + $booleanSearch->searches[0]->hasSameOperators($search)) { + array_shift($booleanSearch->searches); + return $result; + } + } + } + } + return $result; + } + #[\Override] public function __toString(): string { - return $this->getRawInput(); + $result = ''; + foreach ($this->searches as $search) { + $part = $search->__toString(); + if ($part === '') { + continue; + } + $operator = $search instanceof FreshRSS_BooleanSearch ? $search->operator() : 'OR'; + + if ((str_contains($part, ' ') || str_starts_with($part, '-')) && (count($this->searches) > 1 || in_array($operator, ['OR NOT', 'AND NOT'], true))) { + $part = '(' . $part . ')'; + } + + $result .= match ($operator) { + 'OR' => $result === '' ? '' : ' OR ', + 'OR NOT' => $result === '' ? '-' : ' OR -', + 'AND NOT' => $result === '' ? '-' : ' -', + 'AND' => $result === '' ? '' : ' ', + default => throw new InvalidArgumentException('Invalid operator: ' . $operator), + } . $part; + } + return trim($result); } /** @return string Plain text search query. Must be XML-encoded or URL-encoded depending on the situation */ diff --git a/app/Models/Search.php b/app/Models/Search.php index f459d07e3..752e28408 100644 --- a/app/Models/Search.php +++ b/app/Models/Search.php @@ -154,9 +154,296 @@ class FreshRSS_Search implements \Stringable { $this->parseSearch($input); } + private static function quote(string $s): string { + if (str_contains($s, ' ') || $s === '') { + return '"' . addcslashes($s, '\\"') . '"'; + } + return $s; + } + + private static function dateIntervalToString(?int $min, ?int $max): string { + if ($min === null && $max === null) { + return ''; + } + $s = ''; + if ($min !== null) { + $s .= date('Y-m-d\\TH:i:s', $min); + } + $s .= '/'; + if ($max !== null) { + $s .= date('Y-m-d\\TH:i:s', $max); + } + return $s; + } + + /** + * Return true if both searches have the same constraint parameters (even if the values differ), false otherwise. + */ + public function hasSameOperators(FreshRSS_Search $search): bool { + $properties = array_keys(get_object_vars($this)); + $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter + foreach ($properties as $property) { + // @phpstan-ignore property.dynamicName, property.dynamicName + if (gettype($this->$property) !== gettype($search->$property)) { + if (str_contains($property, 'min_') || str_contains($property, 'max_')) { + // Process {min_*, max_*} pairs together (for dates) + $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property); + // @phpstan-ignore property.dynamicName, property.dynamicName, property.dynamicName, property.dynamicName + if (($this->$property !== null || $this->$mate !== null) !== ($search->$property !== null || $search->$mate !== null)) { + return false; + } + } else { + return false; + } + } + // @phpstan-ignore property.dynamicName, property.dynamicName + if (is_array($this->$property) && is_array($search->$property)) { + // @phpstan-ignore property.dynamicName, property.dynamicName + if (count($this->$property) !== count($search->$property)) { + return false; + } + } + } + return true; + } + + /** + * Modifies this search by enforcing the constraint parameters of another search. + * @return FreshRSS_Search a new instance, modified. + */ + public function enforce(FreshRSS_Search $search): self { + $result = clone $this; + $properties = array_keys(get_object_vars($result)); + $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter + $result->raw_input = ''; + foreach ($properties as $property) { + // @phpstan-ignore property.dynamicName + if ($search->$property !== null) { + // @phpstan-ignore property.dynamicName, property.dynamicName + $result->$property = $search->$property; + if (str_contains($property, 'min_') || str_contains($property, 'max_')) { + // Process {min_*, max_*} pairs together (for dates) + $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property); + // @phpstan-ignore property.dynamicName, property.dynamicName + $result->$mate = $search->$mate; + } + } + } + return $result; + } + + /** + * Modifies this search by removing the constraints given by another search. + * @return FreshRSS_Search a new instance, modified. + */ + public function remove(FreshRSS_Search $search): self { + $result = clone $this; + $properties = array_keys(get_object_vars($result)); + $properties = array_diff($properties, ['raw_input']); // raw_input is not a constraint parameter + $result->raw_input = ''; + foreach ($properties as $property) { + // @phpstan-ignore property.dynamicName + if ($search->$property !== null) { + // @phpstan-ignore property.dynamicName + $result->$property = null; + if (str_contains($property, 'min_') || str_contains($property, 'max_')) { + // Process {min_*, max_*} pairs together (for dates) + $mate = str_contains($property, 'min_') ? str_replace('min_', 'max_', $property) : str_replace('max_', 'min_', $property); + // @phpstan-ignore property.dynamicName + $result->$mate = null; + } + } + } + return $result; + } + #[\Override] public function __toString(): string { - return $this->getRawInput(); + $result = ''; + + if ($this->getEntryIds() !== null) { + $result .= ' e:' . implode(',', $this->getEntryIds()); + } + if ($this->getFeedIds() !== null) { + $result .= ' f:' . implode(',', $this->getFeedIds()); + } + if ($this->getCategoryIds() !== null) { + $result .= ' c:' . implode(',', $this->getCategoryIds()); + } + if ($this->getLabelIds() !== null) { + foreach ($this->getLabelIds() as $ids) { + $result .= ' L:' . (is_array($ids) ? implode(',', $ids) : $ids); + } + } + if ($this->getLabelNames() !== null) { + foreach ($this->getLabelNames() as $names) { + $result .= ' labels:' . self::quote(implode(',', $names)); + } + } + + if ($this->getMinUserdate() !== null || $this->getMaxUserdate() !== null) { + $result .= ' userdate:' . self::dateIntervalToString($this->getMinUserdate(), $this->getMaxUserdate()); + } + if ($this->getMinPubdate() !== null || $this->getMaxPubdate() !== null) { + $result .= ' pubdate:' . self::dateIntervalToString($this->getMinPubdate(), $this->getMaxPubdate()); + } + if ($this->getMinDate() !== null || $this->getMaxDate() !== null) { + $result .= ' date:' . self::dateIntervalToString($this->getMinDate(), $this->getMaxDate()); + } + + if ($this->getIntitleRegex() !== null) { + foreach ($this->getIntitleRegex() as $s) { + $result .= ' intitle:' . $s; + } + } + if ($this->getIntitle() !== null) { + foreach ($this->getIntitle() as $s) { + $result .= ' intitle:' . self::quote($s); + } + } + if ($this->getIntextRegex() !== null) { + foreach ($this->getIntextRegex() as $s) { + $result .= ' intext:' . $s; + } + } + if ($this->getIntext() !== null) { + foreach ($this->getIntext() as $s) { + $result .= ' intext:' . self::quote($s); + } + } + if ($this->getAuthorRegex() !== null) { + foreach ($this->getAuthorRegex() as $s) { + $result .= ' author:' . $s; + } + } + if ($this->getAuthor() !== null) { + foreach ($this->getAuthor() as $s) { + $result .= ' author:' . self::quote($s); + } + } + if ($this->getInurlRegex() !== null) { + foreach ($this->getInurlRegex() as $s) { + $result .= ' inurl:' . $s; + } + } + if ($this->getInurl() !== null) { + foreach ($this->getInurl() as $s) { + $result .= ' inurl:' . self::quote($s); + } + } + if ($this->getTagsRegex() !== null) { + foreach ($this->getTagsRegex() as $s) { + $result .= ' #' . $s; + } + } + if ($this->getTags() !== null) { + foreach ($this->getTags() as $s) { + $result .= ' #' . self::quote($s); + } + } + if ($this->getSearchRegex() !== null) { + foreach ($this->getSearchRegex() as $s) { + $result .= ' ' . $s; + } + } + if ($this->getSearch() !== null) { + foreach ($this->getSearch() as $s) { + $result .= ' ' . self::quote($s); + } + } + + if ($this->getNotEntryIds() !== null) { + $result .= ' -e:' . implode(',', $this->getNotEntryIds()); + } + if ($this->getNotFeedIds() !== null) { + $result .= ' -f:' . implode(',', $this->getNotFeedIds()); + } + if ($this->getNotCategoryIds() !== null) { + $result .= ' -c:' . implode(',', $this->getNotCategoryIds()); + } + if ($this->getNotLabelIds() !== null) { + foreach ($this->getNotLabelIds() as $ids) { + $result .= ' -L:' . (is_array($ids) ? implode(',', $ids) : $ids); + } + } + if ($this->getNotLabelNames() !== null) { + foreach ($this->getNotLabelNames() as $names) { + $result .= ' -labels:' . self::quote(implode(',', $names)); + } + } + + if ($this->getNotMinUserdate() !== null || $this->getNotMaxUserdate() !== null) { + $result .= ' -userdate:' . self::dateIntervalToString($this->getNotMinUserdate(), $this->getNotMaxUserdate()); + } + if ($this->getNotMinPubdate() !== null || $this->getNotMaxPubdate() !== null) { + $result .= ' -pubdate:' . self::dateIntervalToString($this->getNotMinPubdate(), $this->getNotMaxPubdate()); + } + if ($this->getNotMinDate() !== null || $this->getNotMaxDate() !== null) { + $result .= ' -date:' . self::dateIntervalToString($this->getNotMinDate(), $this->getNotMaxDate()); + } + + if ($this->getNotIntitleRegex() !== null) { + foreach ($this->getNotIntitleRegex() as $s) { + $result .= ' -intitle:' . $s; + } + } + if ($this->getNotIntitle() !== null) { + foreach ($this->getNotIntitle() as $s) { + $result .= ' -intitle:' . self::quote($s); + } + } + if ($this->getNotIntextRegex() !== null) { + foreach ($this->getNotIntextRegex() as $s) { + $result .= ' -intext:' . $s; + } + } + if ($this->getNotIntext() !== null) { + foreach ($this->getNotIntext() as $s) { + $result .= ' -intext:' . self::quote($s); + } + } + if ($this->getNotAuthorRegex() !== null) { + foreach ($this->getNotAuthorRegex() as $s) { + $result .= ' -author:' . $s; + } + } + if ($this->getNotAuthor() !== null) { + foreach ($this->getNotAuthor() as $s) { + $result .= ' -author:' . self::quote($s); + } + } + if ($this->getNotInurlRegex() !== null) { + foreach ($this->getNotInurlRegex() as $s) { + $result .= ' -inurl:' . $s; + } + } + if ($this->getNotInurl() !== null) { + foreach ($this->getNotInurl() as $s) { + $result .= ' -inurl:' . self::quote($s); + } + } + if ($this->getNotTagsRegex() !== null) { + foreach ($this->getNotTagsRegex() as $s) { + $result .= ' -#' . $s; + } + } + if ($this->getNotTags() !== null) { + foreach ($this->getNotTags() as $s) { + $result .= ' -#' . self::quote($s); + } + } + if ($this->getNotSearchRegex() !== null) { + foreach ($this->getNotSearchRegex() as $s) { + $result .= ' -' . $s; + } + } + if ($this->getNotSearch() !== null) { + foreach ($this->getNotSearch() as $s) { + $result .= ' -' . self::quote($s); + } + } + + return trim($result); } public function getRawInput(): string { diff --git a/app/layout/header.phtml b/app/layout/header.phtml index 2fd6bb548..5c6ea63a4 100644 --- a/app/layout/header.phtml +++ b/app/layout/header.phtml @@ -40,7 +40,7 @@ <?php } ?> <div class="stick"> <input type="search" name="search" id="search" - value="<?= Minz_Request::paramString('search') ?>" + value="<?= FreshRSS_Context::$search->__toString() ?>" placeholder="<?= _t('gen.menu.search') ?>" /> <button class="btn" type="submit"><?= _i('search') ?></button> </div> diff --git a/tests/app/Models/SearchTest.php b/tests/app/Models/SearchTest.php index b7cb43f8f..92024c7b6 100644 --- a/tests/app/Models/SearchTest.php +++ b/tests/app/Models/SearchTest.php @@ -915,4 +915,169 @@ final class SearchTest extends \PHPUnit\Framework\TestCase { ], ]; } + + #[DataProvider('provideToString')] + public static function test__toString(string $input): void { + $search = new FreshRSS_Search($input); + $expected = str_replace("\n", ' ', $input); + self::assertSame($expected, $search->__toString()); + } + + /** + * @return array<array<string>> + */ + public static function provideToString(): array { + return [ + [ + <<<'EOD' + e:1,2 f:10,11 c:20,21 L:30,31 labels:"My label,My other label" + userdate:2025-01-01T00:00:00/2026-01-01T00:00:00 + pubdate:2025-02-01T00:00:00/2026-01-01T00:00:00 + date:2025-03-01T00:00:00/2026-01-01T00:00:00 + intitle:/Interesting/i intitle:good + intext:/Interesting/i intext:good + author:/Bob/ author:Alice + inurl:/https/ inurl:example.net + #/tag2/ #tag1 + /search_regex/i "quoted search" search + -e:3,4 -f:12,13 -c:22,23 -L:32,33 -labels:"Not label,Not other label" + -userdate:2025-06-01T00:00:00/2025-09-01T00:00:00 + -pubdate:2025-06-01T00:00:00/2025-09-01T00:00:00 + -date:2025-06-01T00:00:00/2025-09-01T00:00:00 + -intitle:/Spam/i -intitle:bad + -intext:/Spam/i -intext:bad + -author:/Dave/i -author:Charlie + -inurl:/ftp/ -inurl:example.com + -#/tag4/ -#tag3 + -/not_regex/i -"not quoted" -not_search + EOD + ], + ]; + } + + #[DataProvider('provideBooleanSearchToString')] + public static function testBooleanSearch__toString(string $input, string $expected): void { + $search = new FreshRSS_BooleanSearch($input); + self::assertSame($expected, $search->__toString()); + } + + /** + * @return array<array<string>> + */ + public static function provideBooleanSearchToString(): array { + return [ + [ + '((a OR b) (c OR d) -e) OR -(f g)', + '((a OR b) (c OR d) (-e)) OR -(f g)', + ], + [ + '((a OR b) ((c) OR ((d))) (-e)) OR -(((f g)))', + '((a OR b) (c OR d) (-e)) OR -(f g)', + ], + [ + '!((b c))', + '-(b c)', + ], + [ + '(a) OR !((b c))', + 'a OR -(b c)', + ], + [ + '((a) (b))', + 'a b', + ], + [ + '((a) OR (b))', + 'a OR b', + ], + [ + ' ( !( !( ( a ) ) ) ) ( ) ', + '-(-a)', + ], + [ + '-intitle:a -inurl:b', + '-intitle:a -inurl:b', + ], + ]; + } + + #[DataProvider('provideHasSameOperators')] + public function testHasSameOperators(string $input1, string $input2, bool $expected): void { + $search1 = new FreshRSS_Search($input1); + $search2 = new FreshRSS_Search($input2); + self::assertSame($expected, $search1->hasSameOperators($search2)); + } + + /** + * @return array<array{string,string,bool}> + */ + public static function provideHasSameOperators(): array { + return [ + ['', '', true], + ['intitle:a intext:b', 'intitle:c intext:d', true], + ['intitle:a intext:b', 'intitle:c inurl:d', false], + ]; + } + + #[DataProvider('provideBooleanSearchEnforce')] + public function testBooleanSearchEnforce(string $initialInput, string $enforceInput, string $expectedOutput): void { + $booleanSearch = new FreshRSS_BooleanSearch($initialInput); + $searchToEnforce = new FreshRSS_Search($enforceInput); + $newBooleanSearch = $booleanSearch->enforce($searchToEnforce); + self::assertNotSame($booleanSearch, $newBooleanSearch); + self::assertSame($expectedOutput, $newBooleanSearch->__toString()); + } + + /** + * @return array<array{string,string,string}> + */ + public static function provideBooleanSearchEnforce(): array { + return [ + ['', 'intitle:b', 'intitle:b'], + ['intitle:a', 'intitle:b', 'intitle:b'], + ['a', 'intitle:b', 'intitle:b a'], + ['intitle:a intext:a', 'intitle:b', 'intitle:b intext:a'], + ['intitle:a inurl:a', 'intitle:b', 'intitle:b inurl:a'], + ['intitle:a OR inurl:a', 'intitle:b', 'intitle:b (intitle:a OR inurl:a)'], + ['intitle:a ((inurl:a) (intitle:c))', 'intitle:b', 'intitle:b (inurl:a intitle:c)'], + ['intitle:a ((inurl:a) OR (intitle:c))', 'intitle:b', 'intitle:b (inurl:a OR intitle:c)'], + ['(intitle:a) (inurl:a)', 'intitle:b', 'intitle:b inurl:a'], + ['(inurl:a) (intitle:a)', 'intitle:b', 'inurl:a intitle:b'], + ['(a b) OR (c d)', 'e', 'e ((a b) OR (c d))'], + ['(a b) (c d)', 'e', 'e ((a b) (c d))'], + ['(a b)', 'e', 'e (a b)'], + ['date:2024/', 'date:/2025', 'date:/2025-12-31T23:59:59'], + ['a', 'date:/2025', 'date:/2025-12-31T23:59:59 a'], + ]; + } + + #[DataProvider('provideBooleanSearchRemove')] + public function testBooleanSearchRemove(string $initialInput, string $removeInput, string $expectedOutput): void { + $booleanSearch = new FreshRSS_BooleanSearch($initialInput); + $searchToRemove = new FreshRSS_Search($removeInput); + $newBooleanSearch = $booleanSearch->remove($searchToRemove); + self::assertNotSame($booleanSearch, $newBooleanSearch); + self::assertSame($expectedOutput, $newBooleanSearch->__toString()); + } + + /** + * @return array<array{string,string,string}> + */ + public static function provideBooleanSearchRemove(): array { + return [ + ['', 'intitle:b', ''], + ['intitle:a', 'intitle:b', ''], + ['intitle:a intext:a', 'intitle:b', 'intext:a'], + ['intitle:a inurl:a', 'intitle:b', 'inurl:a'], + ['intitle:a OR inurl:a', 'intitle:b', 'intitle:a OR inurl:a'], + ['intitle:a ((inurl:a) (intitle:c))', 'intitle:b', '(inurl:a intitle:c)'], + ['intitle:a ((inurl:a) OR (intitle:c))', 'intitle:b', '(inurl:a OR intitle:c)'], + ['(intitle:a) (inurl:a)', 'intitle:b', 'inurl:a'], + ['(inurl:a) (intitle:a)', 'intitle:b', 'inurl:a'], + ['e ((a b) OR (c d))', 'e', '((a b) OR (c d))'], + ['e ((a b) (c d))', 'e', '((a b) (c d))'], + ['date:2024/', 'date:/2025', ''], + ['date:2024/ a', 'date:/2025', 'a'], + ]; + } } |
