From 1b90c40fd61aa72d91eb9dbab63f7fcd0d69154e Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Sat, 21 Mar 2026 17:15:07 +0100 Subject: [PATCH] New automatic feed visibility/priority during search (#8609) When the search query includes some feed IDs or category IDs, adjust feed visibility/priority filter to include at minimum feed or category visibility. Fix: https://github.com/FreshRSS/FreshRSS/issues/8602 --- app/Controllers/entryController.php | 52 ++++++++++++++++++----------- app/Controllers/indexController.php | 7 ++-- app/Models/BooleanSearch.php | 23 +++++++++++++ app/Models/Context.php | 10 +++--- app/Models/EntryDAO.php | 37 +++++++++++--------- app/Models/Search.php | 15 +++++++++ app/Models/UserQuery.php | 12 +++---- tests/app/Models/SearchTest.php | 18 ++++++++++ 8 files changed, 123 insertions(+), 51 deletions(-) diff --git a/app/Controllers/entryController.php b/app/Controllers/entryController.php index e6c5b9f66..57d403dc9 100644 --- a/app/Controllers/entryController.php +++ b/app/Controllers/entryController.php @@ -89,33 +89,45 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController { $type_get = $get[0]; $get = (int)substr($get, 2); switch ($type_get) { - case 'c': - $entryDAO->markReadCat($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); + case 'c': // Category + $entryDAO->markReadCat($get, $id_max, + priorityMin: min(FreshRSS_Feed::PRIORITY_CATEGORY, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT), + filters: FreshRSS_Context::$search, state: FreshRSS_Context::$state, is_read: $is_read); break; - case 'f': + case 'f': // Feed $entryDAO->markReadFeed($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); break; - case 's': - $entryDAO->markReadEntries($id_max, true, null, FreshRSS_Feed::PRIORITY_IMPORTANT, - FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); + case 's': // Starred. Deprecated: use $state instead + $entryDAO->markReadEntries($id_max, onlyFavorites: true, + priorityMin: null, + priorityMax: null, + filters: FreshRSS_Context::$search, state: FreshRSS_Context::$state, is_read: $is_read); break; - case 'a': - $entryDAO->markReadEntries($id_max, false, FreshRSS_Feed::PRIORITY_MAIN_STREAM, FreshRSS_Feed::PRIORITY_IMPORTANT, - FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); + case 'a': // All PRIORITY_MAIN_STREAM + $entryDAO->markReadEntries($id_max, onlyFavorites: false, + priorityMin: min(FreshRSS_Feed::PRIORITY_MAIN_STREAM, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT), + priorityMax: null, + filters: FreshRSS_Context::$search, state: FreshRSS_Context::$state, is_read: $is_read); break; - case 'A': - $entryDAO->markReadEntries($id_max, false, FreshRSS_Feed::PRIORITY_CATEGORY, FreshRSS_Feed::PRIORITY_IMPORTANT, - FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); + case 'A': // All except PRIORITY_HIDDEN + $entryDAO->markReadEntries($id_max, onlyFavorites: false, + priorityMin: min(FreshRSS_Feed::PRIORITY_CATEGORY, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT), + priorityMax: null, + filters: FreshRSS_Context::$search, state: FreshRSS_Context::$state, is_read: $is_read); break; - case 'Z': - $entryDAO->markReadEntries($id_max, false, FreshRSS_Feed::PRIORITY_HIDDEN, FreshRSS_Feed::PRIORITY_IMPORTANT, - FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); + case 'Z': // All including PRIORITY_HIDDEN + $entryDAO->markReadEntries($id_max, onlyFavorites: false, + priorityMin: FreshRSS_Feed::PRIORITY_HIDDEN, + priorityMax: null, + filters: FreshRSS_Context::$search, state: FreshRSS_Context::$state, is_read: $is_read); break; - case 'i': - $entryDAO->markReadEntries($id_max, false, FreshRSS_Feed::PRIORITY_IMPORTANT, null, - FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); + case 'i': // Priority important feeds + $entryDAO->markReadEntries($id_max, onlyFavorites: false, + priorityMin: min(FreshRSS_Feed::PRIORITY_IMPORTANT, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT), + priorityMax: null, + filters: FreshRSS_Context::$search, state: FreshRSS_Context::$state, is_read: $is_read); break; - case 't': + case 't': // Tag (label) $entryDAO->markReadTag($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); // Marking all entries in a tag as read can result in other tags also having all entries marked as read, // so the next unread tag calculation is deferred by passing next_get = 'a' instead of the current get ID. @@ -157,7 +169,7 @@ class FreshRSS_entry_Controller extends FreshRSS_ActionController { } } break; - case 'T': + case 'T': // Any tag (label) $entryDAO->markReadTag(0, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read); break; } diff --git a/app/Controllers/indexController.php b/app/Controllers/indexController.php index 64666f784..b043646a0 100644 --- a/app/Controllers/indexController.php +++ b/app/Controllers/indexController.php @@ -300,7 +300,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController { case 'Z': // All including PRIORITY_HIDDEN $this->view->categories = FreshRSS_Context::categories(); break; - case 'c': + case 'c': // Category $cat = FreshRSS_Context::categories()[$id] ?? null; if ($cat == null) { Minz_Error::error(404); @@ -308,7 +308,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController { } $this->view->categories = [$cat->id() => $cat]; break; - case 'f': + case 'f': // Feed // We most likely already have the feed object in cache $feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $id); if ($feed === null) { @@ -321,9 +321,6 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController { } $this->view->feeds = [$feed->id() => $feed]; break; - case 's': - case 't': - case 'T': default: Minz_Error::error(404); return; diff --git a/app/Models/BooleanSearch.php b/app/Models/BooleanSearch.php index 5bde71bbe..5a9148c6d 100644 --- a/app/Models/BooleanSearch.php +++ b/app/Models/BooleanSearch.php @@ -516,6 +516,29 @@ class FreshRSS_BooleanSearch implements \Stringable { return $result; } + /** + * Return the minimum visibility (priority) level needed for this Boolean search, or null if it does not require any specific visibility level. + * For instance, if the search includes some feed IDs then it will return PRIORITY_HIDDEN, + * and if it includes some category IDs then it will return PRIORITY_CATEGORY. + */ + public function needVisibility(): ?int { + $minVisibility = FreshRSS_Feed::PRIORITY_IMPORTANT + 1; + foreach ($this->searches as $search) { + if ($search instanceof FreshRSS_BooleanSearch) { + $visibility = $search->needVisibility(); + if ($visibility !== null) { + $minVisibility = min($minVisibility, $visibility); + } + } elseif ($search instanceof FreshRSS_Search) { + $visibility = $search->needVisibility(); + if ($visibility !== null) { + $minVisibility = min($minVisibility, $visibility); + } + } + } + return $minVisibility < FreshRSS_Feed::PRIORITY_IMPORTANT ? $minVisibility : null; + } + private ?string $expanded = null; #[\Override] diff --git a/app/Models/Context.php b/app/Models/Context.php index 7bd028780..10a399059 100644 --- a/app/Models/Context.php +++ b/app/Models/Context.php @@ -489,7 +489,7 @@ final class FreshRSS_Context { self::$description = FreshRSS_Context::systemConf()->meta_description; self::$get_unread = self::$total_unread; break; - case 's': + case 's': // Starred. Deprecated: use $state instead self::$current_get['starred'] = true; self::$name = _t('index.feed.title_fav'); self::$description = FreshRSS_Context::systemConf()->meta_description; @@ -497,7 +497,7 @@ final class FreshRSS_Context { // Update state if favorite is not yet enabled. self::$state = self::$state | FreshRSS_Entry::STATE_FAVORITE; break; - case 'f': + case 'f': // Feed // We try to find the corresponding feed. When allowing robots, always retrieve the full feed including description $feed = FreshRSS_Context::systemConf()->allow_robots ? null : FreshRSS_Category::findFeed(self::$categories, $id); if ($feed === null) { @@ -509,7 +509,7 @@ final class FreshRSS_Context { self::$description = $feed->description(); self::$get_unread = $feed->nbNotRead(); break; - case 'c': + case 'c': // Category // We try to find the corresponding category. self::$current_get['category'] = $id; $cat = null; @@ -525,7 +525,7 @@ final class FreshRSS_Context { self::$name = $cat->name(); self::$get_unread = $cat->nbNotRead(); break; - case 't': + case 't': // Tag (label) // We try to find the corresponding tag. self::$current_get['tag'] = $id; $tag = null; @@ -545,7 +545,7 @@ final class FreshRSS_Context { self::$name = $tag->name(); self::$get_unread = $tag->nbUnread(); break; - case 'T': + case 'T': // Any tag (label) $tagDAO = FreshRSS_Factory::createTagDao(); self::$current_get['tags'] = true; self::$name = _t('index.menu.mylabels'); diff --git a/app/Models/EntryDAO.php b/app/Models/EntryDAO.php index f172b0718..fe2e0b97d 100644 --- a/app/Models/EntryDAO.php +++ b/app/Models/EntryDAO.php @@ -644,9 +644,11 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo { * * @param int $id category ID * @param numeric-string $idMax fail safe article ID + * @param int $priorityMin minimum feed priority to include * @return int|false affected rows */ - public function markReadCat(int $id, string $idMax = '0', ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true): int|false { + public function markReadCat(int $id, string $idMax = '0', int $priorityMin = FreshRSS_Feed::PRIORITY_CATEGORY, + ?FreshRSS_BooleanSearch $filters = null, int $state = 0, bool $is_read = true): int|false { FreshRSS_UserDAO::touch(); if ($idMax == '0') { $idMax = uTimeString(); @@ -659,7 +661,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo { WHERE is_read <> ? AND id <= ? AND id_feed IN (SELECT f.id FROM `_feed` f WHERE f.category=? AND f.priority >= ? AND f.priority < ?) SQL; - $values = [$is_read ? 1 : 0, time(), $is_read ? 1 : 0, $idMax, $id, FreshRSS_Feed::PRIORITY_CATEGORY, FreshRSS_Feed::PRIORITY_IMPORTANT]; + $values = [$is_read ? 1 : 0, time(), $is_read ? 1 : 0, $idMax, $id, $priorityMin, FreshRSS_Feed::PRIORITY_IMPORTANT]; [$searchValues, $search] = $this->sqlListEntriesWhere(alias: '', state: $state, filters: $filters); @@ -1541,41 +1543,46 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo { $values = []; switch ($type) { case 'a': // All PRIORITY_MAIN_STREAM - $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_MAIN_STREAM . ' '; + $where .= 'f.priority >= ' . + min(FreshRSS_Feed::PRIORITY_MAIN_STREAM, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT) . ' '; break; case 'A': // All except PRIORITY_HIDDEN - $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_FEED . ' '; + $where .= 'f.priority >= ' . + min(FreshRSS_Feed::PRIORITY_CATEGORY, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT) . ' '; break; case 'Z': // All including PRIORITY_HIDDEN - $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_HIDDEN . ' '; + $where .= '1=1 '; break; case 'i': // Priority important feeds - $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_IMPORTANT . ' '; + $where .= 'f.priority >= ' . + min(FreshRSS_Feed::PRIORITY_IMPORTANT, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT) . ' '; break; - case 's': //Starred. Deprecated: use $state instead - $where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_HIDDEN . ' '; + case 's': // Starred. Deprecated: use $state instead + $where .= 'f.priority > ' . + min(FreshRSS_Feed::PRIORITY_HIDDEN, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT) . ' '; $where .= 'AND e.is_favorite=1 '; break; - case 'S': //Starred + case 'S': // Starred $where .= 'e.is_favorite=1 '; break; - case 'c': //Category - $where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_CATEGORY . ' '; + case 'c': // Category + $where .= 'f.priority >= ' . + min(FreshRSS_Feed::PRIORITY_CATEGORY, FreshRSS_Context::$search->needVisibility() ?? FreshRSS_Feed::PRIORITY_IMPORTANT) . ' '; $where .= 'AND f.category=? '; $values[] = $id; break; - case 'f': //Feed + case 'f': // Feed $where .= 'e.id_feed=? '; $values[] = $id; break; - case 't': //Tag (label) + case 't': // Tag (label) $where .= 'et.id_tag=? '; $values[] = $id; break; - case 'T': //Any tag (label) + case 'T': // Any tag (label) $where .= '1=1 '; break; - case 'ST': //Starred or tagged (label) + case 'ST': // Starred or tagged (label) $where .= 'e.is_favorite=1 OR EXISTS (SELECT et2.id_tag FROM `_entrytag` et2 WHERE et2.id_entry = e.id) '; break; default: diff --git a/app/Models/Search.php b/app/Models/Search.php index 8f37c38ef..b1f8a738d 100644 --- a/app/Models/Search.php +++ b/app/Models/Search.php @@ -529,6 +529,21 @@ class FreshRSS_Search implements \Stringable { return $this->not_category_ids; } + /** + * Return the minimum visibility (priority) level needed for this search, + * or null if it does not require any specific visibility level. + * For instance, if the search includes some feed IDs then it will return PRIORITY_HIDDEN, + * and if it includes some category IDs then it will return PRIORITY_CATEGORY. + */ + public function needVisibility(): ?int { + if ($this->feed_ids !== null && count($this->feed_ids) > 0) { + return FreshRSS_Feed::PRIORITY_HIDDEN; + } elseif ($this->category_ids !== null && count($this->category_ids) > 0) { + return FreshRSS_Feed::PRIORITY_CATEGORY; + } + return null; + } + /** @return list|'*'>|null */ public function getLabelIds(): array|null { return $this->label_ids; diff --git a/app/Models/UserQuery.php b/app/Models/UserQuery.php index 919330e64..70ed268af 100644 --- a/app/Models/UserQuery.php +++ b/app/Models/UserQuery.php @@ -154,28 +154,28 @@ class FreshRSS_UserQuery { case 'Z': // All including PRIORITY_HIDDEN $this->get_type = 'Z'; break; - case 'c': + case 'c': // Category $this->get_type = 'category'; $c = $this->categories[$id] ?? null; $this->get_name = $c === null ? '' : $c->name(); break; - case 'f': + case 'f': // Feed $this->get_type = 'feed'; $f = FreshRSS_Category::findFeed($this->categories, $id); $this->get_name = $f === null ? '' : $f->name(); break; - case 'i': + case 'i': // Priority important feeds $this->get_type = 'important'; break; - case 's': + case 's': // Starred. Deprecated: use $state instead $this->get_type = 'favorite'; break; - case 't': + case 't': // Tag (label) $this->get_type = 'label'; $l = $this->labels[$id] ?? null; $this->get_name = $l === null ? '' : $l->name(); break; - case 'T': + case 'T': // Any tag (label) $this->get_type = 'all_labels'; break; } diff --git a/tests/app/Models/SearchTest.php b/tests/app/Models/SearchTest.php index 2c198dc2f..9c424e903 100644 --- a/tests/app/Models/SearchTest.php +++ b/tests/app/Models/SearchTest.php @@ -1322,4 +1322,22 @@ final class SearchTest extends \PHPUnit\Framework\TestCase { ['date:2024/ a', 'date:/2025', 'a'], ]; } + + #[DataProvider('provideNeedVisibility')] + public function testNeedVisibility(string $input, ?int $expected): void { + $search = new FreshRSS_Search($input); + self::assertSame($expected, $search->needVisibility()); + } + + /** @return list> */ + public static function provideNeedVisibility(): array { + return [ + ['', null], + ['f:1', FreshRSS_Feed::PRIORITY_HIDDEN], + ['c:2', FreshRSS_Feed::PRIORITY_CATEGORY], + ['f:1 c:2', FreshRSS_Feed::PRIORITY_HIDDEN], + ['-f:1', null], + ['-c:2', null], + ]; + } }