Просмотр исходного кода

New search engine (#4378)

* New possibility to invoke user queries from a search expression
From the search field: `S:"My query"`.
Can be combined with other filters such as `S:"My query" date:P3d` as long as the user queries do not contain `OR`.
A use-case is to have an RSS filter with a stable address or an external API call with the ability to update the user query.

* Draft of parenthesis logic

* More draft

* Working parenthesis (a OR b) (c OR d)

* Working (A) OR (B)

* Support nested parentheses + unit tests + documentation

* search:MySearch and S:3
Alexandre Alapetite 3 лет назад
Родитель
Сommit
f85c510ed4

+ 208 - 8
app/Models/BooleanSearch.php

@@ -7,17 +7,210 @@ class FreshRSS_BooleanSearch {
 
 
 	/** @var string */
 	/** @var string */
 	private $raw_input = '';
 	private $raw_input = '';
+	/** @var array<FreshRSS_BooleanSearch|FreshRSS_Search> */
 	private $searches = array();
 	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);
 		$input = trim($input);
 		if ($input == '') {
 		if ($input == '') {
 			return;
 			return;
 		}
 		}
 		$this->raw_input = $input;
 		$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);
 		$splits = preg_split('/\b(OR)\b/i', $input, -1, PREG_SPLIT_DELIM_CAPTURE);
 
 
 		$segment = '';
 		$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() {
 	public function searches() {
 		return $this->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) {
 	public function add($search) {
-		if ($search instanceof FreshRSS_Search) {
-			$this->searches[] = $search;
-			return $search;
-		}
-		return null;
+		$this->searches[] = $search;
 	}
 	}
 
 
 	public function __toString(): string {
 	public function __toString(): string {

+ 93 - 85
app/Models/Entry.php

@@ -325,108 +325,116 @@ class FreshRSS_Entry extends Minz_Model {
 	}
 	}
 
 
 	public function matches(FreshRSS_BooleanSearch $booleanSearch): bool {
 	public function matches(FreshRSS_BooleanSearch $booleanSearch): bool {
-		if (count($booleanSearch->searches()) <= 0) {
-			return true;
-		}
+		$ok = true;
 		foreach ($booleanSearch->searches() as $filter) {
 		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 = []) {
 	public function applyFilterActions(array $titlesAsRead = []) {

+ 267 - 243
app/Models/EntryDAO.php

@@ -2,23 +2,27 @@
 
 
 class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 
-	public function isCompressed(): bool {
+	public static function isCompressed(): bool {
 		return true;
 		return true;
 	}
 	}
 
 
-	public function hasNativeHex(): bool {
+	public static function hasNativeHex(): bool {
 		return true;
 		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 . ')';
 		return 'unhex(' . $x . ')';
 	}
 	}
 
 
-	public function sqlHexEncode(string $x): string {
+	public static function sqlHexEncode(string $x): string {
 		return 'hex(' . $x . ')';
 		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);
 		return str_replace('INSERT INTO ', 'INSERT IGNORE INTO ', $sql);
 	}
 	}
 
 
@@ -90,14 +94,14 @@ SQL;
 
 
 	public function addEntry(array $valuesTmp, bool $useTmpTable = true) {
 	public function addEntry(array $valuesTmp, bool $useTmpTable = true) {
 		if ($this->addEntryPrepared == null) {
 		if ($this->addEntryPrepared == null) {
-			$sql = $this->sqlIgnoreConflict(
+			$sql = static::sqlIgnoreConflict(
 				'INSERT INTO `_' . ($useTmpTable ? 'entrytmp' : 'entry') . '` (id, guid, title, author, '
 				'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) '
 				. ', link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags) '
 				. 'VALUES(:id, :guid, :title, :author, '
 				. 'VALUES(:id, :guid, :title, :author, '
-				. ($this->isCompressed() ? 'COMPRESS(:content)' : ':content')
+				. (static::isCompressed() ? 'COMPRESS(:content)' : ':content')
 				. ', :link, :date, :last_seen, '
 				. ', :link, :date, :last_seen, '
-				. $this->sqlHexDecode(':hash')
+				. static::sqlHexDecode(':hash')
 				. ', :is_read, :is_favorite, :id_feed, :tags)');
 				. ', :is_read, :is_favorite, :id_feed, :tags)');
 			$this->addEntryPrepared = $this->pdo->prepare($sql);
 			$this->addEntryPrepared = $this->pdo->prepare($sql);
 		}
 		}
@@ -132,7 +136,7 @@ SQL;
 			$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
 			$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
 			$this->addEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
 			$this->addEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
 
 
-			if ($this->hasNativeHex()) {
+			if (static::hasNativeHex()) {
 				$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
 				$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
 			} else {
 			} else {
 				$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
 				$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
@@ -189,9 +193,9 @@ SQL;
 		if ($this->updateEntryPrepared === null) {
 		if ($this->updateEntryPrepared === null) {
 			$sql = 'UPDATE `_entry` '
 			$sql = 'UPDATE `_entry` '
 				. 'SET title=:title, author=:author, '
 				. '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'
 				. ', link=:link, date=:date, `lastSeen`=:last_seen'
-				. ', hash=' . $this->sqlHexDecode(':hash')
+				. ', hash=' . static::sqlHexDecode(':hash')
 				. ', is_read=COALESCE(:is_read, is_read)'
 				. ', is_read=COALESCE(:is_read, is_read)'
 				. ', tags=:tags '
 				. ', tags=:tags '
 				. 'WHERE id_feed=:id_feed AND guid=:guid';
 				. 'WHERE id_feed=:id_feed AND guid=:guid';
@@ -226,7 +230,7 @@ SQL;
 			$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
 			$valuesTmp['tags'] = safe_utf8($valuesTmp['tags']);
 			$this->updateEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
 			$this->updateEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
 
 
-			if ($this->hasNativeHex()) {
+			if (static::hasNativeHex()) {
 				$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
 				$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
 			} else {
 			} else {
 				$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
 				$valuesTmp['hashBin'] = hex2bin($valuesTmp['hash']);
@@ -649,8 +653,8 @@ SQL;
 
 
 	public function selectAll() {
 	public function selectAll() {
 		$sql = 'SELECT id, guid, title, author, '
 		$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`';
 			. 'FROM `_entry`';
 		$stm = $this->pdo->query($sql);
 		$stm = $this->pdo->query($sql);
 		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
 		while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
@@ -662,7 +666,7 @@ SQL;
 	public function searchByGuid($id_feed, $guid) {
 	public function searchByGuid($id_feed, $guid) {
 		// un guid est unique pour un flux donné
 		// un guid est unique pour un flux donné
 		$sql = 'SELECT id, guid, title, author, '
 		$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 '
 			. ', link, date, is_read, is_favorite, id_feed, tags '
 			. 'FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid';
 			. 'FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid';
 		$stm = $this->pdo->prepare($sql);
 		$stm = $this->pdo->prepare($sql);
@@ -676,7 +680,7 @@ SQL;
 	/** @return FreshRSS_Entry|null */
 	/** @return FreshRSS_Entry|null */
 	public function searchById($id) {
 	public function searchById($id) {
 		$sql = 'SELECT id, guid, title, author, '
 		$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 '
 			. ', link, date, is_read, is_favorite, id_feed, tags '
 			. 'FROM `_entry` WHERE id=:id';
 			. 'FROM `_entry` WHERE id=:id';
 		$stm = $this->pdo->prepare($sql);
 		$stm = $this->pdo->prepare($sql);
@@ -696,281 +700,301 @@ SQL;
 		return isset($res[0]) ? $res[0] : null;
 		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()) {
 					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 .= '?,';
 							$sub_search .= '?,';
-							$values[] = $label_name;
+							$values[] = $label_id;
 						}
 						}
 						$sub_search = rtrim($sub_search, ',');
 						$sub_search = rtrim($sub_search, ',');
 						$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 .= '?,';
 							$sub_search .= '?,';
-							$values[] = $label_name;
+							$values[] = $label_id;
 						}
 						}
 						$sub_search = rtrim($sub_search, ',');
 						$sub_search = rtrim($sub_search, ',');
 						$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);
 		return array($values, $search);
@@ -1040,7 +1064,7 @@ SQL;
 		list($values, $sql) = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
 		list($values, $sql) = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
 
 
 		$sql = 'SELECT e0.id, e0.guid, e0.title, e0.author, '
 		$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 '
 			. ', e0.link, e0.date, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags '
 			. 'FROM `_entry` e0 '
 			. 'FROM `_entry` e0 '
 			. 'INNER JOIN ('
 			. 'INNER JOIN ('
@@ -1085,7 +1109,7 @@ SQL;
 		}
 		}
 
 
 		$sql = 'SELECT id, guid, title, author, '
 		$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 '
 			. ', link, date, is_read, is_favorite, id_feed, tags '
 			. 'FROM `_entry` '
 			. 'FROM `_entry` '
 			. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?) '
 			. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?) '
@@ -1124,7 +1148,7 @@ SQL;
 			return $result;
 			return $result;
 		}
 		}
 		$guids = array_unique($guids);
 		$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). '?)';
 			' AS hex_hash FROM `_entry` WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1). '?)';
 		$stm = $this->pdo->prepare($sql);
 		$stm = $this->pdo->prepare($sql);
 		$values = array($id_feed);
 		$values = array($id_feed);

+ 4 - 4
app/Models/EntryDAOPGSQL.php

@@ -2,19 +2,19 @@
 
 
 class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 
 
-	public function hasNativeHex(): bool {
+	public static function hasNativeHex(): bool {
 		return true;
 		return true;
 	}
 	}
 
 
-	public function sqlHexDecode(string $x): string {
+	public static function sqlHexDecode(string $x): string {
 		return 'decode(' . $x . ", 'hex')";
 		return 'decode(' . $x . ", 'hex')";
 	}
 	}
 
 
-	public function sqlHexEncode(string $x): string {
+	public static function sqlHexEncode(string $x): string {
 		return 'encode(' . $x . ", 'hex')";
 		return 'encode(' . $x . ", 'hex')";
 	}
 	}
 
 
-	public function sqlIgnoreConflict(string $sql): string {
+	public static function sqlIgnoreConflict(string $sql): string {
 		return rtrim($sql, ' ;') . ' ON CONFLICT DO NOTHING';
 		return rtrim($sql, ' ;') . ' ON CONFLICT DO NOTHING';
 	}
 	}
 
 

+ 8 - 8
app/Models/EntryDAOSQLite.php

@@ -2,19 +2,23 @@
 
 
 class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 
 
-	public function isCompressed(): bool {
+	public static function isCompressed(): bool {
 		return false;
 		return false;
 	}
 	}
 
 
-	public function hasNativeHex(): bool {
+	public static function hasNativeHex(): bool {
 		return false;
 		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;
 		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);
 		return str_replace('INSERT INTO ', 'INSERT OR IGNORE INTO ', $sql);
 	}
 	}
 
 
@@ -65,10 +69,6 @@ DROP TABLE IF EXISTS `tmp`;
 		return $result;
 		return $result;
 	}
 	}
 
 
-	protected function sqlConcat($s1, $s2) {
-		return $s1 . '||' . $s2;
-	}
-
 	protected function updateCacheUnreads($catId = false, $feedId = false) {
 	protected function updateCacheUnreads($catId = false, $feedId = false) {
 		$sql = 'UPDATE `_feed` '
 		$sql = 'UPDATE `_feed` '
 		 . 'SET `cache_nbUnreads`=('
 		 . 'SET `cache_nbUnreads`=('

+ 1 - 1
app/Models/UserConfiguration.php

@@ -34,7 +34,7 @@
  * @property bool $onread_jump_next
  * @property bool $onread_jump_next
  * @property string $passwordHash
  * @property string $passwordHash
  * @property int $posts_per_page
  * @property int $posts_per_page
- * @property array<int,array<string,string>> $queries
+ * @property array<array<string,string>> $queries
  * @property bool $reading_confirm
  * @property bool $reading_confirm
  * @property int $since_hours_posts_per_rss
  * @property int $since_hours_posts_per_rss
  * @property bool $show_fav_unread
  * @property bool $show_fav_unread

+ 3 - 2
app/Models/UserQuery.php

@@ -14,6 +14,7 @@ class FreshRSS_UserQuery {
 	private $get_type;
 	private $get_type;
 	private $name;
 	private $name;
 	private $order;
 	private $order;
+	/** @var FreshRSS_BooleanSearch */
 	private $search;
 	private $search;
 	private $state;
 	private $state;
 	private $url;
 	private $url;
@@ -34,7 +35,7 @@ class FreshRSS_UserQuery {
 			$this->parseGet($query['get']);
 			$this->parseGet($query['get']);
 		}
 		}
 		if (isset($query['name'])) {
 		if (isset($query['name'])) {
-			$this->name = $query['name'];
+			$this->name = trim($query['name']);
 		}
 		}
 		if (isset($query['order'])) {
 		if (isset($query['order'])) {
 			$this->order = $query['order'];
 			$this->order = $query['order'];
@@ -42,7 +43,7 @@ class FreshRSS_UserQuery {
 		if (empty($query['url'])) {
 		if (empty($query['url'])) {
 			if (!empty($query)) {
 			if (!empty($query)) {
 				unset($query['name']);
 				unset($query['name']);
-				$this->url = Minz_Url::display(array('params' => $query));
+				$this->url = Minz_Url::display(['params' => $query]);
 			}
 			}
 		} else {
 		} else {
 			$this->url = $query['url'];
 			$this->url = $query['url'];

+ 10 - 8
app/layout/nav_menu.phtml

@@ -39,14 +39,16 @@
 					<a href="<?= _url('configure', 'queries') ?>"><?= _i('configure') ?></a>
 					<a href="<?= _url('configure', 'queries') ?>"><?= _i('configure') ?></a>
 				</li>
 				</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) { ?>
 				<?php if (count(FreshRSS_Context::$user_conf->queries) > 0) { ?>
 				<li class="separator"></li>
 				<li class="separator"></li>

+ 7 - 0
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 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 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 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.
 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).
 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).
 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
 ### By sorting by date
 
 
 You can change the sort order by clicking the toggle button available in the header.
 You can change the sort order by clicking the toggle button available in the header.

+ 9 - 3
docs/fr/users/03_Main_view.md

@@ -206,8 +206,7 @@ the search field.
 
 
 ### Grâce au champ de recherche
 ### 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 ID de flux : `f:123` ou plusieurs flux (*ou*) : `f:123,234,345`
 * par auteur : `author:nom` or `author:'nom composé'`
 * 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 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 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 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
 Attention à ne pas introduire d’espace entre l’opérateur et la valeur
 recherchée.
 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.
 `f:`, `author:`, `intitle:`, `inurl:`, `#`, et texte libre.
 
 
 Combiner plusieurs critères implique un *et* logique, mais le mot clef `OR`
 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)`

+ 1 - 1
p/api/fever.php

@@ -81,7 +81,7 @@ class FeverDAO extends Minz_ModelPdo
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 
 
 		$sql = 'SELECT id, guid, title, author, '
 		$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 '
 			. ', link, date, is_read, is_favorite, id_feed '
 			. 'FROM `_entry` WHERE';
 			. 'FROM `_entry` WHERE';
 
 

+ 35 - 0
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']
+			],
+		];
+	}
 }
 }