Procházet zdrojové kódy

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
Alexandre Alapetite před 1 týdnem
rodič
revize
1b90c40fd6

+ 32 - 20
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;
 				}

+ 2 - 5
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;

+ 23 - 0
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]

+ 5 - 5
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');

+ 22 - 15
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:

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

+ 6 - 6
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;
 			}

+ 18 - 0
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<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],
+		];
+	}
 }