Przeglądaj źródła

Filter global view feed list by state and search (#9132)

* Filter global feed list by favorites

Fixes #4564.

The global view now filters its category/feed list when the Favorites filter is active, using the same article state and search criteria used when a feed is opened. Empty categories are omitted rather than showing feeds that would open to no matching articles.

* Fix favorite feed state query typing

* Rework logic, including searches

After reflection, I have revised the logic so that in the global view, the categories and feeds shown are always filtered by the search query (if any) and the states (if consequential):

<img width="2012" height="846" alt="image" src="https://github.com/user-attachments/assets/1fd693fa-9184-4601-85ed-2579e3b7299c" />


This makes the global view more useful and distinct in my opinion (this was otherwise not a view I was using myself, but now it will be).

Note that this behaviour would be challenging to port to the normal view for performance reasons.

* Show the matching counts in the global view UI instead of number of unreads

* Spelling

* Fix case of STATE_ALL

* Minor: more explicit logic

---------

Co-authored-by: Gerard Alvear <gerard.alvear@logiqd.me>
Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Gerard Alvear Porras 12 godzin temu
rodzic
commit
36d973a3bd

+ 5 - 0
app/Controllers/indexController.php

@@ -224,6 +224,11 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		}
 
 		$this->view->categories = FreshRSS_Context::categories();
+		// Filter feed list when searching or when a restrictive state filter is active
+		if (FreshRSS_Context::$search->toString() !== '' || FreshRSS_Context::isStateConsequential(FreshRSS_Context::$state)) {
+			$entryDAO = FreshRSS_Factory::createEntryDao();
+			$this->view->feedIdsMatching = $entryDAO->listFeedIdsMatching(FreshRSS_Context::$state, FreshRSS_Context::$search);
+		}
 
 		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
 		$title = _t('index.feed.title_global');

+ 14 - 3
app/Models/Context.php

@@ -317,10 +317,21 @@ final class FreshRSS_Context {
 	}
 
 	/**
-	 * Returns if the current state includes $state parameter.
+	 * Checks whether the $state parameter is consequential, i.e. has any effect
+	 * (not zero, and not just including opposite states).
 	 */
-	public static function isStateEnabled(int $state): int {
-		return self::$state & $state;
+	public static function isStateConsequential(int $state): bool {
+		return (($state & FreshRSS_Entry::STATE_READ) xor ($state & FreshRSS_Entry::STATE_NOT_READ)) ||
+			(($state & FreshRSS_Entry::STATE_FAVORITE) xor ($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) ||
+			($state & FreshRSS_Entry::STATE_OR_NOT_READ) ||
+			($state & FreshRSS_Entry::STATE_OR_FAVORITE);
+	}
+
+	/**
+	 * Checks whether the current state includes $state parameter.
+	 */
+	public static function isStateEnabled(int $state): bool {
+		return (self::$state & $state) !== 0;
 	}
 
 	/**

+ 26 - 0
app/Models/EntryDAO.php

@@ -1866,6 +1866,32 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return null;
 	}
 
+	/**
+	 * Get feed IDs that have entries matching the given state and search filters.
+	 * @return array<int,int> The keys are feed IDs, and the values are the number of matching entries for that feed.
+	 */
+	public function listFeedIdsMatching(int $state, ?FreshRSS_BooleanSearch $filters = null): array {
+		[$values, $search] = $this->sqlListEntriesWhere(alias: 'e.', state: $state, filters: $filters);
+		$sql = <<<SQL
+			SELECT e.id_feed, COUNT(*) AS count FROM `_entry` e
+			WHERE 1=1 {$search}
+			GROUP BY e.id_feed
+			SQL;
+		$stm = $this->pdo->prepare($sql);
+		if ($stm !== false && $stm->execute($values)) {
+			/** @var list<array{id_feed:int|string,count:int}> $res */
+			$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+			$result = [];
+			foreach ($res as $row) {
+				$result[(int)$row['id_feed']] = (int)$row['count'];
+			}
+			return $result;
+		}
+		$info = $stm === false ? $this->pdo->errorInfo() : $stm->errorInfo();
+		Minz_Log::error('SQL error ' . __METHOD__ . ' ' . json_encode($info));
+		return [];
+	}
+
 	/**
 	 * @param array<string> $guids
 	 * @return array<string,string> guid => hash

+ 5 - 0
app/Models/View.php

@@ -24,6 +24,11 @@ class FreshRSS_View extends Minz_View {
 	public ?FreshRSS_Feed $feed = null;
 	/** @var array<int,FreshRSS_Feed> where the key is the feed ID */
 	public array $feeds;
+	/**
+	 * The keys are the feed IDs that have entries matching the current state and search filters (global view).
+	 * @var array<int,int>|null
+	 */
+	public ?array $feedIdsMatching = null;
 	public int $nbUnreadTags;
 	/** @var array<int,FreshRSS_Tag> where the key is the label ID */
 	public array $tags;

+ 25 - 6
app/views/index/global.phtml

@@ -50,16 +50,33 @@
 	];
 
 	$unreadArticles = 0;
+	// When STATE_ALL is active (both read and unread), show total articles instead of only unread
+	$showTotalArticles = $this->feedIdsMatching === null && (FreshRSS_Context::$state === 0 || (
+		FreshRSS_Context::isStateEnabled(FreshRSS_Entry::STATE_READ) &&
+		FreshRSS_Context::isStateEnabled(FreshRSS_Entry::STATE_NOT_READ)));
 
 	foreach ($this->categories as $cat) {
-		$feeds = $cat->feeds();
+		$feeds = $this->feedIdsMatching === null ? $cat->feeds() :
+			array_filter($cat->feeds(), fn(FreshRSS_Feed $feed): bool => !empty($this->feedIdsMatching[$feed->id()]));
 		$url_base['params']['get'] = 'c_' . $cat->id();
 
 		if (!empty($feeds)) {
 			$unreadArticles += $cat->nbNotRead();
+			$catMatchingCount = $cat->nbNotRead();
+			if ($this->feedIdsMatching !== null) {
+				$catMatchingCount = 0;
+				foreach ($cat->feeds() as $feed) {
+					$catMatchingCount += $this->feedIdsMatching[$feed->id()] ?? 0;
+				}
+			} elseif ($showTotalArticles) {
+				$catMatchingCount = 0;
+				foreach ($cat->feeds() as $feed) {
+					$catMatchingCount += $feed->nbEntries();
+				}
+			}
 ?>
-	<div class="box category" data-unread="<?= $cat->nbNotRead() ?>">
-		<div class="box-title"><a class="title open-panel" data-unread="<?= format_number($cat->nbNotRead()) ?>"
+	<div class="box category" data-unread="<?= $catMatchingCount ?>">
+		<div class="box-title"><a class="title open-panel" data-unread="<?= format_number($catMatchingCount) ?>"
 			href="<?= Minz_Url::display($url_base) ?>"><h2><?= $cat->name() ?></h2></a></div>
 
 		<ul class="box-content scrollbar-thin">
@@ -68,7 +85,9 @@
 					if ($feed->priority() < FreshRSS_Feed::PRIORITY_FEED) {
 						continue;
 					}
-					$nb_not_read = $feed->nbNotRead();
+					$feedMatchingCount = $this->feedIdsMatching === null
+						? ($showTotalArticles ? $feed->nbEntries() : $feed->nbNotRead())
+						: ($this->feedIdsMatching[$feed->id()] ?? 0);
 
 					$error_class = '';
 					$error_title = '';
@@ -88,9 +107,9 @@
 					$url_base['params']['get'] = 'f_' . $feed->id();
 			?>
 			<li id="f_<?= $feed->id() ?>" class="item feed<?= $error_class, $empty_class, $mute_class ?>" title="<?= $error_title, $empty_title ?>"
-				data-unread="<?= $feed->nbNotRead() ?>" data-priority="<?= $feed->priority() ?>">
+				data-unread="<?= $feedMatchingCount ?>" data-priority="<?= $feed->priority() ?>">
 				<?php if (FreshRSS_Context::userConf()->show_favicons): ?><img class="favicon" src="<?= $feed->favicon() ?>" alt="✇" loading="lazy" /><?php endif; ?>
-				<a class="item-title open-panel" data-unread="<?= format_number($feed->nbNotRead()) ?>" href="<?= Minz_Url::display($url_base) ?>"><?= $feed->name() ?></a>
+				<a class="item-title open-panel" data-unread="<?= format_number($feedMatchingCount) ?>" href="<?= Minz_Url::display($url_base) ?>"><?= $feed->name() ?></a>
 			</li>
 			<?php } ?>
 		</ul>