4
0
Эх сурвалжийг харах

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 жил өмнө
parent
commit
f85c510ed4

+ 208 - 8
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 {

+ 93 - 85
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 = []) {

+ 267 - 243
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);

+ 4 - 4
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';
 	}
 

+ 8 - 8
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`=('

+ 1 - 1
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

+ 3 - 2
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'];

+ 10 - 8
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>

+ 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 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.

+ 9 - 3
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)`

+ 1 - 1
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';
 

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