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
pull/4172/merge
Alexandre Alapetite 2 years ago committed by GitHub
parent f988b996ab
commit f85c510ed4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 216
      app/Models/BooleanSearch.php
  2. 178
      app/Models/Entry.php
  3. 510
      app/Models/EntryDAO.php
  4. 8
      app/Models/EntryDAOPGSQL.php
  5. 16
      app/Models/EntryDAOSQLite.php
  6. 2
      app/Models/UserConfiguration.php
  7. 5
      app/Models/UserQuery.php
  8. 18
      app/layout/nav_menu.phtml
  9. 7
      docs/en/users/03_Main_view.md
  10. 12
      docs/fr/users/03_Main_view.md
  11. 2
      p/api/fever.php
  12. 35
      tests/app/Models/SearchTest.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 {

@ -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 = []) {

@ -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);

@ -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';
}

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

@ -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

@ -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'];

@ -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>

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

@ -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)`

@ -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';

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

Loading…
Cancel
Save