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
This commit is contained in:
Alexandre Alapetite
2026-03-21 17:15:07 +01:00
committed by GitHub
parent b19060aa1f
commit 1b90c40fd6
8 changed files with 123 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<list<int>|'*'>|null */
public function getLabelIds(): array|null {
return $this->label_ids;

View File

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

View File

@@ -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<list<string|int|null>> */
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],
];
}
}