From 509c8cae6381ec46af7c8303eb92fda6ce496a4a Mon Sep 17 00:00:00 2001 From: Alexandre Alapetite Date: Mon, 4 Jul 2022 09:53:26 +0200 Subject: Dynamic OPML (#4407) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Dynamic OPML draft #fix https://github.com/FreshRSS/FreshRSS/issues/4191 * Export dynamic OPML http://opml.org/spec2.opml#1629043127000 * Restart with simpler approach * Minor revert * Export dynamic OPML also for single feeds * Special category type for importing dynamic OPML * Parameter for excludeMutedFeeds * Details * More draft * i18n * Fix update * Draft manual import working * Working manual refresh * Draft automatic update * Working Web refresh + fixes * Import/export dynamic OPML settings * Annoying numerous lines in SQL logs * Fix minor JavaScript error * Fix auto adding new columns * Add require * Add missing πŸ—² * Missing space * Disable adding new feeds to dynamic categories * Link from import * i18n typo * Improve theme icon function * Fix pink-dark --- app/Models/Category.php | 136 +++++++++++++++++++++++++++++++++++++-- app/Models/CategoryDAO.php | 103 ++++++++++++++++++++++++----- app/Models/CategoryDAOSQLite.php | 2 +- app/Models/Context.php | 17 ++++- app/Models/Entry.php | 3 +- app/Models/Feed.php | 35 ++++++---- app/Models/FeedDAO.php | 88 +++++++++++++++---------- app/Models/Themes.php | 41 ++++++++++-- app/Models/UserConfiguration.php | 1 + app/Models/View.php | 2 + 10 files changed, 350 insertions(+), 78 deletions(-) (limited to 'app/Models') diff --git a/app/Models/Category.php b/app/Models/Category.php index b67818e19..d75d7e21e 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -1,17 +1,38 @@ |null */ private $feeds = null; private $hasFeedsWithError = false; - private $isDefault = false; private $attributes = []; + /** @var int */ + private $lastUpdate = 0; + /** @var bool */ + private $error = false; public function __construct(string $name = '', $feeds = null) { $this->_name($name); @@ -30,11 +51,26 @@ class FreshRSS_Category extends Minz_Model { public function id(): int { return $this->id; } + public function kind(): int { + return $this->kind; + } public function name(): string { return $this->name; } + public function lastUpdate(): int { + return $this->lastUpdate; + } + public function _lastUpdate(int $value) { + $this->lastUpdate = $value; + } + public function inError(): bool { + return $this->error; + } + public function _error($value) { + $this->error = (bool)$value; + } public function isDefault(): bool { - return $this->isDefault; + return $this->id == FreshRSS_CategoryDAO::DEFAULTCATEGORYID; } public function nbFeeds(): int { if ($this->nbFeeds < 0) { @@ -52,6 +88,8 @@ class FreshRSS_Category extends Minz_Model { return $this->nbNotRead; } + + /** @return array */ public function feeds(): array { if ($this->feeds === null) { $feedDAO = FreshRSS_Factory::createFeedDao(); @@ -90,12 +128,15 @@ class FreshRSS_Category extends Minz_Model { $this->_name(_t('gen.short.default_category')); } } + + public function _kind(int $kind) { + $this->kind = $kind; + } + public function _name($value) { $this->name = mb_strcut(trim($value), 0, 255, 'UTF-8'); } - public function _isDefault($value) { - $this->isDefault = $value; - } + /** @param array|FreshRSS_Feed $values */ public function _feeds($values) { if (!is_array($values)) { $values = array($values); @@ -104,6 +145,17 @@ class FreshRSS_Category extends Minz_Model { $this->feeds = $values; } + /** + * To manually add feeds to this category (not committing to database). + * @param FreshRSS_Feed $feed + */ + public function addFeed($feed) { + if ($this->feeds === null) { + $this->feeds = []; + } + $this->feeds[] = $feed; + } + public function _attributes($key, $value) { if ('' == $key) { if (is_string($value)) { @@ -118,4 +170,78 @@ class FreshRSS_Category extends Minz_Model { $this->attributes[$key] = $value; } } + + public static function cacheFilename(string $url, array $attributes): string { + $simplePie = customSimplePie($attributes); + $filename = $simplePie->get_cache_filename($url); + return CACHE_PATH . '/' . $filename . '.opml.xml'; + } + + public function refreshDynamicOpml(): bool { + $url = $this->attributes('opml_url'); + if ($url == '') { + return false; + } + $ok = true; + $attributes = []; //TODO + $cachePath = self::cacheFilename($url, $attributes); + $opml = httpGet($url, $cachePath, 'opml', $attributes); + if ($opml == '') { + Minz_Log::warning('Error getting dynamic OPML for category ' . $this->id() . '! ' . + SimplePie_Misc::url_remove_credentials($url)); + $ok = false; + } else { + $dryRunCategory = new FreshRSS_Category(); + $importService = new FreshRSS_Import_Service(); + $importService->importOpml($opml, $dryRunCategory, true, true); + if ($importService->lastStatus()) { + $feedDAO = FreshRSS_Factory::createFeedDao(); + + /** @var array */ + $dryRunFeeds = []; + foreach ($dryRunCategory->feeds() as $dryRunFeed) { + $dryRunFeeds[$dryRunFeed->url()] = $dryRunFeed; + } + + /** @var array */ + $existingFeeds = []; + foreach ($this->feeds() as $existingFeed) { + $existingFeeds[$existingFeed->url()] = $existingFeed; + if (empty($dryRunFeeds[$existingFeed->url()])) { + // The feed does not exist in the new dynamic OPML, so mute (disable) that feed + $existingFeed->_mute(true); + $ok &= ($feedDAO->updateFeed($existingFeed->id(), [ + 'ttl' => $existingFeed->ttl(true), + ]) !== false); + } + } + + foreach ($dryRunCategory->feeds() as $dryRunFeed) { + if (empty($existingFeeds[$dryRunFeed->url()])) { + // The feed does not exist in the current category, so add that feed + $dryRunFeed->_category($this->id()); + $ok &= ($feedDAO->addFeedObject($dryRunFeed) !== false); + } else { + $existingFeed = $existingFeeds[$dryRunFeed->url()]; + if ($existingFeed->mute()) { + // The feed already exists in the current category but was muted (disabled), so unmute (enable) again + $existingFeed->_mute(false); + $ok &= ($feedDAO->updateFeed($existingFeed->id(), [ + 'ttl' => $existingFeed->ttl(true), + ]) !== false); + } + } + } + } else { + $ok = false; + Minz_Log::warning('Error loading dynamic OPML for category ' . $this->id() . '! ' . + SimplePie_Misc::url_remove_credentials($url)); + } + } + + $catDAO = FreshRSS_Factory::createCategoryDao(); + $catDAO->updateLastUpdate($this->id(), !$ok); + + return $ok; + } } diff --git a/app/Models/CategoryDAO.php b/app/Models/CategoryDAO.php index 18747c906..cef8e6d63 100644 --- a/app/Models/CategoryDAO.php +++ b/app/Models/CategoryDAO.php @@ -17,7 +17,13 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable protected function addColumn($name) { Minz_Log::warning(__method__ . ': ' . $name); try { - if ('attributes' === $name) { //v1.15.0 + if ($name === 'kind') { //v1.20.0 + return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN kind SMALLINT DEFAULT 0') !== false; + } elseif ($name === 'lastUpdate') { //v1.20.0 + return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN `lastUpdate` BIGINT DEFAULT 0') !== false; + } elseif ($name === 'error') { //v1.20.0 + return $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN error SMALLINT DEFAULT 0') !== false; + } elseif ('attributes' === $name) { //v1.15.0 $ok = $this->pdo->exec('ALTER TABLE `_category` ADD COLUMN attributes TEXT') !== false; $stm = $this->pdo->query('SELECT * FROM `_feed`'); @@ -69,8 +75,9 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable protected function autoUpdateDb(array $errorInfo) { if (isset($errorInfo[0])) { if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) { - foreach (['attributes'] as $column) { - if (stripos($errorInfo[2], $column) !== false) { + $errorLines = explode("\n", $errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise + foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) { + if (stripos($errorLines[0], $column) !== false) { return $this->addColumn($column); } } @@ -79,12 +86,13 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable return false; } + /** @return int|false */ public function addCategory($valuesTmp) { // TRIM() to provide a type hint as text // No tag of the same name $sql = <<<'SQL' -INSERT INTO `_category`(name, attributes) -SELECT * FROM (SELECT TRIM(?) AS name, TRIM(?) AS attributes) c2 +INSERT INTO `_category`(kind, name, attributes) +SELECT * FROM (SELECT ABS(?) AS kind, TRIM(?) AS name, TRIM(?) AS attributes) c2 WHERE NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = TRIM(?)) SQL; $stm = $this->pdo->prepare($sql); @@ -94,6 +102,7 @@ SQL; $valuesTmp['attributes'] = []; } $values = array( + $valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL, $valuesTmp['name'], is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES), $valuesTmp['name'], @@ -111,13 +120,18 @@ SQL; } } + /** + * @param FreshRSS_Category $category + * @return int|false + */ public function addCategoryObject($category) { $cat = $this->searchByName($category->name()); if (!$cat) { - // Category does not exist yet in DB so we add it before continue - $values = array( + $values = [ + 'kind' => $category->kind(), 'name' => $category->name(), - ); + 'attributes' => $category->attributes(), + ]; return $this->addCategory($values); } @@ -127,7 +141,7 @@ SQL; public function updateCategory($id, $valuesTmp) { // No tag of the same name $sql = <<<'SQL' -UPDATE `_category` SET name=?, attributes=? WHERE id=? +UPDATE `_category` SET name=?, kind=?, attributes=? WHERE id=? AND NOT EXISTS (SELECT 1 FROM `_tag` WHERE name = ?) SQL; $stm = $this->pdo->prepare($sql); @@ -138,6 +152,7 @@ SQL; } $values = array( $valuesTmp['name'], + $valuesTmp['kind'] ?? FreshRSS_Category::KIND_NORMAL, is_string($valuesTmp['attributes']) ? $valuesTmp['attributes'] : json_encode($valuesTmp['attributes'], JSON_UNESCAPED_SLASHES), $id, $valuesTmp['name'], @@ -155,6 +170,24 @@ SQL; } } + public function updateLastUpdate(int $id, bool $inError = false, int $mtime = 0) { + $sql = 'UPDATE `_category` SET `lastUpdate`=?, error=? WHERE id=?'; + $values = [ + $mtime <= 0 ? time() : $mtime, + $inError ? 1 : 0, + $id, + ]; + $stm = $this->pdo->prepare($sql); + + if ($stm && $stm->execute($values)) { + return $stm->rowCount(); + } else { + $info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo(); + Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info)); + return false; + } + } + public function deleteCategory($id) { if ($id <= self::DEFAULTCATEGORYID) { return false; @@ -172,7 +205,7 @@ SQL; } public function selectAll() { - $sql = 'SELECT id, name, attributes FROM `_category`'; + $sql = 'SELECT id, name, kind, `lastUpdate`, error, attributes FROM `_category`'; $stm = $this->pdo->query($sql); if ($stm != false) { while ($row = $stm->fetch(PDO::FETCH_ASSOC)) { @@ -181,15 +214,14 @@ SQL; } else { $info = $this->pdo->errorInfo(); if ($this->autoUpdateDb($info)) { - foreach ($this->selectAll() as $category) { // `yield from` requires PHP 7+ - yield $category; - } + yield from $this->selectAll(); } Minz_Log::error(__method__ . ' error: ' . json_encode($info)); yield false; } } + /** @return FreshRSS_Category|null */ public function searchById($id) { $sql = 'SELECT * FROM `_category` WHERE id=:id'; $stm = $this->pdo->prepare($sql); @@ -204,7 +236,9 @@ SQL; return null; } } - public function searchByName($name) { + + /** @return FreshRSS_Category|null|false */ + public function searchByName(string $name) { $sql = 'SELECT * FROM `_category` WHERE name=:name'; $stm = $this->pdo->prepare($sql); if ($stm == false) { @@ -246,7 +280,7 @@ SQL; public function listCategories($prePopulateFeeds = true, $details = false) { if ($prePopulateFeeds) { - $sql = 'SELECT c.id AS c_id, c.name AS c_name, c.attributes AS c_attributes, ' + $sql = 'SELECT c.id AS c_id, c.name AS c_name, c.kind AS c_kind, c.`lastUpdate` AS c_last_update, c.error AS c_error, c.attributes AS c_attributes, ' . ($details ? 'f.* ' : 'f.id, f.name, f.url, f.website, f.priority, f.error, f.`cache_nbEntries`, f.`cache_nbUnreads`, f.ttl ') . 'FROM `_category` c ' . 'LEFT OUTER JOIN `_feed` f ON f.category=c.id ' @@ -272,6 +306,27 @@ SQL; } } + /** @return array */ + public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0) { + $sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`' + . ($limit < 1 ? '' : ' LIMIT ' . intval($limit)); + $stm = $this->pdo->prepare($sql); + if ($stm && + $stm->bindValue(':kind', FreshRSS_Category::KIND_DYNAMIC_OPML, PDO::PARAM_INT) && + $stm->bindValue(':lu', time() - $defaultCacheDuration, PDO::PARAM_INT) && + $stm->execute()) { + return self::daoToCategory($stm->fetchAll(PDO::FETCH_ASSOC)); + } else { + $info = $stm ? $stm->errorInfo() : $this->pdo->errorInfo(); + if ($this->autoUpdateDb($info)) { + return $this->listCategoriesOrderUpdate($defaultCacheDuration, $limit); + } + Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info)); + return []; + } + } + + /** @return FreshRSS_Category|null */ public function getDefault() { $sql = 'SELECT * FROM `_category` WHERE id=:id'; $stm = $this->pdo->prepare($sql); @@ -290,6 +345,8 @@ SQL; return null; } } + + /** @return int|bool */ public function checkDefault() { $def_cat = $this->searchById(self::DEFAULTCATEGORYID); @@ -345,6 +402,10 @@ SQL; return $res[0]['count']; } + /** + * @param array $categories + * @param int $feed_id + */ public static function findFeed($categories, $feed_id) { foreach ($categories as $category) { foreach ($category->feeds() as $feed) { @@ -356,6 +417,10 @@ SQL; return null; } + /** + * @param array $categories + * @param int $minPriority + */ public static function CountUnreads($categories, $minPriority = 0) { $n = 0; foreach ($categories as $category) { @@ -386,6 +451,7 @@ SQL; $feedDao->daoToFeed($feedsDao, $previousLine['c_id']) ); $cat->_id($previousLine['c_id']); + $cat->_kind($previousLine['c_kind']); $cat->_attributes('', $previousLine['c_attributes']); $list[$previousLine['c_id']] = $cat; @@ -403,6 +469,9 @@ SQL; $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'] ?? false); $cat->_attributes('', $previousLine['c_attributes']); $list[$previousLine['c_id']] = $cat; } @@ -422,8 +491,10 @@ SQL; $dao['name'] ); $cat->_id($dao['id']); + $cat->_kind($dao['kind']); + $cat->_lastUpdate($dao['lastUpdate'] ?? 0); + $cat->_error($dao['error'] ?? false); $cat->_attributes('', isset($dao['attributes']) ? $dao['attributes'] : ''); - $cat->_isDefault(static::DEFAULTCATEGORYID === intval($dao['id'])); $list[$key] = $cat; } diff --git a/app/Models/CategoryDAOSQLite.php b/app/Models/CategoryDAOSQLite.php index 6f200be6d..363ffb427 100644 --- a/app/Models/CategoryDAOSQLite.php +++ b/app/Models/CategoryDAOSQLite.php @@ -5,7 +5,7 @@ class FreshRSS_CategoryDAOSQLite extends FreshRSS_CategoryDAO { protected function autoUpdateDb(array $errorInfo) { if ($tableInfo = $this->pdo->query("PRAGMA table_info('category')")) { $columns = $tableInfo->fetchAll(PDO::FETCH_COLUMN, 1); - foreach (['attributes'] as $column) { + foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) { if (!in_array($column, $columns)) { return $this->addColumn($column); } diff --git a/app/Models/Context.php b/app/Models/Context.php index 55607f5c4..ab855966b 100644 --- a/app/Models/Context.php +++ b/app/Models/Context.php @@ -197,6 +197,20 @@ class FreshRSS_Context { } } + /** + * @return bool true if the current request targets all feeds (main view), false otherwise. + */ + public static function isAll(): bool { + return self::$current_get['all'] != false; + } + + /** + * @return bool true if the current request targets a category, false otherwise. + */ + public static function isCategory(): bool { + return self::$current_get['category'] != false; + } + /** * @return bool true if the current request targets a feed (and not a category or all articles), false otherwise. */ @@ -251,8 +265,7 @@ class FreshRSS_Context { */ public static function _get($get) { $type = $get[0]; - $id = substr($get, 2); - $nb_unread = 0; + $id = intval(substr($get, 2)); if (empty(self::$categories)) { $catDAO = FreshRSS_Factory::createCategoryDao(); diff --git a/app/Models/Entry.php b/app/Models/Entry.php index 57b0e0b60..fb17268b3 100644 --- a/app/Models/Entry.php +++ b/app/Models/Entry.php @@ -488,7 +488,8 @@ class FreshRSS_Entry extends Minz_Model { * @param array $attributes */ public static function getContentByParsing(string $url, string $path, array $attributes = [], int $maxRedirs = 3): string { - $html = getHtml($url, $attributes); + $cachePath = FreshRSS_Feed::cacheFilename($url, $attributes, FreshRSS_Feed::KIND_HTML_XPATH); + $html = httpGet($url, $cachePath, 'html', $attributes); if (strlen($html) > 0) { $doc = new DOMDocument(); $doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); diff --git a/app/Models/Feed.php b/app/Models/Feed.php index 6f6b83af0..e39109b49 100644 --- a/app/Models/Feed.php +++ b/app/Models/Feed.php @@ -162,9 +162,21 @@ class FreshRSS_Feed extends Minz_Model { public function inError(): bool { return $this->error; } - public function ttl(): int { + + /** + * @param bool $raw true for database version combined with mute information, false otherwise + */ + public function ttl(bool $raw = false): int { + if ($raw) { + $ttl = $this->ttl; + if ($this->mute && FreshRSS_Feed::TTL_DEFAULT === $ttl) { + $ttl = FreshRSS_Context::$user_conf ? FreshRSS_Context::$user_conf->ttl_default : 3600; + } + return $ttl * ($this->mute ? -1 : 1); + } return $this->ttl; } + public function attributes($key = '') { if ($key == '') { return $this->attributes; @@ -172,19 +184,11 @@ class FreshRSS_Feed extends Minz_Model { return isset($this->attributes[$key]) ? $this->attributes[$key] : null; } } + public function mute(): bool { return $this->mute; } - // public function ttlExpire() { - // $ttl = $this->ttl; - // if ($ttl == self::TTL_DEFAULT) { //Default - // $ttl = FreshRSS_Context::$user_conf->ttl_default; - // } - // if ($ttl == -1) { //Never - // $ttl = 64000000; //~2 years. Good enough for PubSubHubbub logic - // } - // return $this->lastUpdate + $ttl; - // } + public function nbEntries(): int { if ($this->nbEntries < 0) { $feedDAO = FreshRSS_Factory::createFeedDao(); @@ -248,10 +252,13 @@ class FreshRSS_Feed extends Minz_Model { public function _kind(int $value) { $this->kind = $value; } + + /** @param int $value */ public function _category($value) { $value = intval($value); $this->category = $value >= 0 ? $value : 0; } + public function _name(string $value) { $this->name = $value == '' ? '' : trim($value); } @@ -282,6 +289,9 @@ class FreshRSS_Feed extends Minz_Model { public function _error($value) { $this->error = (bool)$value; } + public function _mute(bool $value) { + $this->mute = $value; + } public function _ttl($value) { $value = intval($value); $value = min($value, 100000000); @@ -584,7 +594,8 @@ class FreshRSS_Feed extends Minz_Model { return null; } - $html = getHtml($feedSourceUrl, $attributes); + $cachePath = FreshRSS_Feed::cacheFilename($feedSourceUrl, $attributes, FreshRSS_Feed::KIND_HTML_XPATH); + $html = httpGet($feedSourceUrl, $cachePath, 'html', $attributes); if (strlen($html) <= 0) { return null; } diff --git a/app/Models/FeedDAO.php b/app/Models/FeedDAO.php index ec507b324..8d54e7be2 100644 --- a/app/Models/FeedDAO.php +++ b/app/Models/FeedDAO.php @@ -19,8 +19,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { protected function autoUpdateDb(array $errorInfo) { if (isset($errorInfo[0])) { if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) { + $errorLines = explode("\n", $errorInfo[2], 2); // The relevant column name is on the first line, other lines are noise foreach (['attributes', 'kind'] as $column) { - if (stripos($errorInfo[2], $column) !== false) { + if (stripos($errorLines[0], $column) !== false) { return $this->addColumn($column); } } @@ -29,26 +30,10 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { return false; } + /** @return int|false */ public function addFeed(array $valuesTmp) { - $sql = ' - INSERT INTO `_feed` - ( - url, - kind, - category, - name, - website, - description, - `lastUpdate`, - priority, - `pathEntries`, - `httpAuth`, - error, - ttl, - attributes - ) - VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + $sql = 'INSERT INTO `_feed` (url, kind, category, name, website, description, `lastUpdate`, priority, `pathEntries`, `httpAuth`, error, ttl, attributes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; $stm = $this->pdo->prepare($sql); $valuesTmp['url'] = safe_ascii($valuesTmp['url']); @@ -88,10 +73,8 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { } } - public function addFeedObject(FreshRSS_Feed $feed): int { - // TODO: not sure if we should write this method in DAO since DAO - // should not be aware about feed class - + /** @return int|false */ + public function addFeedObject(FreshRSS_Feed $feed) { // Add feed only if we don’t find it in DB $feed_search = $this->searchByUrl($feed->url()); if (!$feed_search) { @@ -106,13 +89,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { 'lastUpdate' => 0, 'pathEntries' => $feed->pathEntries(), 'httpAuth' => $feed->httpAuth(), + 'ttl' => $feed->ttl(true), 'attributes' => $feed->attributes(), ); - if ($feed->mute() || ( - FreshRSS_Context::$user_conf != null && //When creating a new user - $feed->ttl() != FreshRSS_Context::$user_conf->ttl_default)) { - $values['ttl'] = $feed->ttl() * ($feed->mute() ? -1 : 1); - } $id = $this->addFeed($values); if ($id) { @@ -121,11 +100,36 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { } return $id; - } + } else { + // The feed already exists so make sure it is not muted + $feed->_ttl($feed_search->ttl()); + $feed->_mute(false); + + // Merge existing and import attributes + $existingAttributes = $feed_search->attributes(); + $importAttributes = $feed->attributes(); + $feed->_attributes('', array_merge_recursive($existingAttributes, $importAttributes)); + + // Update some values of the existing feed using the import + $values = [ + 'kind' => $feed->kind(), + 'name' => $feed->name(), + 'website' => $feed->website(), + 'description' => $feed->description(), + 'pathEntries' => $feed->pathEntries(), + 'ttl' => $feed->ttl(true), + 'attributes' => $feed->attributes(), + ]; + + if (!$this->updateFeed($feed_search->id(), $values)) { + return false; + } - return $feed_search->id(); + return $feed_search->id(); + } } + /** @return int|false */ public function updateFeed(int $id, array $valuesTmp) { if (isset($valuesTmp['name'])) { $valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'); @@ -193,7 +197,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { return $stm->rowCount(); } else { $info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo(); - Minz_Log::error('SQL error updateLastUpdate: ' . $info[2]); + Minz_Log::warning(__METHOD__ . ' error: ' . $sql . ' : ' . json_encode($info)); return false; } } @@ -227,6 +231,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { } } + /** @return int|false */ public function deleteFeed(int $id) { $sql = 'DELETE FROM `_feed` WHERE id=?'; $stm = $this->pdo->prepare($sql); @@ -241,8 +246,16 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable { return false; } } - public function deleteFeedByCategory(int $id) { + + /** + * @param bool|null $muted to include only muted feeds + * @return int|false + */ + public function deleteFeedByCategory(int $id, $muted = null) { $sql = 'DELETE FROM `_feed` WHERE category=?'; + if ($muted) { + $sql .= ' AND ttl < 0'; + } $stm = $this->pdo->prepare($sql); $values = array($id); @@ -349,6 +362,7 @@ SQL; /** * Use $defaultCacheDuration == -1 to return all feeds, without filtering them by TTL. + * @return array */ public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0) { $this->updateTTL(); @@ -365,7 +379,7 @@ SQL; } else { $info = $this->pdo->errorInfo(); if ($this->autoUpdateDb($info)) { - return $this->listFeedsOrderUpdate($defaultCacheDuration); + return $this->listFeedsOrderUpdate($defaultCacheDuration, $limit); } Minz_Log::error('SQL error listFeedsOrderUpdate: ' . $info[2]); return array(); @@ -386,10 +400,14 @@ SQL; } /** + * @param bool|null $muted to include only muted feeds * @return array */ - public function listByCategory(int $cat): array { + public function listByCategory(int $cat, $muted = null): array { $sql = 'SELECT * FROM `_feed` WHERE category=?'; + if ($muted) { + $sql .= ' AND ttl < 0'; + } $stm = $this->pdo->prepare($sql); $stm->execute(array($cat)); diff --git a/app/Models/Themes.php b/app/Models/Themes.php index ceaa49266..a59f4b663 100644 --- a/app/Models/Themes.php +++ b/app/Models/Themes.php @@ -68,6 +68,13 @@ class FreshRSS_Themes extends Minz_Model { return $infos; } + public static function title($name) { + static $titles = [ + 'opml-dyn' => 'sub.category.dynamic_opml', + ]; + return $titles[$name] ?? ''; + } + public static function alt($name) { static $alts = array( 'add' => 'βž•', //✚ @@ -94,6 +101,7 @@ class FreshRSS_Themes extends Minz_Model { 'next' => '⏩', 'non-starred' => 'β˜†', 'notice' => 'ℹ️', //β“˜ + 'opml-dyn' => 'πŸ—²', 'prev' => 'βͺ', 'read' => 'β˜‘οΈ', //β˜‘ 'rss' => 'πŸ“£', //β˜„ @@ -115,7 +123,13 @@ class FreshRSS_Themes extends Minz_Model { return isset($name) ? $alts[$name] : ''; } - public static function icon($name, $urlOnly = false) { + // TODO: Change for enum in PHP 8.1+ + const ICON_DEFAULT = 0; + const ICON_IMG = 1; + const ICON_URL = 2; + const ICON_EMOJI = 3; + + public static function icon(string $name, int $type = self::ICON_DEFAULT): string { $alt = self::alt($name); if ($alt == '') { return ''; @@ -124,14 +138,29 @@ class FreshRSS_Themes extends Minz_Model { $url = $name . '.svg'; $url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url); - if ($urlOnly) { - return Minz_Url::display($url); + $title = self::title($name); + if ($title != '') { + $title = ' title="' . _t($title) . '"'; } - if (FreshRSS_Context::$user_conf && FreshRSS_Context::$user_conf->icons_as_emojis) { - return '' . $alt . ''; + if ($type == self::ICON_DEFAULT) { + if ((FreshRSS_Context::$user_conf && FreshRSS_Context::$user_conf->icons_as_emojis) || + // default to emoji alternate for some icons + in_array($name, [ 'opml-dyn' ])) { + $type = self::ICON_EMOJI; + } else { + $type = self::ICON_IMG; + } } - return '' . $alt . ''; + switch ($type) { + case self::ICON_URL: + return Minz_Url::display($url); + case self::ICON_IMG: + return '' . $alt . ''; + case self::ICON_EMOJI: + default: + return '' . $alt . ''; + } } } diff --git a/app/Models/UserConfiguration.php b/app/Models/UserConfiguration.php index 96fc77b59..45ec12e5a 100644 --- a/app/Models/UserConfiguration.php +++ b/app/Models/UserConfiguration.php @@ -57,6 +57,7 @@ * @property bool $topline_summary * @property string $topline_thumbnail * @property int $ttl_default + * @property int $dynamic_opml_ttl_default * @property-read bool $unsafe_autologin_enabled * @property string $view_mode * @property array $volatile diff --git a/app/Models/View.php b/app/Models/View.php index a46ebd95e..0169f130a 100644 --- a/app/Models/View.php +++ b/app/Models/View.php @@ -25,6 +25,8 @@ class FreshRSS_View extends Minz_View { public $tags; /** @var array */ public $notification; + /** @var bool */ + public $excludeMutedFeeds; // Substriptions public $default_category; -- cgit v1.2.3