Browse Source

Regex search (#6706)

* Regex search
fix https://github.com/FreshRSS/FreshRSS/issues/3549

* Fix PHPStan

* Fix escape

* Fix ungreedy

* Initial support for regex search in PostgreSQL and MySQL

* Improvements, support MySQL

* Fix multiline

* Add support for SQLite

* A few tests

* Added author: and inurl: support, documentation

* author example

* Remove \b for now

* Disable regex sanitization for now

* Fix getInurlRegex

* getNotInurlRegex

* Quotes for inurl:

* Fix test

* Fix quoted tags + regex for tags
https://github.com/FreshRSS/FreshRSS/issues/6761

* Fix wrong regex detection

* Add MariaDB

* Fix logic

* Increase requirements for MySQL and MariaDB
Check support for multiline mode in MySQL

* Remove sanitizeRegexes()

* Allow searching HTML code
Allow searching for instance `/<pre>/`
Fix https://github.com/FreshRSS/FreshRSS/issues/6775#issuecomment-2331769883

* Doc regex search HTML

* Fix Doctype
Alexandre Alapetite 1 year ago
parent
commit
1a552bd60e

+ 1 - 1
CONTRIBUTING.md

@@ -21,7 +21,7 @@ If you have to create a new ticket, try to apply the following advice:
 - We also need some information:
 - We also need some information:
 	- Your FreshRSS version (on about page or `constants.php` file)
 	- Your FreshRSS version (on about page or `constants.php` file)
 	- Your server configuration: type of hosting, PHP version
 	- Your server configuration: type of hosting, PHP version
-	- Your storage system (SQLite, MySQL, MariaDB, PostgreSQL)
+	- Your storage system (SQLite, PostgreSQL, MariaDB, MySQL)
 	- If possible, the related logs (PHP logs and FreshRSS logs under `data/users/your_user/log.txt`)
 	- If possible, the related logs (PHP logs and FreshRSS logs under `data/users/your_user/log.txt`)
 
 
 ## Fix a bug
 ## Fix a bug

+ 1 - 1
README.fr.md

@@ -66,7 +66,7 @@ FreshRSS n’est fourni avec aucune garantie.
 	* Extensions requises : [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype)
 	* Extensions requises : [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype)
 	* Extensions recommandées : [PDO_SQLite](https://www.php.net/pdo-sqlite) (pour l’export/import), [GMP](https://www.php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](https://www.php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](https://www.php.net/mbstring) (pour le texte Unicode), [iconv](https://www.php.net/iconv) (pour conversion d’encodages), [ZIP](https://www.php.net/zip) (pour import/export), [zlib](https://www.php.net/zlib) (pour les flux compressés)
 	* Extensions recommandées : [PDO_SQLite](https://www.php.net/pdo-sqlite) (pour l’export/import), [GMP](https://www.php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](https://www.php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](https://www.php.net/mbstring) (pour le texte Unicode), [iconv](https://www.php.net/iconv) (pour conversion d’encodages), [ZIP](https://www.php.net/zip) (pour import/export), [zlib](https://www.php.net/zlib) (pour les flux compressés)
 	* Extension pour base de données : [PDO_PGSQL](https://www.php.net/pdo-pgsql) ou [PDO_SQLite](https://www.php.net/pdo-sqlite) ou [PDO_MySQL](https://www.php.net/pdo-mysql)
 	* Extension pour base de données : [PDO_PGSQL](https://www.php.net/pdo-pgsql) ou [PDO_SQLite](https://www.php.net/pdo-sqlite) ou [PDO_MySQL](https://www.php.net/pdo-mysql)
-* PostgreSQL 10+ ou SQLite ou MySQL 5.5.3+ ou MariaDB 5.5+
+* PostgreSQL 10+ ou SQLite ou MariaDB 10.0.5+ ou MySQL 8.0+
 
 
 # [Installation](https://freshrss.github.io/FreshRSS/fr/users/01_Installation.html)
 # [Installation](https://freshrss.github.io/FreshRSS/fr/users/01_Installation.html)
 
 

+ 2 - 2
README.md

@@ -61,12 +61,12 @@ FreshRSS comes with absolutely no warranty.
 	* Works on mobile (except a few features)
 	* Works on mobile (except a few features)
 * Light server running Linux or Windows
 * Light server running Linux or Windows
 	* It even works on Raspberry Pi 1 with response time under a second (tested with 150 feeds, 22k articles)
 	* It even works on Raspberry Pi 1 with response time under a second (tested with 150 feeds, 22k articles)
-* A web server: Apache2.4+ (recommended), nginx, lighttpd (not tested on others)
+* A Web server: Apache2.4+ (recommended), nginx, lighttpd (not tested on others)
 * PHP 8.1+
 * PHP 8.1+
 	* Required extensions: [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype)
 	* Required extensions: [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype)
 	* Recommended extensions: [PDO_SQLite](https://www.php.net/pdo-sqlite) (for export/import), [GMP](https://www.php.net/gmp) (for API access on 32-bit platforms), [IDN](https://www.php.net/intl.idn) (for Internationalized Domain Names), [mbstring](https://www.php.net/mbstring) (for Unicode strings), [iconv](https://www.php.net/iconv) (for charset conversion), [ZIP](https://www.php.net/zip) (for import/export), [zlib](https://www.php.net/zlib) (for compressed feeds)
 	* Recommended extensions: [PDO_SQLite](https://www.php.net/pdo-sqlite) (for export/import), [GMP](https://www.php.net/gmp) (for API access on 32-bit platforms), [IDN](https://www.php.net/intl.idn) (for Internationalized Domain Names), [mbstring](https://www.php.net/mbstring) (for Unicode strings), [iconv](https://www.php.net/iconv) (for charset conversion), [ZIP](https://www.php.net/zip) (for import/export), [zlib](https://www.php.net/zlib) (for compressed feeds)
 	* Extension for database: [PDO_PGSQL](https://www.php.net/pdo-pgsql) or [PDO_SQLite](https://www.php.net/pdo-sqlite) or [PDO_MySQL](https://www.php.net/pdo-mysql)
 	* Extension for database: [PDO_PGSQL](https://www.php.net/pdo-pgsql) or [PDO_SQLite](https://www.php.net/pdo-sqlite) or [PDO_MySQL](https://www.php.net/pdo-mysql)
-* PostgreSQL 10+ or SQLite or MySQL 5.5.3+ or MariaDB 5.5+
+* PostgreSQL 10+ or SQLite or MariaDB 10.0.5+ or MySQL 8.0+
 
 
 # [Installation](https://freshrss.github.io/FreshRSS/en/admins/03_Installation.html)
 # [Installation](https://freshrss.github.io/FreshRSS/en/admins/03_Installation.html)
 
 

+ 25 - 2
app/Models/DatabaseDAO.php

@@ -185,6 +185,30 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 		return $list;
 		return $list;
 	}
 	}
 
 
+	private static ?string $staticVersion = null;
+	/**
+	 * To override the database version. Useful for testing.
+	 */
+	public static function setStaticVersion(?string $version): void {
+		self::$staticVersion = $version;
+	}
+
+	public function version(): string {
+		if (self::$staticVersion !== null) {
+			return self::$staticVersion;
+		}
+		static $version = null;
+		if ($version === null) {
+			$version = $this->fetchValue('SELECT version()') ?? '';
+		}
+		return $version;
+	}
+
+	final public function isMariaDB(): bool {
+		// MariaDB includes its name in version, but not MySQL
+		return str_contains($this->version(), 'MariaDB');
+	}
+
 	public function size(bool $all = false): int {
 	public function size(bool $all = false): int {
 		$db = FreshRSS_Context::systemConf()->db;
 		$db = FreshRSS_Context::systemConf()->db;
 
 
@@ -237,8 +261,7 @@ SQL;
 			$isMariaDB = false;
 			$isMariaDB = false;
 
 
 			if ($this->pdo->dbType() === 'mysql') {
 			if ($this->pdo->dbType() === 'mysql') {
-				$dbVersion = $this->fetchValue('SELECT version()') ?? '';
-				$isMariaDB = stripos($dbVersion, 'MariaDB') !== false;	// MariaDB includes its name in version, but not MySQL
+				$isMariaDB = $this->isMariaDB();
 				if (!$isMariaDB) {
 				if (!$isMariaDB) {
 					// MySQL does not support `DROP INDEX IF EXISTS` yet https://dev.mysql.com/doc/refman/8.3/en/drop-index.html
 					// MySQL does not support `DROP INDEX IF EXISTS` yet https://dev.mysql.com/doc/refman/8.3/en/drop-index.html
 					// but MariaDB does https://mariadb.com/kb/en/drop-index/
 					// but MariaDB does https://mariadb.com/kb/en/drop-index/

+ 66 - 0
app/Models/Entry.php

@@ -631,27 +631,60 @@ HTML;
 						$ok &= stripos(implode(';', $this->authors), $author) !== false;
 						$ok &= stripos(implode(';', $this->authors), $author) !== false;
 					}
 					}
 				}
 				}
+				if ($ok && $filter->getAuthorRegex()) {
+					foreach ($filter->getAuthorRegex() as $author) {
+						$ok &= preg_match($author, implode("\n", $this->authors)) === 1;
+					}
+				}
 				if ($ok && $filter->getNotAuthor()) {
 				if ($ok && $filter->getNotAuthor()) {
 					foreach ($filter->getNotAuthor() as $author) {
 					foreach ($filter->getNotAuthor() as $author) {
 						$ok &= stripos(implode(';', $this->authors), $author) === false;
 						$ok &= stripos(implode(';', $this->authors), $author) === false;
 					}
 					}
 				}
 				}
+				if ($ok && $filter->getNotAuthorRegex()) {
+					foreach ($filter->getNotAuthorRegex() as $author) {
+						$ok &= preg_match($author, implode("\n", $this->authors)) === 0;
+					}
+				}
 				if ($ok && $filter->getIntitle()) {
 				if ($ok && $filter->getIntitle()) {
 					foreach ($filter->getIntitle() as $title) {
 					foreach ($filter->getIntitle() as $title) {
 						$ok &= stripos($this->title, $title) !== false;
 						$ok &= stripos($this->title, $title) !== false;
 					}
 					}
 				}
 				}
+				if ($ok && $filter->getIntitleRegex()) {
+					foreach ($filter->getIntitleRegex() as $title) {
+						$ok &= preg_match($title, $this->title) === 1;
+					}
+				}
 				if ($ok && $filter->getNotIntitle()) {
 				if ($ok && $filter->getNotIntitle()) {
 					foreach ($filter->getNotIntitle() as $title) {
 					foreach ($filter->getNotIntitle() as $title) {
 						$ok &= stripos($this->title, $title) === false;
 						$ok &= stripos($this->title, $title) === false;
 					}
 					}
 				}
 				}
+				if ($ok && $filter->getNotIntitleRegex()) {
+					foreach ($filter->getNotIntitleRegex() as $title) {
+						$ok &= preg_match($title, $this->title) === 0;
+					}
+				}
 				if ($ok && $filter->getTags()) {
 				if ($ok && $filter->getTags()) {
 					foreach ($filter->getTags() as $tag2) {
 					foreach ($filter->getTags() as $tag2) {
 						$found = false;
 						$found = false;
 						foreach ($this->tags as $tag1) {
 						foreach ($this->tags as $tag1) {
 							if (strcasecmp($tag1, $tag2) === 0) {
 							if (strcasecmp($tag1, $tag2) === 0) {
 								$found = true;
 								$found = true;
+								break;
+							}
+						}
+						$ok &= $found;
+					}
+				}
+				if ($ok && $filter->getTagsRegex()) {
+					foreach ($filter->getTagsRegex() as $tag2) {
+						$found = false;
+						foreach ($this->tags as $tag1) {
+							if (preg_match($tag2, $tag1) === 1) {
+								$found = true;
+								break;
 							}
 							}
 						}
 						}
 						$ok &= $found;
 						$ok &= $found;
@@ -663,6 +696,19 @@ HTML;
 						foreach ($this->tags as $tag1) {
 						foreach ($this->tags as $tag1) {
 							if (strcasecmp($tag1, $tag2) === 0) {
 							if (strcasecmp($tag1, $tag2) === 0) {
 								$found = true;
 								$found = true;
+								break;
+							}
+						}
+						$ok &= !$found;
+					}
+				}
+				if ($ok && $filter->getNotTagsRegex()) {
+					foreach ($filter->getNotTagsRegex() as $tag2) {
+						$found = false;
+						foreach ($this->tags as $tag1) {
+							if (preg_match($tag2, $tag1) === 1) {
+								$found = true;
+								break;
 							}
 							}
 						}
 						}
 						$ok &= !$found;
 						$ok &= !$found;
@@ -673,11 +719,21 @@ HTML;
 						$ok &= stripos($this->link, $url) !== false;
 						$ok &= stripos($this->link, $url) !== false;
 					}
 					}
 				}
 				}
+				if ($ok && $filter->getInurlRegex()) {
+					foreach ($filter->getInurlRegex() as $url) {
+						$ok &= preg_match($url, $this->link) === 1;
+					}
+				}
 				if ($ok && $filter->getNotInurl()) {
 				if ($ok && $filter->getNotInurl()) {
 					foreach ($filter->getNotInurl() as $url) {
 					foreach ($filter->getNotInurl() as $url) {
 						$ok &= stripos($this->link, $url) === false;
 						$ok &= stripos($this->link, $url) === false;
 					}
 					}
 				}
 				}
+				if ($ok && $filter->getNotInurlRegex()) {
+					foreach ($filter->getNotInurlRegex() as $url) {
+						$ok &= preg_match($url, $this->link) === 0;
+					}
+				}
 				if ($ok && $filter->getSearch()) {
 				if ($ok && $filter->getSearch()) {
 					foreach ($filter->getSearch() as $needle) {
 					foreach ($filter->getSearch() as $needle) {
 						$ok &= (stripos($this->title, $needle) !== false || stripos($this->content, $needle) !== false);
 						$ok &= (stripos($this->title, $needle) !== false || stripos($this->content, $needle) !== false);
@@ -688,6 +744,16 @@ HTML;
 						$ok &= (stripos($this->title, $needle) === false && stripos($this->content, $needle) === false);
 						$ok &= (stripos($this->title, $needle) === false && stripos($this->content, $needle) === false);
 					}
 					}
 				}
 				}
+				if ($ok && $filter->getSearchRegex()) {
+					foreach ($filter->getSearchRegex() as $needle) {
+						$ok &= (preg_match($needle, $this->title) === 1 || preg_match($needle, $this->content) === 1);
+					}
+				}
+				if ($ok && $filter->getNotSearchRegex()) {
+					foreach ($filter->getNotSearchRegex() as $needle) {
+						$ok &= (preg_match($needle, $this->title) === 0 && preg_match($needle, $this->content) === 0);
+					}
+				}
 				if ($ok) {
 				if ($ok) {
 					return true;
 					return true;
 				}
 				}

+ 114 - 2
app/Models/EntryDAO.php

@@ -27,6 +27,55 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return str_replace('INSERT INTO ', 'INSERT IGNORE INTO ', $sql);
 		return str_replace('INSERT INTO ', 'INSERT IGNORE INTO ', $sql);
 	}
 	}
 
 
+	/** @return array{pattern?:string,matchType?:string} */
+	protected static function regexToSql(string $regex): array {
+		if (preg_match('#^/(?P<pattern>.*)/(?P<matchType>[im]*)$#', $regex, $matches)) {
+			return $matches;
+		}
+		return [];
+	}
+
+	/** @param array<int|string> $values */
+	protected static function sqlRegex(string $expression, string $regex, array &$values): string {
+		// The implementation of this function is solely for MySQL and MariaDB
+		static $databaseDAOMySQL = null;
+		if ($databaseDAOMySQL === null) {
+			$databaseDAOMySQL = new FreshRSS_DatabaseDAO();
+		}
+
+		$matches = static::regexToSql($regex);
+		if (isset($matches['pattern'])) {
+			$matchType = $matches['matchType'] ?? '';
+			if ($databaseDAOMySQL->isMariaDB()) {
+				if (str_contains($matchType, 'm')) {
+					// multiline mode
+					$matches['pattern'] = '(?m)' . $matches['pattern'];
+				}
+				if (str_contains($matchType, 'i')) {
+					// case-insensitive match
+					$matches['pattern'] = '(?i)' . $matches['pattern'];
+				} else {
+					$matches['pattern'] = '(?-i)' . $matches['pattern'];
+				}
+				$values[] = $matches['pattern'];
+				return "{$expression} REGEXP ?";
+			} else {	// MySQL
+				if (!str_contains($matchType, 'i')) {
+					// Case-sensitive matching
+					$matchType .= 'c';
+				}
+				$values[] = $matches['pattern'];
+				return "REGEXP_LIKE({$expression},?,'{$matchType}')";
+			}
+		}
+		return '';
+	}
+
+	/** Register any needed SQL function for the query, e.g. application-defined functions for SQLite */
+	protected function registerSqlFunctions(string $sql): void {
+		// Nothing to do for MySQL
+	}
+
 	private function updateToMediumBlob(): bool {
 	private function updateToMediumBlob(): bool {
 		if ($this->pdo->dbType() !== 'mysql') {
 		if ($this->pdo->dbType() !== 'mysql') {
 			return false;
 			return false;
@@ -910,24 +959,44 @@ SQL;
 					$values[] = "%{$author}%";
 					$values[] = "%{$author}%";
 				}
 				}
 			}
 			}
+			if ($filter->getAuthorRegex() !== null) {
+				foreach ($filter->getAuthorRegex() as $author) {
+					$sub_search .= 'AND ' . static::sqlRegex("REPLACE({$alias}author, ';', '\n')", $author, $values) . ' ';
+				}
+			}
 			if ($filter->getIntitle() !== null) {
 			if ($filter->getIntitle() !== null) {
 				foreach ($filter->getIntitle() as $title) {
 				foreach ($filter->getIntitle() as $title) {
 					$sub_search .= 'AND ' . $alias . 'title LIKE ? ';
 					$sub_search .= 'AND ' . $alias . 'title LIKE ? ';
 					$values[] = "%{$title}%";
 					$values[] = "%{$title}%";
 				}
 				}
 			}
 			}
+			if ($filter->getIntitleRegex() !== null) {
+				foreach ($filter->getIntitleRegex() as $title) {
+					$sub_search .= 'AND ' . static::sqlRegex($alias . 'title', $title, $values) . ' ';
+				}
+			}
 			if ($filter->getTags() !== null) {
 			if ($filter->getTags() !== null) {
 				foreach ($filter->getTags() as $tag) {
 				foreach ($filter->getTags() as $tag) {
 					$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' LIKE ? ';
 					$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' LIKE ? ';
 					$values[] = "%{$tag} #%";
 					$values[] = "%{$tag} #%";
 				}
 				}
 			}
 			}
+			if ($filter->getTagsRegex() !== null) {
+				foreach ($filter->getTagsRegex() as $tag) {
+					$sub_search .= 'AND ' . static::sqlRegex("REPLACE(REPLACE({$alias}tags, ' #', '#'), '#', '\n')", $tag, $values) . ' ';
+				}
+			}
 			if ($filter->getInurl() !== null) {
 			if ($filter->getInurl() !== null) {
 				foreach ($filter->getInurl() as $url) {
 				foreach ($filter->getInurl() as $url) {
 					$sub_search .= 'AND ' . $alias . 'link LIKE ? ';
 					$sub_search .= 'AND ' . $alias . 'link LIKE ? ';
 					$values[] = "%{$url}%";
 					$values[] = "%{$url}%";
 				}
 				}
 			}
 			}
+			if ($filter->getInurlRegex() !== null) {
+				foreach ($filter->getInurlRegex() as $url) {
+					$sub_search .= 'AND ' . static::sqlRegex($alias . 'link', $url, $values) . ' ';
+				}
+			}
 
 
 			if ($filter->getNotAuthor() !== null) {
 			if ($filter->getNotAuthor() !== null) {
 				foreach ($filter->getNotAuthor() as $author) {
 				foreach ($filter->getNotAuthor() as $author) {
@@ -935,29 +1004,49 @@ SQL;
 					$values[] = "%{$author}%";
 					$values[] = "%{$author}%";
 				}
 				}
 			}
 			}
+			if ($filter->getNotAuthorRegex() !== null) {
+				foreach ($filter->getNotAuthorRegex() as $author) {
+					$sub_search .= 'AND NOT ' . static::sqlRegex("REPLACE({$alias}author, ';', '\n')", $author, $values) . ' ';
+				}
+			}
 			if ($filter->getNotIntitle() !== null) {
 			if ($filter->getNotIntitle() !== null) {
 				foreach ($filter->getNotIntitle() as $title) {
 				foreach ($filter->getNotIntitle() as $title) {
 					$sub_search .= 'AND ' . $alias . 'title NOT LIKE ? ';
 					$sub_search .= 'AND ' . $alias . 'title NOT LIKE ? ';
 					$values[] = "%{$title}%";
 					$values[] = "%{$title}%";
 				}
 				}
 			}
 			}
+			if ($filter->getNotIntitleRegex() !== null) {
+				foreach ($filter->getNotIntitleRegex() as $title) {
+					$sub_search .= 'AND NOT ' . static::sqlRegex($alias . 'title', $title, $values) . ' ';
+				}
+			}
 			if ($filter->getNotTags() !== null) {
 			if ($filter->getNotTags() !== null) {
 				foreach ($filter->getNotTags() as $tag) {
 				foreach ($filter->getNotTags() as $tag) {
 					$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' NOT LIKE ? ';
 					$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' NOT LIKE ? ';
 					$values[] = "%{$tag} #%";
 					$values[] = "%{$tag} #%";
 				}
 				}
 			}
 			}
+			if ($filter->getNotTagsRegex() !== null) {
+				foreach ($filter->getNotTagsRegex() as $tag) {
+					$sub_search .= 'AND NOT ' . static::sqlRegex("REPLACE(REPLACE({$alias}tags, ' #', '#'), '#', '\n')", $tag, $values) . ' ';
+				}
+			}
 			if ($filter->getNotInurl() !== null) {
 			if ($filter->getNotInurl() !== null) {
 				foreach ($filter->getNotInurl() as $url) {
 				foreach ($filter->getNotInurl() as $url) {
 					$sub_search .= 'AND ' . $alias . 'link NOT LIKE ? ';
 					$sub_search .= 'AND ' . $alias . 'link NOT LIKE ? ';
 					$values[] = "%{$url}%";
 					$values[] = "%{$url}%";
 				}
 				}
 			}
 			}
+			if ($filter->getNotInurlRegex() !== null) {
+				foreach ($filter->getNotInurlRegex() as $url) {
+					$sub_search .= 'AND NOT ' . static::sqlRegex($alias . 'link', $url, $values) . ' ';
+				}
+			}
 
 
 			if ($filter->getSearch() !== null) {
 			if ($filter->getSearch() !== null) {
 				foreach ($filter->getSearch() as $search_value) {
 				foreach ($filter->getSearch() as $search_value) {
 					if (static::isCompressed()) {	// MySQL-only
 					if (static::isCompressed()) {	// MySQL-only
-						$sub_search .= 'AND CONCAT(' . $alias . 'title, UNCOMPRESS(' . $alias . 'content_bin)) LIKE ? ';
+						$sub_search .= "AND CONCAT({$alias}title, '\\n', UNCOMPRESS({$alias}content_bin)) LIKE ? ";
 						$values[] = "%{$search_value}%";
 						$values[] = "%{$search_value}%";
 					} else {
 					} else {
 						$sub_search .= 'AND (' . $alias . 'title LIKE ? OR ' . $alias . 'content LIKE ?) ';
 						$sub_search .= 'AND (' . $alias . 'title LIKE ? OR ' . $alias . 'content LIKE ?) ';
@@ -966,10 +1055,21 @@ SQL;
 					}
 					}
 				}
 				}
 			}
 			}
+			if ($filter->getSearchRegex() !== null) {
+				foreach ($filter->getSearchRegex() as $search_value) {
+					if (static::isCompressed()) {	// MySQL-only
+						$sub_search .= 'AND (' . static::sqlRegex($alias . 'title', $search_value, $values) .
+							' OR ' . static::sqlRegex("UNCOMPRESS({$alias}content_bin)", $search_value, $values) . ') ';
+					} else {
+						$sub_search .= 'AND (' . static::sqlRegex($alias . 'title', $search_value, $values) .
+							' OR ' . static::sqlRegex($alias . 'content', $search_value, $values) . ') ';
+					}
+				}
+			}
 			if ($filter->getNotSearch() !== null) {
 			if ($filter->getNotSearch() !== null) {
 				foreach ($filter->getNotSearch() as $search_value) {
 				foreach ($filter->getNotSearch() as $search_value) {
 					if (static::isCompressed()) {	// MySQL-only
 					if (static::isCompressed()) {	// MySQL-only
-						$sub_search .= 'AND CONCAT(' . $alias . 'title, UNCOMPRESS(' . $alias . 'content_bin)) NOT LIKE ? ';
+						$sub_search .= "AND CONCAT({$alias}title, '\\n', UNCOMPRESS({$alias}content_bin)) NOT LIKE ? ";
 						$values[] = "%{$search_value}%";
 						$values[] = "%{$search_value}%";
 					} else {
 					} else {
 						$sub_search .= 'AND ' . $alias . 'title NOT LIKE ? AND ' . $alias . 'content NOT LIKE ? ';
 						$sub_search .= 'AND ' . $alias . 'title NOT LIKE ? AND ' . $alias . 'content NOT LIKE ? ';
@@ -978,6 +1078,17 @@ SQL;
 					}
 					}
 				}
 				}
 			}
 			}
+			if ($filter->getNotSearchRegex() !== null) {
+				foreach ($filter->getNotSearchRegex() as $search_value) {
+					if (static::isCompressed()) {	// MySQL-only
+						$sub_search .= 'AND NOT ' . static::sqlRegex($alias . 'title', $search_value, $values) .
+							' ANT NOT ' . static::sqlRegex("UNCOMPRESS({$alias}content_bin)", $search_value, $values) . ' ';
+					} else {
+						$sub_search .= 'AND NOT ' . static::sqlRegex($alias . 'title', $search_value, $values) .
+							' AND NOT ' . static::sqlRegex($alias . 'content', $search_value, $values) . ' ';
+					}
+				}
+			}
 
 
 			if ($sub_search != '') {
 			if ($sub_search != '') {
 				if ($isOpen) {
 				if ($isOpen) {
@@ -1039,6 +1150,7 @@ SQL;
 			if ($filterSearch !== '') {
 			if ($filterSearch !== '') {
 				$search .= 'AND (' . $filterSearch . ') ';
 				$search .= 'AND (' . $filterSearch . ') ';
 				$values = array_merge($values, $filterValues);
 				$values = array_merge($values, $filterValues);
+				$this->registerSqlFunctions($search);
 			}
 			}
 		}
 		}
 		return [$values, $search];
 		return [$values, $search];

+ 26 - 0
app/Models/EntryDAOPGSQL.php

@@ -23,6 +23,32 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 		return rtrim($sql, ' ;') . ' ON CONFLICT DO NOTHING';
 		return rtrim($sql, ' ;') . ' ON CONFLICT DO NOTHING';
 	}
 	}
 
 
+	#[\Override]
+	protected static function sqlRegex(string $expression, string $regex, array &$values): string {
+		$matches = static::regexToSql($regex);
+		if (isset($matches['pattern'])) {
+			$matchType = $matches['matchType'] ?? '';
+			if (str_contains($matchType, 'm')) {
+				// newline-sensitive matching
+				$matches['pattern'] = '(?m)' . $matches['pattern'];
+			}
+			$values[] = $matches['pattern'];
+			if (str_contains($matchType, 'i')) {
+				// case-insensitive matching
+				return "{$expression} ~* ?";
+			} else {
+				// case-sensitive matching
+				return "{$expression} ~ ?";
+			}
+		}
+		return '';
+	}
+
+	#[\Override]
+	protected function registerSqlFunctions(string $sql): void {
+		// Nothing to do for PostgreSQL
+	}
+
 	/** @param array<string|int> $errorInfo */
 	/** @param array<string|int> $errorInfo */
 	#[\Override]
 	#[\Override]
 	protected function autoUpdateDb(array $errorInfo): bool {
 	protected function autoUpdateDb(array $errorInfo): bool {

+ 21 - 0
app/Models/EntryDAOSQLite.php

@@ -28,6 +28,27 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		return str_replace('INSERT INTO ', 'INSERT OR IGNORE INTO ', $sql);
 		return str_replace('INSERT INTO ', 'INSERT OR IGNORE INTO ', $sql);
 	}
 	}
 
 
+	#[\Override]
+	protected static function sqlRegex(string $expression, string $regex, array &$values): string {
+		$values[] = $regex;
+		return "{$expression} REGEXP ?";
+	}
+
+	#[\Override]
+	protected function registerSqlFunctions(string $sql): void {
+		if (!str_contains($sql, ' REGEXP ')) {
+			return;
+		}
+		// https://php.net/pdo.sqlitecreatefunction
+		// https://www.sqlite.org/lang_expr.html#the_like_glob_regexp_match_and_extract_operators
+		$this->pdo->sqliteCreateFunction('regexp',
+			function (string $pattern, string $text): bool {
+				return preg_match($pattern, $text) === 1;
+			},
+			2
+		);
+	}
+
 	/** @param array<string|int> $errorInfo */
 	/** @param array<string|int> $errorInfo */
 	#[\Override]
 	#[\Override]
 	protected function autoUpdateDb(array $errorInfo): bool {
 	protected function autoUpdateDb(array $errorInfo): bool {

+ 173 - 34
app/Models/Search.php

@@ -27,6 +27,8 @@ class FreshRSS_Search {
 	private ?array $label_names = null;
 	private ?array $label_names = null;
 	/** @var array<string>|null */
 	/** @var array<string>|null */
 	private ?array $intitle = null;
 	private ?array $intitle = null;
+	/** @var array<string>|null */
+	private ?array $intitle_regex = null;
 	/** @var int|false|null */
 	/** @var int|false|null */
 	private $min_date = null;
 	private $min_date = null;
 	/** @var int|false|null */
 	/** @var int|false|null */
@@ -38,11 +40,19 @@ class FreshRSS_Search {
 	/** @var array<string>|null */
 	/** @var array<string>|null */
 	private ?array $inurl = null;
 	private ?array $inurl = null;
 	/** @var array<string>|null */
 	/** @var array<string>|null */
+	private ?array $inurl_regex = null;
+	/** @var array<string>|null */
 	private ?array $author = null;
 	private ?array $author = null;
 	/** @var array<string>|null */
 	/** @var array<string>|null */
+	private ?array $author_regex = null;
+	/** @var array<string>|null */
 	private ?array $tags = null;
 	private ?array $tags = null;
 	/** @var array<string>|null */
 	/** @var array<string>|null */
+	private ?array $tags_regex = null;
+	/** @var array<string>|null */
 	private ?array $search = null;
 	private ?array $search = null;
+	/** @var array<string>|null */
+	private ?array $search_regex = null;
 
 
 	/** @var array<string>|null */
 	/** @var array<string>|null */
 	private ?array $not_entry_ids = null;
 	private ?array $not_entry_ids = null;
@@ -54,6 +64,8 @@ class FreshRSS_Search {
 	private ?array $not_label_names = null;
 	private ?array $not_label_names = null;
 	/** @var array<string>|null */
 	/** @var array<string>|null */
 	private ?array $not_intitle = null;
 	private ?array $not_intitle = null;
+	/** @var array<string>|null */
+	private ?array $not_intitle_regex = null;
 	/** @var int|false|null */
 	/** @var int|false|null */
 	private $not_min_date = null;
 	private $not_min_date = null;
 	/** @var int|false|null */
 	/** @var int|false|null */
@@ -65,11 +77,19 @@ class FreshRSS_Search {
 	/** @var array<string>|null */
 	/** @var array<string>|null */
 	private ?array $not_inurl = null;
 	private ?array $not_inurl = null;
 	/** @var array<string>|null */
 	/** @var array<string>|null */
+	private ?array $not_inurl_regex = null;
+	/** @var array<string>|null */
 	private ?array $not_author = null;
 	private ?array $not_author = null;
 	/** @var array<string>|null */
 	/** @var array<string>|null */
+	private ?array $not_author_regex = null;
+	/** @var array<string>|null */
 	private ?array $not_tags = null;
 	private ?array $not_tags = null;
 	/** @var array<string>|null */
 	/** @var array<string>|null */
+	private ?array $not_tags_regex = null;
+	/** @var array<string>|null */
 	private ?array $not_search = null;
 	private ?array $not_search = null;
+	/** @var array<string>|null */
+	private ?array $not_search_regex = null;
 
 
 	public function __construct(string $input) {
 	public function __construct(string $input) {
 		$input = self::cleanSearch($input);
 		$input = self::cleanSearch($input);
@@ -156,9 +176,17 @@ class FreshRSS_Search {
 		return $this->intitle;
 		return $this->intitle;
 	}
 	}
 	/** @return array<string>|null */
 	/** @return array<string>|null */
+	public function getIntitleRegex(): ?array {
+		return $this->intitle_regex;
+	}
+	/** @return array<string>|null */
 	public function getNotIntitle(): ?array {
 	public function getNotIntitle(): ?array {
 		return $this->not_intitle;
 		return $this->not_intitle;
 	}
 	}
+	/** @return array<string>|null */
+	public function getNotIntitleRegex(): ?array {
+		return $this->not_intitle_regex;
+	}
 
 
 	public function getMinDate(): ?int {
 	public function getMinDate(): ?int {
 		return $this->min_date ?: null;
 		return $this->min_date ?: null;
@@ -199,36 +227,68 @@ class FreshRSS_Search {
 		return $this->inurl;
 		return $this->inurl;
 	}
 	}
 	/** @return array<string>|null */
 	/** @return array<string>|null */
+	public function getInurlRegex(): ?array {
+		return $this->inurl_regex;
+	}
+	/** @return array<string>|null */
 	public function getNotInurl(): ?array {
 	public function getNotInurl(): ?array {
 		return $this->not_inurl;
 		return $this->not_inurl;
 	}
 	}
+	/** @return array<string>|null */
+	public function getNotInurlRegex(): ?array {
+		return $this->not_inurl_regex;
+	}
 
 
 	/** @return array<string>|null */
 	/** @return array<string>|null */
 	public function getAuthor(): ?array {
 	public function getAuthor(): ?array {
 		return $this->author;
 		return $this->author;
 	}
 	}
 	/** @return array<string>|null */
 	/** @return array<string>|null */
+	public function getAuthorRegex(): ?array {
+		return $this->author_regex;
+	}
+	/** @return array<string>|null */
 	public function getNotAuthor(): ?array {
 	public function getNotAuthor(): ?array {
 		return $this->not_author;
 		return $this->not_author;
 	}
 	}
+	/** @return array<string>|null */
+	public function getNotAuthorRegex(): ?array {
+		return $this->not_author_regex;
+	}
 
 
 	/** @return array<string>|null */
 	/** @return array<string>|null */
 	public function getTags(): ?array {
 	public function getTags(): ?array {
 		return $this->tags;
 		return $this->tags;
 	}
 	}
 	/** @return array<string>|null */
 	/** @return array<string>|null */
+	public function getTagsRegex(): ?array {
+		return $this->tags_regex;
+	}
+	/** @return array<string>|null */
 	public function getNotTags(): ?array {
 	public function getNotTags(): ?array {
 		return $this->not_tags;
 		return $this->not_tags;
 	}
 	}
+	/** @return array<string>|null */
+	public function getNotTagsRegex(): ?array {
+		return $this->not_tags_regex;
+	}
 
 
 	/** @return array<string>|null */
 	/** @return array<string>|null */
 	public function getSearch(): ?array {
 	public function getSearch(): ?array {
 		return $this->search;
 		return $this->search;
 	}
 	}
 	/** @return array<string>|null */
 	/** @return array<string>|null */
+	public function getSearchRegex(): ?array {
+		return $this->search_regex;
+	}
+	/** @return array<string>|null */
 	public function getNotSearch(): ?array {
 	public function getNotSearch(): ?array {
 		return $this->not_search;
 		return $this->not_search;
 	}
 	}
+	/** @return array<string>|null */
+	public function getNotSearchRegex(): ?array {
+		return $this->not_search_regex;
+	}
 
 
 	/**
 	/**
 	 * @param array<string>|null $anArray
 	 * @param array<string>|null $anArray
@@ -253,11 +313,19 @@ class FreshRSS_Search {
 		return $value;
 		return $value;
 	}
 	}
 
 
+	/**
+	 * @param array<string> $strings
+	 * @return array<string>
+	 */
+	private static function htmlspecialchars_decodes(array $strings): array {
+		return array_map(static fn(string $s) => htmlspecialchars_decode($s, ENT_QUOTES), $strings);
+	}
+
 	/**
 	/**
 	 * Parse the search string to find entry (article) IDs.
 	 * Parse the search string to find entry (article) IDs.
 	 */
 	 */
 	private function parseEntryIds(string $input): string {
 	private function parseEntryIds(string $input): string {
-		if (preg_match_all('/\be:(?P<search>[0-9,]*)/', $input, $matches)) {
+		if (preg_match_all('/\\be:(?P<search>[0-9,]*)/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$ids_lists = $matches['search'];
 			$ids_lists = $matches['search'];
 			$this->entry_ids = [];
 			$this->entry_ids = [];
@@ -273,7 +341,7 @@ class FreshRSS_Search {
 	}
 	}
 
 
 	private function parseNotEntryIds(string $input): string {
 	private function parseNotEntryIds(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-]e:(?P<search>[0-9,]*)/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-]e:(?P<search>[0-9,]*)/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$ids_lists = $matches['search'];
 			$ids_lists = $matches['search'];
 			$this->not_entry_ids = [];
 			$this->not_entry_ids = [];
@@ -289,7 +357,7 @@ class FreshRSS_Search {
 	}
 	}
 
 
 	private function parseFeedIds(string $input): string {
 	private function parseFeedIds(string $input): string {
-		if (preg_match_all('/\bf:(?P<search>[0-9,]*)/', $input, $matches)) {
+		if (preg_match_all('/\\bf:(?P<search>[0-9,]*)/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$ids_lists = $matches['search'];
 			$ids_lists = $matches['search'];
 			$this->feed_ids = [];
 			$this->feed_ids = [];
@@ -307,7 +375,7 @@ class FreshRSS_Search {
 	}
 	}
 
 
 	private function parseNotFeedIds(string $input): string {
 	private function parseNotFeedIds(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-]f:(?P<search>[0-9,]*)/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-]f:(?P<search>[0-9,]*)/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$ids_lists = $matches['search'];
 			$ids_lists = $matches['search'];
 			$this->not_feed_ids = [];
 			$this->not_feed_ids = [];
@@ -328,7 +396,7 @@ class FreshRSS_Search {
 	 * Parse the search string to find tags (labels) IDs.
 	 * Parse the search string to find tags (labels) IDs.
 	 */
 	 */
 	private function parseLabelIds(string $input): string {
 	private function parseLabelIds(string $input): string {
-		if (preg_match_all('/\b[lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
+		if (preg_match_all('/\\b[lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$ids_lists = $matches['search'];
 			$ids_lists = $matches['search'];
 			$this->label_ids = [];
 			$this->label_ids = [];
@@ -350,7 +418,7 @@ class FreshRSS_Search {
 	}
 	}
 
 
 	private function parseNotLabelIds(string $input): string {
 	private function parseNotLabelIds(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-][lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-][lL]:(?P<search>[0-9,]+|[*])/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$ids_lists = $matches['search'];
 			$ids_lists = $matches['search'];
 			$this->not_label_ids = [];
 			$this->not_label_ids = [];
@@ -376,11 +444,11 @@ class FreshRSS_Search {
 	 */
 	 */
 	private function parseLabelNames(string $input): string {
 	private function parseLabelNames(string $input): string {
 		$names_lists = [];
 		$names_lists = [];
-		if (preg_match_all('/\blabels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+		if (preg_match_all('/\\blabels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$names_lists = $matches['search'];
 			$names_lists = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
-		if (preg_match_all('/\blabels?:(?P<search>[^\s"]*)/', $input, $matches)) {
+		if (preg_match_all('/\\blabels?:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$names_lists = array_merge($names_lists, $matches['search']);
 			$names_lists = array_merge($names_lists, $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
@@ -402,11 +470,11 @@ class FreshRSS_Search {
 	 */
 	 */
 	private function parseNotLabelNames(string $input): string {
 	private function parseNotLabelNames(string $input): string {
 		$names_lists = [];
 		$names_lists = [];
-		if (preg_match_all('/(?<=\s|^)[!-]labels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-]labels?:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$names_lists = $matches['search'];
 			$names_lists = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
-		if (preg_match_all('/(?<=\s|^)[!-]labels?:(?P<search>[^\s"]*)/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-]labels?:(?P<search>[^\\s"]*)/', $input, $matches)) {
 			$names_lists = array_merge($names_lists, $matches['search']);
 			$names_lists = array_merge($names_lists, $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
@@ -428,11 +496,15 @@ class FreshRSS_Search {
 	 * The search is the first word following the keyword.
 	 * The search is the first word following the keyword.
 	 */
 	 */
 	private function parseIntitleSearch(string $input): string {
 	private function parseIntitleSearch(string $input): string {
-		if (preg_match_all('/\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+		if (preg_match_all('#\\bintitle:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
+			$this->intitle_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/\\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->intitle = $matches['search'];
 			$this->intitle = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
-		if (preg_match_all('/\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) {
+		if (preg_match_all('/\\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$this->intitle = array_merge($this->intitle ?: [], $matches['search']);
 			$this->intitle = array_merge($this->intitle ?: [], $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
@@ -444,11 +516,15 @@ class FreshRSS_Search {
 	}
 	}
 
 
 	private function parseNotIntitleSearch(string $input): string {
 	private function parseNotIntitleSearch(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+		if (preg_match_all('#(?<=\\s|^)[!-]intitle:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
+			$this->not_intitle_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/(?<=\\s|^)[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->not_intitle = $matches['search'];
 			$this->not_intitle = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
-		if (preg_match_all('/(?<=\s|^)[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$this->not_intitle = array_merge($this->not_intitle ?: [], $matches['search']);
 			$this->not_intitle = array_merge($this->not_intitle ?: [], $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
@@ -465,11 +541,15 @@ class FreshRSS_Search {
 	 * a delimiter. Supported delimiters are single quote (') and double quotes (").
 	 * a delimiter. Supported delimiters are single quote (') and double quotes (").
 	 */
 	 */
 	private function parseAuthorSearch(string $input): string {
 	private function parseAuthorSearch(string $input): string {
-		if (preg_match_all('/\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+		if (preg_match_all('#\\bauthor:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
+			$this->author_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/\\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->author = $matches['search'];
 			$this->author = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
-		if (preg_match_all('/\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) {
+		if (preg_match_all('/\\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$this->author = array_merge($this->author ?: [], $matches['search']);
 			$this->author = array_merge($this->author ?: [], $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
@@ -481,11 +561,15 @@ class FreshRSS_Search {
 	}
 	}
 
 
 	private function parseNotAuthorSearch(string $input): string {
 	private function parseNotAuthorSearch(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+		if (preg_match_all('#(?<=\\s|^)[!-]author:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
+			$this->not_author_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/(?<=\\s|^)[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->not_author = $matches['search'];
 			$this->not_author = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
-		if (preg_match_all('/(?<=\s|^)[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$this->not_author = array_merge($this->not_author ?: [], $matches['search']);
 			$this->not_author = array_merge($this->not_author ?: [], $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
@@ -501,19 +585,41 @@ class FreshRSS_Search {
 	 * The search is the first word following the keyword.
 	 * The search is the first word following the keyword.
 	 */
 	 */
 	private function parseInurlSearch(string $input): string {
 	private function parseInurlSearch(string $input): string {
-		if (preg_match_all('/\binurl:(?P<search>[^\s]*)/', $input, $matches)) {
+		if (preg_match_all('#\\binurl:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
+			$this->inurl_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/\\binurl:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->inurl = $matches['search'];
 			$this->inurl = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
-			$this->inurl = self::removeEmptyValues($this->inurl);
+		}
+		if (preg_match_all('/\\binurl:(?P<search>[^\\s]*)/', $input, $matches)) {
+			$this->inurl = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->inurl = self::removeEmptyValues($this->inurl);
+		if (empty($this->inurl)) {
+			$this->inurl = null;
 		}
 		}
 		return $input;
 		return $input;
 	}
 	}
 
 
 	private function parseNotInurlSearch(string $input): string {
 	private function parseNotInurlSearch(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-]inurl:(?P<search>[^\s]*)/', $input, $matches)) {
+		if (preg_match_all('#(?<=\\s|^)[!-]inurl:(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
+			$this->not_inurl_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/(?<=\\s|^)[!-]inurl:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->not_inurl = $matches['search'];
 			$this->not_inurl = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
-			$this->not_inurl = self::removeEmptyValues($this->not_inurl);
+		}
+		if (preg_match_all('/(?<=\\s|^)[!-]inurl:(?P<search>[^\\s]*)/', $input, $matches)) {
+			$this->not_inurl = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->not_inurl = self::removeEmptyValues($this->not_inurl);
+		if (empty($this->not_inurl)) {
+			$this->not_inurl = null;
 		}
 		}
 		return $input;
 		return $input;
 	}
 	}
@@ -523,7 +629,7 @@ class FreshRSS_Search {
 	 * The search is the first word following the keyword.
 	 * The search is the first word following the keyword.
 	 */
 	 */
 	private function parseDateSearch(string $input): string {
 	private function parseDateSearch(string $input): string {
-		if (preg_match_all('/\bdate:(?P<search>[^\s]*)/', $input, $matches)) {
+		if (preg_match_all('/\\bdate:(?P<search>[^\\s]*)/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$dates = self::removeEmptyValues($matches['search']);
 			$dates = self::removeEmptyValues($matches['search']);
 			if (!empty($dates[0])) {
 			if (!empty($dates[0])) {
@@ -534,7 +640,7 @@ class FreshRSS_Search {
 	}
 	}
 
 
 	private function parseNotDateSearch(string $input): string {
 	private function parseNotDateSearch(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-]date:(?P<search>[^\s]*)/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-]date:(?P<search>[^\\s]*)/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$dates = self::removeEmptyValues($matches['search']);
 			$dates = self::removeEmptyValues($matches['search']);
 			if (!empty($dates[0])) {
 			if (!empty($dates[0])) {
@@ -550,7 +656,7 @@ class FreshRSS_Search {
 	 * The search is the first word following the keyword.
 	 * The search is the first word following the keyword.
 	 */
 	 */
 	private function parsePubdateSearch(string $input): string {
 	private function parsePubdateSearch(string $input): string {
-		if (preg_match_all('/\bpubdate:(?P<search>[^\s]*)/', $input, $matches)) {
+		if (preg_match_all('/\\bpubdate:(?P<search>[^\\s]*)/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$dates = self::removeEmptyValues($matches['search']);
 			$dates = self::removeEmptyValues($matches['search']);
 			if (!empty($dates[0])) {
 			if (!empty($dates[0])) {
@@ -561,7 +667,7 @@ class FreshRSS_Search {
 	}
 	}
 
 
 	private function parseNotPubdateSearch(string $input): string {
 	private function parseNotPubdateSearch(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-]pubdate:(?P<search>[^\s]*)/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-]pubdate:(?P<search>[^\\s]*)/', $input, $matches)) {
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 			$dates = self::removeEmptyValues($matches['search']);
 			$dates = self::removeEmptyValues($matches['search']);
 			if (!empty($dates[0])) {
 			if (!empty($dates[0])) {
@@ -577,20 +683,44 @@ class FreshRSS_Search {
 	 * The search is the first word following the #.
 	 * The search is the first word following the #.
 	 */
 	 */
 	private function parseTagsSearch(string $input): string {
 	private function parseTagsSearch(string $input): string {
-		if (preg_match_all('/#(?P<search>[^\s]+)/', $input, $matches)) {
+		if (preg_match_all('%#(?P<search>/.*?(?<!\\\\)/[im]*)%', $input, $matches)) {
+			$this->tags_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/#(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->tags = $matches['search'];
 			$this->tags = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
-			$this->tags = self::removeEmptyValues($this->tags);
+		}
+		if (preg_match_all('/#(?P<search>[^\\s]+)/', $input, $matches)) {
+			$this->tags = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->tags = self::removeEmptyValues($this->tags);
+		if (empty($this->tags)) {
+			$this->tags = null;
+		} else {
 			$this->tags = self::decodeSpaces($this->tags);
 			$this->tags = self::decodeSpaces($this->tags);
 		}
 		}
 		return $input;
 		return $input;
 	}
 	}
 
 
 	private function parseNotTagsSearch(string $input): string {
 	private function parseNotTagsSearch(string $input): string {
-		if (preg_match_all('/(?<=\s|^)[!-]#(?P<search>[^\s]+)/', $input, $matches)) {
+		if (preg_match_all('%(?<=\\s|^)[!-]#(?P<search>/.*?(?<!\\\\)/[im]*)%', $input, $matches)) {
+			$this->not_tags_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/(?<=\\s|^)[!-]#(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->not_tags = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/(?<=\\s|^)[!-]#(?P<search>[^\\s]+)/', $input, $matches)) {
 			$this->not_tags = $matches['search'];
 			$this->not_tags = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
-			$this->not_tags = self::removeEmptyValues($this->not_tags);
+		}
+		$this->not_tags = self::removeEmptyValues($this->not_tags);
+		if (empty($this->not_tags)) {
+			$this->not_tags = null;
+		} else {
 			$this->not_tags = self::decodeSpaces($this->not_tags);
 			$this->not_tags = self::decodeSpaces($this->not_tags);
 		}
 		}
 		return $input;
 		return $input;
@@ -599,13 +729,18 @@ class FreshRSS_Search {
 	/**
 	/**
 	 * Parse the search string to find search values.
 	 * Parse the search string to find search values.
 	 * Every word is a distinct search value using a delimiter.
 	 * Every word is a distinct search value using a delimiter.
-	 * Supported delimiters are single quote (') and double quotes (").
+	 * Supported delimiters are single quote (') and double quotes (") and regex (/).
 	 */
 	 */
 	private function parseQuotedSearch(string $input): string {
 	private function parseQuotedSearch(string $input): string {
 		$input = self::cleanSearch($input);
 		$input = self::cleanSearch($input);
 		if ($input === '') {
 		if ($input === '') {
 			return '';
 			return '';
 		}
 		}
+		if (preg_match_all('#(?<=\\s|^)(?<![!-\\\\])(?P<search>/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
+			$this->search_regex = self::htmlspecialchars_decodes($matches['search']);
+			//TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE
+			$input = str_replace($matches[0], '', $input);
+		}
 		if (preg_match_all('/(?<![!-])(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 		if (preg_match_all('/(?<![!-])(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->search = $matches['search'];
 			$this->search = $matches['search'];
 			//TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE
 			//TODO: Replace all those str_replace with PREG_OFFSET_CAPTURE
@@ -636,7 +771,11 @@ class FreshRSS_Search {
 		if ($input === '') {
 		if ($input === '') {
 			return '';
 			return '';
 		}
 		}
-		if (preg_match_all('/(?<=\s|^)[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+		if (preg_match_all('#(?<=\\s|^)[!-](?P<search>(?<!\\\\)/.*?(?<!\\\\)/[im]*)#', $input, $matches)) {
+			$this->not_search_regex = self::htmlspecialchars_decodes($matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/(?<=\\s|^)[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
 			$this->not_search = $matches['search'];
 			$this->not_search = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
@@ -644,7 +783,7 @@ class FreshRSS_Search {
 		if ($input === '') {
 		if ($input === '') {
 			return '';
 			return '';
 		}
 		}
-		if (preg_match_all('/(?<=\s|^)[!-](?P<search>[^\s]+)/', $input, $matches)) {
+		if (preg_match_all('/(?<=\\s|^)[!-](?P<search>[^\\s]+)/', $input, $matches)) {
 			$this->not_search = array_merge(is_array($this->not_search) ? $this->not_search : [], $matches['search']);
 			$this->not_search = array_merge(is_array($this->not_search) ? $this->not_search : [], $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 			$input = str_replace($matches[0], '', $input);
 		}
 		}
@@ -656,7 +795,7 @@ class FreshRSS_Search {
 	 * Remove all unnecessary spaces in the search
 	 * Remove all unnecessary spaces in the search
 	 */
 	 */
 	private static function cleanSearch(string $input): string {
 	private static function cleanSearch(string $input): string {
-		$input = preg_replace('/\s+/', ' ', $input);
+		$input = preg_replace('/\\s+/', ' ', $input);
 		if (!is_string($input)) {
 		if (!is_string($input)) {
 			return '';
 			return '';
 		}
 		}

+ 1 - 1
docs/en/admins/02_Prerequisites.md

@@ -9,7 +9,7 @@ You need to verify that your server can run FreshRSS before installing it. If yo
 | Web server    | **Apache 2.4**          | nginx, lighttpd<br />minimal compatibility with Apache 2.2    |
 | Web server    | **Apache 2.4**          | nginx, lighttpd<br />minimal compatibility with Apache 2.2    |
 | PHP           | **PHP 8.1+**            | FreshRSS 1.21/1.22: PHP 7.2+; FreshRSS 1.23/1.24: PHP 7.4+    |
 | PHP           | **PHP 8.1+**            | FreshRSS 1.21/1.22: PHP 7.2+; FreshRSS 1.23/1.24: PHP 7.4+    |
 | PHP modules   | Required: libxml, cURL, JSON, PDO_MySQL, PCRE and ctype.<br />Required (32-bit only): GMP <br />Recommended: Zlib, mbstring, iconv, ZipArchive<br />*For the whole modules list see [Dockerfile](https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/Dockerfile-Alpine#L7-L9)* | |
 | PHP modules   | Required: libxml, cURL, JSON, PDO_MySQL, PCRE and ctype.<br />Required (32-bit only): GMP <br />Recommended: Zlib, mbstring, iconv, ZipArchive<br />*For the whole modules list see [Dockerfile](https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/Dockerfile-Alpine#L7-L9)* | |
-| Database      | **PostgreSQL 10+**      | SQLite, MySQL 5.5.3+, MariaDB 5.5+   |
+| Database      | **PostgreSQL 10+**     | SQLite, MariaDB 10.0.5+, MySQL 8.0+ |
 | Browser       | **Firefox**             | Chrome, Opera, Safari, or Edge       |
 | Browser       | **Firefox**             | Chrome, Opera, Safari, or Edge       |
 
 
 ## Getting the appropriate version of FreshRSS
 ## Getting the appropriate version of FreshRSS

+ 1 - 1
docs/en/admins/DatabaseConfig.md

@@ -1,6 +1,6 @@
 # Database configuration
 # Database configuration
 
 
-FreshRSS supports the databases SQLite (built-in), PostgreSQL, MySQL / MariaDB.
+FreshRSS supports the databases SQLite (built-in), PostgreSQL, MariaDB / MySQL.
 
 
 While the default installation should be fine for most cases, additional tuning can be made.
 While the default installation should be fine for most cases, additional tuning can be made.
 
 

+ 1 - 1
docs/en/developers/06_Reporting_Bugs.md

@@ -64,7 +64,7 @@ Remember to give the following information if you know it:
 1. Which browser? Which version?
 1. Which browser? Which version?
 2. Which server: Apache, Nginx? Which version?
 2. Which server: Apache, Nginx? Which version?
 3. Which version of PHP?
 3. Which version of PHP?
-4. Which database: SQLite, MySQL, MariaDB, PostgreSQL? Which version?
+4. Which database: SQLite, PostgreSQL, MariaDB, MySQL? Which version?
 5. Which distribution runs on the server? And… which version?
 5. Which distribution runs on the server? And… which version?
 
 
 ## How to provide feed data
 ## How to provide feed data

+ 28 - 1
docs/en/users/10_filter.md

@@ -49,7 +49,7 @@ You can use the search field to further refine results:
 * by author: `author:name` or `author:'composed name'`
 * by author: `author:name` or `author:'composed name'`
 * by title: `intitle:keyword` or `intitle:'composed keyword'`
 * by title: `intitle:keyword` or `intitle:'composed keyword'`
 * by URL: `inurl:keyword` or `inurl:'composed keyword'`
 * by URL: `inurl:keyword` or `inurl:'composed keyword'`
-* by tag: `#tag` or `#tag+with+whitespace`
+* by tag: `#tag` or `#tag+with+whitespace` or or `#'tag with whitespace'`
 * by free-text: `keyword` or `'composed keyword'`
 * by free-text: `keyword` or `'composed keyword'`
 * by date of discovery, using the [ISO 8601 time interval format](http://en.wikipedia.org/wiki/ISO_8601#Time_intervals): `date:<date-interval>`
 * by date of discovery, using the [ISO 8601 time interval format](http://en.wikipedia.org/wiki/ISO_8601#Time_intervals): `date:<date-interval>`
 	* From a specific day, or month, or year:
 	* From a specific day, or month, or year:
@@ -105,6 +105,8 @@ can be used to combine several search criteria with a logical *or* instead: `aut
 You don’t have to do anything special to combine multiple negative operators. Writing `!intitle:'thing1' !intitle:'thing2'` implies AND, see above. For more pointers on how AND and OR interact with negation, see [this GitHub comment](https://github.com/FreshRSS/FreshRSS/issues/3236#issuecomment-891219460).
 You don’t have to do anything special to combine multiple negative operators. Writing `!intitle:'thing1' !intitle:'thing2'` implies AND, see above. For more pointers on how AND and OR interact with negation, see [this GitHub comment](https://github.com/FreshRSS/FreshRSS/issues/3236#issuecomment-891219460).
 Additional reading: [De Morgan’s laws](https://en.wikipedia.org/wiki/De_Morgan%27s_laws).
 Additional reading: [De Morgan’s laws](https://en.wikipedia.org/wiki/De_Morgan%27s_laws).
 
 
+> ℹ️ Searches are applied to the raw HTML content
+
 Finally, parentheses may be used to express more complex queries, with basic negation support:
 Finally, parentheses may be used to express more complex queries, with basic negation support:
 
 
 * `(author:Alice OR intitle:hello) (author:Bob OR intitle:world)`
 * `(author:Alice OR intitle:hello) (author:Bob OR intitle:world)`
@@ -115,6 +117,31 @@ Finally, parentheses may be used to express more complex queries, with basic neg
 
 
 > ℹ️ If you need to search for a parenthesis, it needs to be escaped like `\(` or `\)`
 > ℹ️ If you need to search for a parenthesis, it needs to be escaped like `\(` or `\)`
 
 
+### Regex
+
+Text searches (including `author:`, `intitle:`, `inurl:`, `#`) may use regular expressions, which must be enclosed in `/ /`.
+
+Regex searches are case-sensitive by default, but can be made case-insensitive with the `i` modifier like: `/Alice/i`
+
+Supports multiline mode with `m` modifier like: `/^Alice/m`
+
+> ℹ️ `author:` is working with one author per line, so the multiline mode may advantageously be used, like: `author:/^Alice Dupont$/im`
+>
+> ℹ️ `#` is likewise working with one tag per line, so the multiline mode may advantageously be used, like: `#/^Hello World$/im`
+
+Example to search entries, which title starts with the *Lol* word, with any number of *o*: `intitle:/^Lo+l/i`
+
+As opposed to normal searches, HTML special characters are not escaped in regex searches, to allow searching HTML code, like: `/Hello <span>world<\/span>/`
+
+⚠️ Advanced regex syntax details depend on the regex engine used:
+
+* FreshRSS filter actions such as auto-mark-as-read and auto-favourite use [PHP preg_match](https://php.net/function.preg-match).
+* Regex searches depend on which database you are using:
+	* For SQLite, [PHP preg_match](https://php.net/function.preg-match) is used;
+	* [For PostgreSQL](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-POSIX-REGEXP);
+	* [For MariaDB](https://mariadb.com/kb/en/pcre/);
+	* [For MySQL](https://dev.mysql.com/doc/refman/9.0/en/regexp.html#function_regexp-like).
+
 ## By sorting by date
 ## By sorting by date
 
 
 You can change the sort order by clicking the toggle button available in the header.
 You can change the sort order by clicking the toggle button available in the header.

+ 1 - 1
docs/fr/contributing.md

@@ -32,7 +32,7 @@ Nous avons aussi besoin de quelques informations :
 
 
 * Votre version de FreshRSS (sur la page A propos) ou le fichier `constants.php`)
 * Votre version de FreshRSS (sur la page A propos) ou le fichier `constants.php`)
 * Votre configuration de serveur : type d’hébergement, version PHP
 * Votre configuration de serveur : type d’hébergement, version PHP
-* Quelle base de données : SQLite, MySQL, MariaDB, PostgreSQL ? Quelle version ?
+* Quelle base de données : SQLite, PostgreSQL, MariaDB, MySQL ? Quelle version ?
 * Si possible, les logs associés (logs PHP et logs FreshRSS sous `data/users/your_user/log.txt`)
 * Si possible, les logs associés (logs PHP et logs FreshRSS sous `data/users/your_user/log.txt`)
 
 
 ## Corriger un bogue
 ## Corriger un bogue

+ 1 - 1
docs/fr/developers/02_Github.md

@@ -100,7 +100,7 @@ Pensez à donner les informations suivantes si vous les connaissez :
 1. Quel navigateur ? Quelle version ?
 1. Quel navigateur ? Quelle version ?
 2. Quel serveur : Apache, Nginx ? Quelle version ?
 2. Quel serveur : Apache, Nginx ? Quelle version ?
 3. Quelle version de PHP ?
 3. Quelle version de PHP ?
-4. Quelle base de données : SQLite, MySQL, MariaDB, PostgreSQL ? Quelle version ?
+4. Quelle base de données : SQLite, PostgreSQL, MariaDB, MySQL ? Quelle version ?
 5. Quelle distribution sur le serveur ? Et… quelle version ?
 5. Quelle distribution sur le serveur ? Et… quelle version ?
 
 
 ## Système de branches
 ## Système de branches

+ 1 - 1
docs/fr/users/01_Installation.md

@@ -9,7 +9,7 @@ Il est toutefois de votre responsabilité de vérifier que votre hébergement pe
 | Serveur web      | **Apache 2.4+**                                                                                                | nginx, lighttpd                |
 | Serveur web      | **Apache 2.4+**                                                                                                | nginx, lighttpd                |
 | PHP              | **PHP 8.1+**                                                                                                   |                                |
 | PHP              | **PHP 8.1+**                                                                                                   |                                |
 | Modules PHP      | Requis : libxml, cURL, JSON, PDO_MySQL, PCRE et ctype<br />Requis (32 bits seulement) : GMP<br />Recommandé : Zlib, mbstring et iconv, ZipArchive<br />*Pour une liste complète des modules nécessaires voir le [Dockerfile](https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/Dockerfile-Alpine#L7-L9)* |                                |
 | Modules PHP      | Requis : libxml, cURL, JSON, PDO_MySQL, PCRE et ctype<br />Requis (32 bits seulement) : GMP<br />Recommandé : Zlib, mbstring et iconv, ZipArchive<br />*Pour une liste complète des modules nécessaires voir le [Dockerfile](https://github.com/FreshRSS/FreshRSS/blob/edge/Docker/Dockerfile-Alpine#L7-L9)* |                                |
-| Base de données  | **PostgreSQL 10+** | SQLite, MySQL 5.5.3+, MariaDB 5.5+ |
+| Base de données  | **PostgreSQL 10+** | SQLite, MariaDB 10.0.5+, MySQL 8.0+ |
 | Navigateur       | **Firefox**                                                                                                    | Chrome, Opera, Safari, or Edge   |
 | Navigateur       | **Firefox**                                                                                                    | Chrome, Opera, Safari, or Edge   |
 
 
 ## Choisir la bonne version de FreshRSS
 ## Choisir la bonne version de FreshRSS

+ 28 - 1
docs/fr/users/03_Main_view.md

@@ -208,7 +208,7 @@ Il est possible d’utiliser le champ de recherche pour raffiner les résultats
 * par auteur : `author:nom` ou `author:'nom composé'`
 * par auteur : `author:nom` ou `author:'nom composé'`
 * par titre : `intitle:mot` ou `intitle:'mot composé'`
 * par titre : `intitle:mot` ou `intitle:'mot composé'`
 * par URL : `inurl:mot` ou `inurl:'mot composé'`
 * par URL : `inurl:mot` ou `inurl:'mot composé'`
-* par tag : `#tag`
+* par tag : `#tag` ou `#'tag avec espace'`
 * par texte libre : `mot` ou `'mot composé'`
 * par texte libre : `mot` ou `'mot composé'`
 * par date d’ajout, en utilisant le [format ISO 8601 d’intervalle entre deux dates](https://fr.wikipedia.org/wiki/ISO_8601#Intervalle_entre_deux_dates) : `date:<intervalle-de-dates>`
 * par date d’ajout, en utilisant le [format ISO 8601 d’intervalle entre deux dates](https://fr.wikipedia.org/wiki/ISO_8601#Intervalle_entre_deux_dates) : `date:<intervalle-de-dates>`
 	* D’un jour spécifique, ou mois, ou année :
 	* D’un jour spécifique, ou mois, ou année :
@@ -264,6 +264,8 @@ encore plus précis, et il est autorisé d’avoir plusieurs instances de :
 Combiner plusieurs critères implique un *et* logique, mais le mot clef `OR`
 Combiner plusieurs critères implique un *et* logique, mais le mot clef `OR`
 peut être utilisé 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`
 
 
+> ℹ️ Les recherches sont effectuées sur le code HTML brut
+
 Enfin, les parenthèses peuvent être utilisées pour des expressions plus complexes, avec un support basique de la négation :
 Enfin, les parenthèses peuvent être utilisées pour des expressions plus complexes, avec un support basique de la négation :
 
 
 * `(author:Alice OR intitle:bonjour) (author:Bob OR intitle:monde)`
 * `(author:Alice OR intitle:bonjour) (author:Bob OR intitle:monde)`
@@ -273,3 +275,28 @@ Enfin, les parenthèses peuvent être utilisées pour des expressions plus compl
 * `!(S:1 OR S:2)`
 * `!(S:1 OR S:2)`
 
 
 > ℹ️ Si vous devez chercher une parenthèse, elle doit être *échappée* comme suit : `\(` ou `\)`
 > ℹ️ Si vous devez chercher une parenthèse, elle doit être *échappée* comme suit : `\(` ou `\)`
+
+#### Regex
+
+Les recherches de texte (incluant `author:`, `intitle:`, `inurl:`, `#`) peuvent utiliser les expressions régulières qui doivent être exprimées comme `/ /`.
+
+Les recherches regex sont sensibles à la casse, mais peuvent être rendues insensibles à la casse avec l’option de recherche `i` comme : `/Alice/i`
+
+Le mode multilignes peut être activé avec l’option de recherche `m` comme : `/^Alice/m`
+
+> ℹ️ `author:` fonctionne avec un auteur par ligne, ce qui fait que le mode multilignes peut être avantageux, comme : `author:/^Alice Doe$/im`
+>
+> ℹ️ `#` fonctionne également avec un tag par line, ce qui fait que le mode multilignes peut être avantageux, comme : `#/^Hello World$/im`
+
+Exemple pour rechercher des articles dont le titre commence par le mot *Lol* avec un nombre indéterminé de *o*: `intitle:/^Lo+l/i`
+
+Contrairement aux recherches normales, les caractères spéciaux HTML ne sont pas encodés dans les recherches regex, afin de permettre de chercher du code HTML, comme : `/Bonjour <span>à tous<\/span>/`
+
+⚠️ Les détails de syntaxe regex avancée dépendent du moteur regex utilisé :
+
+* Les filtres d’action de FreshRSS comme marquer-automatiquement-comme-lu et mettre-automatiquement-en-favori utilisent [PHP preg_match](https://php.net/function.preg-match).
+* Les recherches regex dépendent de la base de données utilisée :
+	* Pour SQLite, [PHP preg_match](https://php.net/function.preg-match) est utilisé ;
+	* [Pour PostgreSQL](https://www.postgresql.org/docs/current/functions-matching.html#FUNCTIONS-POSIX-REGEXP) ;
+	* [Pour MariaDB](https://mariadb.com/kb/en/pcre/) ;
+	* [Pour MySQL](https://dev.mysql.com/doc/refman/9.0/en/regexp.html#function_regexp-like).

+ 8 - 0
lib/Minz/ModelPdo.php

@@ -16,6 +16,11 @@ class Minz_ModelPdo {
 	 */
 	 */
 	public static bool $usesSharedPdo = true;
 	public static bool $usesSharedPdo = true;
 
 
+	/**
+	 * If true, the connection to the database will be a dummy one. Useful for unit tests.
+	 */
+	public static bool $dummyConnection = false;
+
 	private static ?Minz_Pdo $sharedPdo = null;
 	private static ?Minz_Pdo $sharedPdo = null;
 
 
 	private static string $sharedCurrentUser = '';
 	private static string $sharedCurrentUser = '';
@@ -97,6 +102,9 @@ class Minz_ModelPdo {
 			$this->pdo = $currentPdo;
 			$this->pdo = $currentPdo;
 			return;
 			return;
 		}
 		}
+		if (self::$dummyConnection) {
+			return;
+		}
 		if ($currentUser == null) {
 		if ($currentUser == null) {
 			throw new Minz_PDOConnectionException('Current user must not be empty!', '', Minz_Exception::ERROR);
 			throw new Minz_PDOConnectionException('Current user must not be empty!', '', Minz_Exception::ERROR);
 		}
 		}

+ 171 - 3
tests/app/Models/SearchTest.php

@@ -124,10 +124,10 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 	public static function provideInurlSearch(): array {
 	public static function provideInurlSearch(): array {
 		return [
 		return [
 			['inurl:word1', ['word1'], null],
 			['inurl:word1', ['word1'], null],
-			['inurl: word1', [], ['word1']],
+			['inurl: word1', null, ['word1']],
 			['inurl:123', ['123'], null],
 			['inurl:123', ['123'], null],
 			['inurl:word1 word2', ['word1'], ['word2']],
 			['inurl:word1 word2', ['word1'], ['word2']],
-			['inurl:"word1 word2"', ['"word1'], ['word2"']],
+			['inurl:"word1 word2"', ['word1 word2'], null],
 			['inurl:word1 word2 inurl:word3', ['word1', 'word3'], ['word2']],
 			['inurl:word1 word2 inurl:word3', ['word1', 'word3'], ['word2']],
 			["inurl:word1 'word2 word3' word4", ['word1'], ['word2 word3', 'word4']],
 			["inurl:word1 'word2 word3' word4", ['word1'], ['word2 word3', 'word4']],
 			['inurl:word1+word2', ['word1+word2'], null],
 			['inurl:word1+word2', ['word1+word2'], null],
@@ -196,7 +196,7 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 			['# word1', null, ['#', 'word1']],
 			['# word1', null, ['#', 'word1']],
 			['#123', ['123'], null],
 			['#123', ['123'], null],
 			['#word1 word2', ['word1'], ['word2']],
 			['#word1 word2', ['word1'], ['word2']],
-			['#"word1 word2"', ['"word1'], ['word2"'],],
+			['#"word1 word2"', ['word1 word2'], null],
 			['#word1 #word2', ['word1', 'word2'], null],
 			['#word1 #word2', ['word1', 'word2'], null],
 			["#word1 'word2 word3' word4", ['word1'], ['word2 word3', 'word4']],
 			["#word1 'word2 word3' word4", ['word1'], ['word2 word3', 'word4']],
 			['#word1+word2', ['word1 word2'], null]
 			['#word1+word2', ['word1 word2'], null]
@@ -442,4 +442,172 @@ class SearchTest extends PHPUnit\Framework\TestCase {
 			],
 			],
 		];
 		];
 	}
 	}
+
+	/**
+	 * @dataProvider provideRegexPostreSQL
+	 * @param array<string> $values
+	 */
+	public function test__regex_postgresql(string $input, string $sql, array $values): void {
+		[$filterValues, $filterSearch] = FreshRSS_EntryDAOPGSQL::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input));
+		self::assertEquals(trim($sql), trim($filterSearch));
+		self::assertEquals($values, $filterValues);
+	}
+
+	/** @return array<array<mixed>> */
+	public function provideRegexPostreSQL(): array {
+		return [
+			[
+				'intitle:/^ab$/',
+				'(e.title ~ ? )',
+				['^ab$']
+			],
+			[
+				'intitle:/^ab$/i',
+				'(e.title ~* ? )',
+				['^ab$']
+			],
+			[
+				'intitle:/^ab$/m',
+				'(e.title ~ ? )',
+				['(?m)^ab$']
+			],
+			[
+				'intitle:/^ab\\M/',
+				'(e.title ~ ? )',
+				['^ab\\M']
+			],
+			[
+				'author:/^ab$/',
+				"(REPLACE(e.author, ';', '\n') ~ ? )",
+				['^ab$']
+			],
+			[
+				'inurl:/^ab$/',
+				'(e.link ~ ? )',
+				['^ab$']
+			],
+			[
+				'/^ab$/',
+				'((e.title ~ ? OR e.content ~ ?) )',
+				['^ab$', '^ab$']
+			],
+			[
+				'!/^ab$/',
+				'(NOT e.title ~ ? AND NOT e.content ~ ? )',
+				['^ab$', '^ab$']
+			],
+			[	// Not a regex
+				'inurl:https://example.net/test/',
+				'(e.link LIKE ? )',
+				['%https://example.net/test/%']
+			],
+			[	// Not a regex
+				'https://example.net/test/',
+				'((e.title LIKE ? OR e.content LIKE ?) )',
+				['%https://example.net/test/%', '%https://example.net/test/%']
+			],
+		];
+	}
+
+	/**
+	 * @dataProvider provideRegexMariaDB
+	 * @param array<string> $values
+	 */
+	public function test__regex_mariadb(string $input, string $sql, array $values): void {
+		FreshRSS_DatabaseDAO::$dummyConnection = true;
+		FreshRSS_DatabaseDAO::setStaticVersion('11.4.3-MariaDB-ubu2404');
+		[$filterValues, $filterSearch] = FreshRSS_EntryDAO::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input));
+		self::assertEquals(trim($sql), trim($filterSearch));
+		self::assertEquals($values, $filterValues);
+	}
+
+	/** @return array<array<mixed>> */
+	public function provideRegexMariaDB(): array {
+		return [
+			[
+				'intitle:/^ab$/',
+				"(e.title REGEXP ? )",
+				['(?-i)^ab$']
+			],
+			[
+				'intitle:/^ab$/i',
+				"(e.title REGEXP ? )",
+				['(?i)^ab$']
+			],
+			[
+				'intitle:/^ab$/m',
+				"(e.title REGEXP ? )",
+				['(?-i)(?m)^ab$']
+			],
+		];
+	}
+
+	/**
+	 * @dataProvider provideRegexMySQL
+	 * @param array<string> $values
+	 */
+	public function test__regex_mysql(string $input, string $sql, array $values): void {
+		FreshRSS_DatabaseDAO::$dummyConnection = true;
+		FreshRSS_DatabaseDAO::setStaticVersion('9.0.1');
+		[$filterValues, $filterSearch] = FreshRSS_EntryDAO::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input));
+		self::assertEquals(trim($sql), trim($filterSearch));
+		self::assertEquals($values, $filterValues);
+	}
+
+	/** @return array<array<mixed>> */
+	public function provideRegexMySQL(): array {
+		return [
+			[
+				'intitle:/^ab$/',
+				"(REGEXP_LIKE(e.title,?,'c') )",
+				['^ab$']
+			],
+			[
+				'intitle:/^ab$/i',
+				"(REGEXP_LIKE(e.title,?,'i') )",
+				['^ab$']
+			],
+			[
+				'intitle:/^ab$/m',
+				"(REGEXP_LIKE(e.title,?,'mc') )",
+				['^ab$']
+			],
+		];
+	}
+
+	/**
+	 * @dataProvider provideRegexSQLite
+	 * @param array<string> $values
+	 */
+	public function test__regex_sqlite(string $input, string $sql, array $values): void {
+		[$filterValues, $filterSearch] = FreshRSS_EntryDAOSQLite::sqlBooleanSearch('e.', new FreshRSS_BooleanSearch($input));
+		self::assertEquals(trim($sql), trim($filterSearch));
+		self::assertEquals($values, $filterValues);
+	}
+
+	/** @return array<array<mixed>> */
+	public function provideRegexSQLite(): array {
+		return [
+			[
+				'intitle:/^ab$/',
+				"(e.title REGEXP ? )",
+				['/^ab$/']
+			],
+			[
+				'intitle:/^ab$/i',
+				"(e.title REGEXP ? )",
+				['/^ab$/i']
+			],
+			[
+				'intitle:/^ab$/m',
+				"(e.title REGEXP ? )",
+				['/^ab$/m']
+			],
+			[
+				'intitle:/^ab\\b/',
+				'(e.title REGEXP ? )',
+				['/^ab\\b/']
+			],
+		];
+	}
 }
 }