Browse Source

Add filter actions (auto mark read) at category and global levels (#5942)

* Add filter actions (auto mark read) at category level
fix https://github.com/FreshRSS/FreshRSS/issues/3497

* Add filter actions (auto mark read) at global level
fix https://github.com/FreshRSS/FreshRSS/issues/2788

* Fix feed category ID

* Minor comment
Alexandre Alapetite 2 years ago
parent
commit
6bb45a8726

+ 57 - 22
app/Controllers/categoryController.php

@@ -80,45 +80,80 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController {
 
 	/**
 	 * This action updates the given category.
-	 * @todo Check whether this function is used at all
-	 * @see FreshRSS_subscription_Controller::categoryAction() (consider merging)
-	 *
-	 * Request parameters are:
-	 *   - id
-	 *   - name
 	 */
 	public function updateAction(): void {
-		$catDAO = FreshRSS_Factory::createCategoryDao();
-		$url_redirect = ['c' => 'subscription', 'a' => 'index'];
+		if (Minz_Request::paramBoolean('ajax')) {
+			$this->view->_layout(null);
+		}
+
+		$categoryDAO = FreshRSS_Factory::createCategoryDao();
+
+		$id = Minz_Request::paramInt('id');
+		$category = $categoryDAO->searchById($id);
+		if ($id === 0 || null === $category) {
+			Minz_Error::error(404);
+			return;
+		}
+		$this->view->category = $category;
+
+		FreshRSS_View::prependTitle($category->name() . ' · ' . _t('sub.title') . ' · ');
 
 		if (Minz_Request::isPost()) {
-			invalidateHttpCache();
+			$category->_filtersAction('read', Minz_Request::paramTextToArray('filteractions_read'));
 
-			$id = Minz_Request::paramInt('id');
-			$name = Minz_Request::paramString('name');
-			if (strlen($name) <= 0) {
-				Minz_Request::bad(_t('feedback.sub.category.no_name'), $url_redirect);
+			if (Minz_Request::paramBoolean('use_default_purge_options')) {
+				$category->_attributes('archiving', null);
+			} else {
+				if (!Minz_Request::paramBoolean('enable_keep_max')) {
+					$keepMax = false;
+				} elseif (($keepMax = Minz_Request::paramInt('keep_max')) !== 0) {
+					$keepMax = FreshRSS_Feed::ARCHIVING_RETENTION_COUNT_LIMIT;
+				}
+				if (Minz_Request::paramBoolean('enable_keep_period')) {
+					$keepPeriod = FreshRSS_Feed::ARCHIVING_RETENTION_PERIOD;
+					if (is_numeric(Minz_Request::paramString('keep_period_count')) && preg_match('/^PT?1[YMWDH]$/', Minz_Request::paramString('keep_period_unit'))) {
+						$keepPeriod = str_replace('1', Minz_Request::paramString('keep_period_count'), Minz_Request::paramString('keep_period_unit'));
+					}
+				} else {
+					$keepPeriod = false;
+				}
+				$category->_attributes('archiving', [
+					'keep_period' => $keepPeriod,
+					'keep_max' => $keepMax,
+					'keep_min' => Minz_Request::paramInt('keep_min'),
+					'keep_favourites' => Minz_Request::paramBoolean('keep_favourites'),
+					'keep_labels' => Minz_Request::paramBoolean('keep_labels'),
+					'keep_unreads' => Minz_Request::paramBoolean('keep_unreads'),
+				]);
 			}
 
-			$cat = $catDAO->searchById($id);
-			if ($cat === null) {
-				Minz_Request::bad(_t('feedback.sub.category.not_exist'), $url_redirect);
+			$position = Minz_Request::paramInt('position') ?: null;
+			$category->_attributes('position', $position);
+
+			$opml_url = checkUrl(Minz_Request::paramString('opml_url'));
+			if ($opml_url != '') {
+				$category->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
+				$category->_attributes('opml_url', $opml_url);
+			} else {
+				$category->_kind(FreshRSS_Category::KIND_NORMAL);
+				$category->_attributes('opml_url', null);
 			}
 
 			$values = [
-				'name' => $cat->name(),
-				'kind' => $cat->kind(),
-				'attributes' => $cat->attributes(),
+				'kind' => $category->kind(),
+				'name' => Minz_Request::paramString('name'),
+				'attributes' => $category->attributes(),
 			];
 
-			if ($catDAO->updateCategory($id, $values)) {
+			invalidateHttpCache();
+
+			$url_redirect = ['c' => 'subscription', 'params' => ['id' => $id, 'type' => 'category']];
+			if (false !== $categoryDAO->updateCategory($id, $values)) {
 				Minz_Request::good(_t('feedback.sub.category.updated'), $url_redirect);
 			} else {
 				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
 			}
 		}
-
-		Minz_Request::forward($url_redirect, true);
 	}
 
 	/**

+ 1 - 0
app/Controllers/configureController.php

@@ -142,6 +142,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 				'site' => Minz_Request::paramBoolean('mark_open_site'),
 				'focus' => Minz_Request::paramBoolean('mark_focus'),
 			];
+			FreshRSS_Context::$user_conf->_filtersAction('read', Minz_Request::paramTextToArray('filteractions_read'));
 			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();
 

+ 13 - 0
app/Controllers/feedController.php

@@ -361,6 +361,19 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 			}
 		} else {
 			$feeds = $feedDAO->listFeedsOrderUpdate(-1);
+
+			// Hydrate category for each feed to avoid that each feed has to make an SQL request
+			$categories = [];
+			$catDAO = FreshRSS_Factory::createCategoryDao();
+			foreach ($catDAO->listCategories(false, false) as $category) {
+				$categories[$category->id()] = $category;
+			}
+			foreach ($feeds as $feed) {
+				$category = $categories[$feed->categoryId()] ?? null;
+				if ($category !== null) {
+					$feed->_category($category);
+				}
+			}
 		}
 
 		// WebSub (PubSubHubbub) support

+ 1 - 74
app/Controllers/subscriptionController.php

@@ -197,7 +197,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 				]);
 			}
 
-			$feed->_filtersAction('read', preg_split('/[\n\r]+/', Minz_Request::paramString('filteractions_read')) ?: []);
+			$feed->_filtersAction('read', Minz_Request::paramTextToArray('filteractions_read'));
 
 			$feed->_kind(Minz_Request::paramInt('feed_kind') ?: FreshRSS_Feed::KIND_RSS);
 			if ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH || $feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) {
@@ -279,79 +279,6 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 		}
 	}
 
-	public function categoryAction(): void {
-		if (Minz_Request::paramBoolean('ajax')) {
-			$this->view->_layout(null);
-		}
-
-		$categoryDAO = FreshRSS_Factory::createCategoryDao();
-
-		$id = Minz_Request::paramInt('id');
-		$category = $categoryDAO->searchById($id);
-		if ($id === 0 || null === $category) {
-			Minz_Error::error(404);
-			return;
-		}
-		$this->view->category = $category;
-
-		FreshRSS_View::prependTitle($category->name() . ' · ' . _t('sub.title') . ' · ');
-
-		if (Minz_Request::isPost()) {
-			if (Minz_Request::paramBoolean('use_default_purge_options')) {
-				$category->_attributes('archiving', null);
-			} else {
-				if (!Minz_Request::paramBoolean('enable_keep_max')) {
-					$keepMax = false;
-				} elseif (($keepMax = Minz_Request::paramInt('keep_max')) !== 0) {
-					$keepMax = FreshRSS_Feed::ARCHIVING_RETENTION_COUNT_LIMIT;
-				}
-				if (Minz_Request::paramBoolean('enable_keep_period')) {
-					$keepPeriod = FreshRSS_Feed::ARCHIVING_RETENTION_PERIOD;
-					if (is_numeric(Minz_Request::paramString('keep_period_count')) && preg_match('/^PT?1[YMWDH]$/', Minz_Request::paramString('keep_period_unit'))) {
-						$keepPeriod = str_replace('1', Minz_Request::paramString('keep_period_count'), Minz_Request::paramString('keep_period_unit'));
-					}
-				} else {
-					$keepPeriod = false;
-				}
-				$category->_attributes('archiving', [
-					'keep_period' => $keepPeriod,
-					'keep_max' => $keepMax,
-					'keep_min' => Minz_Request::paramInt('keep_min'),
-					'keep_favourites' => Minz_Request::paramBoolean('keep_favourites'),
-					'keep_labels' => Minz_Request::paramBoolean('keep_labels'),
-					'keep_unreads' => Minz_Request::paramBoolean('keep_unreads'),
-				]);
-			}
-
-			$position = Minz_Request::paramInt('position') ?: null;
-			$category->_attributes('position', $position);
-
-			$opml_url = checkUrl(Minz_Request::paramString('opml_url'));
-			if ($opml_url != '') {
-				$category->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
-				$category->_attributes('opml_url', $opml_url);
-			} else {
-				$category->_kind(FreshRSS_Category::KIND_NORMAL);
-				$category->_attributes('opml_url', null);
-			}
-
-			$values = [
-				'kind' => $category->kind(),
-				'name' => Minz_Request::paramString('name'),
-				'attributes' => $category->attributes(),
-			];
-
-			invalidateHttpCache();
-
-			$url_redirect = ['c' => 'subscription', 'params' => ['id' => $id, 'type' => 'category']];
-			if (false !== $categoryDAO->updateCategory($id, $values)) {
-				Minz_Request::good(_t('feedback.sub.category.updated'), $url_redirect);
-			} else {
-				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
-			}
-		}
-	}
-
 	/**
 	 * This action displays the bookmarklet page.
 	 */

+ 40 - 0
app/Models/AttributesTrait.php

@@ -0,0 +1,40 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Logic to work with (JSON) attributes (for entries, feeds, categories, tags...).
+ */
+trait FreshRSS_AttributesTrait {
+	/**
+	 * @var array<string,mixed>
+	 */
+	private array $attributes = [];
+
+	/**
+	 * @phpstan-return ($key is non-empty-string ? mixed : array<string,mixed>)
+	 * @return array<string,mixed>|mixed|null
+	 */
+	public function attributes(string $key = '') {
+		if ($key === '') {
+			return $this->attributes;
+		} else {
+			return $this->attributes[$key] ?? null;
+		}
+	}
+
+	/** @param string|array<mixed>|bool|int|null $value Value, not HTML-encoded */
+	public function _attributes(string $key, $value = null): void {
+		if ($key == '') {
+			if (is_string($value)) {
+				$value = json_decode($value, true);
+			}
+			if (is_array($value)) {
+				$this->attributes = $value;
+			}
+		} elseif ($value === null) {
+			unset($this->attributes[$key]);
+		} else {
+			$this->attributes[$key] = $value;
+		}
+	}
+}

+ 4 - 31
app/Models/Category.php

@@ -2,6 +2,7 @@
 declare(strict_types=1);
 
 class FreshRSS_Category extends Minz_Model {
+	use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;
 
 	/**
 	 * Normal
@@ -22,21 +23,21 @@ class FreshRSS_Category extends Minz_Model {
 	private ?array $feeds = null;
 	/** @var bool|int */
 	private $hasFeedsWithError = false;
-	/** @var array<string,mixed> */
-	private array $attributes = [];
 	private int $lastUpdate = 0;
 	private bool $error = false;
 
 	/**
 	 * @param array<FreshRSS_Feed>|null $feeds
 	 */
-	public function __construct(string $name = '', ?array $feeds = null) {
+	public function __construct(string $name = '', int $id = 0, ?array $feeds = null) {
+		$this->_id($id);
 		$this->_name($name);
 		if ($feeds !== null) {
 			$this->_feeds($feeds);
 			$this->nbFeeds = 0;
 			$this->nbNotRead = 0;
 			foreach ($feeds as $feed) {
+				$feed->_category($this);
 				$this->nbFeeds++;
 				$this->nbNotRead += $feed->nbNotRead();
 				$this->hasFeedsWithError |= $feed->inError();
@@ -120,18 +121,6 @@ class FreshRSS_Category extends Minz_Model {
 		return (bool)($this->hasFeedsWithError);
 	}
 
-	/**
-	 * @phpstan-return ($key is non-empty-string ? mixed : array<string,mixed>)
-	 * @return array<string,mixed>|mixed|null
-	 */
-	public function attributes(string $key = '') {
-		if ($key === '') {
-			return $this->attributes;
-		} else {
-			return $this->attributes[$key] ?? null;
-		}
-	}
-
 	public function _id(int $id): void {
 		$this->id = $id;
 		if ($id === FreshRSS_CategoryDAO::DEFAULTCATEGORYID) {
@@ -169,22 +158,6 @@ class FreshRSS_Category extends Minz_Model {
 		$this->sortFeeds();
 	}
 
-	/** @param string|array<mixed>|bool|int|null $value Value, not HTML-encoded */
-	public function _attributes(string $key, $value): void {
-		if ('' === $key) {
-			if (is_string($value)) {
-				$value = json_decode($value, true);
-			}
-			if (is_array($value)) {
-				$this->attributes = $value;
-			}
-		} elseif (null === $value) {
-			unset($this->attributes[$key]);
-		} else {
-			$this->attributes[$key] = $value;
-		}
-	}
-
 	/**
 	 * @param array<string> $attributes
 	 * @throws FreshRSS_Context_Exception

+ 5 - 6
app/Models/CategoryDAO.php

@@ -351,8 +351,7 @@ SQL;
 		$def_cat = $this->searchById(self::DEFAULTCATEGORYID);
 
 		if ($def_cat == null) {
-			$cat = new FreshRSS_Category(_t('gen.short.default_category'));
-			$cat->_id(self::DEFAULTCATEGORYID);
+			$cat = new FreshRSS_Category(_t('gen.short.default_category'), self::DEFAULTCATEGORYID);
 
 			$sql = 'INSERT INTO `_category`(id, name) VALUES(?, ?)';
 			if ($this->pdo->dbType() === 'pgsql') {
@@ -441,9 +440,9 @@ SQL;
 				// End of the current category, we add it to the $list
 				$cat = new FreshRSS_Category(
 					$previousLine['c_name'],
+					$previousLine['c_id'],
 					$feedDao::daoToFeed($feedsDao, $previousLine['c_id'])
 				);
-				$cat->_id($previousLine['c_id']);
 				$cat->_kind($previousLine['c_kind']);
 				$cat->_attributes('', $previousLine['c_attributes'] ?? '[]');
 				$list[(int)$previousLine['c_id']] = $cat;
@@ -459,9 +458,9 @@ SQL;
 		if ($previousLine != null) {
 			$cat = new FreshRSS_Category(
 				$previousLine['c_name'],
+				$previousLine['c_id'],
 				$feedDao::daoToFeed($feedsDao, $previousLine['c_id'])
 			);
-			$cat->_id($previousLine['c_id']);
 			$cat->_kind($previousLine['c_kind']);
 			$cat->_lastUpdate($previousLine['c_last_update'] ?? 0);
 			$cat->_error($previousLine['c_error'] ?? 0);
@@ -482,9 +481,9 @@ SQL;
 		foreach ($listDAO as $dao) {
 			FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'kind', 'lastUpdate', 'error']);
 			$cat = new FreshRSS_Category(
-				$dao['name']
+				$dao['name'],
+				$dao['id']
 			);
-			$cat->_id($dao['id']);
 			$cat->_kind($dao['kind']);
 			$cat->_lastUpdate($dao['lastUpdate'] ?? 0);
 			$cat->_error($dao['error'] ?? 0);

+ 19 - 61
app/Models/Entry.php

@@ -2,6 +2,8 @@
 declare(strict_types=1);
 
 class FreshRSS_Entry extends Minz_Model {
+	use FreshRSS_AttributesTrait;
+
 	public const STATE_READ = 1;
 	public const STATE_NOT_READ = 2;
 	public const STATE_ALL = 3;
@@ -26,8 +28,6 @@ class FreshRSS_Entry extends Minz_Model {
 	private ?FreshRSS_Feed $feed;
 	/** @var array<string> */
 	private array $tags = [];
-	/** @var array<string,mixed> */
-	private array $attributes = [];
 
 	/**
 	 * @param int|string $pubdate
@@ -396,34 +396,6 @@ HTML;
 		}
 	}
 
-	/**
-	 * @phpstan-return ($key is non-empty-string ? mixed : array<string,mixed>)
-	 * @return array<string,mixed>|mixed|null
-	 */
-	public function attributes(string $key = '') {
-		if ($key === '') {
-			return $this->attributes;
-		} else {
-			return $this->attributes[$key] ?? null;
-		}
-	}
-
-	/** @param string|array<mixed>|bool|int|null $value Value, not HTML-encoded */
-	public function _attributes(string $key, $value): void {
-		if ($key == '') {
-			if (is_string($value)) {
-				$value = json_decode($value, true);
-			}
-			if (is_array($value)) {
-				$this->attributes = $value;
-			}
-		} elseif ($value === null) {
-			unset($this->attributes[$key]);
-		} else {
-			$this->attributes[$key] = $value;
-		}
-	}
-
 	public function hash(): string {
 		if ($this->hash == '') {
 			//Do not include $this->date because it may be automatically generated when lacking
@@ -660,40 +632,26 @@ HTML;
 
 	/** @param array<string,bool> $titlesAsRead */
 	public function applyFilterActions(array $titlesAsRead = []): void {
-		if ($this->feed != null) {
-			if (!$this->isRead()) {
-				if ($this->feed->attributes('read_upon_reception') ||
-					($this->feed->attributes('read_upon_reception') === null && FreshRSS_Context::$user_conf->mark_when['reception'])) {
-					$this->_isRead(true);
-					Minz_ExtensionManager::callHook('entry_auto_read', $this, 'upon_reception');
-				}
-				if (!empty($titlesAsRead[$this->title()])) {
-					Minz_Log::debug('Mark title as read: ' . $this->title());
-					$this->_isRead(true);
-					Minz_ExtensionManager::callHook('entry_auto_read', $this, 'same_title_in_feed');
-				}
+		if ($this->feed === null) {
+			return;
+		}
+		if (!$this->isRead()) {
+			if ($this->feed->attributes('read_upon_reception') ||
+				($this->feed->attributes('read_upon_reception') === null && FreshRSS_Context::$user_conf->mark_when['reception'])) {
+				$this->_isRead(true);
+				Minz_ExtensionManager::callHook('entry_auto_read', $this, 'upon_reception');
 			}
-			foreach ($this->feed->filterActions() as $filterAction) {
-				if ($this->matches($filterAction->booleanSearch())) {
-					foreach ($filterAction->actions() as $action) {
-						switch ($action) {
-							case 'read':
-								if (!$this->isRead()) {
-									$this->_isRead(true);
-									Minz_ExtensionManager::callHook('entry_auto_read', $this, 'filter');
-								}
-								break;
-							case 'star':
-								$this->_isFavorite(true);
-								break;
-							case 'label':
-								//TODO: Implement more actions
-								break;
-						}
-					}
-				}
+			if (!empty($titlesAsRead[$this->title()])) {
+				Minz_Log::debug('Mark title as read: ' . $this->title());
+				$this->_isRead(true);
+				Minz_ExtensionManager::callHook('entry_auto_read', $this, 'same_title_in_feed');
 			}
 		}
+		FreshRSS_Context::$user_conf->applyFilterActions($this);
+		if ($this->feed->category() !== null) {
+			$this->feed->category()->applyFilterActions($this);
+		}
+		$this->feed->applyFilterActions($this);
 	}
 
 	public function isDay(int $day, int $today): bool {

+ 6 - 147
app/Models/Feed.php

@@ -2,6 +2,7 @@
 declare(strict_types=1);
 
 class FreshRSS_Feed extends Minz_Model {
+	use FreshRSS_AttributesTrait, FreshRSS_FilterActionsTrait;
 
 	/**
 	 * Normal RSS or Atom feed
@@ -42,7 +43,7 @@ class FreshRSS_Feed extends Minz_Model {
 	private int $id = 0;
 	private string $url = '';
 	private int $kind = 0;
-	private int $categoryId = 1;
+	private int $categoryId = 0;
 	private ?FreshRSS_Category $category;
 	private int $nbEntries = -1;
 	private int $nbNotRead = -1;
@@ -55,15 +56,11 @@ class FreshRSS_Feed extends Minz_Model {
 	private string $httpAuth = '';
 	private bool $error = false;
 	private int $ttl = self::TTL_DEFAULT;
-	/** @var array<string,mixed> */
-	private array $attributes = [];
 	private bool $mute = false;
 	private string $hash = '';
 	private string $lockPath = '';
 	private string $hubUrl = '';
 	private string $selfUrl = '';
-	/** @var array<FreshRSS_FilterAction>|null $filterActions */
-	private ?array $filterActions = null;
 
 	public function __construct(string $url, bool $validate = true) {
 		if ($validate) {
@@ -105,7 +102,7 @@ class FreshRSS_Feed extends Minz_Model {
 	}
 
 	public function category(): ?FreshRSS_Category {
-		if ($this->category === null) {
+		if ($this->category === null && $this->categoryId > 0) {
 			$catDAO = FreshRSS_Factory::createCategoryDao();
 			$this->category = $catDAO->searchById($this->categoryId);
 		}
@@ -113,6 +110,9 @@ class FreshRSS_Feed extends Minz_Model {
 	}
 
 	public function categoryId(): int {
+		if ($this->category !== null) {
+			return $this->category->id() ?: $this->categoryId;
+		}
 		return $this->categoryId;
 	}
 
@@ -186,18 +186,6 @@ class FreshRSS_Feed extends Minz_Model {
 		return $this->ttl;
 	}
 
-	/**
-	 * @phpstan-return ($key is non-empty-string ? mixed : array<string,mixed>)
-	 * @return array<string,mixed>|mixed|null
-	 */
-	public function attributes(string $key = '') {
-		if ($key === '') {
-			return $this->attributes;
-		} else {
-			return $this->attributes[$key] ?? null;
-		}
-	}
-
 	public function mute(): bool {
 		return $this->mute;
 	}
@@ -325,22 +313,6 @@ class FreshRSS_Feed extends Minz_Model {
 		$this->mute = $value < self::TTL_DEFAULT;
 	}
 
-	/** @param string|array<mixed>|bool|int|null $value Value, not HTML-encoded */
-	public function _attributes(string $key, $value): void {
-		if ($key == '') {
-			if (is_string($value)) {
-				$value = json_decode($value, true);
-			}
-			if (is_array($value)) {
-				$this->attributes = $value;
-			}
-		} elseif ($value === null) {
-			unset($this->attributes[$key]);
-		} else {
-			$this->attributes[$key] = $value;
-		}
-	}
-
 	public function _nbNotRead(int $value): void {
 		$this->nbNotRead = $value;
 	}
@@ -871,119 +843,6 @@ class FreshRSS_Feed extends Minz_Model {
 		return @unlink($this->lockPath);
 	}
 
-	/**
-	 * @return array<FreshRSS_FilterAction>
-	 */
-	public function filterActions(): array {
-		if (empty($this->filterActions)) {
-			$this->filterActions = [];
-			$filters = $this->attributes('filters');
-			if (is_array($filters)) {
-				foreach ($filters as $filter) {
-					$filterAction = FreshRSS_FilterAction::fromJSON($filter);
-					if ($filterAction != null) {
-						$this->filterActions[] = $filterAction;
-					}
-				}
-			}
-		}
-		return $this->filterActions;
-	}
-
-	/**
-	 * @param array<FreshRSS_FilterAction>|null $filterActions
-	 */
-	private function _filterActions(?array $filterActions): void {
-		$this->filterActions = $filterActions;
-		if (is_array($this->filterActions) && !empty($this->filterActions)) {
-			$this->_attributes('filters', array_map(static function (?FreshRSS_FilterAction $af) {
-					return $af == null ? null : $af->toJSON();
-				}, $this->filterActions));
-		} else {
-			$this->_attributes('filters', null);
-		}
-	}
-
-	/** @return array<FreshRSS_BooleanSearch> */
-	public function filtersAction(string $action): array {
-		$action = trim($action);
-		if ($action == '') {
-			return [];
-		}
-		$filters = [];
-		$filterActions = $this->filterActions();
-		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
-			$filterAction = $filterActions[$i];
-			if ($filterAction != null && $filterAction->booleanSearch() != null &&
-				$filterAction->actions() != null && in_array($action, $filterAction->actions(), true)) {
-				$filters[] = $filterAction->booleanSearch();
-			}
-		}
-		return $filters;
-	}
-
-	/**
-	 * @param array<string> $filters
-	 */
-	public function _filtersAction(string $action, array $filters): void {
-		$action = trim($action);
-		if ($action == '') {
-			return;
-		}
-		$filters = array_unique(array_map('trim', $filters));
-		$filterActions = $this->filterActions();
-
-		//Check existing filters
-		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
-			$filterAction = $filterActions[$i];
-			if ($filterAction == null || !is_array($filterAction->actions()) ||
-				$filterAction->booleanSearch() == null || trim($filterAction->booleanSearch()->getRawInput()) == '') {
-				array_splice($filterActions, $i, 1);
-				continue;
-			}
-			$actions = $filterAction->actions();
-			//Remove existing rules with same action
-			for ($j = count($actions) - 1; $j >= 0; $j--) {
-				if ($actions[$j] === $action) {
-					array_splice($actions, $j, 1);
-				}
-			}
-			//Update existing filter with new action
-			for ($k = count($filters) - 1; $k >= 0; $k --) {
-				$filter = $filters[$k];
-				if ($filter === $filterAction->booleanSearch()->getRawInput()) {
-					$actions[] = $action;
-					array_splice($filters, $k, 1);
-				}
-			}
-			//Save result
-			if (empty($actions)) {
-				array_splice($filterActions, $i, 1);
-			} else {
-				$filterAction->_actions($actions);
-			}
-		}
-
-		//Add new filters
-		for ($k = count($filters) - 1; $k >= 0; $k --) {
-			$filter = $filters[$k];
-			if ($filter != '') {
-				$filterAction = FreshRSS_FilterAction::fromJSON([
-					'search' => $filter,
-					'actions' => [$action],
-				]);
-				if ($filterAction != null) {
-					$filterActions[] = $filterAction;
-				}
-			}
-		}
-
-		if (empty($filterActions)) {
-			$filterActions = null;
-		}
-		$this->_filterActions($filterActions);
-	}
-
 	//<WebSub>
 
 	public function pubSubHubbubEnabled(): bool {

+ 1 - 1
app/Models/FeedDAO.php

@@ -373,7 +373,7 @@ SQL;
 	 * @return array<FreshRSS_Feed>
 	 */
 	public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0): array {
-		$sql = 'SELECT id, url, kind, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, ttl, attributes, `cache_nbEntries`, `cache_nbUnreads` '
+		$sql = 'SELECT id, url, kind, category, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, ttl, attributes, `cache_nbEntries`, `cache_nbUnreads` '
 			. 'FROM `_feed` '
 			. ($defaultCacheDuration < 0 ? '' : 'WHERE ttl >= ' . FreshRSS_Feed::TTL_DEFAULT
 				. ' AND `lastUpdate` < (' . (time() + 60)

+ 147 - 0
app/Models/FilterActionsTrait.php

@@ -0,0 +1,147 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Logic to apply filter actions (for feeds, categories, user configuration...).
+ */
+trait FreshRSS_FilterActionsTrait {
+
+	/** @var array<FreshRSS_FilterAction>|null $filterActions */
+	private ?array $filterActions = null;
+
+	/**
+	 * @return array<FreshRSS_FilterAction>
+	 */
+	private function filterActions(): array {
+		if (empty($this->filterActions)) {
+			$this->filterActions = [];
+			$filters = $this->attributes('filters');
+			if (is_array($filters)) {
+				foreach ($filters as $filter) {
+					$filterAction = FreshRSS_FilterAction::fromJSON($filter);
+					if ($filterAction != null) {
+						$this->filterActions[] = $filterAction;
+					}
+				}
+			}
+		}
+		return $this->filterActions;
+	}
+
+	/**
+	 * @param array<FreshRSS_FilterAction>|null $filterActions
+	 */
+	private function _filterActions(?array $filterActions): void {
+		$this->filterActions = $filterActions;
+		if (is_array($this->filterActions) && !empty($this->filterActions)) {
+			$this->_attributes('filters', array_map(static function (?FreshRSS_FilterAction $af) {
+					return $af == null ? null : $af->toJSON();
+				}, $this->filterActions));
+		} else {
+			$this->_attributes('filters', null);
+		}
+	}
+
+	/** @return array<FreshRSS_BooleanSearch> */
+	public function filtersAction(string $action): array {
+		$action = trim($action);
+		if ($action == '') {
+			return [];
+		}
+		$filters = [];
+		$filterActions = $this->filterActions();
+		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
+			$filterAction = $filterActions[$i];
+			if ($filterAction != null && $filterAction->booleanSearch() != null &&
+				$filterAction->actions() != null && in_array($action, $filterAction->actions(), true)) {
+				$filters[] = $filterAction->booleanSearch();
+			}
+		}
+		return $filters;
+	}
+
+	/**
+	 * @param array<string> $filters
+	 */
+	public function _filtersAction(string $action, array $filters): void {
+		$action = trim($action);
+		if ($action === '') {
+			return;
+		}
+		$filters = array_unique(array_map('trim', $filters), SORT_STRING);
+		$filterActions = $this->filterActions();
+
+		//Check existing filters
+		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
+			$filterAction = $filterActions[$i];
+			if ($filterAction == null || !is_array($filterAction->actions()) ||
+				$filterAction->booleanSearch() == null || trim($filterAction->booleanSearch()->getRawInput()) == '') {
+				array_splice($filterActions, $i, 1);
+				continue;
+			}
+			$actions = $filterAction->actions();
+			//Remove existing rules with same action
+			for ($j = count($actions) - 1; $j >= 0; $j--) {
+				if ($actions[$j] === $action) {
+					array_splice($actions, $j, 1);
+				}
+			}
+			//Update existing filter with new action
+			for ($k = count($filters) - 1; $k >= 0; $k --) {
+				$filter = $filters[$k];
+				if ($filter === $filterAction->booleanSearch()->getRawInput()) {
+					$actions[] = $action;
+					array_splice($filters, $k, 1);
+				}
+			}
+			//Save result
+			if (empty($actions)) {
+				array_splice($filterActions, $i, 1);
+			} else {
+				$filterAction->_actions($actions);
+			}
+		}
+
+		//Add new filters
+		for ($k = count($filters) - 1; $k >= 0; $k --) {
+			$filter = $filters[$k];
+			if ($filter != '') {
+				$filterAction = FreshRSS_FilterAction::fromJSON([
+					'search' => $filter,
+					'actions' => [$action],
+				]);
+				if ($filterAction != null) {
+					$filterActions[] = $filterAction;
+				}
+			}
+		}
+
+		if (empty($filterActions)) {
+			$filterActions = null;
+		}
+		$this->_filterActions($filterActions);
+	}
+
+	public function applyFilterActions(FreshRSS_Entry $entry): void {
+		foreach ($this->filterActions() as $filterAction) {
+			if ($entry->matches($filterAction->booleanSearch())) {
+				foreach ($filterAction->actions() as $action) {
+					switch ($action) {
+						case 'read':
+							if (!$entry->isRead()) {
+								$entry->_isRead(true);
+								Minz_ExtensionManager::callHook('entry_auto_read', $entry, 'filter');
+							}
+							break;
+						case 'star':
+							$entry->_isFavorite(true);
+							break;
+						case 'label':
+							//TODO: Implement more actions
+							break;
+					}
+				}
+			}
+		}
+	}
+}

+ 1 - 32
app/Models/Tag.php

@@ -2,13 +2,10 @@
 declare(strict_types=1);
 
 class FreshRSS_Tag extends Minz_Model {
+	use FreshRSS_AttributesTrait;
 
 	private int $id = 0;
 	private string $name;
-	/**
-	 * @var array<string,mixed>
-	 */
-	private array $attributes = [];
 	private int $nbEntries = -1;
 	private int $nbUnread = -1;
 
@@ -35,34 +32,6 @@ class FreshRSS_Tag extends Minz_Model {
 		$this->name = trim($value);
 	}
 
-	/**
-	 * @phpstan-return ($key is non-empty-string ? mixed : array<string,mixed>)
-	 * @return array<string,mixed>|mixed|null
-	 */
-	public function attributes(string $key = '') {
-		if ($key === '') {
-			return $this->attributes;
-		} else {
-			return $this->attributes[$key] ?? null;
-		}
-	}
-
-	/** @param string|array<mixed>|bool|int|null $value Value, not HTML-encoded */
-	public function _attributes(string $key, $value = null): void {
-		if ($key == '') {
-			if (is_string($value)) {
-				$value = json_decode($value, true);
-			}
-			if (is_array($value)) {
-				$this->attributes = $value;
-			}
-		} elseif ($value === null) {
-			unset($this->attributes[$key]);
-		} else {
-			$this->attributes[$key] = $value;
-		}
-	}
-
 	public function nbEntries(): int {
 		if ($this->nbEntries < 0) {
 			$tagDAO = FreshRSS_Factory::createTagDao();

+ 21 - 0
app/Models/UserConfiguration.php

@@ -72,10 +72,31 @@ declare(strict_types=1);
  * @property array<string,mixed> $volatile
  */
 final class FreshRSS_UserConfiguration extends Minz_Configuration {
+	use FreshRSS_FilterActionsTrait;
 
 	/** @throws Minz_ConfigurationNamespaceException */
 	public static function init(string $config_filename, ?string $default_filename = null): FreshRSS_UserConfiguration {
 		parent::register('user', $config_filename, $default_filename);
 		return parent::get('user');
 	}
+
+	/**
+	 * @phpstan-return ($key is non-empty-string ? mixed : array<string,mixed>)
+	 * @return array<string,mixed>|mixed|null
+	 */
+	public function attributes(string $key = '') {
+		if ($key === '') {
+			return [];	// Not implemented for user configuration
+		} else {
+			return parent::param($key, null);
+		}
+	}
+
+	/** @param string|array<mixed>|bool|int|null $value Value, not HTML-encoded */
+	public function _attributes(string $key, $value = null): void {
+		if ($key == '') {
+			return;	// Not implemented for user configuration
+		}
+		parent::_param($key, $value);
+	}
 }

+ 1 - 1
app/Services/ImportService.php

@@ -179,7 +179,7 @@ class FreshRSS_Import_Service {
 			if (isset($feed_elt['frss:filtersActionRead'])) {
 				$feed->_filtersAction(
 					'read',
-					preg_split('/[\n\r]+/', $feed_elt['frss:filtersActionRead']) ?: []
+					preg_split('/\R/', $feed_elt['frss:filtersActionRead']) ?: []
 				);
 			}
 

+ 0 - 0
app/views/subscription/category.phtml → app/views/category/update.phtml


+ 15 - 0
app/views/configure/reading.phtml

@@ -345,6 +345,21 @@
 			</div>
 		</fieldset>
 
+		<fieldset>
+			<legend><?= _t('sub.feed.filteractions') ?></legend>
+			<div class="form-group">
+				<label class="group-name" for="filteractions_read"><?= _t('conf.reading.read.when') ?></label>
+				<div class="group-controls">
+					<textarea name="filteractions_read" id="filteractions_read" class="w100"><?php
+						foreach (FreshRSS_Context::$user_conf->filtersAction('read') as $filterRead) {
+							echo $filterRead->getRawInput(), PHP_EOL;
+						}
+					?></textarea>
+					<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>
+				</div>
+			</div>
+		</fieldset>
+
 		<fieldset>
 			<legend><?= _t('conf.reading.headline.misc') ?></legend>
 			<div class="form-group">

+ 21 - 1
app/views/helpers/category/update.phtml

@@ -12,7 +12,7 @@
 		<a href="<?= _url('index', 'index', 'get', 'c_' . $this->category->id()) ?>"><?= _i('link') ?> <?= _t('gen.action.filter') ?></a>
 	</div>
 
-	<form method="post" action="<?= _url('subscription', 'category', 'id', $this->category->id(), '#', 'slider') ?>" autocomplete="off">
+	<form method="post" action="<?= _url('category', 'update', 'id', $this->category->id(), '#', 'slider') ?>" autocomplete="off">
 		<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
 
 		<legend><?= _t('sub.category.information') ?></legend>
@@ -68,6 +68,26 @@
 		</div>
 		<?php endif; ?>
 
+		<legend><?= _t('sub.feed.filteractions') ?></legend>
+		<div class="form-group">
+			<label class="group-name" for="filteractions_read"><?= _t('conf.reading.read.when') ?></label>
+			<div class="group-controls">
+				<textarea name="filteractions_read" id="filteractions_read" class="w100"><?php
+					foreach ($this->category->filtersAction('read') as $filterRead) {
+						echo $filterRead->getRawInput(), PHP_EOL;
+					}
+				?></textarea>
+				<p class="help"><?= _i('help') ?> <?= _t('sub.feed.filteractions.help') ?></p>
+			</div>
+		</div>
+
+		<div class="form-group form-actions">
+			<div class="group-controls">
+				<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
+				<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
+			</div>
+		</div>
+
 		<legend><?= _t('sub.category.archiving') ?></legend>
 		<?php
 			$archiving = $this->category->attributes('archiving');

+ 1 - 1
app/views/subscription/index.phtml

@@ -34,7 +34,7 @@
 		?>
 		<div class="box">
 			<div class="box-title">
-				<a class="configure open-slider" href="<?= _url('subscription', 'category', 'id', $cat->id()) ?>" data-cat-position="<?= $cat->attributes('position') ?>"><?= _i('configure') ?></a>
+				<a class="configure open-slider" href="<?= _url('category', 'update', 'id', $cat->id()) ?>" data-cat-position="<?= $cat->attributes('position') ?>"><?= _i('configure') ?></a>
 				<h2><?= $cat->name() ?><?php if ($cat->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { echo " " . _i('opml-dyn'); } ?></h2>
 			</div>
 			<ul class="box-content drop-zone scrollbar-thin" dropzone="move" data-cat-id="<?= $cat->id() ?>">

+ 1 - 0
config-user.default.php

@@ -64,6 +64,7 @@ return array (
 		'focus' => false,
 		'site' => true,
 	),
+	'filters' => [],
 	'theme' => 'Origine',
 	'darkMode' => 'no',
 	'content_width' => 'thin',

+ 1 - 1
tests/app/Models/CategoryTest.php

@@ -51,7 +51,7 @@ class CategoryTest extends PHPUnit\Framework\TestCase {
 			->method('name')
 			->willReturn('lll');
 
-		$category = new FreshRSS_Category('test', [
+		$category = new FreshRSS_Category('test', 0, [
 			$feed_1,
 			$feed_2,
 			$feed_3,