aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/Models/BooleanSearch.php216
-rw-r--r--app/Models/Entry.php178
-rw-r--r--app/Models/EntryDAO.php510
-rw-r--r--app/Models/EntryDAOPGSQL.php8
-rw-r--r--app/Models/EntryDAOSQLite.php16
-rw-r--r--app/Models/UserConfiguration.php2
-rw-r--r--app/Models/UserQuery.php5
-rw-r--r--app/layout/nav_menu.phtml18
-rw-r--r--docs/en/users/03_Main_view.md7
-rw-r--r--docs/fr/users/03_Main_view.md12
-rw-r--r--p/api/fever.php2
-rw-r--r--tests/app/Models/SearchTest.php35
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('/:&quot;(.*?)&quot;/', ':"\1"', $input);
- $input = preg_replace('/(?<=[\s!-]|^)&quot;(.*?)&quot;/', '"\1"', $input);
+ if ($level === 0) {
+ $input = preg_replace('/:&quot;(.*?)&quot;/', ':"\1"', $input);
+ $input = preg_replace('/(?<=[\s!-]|^)&quot;(.*?)&quot;/', '"\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']
+ ],
+ ];
+ }
}