diff --git a/CHANGELOG.md b/CHANGELOG.md index ef93a8a73..4291333f2 100644 --- a/CHANGELOG.md +++ b/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. diff --git a/app/Controllers/feedController.php b/app/Controllers/feedController.php index 99b760bc5..9a6a7daa6 100644 --- a/app/Controllers/feedController.php +++ b/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()); + } } } diff --git a/app/Controllers/indexController.php b/app/Controllers/indexController.php index 7fe1e43a0..36fd1c9b2 100644 --- a/app/Controllers/indexController.php +++ b/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') { diff --git a/app/Controllers/searchController.php b/app/Controllers/searchController.php index f953fd14a..14b0ad6c5 100644 --- a/app/Controllers/searchController.php +++ b/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 !== '') { diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 146423fd2..9f4e9f092 100644 --- a/app/Models/Entry.php +++ b/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,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,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,attributes?:?string, - * thumbnail?:string,timestamp?:string}> $daos + * @param Traversable,attributes?:?string, + * thumbnail?:string,timestamp?:string}> $daos * @return Traversable */ 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} */ 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(), diff --git a/app/Models/EntryDAO.php b/app/Models/EntryDAO.php index f14cc27f0..f7caec5c2 100644 --- a/app/Models/EntryDAO.php +++ b/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} $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} $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} $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} $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 */ 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 = <<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 = <<fetchAssoc($sql, [':id_feed' => $id_feed, ':guid' => $guid]); - /** @var list $res */ + /** @var list $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 = <<fetchAssoc($sql, [':id' => $id]); - /** @var list $res */ + /** @var list $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 $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 $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 $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 = <<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 = <<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); } } diff --git a/app/Models/EntryDAOPGSQL.php b/app/Models/EntryDAOPGSQL.php index 05850632f..323500656 100644 --- a/app/Models/EntryDAOPGSQL.php +++ b/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); } diff --git a/app/Models/EntryDAOSQLite.php b/app/Models/EntryDAOSQLite.php index daccc66d4..d4675a38c 100644 --- a/app/Models/EntryDAOSQLite.php +++ b/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`); diff --git a/app/Models/Search.php b/app/Models/Search.php index b2742a8c6..ba14273a4 100644 --- a/app/Models/Search.php +++ b/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|null */ private ?array $inurl = null; /** @var list|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|null */ private ?array $not_inurl = null; /** @var list|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|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[^\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[^\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. diff --git a/app/SQL/install.sql.mysql.php b/app/SQL/install.sql.mysql.php index 5e8d092c5..cfb40a59f 100644 --- a/app/SQL/install.sql.mysql.php +++ b/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' diff --git a/app/SQL/install.sql.pgsql.php b/app/SQL/install.sql.pgsql.php index 557a42a34..8e94f6bb8 100644 --- a/app/SQL/install.sql.pgsql.php +++ b/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; diff --git a/app/SQL/install.sql.sqlite.php b/app/SQL/install.sql.sqlite.php index 55de33f71..1bfdc13b1 100644 --- a/app/SQL/install.sql.sqlite.php +++ b/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`; diff --git a/app/i18n/cs/gen.php b/app/i18n/cs/gen.php index 20b847820..545d5b6b8 100644 --- a/app/i18n/cs/gen.php +++ b/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 diff --git a/app/i18n/de/gen.php b/app/i18n/de/gen.php index 3da8c423c..899f919c3 100644 --- a/app/i18n/de/gen.php +++ b/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', diff --git a/app/i18n/el/gen.php b/app/i18n/el/gen.php index 477b62a13..5c4ca248c 100644 --- a/app/i18n/el/gen.php +++ b/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' => 'Διάστημα ημερομηνίας', diff --git a/app/i18n/en-US/gen.php b/app/i18n/en-US/gen.php index 08e1dc146..e0c7dface 100644 --- a/app/i18n/en-US/gen.php +++ b/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 diff --git a/app/i18n/en/gen.php b/app/i18n/en/gen.php index e1b857bfe..56585b9cd 100644 --- a/app/i18n/en/gen.php +++ b/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', diff --git a/app/i18n/es/gen.php b/app/i18n/es/gen.php index 3a57bd764..320401d6c 100644 --- a/app/i18n/es/gen.php +++ b/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', diff --git a/app/i18n/fa/gen.php b/app/i18n/fa/gen.php index 9809e2425..9f256a12e 100644 --- a/app/i18n/fa/gen.php +++ b/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 diff --git a/app/i18n/fi/gen.php b/app/i18n/fi/gen.php index 3a260afaf..db8981364 100644 --- a/app/i18n/fi/gen.php +++ b/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 diff --git a/app/i18n/fr/gen.php b/app/i18n/fr/gen.php index 974312a9d..c02806c16 100644 --- a/app/i18n/fr/gen.php +++ b/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', diff --git a/app/i18n/he/gen.php b/app/i18n/he/gen.php index ee897ee8f..3d2047ef4 100644 --- a/app/i18n/he/gen.php +++ b/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 diff --git a/app/i18n/hu/gen.php b/app/i18n/hu/gen.php index 740ab85d7..a57351102 100644 --- a/app/i18n/hu/gen.php +++ b/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', diff --git a/app/i18n/id/gen.php b/app/i18n/id/gen.php index e973e3ca4..8842ed3b7 100644 --- a/app/i18n/id/gen.php +++ b/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 diff --git a/app/i18n/it/gen.php b/app/i18n/it/gen.php index c9b504f89..c0390ecaa 100644 --- a/app/i18n/it/gen.php +++ b/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', diff --git a/app/i18n/ja/gen.php b/app/i18n/ja/gen.php index e651f8aba..e46b5c496 100644 --- a/app/i18n/ja/gen.php +++ b/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 diff --git a/app/i18n/ko/gen.php b/app/i18n/ko/gen.php index 31ca24011..c6f333ec4 100644 --- a/app/i18n/ko/gen.php +++ b/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 diff --git a/app/i18n/lv/gen.php b/app/i18n/lv/gen.php index de42fc454..2329c43aa 100644 --- a/app/i18n/lv/gen.php +++ b/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 diff --git a/app/i18n/nl/gen.php b/app/i18n/nl/gen.php index 59e73d938..4586782c2 100644 --- a/app/i18n/nl/gen.php +++ b/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', diff --git a/app/i18n/oc/gen.php b/app/i18n/oc/gen.php index 2e75c299e..17d8b0445 100644 --- a/app/i18n/oc/gen.php +++ b/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 diff --git a/app/i18n/pl/gen.php b/app/i18n/pl/gen.php index 297e63c9b..3702fd56e 100644 --- a/app/i18n/pl/gen.php +++ b/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', diff --git a/app/i18n/pt-BR/gen.php b/app/i18n/pt-BR/gen.php index a6ec0e8e4..7ec7da266 100644 --- a/app/i18n/pt-BR/gen.php +++ b/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', diff --git a/app/i18n/pt-PT/gen.php b/app/i18n/pt-PT/gen.php index 5cda26042..1b9e65b67 100644 --- a/app/i18n/pt-PT/gen.php +++ b/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 diff --git a/app/i18n/ru/gen.php b/app/i18n/ru/gen.php index 7c86d8216..f0ebce010 100644 --- a/app/i18n/ru/gen.php +++ b/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' => 'Диапазон дат', diff --git a/app/i18n/sk/gen.php b/app/i18n/sk/gen.php index 6aca3babc..0b6a987da 100644 --- a/app/i18n/sk/gen.php +++ b/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 diff --git a/app/i18n/tr/gen.php b/app/i18n/tr/gen.php index 701cccd70..c9ac657d0 100644 --- a/app/i18n/tr/gen.php +++ b/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 diff --git a/app/i18n/uk/gen.php b/app/i18n/uk/gen.php index 329ac2b49..55412e7ad 100644 --- a/app/i18n/uk/gen.php +++ b/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 diff --git a/app/i18n/zh-CN/gen.php b/app/i18n/zh-CN/gen.php index f84607672..1a42191f6 100644 --- a/app/i18n/zh-CN/gen.php +++ b/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' => '日期范围', diff --git a/app/i18n/zh-TW/gen.php b/app/i18n/zh-TW/gen.php index 5e4b47e7d..0f1f6ff8f 100644 --- a/app/i18n/zh-TW/gen.php +++ b/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 diff --git a/app/views/search/index.phtml b/app/views/search/index.phtml index fb64b0524..9be509437 100644 --- a/app/views/search/index.phtml +++ b/app/views/search/index.phtml @@ -98,6 +98,24 @@ +
+ +
+
+ +
+
+
+
@@ -148,6 +166,20 @@
+
+ +
+
+ + +
+
+
+
diff --git a/docs/en/users/10_filter.md b/docs/en/users/10_filter.md index bb90f9925..edbc72190 100644 --- a/docs/en/users/10_filter.md +++ b/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:` -* by date of user modification, using the same format: `userdate:` +* by date of server modification, using the same format: `mdate:` +* by date of user modification (e.g. mark as read or favourite), using the same format: `userdate:` * 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"` diff --git a/docs/fr/users/03_Main_view.md b/docs/fr/users/03_Main_view.md index 042a3bc68..df7578e6f 100644 --- a/docs/fr/users/03_Main_view.md +++ b/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:` -* par date de modification par l’utilisateur, avec la même syntaxe : `userdate:` +* par date de modification par le serveur, avec la même syntaxe : `mdate:` +* par date de modification par l’utilisateur (par exemple marqué comme lu ou favori), avec la même syntaxe : `userdate:` * 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"` diff --git a/p/api/greader.php b/p/api/greader.php index 144508ad4..44f4b6642 100644 --- a/p/api/greader.php +++ b/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); } diff --git a/tests/app/Models/SearchTest.php b/tests/app/Models/SearchTest.php index 732a3ff28..e33172253 100644 --- a/tests/app/Models/SearchTest.php +++ b/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> + */ + 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 $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://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"