فهرست منبع

Implement filter on last modified date by server (#8131)

* Implement filter on last modified date by server
Especially relevant for API, to get the modified changes: the API will now return the articles that are new or which content has been modified since `ot`:

fix https://github.com/FreshRSS/FreshRSS/issues/7304
fix https://github.com/FreshRSS/FreshRSS/issues/2566
https://github.com/jocmp/capyreader/discussions/533#discussioncomment-11341808

New corresponding search operator `mdate:` and new UI:

<img width="650" height="627" alt="image" src="https://github.com/user-attachments/assets/8ba02937-abc7-44bf-b718-cf269cc37caf" />

* Migration from existing id column

* Fix auto-update

* Index after update for performance

* Minor comment

* Minor whitespace

* Fix regex

* Minor .gitignore

* Changelog and warning

* Update app/i18n/pl/gen.php

Co-authored-by: Inverle <inverle@proton.me>

* make fix-all

* Optimise SQL auto-update
For speed and resilience

* Minor SQLite change of sequence

* Changelog

* Speed optimisation: No DEFAULT 0

* Better migration

* Revert small bug

* Prepare filtering on multiple dates for API

* make fix-all

* Update tests

* Remaining manual merge

* Update versions

* Remove warnings no longer relevant in changelog

* Implement in API, and COALESCE

* No lastModified when adding new article

* Rework logic

* Sort IS NOT NULL

* Remove forgotten lastModified

---------

Co-authored-by: Inverle <inverle@proton.me>
Alexandre Alapetite 1 ماه پیش
والد
کامیت
cf631b6f87
44فایلهای تغییر یافته به همراه424 افزوده شده و 89 حذف شده
  1. 4 1
      CHANGELOG.md
  2. 6 1
      app/Controllers/feedController.php
  3. 4 4
      app/Controllers/indexController.php
  4. 19 4
      app/Controllers/searchController.php
  5. 49 18
      app/Models/Entry.php
  6. 88 34
      app/Models/EntryDAO.php
  7. 1 6
      app/Models/EntryDAOPGSQL.php
  8. 2 7
      app/Models/EntryDAOSQLite.php
  9. 67 1
      app/Models/Search.php
  10. 12 4
      app/SQL/install.sql.mysql.php
  11. 10 3
      app/SQL/install.sql.pgsql.php
  12. 10 3
      app/SQL/install.sql.sqlite.php
  13. 1 0
      app/i18n/cs/gen.php
  14. 1 0
      app/i18n/de/gen.php
  15. 1 0
      app/i18n/el/gen.php
  16. 1 0
      app/i18n/en-US/gen.php
  17. 1 0
      app/i18n/en/gen.php
  18. 1 0
      app/i18n/es/gen.php
  19. 1 0
      app/i18n/fa/gen.php
  20. 1 0
      app/i18n/fi/gen.php
  21. 1 0
      app/i18n/fr/gen.php
  22. 1 0
      app/i18n/he/gen.php
  23. 1 0
      app/i18n/hu/gen.php
  24. 1 0
      app/i18n/id/gen.php
  25. 1 0
      app/i18n/it/gen.php
  26. 1 0
      app/i18n/ja/gen.php
  27. 1 0
      app/i18n/ko/gen.php
  28. 1 0
      app/i18n/lv/gen.php
  29. 1 0
      app/i18n/nl/gen.php
  30. 1 0
      app/i18n/oc/gen.php
  31. 1 0
      app/i18n/pl/gen.php
  32. 1 0
      app/i18n/pt-BR/gen.php
  33. 1 0
      app/i18n/pt-PT/gen.php
  34. 1 0
      app/i18n/ru/gen.php
  35. 1 0
      app/i18n/sk/gen.php
  36. 1 0
      app/i18n/tr/gen.php
  37. 1 0
      app/i18n/uk/gen.php
  38. 1 0
      app/i18n/zh-CN/gen.php
  39. 1 0
      app/i18n/zh-TW/gen.php
  40. 32 0
      app/views/search/index.phtml
  41. 2 1
      docs/en/users/10_filter.md
  42. 2 1
      docs/fr/users/03_Main_view.md
  43. 6 0
      p/api/greader.php
  44. 83 1
      tests/app/Models/SearchTest.php

+ 4 - 1
CHANGELOG.md

@@ -6,6 +6,9 @@ See also [the FreshRSS releases](https://github.com/FreshRSS/FreshRSS/releases).
 
 * Features
 	* New sort order preferences at global, category, and feed levels [#8234](https://github.com/FreshRSS/FreshRSS/pull/8234)
+	* New filtering by date of *Server modification date* [#8131](https://github.com/FreshRSS/FreshRSS/pull/8131)
+		* Corresponding search operator, e.g. `mdate:P1D` for finding articles modified by the author / server during the past day.
+		* Especially useful for optimising the API synchronisation.
 	* Add option to enable/disable notifications, also for PWA [#8458](https://github.com/FreshRSS/FreshRSS/pull/8458)
 	* Allow WebSub hub push from same private network [#8450](https://github.com/FreshRSS/FreshRSS/pull/8450)
 * Bug fixing
@@ -72,7 +75,7 @@ See also [the FreshRSS releases](https://github.com/FreshRSS/FreshRSS/releases).
 ## 2025-12-24 FreshRSS 1.28.0
 
 * Features
-	* New sorting and filtering by date of *User modified* [#7886](https://github.com/FreshRSS/FreshRSS/pull/7886), [#8090](https://github.com/FreshRSS/FreshRSS/pull/8090),
+	* New sorting and filtering by *User modification date* [#7886](https://github.com/FreshRSS/FreshRSS/pull/7886), [#8090](https://github.com/FreshRSS/FreshRSS/pull/8090),
 		[#8105](https://github.com/FreshRSS/FreshRSS/pull/8105), [#8118](https://github.com/FreshRSS/FreshRSS/pull/8118), [#8130](https://github.com/FreshRSS/FreshRSS/pull/8130)
 		* Corresponding search operator, e.g. `userdate:PT1H` for the past hour [#8093](https://github.com/FreshRSS/FreshRSS/pull/8093)
 		* Allows finding articles marked by the local user as read/unread or starred/unstarred at specific dates for e.g. undo action.

+ 6 - 1
app/Controllers/feedController.php

@@ -616,6 +616,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 						if (strcasecmp($existingHash, $entry->hash()) !== 0) {
 							//This entry already exists but has been updated
 							$entry->_isUpdated(true);
+							$entry->_lastModified($mtime);
 							//Minz_Log::debug('Entry with GUID `' . $entry->guid() . '` updated in feed ' . $feed->url(false) .
 								//', old hash ' . $existingHash . ', new hash ' . $entry->hash());
 							$entry->_isFavorite(null);	// Do not change favourite state
@@ -1190,8 +1191,12 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 		}
 
 		foreach ($entries as $entry) {
+			$oldContent = $entry->content(withEnclosures: false);
 			if ($entry->loadCompleteContent(true)) {
-				$entryDAO2->updateEntry($entry->toArray());
+				$entry->_lastModified(time());
+				if ($entry->content(withEnclosures: false) !== $oldContent) {
+					$entryDAO2->updateEntry($entry->toArray());
+				}
 			}
 		}
 

+ 4 - 4
app/Controllers/indexController.php

@@ -59,8 +59,8 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 				' — ' . timestamptodate($entry->dateAdded(raw: true), hour: false),
 			'date' => _t('index.feed.published' . self::dayRelative($entry->date(raw: true), mayBeFuture: true)) .
 				' — ' . timestamptodate($entry->date(raw: true), hour: false),
-			'lastUserModified' => _t('index.feed.userModified' . self::dayRelative($entry->lastUserModified(), mayBeFuture: false)) .
-				' — ' . timestamptodate($entry->lastUserModified(), hour: false),
+			'lastUserModified' => _t('index.feed.userModified' . self::dayRelative($entry->lastUserModified() ?? 0, mayBeFuture: false)) .
+				' — ' . timestamptodate($entry->lastUserModified() ?? 0, hour: false),
 			'c.name' => $entry->feed()?->category()?->name() ?? '',
 			'f.name' => $entry->feed()?->name() ?? '',
 			default => '',
@@ -89,7 +89,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		$timestamp = match (FreshRSS_Context::$sort) {
 			'id' => $entry->dateAdded(raw: true),
 			'date' => $entry->date(raw: true),
-			'lastUserModified' => $entry->lastUserModified(),
+			'lastUserModified' => $entry->lastUserModified() ?? 0,
 			default => throw new InvalidArgumentException('Unsupported sort criterion for transition: ' . FreshRSS_Context::$sort),
 		};
 		$searchString = $operator . ':' . ($offset < 0 ? '/' : '') . date('Y-m-d', $timestamp + ($offset * 86400)) . ($offset > 0 ? '/' : '');
@@ -370,7 +370,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 					'f.name' => $pagingEntry->feed()?->name(raw: true) ?? '',
 					'link' => $pagingEntry->link(raw: true),
 					'title' => $pagingEntry->title(),
-					'lastUserModified' => $pagingEntry->lastUserModified(),
+					'lastUserModified' => $pagingEntry->lastUserModified() ?? 0,
 					'length' => $pagingEntry->sqlContentLength() ?? 0,
 				};
 				if (FreshRSS_Context::$sort === 'c.name') {

+ 19 - 4
app/Controllers/searchController.php

@@ -135,8 +135,6 @@ class FreshRSS_search_Controller extends FreshRSS_ActionController {
 		$pubDateUnit = trim(Minz_Request::paramString('pubdate_unit'));
 
 		if ($pubDateNumber > 0 && $pubDateUnit !== '') {
-			// Convert to ISO 8601 duration format: P1D, P1W, P1M, PT1H, etc.
-			// Time units (H, M, S) require a T separator
 			$prefix = ($pubDateUnit === 'H' || $pubDateUnit === 'M' || $pubDateUnit === 'S') ? 'PT' : 'P';
 			$searchTerms[] = "pubdate:{$prefix}{$pubDateNumber}{$pubDateUnit}";
 		} elseif ($pubDateFrom !== '' || $pubDateTo !== '') {
@@ -149,6 +147,25 @@ class FreshRSS_search_Controller extends FreshRSS_ActionController {
 			}
 		}
 
+		// Server modification date
+		$mDateFrom = trim(Minz_Request::paramString('mdate_from'));
+		$mDateTo = trim(Minz_Request::paramString('mdate_to'));
+		$mDateNumber = Minz_Request::paramInt('mdate_number');
+		$mDateUnit = trim(Minz_Request::paramString('mdate_unit'));
+
+		if ($mDateNumber > 0 && $mDateUnit !== '') {
+			$prefix = ($mDateUnit === 'H' || $mDateUnit === 'M' || $mDateUnit === 'S') ? 'PT' : 'P';
+			$searchTerms[] = "mdate:{$prefix}{$mDateNumber}{$mDateUnit}";
+		} elseif ($mDateFrom !== '' || $mDateTo !== '') {
+			if ($mDateFrom !== '' && $mDateTo !== '') {
+				$searchTerms[] = "mdate:$mDateFrom/$mDateTo";
+			} elseif ($mDateFrom !== '') {
+				$searchTerms[] = "mdate:$mDateFrom/";
+			} elseif ($mDateTo !== '') {
+				$searchTerms[] = "mdate:/$mDateTo";
+			}
+		}
+
 		// User modification date
 		$userDateFrom = trim(Minz_Request::paramString('userdate_from'));
 		$userDateTo = trim(Minz_Request::paramString('userdate_to'));
@@ -156,8 +173,6 @@ class FreshRSS_search_Controller extends FreshRSS_ActionController {
 		$userDateUnit = trim(Minz_Request::paramString('userdate_unit'));
 
 		if ($userDateNumber > 0 && $userDateUnit !== '') {
-			// Convert to ISO 8601 duration format: P1D, P1W, P1M, PT1H, etc.
-			// Time units (H, M, S) require a T separator
 			$prefix = ($userDateUnit === 'H' || $userDateUnit === 'M' || $userDateUnit === 'S') ? 'PT' : 'P';
 			$searchTerms[] = "userdate:{$prefix}{$userDateNumber}{$userDateUnit}";
 		} elseif ($userDateFrom !== '' || $userDateTo !== '') {

+ 49 - 18
app/Models/Entry.php

@@ -24,7 +24,8 @@ class FreshRSS_Entry extends Minz_Model {
 	private string $link;
 	private int $date;
 	private int $lastSeen = 0;
-	private int $lastUserModified = 0;
+	private ?int $lastModified = null;
+	private ?int $lastUserModified = null;
 	/** In microseconds */
 	private string $date_added = '0';
 	private string $hash = '';
@@ -55,9 +56,11 @@ class FreshRSS_Entry extends Minz_Model {
 		$this->_guid($guid);
 	}
 
-	/** @param array{id?:string,id_feed?:int,guid?:string,title?:string,author?:string,content?:string,link?:string,
-	 * 		date?:int|string,lastSeen?:int,lastUserModified?:int,
-	 * 		hash?:string,is_read?:bool|int,is_favorite?:bool|int,tags?:string|array<string>,attributes?:?string,thumbnail?:string,timestamp?:string,
+	/** @param array{id?:string,guid?:string,title?:string,author?:string,content?:string,link?:string,
+	 * 		date?:int|string,lastSeen?:int,lastModified?:int,lastUserModified?:int,
+	 * 		hash?:string,is_read?:bool|int,is_favorite?:bool|int,id_feed?:int,
+	 * 		tags?:string|array<string>,attributes?:?string,
+	 * 		thumbnail?:string,timestamp?:string,
 	 * 		content_length?:int} $dao */
 	public static function fromArray(array $dao): FreshRSS_Entry {
 		if (empty($dao['content']) || !is_string($dao['content'])) {
@@ -98,10 +101,17 @@ class FreshRSS_Entry extends Minz_Model {
 		if (!empty($dao['timestamp'])) {
 			$entry->_date(strtotime($dao['timestamp']) ?: 0);
 		}
-		if (isset($dao['lastSeen'])) {
+		if (empty($dao['lastSeen'])) {
+			$entry->_lastSeen($entry->id() == '0' ?
+				0 :
+				(int)substr($entry->id(), 0, -6));	// Microseconds to seconds
+		} else {
 			$entry->_lastSeen($dao['lastSeen']);
 		}
-		if (isset($dao['lastUserModified'])) {
+		if (!empty($dao['lastModified'])) {
+			$entry->_lastModified($dao['lastModified']);
+		}
+		if (!empty($dao['lastUserModified'])) {
 			$entry->_lastUserModified($dao['lastUserModified']);
 		}
 		if (!empty($dao['attributes'])) {
@@ -117,11 +127,10 @@ class FreshRSS_Entry extends Minz_Model {
 	}
 
 	/**
-	 * @param Traversable<array{id?:string,id_feed?:int,guid?:string,
-	 * title?:string,author?:string,content?:string,link?:string,
-	 * date?:int|string,lastSeen?:int,lastUserModified?:int,hash?:string,is_read?:bool|int,
-	 * is_favorite?:bool|int,tags?:string|array<string>,attributes?:?string,
-	 * thumbnail?:string,timestamp?:string}> $daos
+	 * @param Traversable<array{id?:string,guid?:string,title?:string,author?:string,content?:string,link?:string,
+	 * 	date?:int|string,lastSeen?:int,lastModified?:int,lastUserModified?:int,hash?:string,is_read?:bool|int,
+	 * 	is_favorite?:bool|int,id_feed?:int,tags?:string|array<string>,attributes?:?string,
+	 * 	thumbnail?:string,timestamp?:string}> $daos
 	 * @return Traversable<FreshRSS_Entry>
 	 */
 	public static function fromTraversable(Traversable $daos): Traversable {
@@ -435,7 +444,11 @@ HTML;
 		return $this->lastSeen;
 	}
 
-	public function lastUserModified(): int {
+	public function lastModified(): ?int {
+		return $this->lastModified;
+	}
+
+	public function lastUserModified(): ?int {
 		return $this->lastUserModified;
 	}
 
@@ -583,9 +596,14 @@ HTML;
 		$this->lastSeen = $value > 0 ? $value : 0;
 	}
 
+	public function _lastModified(int|string $value): void {
+		$value = (int)$value;
+		$this->lastModified = $value > 0 ? $value : null;
+	}
+
 	public function _lastUserModified(int|string $value): void {
 		$value = (int)$value;
-		$this->lastUserModified = $value > 0 ? $value : 0;
+		$this->lastUserModified = $value > 0 ? $value : null;
 	}
 
 	/** @param int|numeric-string $value */
@@ -673,17 +691,29 @@ HTML;
 				if ($ok && $filter->getNotMaxPubdate() !== null) {
 					$ok &= $this->date > $filter->getNotMaxPubdate();
 				}
+				if ($ok && $filter->getMinModifiedDate() !== null) {
+					$ok &= ($this->lastModified ?? 0) >= $filter->getMinModifiedDate();
+				}
+				if ($ok && $filter->getNotMinModifiedDate() !== null) {
+					$ok &= ($this->lastModified ?? 0) < $filter->getNotMinModifiedDate();
+				}
+				if ($ok && $filter->getMaxModifiedDate() !== null) {
+					$ok &= ($this->lastModified ?? 0) <= $filter->getMaxModifiedDate();
+				}
+				if ($ok && $filter->getNotMaxModifiedDate() !== null) {
+					$ok &= ($this->lastModified ?? 0) > $filter->getNotMaxModifiedDate();
+				}
 				if ($ok && $filter->getMinUserdate() !== null) {
-					$ok &= $this->lastUserModified >= $filter->getMinUserdate();
+					$ok &= ($this->lastUserModified ?? 0) >= $filter->getMinUserdate();
 				}
 				if ($ok && $filter->getNotMinUserdate() !== null) {
-					$ok &= $this->lastUserModified < $filter->getNotMinUserdate();
+					$ok &= ($this->lastUserModified ?? 0) < $filter->getNotMinUserdate();
 				}
 				if ($ok && $filter->getMaxUserdate() !== null) {
-					$ok &= $this->lastUserModified <= $filter->getMaxUserdate();
+					$ok &= ($this->lastUserModified ?? 0) <= $filter->getMaxUserdate();
 				}
 				if ($ok && $filter->getNotMaxUserdate() !== null) {
-					$ok &= $this->lastUserModified > $filter->getNotMaxUserdate();
+					$ok &= ($this->lastUserModified ?? 0) > $filter->getNotMaxUserdate();
 				}
 				if ($ok && $filter->getFeedIds() !== null) {
 					$ok &= in_array($this->feedId, $filter->getFeedIds(), true);
@@ -1108,7 +1138,7 @@ HTML;
 
 	/**
 	 * @return array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,
-	 * 	lastSeen:int,lastUserModified:int,
+	 * 	lastSeen:int,lastModified:?int,lastUserModified:?int,
 	 * 	hash:string,is_read:?bool,is_favorite:?bool,id_feed:int,tags:string,attributes:array<string,mixed>}
 	 */
 	public function toArray(): array {
@@ -1121,6 +1151,7 @@ HTML;
 			'link' => $this->link(raw: true),
 			'date' => $this->date(true),
 			'lastSeen' => $this->lastSeen(),
+			'lastModified' => $this->lastModified(),
 			'lastUserModified' => $this->lastUserModified(),
 			'hash' => $this->hash(),
 			'is_read' => $this->isRead(),

+ 88 - 34
app/Models/EntryDAO.php

@@ -42,10 +42,6 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return "LIMIT {$limit} OFFSET {$offset}";
 	}
 
-	public static function sqlGreatest(string $a, string $b): string {
-		return 'GREATEST(' . $a . ', ' . $b . ')';
-	}
-
 	public static function sqlRandom(): string {
 		return 'RAND()';
 	}
@@ -139,6 +135,13 @@ SQL;
 				}
 				return $this->pdo->exec($sql) !== false;
 			}
+			if ($name === 'lastModified') {	//v1.29.0
+				$sql = $GLOBALS['ALTER_TABLE_ENTRY_LAST_MODIFIED'] ?? null;
+				if (!is_string($sql)) {
+					throw new Exception('ALTER_TABLE_ENTRY_LAST_MODIFIED is not a string!');
+				}
+				return $this->pdo->exec($sql) !== false;
+			}
 		} catch (Exception $e) {
 			Minz_Log::error(__METHOD__ . ' error: ' . $e->getMessage());
 		}
@@ -151,7 +154,7 @@ SQL;
 		if (isset($errorInfo[0])) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
 				$errorLines = explode("\n", $errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
-				foreach (['attributes', 'lastUserModified'] as $column) {
+				foreach (['attributes', 'lastUserModified', 'lastModified'] as $column) {
 					if (str_contains($errorLines[0], $column)) {
 						return $this->addColumn($column);
 					}
@@ -171,8 +174,8 @@ SQL;
 
 	private PDOStatement|null|false $addEntryPrepared = null;
 
-	/** @param array{'id':string,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,'lastSeen':int,'hash':string,
-	 *		'is_read':bool|int|null,'is_favorite':bool|int|null,'id_feed':int,'tags':string,'attributes'?:null|string|array<string,mixed>} $valuesTmp */
+	/** @param array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,lastSeen:int,hash:string,
+	 *		is_read:bool|int|null,is_favorite:bool|int|null,id_feed:int,tags:string,attributes?:null|string|array<string,mixed>} $valuesTmp */
 	public function addEntry(array $valuesTmp, bool $useTmpTable = true): bool {
 		if ($this->addEntryPrepared == null) {
 			$sql = static::sqlIgnoreConflict(
@@ -275,7 +278,7 @@ SQL;
 
 	/**
 	 * @param array{id:string,guid:string,title:string,author:string,content:string,link:string,
-	 * 	date:int,lastSeen:int,lastUserModified?:int,hash:string,
+	 * 	date:int,lastSeen:int,lastModified?:?int,lastUserModified?:?int,hash:string,
 	 * 	is_read:bool|int|null,is_favorite:bool|int|null,id_feed:int,tags:string,attributes:array<string,mixed>} $valuesTmp
 	 */
 	public function updateEntry(array $valuesTmp): bool {
@@ -285,16 +288,19 @@ SQL;
 		if (!isset($valuesTmp['is_favorite'])) {
 			$valuesTmp['is_favorite'] = null;
 		}
-		if (empty($valuesTmp['lastUserModified'])) {
-			$valuesTmp['lastUserModified'] = 0;
+		if (!isset($valuesTmp['lastUserModified'])) {
+			$valuesTmp['lastUserModified'] = null;
+		}
+		if (!isset($valuesTmp['lastModified'])) {
+			$valuesTmp['lastModified'] = null;
 		}
-
 		if ($this->updateEntryPrepared == null) {
 			$sql = 'UPDATE `_entry` '
 				. 'SET title=:title, author=:author, '
 				. (static::isCompressed() ? 'content_bin=COMPRESS(:content)' : 'content=:content')
 				. ', link=:link, date=:date, `lastSeen`=:last_seen'
-				. ', `lastUserModified`=' . static::sqlGreatest(':last_user_modified', '`lastUserModified`')
+				. ', `lastModified`=COALESCE(:last_modified, `lastModified`)'
+				. ', `lastUserModified`=COALESCE(:last_user_modified, `lastUserModified`)'
 				. ', hash=' . static::sqlHexDecode(':hash')
 				. ', is_read=COALESCE(:is_read, is_read)'
 				. ', is_favorite=COALESCE(:is_favorite, is_favorite)'
@@ -319,7 +325,16 @@ SQL;
 			$this->updateEntryPrepared->bindParam(':link', $valuesTmp['link']);
 			$this->updateEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
 			$this->updateEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
-			$this->updateEntryPrepared->bindParam(':last_user_modified', $valuesTmp['lastUserModified'], PDO::PARAM_INT);
+			if ($valuesTmp['lastModified'] === null) {
+				$this->updateEntryPrepared->bindValue(':last_modified', null, PDO::PARAM_NULL);
+			} else {
+				$this->updateEntryPrepared->bindValue(':last_modified', $valuesTmp['lastModified'], PDO::PARAM_INT);
+			}
+			if ($valuesTmp['lastUserModified'] === null) {
+				$this->updateEntryPrepared->bindValue(':last_user_modified', null, PDO::PARAM_NULL);
+			} else {
+				$this->updateEntryPrepared->bindValue(':last_user_modified', $valuesTmp['lastUserModified'], PDO::PARAM_INT);
+			}
 			if ($valuesTmp['is_read'] === null) {
 				$this->updateEntryPrepared->bindValue(':is_read', null, PDO::PARAM_NULL);
 			} else {
@@ -353,7 +368,7 @@ SQL;
 		} else {
 			$info = $this->updateEntryPrepared == false ? $this->pdo->errorInfo() : $this->updateEntryPrepared->errorInfo();
 			/** @var array{id:string,guid:string,title:string,author:string,content:string,link:string,
-			 * 	date:int,lastSeen:int,lastUserModified:int,hash:string,
+			 * 	date:int,lastSeen:int,lastModified:int,lastUserModified:int,hash:string,
 			 * 	is_read:bool|int|null,is_favorite:bool|int|null,id_feed:int,tags:string,attributes:array<string,mixed>} $valuesTmp */
 			/** @var array{0:string,1:int,2:string} $info */
 			if ($this->autoUpdateDb($info)) {
@@ -789,7 +804,7 @@ SQL;
 	/**
 	 * @param 'ASC'|'DESC' $order
 	 * @return Traversable<array{id:string,guid:string,title:string,author:string,content:string,link:string,
-	 * 	date:int,lastSeen:int,lastUserModified:int,
+	 * 	date:int,lastSeen:int,lastModified:int,lastUserModified:int,
 	 *	hash:string,is_read:bool,is_favorite:bool,id_feed:int,tags:string,attributes:?string}>
 	 */
 	public function selectAll(string $order = 'ASC', int $limit = -1, int $offset = 0): Traversable {
@@ -798,14 +813,15 @@ SQL;
 		$order = in_array($order, ['ASC', 'DESC'], true) ? $order : 'ASC';
 		$sqlLimit = static::sqlLimit($limit, $offset);
 		$sql = <<<SQL
-SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastUserModified`, {$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes
+SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
+	{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes
 FROM `_entry`
 ORDER BY id {$order} {$sqlLimit}
 SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
 			while (is_array($row = $stm->fetch(PDO::FETCH_ASSOC))) {
-				/** @var array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,lastSeen:int,lastUserModified:int,
+				/** @var array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,lastSeen:int,lastModified:int,lastUserModified:int,
 				 *	hash:string,is_read:bool,is_favorite:bool,id_feed:int,tags:string,attributes:?string} $row */
 				yield $row;
 			}
@@ -825,13 +841,16 @@ SQL;
 		$contentLength = 'LENGTH(' . (static::isCompressed() ? 'content_bin' : 'content') . ') AS content_length';
 		$hash = static::sqlHexEncode('hash');
 		$sql = <<<SQL
-SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastUserModified`, {$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes,
+SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
+	{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes,
 	{$contentLength}
 FROM `_entry` WHERE id_feed=:id_feed AND guid=:guid
 SQL;
 		$res = $this->fetchAssoc($sql, [':id_feed' => $id_feed, ':guid' => $guid]);
-		/** @var list<array{id:string,id_feed:int,guid:string,title:string,author:string,content:string,link:string,date:int,
-		 * 		is_read:int,is_favorite:int,tags:string,attributes:?string,content_length:int}> $res */
+		/** @var list<array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,
+		 *		lastSeen:int,lastModified:int,lastUserModified:int,hash:string,is_read:int,is_favorite:int,id_feed:int,
+		 * 		tags:string,attributes:?string,
+		 * 		content_length:int}> $res */
 		return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null;
 	}
 
@@ -840,13 +859,16 @@ SQL;
 		$contentLength = 'LENGTH(' . (static::isCompressed() ? 'content_bin' : 'content') . ') AS content_length';
 		$hash = static::sqlHexEncode('hash');
 		$sql = <<<SQL
-SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastUserModified`, {$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes,
+SELECT id, guid, title, author, {$content}, link, date, `lastSeen`, `lastModified`, `lastUserModified`,
+	{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes,
 	{$contentLength}
 FROM `_entry` WHERE id=:id
 SQL;
 		$res = $this->fetchAssoc($sql, [':id' => $id]);
-		/** @var list<array{id:string,id_feed:int,guid:string,title:string,author:string,content:string,link:string,date:int,
-		 * 		is_read:int,is_favorite:int,tags:string,attributes:?string,content_length:int}> $res */
+		/** @var list<array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,
+		 *		lastSeen:int,lastModified:int,lastUserModified:int,hash:string,is_read:int,is_favorite:int,id_feed:int,
+		 * 		tags:string,attributes:?string,
+		 * 		content_length:int}> $res */
 		return isset($res[0]) ? FreshRSS_Entry::fromArray($res[0]) : null;
 	}
 
@@ -920,6 +942,14 @@ SQL;
 				$sub_search .= 'AND ' . $alias . 'date <= ? ';
 				$values[] = $filter->getMaxPubdate();
 			}
+			if ($filter->getMinModifiedDate() !== null) {
+				$sub_search .= 'AND ' . $alias . '`lastModified` >= ? ';
+				$values[] = $filter->getMinModifiedDate();
+			}
+			if ($filter->getMaxModifiedDate() !== null) {
+				$sub_search .= 'AND COALESCE(' . $alias . '`lastModified`, 0) <= ? ';
+				$values[] = $filter->getMaxModifiedDate();
+			}
 			if ($filter->getMinUserdate() !== null) {
 				$sub_search .= 'AND ' . $alias . '`lastUserModified` >= ? ';
 				$values[] = $filter->getMinUserdate();
@@ -960,10 +990,25 @@ SQL;
 				}
 				$sub_search .= ') ';
 			}
+			if ($filter->getNotMinModifiedDate() !== null || $filter->getNotMaxModifiedDate() !== null) {
+				$sub_search .= 'AND (';
+				if ($filter->getNotMinModifiedDate() !== null) {
+					$sub_search .= 'COALESCE(' . $alias . '`lastModified`, 0) < ?';
+					$values[] = $filter->getNotMinModifiedDate();
+					if ($filter->getNotMaxModifiedDate()) {
+						$sub_search .= ' OR ';
+					}
+				}
+				if ($filter->getNotMaxModifiedDate() !== null) {
+					$sub_search .= $alias . '`lastModified` > ?';
+					$values[] = $filter->getNotMaxModifiedDate();
+				}
+				$sub_search .= ') ';
+			}
 			if ($filter->getNotMinUserdate() !== null || $filter->getNotMaxUserdate() !== null) {
 				$sub_search .= 'AND (';
 				if ($filter->getNotMinUserdate() !== null) {
-					$sub_search .= $alias . '`lastUserModified` < ?';
+					$sub_search .= 'COALESCE(' . $alias . '`lastUserModified`, 0) < ?';
 					$values[] = $filter->getNotMinUserdate();
 					if ($filter->getNotMaxUserdate()) {
 						$sub_search .= ' OR ';
@@ -1270,7 +1315,7 @@ SQL;
 	/**
 	 * @param numeric-string $id_min
 	 * @param numeric-string $id_max
-	 * @param 'id'|'c.name'|'date'|'f.name'|'lastUserModified'|'length'|'link'|'rand'|'title' $sort
+	 * @param 'id'|'c.name'|'date'|'f.name'|'lastModified'|'lastUserModified'|'length'|'link'|'rand'|'title' $sort
 	 * @param 'ASC'|'DESC' $order
 	 * @param numeric-string $continuation_id
 	 * @param list<string|int> $continuation_values
@@ -1341,13 +1386,14 @@ SQL;
 			$values[] = $id_min;
 		}
 
-		if ($continuation_id !== '0' && in_array($sort, ['c.name', 'date', 'f.name', 'lastUserModified', 'length', 'link', 'title'], true)) {
+		if ($continuation_id !== '0' && in_array($sort, ['c.name', 'date', 'f.name', 'lastModified', 'lastUserModified', 'length', 'link', 'title'], true)) {
 			$sign = $order === 'ASC' ? '>' : '<';
 			$sign2 = $secondary_sort_order === 'ASC' ? '>' : '<';
 			$orderBy = match ($sort) {
 				'c.name' => 'c.name',
 				'date' => $alias . 'date',
 				'f.name' => 'f.name',
+				'lastModified' => $alias . '`lastModified`',
 				'lastUserModified' => $alias . '`lastUserModified`',
 				'length' => 'LENGTH(' . $alias . (static::isCompressed() ? 'content_bin' : 'content') . ')',
 				'link' => $alias . 'link',
@@ -1409,7 +1455,7 @@ SQL;
 	 * @param int $id category/feed/tag ID
 	 * @param numeric-string $id_min
 	 * @param numeric-string $id_max
-	 * @param 'id'|'c.name'|'date'|'f.name'|'lastUserModified'|'length'|'link'|'rand'|'title' $sort
+	 * @param 'id'|'c.name'|'date'|'f.name'|'lastModified'|'lastUserModified'|'length'|'link'|'rand'|'title' $sort
 	 * @param 'ASC'|'DESC' $order
 	 * @param numeric-string $continuation_id
 	 * @param list<string|int> $continuation_values
@@ -1477,6 +1523,7 @@ SQL;
 			'c.name' => 'c.name',
 			'date' => 'e.date',
 			'f.name' => 'f.name',
+			'lastModified' => 'e.`lastModified`',
 			'lastUserModified' => 'e.`lastUserModified`',
 			'length' => 'LENGTH(e.' . (static::isCompressed() ? 'content_bin' : 'content') . ')',
 			'link' => 'e.link',
@@ -1489,6 +1536,9 @@ SQL;
 			'link' => 'e.link',
 			'title' => 'e.title',
 		};
+		if (in_array($sort, ['lastModified', 'lastUserModified'], true)) {
+			$where = $orderBy . ' IS NOT NULL AND ' . $where;
+		}
 		[$searchValues, $search] = $this->sqlListEntriesWhere(alias: 'e.', state: $state, filters: $filters, id_min: $id_min, id_max: $id_max,
 			sort: $sort, order: $order, continuation_id: $continuation_id, continuation_values: $continuation_values,
 			secondary_sort: $secondary_sort, secondary_sort_order: $secondary_sort_order);
@@ -1523,7 +1573,7 @@ SQL;
 	 * @param int $id category/feed/tag ID
 	 * @param numeric-string $id_min
 	 * @param numeric-string $id_max
-	 * @param 'id'|'c.name'|'date'|'f.name'|'lastUserModified'|'length'|'link'|'rand'|'title' $sort
+	 * @param 'id'|'c.name'|'date'|'f.name'|'lastModified'|'lastUserModified'|'length'|'link'|'rand'|'title' $sort
 	 * @param 'ASC'|'DESC' $order
 	 * @param numeric-string $continuation_id
 	 * @param list<string|int> $continuation_values
@@ -1547,6 +1597,7 @@ SQL;
 			'c.name' => 'c0.name',
 			'date' => 'e0.date',
 			'f.name' => 'f0.name',
+			'lastModified' => 'e0.`lastModified`',
 			'lastUserModified' => 'e0.`lastUserModified`',
 			'length' => 'LENGTH(e0.' . (static::isCompressed() ? 'content_bin' : 'content') . ')',
 			'link' => 'e0.link',
@@ -1563,7 +1614,7 @@ SQL;
 		$hash = static::sqlHexEncode('e0.hash');
 		$sql = <<<SQL
 SELECT e0.id, e0.guid, e0.title, e0.author, {$content}, e0.link,
-	e0.date, e0.`lastSeen`, e0.`lastUserModified`, {$hash} AS hash, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags, e0.attributes
+	e0.date, e0.`lastSeen`, e0.`lastModified`, e0.`lastUserModified`, {$hash} AS hash, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags, e0.attributes
 FROM `_entry` e0 INNER JOIN ({$sql}) e2 ON e2.id=e0.id
 SQL;
 		if ($sort === 'f.name' || $sort === 'c.name') {
@@ -1622,8 +1673,9 @@ SQL;
 			secondary_sort: $secondary_sort, secondary_sort_order: $secondary_sort_order);
 		if ($stm !== false) {
 			while (is_array($row = $stm->fetch(PDO::FETCH_ASSOC))) {
-				/** @var array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
-				 *		'hash':string,'is_read':int,'is_favorite':int,'tags':string,'attributes'?:?string} $row */
+				/** @var array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,
+				 * 		lastSeen:int,lastModified:int,lastUserModified:int,hash:string,is_read:int,is_favorite:int,id_feed:int,
+				 * 		tags:string,attributes:?string} $row */
 				yield FreshRSS_Entry::fromArray($row);
 			}
 		}
@@ -1654,7 +1706,8 @@ SQL;
 		$hash = static::sqlHexEncode('hash');
 		$repeats = str_repeat('?,', count($ids) - 1) . '?';
 		$sql = <<<SQL
-SELECT id, guid, title, author, link, date, {$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes, {$content}, `lastUserModified`
+SELECT id, guid, title, author, link, date, `lastModified`, `lastUserModified`,
+	{$hash} AS hash, is_read, is_favorite, id_feed, tags, attributes, {$content}
 FROM `_entry`
 WHERE id IN ({$repeats})
 ORDER BY id {$order}
@@ -1666,8 +1719,9 @@ SQL;
 			return;
 		}
 		while (is_array($row = $stm->fetch(PDO::FETCH_ASSOC))) {
-			/** @var array{'id':string,'id_feed':int,'guid':string,'title':string,'author':string,'content':string,'link':string,'date':int,
-			 *		'hash':string,'is_read':int,'is_favorite':int,'tags':string,'attributes':?string} $row */
+			/** @var array{id:string,guid:string,title:string,author:string,content:string,link:string,date:int,
+			 * 		lastSeen:int,lastModified:int,lastUserModified:int,hash:string,is_read:int,is_favorite:int,id_feed:int,
+			 * 		tags:string,attributes:?string} $row */
 			yield FreshRSS_Entry::fromArray($row);
 		}
 	}

+ 1 - 6
app/Models/EntryDAOPGSQL.php

@@ -29,11 +29,6 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 		return 'ALL';
 	}
 
-	#[\Override]
-	public static function sqlGreatest(string $a, string $b): string {
-		return 'GREATEST(' . $a . ', ' . $b . ')';
-	}
-
 	#[\Override]
 	public static function sqlRandom(): string {
 		return 'RANDOM()';
@@ -77,7 +72,7 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 		if (isset($errorInfo[0])) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
 				$errorLines = explode("\n", $errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
-				foreach (['attributes', 'lastUserModified'] as $column) {
+				foreach (['attributes', 'lastUserModified', 'lastModified'] as $column) {
 					if (str_contains($errorLines[0], $column)) {
 						return $this->addColumn($column);
 					}

+ 2 - 7
app/Models/EntryDAOSQLite.php

@@ -34,11 +34,6 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		return '-1';
 	}
 
-	#[\Override]
-	public static function sqlGreatest(string $a, string $b): string {
-		return 'MAX(' . $a . ', ' . $b . ')';
-	}
-
 	#[\Override]
 	public static function sqlRandom(): string {
 		return 'RANDOM()';
@@ -69,7 +64,7 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	#[\Override]
 	protected function autoUpdateDb(array $errorInfo): bool {
 		if (($tableInfo = $this->pdo->query("PRAGMA table_info('entry')")) !== false && ($columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1)) !== false) {
-			foreach (['attributes', 'lastUserModified'] as $column) {
+			foreach (['attributes', 'lastUserModified', 'lastModified'] as $column) {
 				if (!in_array($column, $columns, true)) {
 					return $this->addColumn($column);
 				}
@@ -89,7 +84,7 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 			INSERT OR IGNORE INTO `_entry`
 				(id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes)
 				SELECT rowid + (SELECT MAX(id) - COUNT(*) FROM `tmp`) AS id,
-				guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
+					guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags, attributes
 				FROM `tmp` t
 				ORDER BY t.date, t.id;
 			DELETE FROM `_entrytmp` WHERE id <= (SELECT MAX(id) FROM `tmp`);

+ 67 - 1
app/Models/Search.php

@@ -44,6 +44,9 @@ class FreshRSS_Search implements \Stringable {
 	private ?string $input_userdate = null;
 	private int|false|null $min_userdate = null;
 	private int|false|null $max_userdate = null;
+	private ?string $input_modified_date = null;
+	private int|false|null $min_modified_date = null;
+	private int|false|null $max_modified_date = null;
 	/** @var list<string>|null */
 	private ?array $inurl = null;
 	/** @var list<string>|null */
@@ -88,6 +91,9 @@ class FreshRSS_Search implements \Stringable {
 	private ?string $input_not_userdate = null;
 	private int|false|null $not_min_userdate = null;
 	private int|false|null $not_max_userdate = null;
+	private ?string $input_not_modified_date = null;
+	private int|false|null $not_min_modified_date = null;
+	private int|false|null $not_max_modified_date = null;
 	/** @var list<string>|null */
 	private ?array $not_inurl = null;
 	/** @var list<string>|null */
@@ -118,6 +124,7 @@ class FreshRSS_Search implements \Stringable {
 		$input = $this->parseNotLabelNames($input);
 
 		$input = $this->parseNotUserdateSearch($input);
+		$input = $this->parseNotModifiedDateSearch($input);
 		$input = $this->parseNotPubdateSearch($input);
 		$input = $this->parseNotDateSearch($input);
 
@@ -134,6 +141,7 @@ class FreshRSS_Search implements \Stringable {
 		$input = $this->parseLabelNames($input);
 
 		$input = $this->parseUserdateSearch($input);
+		$input = $this->parseModifiedDateSearch($input);
 		$input = $this->parsePubdateSearch($input);
 		$input = $this->parseDateSearch($input);
 
@@ -289,6 +297,9 @@ class FreshRSS_Search implements \Stringable {
 		if ($this->input_userdate !== null) {
 			$result .= ' userdate:' . $this->input_userdate;
 		}
+		if ($this->input_modified_date !== null) {
+			$result .= ' mdate:' . $this->input_modified_date;
+		}
 		if ($this->input_pubdate !== null) {
 			$result .= ' pubdate:' . $this->input_pubdate;
 		}
@@ -380,6 +391,9 @@ class FreshRSS_Search implements \Stringable {
 		if ($this->input_not_userdate !== null) {
 			$result .= ' -userdate:' . $this->input_not_userdate;
 		}
+		if ($this->input_not_modified_date !== null) {
+			$result .= ' -mdate:' . $this->input_not_modified_date;
+		}
 		if ($this->input_not_pubdate !== null) {
 			$result .= ' -pubdate:' . $this->input_not_pubdate;
 		}
@@ -577,13 +591,37 @@ class FreshRSS_Search implements \Stringable {
 	public function getNotMinUserdate(): ?int {
 		return $this->not_min_userdate ?: null;
 	}
-
+	public function setMinUserdate(int $value): void {
+		$this->min_userdate = $value;
+	}
 	public function getMaxUserdate(): ?int {
 		return $this->max_userdate ?: null;
 	}
 	public function getNotMaxUserdate(): ?int {
 		return $this->not_max_userdate ?: null;
 	}
+	public function setMaxUserdate(int $value): void {
+		$this->max_userdate = $value;
+	}
+
+	public function getMinModifiedDate(): ?int {
+		return $this->min_modified_date ?: null;
+	}
+	public function getNotMinModifiedDate(): ?int {
+		return $this->not_min_modified_date ?: null;
+	}
+	public function setMinModifiedDate(int $value): void {
+		$this->min_modified_date = $value;
+	}
+	public function getMaxModifiedDate(): ?int {
+		return $this->max_modified_date ?: null;
+	}
+	public function getNotMaxModifiedDate(): ?int {
+		return $this->not_max_modified_date ?: null;
+	}
+	public function setMaxModifiedDate(int $value): void {
+		$this->max_modified_date = $value;
+	}
 
 	/** @return list<string>|null */
 	public function getInurl(bool $plaintext = false): ?array {
@@ -1122,6 +1160,34 @@ class FreshRSS_Search implements \Stringable {
 		return $input;
 	}
 
+	private function parseModifiedDateSearch(string $input): string {
+		if (preg_match_all('/\bmdate:(?P<search>[^\s]*)/', $input, $matches)) {
+			$input = str_replace($matches[0], '', $input);
+			$dates = self::removeEmptyValues($matches['search']);
+			if (!empty($dates[0])) {
+				[$this->min_modified_date, $this->max_modified_date] = parseDateInterval($dates[0]);
+				if (is_int($this->min_modified_date) || is_int($this->max_modified_date)) {
+					$this->input_modified_date = $dates[0];
+				}
+			}
+		}
+		return $input;
+	}
+
+	private function parseNotModifiedDateSearch(string $input): string {
+		if (preg_match_all('/(?<=[\s(]|^)[!-]mdate:(?P<search>[^\s]*)/', $input, $matches)) {
+			$input = str_replace($matches[0], '', $input);
+			$dates = self::removeEmptyValues($matches['search']);
+			if (!empty($dates[0])) {
+				[$this->not_min_modified_date, $this->not_max_modified_date] = parseDateInterval($dates[0]);
+				if (is_int($this->not_min_modified_date) || is_int($this->not_max_modified_date)) {
+					$this->input_not_modified_date = $dates[0];
+				}
+			}
+		}
+		return $input;
+	}
+
 	/**
 	 * Parse the search string to find userdate keyword and the search related to it.
 	 * The search is the first word following the keyword.

+ 12 - 4
app/SQL/install.sql.mysql.php

@@ -49,7 +49,8 @@ CREATE TABLE IF NOT EXISTS `_entry` (
 	`link` VARCHAR(16383) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
 	`date` BIGINT,
 	`lastSeen` BIGINT DEFAULT 0,
-	`lastUserModified` BIGINT DEFAULT 0,	-- v1.28.0
+	`lastModified` BIGINT,	-- v1.29.0
+	`lastUserModified` BIGINT,	-- v1.28.0
 	`hash` BINARY(16),	-- v1.1.1
 	`is_read` BOOLEAN NOT NULL DEFAULT 0,
 	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
@@ -62,7 +63,8 @@ CREATE TABLE IF NOT EXISTS `_entry` (
 	INDEX (`is_favorite`),	-- v0.7
 	INDEX (`is_read`),	-- v0.7
 	INDEX `entry_lastSeen_index` (`lastSeen`),	-- v1.1.1
-	INDEX `entry_last_user_modified_index` (`lastUserModified`),	-- v1.28.0
+	INDEX `entry_last_modified_index` (`lastModified`),
+	INDEX `entry_last_user_modified_index` (`lastUserModified`),
 	INDEX `entry_feed_read_index` (`id_feed`,`is_read`)	-- v1.7
 ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
 ENGINE = INNODB;
@@ -112,8 +114,14 @@ ENGINE = INNODB;
 SQL;
 
 $GLOBALS['ALTER_TABLE_ENTRY_LAST_USER_MODIFIED'] = <<<'SQL'
-ALTER TABLE `_entry` ADD `lastUserModified` BIGINT DEFAULT 0;	-- 1.28.0
-CREATE INDEX IF NOT EXISTS `entry_last_user_modified_index` ON `_entry` (`lastUserModified`);	-- //v1.28.0
+ALTER TABLE `_entry`
+	ADD COLUMN IF NOT EXISTS `lastUserModified` BIGINT,	-- 1.28.0
+	ADD INDEX `entry_last_user_modified_index` (`lastUserModified`);	-- IF NOT EXISTS works with MariaDB but not with MySQL
+SQL;
+
+$GLOBALS['ALTER_TABLE_ENTRY_LAST_MODIFIED'] = <<<'SQL'
+ALTER TABLE `_entry` ADD COLUMN IF NOT EXISTS `lastModified` BIGINT;	-- 1.29.0
+ALTER TABLE `_entry` ADD INDEX `entry_last_modified_index` (`lastModified`);	-- IF NOT EXISTS works with MariaDB but not with MySQL
 SQL;
 
 $GLOBALS['SQL_DROP_TABLES'] = <<<'SQL'

+ 10 - 3
app/SQL/install.sql.pgsql.php

@@ -44,7 +44,8 @@ CREATE TABLE IF NOT EXISTS `_entry` (
 	"link" VARCHAR(16383) NOT NULL,
 	"date" BIGINT,
 	"lastSeen" BIGINT DEFAULT 0,
-	"lastUserModified" BIGINT DEFAULT 0,
+	"lastModified" BIGINT,	-- v1.29.0
+	"lastUserModified" BIGINT,	-- v1.28.0
 	"hash" BYTEA,
 	"is_read" SMALLINT NOT NULL DEFAULT 0,
 	"is_favorite" SMALLINT NOT NULL DEFAULT 0,
@@ -57,8 +58,9 @@ CREATE TABLE IF NOT EXISTS `_entry` (
 CREATE INDEX IF NOT EXISTS `_is_favorite_index` ON `_entry` ("is_favorite");
 CREATE INDEX IF NOT EXISTS `_is_read_index` ON `_entry` ("is_read");
 CREATE INDEX IF NOT EXISTS `_entry_lastSeen_index` ON `_entry` ("lastSeen");
+CREATE INDEX IF NOT EXISTS `_entry_last_modified_index` ON `_entry` ("lastModified");
+CREATE INDEX IF NOT EXISTS `_entry_last_user_modified_index` ON `_entry` ("lastUserModified");
 CREATE INDEX IF NOT EXISTS `_entry_feed_read_index` ON `_entry` ("id_feed","is_read");	-- v1.7
-CREATE INDEX IF NOT EXISTS `_entry_last_user_modified_index` ON `_entry` ("lastUserModified");	-- v1.28.0
 
 INSERT INTO `_category` (id, name)
 	SELECT 1, 'Uncategorized'
@@ -101,10 +103,15 @@ CREATE INDEX IF NOT EXISTS `_entrytag_id_entry_index` ON `_entrytag` ("id_entry"
 SQL;
 
 $GLOBALS['ALTER_TABLE_ENTRY_LAST_USER_MODIFIED'] = <<<'SQL'
-ALTER TABLE `_entry` ADD `lastUserModified` BIGINT DEFAULT 0;	-- 1.28.0
+ALTER TABLE `_entry` ADD COLUMN IF NOT EXISTS `lastUserModified` BIGINT;	-- 1.28.0
 CREATE INDEX IF NOT EXISTS `_entry_last_user_modified_index` ON `_entry` (`lastUserModified`);
 SQL;
 
+$GLOBALS['ALTER_TABLE_ENTRY_LAST_MODIFIED'] = <<<'SQL'
+ALTER TABLE `_entry` ADD COLUMN IF NOT EXISTS `lastModified` BIGINT;	-- 1.29.0
+CREATE INDEX IF NOT EXISTS `_entry_last_modified_index` ON `_entry` (`lastModified`);
+SQL;
+
 $GLOBALS['SQL_DROP_TABLES'] = <<<'SQL'
 DROP TABLE IF EXISTS `_entrytag`, `_tag`, `_entrytmp`, `_entry`, `_feed`, `_category`;
 SQL;

+ 10 - 3
app/SQL/install.sql.sqlite.php

@@ -45,7 +45,8 @@ CREATE TABLE IF NOT EXISTS `entry` (
 	`link` VARCHAR(16383) NOT NULL,
 	`date` BIGINT,
 	`lastSeen` BIGINT DEFAULT 0,
-	`lastUserModified` BIGINT DEFAULT 0,	-- v1.28.0
+	`lastModified` BIGINT,	-- v1.29.0
+	`lastUserModified` BIGINT,	-- v1.28.0
 	`hash` BINARY(16),	-- v1.1.1
 	`is_read` BOOLEAN NOT NULL DEFAULT 0,
 	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
@@ -59,8 +60,9 @@ CREATE TABLE IF NOT EXISTS `entry` (
 CREATE INDEX IF NOT EXISTS entry_is_favorite_index ON `entry`(`is_favorite`);
 CREATE INDEX IF NOT EXISTS entry_is_read_index ON `entry`(`is_read`);
 CREATE INDEX IF NOT EXISTS entry_lastSeen_index ON `entry`(`lastSeen`);	-- //v1.1.1
+CREATE INDEX IF NOT EXISTS entry_last_modified_index ON `entry` (`lastModified`);
+CREATE INDEX IF NOT EXISTS entry_last_user_modified_index ON `entry` (`lastUserModified`);
 CREATE INDEX IF NOT EXISTS entry_feed_read_index ON `entry`(`id_feed`,`is_read`);	-- v1.7
-CREATE INDEX IF NOT EXISTS entry_last_user_modified_index ON `entry` (`lastUserModified`);	-- //v1.28.0
 
 INSERT OR IGNORE INTO `category` (id, name) VALUES(1, 'Uncategorized');
 
@@ -102,10 +104,15 @@ CREATE INDEX IF NOT EXISTS entrytag_id_entry_index ON `entrytag` (`id_entry`);
 SQL;
 
 $GLOBALS['ALTER_TABLE_ENTRY_LAST_USER_MODIFIED'] = <<<'SQL'
-ALTER TABLE `entry` ADD `lastUserModified` BIGINT DEFAULT 0;	-- 1.28.0
+ALTER TABLE `entry` ADD COLUMN `lastUserModified` BIGINT;	-- 1.28.0
 CREATE INDEX IF NOT EXISTS entry_last_user_modified_index ON `entry` (`lastUserModified`);
 SQL;
 
+$GLOBALS['ALTER_TABLE_ENTRY_LAST_MODIFIED'] = <<<'SQL'
+ALTER TABLE `entry` ADD COLUMN `lastModified` BIGINT;	-- 1.29.0
+CREATE INDEX IF NOT EXISTS entry_last_modified_index ON `entry` (`lastModified`);
+SQL;
+
 $GLOBALS['SQL_DROP_TABLES'] = <<<'SQL'
 DROP TABLE IF EXISTS `entrytag`;
 DROP TABLE IF EXISTS `tag`;

+ 1 - 0
app/i18n/cs/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/de/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Kategorien',
 		'content' => 'Inhalt',
 		'date_from' => 'Ab',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In der Vergangenheit',
 		'date_published' => 'Veröffentlichungsdatum',
 		'date_range' => 'Zeitraum',

+ 1 - 0
app/i18n/el/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Κατηγορίες',
 		'content' => 'Περιεχόμενο',
 		'date_from' => 'Από',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'Στο παρελθόν',
 		'date_published' => 'Ημερομηνία έκδοσης',
 		'date_range' => 'Διάστημα ημερομηνίας',

+ 1 - 0
app/i18n/en-US/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// IGNORE
 		'content' => 'Content',	// IGNORE
 		'date_from' => 'From',	// IGNORE
+		'date_modified' => 'Server Modification Date',	// IGNORE
 		'date_past' => 'In the past',	// IGNORE
 		'date_published' => 'Publication Date',	// IGNORE
 		'date_range' => 'Date Range',	// IGNORE

+ 1 - 0
app/i18n/en/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',
 		'content' => 'Content',
 		'date_from' => 'From',
+		'date_modified' => 'Server Modification Date',
 		'date_past' => 'In the past',
 		'date_published' => 'Publication Date',
 		'date_range' => 'Date Range',

+ 1 - 0
app/i18n/es/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categorías',
 		'content' => 'Contenido',
 		'date_from' => 'Desde',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'En el pasado',
 		'date_published' => 'Fecha de publicación',
 		'date_range' => 'Rango de fechas',

+ 1 - 0
app/i18n/fa/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/fi/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/fr/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Catégories',
 		'content' => 'Contenu',
 		'date_from' => 'Depuis',
+		'date_modified' => 'Date de modification par le serveur',
 		'date_past' => 'Dans le passé',
 		'date_published' => 'Date de publication',
 		'date_range' => 'Plage de dates',

+ 1 - 0
app/i18n/he/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/hu/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Kategóriák',
 		'content' => 'Tartalom',
 		'date_from' => 'Dátumtól',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'A múltban',
 		'date_published' => 'Közzététel dátuma',
 		'date_range' => 'Dátumtartomány',

+ 1 - 0
app/i18n/id/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/it/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categorie',
 		'content' => 'Contenuto',
 		'date_from' => 'Da',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'Nel passato',
 		'date_published' => 'Data di pubblicazione',
 		'date_range' => 'Intervallo date',

+ 1 - 0
app/i18n/ja/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/ko/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/lv/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/nl/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categorieën',
 		'content' => 'Inhoud',
 		'date_from' => 'Van',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In het verleden',
 		'date_published' => 'Publicatiedatum',
 		'date_range' => 'Datumbereik',

+ 1 - 0
app/i18n/oc/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/pl/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Kategorie',
 		'content' => 'Zawartość',
 		'date_from' => 'Od',
+		'date_modified' => 'Data modyfikacji przez serwer',
 		'date_past' => 'W przeszłych',
 		'date_published' => 'Data publikacji',
 		'date_range' => 'Zasięg dat',

+ 1 - 0
app/i18n/pt-BR/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categorias',
 		'content' => 'Conteúdo',
 		'date_from' => 'De',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'No passado',
 		'date_published' => 'Data de publicação',
 		'date_range' => 'Intervalo de datas',

+ 1 - 0
app/i18n/pt-PT/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/ru/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Категории',
 		'content' => 'Содержимое',
 		'date_from' => 'С',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'За прошедший период',
 		'date_published' => 'Дата публикации',
 		'date_range' => 'Диапазон дат',

+ 1 - 0
app/i18n/sk/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/tr/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/uk/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 1 - 0
app/i18n/zh-CN/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => '类别',
 		'content' => '内容',
 		'date_from' => '从',
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => '过去',	// DIRTY
 		'date_published' => '发布日期',
 		'date_range' => '日期范围',

+ 1 - 0
app/i18n/zh-TW/gen.php

@@ -228,6 +228,7 @@ return array(
 		'categories' => 'Categories',	// TODO
 		'content' => 'Content',	// TODO
 		'date_from' => 'From',	// TODO
+		'date_modified' => 'Server Modification Date',	// TODO
 		'date_past' => 'In the past',	// TODO
 		'date_published' => 'Publication Date',	// TODO
 		'date_range' => 'Date Range',	// TODO

+ 32 - 0
app/views/search/index.phtml

@@ -98,6 +98,24 @@
 				</div>
 			</div>
 
+			<div class="form-group">
+				<label class="group-name"><?= _t('gen.search.date_modified') ?></label>
+				<div class="group-controls">
+					<div>
+						<label for="mdate_number"><?= _t('gen.search.date_past') ?>
+							<input id="mdate_number" name="mdate_number" type="number" min="0" placeholder="0" />
+							<select id="mdate_unit" name="mdate_unit">
+								<option value="H"><?= _t('gen.period.hours') ?></option>
+								<option value="D"><?= _t('gen.period.days') ?></option>
+								<option value="W"><?= _t('gen.period.weeks') ?></option>
+								<option value="M"><?= _t('gen.period.months') ?></option>
+								<option value="Y"><?= _t('gen.period.years') ?></option>
+							</select>
+						</label>
+					</div>
+				</div>
+			</div>
+
 			<div class="form-group">
 				<label class="group-name"><?= _t('gen.search.date_user') ?></label>
 				<div class="group-controls">
@@ -148,6 +166,20 @@
 				</div>
 			</div>
 
+			<div class="form-group">
+				<label class="group-name"><?= _t('gen.search.date_modified') ?></label>
+				<div class="group-controls">
+					<div>
+						<label for="mdate_from"><?= _t('gen.search.date_from') ?>
+							<input id="mdate_from" name="mdate_from" type="date" />
+						</label>
+						<label for="mdate_to"><?= _t('gen.search.date_to') ?>
+							<input id="mdate_to" name="mdate_to" type="date" />
+						</label>
+					</div>
+				</div>
+			</div>
+
 			<div class="form-group">
 				<label class="group-name"><?= _t('gen.search.date_user') ?></label>
 				<div class="group-controls">

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

@@ -91,7 +91,8 @@ You can use the search field to further refine results:
 	* Date constraints may be combined:
 		* `date:P1Y !date:P1M` (from one year before now until one month before now)
 * by date of publication, using the same format: `pubdate:<date-interval>`
-* by date of user modification, using the same format: `userdate:<date-interval>`
+* by date of server modification, using the same format: `mdate:<date-interval>`
+* by date of user modification (e.g. mark as read or favourite), using the same format: `userdate:<date-interval>`
 * by custom label ID `L:12` or multiple label IDs: `L:12,13,14` or with any label: `L:*`
 * 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"`

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

@@ -250,7 +250,8 @@ Il est possible d’utiliser le champ de recherche pour raffiner les résultats
 	* Les contraintes de date peuvent être combinées :
 		* `date:P1Y !date:P1M` (depuis un an avant maintenant jusqu’à un mois avant maintenant)
 * par date de publication, avec la même syntaxe : `pubdate:<date-interval>`
-* par date de modification par l’utilisateur, avec la même syntaxe : `userdate:<date-interval>`
+* par date de modification par le serveur, avec la même syntaxe : `mdate:<date-interval>`
+* par date de modification par l’utilisateur (par exemple marqué comme lu ou favori), avec la même syntaxe : `userdate:<date-interval>`
 * par ID d’étiquette : `L:12` ou de plusieurs étiquettes : `L:12,13,14` ou avec n’importe quelle étiquette : `L:*`
 * par nom d’étiquette : `label:étiquette`, `label:"mon étiquette"` ou d’une étiquette parmi une liste (*ou*) : `labels:"mon étiquette,mon autre étiquette"`
 * par plusieurs noms d’étiquettes (*et*) : `label:"mon étiquette" label:"mon autre étiquette"`

+ 6 - 0
p/api/greader.php

@@ -661,10 +661,16 @@ final class GReaderAPI {
 			$search = new FreshRSS_Search('');
 			$search->setMinDate($start_time);
 			$searches->add($search);
+			// OR
+			$search = new FreshRSS_Search('');
+			$search->setMinModifiedDate($start_time);
+			$searches->add($search);
 		}
 		if ($stop_time !== 0) {
 			$search = new FreshRSS_Search('');
 			$search->setMaxDate($stop_time);
+			// AND
+			$search->setMaxModifiedDate($stop_time);
 			$searches->add($search);
 		}
 

+ 83 - 1
tests/app/Models/SearchTest.php

@@ -198,6 +198,25 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 		];
 	}
 
+
+
+	#[DataProvider('provideModifiedDateSearch')]
+	public static function test__construct_whenInputContainsModifiedDate(string $input, ?int $min_modified_value, ?int $max_modified_value): void {
+		$search = new FreshRSS_Search($input);
+		self::assertSame($min_modified_value, $search->getMinModifiedDate());
+		self::assertSame($max_modified_value, $search->getMaxModifiedDate());
+	}
+
+	/**
+	 * @return list<list<mixed>>
+	 */
+	public static function provideModifiedDateSearch(): array {
+		return [
+			['mdate:2007-03-01T13:00:00Z/2008-05-11T15:30:00Z', strtotime('2007-03-01T13:00:00Z'), strtotime('2008-05-11T15:30:00Z')],
+			['mdate:/2008-05-11', null, strtotime('2008-05-12') - 1],
+		];
+	}
+
 	#[DataProvider('provideUserdateSearch')]
 	public static function test__construct_whenInputContainsUserdate(string $input, ?int $min_userdate_value, ?int $max_userdate_value): void {
 		$search = new FreshRSS_Search($input);
@@ -621,6 +640,41 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 		];
 	}
 
+	public function test__add_single_search_combines_conditions_with_and(): void {
+		$startTime = strtotime('2026-02-21T12:00:00Z');
+		$searches = new FreshRSS_BooleanSearch('');
+
+		$search = new FreshRSS_Search('');
+		$search->setMinDate($startTime);
+		$search->setMinModifiedDate($startTime);
+		$searches->add($search);
+
+		[$filterValues, $filterSearch] = FreshRSS_EntryDAOPGSQL::sqlBooleanSearch('e.', $searches);
+
+		$filterSearch = preg_replace('/\s+/', ' ', trim($filterSearch)) ?? '';
+		self::assertSame('(e.id >= ? AND e.`lastModified` >= ?)', $filterSearch);
+		self::assertSame([$startTime . '000000', $startTime], $filterValues);
+	}
+
+	public function test__add_multiple_searches_combines_conditions_with_or(): void {
+		$startTime = strtotime('2026-02-21T12:00:00Z');
+		$searches = new FreshRSS_BooleanSearch('');
+
+		$search = new FreshRSS_Search('');
+		$search->setMinDate($startTime);
+		$searches->add($search);
+
+		$search = new FreshRSS_Search('');
+		$search->setMinModifiedDate($startTime);
+		$searches->add($search);
+
+		[$filterValues, $filterSearch] = FreshRSS_EntryDAOPGSQL::sqlBooleanSearch('e.', $searches);
+
+		$filterSearch = preg_replace('/\s+/', ' ', trim($filterSearch)) ?? '';
+		self::assertSame('(e.id >= ?) OR (e.`lastModified` >= ?)', $filterSearch);
+		self::assertSame([$startTime . '000000', $startTime], $filterValues);
+	}
+
 	/**
 	 * @param array<string> $values
 	 */
@@ -666,6 +720,22 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 				'(e.date <= ?)',
 				[strtotime('2008-05-11T23:59:59Z')],
 			],
+			// Basic modified date operator tests
+			[
+				'mdate:2007-03-01T13:00:00Z/2008-05-11T15:30:00Z',
+				'(e.`lastModified` >= ? AND COALESCE(e.`lastModified`, 0) <= ?)',
+				[strtotime('2007-03-01T13:00:00Z'), strtotime('2008-05-11T15:30:00Z')],
+			],
+			[
+				'mdate:2007-03-01/',
+				'(e.`lastModified` >= ?)',
+				[strtotime('2007-03-01T00:00:00Z')],
+			],
+			[
+				'mdate:/2008-05-11',
+				'(COALESCE(e.`lastModified`, 0) <= ?)',
+				[strtotime('2008-05-11T23:59:59Z')],
+			],
 			// Basic userdate operator tests
 			[
 				'userdate:2007-03-01T13:00:00Z/2008-05-11T15:30:00Z',
@@ -693,9 +763,14 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 				'((e.date < ? OR e.date > ?))',
 				[strtotime('2007-03-01T13:00:00Z'), strtotime('2008-05-11T15:30:00Z')],
 			],
+			[
+				'!mdate:2007-03-01T13:00:00Z/2008-05-11T15:30:00Z',
+				'((COALESCE(e.`lastModified`, 0) < ? OR e.`lastModified` > ?))',
+				[strtotime('2007-03-01T13:00:00Z'), strtotime('2008-05-11T15:30:00Z')],
+			],
 			[
 				'!userdate:2007-03-01T13:00:00Z/2008-05-11T15:30:00Z',
-				'((e.`lastUserModified` < ? OR e.`lastUserModified` > ?))',
+				'((COALESCE(e.`lastUserModified`, 0) < ? OR e.`lastUserModified` > ?))',
 				[strtotime('2007-03-01T13:00:00Z'), strtotime('2008-05-11T15:30:00Z')],
 			],
 			// Combined date operators
@@ -709,6 +784,11 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 				'(e.date >= ? AND e.`lastUserModified` <= ?)',
 				[strtotime('2007-03-01T00:00:00Z'), strtotime('2008-05-11T23:59:59Z')],
 			],
+			[
+				'userdate:2007-03-01/ mdate:/2008-05-11',
+				'(COALESCE(e.`lastModified`, 0) <= ? AND e.`lastUserModified` >= ?)',
+				[strtotime('2008-05-11T23:59:59Z'), strtotime('2007-03-01T00:00:00Z')],
+			],
 			[
 				'date:2007-03-01/ userdate:2007-06-01/',
 				'(e.id >= ? AND e.`lastUserModified` >= ?)',
@@ -973,6 +1053,7 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 				<<<'EOD'
 					e:1,2 f:10,11 c:20,21 L:30,31 labels:"My label,My other label"
 					userdate:2025-01-01T00:00:00/2026-01-01T00:00:00
+					mdate:2025-12
 					pubdate:2025-02-01T00:00:00/2026-01-01T00:00:00
 					date:2025-03-01T00:00:00/2026-01-01T00:00:00
 					intitle:/<Inter&sting>/i intitle:"g ' & d\\:"
@@ -983,6 +1064,7 @@ final class SearchTest extends \PHPUnit\Framework\TestCase {
 					/search_regex/i "quoted search" search
 					-e:3,4 -f:12,13 -c:22,23 -L:32,33 -labels:"Not label,Not other label"
 					-userdate:2025-06-01T00:00:00/2025-09-01T00:00:00
+					-mdate:2025-12-27
 					-pubdate:2025
 					-date:P30D
 					-intitle:/Spam/i -intitle:"'bad"