From 6bb45a87268157aab961a6a4a728d9a9bbe043b0 Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Fri, 15 Dec 2023 23:04:29 +0100 Subject: 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 --- app/Controllers/categoryController.php | 79 ++++++++++----- app/Controllers/configureController.php | 1 + app/Controllers/feedController.php | 13 +++ app/Controllers/subscriptionController.php | 75 +------------- app/Models/AttributesTrait.php | 40 ++++++++ app/Models/Category.php | 35 +------ app/Models/CategoryDAO.php | 11 +-- app/Models/Entry.php | 80 ++++----------- app/Models/Feed.php | 153 ++--------------------------- app/Models/FeedDAO.php | 2 +- app/Models/FilterActionsTrait.php | 147 +++++++++++++++++++++++++++ app/Models/Tag.php | 33 +------ app/Models/UserConfiguration.php | 21 ++++ app/Services/ImportService.php | 2 +- app/views/category/update.phtml | 10 ++ app/views/configure/reading.phtml | 15 +++ app/views/helpers/category/update.phtml | 22 ++++- app/views/subscription/category.phtml | 10 -- app/views/subscription/index.phtml | 2 +- config-user.default.php | 1 + tests/app/Models/CategoryTest.php | 2 +- 21 files changed, 366 insertions(+), 388 deletions(-) create mode 100644 app/Models/AttributesTrait.php create mode 100644 app/Models/FilterActionsTrait.php create mode 100644 app/views/category/update.phtml delete mode 100644 app/views/subscription/category.phtml diff --git a/app/Controllers/categoryController.php b/app/Controllers/categoryController.php index de6399e27..daee1666a 100644 --- a/app/Controllers/categoryController.php +++ b/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); } /** diff --git a/app/Controllers/configureController.php b/app/Controllers/configureController.php index bca47319e..d7c087620 100644 --- a/app/Controllers/configureController.php +++ b/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(); diff --git a/app/Controllers/feedController.php b/app/Controllers/feedController.php index bba5870dd..aec39587e 100644 --- a/app/Controllers/feedController.php +++ b/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 diff --git a/app/Controllers/subscriptionController.php b/app/Controllers/subscriptionController.php index 8d1fc0b68..21c5abb30 100644 --- a/app/Controllers/subscriptionController.php +++ b/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. */ diff --git a/app/Models/AttributesTrait.php b/app/Models/AttributesTrait.php new file mode 100644 index 000000000..39154182b --- /dev/null +++ b/app/Models/AttributesTrait.php @@ -0,0 +1,40 @@ + + */ + private array $attributes = []; + + /** + * @phpstan-return ($key is non-empty-string ? mixed : array) + * @return array|mixed|null + */ + public function attributes(string $key = '') { + if ($key === '') { + return $this->attributes; + } else { + return $this->attributes[$key] ?? null; + } + } + + /** @param string|array|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; + } + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php index ab08a5b74..b1e35650a 100644 --- a/app/Models/Category.php +++ b/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 */ - private array $attributes = []; private int $lastUpdate = 0; private bool $error = false; /** * @param array|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) - * @return array|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|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 $attributes * @throws FreshRSS_Context_Exception diff --git a/app/Models/CategoryDAO.php b/app/Models/CategoryDAO.php index 2477d0ea2..20347e4f2 100644 --- a/app/Models/CategoryDAO.php +++ b/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); diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 476c5a8cf..186b1f166 100644 --- a/app/Models/Entry.php +++ b/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 */ private array $tags = []; - /** @var array */ - private array $attributes = []; /** * @param int|string $pubdate @@ -396,34 +396,6 @@ HTML; } } - /** - * @phpstan-return ($key is non-empty-string ? mixed : array) - * @return array|mixed|null - */ - public function attributes(string $key = '') { - if ($key === '') { - return $this->attributes; - } else { - return $this->attributes[$key] ?? null; - } - } - - /** @param string|array|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 $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 { diff --git a/app/Models/Feed.php b/app/Models/Feed.php index a379f5016..dbe6aaa73 100644 --- a/app/Models/Feed.php +++ b/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 */ - private array $attributes = []; private bool $mute = false; private string $hash = ''; private string $lockPath = ''; private string $hubUrl = ''; private string $selfUrl = ''; - /** @var array|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) - * @return array|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|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 - */ - 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|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 */ - 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 $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); - } - // public function pubSubHubbubEnabled(): bool { diff --git a/app/Models/FeedDAO.php b/app/Models/FeedDAO.php index 055ec60e4..ac844217a 100644 --- a/app/Models/FeedDAO.php +++ b/app/Models/FeedDAO.php @@ -373,7 +373,7 @@ SQL; * @return array */ 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) diff --git a/app/Models/FilterActionsTrait.php b/app/Models/FilterActionsTrait.php new file mode 100644 index 000000000..869992b21 --- /dev/null +++ b/app/Models/FilterActionsTrait.php @@ -0,0 +1,147 @@ +|null $filterActions */ + private ?array $filterActions = null; + + /** + * @return array + */ + 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|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 */ + 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 $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; + } + } + } + } + } +} diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 0e50763d0..cedc4647d 100644 --- a/app/Models/Tag.php +++ b/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 - */ - 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) - * @return array|mixed|null - */ - public function attributes(string $key = '') { - if ($key === '') { - return $this->attributes; - } else { - return $this->attributes[$key] ?? null; - } - } - - /** @param string|array|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(); diff --git a/app/Models/UserConfiguration.php b/app/Models/UserConfiguration.php index d75c76bcb..0aec3a05f 100644 --- a/app/Models/UserConfiguration.php +++ b/app/Models/UserConfiguration.php @@ -72,10 +72,31 @@ declare(strict_types=1); * @property array $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) + * @return array|mixed|null + */ + public function attributes(string $key = '') { + if ($key === '') { + return []; // Not implemented for user configuration + } else { + return parent::param($key, null); + } + } + + /** @param string|array|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); + } } diff --git a/app/Services/ImportService.php b/app/Services/ImportService.php index ec414a203..f33ebed96 100644 --- a/app/Services/ImportService.php +++ b/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']) ?: [] ); } diff --git a/app/views/category/update.phtml b/app/views/category/update.phtml new file mode 100644 index 000000000..daf4523bb --- /dev/null +++ b/app/views/category/update.phtml @@ -0,0 +1,10 @@ +partial('aside_subscription'); +} +if ($this->category) { + $this->renderHelper('category/update'); +} diff --git a/app/views/configure/reading.phtml b/app/views/configure/reading.phtml index 5f22d4daf..94cf51977 100644 --- a/app/views/configure/reading.phtml +++ b/app/views/configure/reading.phtml @@ -345,6 +345,21 @@ +
+ +
+ +
+ +

+
+
+
+
diff --git a/app/views/helpers/category/update.phtml b/app/views/helpers/category/update.phtml index 4e3b7ae9e..68132ad27 100644 --- a/app/views/helpers/category/update.phtml +++ b/app/views/helpers/category/update.phtml @@ -12,7 +12,7 @@
-
+ @@ -68,6 +68,26 @@ + +
+ +
+ +

+
+
+ +
+
+ + +
+
+ category->attributes('archiving'); diff --git a/app/views/subscription/category.phtml b/app/views/subscription/category.phtml deleted file mode 100644 index daf4523bb..000000000 --- a/app/views/subscription/category.phtml +++ /dev/null @@ -1,10 +0,0 @@ -partial('aside_subscription'); -} -if ($this->category) { - $this->renderHelper('category/update'); -} diff --git a/app/views/subscription/index.phtml b/app/views/subscription/index.phtml index 280799f2d..a1a874b31 100644 --- a/app/views/subscription/index.phtml +++ b/app/views/subscription/index.phtml @@ -34,7 +34,7 @@ ?>
- +

name() ?>kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { echo " " . _i('opml-dyn'); } ?>

    diff --git a/config-user.default.php b/config-user.default.php index 5d6f53a40..733701a3a 100644 --- a/config-user.default.php +++ b/config-user.default.php @@ -64,6 +64,7 @@ return array ( 'focus' => false, 'site' => true, ), + 'filters' => [], 'theme' => 'Origine', 'darkMode' => 'no', 'content_width' => 'thin', diff --git a/tests/app/Models/CategoryTest.php b/tests/app/Models/CategoryTest.php index bdb2a971b..ac450de1d 100644 --- a/tests/app/Models/CategoryTest.php +++ b/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, -- cgit v1.2.3