aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Alexandre Alapetite <alexandre@alapetite.fr> 2023-12-15 23:04:29 +0100
committerGravatar GitHub <noreply@github.com> 2023-12-15 23:04:29 +0100
commit6bb45a87268157aab961a6a4a728d9a9bbe043b0 (patch)
treed1c36638d5ee61e2e663d214d724a71f07a89354
parenta3ed8269132303eebc03d3e6df822f1f101fa95b (diff)
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
-rw-r--r--app/Controllers/categoryController.php79
-rw-r--r--app/Controllers/configureController.php1
-rw-r--r--app/Controllers/feedController.php13
-rw-r--r--app/Controllers/subscriptionController.php75
-rw-r--r--app/Models/AttributesTrait.php40
-rw-r--r--app/Models/Category.php35
-rw-r--r--app/Models/CategoryDAO.php11
-rw-r--r--app/Models/Entry.php80
-rw-r--r--app/Models/Feed.php153
-rw-r--r--app/Models/FeedDAO.php2
-rw-r--r--app/Models/FilterActionsTrait.php147
-rw-r--r--app/Models/Tag.php33
-rw-r--r--app/Models/UserConfiguration.php21
-rw-r--r--app/Services/ImportService.php2
-rw-r--r--app/views/category/update.phtml (renamed from app/views/subscription/category.phtml)0
-rw-r--r--app/views/configure/reading.phtml15
-rw-r--r--app/views/helpers/category/update.phtml22
-rw-r--r--app/views/subscription/index.phtml2
-rw-r--r--config-user.default.php1
-rw-r--r--tests/app/Models/CategoryTest.php2
20 files changed, 356 insertions, 378 deletions
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 @@
+<?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;
+ }
+ }
+}
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<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
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<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 {
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<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 {
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<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)
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 @@
+<?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;
+ }
+ }
+ }
+ }
+ }
+}
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<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();
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<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);
+ }
}
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/subscription/category.phtml b/app/views/category/update.phtml
index daf4523bb..daf4523bb 100644
--- a/app/views/subscription/category.phtml
+++ b/app/views/category/update.phtml
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
@@ -346,6 +346,21 @@
</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">
<div class="group-controls">
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 @@
<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');
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 @@
?>
<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() ?>">
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,