diff options
| -rw-r--r-- | app/Models/BooleanSearch.php | 216 | ||||
| -rw-r--r-- | app/Models/Entry.php | 178 | ||||
| -rw-r--r-- | app/Models/EntryDAO.php | 510 | ||||
| -rw-r--r-- | app/Models/EntryDAOPGSQL.php | 8 | ||||
| -rw-r--r-- | app/Models/EntryDAOSQLite.php | 16 | ||||
| -rw-r--r-- | app/Models/UserConfiguration.php | 2 | ||||
| -rw-r--r-- | app/Models/UserQuery.php | 5 | ||||
| -rw-r--r-- | app/layout/nav_menu.phtml | 18 | ||||
| -rw-r--r-- | docs/en/users/03_Main_view.md | 7 | ||||
| -rw-r--r-- | docs/fr/users/03_Main_view.md | 12 | ||||
| -rw-r--r-- | p/api/fever.php | 2 | ||||
| -rw-r--r-- | tests/app/Models/SearchTest.php | 35 |
12 files changed, 646 insertions, 363 deletions
diff --git a/app/Models/BooleanSearch.php b/app/Models/BooleanSearch.php index 774e42073..4cb74865a 100644 --- a/app/Models/BooleanSearch.php +++ b/app/Models/BooleanSearch.php @@ -7,17 +7,210 @@ class FreshRSS_BooleanSearch { /** @var string */ private $raw_input = ''; + /** @var array<FreshRSS_BooleanSearch|FreshRSS_Search> */ private $searches = array(); - public function __construct($input) { + /** @var string 'AND' or 'OR' */ + private $operator; + + public function __construct(string $input, int $level = 0, $operator = 'AND') { + $this->operator = $operator; $input = trim($input); if ($input == '') { return; } $this->raw_input = $input; - $input = preg_replace('/:"(.*?)"/', ':"\1"', $input); - $input = preg_replace('/(?<=[\s!-]|^)"(.*?)"/', '"\1"', $input); + if ($level === 0) { + $input = preg_replace('/:"(.*?)"/', ':"\1"', $input); + $input = preg_replace('/(?<=[\s!-]|^)"(.*?)"/', '"\1"', $input); + + $input = $this->parseUserQueryNames($input); + $input = $this->parseUserQueryIds($input); + } + + // Either parse everything as a series of BooleanSearch's combined by implicit AND + // or parse everything as a series of Search's combined by explicit OR + $this->parseParentheses($input, $level) || $this->parseOrSegments($input); + } + + /** + * Parse the user queries (saved searches) by name and expand them in the input string. + */ + private function parseUserQueryNames(string $input): string { + $all_matches = []; + if (preg_match_all('/\bsearch:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) { + $all_matches[] = $matches; + + } + if (preg_match_all('/\bsearch:(?P<search>[^\s"]*)/', $input, $matches)) { + $all_matches[] = $matches; + } + + if (!empty($all_matches)) { + /** @var array<string,FreshRSS_UserQuery> */ + $queries = []; + foreach (FreshRSS_Context::$user_conf->queries as $raw_query) { + $query = new FreshRSS_UserQuery($raw_query); + $queries[$query->getName()] = $query; + } + + $fromS = []; + $toS = []; + foreach ($all_matches as $matches) { + for ($i = count($matches['search']) - 1; $i >= 0; $i--) { + $name = trim($matches['search'][$i]); + if (!empty($queries[$name])) { + $fromS[] = $matches[0][$i]; + $toS[] = '(' . trim($queries[$name]->getSearch()) . ')'; + } + } + } + + $input = str_replace($fromS, $toS, $input); + } + return $input; + } + + /** + * Parse the user queries (saved searches) by ID and expand them in the input string. + */ + private function parseUserQueryIds(string $input): string { + $all_matches = []; + + if (preg_match_all('/\bS:(?P<search>\d+)/', $input, $matches)) { + $all_matches[] = $matches; + } + + if (!empty($all_matches)) { + /** @var array<string,FreshRSS_UserQuery> */ + $queries = []; + foreach (FreshRSS_Context::$user_conf->queries as $raw_query) { + $query = new FreshRSS_UserQuery($raw_query); + $queries[] = $query; + } + + $fromS = []; + $toS = []; + foreach ($all_matches as $matches) { + for ($i = count($matches['search']) - 1; $i >= 0; $i--) { + // Index starting from 1 + $id = intval(trim($matches['search'][$i])) - 1; + if (!empty($queries[$id])) { + $fromS[] = $matches[0][$i]; + $toS[] = '(' . trim($queries[$id]->getSearch()) . ')'; + } + } + } + + $input = str_replace($fromS, $toS, $input); + } + return $input; + } + + /** @return bool True if some parenthesis logic took over, false otherwise */ + private function parseParentheses(string $input, int $level): bool { + $input = trim($input); + $length = strlen($input); + $i = 0; + $before = ''; + $hasParenthesis = false; + $nextOperator = 'AND'; + while ($i < $length) { + $c = $input[$i]; + + if ($c === '(') { + $hasParenthesis = true; + + $before = trim($before); + if (preg_match('/\bOR$/i', $before)) { + // Trim trailing OR + $before = substr($before, 0, -2); + + // The text prior to the OR is a BooleanSearch + $searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator); + if (count($searchBefore->searches()) > 0) { + $this->searches[] = $searchBefore; + } + $before = ''; + + // The next BooleanSearch will have to be combined with OR instead of default AND + $nextOperator = 'OR'; + } elseif ($before !== '') { + // The text prior to the opening parenthesis is a BooleanSearch + $searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator); + if (count($searchBefore->searches()) > 0) { + $this->searches[] = $searchBefore; + } + $before = ''; + } + + // Search the matching closing parenthesis + $parentheses = 1; + $sub = ''; + $i++; + while ($i < $length) { + $c = $input[$i]; + if ($c === '(') { + // One nested level deeper + $parentheses++; + $sub .= $c; + } elseif ($c === ')') { + $parentheses--; + if ($parentheses === 0) { + // Found the matching closing parenthesis + $searchSub = new FreshRSS_BooleanSearch($sub, $level + 1, $nextOperator); + $nextOperator = 'AND'; + if (count($searchSub->searches()) > 0) { + $this->searches[] = $searchSub; + } + $sub = ''; + break; + } else { + $sub .= $c; + } + } else { + $sub .= $c; + } + $i++; + } + // $sub = trim($sub); + // if ($sub != '') { + // // TODO: Consider throwing an error or warning in case of non-matching parenthesis + // } + // } elseif ($c === ')') { + // // TODO: Consider throwing an error or warning in case of non-matching parenthesis + } else { + $before .= $c; + } + $i++; + } + if ($hasParenthesis) { + $before = trim($before); + if (preg_match('/^OR\b/i', $before)) { + // The next BooleanSearch will have to be combined with OR instead of default AND + $nextOperator = 'OR'; + // Trim leading OR + $before = substr($before, 2); + } + + // The remaining text after the last parenthesis is a BooleanSearch + $searchBefore = new FreshRSS_BooleanSearch($before, $level + 1, $nextOperator); + $nextOperator = 'AND'; + if (count($searchBefore->searches()) > 0) { + $this->searches[] = $searchBefore; + } + return true; + } + // There was no parenthesis logic to apply + return false; + } + + private function parseOrSegments(string $input) { + $input = trim($input); + if ($input == '') { + return; + } $splits = preg_split('/\b(OR)\b/i', $input, -1, PREG_SPLIT_DELIM_CAPTURE); $segment = ''; @@ -43,16 +236,23 @@ class FreshRSS_BooleanSearch { } } + /** + * Either a list of FreshRSS_BooleanSearch combined by implicit AND + * or a series of FreshRSS_Search combined by explicit OR + * @return array<FreshRSS_BooleanSearch|FreshRSS_Search> + */ public function searches() { return $this->searches; } + /** @return string 'AND' or 'OR' depending on how this BooleanSearch should be combined */ + public function operator(): string { + return $this->operator; + } + + /** @param FreshRSS_BooleanSearch|FreshRSS_Search $search */ public function add($search) { - if ($search instanceof FreshRSS_Search) { - $this->searches[] = $search; - return $search; - } - return null; + $this->searches[] = $search; } public function __toString(): string { diff --git a/app/Models/Entry.php b/app/Models/Entry.php index c3cb337fe..57b0e0b60 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -325,108 +325,116 @@ class FreshRSS_Entry extends Minz_Model { } public function matches(FreshRSS_BooleanSearch $booleanSearch): bool { - if (count($booleanSearch->searches()) <= 0) { - return true; - } + $ok = true; foreach ($booleanSearch->searches() as $filter) { - $ok = true; - if ($filter->getMinDate()) { - $ok &= strnatcmp($this->id, $filter->getMinDate() . '000000') >= 0; - } - if ($ok && $filter->getNotMinDate()) { - $ok &= strnatcmp($this->id, $filter->getNotMinDate() . '000000') < 0; - } - if ($ok && $filter->getMaxDate()) { - $ok &= strnatcmp($this->id, $filter->getMaxDate() . '000000') <= 0; - } - if ($ok && $filter->getNotMaxDate()) { - $ok &= strnatcmp($this->id, $filter->getNotMaxDate() . '000000') > 0; - } - if ($ok && $filter->getMinPubdate()) { - $ok &= $this->date >= $filter->getMinPubdate(); - } - if ($ok && $filter->getNotMinPubdate()) { - $ok &= $this->date < $filter->getNotMinPubdate(); - } - if ($ok && $filter->getMaxPubdate()) { - $ok &= $this->date <= $filter->getMaxPubdate(); - } - if ($ok && $filter->getNotMaxPubdate()) { - $ok &= $this->date > $filter->getNotMaxPubdate(); - } - if ($ok && $filter->getFeedIds()) { - $ok &= in_array($this->feedId, $filter->getFeedIds()); - } - if ($ok && $filter->getNotFeedIds()) { - $ok &= !in_array($this->feedId, $filter->getFeedIds()); - } - if ($ok && $filter->getAuthor()) { - foreach ($filter->getAuthor() as $author) { - $ok &= stripos(implode(';', $this->authors), $author) !== false; + if ($filter instanceof FreshRSS_BooleanSearch) { + // BooleanSearches are combined by AND (default) or OR (special case) operator and are recursive + if ($filter->operator() === 'OR') { + $ok |= $this->matches($filter); + } else { + $ok &= $this->matches($filter); } - } - if ($ok && $filter->getNotAuthor()) { - foreach ($filter->getNotAuthor() as $author) { - $ok &= stripos(implode(';', $this->authors), $author) === false; + } elseif ($filter instanceof FreshRSS_Search) { + // Searches are combined by OR and are not recursive + $ok = true; + if ($filter->getMinDate()) { + $ok &= strnatcmp($this->id, $filter->getMinDate() . '000000') >= 0; } - } - if ($ok && $filter->getIntitle()) { - foreach ($filter->getIntitle() as $title) { - $ok &= stripos($this->title, $title) !== false; + if ($ok && $filter->getNotMinDate()) { + $ok &= strnatcmp($this->id, $filter->getNotMinDate() . '000000') < 0; } - } - if ($ok && $filter->getNotIntitle()) { - foreach ($filter->getNotIntitle() as $title) { - $ok &= stripos($this->title, $title) === false; + if ($ok && $filter->getMaxDate()) { + $ok &= strnatcmp($this->id, $filter->getMaxDate() . '000000') <= 0; } - } - if ($ok && $filter->getTags()) { - foreach ($filter->getTags() as $tag2) { - $found = false; - foreach ($this->tags as $tag1) { - if (strcasecmp($tag1, $tag2) === 0) { - $found = true; + if ($ok && $filter->getNotMaxDate()) { + $ok &= strnatcmp($this->id, $filter->getNotMaxDate() . '000000') > 0; + } + if ($ok && $filter->getMinPubdate()) { + $ok &= $this->date >= $filter->getMinPubdate(); + } + if ($ok && $filter->getNotMinPubdate()) { + $ok &= $this->date < $filter->getNotMinPubdate(); + } + if ($ok && $filter->getMaxPubdate()) { + $ok &= $this->date <= $filter->getMaxPubdate(); + } + if ($ok && $filter->getNotMaxPubdate()) { + $ok &= $this->date > $filter->getNotMaxPubdate(); + } + if ($ok && $filter->getFeedIds()) { + $ok &= in_array($this->feedId, $filter->getFeedIds()); + } + if ($ok && $filter->getNotFeedIds()) { + $ok &= !in_array($this->feedId, $filter->getFeedIds()); + } + if ($ok && $filter->getAuthor()) { + foreach ($filter->getAuthor() as $author) { + $ok &= stripos(implode(';', $this->authors), $author) !== false; + } + } + if ($ok && $filter->getNotAuthor()) { + foreach ($filter->getNotAuthor() as $author) { + $ok &= stripos(implode(';', $this->authors), $author) === false; + } + } + if ($ok && $filter->getIntitle()) { + foreach ($filter->getIntitle() as $title) { + $ok &= stripos($this->title, $title) !== false; + } + } + if ($ok && $filter->getNotIntitle()) { + foreach ($filter->getNotIntitle() as $title) { + $ok &= stripos($this->title, $title) === false; + } + } + if ($ok && $filter->getTags()) { + foreach ($filter->getTags() as $tag2) { + $found = false; + foreach ($this->tags as $tag1) { + if (strcasecmp($tag1, $tag2) === 0) { + $found = true; + } } + $ok &= $found; } - $ok &= $found; } - } - if ($ok && $filter->getNotTags()) { - foreach ($filter->getNotTags() as $tag2) { - $found = false; - foreach ($this->tags as $tag1) { - if (strcasecmp($tag1, $tag2) === 0) { - $found = true; + if ($ok && $filter->getNotTags()) { + foreach ($filter->getNotTags() as $tag2) { + $found = false; + foreach ($this->tags as $tag1) { + if (strcasecmp($tag1, $tag2) === 0) { + $found = true; + } } + $ok &= !$found; } - $ok &= !$found; } - } - if ($ok && $filter->getInurl()) { - foreach ($filter->getInurl() as $url) { - $ok &= stripos($this->link, $url) !== false; + if ($ok && $filter->getInurl()) { + foreach ($filter->getInurl() as $url) { + $ok &= stripos($this->link, $url) !== false; + } } - } - if ($ok && $filter->getNotInurl()) { - foreach ($filter->getNotInurl() as $url) { - $ok &= stripos($this->link, $url) === false; + if ($ok && $filter->getNotInurl()) { + foreach ($filter->getNotInurl() as $url) { + $ok &= stripos($this->link, $url) === false; + } } - } - if ($ok && $filter->getSearch()) { - foreach ($filter->getSearch() as $needle) { - $ok &= (stripos($this->title, $needle) !== false || stripos($this->content, $needle) !== false); + if ($ok && $filter->getSearch()) { + foreach ($filter->getSearch() as $needle) { + $ok &= (stripos($this->title, $needle) !== false || stripos($this->content, $needle) !== false); + } } - } - if ($ok && $filter->getNotSearch()) { - foreach ($filter->getNotSearch() as $needle) { - $ok &= (stripos($this->title, $needle) === false && stripos($this->content, $needle) === false); + if ($ok && $filter->getNotSearch()) { + foreach ($filter->getNotSearch() as $needle) { + $ok &= (stripos($this->title, $needle) === false && stripos($this->content, $needle) === false); + } + } + if ($ok) { + return true; } - } - if ($ok) { - return true; } } - return false; + return $ok; } public function applyFilterActions(array $titlesAsRead = []) { diff --git a/app/Models/EntryDAO.php b/app/Models/EntryDAO.php index 8f248e20f..426c294c4 100644 --- a/app/Models/EntryDAO.php +++ b/app/Models/EntryDAO.php @@ -2,23 +2,27 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable { - public function isCompressed(): bool { + public static function isCompressed(): bool { return true; } - public function hasNativeHex(): bool { + public static function hasNativeHex(): bool { return true; } - public function sqlHexDecode(string $x): string { + protected static function sqlConcat($s1, $s2) { + return 'CONCAT(' . $s1 . ',' . $s2 . ')'; //MySQL + } + + public static function sqlHexDecode(string $x): string { return 'unhex(' . $x . ')'; } - public function sqlHexEncode(string $x): string { + public static function sqlHexEncode(string $x): string { return 'hex(' . $x . ')'; } - public function sqlIgnoreConflict(string $sql): string { + public static function sqlIgnoreConflict(string $sql): string { return str_replace('INSERT INTO ', 'INSERT IGNORE INTO ', $sql); } @@ -90,14 +94,14 @@ SQL; public function addEntry(array $valuesTmp, bool $useTmpTable = true) { if ($this->addEntryPrepared == null) { - $sql = $this->sqlIgnoreConflict( + $sql = static::sqlIgnoreConflict( 'INSERT INTO `_' . ($useTmpTable ? 'entrytmp' : 'entry') . '` (id, guid, title, author, ' - . ($this->isCompressed() ? 'content_bin' : 'content') + . (static::isCompressed() ? 'content_bin' : 'content') . ', link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags) ' . 'VALUES(:id, :guid, :title, :author, ' - . ($this->isCompressed() ? 'COMPRESS(:content)' : ':content') + . (static::isCompressed() ? 'COMPRESS(:content)' : ':content') . ', :link, :date, :last_seen, ' - . $this->sqlHexDecode(':hash') + . static::sqlHexDecode(':hash') . ', :is_read, :is_favorite, :id_feed, :tags)'); $this->addEntryPrepared = $this->pdo->prepare($sql); } @@ -132,7 +136,7 @@ SQL; $valuesTmp['tags'] = safe_utf8($valuesTmp['tags']); $this->addEntryPrepared->bindParam(':tags', $valuesTmp['tags']); - if ($this->hasNativeHex()) { + if (static::hasNativeHex()) { $this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']); } else { $valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']); @@ -189,9 +193,9 @@ SQL; if ($this->updateEntryPrepared === null) { $sql = 'UPDATE `_entry` ' . 'SET title=:title, author=:author, ' - . ($this->isCompressed() ? 'content_bin=COMPRESS(:content)' : 'content=:content') + . (static::isCompressed() ? 'content_bin=COMPRESS(:content)' : 'content=:content') . ', link=:link, date=:date, `lastSeen`=:last_seen' - . ', hash=' . $this->sqlHexDecode(':hash') + . ', hash=' . static::sqlHexDecode(':hash') . ', is_read=COALESCE(:is_read, is_read)' . ', tags=:tags ' . 'WHERE id_feed=:id_feed AND guid=:guid'; @@ -226,7 +230,7 @@ SQL; $valuesTmp['tags'] = safe_utf8($valuesTmp['tags']); $this->updateEntryPrepared->bindParam(':tags', $valuesTmp['tags']); - if ($this->hasNativeHex()) { + if (static::hasNativeHex()) { $this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hash']); } else { $valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']); @@ -649,8 +653,8 @@ SQL; public function selectAll() { $sql = 'SELECT id, guid, title, author, ' - . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') - . ', link, date, `lastSeen`, ' . $this->sqlHexEncode('hash') . ' AS hash, is_read, is_favorite, id_feed, tags ' + . (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') + . ', link, date, `lastSeen`, ' . static::sqlHexEncode('hash') . ' AS hash, is_read, is_favorite, id_feed, tags ' . 'FROM `_entry`'; $stm = $this->pdo->query($sql); while ($row = $stm->fetch(PDO::FETCH_ASSOC)) { @@ -662,7 +666,7 @@ SQL; public function searchByGuid($id_feed, $guid) { // un guid est unique pour un flux donné $sql = 'SELECT id, guid, title, author, ' - . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') + . (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') . ', link, date, is_read, is_favorite, id_feed, tags ' . 'FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid'; $stm = $this->pdo->prepare($sql); @@ -676,7 +680,7 @@ SQL; /** @return FreshRSS_Entry|null */ public function searchById($id) { $sql = 'SELECT id, guid, title, author, ' - . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') + . (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') . ', link, date, is_read, is_favorite, id_feed, tags ' . 'FROM `_entry` WHERE id=:id'; $stm = $this->pdo->prepare($sql); @@ -696,281 +700,301 @@ SQL; return isset($res[0]) ? $res[0] : null; } - protected function sqlConcat($s1, $s2) { - return 'CONCAT(' . $s1 . ',' . $s2 . ')'; //MySQL - } + /** @param FreshRSS_BooleanSearch $filters */ + public static function sqlBooleanSearch(string $alias, $filters, int $level = 0) { + $search = ''; + $values = []; - /** - * @param FreshRSS_BooleanSearch|null $filters - */ - protected function sqlListEntriesWhere(string $alias = '', $filters = null, int $state = FreshRSS_Entry::STATE_ALL, - string $order = 'DESC', string $firstId = '', int $date_min = 0) { - $search = ' '; - $values = array(); - if ($state & FreshRSS_Entry::STATE_NOT_READ) { - if (!($state & FreshRSS_Entry::STATE_READ)) { - $search .= 'AND ' . $alias . 'is_read=0 '; + $isOpen = false; + foreach ($filters->searches() as $filter) { + if ($filter == null) { + continue; } - } elseif ($state & FreshRSS_Entry::STATE_READ) { - $search .= 'AND ' . $alias . 'is_read=1 '; - } - if ($state & FreshRSS_Entry::STATE_FAVORITE) { - if (!($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) { - $search .= 'AND ' . $alias . 'is_favorite=1 '; - } - } elseif ($state & FreshRSS_Entry::STATE_NOT_FAVORITE) { - $search .= 'AND ' . $alias . 'is_favorite=0 '; - } - - switch ($order) { - case 'DESC': - case 'ASC': - break; - default: - throw new FreshRSS_EntriesGetter_Exception('Bad order in Entry->listByType: [' . $order . ']!'); - } - if ($firstId !== '') { - $search .= 'AND ' . $alias . 'id ' . ($order === 'DESC' ? '<=' : '>=') . ' ? '; - $values[] = $firstId; - } - if ($date_min > 0) { - $search .= 'AND ' . $alias . 'id >= ? '; - $values[] = $date_min . '000000'; - } - if ($filters && count($filters->searches()) > 0) { - $isOpen = false; - foreach ($filters->searches() as $filter) { - if ($filter == null) { - continue; + if ($filter instanceof FreshRSS_BooleanSearch) { + // BooleanSearches are combined by AND (default) or OR (special case) operator and are recursive + list($filterValues, $filterSearch) = self::sqlBooleanSearch($alias, $filter, $level + 1); + $filterSearch = trim($filterSearch); + + if ($filterSearch !== '') { + if ($search !== '') { + $search .= $filter->operator(); + } + $search .= ' (' . $filterSearch . ') '; + $values = array_merge($values, $filterValues); } - $sub_search = ''; - - if ($filter->getEntryIds()) { - foreach ($filter->getEntryIds() as $entry_ids) { - $sub_search .= 'AND ' . $alias . 'id IN ('; - foreach ($entry_ids as $entry_id) { - $sub_search .= '?,'; - $values[] = $entry_id; - } - $sub_search = rtrim($sub_search, ','); - $sub_search .= ') '; + continue; + } + // Searches are combined by OR and are not recursive + $sub_search = ''; + if ($filter->getEntryIds()) { + foreach ($filter->getEntryIds() as $entry_ids) { + $sub_search .= 'AND ' . $alias . 'id IN ('; + foreach ($entry_ids as $entry_id) { + $sub_search .= '?,'; + $values[] = $entry_id; } + $sub_search = rtrim($sub_search, ','); + $sub_search .= ') '; } - if ($filter->getNotEntryIds()) { - foreach ($filter->getNotEntryIds() as $entry_ids) { - $sub_search .= 'AND ' . $alias . 'id NOT IN ('; - foreach ($entry_ids as $entry_id) { - $sub_search .= '?,'; - $values[] = $entry_id; - } - $sub_search = rtrim($sub_search, ','); - $sub_search .= ') '; + } + if ($filter->getNotEntryIds()) { + foreach ($filter->getNotEntryIds() as $entry_ids) { + $sub_search .= 'AND ' . $alias . 'id NOT IN ('; + foreach ($entry_ids as $entry_id) { + $sub_search .= '?,'; + $values[] = $entry_id; } + $sub_search = rtrim($sub_search, ','); + $sub_search .= ') '; } + } - if ($filter->getMinDate()) { - $sub_search .= 'AND ' . $alias . 'id >= ? '; - $values[] = "{$filter->getMinDate()}000000"; - } - if ($filter->getMaxDate()) { - $sub_search .= 'AND ' . $alias . 'id <= ? '; - $values[] = "{$filter->getMaxDate()}000000"; - } - if ($filter->getMinPubdate()) { - $sub_search .= 'AND ' . $alias . 'date >= ? '; - $values[] = $filter->getMinPubdate(); - } - if ($filter->getMaxPubdate()) { - $sub_search .= 'AND ' . $alias . 'date <= ? '; - $values[] = $filter->getMaxPubdate(); - } + if ($filter->getMinDate()) { + $sub_search .= 'AND ' . $alias . 'id >= ? '; + $values[] = "{$filter->getMinDate()}000000"; + } + if ($filter->getMaxDate()) { + $sub_search .= 'AND ' . $alias . 'id <= ? '; + $values[] = "{$filter->getMaxDate()}000000"; + } + if ($filter->getMinPubdate()) { + $sub_search .= 'AND ' . $alias . 'date >= ? '; + $values[] = $filter->getMinPubdate(); + } + if ($filter->getMaxPubdate()) { + $sub_search .= 'AND ' . $alias . 'date <= ? '; + $values[] = $filter->getMaxPubdate(); + } - //Negation of date intervals must be combined by OR - if ($filter->getNotMinDate() || $filter->getNotMaxDate()) { - $sub_search .= 'AND ('; - if ($filter->getNotMinDate()) { - $sub_search .= $alias . 'id < ?'; - $values[] = "{$filter->getNotMinDate()}000000"; - if ($filter->getNotMaxDate()) { - $sub_search .= ' OR '; - } - } + //Negation of date intervals must be combined by OR + if ($filter->getNotMinDate() || $filter->getNotMaxDate()) { + $sub_search .= 'AND ('; + if ($filter->getNotMinDate()) { + $sub_search .= $alias . 'id < ?'; + $values[] = "{$filter->getNotMinDate()}000000"; if ($filter->getNotMaxDate()) { - $sub_search .= $alias . 'id > ?'; - $values[] = "{$filter->getNotMaxDate()}000000"; + $sub_search .= ' OR '; } - $sub_search .= ') '; } - if ($filter->getNotMinPubdate() || $filter->getNotMaxPubdate()) { - $sub_search .= 'AND ('; - if ($filter->getNotMinPubdate()) { - $sub_search .= $alias . 'date < ?'; - $values[] = $filter->getNotMinPubdate(); - if ($filter->getNotMaxPubdate()) { - $sub_search .= ' OR '; - } - } - if ($filter->getNotMaxPubdate()) { - $sub_search .= $alias . 'date > ?'; - $values[] = $filter->getNotMaxPubdate(); - } - $sub_search .= ') '; + if ($filter->getNotMaxDate()) { + $sub_search .= $alias . 'id > ?'; + $values[] = "{$filter->getNotMaxDate()}000000"; } - - if ($filter->getFeedIds()) { - foreach ($filter->getFeedIds() as $feed_ids) { - $sub_search .= 'AND ' . $alias . 'id_feed IN ('; - foreach ($feed_ids as $feed_id) { - $sub_search .= '?,'; - $values[] = $feed_id; - } - $sub_search = rtrim($sub_search, ','); - $sub_search .= ') '; + $sub_search .= ') '; + } + if ($filter->getNotMinPubdate() || $filter->getNotMaxPubdate()) { + $sub_search .= 'AND ('; + if ($filter->getNotMinPubdate()) { + $sub_search .= $alias . 'date < ?'; + $values[] = $filter->getNotMinPubdate(); + if ($filter->getNotMaxPubdate()) { + $sub_search .= ' OR '; } } - if ($filter->getNotFeedIds()) { - foreach ($filter->getNotFeedIds() as $feed_ids) { - $sub_search .= 'AND ' . $alias . 'id_feed NOT IN ('; - foreach ($feed_ids as $feed_id) { - $sub_search .= '?,'; - $values[] = $feed_id; - } - $sub_search = rtrim($sub_search, ','); - $sub_search .= ') '; - } + if ($filter->getNotMaxPubdate()) { + $sub_search .= $alias . 'date > ?'; + $values[] = $filter->getNotMaxPubdate(); } + $sub_search .= ') '; + } - if ($filter->getLabelIds()) { - foreach ($filter->getLabelIds() as $label_ids) { - if ($label_ids === '*') { - $sub_search .= 'AND EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) '; - } else { - $sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN ('; - foreach ($label_ids as $label_id) { - $sub_search .= '?,'; - $values[] = $label_id; - } - $sub_search = rtrim($sub_search, ','); - $sub_search .= ')) '; - } + if ($filter->getFeedIds()) { + foreach ($filter->getFeedIds() as $feed_ids) { + $sub_search .= 'AND ' . $alias . 'id_feed IN ('; + foreach ($feed_ids as $feed_id) { + $sub_search .= '?,'; + $values[] = $feed_id; } + $sub_search = rtrim($sub_search, ','); + $sub_search .= ') '; } - if ($filter->getNotLabelIds()) { - foreach ($filter->getNotLabelIds() as $label_ids) { - if ($label_ids === '*') { - $sub_search .= 'AND NOT EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) '; - } else { - $sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN ('; - foreach ($label_ids as $label_id) { - $sub_search .= '?,'; - $values[] = $label_id; - } - $sub_search = rtrim($sub_search, ','); - $sub_search .= ')) '; - } + } + if ($filter->getNotFeedIds()) { + foreach ($filter->getNotFeedIds() as $feed_ids) { + $sub_search .= 'AND ' . $alias . 'id_feed NOT IN ('; + foreach ($feed_ids as $feed_id) { + $sub_search .= '?,'; + $values[] = $feed_id; } + $sub_search = rtrim($sub_search, ','); + $sub_search .= ') '; } + } - if ($filter->getLabelNames()) { - foreach ($filter->getLabelNames() as $label_names) { - $sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN ('; - foreach ($label_names as $label_name) { + if ($filter->getLabelIds()) { + foreach ($filter->getLabelIds() as $label_ids) { + if ($label_ids === '*') { + $sub_search .= 'AND EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) '; + } else { + $sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN ('; + foreach ($label_ids as $label_id) { $sub_search .= '?,'; - $values[] = $label_name; + $values[] = $label_id; } $sub_search = rtrim($sub_search, ','); $sub_search .= ')) '; } } - if ($filter->getNotLabelNames()) { - foreach ($filter->getNotLabelNames() as $label_names) { - $sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN ('; - foreach ($label_names as $label_name) { + } + if ($filter->getNotLabelIds()) { + foreach ($filter->getNotLabelIds() as $label_ids) { + if ($label_ids === '*') { + $sub_search .= 'AND NOT EXISTS (SELECT et.id_tag FROM `_entrytag` et WHERE et.id_entry = ' . $alias . 'id) '; + } else { + $sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN ('; + foreach ($label_ids as $label_id) { $sub_search .= '?,'; - $values[] = $label_name; + $values[] = $label_id; } $sub_search = rtrim($sub_search, ','); $sub_search .= ')) '; } } + } - if ($filter->getAuthor()) { - foreach ($filter->getAuthor() as $author) { - $sub_search .= 'AND ' . $alias . 'author LIKE ? '; - $values[] = "%{$author}%"; + if ($filter->getLabelNames()) { + foreach ($filter->getLabelNames() as $label_names) { + $sub_search .= 'AND ' . $alias . 'id IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN ('; + foreach ($label_names as $label_name) { + $sub_search .= '?,'; + $values[] = $label_name; } + $sub_search = rtrim($sub_search, ','); + $sub_search .= ')) '; } - if ($filter->getIntitle()) { - foreach ($filter->getIntitle() as $title) { - $sub_search .= 'AND ' . $alias . 'title LIKE ? '; - $values[] = "%{$title}%"; + } + if ($filter->getNotLabelNames()) { + foreach ($filter->getNotLabelNames() as $label_names) { + $sub_search .= 'AND ' . $alias . 'id NOT IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN ('; + foreach ($label_names as $label_name) { + $sub_search .= '?,'; + $values[] = $label_name; } + $sub_search = rtrim($sub_search, ','); + $sub_search .= ')) '; } - if ($filter->getTags()) { - foreach ($filter->getTags() as $tag) { - $sub_search .= 'AND ' . $alias . 'tags LIKE ? '; - $values[] = "%{$tag}%"; - } + } + + if ($filter->getAuthor()) { + foreach ($filter->getAuthor() as $author) { + $sub_search .= 'AND ' . $alias . 'author LIKE ? '; + $values[] = "%{$author}%"; } - if ($filter->getInurl()) { - foreach ($filter->getInurl() as $url) { - $sub_search .= 'AND ' . $this->sqlConcat($alias . 'link', $alias . 'guid') . ' LIKE ? '; - $values[] = "%{$url}%"; - } + } + if ($filter->getIntitle()) { + foreach ($filter->getIntitle() as $title) { + $sub_search .= 'AND ' . $alias . 'title LIKE ? '; + $values[] = "%{$title}%"; } + } + if ($filter->getTags()) { + foreach ($filter->getTags() as $tag) { + $sub_search .= 'AND ' . $alias . 'tags LIKE ? '; + $values[] = "%{$tag}%"; + } + } + if ($filter->getInurl()) { + foreach ($filter->getInurl() as $url) { + $sub_search .= 'AND ' . static::sqlConcat($alias . 'link', $alias . 'guid') . ' LIKE ? '; + $values[] = "%{$url}%"; + } + } - if ($filter->getNotAuthor()) { - foreach ($filter->getNotAuthor() as $author) { - $sub_search .= 'AND (NOT ' . $alias . 'author LIKE ?) '; - $values[] = "%{$author}%"; - } + if ($filter->getNotAuthor()) { + foreach ($filter->getNotAuthor() as $author) { + $sub_search .= 'AND (NOT ' . $alias . 'author LIKE ?) '; + $values[] = "%{$author}%"; } - if ($filter->getNotIntitle()) { - foreach ($filter->getNotIntitle() as $title) { - $sub_search .= 'AND (NOT ' . $alias . 'title LIKE ?) '; - $values[] = "%{$title}%"; - } + } + if ($filter->getNotIntitle()) { + foreach ($filter->getNotIntitle() as $title) { + $sub_search .= 'AND (NOT ' . $alias . 'title LIKE ?) '; + $values[] = "%{$title}%"; } - if ($filter->getNotTags()) { - foreach ($filter->getNotTags() as $tag) { - $sub_search .= 'AND (NOT ' . $alias . 'tags LIKE ?) '; - $values[] = "%{$tag}%"; - } + } + if ($filter->getNotTags()) { + foreach ($filter->getNotTags() as $tag) { + $sub_search .= 'AND (NOT ' . $alias . 'tags LIKE ?) '; + $values[] = "%{$tag}%"; } - if ($filter->getNotInurl()) { - foreach ($filter->getNotInurl() as $url) { - $sub_search .= 'AND (NOT ' . $this->sqlConcat($alias . 'link', $alias . 'guid') . ' LIKE ?) '; - $values[] = "%{$url}%"; - } + } + if ($filter->getNotInurl()) { + foreach ($filter->getNotInurl() as $url) { + $sub_search .= 'AND (NOT ' . static::sqlConcat($alias . 'link', $alias . 'guid') . ' LIKE ?) '; + $values[] = "%{$url}%"; } + } - if ($filter->getSearch()) { - foreach ($filter->getSearch() as $search_value) { - $sub_search .= 'AND ' . $this->sqlConcat($alias . 'title', - $this->isCompressed() ? 'UNCOMPRESS(' . $alias . 'content_bin)' : '' . $alias . 'content') . ' LIKE ? '; - $values[] = "%{$search_value}%"; - } + if ($filter->getSearch()) { + foreach ($filter->getSearch() as $search_value) { + $sub_search .= 'AND ' . static::sqlConcat($alias . 'title', + static::isCompressed() ? 'UNCOMPRESS(' . $alias . 'content_bin)' : '' . $alias . 'content') . ' LIKE ? '; + $values[] = "%{$search_value}%"; } - if ($filter->getNotSearch()) { - foreach ($filter->getNotSearch() as $search_value) { - $sub_search .= 'AND (NOT ' . $this->sqlConcat($alias . 'title', - $this->isCompressed() ? 'UNCOMPRESS(' . $alias . 'content_bin)' : '' . $alias . 'content') . ' LIKE ?) '; - $values[] = "%{$search_value}%"; - } + } + if ($filter->getNotSearch()) { + foreach ($filter->getNotSearch() as $search_value) { + $sub_search .= 'AND (NOT ' . static::sqlConcat($alias . 'title', + static::isCompressed() ? 'UNCOMPRESS(' . $alias . 'content_bin)' : '' . $alias . 'content') . ' LIKE ?) '; + $values[] = "%{$search_value}%"; } + } - if ($sub_search != '') { - if ($isOpen) { - $search .= 'OR '; - } else { - $search .= 'AND ('; - $isOpen = true; - } - $search .= '(' . substr($sub_search, 4) . ') '; + if ($sub_search != '') { + if ($isOpen) { + $search .= ' OR '; + } else { + $isOpen = true; } + // Remove superfluous leading 'AND ' + $search .= '(' . substr($sub_search, 4) . ')'; } - if ($isOpen) { - $search .= ') '; + } + + return [ $values, $search ]; + } + + /** @param FreshRSS_BooleanSearch|null $filters */ + protected function sqlListEntriesWhere(string $alias = '', $filters = null, int $state = FreshRSS_Entry::STATE_ALL, + string $order = 'DESC', string $firstId = '', int $date_min = 0) { + $search = ' '; + $values = array(); + if ($state & FreshRSS_Entry::STATE_NOT_READ) { + if (!($state & FreshRSS_Entry::STATE_READ)) { + $search .= 'AND ' . $alias . 'is_read=0 '; + } + } elseif ($state & FreshRSS_Entry::STATE_READ) { + $search .= 'AND ' . $alias . 'is_read=1 '; + } + if ($state & FreshRSS_Entry::STATE_FAVORITE) { + if (!($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) { + $search .= 'AND ' . $alias . 'is_favorite=1 '; + } + } elseif ($state & FreshRSS_Entry::STATE_NOT_FAVORITE) { + $search .= 'AND ' . $alias . 'is_favorite=0 '; + } + + switch ($order) { + case 'DESC': + case 'ASC': + break; + default: + throw new FreshRSS_EntriesGetter_Exception('Bad order in Entry->listByType: [' . $order . ']!'); + } + if ($firstId !== '') { + $search .= 'AND ' . $alias . 'id ' . ($order === 'DESC' ? '<=' : '>=') . ' ? '; + $values[] = $firstId; + } + if ($date_min > 0) { + $search .= 'AND ' . $alias . 'id >= ? '; + $values[] = $date_min . '000000'; + } + if ($filters && count($filters->searches()) > 0) { + list($filterValues, $filterSearch) = self::sqlBooleanSearch($alias, $filters); + $filterSearch = trim($filterSearch); + if ($filterSearch !== '') { + $search .= 'AND (' . $filterSearch . ') '; + $values = array_merge($values, $filterValues); } } return array($values, $search); @@ -1040,7 +1064,7 @@ SQL; list($values, $sql) = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filters, $date_min); $sql = 'SELECT e0.id, e0.guid, e0.title, e0.author, ' - . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') + . (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') . ', e0.link, e0.date, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags ' . 'FROM `_entry` e0 ' . 'INNER JOIN (' @@ -1085,7 +1109,7 @@ SQL; } $sql = 'SELECT id, guid, title, author, ' - . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') + . (static::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') . ', link, date, is_read, is_favorite, id_feed, tags ' . 'FROM `_entry` ' . 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?) ' @@ -1124,7 +1148,7 @@ SQL; return $result; } $guids = array_unique($guids); - $sql = 'SELECT guid, ' . $this->sqlHexEncode('hash') . + $sql = 'SELECT guid, ' . static::sqlHexEncode('hash') . ' AS hex_hash FROM `_entry` WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1). '?)'; $stm = $this->pdo->prepare($sql); $values = array($id_feed); diff --git a/app/Models/EntryDAOPGSQL.php b/app/Models/EntryDAOPGSQL.php index b97417a7c..13bf8b41f 100644 --- a/app/Models/EntryDAOPGSQL.php +++ b/app/Models/EntryDAOPGSQL.php @@ -2,19 +2,19 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite { - public function hasNativeHex(): bool { + public static function hasNativeHex(): bool { return true; } - public function sqlHexDecode(string $x): string { + public static function sqlHexDecode(string $x): string { return 'decode(' . $x . ", 'hex')"; } - public function sqlHexEncode(string $x): string { + public static function sqlHexEncode(string $x): string { return 'encode(' . $x . ", 'hex')"; } - public function sqlIgnoreConflict(string $sql): string { + public static function sqlIgnoreConflict(string $sql): string { return rtrim($sql, ' ;') . ' ON CONFLICT DO NOTHING'; } diff --git a/app/Models/EntryDAOSQLite.php b/app/Models/EntryDAOSQLite.php index 16d15f899..e4e079959 100644 --- a/app/Models/EntryDAOSQLite.php +++ b/app/Models/EntryDAOSQLite.php @@ -2,19 +2,23 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO { - public function isCompressed(): bool { + public static function isCompressed(): bool { return false; } - public function hasNativeHex(): bool { + public static function hasNativeHex(): bool { return false; } - public function sqlHexDecode(string $x): string { + protected static function sqlConcat($s1, $s2) { + return $s1 . '||' . $s2; + } + + public static function sqlHexDecode(string $x): string { return $x; } - public function sqlIgnoreConflict(string $sql): string { + public static function sqlIgnoreConflict(string $sql): string { return str_replace('INSERT INTO ', 'INSERT OR IGNORE INTO ', $sql); } @@ -65,10 +69,6 @@ DROP TABLE IF EXISTS `tmp`; return $result; } - protected function sqlConcat($s1, $s2) { - return $s1 . '||' . $s2; - } - protected function updateCacheUnreads($catId = false, $feedId = false) { $sql = 'UPDATE `_feed` ' . 'SET `cache_nbUnreads`=(' diff --git a/app/Models/UserConfiguration.php b/app/Models/UserConfiguration.php index cf6f7f342..96fc77b59 100644 --- a/app/Models/UserConfiguration.php +++ b/app/Models/UserConfiguration.php @@ -34,7 +34,7 @@ * @property bool $onread_jump_next * @property string $passwordHash * @property int $posts_per_page - * @property array<int,array<string,string>> $queries + * @property array<array<string,string>> $queries * @property bool $reading_confirm * @property int $since_hours_posts_per_rss * @property bool $show_fav_unread diff --git a/app/Models/UserQuery.php b/app/Models/UserQuery.php index 236813942..964324bf7 100644 --- a/app/Models/UserQuery.php +++ b/app/Models/UserQuery.php @@ -14,6 +14,7 @@ class FreshRSS_UserQuery { private $get_type; private $name; private $order; + /** @var FreshRSS_BooleanSearch */ private $search; private $state; private $url; @@ -34,7 +35,7 @@ class FreshRSS_UserQuery { $this->parseGet($query['get']); } if (isset($query['name'])) { - $this->name = $query['name']; + $this->name = trim($query['name']); } if (isset($query['order'])) { $this->order = $query['order']; @@ -42,7 +43,7 @@ class FreshRSS_UserQuery { if (empty($query['url'])) { if (!empty($query)) { unset($query['name']); - $this->url = Minz_Url::display(array('params' => $query)); + $this->url = Minz_Url::display(['params' => $query]); } } else { $this->url = $query['url']; diff --git a/app/layout/nav_menu.phtml b/app/layout/nav_menu.phtml index 3fd6f9e0e..ee15cfe61 100644 --- a/app/layout/nav_menu.phtml +++ b/app/layout/nav_menu.phtml @@ -39,14 +39,16 @@ <a href="<?= _url('configure', 'queries') ?>"><?= _i('configure') ?></a> </li> - <?php - foreach (FreshRSS_Context::$user_conf->queries as $raw_query) { - $query = new FreshRSS_UserQuery($raw_query); - ?> - <li class="item query"> - <a href="<?= $query->getUrl() ?>"><?= $query->getName() ?></a> - </li> - <?php } ?> + <?php foreach (FreshRSS_Context::$user_conf->queries as $raw_query): ?> + <li class="item query"> + <?php if (!empty($raw_query['url'])): ?> + <a href="<?= $raw_query['url'] ?>"><?= $raw_query['name'] ?></a> + <?php else: ?> + <?php $query = new FreshRSS_UserQuery($raw_query); ?> + <a href="<?= $query->getUrl() ?>"><?= $query->getName() ?></a> + <?php endif; ?> + </li> + <?php endforeach; ?> <?php if (count(FreshRSS_Context::$user_conf->queries) > 0) { ?> <li class="separator"></li> diff --git a/docs/en/users/03_Main_view.md b/docs/en/users/03_Main_view.md index 2e278fb61..a5a2afe34 100644 --- a/docs/en/users/03_Main_view.md +++ b/docs/en/users/03_Main_view.md @@ -222,6 +222,8 @@ You can use the search field to further refine results: * by custom label name `label:label`, `label:"my label"` or any label name from a list (*or*): `labels:"my label,my other label"` * by several label names (*and*): `label:"my label" label:"my other label"` * by entry (article) ID: `e:1639310674957894` or multiple entry IDs (*or*): `e:1639310674957894,1639310674957893` +* by user query (saved search) name: `search:myQuery`, `search:"My query"` or saved search ID: `S:3` + * internally, those references are replaced by the corresponding user query in the search expression Be careful not to enter a space between the operator and the search value. @@ -237,6 +239,11 @@ can be used to combine several search criteria with a logical *or* instead: `aut You don’t have to do anything special to combine multiple negative operators. Writing `!intitle:'thing1' !intitle:'thing2'` implies AND, see above. For more pointers on how AND and OR interact with negation, see [this GitHub comment](https://github.com/FreshRSS/FreshRSS/issues/3236#issuecomment-891219460). Additional reading: [De Morgan's laws](https://en.wikipedia.org/wiki/De_Morgan%27s_laws). +Finally, parentheses may be used to express more complex queries: + +* `(author:Alice OR intitle:hello) (author:Bob OR intitle:world)` +* `(author:Alice intitle:hello) OR (author:Bob intitle:world)` + ### By sorting by date You can change the sort order by clicking the toggle button available in the header. diff --git a/docs/fr/users/03_Main_view.md b/docs/fr/users/03_Main_view.md index 4934301af..11df737b7 100644 --- a/docs/fr/users/03_Main_view.md +++ b/docs/fr/users/03_Main_view.md @@ -206,8 +206,7 @@ the search field. ### Grâce au champ de recherche -Il est possible d’utiliser le champ de recherche pour raffiner les résultats -: +Il est possible d’utiliser le champ de recherche pour raffiner les résultats : * par ID de flux : `f:123` ou plusieurs flux (*ou*) : `f:123,234,345` * par auteur : `author:nom` or `author:'nom composé'` @@ -252,6 +251,8 @@ Il est possible d’utiliser le champ de recherche pour raffiner les résultats * par nom d’étiquette : `label:étiquette`, `label:"mon étiquette"` ou d’une étiquette parmis une liste (*ou*) : `labels:"mon étiquette,mon autre étiquette"` * par plusieurs noms d’étiquettes (*et*) : `label:"mon étiquette" label:"mon autre étiquette"` * par ID d’article (entrée) : `e:1639310674957894` ou de plusieurs articles (*ou*): `e:1639310674957894,1639310674957893` +* par nom de filtre utilisateur (recherche enregistrée) : `search:maRecherche`, `search:"Ma recherche"` ou par ID de recherche : `S:3` + * en interne, ces références sont remplacées par le filtre utilisateur correspondant dans l’expression de recherche Attention à ne pas introduire d’espace entre l’opérateur et la valeur recherchée. @@ -265,4 +266,9 @@ encore plus précis, et il est autorisé d’avoir plusieurs instances de : `f:`, `author:`, `intitle:`, `inurl:`, `#`, et texte libre. Combiner plusieurs critères implique un *et* logique, mais le mot clef `OR` -peut être utiliser pour combiner plusieurs critères avec un *ou* logique : `author:Dupont OR author:Dupond` +peut être utilisé pour combiner plusieurs critères avec un *ou* logique : `author:Dupont OR author:Dupond` + +Enfin, les parenthèses peuvent être utilisées pour des expressions plus complexes : + +* `(author:Alice OR intitle:bonjour) (author:Bob OR intitle:monde)` +* `(author:Alice intitle:bonjour) OR (author:Bob intitle:monde)` diff --git a/p/api/fever.php b/p/api/fever.php index 139cd658a..1dcb7220e 100644 --- a/p/api/fever.php +++ b/p/api/fever.php @@ -81,7 +81,7 @@ class FeverDAO extends Minz_ModelPdo $entryDAO = FreshRSS_Factory::createEntryDao(); $sql = 'SELECT id, guid, title, author, ' - . ($entryDAO->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') + . ($entryDAO::isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content') . ', link, date, is_read, is_favorite, id_feed ' . 'FROM `_entry` WHERE'; diff --git a/tests/app/Models/SearchTest.php b/tests/app/Models/SearchTest.php index b43834147..74c1596f6 100644 --- a/tests/app/Models/SearchTest.php +++ b/tests/app/Models/SearchTest.php @@ -297,4 +297,39 @@ class SearchTest extends PHPUnit\Framework\TestCase { ), ); } + + /** + * @dataProvider provideParentheses + * @param array<string> $values + */ + public function test__construct_parentheses(string $input, string $sql, $values) { + list($filterValues, $filterSearch) = FreshRSS_EntryDAOPGSQL::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input)); + $this->assertEquals($sql, $filterSearch); + $this->assertEquals($values, $filterValues); + } + + public function provideParentheses() { + return [ + [ + 'f:1 (f:2 OR f:3 OR f:4) (f:5 OR (f:6 OR f:7))', + ' ((e.id_feed IN (?) )) AND ((e.id_feed IN (?) ) OR (e.id_feed IN (?) ) OR (e.id_feed IN (?) )) AND' . + ' (((e.id_feed IN (?) )) OR ((e.id_feed IN (?) ) OR (e.id_feed IN (?) ))) ', + ['1', '2', '3', '4', '5', '6', '7'] + ], + [ + '#tag Hello OR (author:Alice inurl:example) OR (f:3 intitle:World) OR L:12', + ' ((e.tags LIKE ? AND e.title||e.content LIKE ? )) OR ((e.author LIKE ? AND e.link||e.guid LIKE ? )) OR' . + ' ((e.id_feed IN (?) AND e.title LIKE ? )) OR ((e.id IN (SELECT et.id_entry FROM `_entrytag` et WHERE et.id_tag IN (?)) )) ', + ['%tag%','%Hello%','%Alice%','%example%','3','%World%', '12'] + ], + [ + '#tag Hello (author:Alice inurl:example) (f:3 intitle:World) label:Bleu', + ' ((e.tags LIKE ? AND e.title||e.content LIKE ? )) AND' . + ' ((e.author LIKE ? AND e.link||e.guid LIKE ? )) AND' . + ' ((e.id_feed IN (?) AND e.title LIKE ? )) AND' . + ' ((e.id IN (SELECT et.id_entry FROM `_entrytag` et, `_tag` t WHERE et.id_tag = t.id AND t.name IN (?)) )) ', + ['%tag%','%Hello%','%Alice%','%example%','3','%World%', 'Bleu'] + ], + ]; + } } |
