diff options
| author | 2022-07-04 09:53:26 +0200 | |
|---|---|---|
| committer | 2022-07-04 09:53:26 +0200 | |
| commit | 509c8cae6381ec46af7c8303eb92fda6ce496a4a (patch) | |
| tree | 653f7f44df842f9d7135decd89467879a0098c50 | |
| parent | 57d571230eeb2d3ede57e640b640f17c7a2298a2 (diff) | |
Dynamic OPML (#4407)
* 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
85 files changed, 1136 insertions, 269 deletions
diff --git a/.editorconfig b/.editorconfig index bcb7b4666..8afe6051c 100644 --- a/.editorconfig +++ b/.editorconfig @@ -19,6 +19,9 @@ indent_style = tab indent_size = 4 indent_style = tab +[*.svg] +indent_style = tab + [*.xml] indent_style = tab diff --git a/.typos.toml b/.typos.toml index 327bb8360..2ae8b3088 100644 --- a/.typos.toml +++ b/.typos.toml @@ -2,6 +2,9 @@ ot = "ot" Ths2 = "Ths2" +[default.extend-words] +ba = "ba" + [files] extend-exclude = [ "*.fr.md", diff --git a/app/Controllers/categoryController.php b/app/Controllers/categoryController.php index 7226e44af..62901c78e 100644 --- a/app/Controllers/categoryController.php +++ b/app/Controllers/categoryController.php @@ -40,8 +40,8 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { if (Minz_Request::isPost()) { invalidateHttpCache(); - $cat_name = Minz_Request::param('new-category'); - if (!$cat_name) { + $cat_name = trim(Minz_Request::param('new-category', '')); + if ($cat_name == '') { Minz_Request::bad(_t('feedback.sub.category.no_name'), $url_redirect); } @@ -51,12 +51,16 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { Minz_Request::bad(_t('feedback.sub.category.name_exists'), $url_redirect); } - $values = array( - 'id' => $cat->id(), - 'name' => $cat->name(), - ); + $opml_url = checkUrl(Minz_Request::param('opml_url', '')); + if ($opml_url != '') { + $cat->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML); + $cat->_attributes('opml_url', $opml_url); + } else { + $cat->_kind(FreshRSS_Category::KIND_NORMAL); + $cat->_attributes('opml_url', null); + } - if ($catDAO->addCategory($values)) { + if ($catDAO->addCategoryObject($cat)) { $url_redirect['a'] = 'index'; Minz_Request::good(_t('feedback.sub.category.created', $cat->name()), $url_redirect); } else { @@ -156,6 +160,7 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { * * Request parameter is: * - id (of a category) + * - muted (truthy to remove only muted feeds, or falsy otherwise) */ public function emptyAction() { $feedDAO = FreshRSS_Factory::createFeedDao(); @@ -169,10 +174,15 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect); } + $muted = Minz_Request::param('muted', null); + if ($muted !== null) { + $muted = boolval($muted); + } + // List feeds to remove then related user queries. - $feeds = $feedDAO->listByCategory($id); + $feeds = $feedDAO->listByCategory($id, $muted); - if ($feedDAO->deleteFeedByCategory($id)) { + if ($feedDAO->deleteFeedByCategory($id, $muted)) { // TODO: Delete old favicons // Remove related queries @@ -190,4 +200,62 @@ class FreshRSS_category_Controller extends FreshRSS_ActionController { Minz_Request::forward($url_redirect, true); } + + /** + * Request parameter is: + * - id (of a category) + */ + public function refreshOpmlAction() { + $catDAO = FreshRSS_Factory::createCategoryDao(); + $url_redirect = array('c' => 'subscription', 'a' => 'index'); + + if (Minz_Request::isPost()) { + invalidateHttpCache(); + + $id = Minz_Request::param('id'); + if (!$id) { + Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect); + } + + $category = $catDAO->searchById($id); + if ($category == null) { + Minz_Request::bad(_t('feedback.sub.category.not_exist'), $url_redirect); + } + + invalidateHttpCache(); + + $ok = $category->refreshDynamicOpml(); + + if (Minz_Request::param('ajax')) { + Minz_Request::setGoodNotification(_t('feedback.sub.category.updated')); + $this->view->_layout(false); + } else { + if ($ok) { + 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); + } + } + } + + /** @return array<string,int> */ + public static function refreshDynamicOpmls() { + $successes = 0; + $errors = 0; + $catDAO = FreshRSS_Factory::createCategoryDao(); + $categories = $catDAO->listCategoriesOrderUpdate(FreshRSS_Context::$user_conf->dynamic_opml_ttl_default ?? 86400); + foreach ($categories as $category) { + if ($category->refreshDynamicOpml()) { + $successes++; + } else { + $errors++; + } + } + return [ + 'successes' => $successes, + 'errors' => $errors, + ]; + } } diff --git a/app/Controllers/feedController.php b/app/Controllers/feedController.php index fe5641642..8621cb535 100755 --- a/app/Controllers/feedController.php +++ b/app/Controllers/feedController.php @@ -67,6 +67,10 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { $cat_id = $cat == null ? FreshRSS_CategoryDAO::DEFAULTCATEGORYID : $cat->id(); $feed = new FreshRSS_Feed($url); //Throws FreshRSS_BadUrl_Exception + $title = trim($title); + if ($title != '') { + $feed->_name($title); + } $feed->_kind($kind); $feed->_attributes('', $attributes); $feed->_httpAuth($http_auth); @@ -92,19 +96,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { throw new FreshRSS_FeedNotAdded_Exception($url); } - $values = array( - 'url' => $feed->url(), - 'kind' => $feed->kind(), - 'category' => $feed->category(), - 'name' => $title != '' ? $title : $feed->name(true), - 'website' => $feed->website(), - 'description' => $feed->description(), - 'lastUpdate' => 0, - 'httpAuth' => $feed->httpAuth(), - 'attributes' => $feed->attributes(), - ); - - $id = $feedDAO->addFeed($values); + $id = $feedDAO->addFeedObject($feed); if (!$id) { // There was an error in database… we cannot say what here. throw new FreshRSS_FeedNotAdded_Exception($url); @@ -469,7 +461,8 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { } if ($pubSubHubbubEnabled && !$simplePiePush) { //We use push, but have discovered an article by pull! - $text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' . $url . + $text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' . + SimplePie_Misc::url_remove_credentials($url) . ' GUID ' . $entry->guid(); Minz_Log::warning($text, PSHB_LOG); Minz_Log::warning($text); @@ -528,7 +521,8 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { } } } elseif ($feed->url() !== $url) { // HTTP 301 Moved Permanently - Minz_Log::notice('Feed ' . $url . ' moved permanently to ' . $feed->url(false)); + Minz_Log::notice('Feed ' . SimplePie_Misc::url_remove_credentials($url) . + ' moved permanently to ' . SimplePie_Misc::url_remove_credentials($feed->url(false))); $feedProperties['url'] = $feed->url(); } @@ -629,6 +623,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController { $databaseDAO = FreshRSS_Factory::createDatabaseDAO(); $databaseDAO->minorDbMaintenance(); } else { + FreshRSS_category_Controller::refreshDynamicOpmls(); list($updated_feeds, $feed, $nb_new_articles) = self::actualizeFeed($id, $url, $force, null, $noCommit, $maxFeeds); } diff --git a/app/Controllers/importExportController.php b/app/Controllers/importExportController.php index 8b2b9cf27..c7b2d579f 100644 --- a/app/Controllers/importExportController.php +++ b/app/Controllers/importExportController.php @@ -5,7 +5,10 @@ */ class FreshRSS_importExport_Controller extends FreshRSS_ActionController { + /** @var FreshRSS_EntryDAO */ private $entryDAO; + + /** @var FreshRSS_FeedDAO */ private $feedDAO; /** @@ -96,7 +99,8 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController { $importService = new FreshRSS_Import_Service($username); foreach ($list_files['opml'] as $opml_file) { - if (!$importService->importOpml($opml_file)) { + $importService->importOpml($opml_file); + if (!$importService->lastStatus()) { $ok = false; if (FreshRSS_Context::$isCli) { fwrite(STDERR, 'FreshRSS error during OPML import' . "\n"); @@ -520,7 +524,7 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController { $feed->_name($name); $feed->_website($website); if (!empty($origin['disable'])) { - $feed->_ttl(-1 * FreshRSS_Context::$user_conf->ttl_default); + $feed->_mute(true); } // Call the extension hook diff --git a/app/Controllers/indexController.php b/app/Controllers/indexController.php index 34770fffb..70b8824c3 100755 --- a/app/Controllers/indexController.php +++ b/app/Controllers/indexController.php @@ -174,6 +174,76 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController { header('Content-Type: application/rss+xml; charset=utf-8'); } + public function opmlAction() { + $allow_anonymous = FreshRSS_Context::$system_conf->allow_anonymous; + $token = FreshRSS_Context::$user_conf->token; + $token_param = Minz_Request::param('token', ''); + $token_is_ok = ($token != '' && $token === $token_param); + + // Check if user has access. + if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous && !$token_is_ok) { + Minz_Error::error(403); + } + + try { + $this->updateContext(); + } catch (FreshRSS_Context_Exception $e) { + Minz_Error::error(404); + } + + $get = FreshRSS_Context::currentGet(true); + if (is_array($get)) { + $type = $get[0]; + $id = $get[1]; + } else { + $type = $get; + $id = ''; + } + + $catDAO = FreshRSS_Factory::createCategoryDao(); + $categories = $catDAO->listCategories(true, true); + $this->view->excludeMutedFeeds = true; + + switch ($type) { + case 'a': + $this->view->categories = $categories; + break; + case 'c': + $cat = $categories[$id] ?? null; + if ($cat == null) { + Minz_Error::error(404); + return; + } + $this->view->categories = [ $cat ]; + break; + case 'f': + // We most likely already have the feed object in cache + $feed = FreshRSS_CategoryDAO::findFeed($categories, $id); + if ($feed == null) { + $feedDAO = FreshRSS_Factory::createFeedDao(); + $feed = $feedDAO->searchById($id); + if ($feed == null) { + Minz_Error::error(404); + return; + } + } + $this->view->feeds = [ $feed ]; + break; + case 's': + case 't': + case 'T': + default: + Minz_Error::error(404); + return; + } + + require_once(LIB_PATH . '/lib_opml.php'); + + // No layout for OPML output. + $this->view->_layout(false); + header('Content-Type: application/xml; charset=utf-8'); + } + /** * This action updates the Context object by using request parameters. * diff --git a/app/Controllers/javascriptController.php b/app/Controllers/javascriptController.php index 3eaae486a..c2a5cb872 100755 --- a/app/Controllers/javascriptController.php +++ b/app/Controllers/javascriptController.php @@ -8,6 +8,10 @@ class FreshRSS_javascript_Controller extends FreshRSS_ActionController { public function actualizeAction() { header('Content-Type: application/json; charset=UTF-8'); Minz_Session::_param('actualize_feeds', false); + + $catDAO = FreshRSS_Factory::createCategoryDao(); + $this->view->categories = $catDAO->listCategoriesOrderUpdate(FreshRSS_Context::$user_conf->dynamic_opml_ttl_default); + $feedDAO = FreshRSS_Factory::createFeedDao(); $this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$user_conf->ttl_default); } diff --git a/app/Controllers/subscriptionController.php b/app/Controllers/subscriptionController.php index 8c7cf7e4a..cdf30b378 100644 --- a/app/Controllers/subscriptionController.php +++ b/app/Controllers/subscriptionController.php @@ -19,7 +19,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { $catDAO->checkDefault(); $feedDAO->updateTTL(); - $this->view->categories = $catDAO->listSortedCategories(false); + $this->view->categories = $catDAO->listSortedCategories(false, true, true); $this->view->default_category = $catDAO->getDefault(); $signalError = false; @@ -120,11 +120,8 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { $cat = intval(Minz_Request::param('category', 0)); - $mute = Minz_Request::param('mute', false); - $ttl = intval(Minz_Request::param('ttl', FreshRSS_Feed::TTL_DEFAULT)); - if ($mute && FreshRSS_Feed::TTL_DEFAULT === $ttl) { - $ttl = FreshRSS_Context::$user_conf->ttl_default; - } + $feed->_ttl(intval(Minz_Request::param('ttl', FreshRSS_Feed::TTL_DEFAULT))); + $feed->_mute(boolval(Minz_Request::param('mute', false))); $feed->_attributes('read_upon_gone', Minz_Request::paramTernary('read_upon_gone')); $feed->_attributes('mark_updated_article_unread', Minz_Request::paramTernary('mark_updated_article_unread')); @@ -196,8 +193,8 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { $feed->_filtersAction('read', preg_split('/[\n\r]+/', Minz_Request::param('filteractions_read', ''))); - $feed_kind = Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS); - if ($feed_kind == FreshRSS_Feed::KIND_HTML_XPATH) { + $feed->_kind(intval(Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS))); + if ($feed->kind() == FreshRSS_Feed::KIND_HTML_XPATH) { $xPathSettings = []; if (Minz_Request::param('xPathItem', '') != '') $xPathSettings['item'] = Minz_Request::param('xPathItem', '', true); if (Minz_Request::param('xPathItemTitle', '') != '') $xPathSettings['itemTitle'] = Minz_Request::param('xPathItemTitle', '', true); @@ -214,7 +211,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { $values = array( 'name' => Minz_Request::param('name', ''), - 'kind' => $feed_kind, + 'kind' => $feed->kind(), 'description' => sanitizeHTML(Minz_Request::param('description', '', true)), 'website' => checkUrl(Minz_Request::param('website', '')), 'url' => checkUrl(Minz_Request::param('url', '')), @@ -222,7 +219,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { 'pathEntries' => Minz_Request::param('path_entries', ''), 'priority' => intval(Minz_Request::param('priority', FreshRSS_Feed::PRIORITY_MAIN_STREAM)), 'httpAuth' => $httpAuth, - 'ttl' => $ttl * ($mute ? -1 : 1), + 'ttl' => $feed->ttl(true), 'attributes' => $feed->attributes(), ); @@ -300,7 +297,17 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController { $position = Minz_Request::param('position'); $category->_attributes('position', '' === $position ? null : (int) $position); + $opml_url = checkUrl(Minz_Request::param('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::param('name', ''), 'attributes' => $category->attributes(), ]; 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 @@ <?php class FreshRSS_Category extends Minz_Model { + + /** + * Normal + * @var int + */ + const KIND_NORMAL = 0; + + /** + * Category tracking a third-party Dynamic OPML + * @var int + */ + const KIND_DYNAMIC_OPML = 2; + + const TTL_DEFAULT = 0; + /** * @var int */ private $id = 0; + /** @var int */ + private $kind = 0; private $name; private $nbFeeds = -1; private $nbNotRead = -1; + /** @var array<FreshRSS_Feed>|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<FreshRSS_Feed> */ 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>|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<string,FreshRSS_Feed> */ + $dryRunFeeds = []; + foreach ($dryRunCategory->feeds() as $dryRunFeed) { + $dryRunFeeds[$dryRunFeed->url()] = $dryRunFeed; + } + + /** @var array<string,FreshRSS_Feed> */ + $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<FreshRSS_Category> */ + 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<FreshRSS_Category> $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<FreshRSS_Category> $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 @@ -198,6 +198,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. */ public static function isFeed(): bool { @@ -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<string,mixed> $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<FreshRSS_Feed> */ 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<FreshRSS_Feed> */ - 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 '<span class="icon">' . $alt . '</span>'; + 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 '<img class="icon" src="' . Minz_Url::display($url) . '" loading="lazy" alt="' . $alt . '" />'; + switch ($type) { + case self::ICON_URL: + return Minz_Url::display($url); + case self::ICON_IMG: + return '<img class="icon" src="' . Minz_Url::display($url) . '" loading="lazy" alt="' . $alt . '"' . $title . ' />'; + case self::ICON_EMOJI: + default: + return '<span class="icon"' . $title . '>' . $alt . '</span>'; + } } } 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<string,mixed> $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<string,string> */ public $notification; + /** @var bool */ + public $excludeMutedFeeds; // Substriptions public $default_category; diff --git a/app/SQL/install.sql.mysql.php b/app/SQL/install.sql.mysql.php index 2009e09d4..226cc0346 100644 --- a/app/SQL/install.sql.mysql.php +++ b/app/SQL/install.sql.mysql.php @@ -7,6 +7,9 @@ $GLOBALS['SQL_CREATE_TABLES'] = <<<'SQL' CREATE TABLE IF NOT EXISTS `_category` ( `id` INT NOT NULL AUTO_INCREMENT, -- v0.7 `name` VARCHAR(191) NOT NULL, -- Max index length for Unicode is 191 characters (767 bytes) FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE + `kind` SMALLINT DEFAULT 0, -- 1.20.0 + `lastUpdate` BIGINT DEFAULT 0, -- 1.20.0 + `error` SMALLINT DEFAULT 0, -- 1.20.0 `attributes` TEXT, -- v1.15.0 PRIMARY KEY (`id`), UNIQUE KEY (`name`) -- v0.7 @@ -16,7 +19,7 @@ ENGINE = INNODB; CREATE TABLE IF NOT EXISTS `_feed` ( `id` INT NOT NULL AUTO_INCREMENT, -- v0.7 `url` VARCHAR(511) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL, - `kind` SMALLINT DEFAULT 0, -- 1.20.0 + `kind` SMALLINT DEFAULT 0, -- 1.20.0 `category` INT DEFAULT 0, -- 1.20.0 `name` VARCHAR(191) NOT NULL, `website` VARCHAR(255) CHARACTER SET latin1 COLLATE latin1_bin, diff --git a/app/SQL/install.sql.pgsql.php b/app/SQL/install.sql.pgsql.php index 58381bb11..d453d65fb 100644 --- a/app/SQL/install.sql.pgsql.php +++ b/app/SQL/install.sql.pgsql.php @@ -7,6 +7,9 @@ $GLOBALS['SQL_CREATE_TABLES'] = <<<'SQL' CREATE TABLE IF NOT EXISTS `_category` ( "id" SERIAL PRIMARY KEY, "name" VARCHAR(255) UNIQUE NOT NULL, + "kind" SMALLINT DEFAULT 0, -- 1.20.0 + "lastUpdate" BIGINT DEFAULT 0, -- 1.20.0 + "error" SMALLINT DEFAULT 0, -- 1.20.0 "attributes" TEXT -- v1.15.0 ); diff --git a/app/SQL/install.sql.sqlite.php b/app/SQL/install.sql.sqlite.php index 24de8297a..dd2cca708 100644 --- a/app/SQL/install.sql.sqlite.php +++ b/app/SQL/install.sql.sqlite.php @@ -7,6 +7,9 @@ $GLOBALS['SQL_CREATE_TABLES'] = <<<'SQL' CREATE TABLE IF NOT EXISTS `category` ( `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` VARCHAR(255) NOT NULL, + `kind` SMALLINT DEFAULT 0, -- 1.20.0 + `lastUpdate` BIGINT DEFAULT 0, -- 1.20.0 + `error` SMALLINT DEFAULT 0, -- 1.20.0 `attributes` TEXT, -- v1.15.0 UNIQUE (`name`) ); @@ -14,7 +17,7 @@ CREATE TABLE IF NOT EXISTS `category` ( CREATE TABLE IF NOT EXISTS `feed` ( `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` VARCHAR(511) NOT NULL, - `kind` SMALLINT DEFAULT 0, -- 1.20.0 + `kind` SMALLINT DEFAULT 0, -- 1.20.0 `category` INTEGER DEFAULT 0, -- 1.20.0 `name` VARCHAR(255) NOT NULL, `website` VARCHAR(255), diff --git a/app/Services/ExportService.php b/app/Services/ExportService.php index 25e217968..eb1ec3f84 100644 --- a/app/Services/ExportService.php +++ b/app/Services/ExportService.php @@ -47,7 +47,8 @@ class FreshRSS_Export_Service { $view = new FreshRSS_View(); $day = date('Y-m-d'); - $view->categories = $this->category_dao->listCategories(true); + $view->categories = $this->category_dao->listCategories(true, true); + $view->excludeMutedFeeds = false; return [ "feeds_{$day}.opml.xml", diff --git a/app/Services/ImportService.php b/app/Services/ImportService.php index 7e7cccfdb..4cd866377 100644 --- a/app/Services/ImportService.php +++ b/app/Services/ImportService.php @@ -10,25 +10,36 @@ class FreshRSS_Import_Service { /** @var FreshRSS_FeedDAO */ private $feedDAO; + /** @var bool true if success, false otherwise */ + private $lastStatus; + /** * Initialize the service for the given user. * * @param string $username */ - public function __construct($username) { + public function __construct($username = null) { require_once(LIB_PATH . '/lib_opml.php'); $this->catDAO = FreshRSS_Factory::createCategoryDao($username); $this->feedDAO = FreshRSS_Factory::createFeedDao($username); } + /** @return bool true if success, false otherwise */ + public function lastStatus(): bool { + return $this->lastStatus; + } + /** * This method parses and imports an OPML file. * * @param string $opml_file the OPML file content. - * @return boolean false if an error occurred, true otherwise. + * @param FreshRSS_Category|null $parent_cat the name of the parent category. + * @param boolean $flatten true to disable categories, false otherwise. + * @return array<FreshRSS_Category>|false an array of categories containing some feeds, or false if an error occurred. */ - public function importOpml($opml_file) { + public function importOpml(string $opml_file, $parent_cat = null, $flatten = false, $dryRun = false) { + $this->lastStatus = true; $opml_array = array(); try { $opml_array = libopml_parse_string($opml_file, false); @@ -38,24 +49,22 @@ class FreshRSS_Import_Service { } else { Minz_Log::warning($e->getMessage()); } + $this->lastStatus = false; return false; } - $this->catDAO->checkDefault(); - - return $this->addOpmlElements($opml_array['body']); + return $this->addOpmlElements($opml_array['body'], $parent_cat, $flatten, $dryRun); } /** * This method imports an OPML file based on its body. * * @param array $opml_elements an OPML element (body or outline). - * @param string $parent_cat the name of the parent category. - * @return boolean false if an error occurred, true otherwise. + * @param FreshRSS_Category|null $parent_cat the name of the parent category. + * @param boolean $flatten true to disable categories, false otherwise. + * @return array<FreshRSS_Category> an array of categories containing some feeds */ - private function addOpmlElements($opml_elements, $parent_cat = null) { - $isOkStatus = true; - + private function addOpmlElements($opml_elements, $parent_cat = null, $flatten = false, $dryRun = false) { $nb_feeds = count($this->feedDAO->listFeeds()); $nb_cats = count($this->catDAO->listCategories(false)); $limits = FreshRSS_Context::$system_conf->limits; @@ -67,64 +76,61 @@ class FreshRSS_Import_Service { (isset($b['xmlUrl']) ? 'Z' : 'A') . (isset($b['text']) ? $b['text'] : '')); }); + $categories = []; + foreach ($opml_elements as $elt) { if (isset($elt['xmlUrl'])) { // If xmlUrl exists, it means it is a feed if (FreshRSS_Context::$isCli && $nb_feeds >= $limits['max_feeds']) { Minz_Log::warning(_t('feedback.sub.feed.over_max', $limits['max_feeds'])); - $isOkStatus = false; + $this->lastStatus = false; continue; } - if ($this->addFeedOpml($elt, $parent_cat)) { + if ($this->addFeedOpml($elt, $parent_cat, $dryRun)) { $nb_feeds++; } else { - $isOkStatus = false; + $this->lastStatus = false; } } elseif (!empty($elt['text'])) { // No xmlUrl? It should be a category! - $limit_reached = ($nb_cats >= $limits['max_categories']); + $limit_reached = !$flatten && ($nb_cats >= $limits['max_categories']); if (!FreshRSS_Context::$isCli && $limit_reached) { Minz_Log::warning(_t('feedback.sub.category.over_max', $limits['max_categories'])); - $isOkStatus = false; - continue; + $this->lastStatus = false; + $flatten = true; } - if ($this->addCategoryOpml($elt, $parent_cat, $limit_reached)) { + $category = $this->addCategoryOpml($elt, $parent_cat, $flatten, $dryRun); + + if ($category) { $nb_cats++; - } else { - $isOkStatus = false; + $categories[] = $category; } } } - return $isOkStatus; + return $categories; } /** * This method imports an OPML feed element. * * @param array $feed_elt an OPML element (must be a feed element). - * @param string $parent_cat the name of the parent category. - * @return boolean false if an error occurred, true otherwise. + * @param FreshRSS_Category|null $parent_cat the name of the parent category. + * @return FreshRSS_Feed|null a feed. */ - private function addFeedOpml($feed_elt, $parent_cat) { + private function addFeedOpml($feed_elt, $parent_cat, $dryRun = false) { if ($parent_cat == null) { // This feed has no parent category so we get the default one $this->catDAO->checkDefault(); - $default_cat = $this->catDAO->getDefault(); - $parent_cat = $default_cat->name(); - } - - $cat = $this->catDAO->searchByName($parent_cat); - if ($cat == null) { - // If there is not $cat, it means parent category does not exist in - // database. - // If it happens, take the default category. - $this->catDAO->checkDefault(); - $cat = $this->catDAO->getDefault(); + $parent_cat = $this->catDAO->getDefault(); + if ($parent_cat == null) { + $this->lastStatus = false; + return null; + } } // We get different useful information @@ -139,11 +145,11 @@ class FreshRSS_Import_Service { $description = Minz_Helper::htmlspecialchars_utf8($feed_elt['description']); } - $error = false; try { // Create a Feed object and add it in DB $feed = new FreshRSS_Feed($url); - $feed->_category($cat->id()); + $feed->_category($parent_cat->id()); + $parent_cat->addFeed($feed); $feed->_name($name); $feed->_website($website); $feed->_description($description); @@ -180,14 +186,20 @@ class FreshRSS_Import_Service { } // Call the extension hook + /** @var FreshRSS_Feed|null */ $feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed); + if ($dryRun) { + return $feed; + } if ($feed != null) { - // addFeedObject checks if feed is already in DB so nothing else to - // check here + // addFeedObject checks if feed is already in DB $id = $this->feedDAO->addFeedObject($feed); - $error = ($id == false); - } else { - $error = true; + if ($id == false) { + $this->lastStatus = false; + } else { + $feed->_id($id); + return $feed; + } } } catch (FreshRSS_Feed_Exception $e) { if (FreshRSS_Context::$isCli) { @@ -195,54 +207,76 @@ class FreshRSS_Import_Service { } else { Minz_Log::warning($e->getMessage()); } - $error = true; + $this->lastStatus = false; } - if ($error) { - if (FreshRSS_Context::$isCli) { - fwrite(STDERR, 'FreshRSS error during OPML feed import from URL: ' . $url . ' in category ' . $cat->id() . "\n"); - } else { - Minz_Log::warning('Error during OPML feed import from URL: ' . $url . ' in category ' . $cat->id()); - } + if (FreshRSS_Context::$isCli) { + fwrite(STDERR, 'FreshRSS error during OPML feed import from URL: ' . + SimplePie_Misc::url_remove_credentials($url) . ' in category ' . $parent_cat->id() . "\n"); + } else { + Minz_Log::warning('Error during OPML feed import from URL: ' . + SimplePie_Misc::url_remove_credentials($url) . ' in category ' . $parent_cat->id()); } - return !$error; + return null; } /** * This method imports an OPML category element. * * @param array $cat_elt an OPML element (must be a category element). - * @param string $parent_cat the name of the parent category. - * @param boolean $cat_limit_reached indicates if category limit has been reached. - * if yes, category is not added (but we try for feeds!) - * @return boolean false if an error occurred, true otherwise. + * @param FreshRSS_Category|null $parent_cat the name of the parent category. + * @param boolean $flatten true to disable categories, false otherwise. + * @return FreshRSS_Category|null a new category containing some feeds, or null if no category was created, or false if an error occurred. */ - private function addCategoryOpml($cat_elt, $parent_cat, $cat_limit_reached) { - // Create a new Category object - $catName = Minz_Helper::htmlspecialchars_utf8($cat_elt['text']); - $cat = new FreshRSS_Category($catName); - - $error = true; - if (FreshRSS_Context::$isCli || !$cat_limit_reached) { - $id = $this->catDAO->addCategoryObject($cat); - $error = ($id === false); - } - if ($error) { - if (FreshRSS_Context::$isCli) { - fwrite(STDERR, 'FreshRSS error during OPML category import from URL: ' . $catName . "\n"); + private function addCategoryOpml($cat_elt, $parent_cat, $flatten = false, $dryRun = false) { + $error = false; + $cat = null; + if (!$flatten) { + $catName = Minz_Helper::htmlspecialchars_utf8($cat_elt['text']); + $cat = new FreshRSS_Category($catName); + + foreach ($cat_elt as $key => $value) { + if (is_array($value) && !empty($value['value']) && ($value['namespace'] ?? '') === FreshRSS_Export_Service::FRSS_NAMESPACE) { + switch ($key) { + case 'opmlUrl': + $opml_url = checkUrl($value['value']); + if ($opml_url != '') { + $cat->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML); + $cat->_attributes('opml_url', $opml_url); + } + break; + } + } + } + + if (!$dryRun) { + $id = $this->catDAO->addCategoryObject($cat); + if ($id == false) { + $this->lastStatus = false; + $error = true; + } else { + $cat->_id($id); + } + } + if ($error) { + if (FreshRSS_Context::$isCli) { + fwrite(STDERR, 'FreshRSS error during OPML category import from URL: ' . $catName . "\n"); + } else { + Minz_Log::warning('Error during OPML category import from URL: ' . $catName); + } } else { - Minz_Log::warning('Error during OPML category import from URL: ' . $catName); + $parent_cat = $cat; } } if (isset($cat_elt['@outlines'])) { // Our cat_elt contains more categories or more feeds, so we // add them recursively. - // Note: FreshRSS does not support yet category arborescence - $error &= !$this->addOpmlElements($cat_elt['@outlines'], $catName); + // Note: FreshRSS does not support yet category arborescence, so always flatten from here + $this->addOpmlElements($cat_elt['@outlines'], $parent_cat, true, $dryRun); } - return !$error; + return $cat; } } diff --git a/app/i18n/cz/gen.php b/app/i18n/cz/gen.php index 2a986b86d..26e7387ea 100644 --- a/app/i18n/cz/gen.php +++ b/app/i18n/cz/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Jít zpět na vaše kanály RSS', 'cancel' => 'Zrušit', 'create' => 'Vytvořit', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Snížit úroveň', 'disable' => 'Zakázat', 'empty' => 'Vyprázdnit', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Zvýšit úroveň', 'purge' => 'Vymazat', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Odebrat', 'rename' => 'Přejmenovat', 'see_website' => 'Zobrazit webovou stránku', diff --git a/app/i18n/cz/sub.php b/app/i18n/cz/sub.php index 7efc2ab99..045479d8d 100644 --- a/app/i18n/cz/sub.php +++ b/app/i18n/cz/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Kategorie', 'add' => 'Přidat kategorii', 'archiving' => 'Archivace', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Vyprázdit kategorii', 'information' => 'Informace', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Zobrazit pozici', 'position_help' => 'Pro ovládání pořadí řazení kategorií', 'title' => 'Název', @@ -181,6 +186,7 @@ return array( '_' => 'Správa odběrů', 'add' => 'Přidat kanál nebo kategorii', 'add_category' => 'Přidat kategorii', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Přidat kanál', 'add_label' => 'Přidat popisek', 'delete_label' => 'Odstranit popisek', diff --git a/app/i18n/de/gen.php b/app/i18n/de/gen.php index c1b93978d..3d05eb5ab 100644 --- a/app/i18n/de/gen.php +++ b/app/i18n/de/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Zurück zu Ihren RSS-Feeds gehen', 'cancel' => 'Abbrechen', 'create' => 'Erstellen', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Zurückstufen', 'disable' => 'Deaktivieren', 'empty' => 'Leeren', @@ -31,6 +32,7 @@ return array( 'open_url' => 'URL öffnen', 'promote' => 'Hochstufen', 'purge' => 'Bereinigen', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Entfernen', 'rename' => 'Umbenennen', 'see_website' => 'Website ansehen', diff --git a/app/i18n/de/sub.php b/app/i18n/de/sub.php index 7eba536da..c0d1e3d04 100644 --- a/app/i18n/de/sub.php +++ b/app/i18n/de/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Kategorie', 'add' => 'Kategorie hinzufügen', 'archiving' => 'Archivierung', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Leere Kategorie', 'information' => 'Information', // IGNORE + 'opml_url' => 'OPML URL', // TODO 'position' => 'Reihenfolge', 'position_help' => 'Steuert die Kategoriesortierung', 'title' => 'Titel', @@ -181,6 +186,7 @@ return array( '_' => 'Abonnementverwaltung', 'add' => 'Feed oder Kategorie hinzufügen', 'add_category' => 'Kategorie hinzufügen', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Feed hinzufügen', 'add_label' => 'Label hinzufügen', 'delete_label' => 'Label löschen', diff --git a/app/i18n/en-us/gen.php b/app/i18n/en-us/gen.php index 52e0f9bf5..aedf7bd50 100644 --- a/app/i18n/en-us/gen.php +++ b/app/i18n/en-us/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Go back to your RSS feeds', // IGNORE 'cancel' => 'Cancel', // IGNORE 'create' => 'Create', // IGNORE + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Demote', // IGNORE 'disable' => 'Disable', // IGNORE 'empty' => 'Empty', // IGNORE @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // IGNORE 'promote' => 'Promote', // IGNORE 'purge' => 'Purge', // IGNORE + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Remove', // IGNORE 'rename' => 'Rename', // IGNORE 'see_website' => 'See website', // IGNORE diff --git a/app/i18n/en-us/sub.php b/app/i18n/en-us/sub.php index 69853e8cb..7006ff659 100644 --- a/app/i18n/en-us/sub.php +++ b/app/i18n/en-us/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Category', // IGNORE 'add' => 'Add a category', // IGNORE 'archiving' => 'Archiving', // IGNORE + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Empty category', // IGNORE 'information' => 'Information', // IGNORE + 'opml_url' => 'OPML URL', // TODO 'position' => 'Display position', // IGNORE 'position_help' => 'To control category sort order', // IGNORE 'title' => 'Title', // IGNORE @@ -181,6 +186,7 @@ return array( '_' => 'Subscription management', // IGNORE 'add' => 'Add a feed or category', // IGNORE 'add_category' => 'Add a category', // IGNORE + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Add a feed', // IGNORE 'add_label' => 'Add a label', // IGNORE 'delete_label' => 'Delete a label', // IGNORE diff --git a/app/i18n/en/gen.php b/app/i18n/en/gen.php index ce3a72bc4..e14790440 100644 --- a/app/i18n/en/gen.php +++ b/app/i18n/en/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Go back to your RSS feeds', 'cancel' => 'Cancel', 'create' => 'Create', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Demote', 'disable' => 'Disable', 'empty' => 'Empty', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', 'promote' => 'Promote', 'purge' => 'Purge', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Remove', 'rename' => 'Rename', 'see_website' => 'See website', diff --git a/app/i18n/en/sub.php b/app/i18n/en/sub.php index 697f5facc..a8bb995a2 100644 --- a/app/i18n/en/sub.php +++ b/app/i18n/en/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Category', 'add' => 'Add a category', 'archiving' => 'Archiving', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Empty category', 'information' => 'Information', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Display position', 'position_help' => 'To control category sort order', 'title' => 'Title', @@ -181,6 +186,7 @@ return array( '_' => 'Subscription management', 'add' => 'Add a feed or category', 'add_category' => 'Add a category', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Add a feed', 'add_label' => 'Add a label', 'delete_label' => 'Delete a label', diff --git a/app/i18n/es/gen.php b/app/i18n/es/gen.php index eb0c96df3..b224000a3 100755 --- a/app/i18n/es/gen.php +++ b/app/i18n/es/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← regresar a tus fuentes RSS', 'cancel' => 'Cancelar', 'create' => 'Crear', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Degradar', 'disable' => 'Desactivar', 'empty' => 'Vaciar', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Promover', 'purge' => 'Eliminar', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Borrar', 'rename' => 'Cambiar el nombre a', 'see_website' => 'Ver web', diff --git a/app/i18n/es/sub.php b/app/i18n/es/sub.php index d53e6b2da..2302914cc 100755 --- a/app/i18n/es/sub.php +++ b/app/i18n/es/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Categoría', 'add' => 'Añadir categoría', 'archiving' => 'Archivo', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Vaciar categoría', 'information' => 'Información', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Posición de visualización', 'position_help' => 'Para controlar el orden de clasificación de categorías', 'title' => 'Título', @@ -181,6 +186,7 @@ return array( '_' => 'Administración de suscripciones', 'add' => 'Agregar un feed o una categoría', 'add_category' => 'Agregar una categoría', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Añadir un feed', 'add_label' => 'Añadir una etiqueta', 'delete_label' => 'Eliminar una etiqueta', diff --git a/app/i18n/fr/gen.php b/app/i18n/fr/gen.php index b42539047..40eca19c1 100644 --- a/app/i18n/fr/gen.php +++ b/app/i18n/fr/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Retour à vos flux RSS', 'cancel' => 'Annuler', 'create' => 'Créer', + 'delete_muted_feeds' => 'Supprimer les flux désactivés', 'demote' => 'Rétrograder', 'disable' => 'Désactiver', 'empty' => 'Vider', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Ouvrir l’URL', 'promote' => 'Promouvoir', 'purge' => 'Purger', + 'refresh_opml' => 'Rafraîchir OPML', 'remove' => 'Supprimer', 'rename' => 'Renommer', 'see_website' => 'Voir le site', diff --git a/app/i18n/fr/sub.php b/app/i18n/fr/sub.php index 461c8abdd..90ece987b 100644 --- a/app/i18n/fr/sub.php +++ b/app/i18n/fr/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Catégorie', 'add' => 'Ajouter catégorie', 'archiving' => 'Archivage', + 'dynamic_opml' => array( + '_' => 'OPML dynamique', + 'help' => 'Fournir l’URL d’un <a href=http://opml.org/ target=_blank>fichier OPML</a> qui donnera dynamiquement la liste des flux de cette catégorie', + ), 'empty' => 'Catégorie vide', 'information' => 'Informations', + 'opml_url' => 'URL de l’OPML', 'position' => 'Position d’affichage', 'position_help' => 'Pour contrôler l’ordre de tri des catégories', 'title' => 'Titre', @@ -112,7 +117,7 @@ return array( 'title' => 'Maintenance', // IGNORE ), 'moved_category_deleted' => 'Lors de la suppression d’une catégorie, ses flux seront automatiquement classés dans <em>%s</em>.', - 'mute' => 'muet', + 'mute' => 'désactivé', 'no_selected' => 'Aucun flux sélectionné.', 'number_entries' => '%d articles', // IGNORE 'priority' => array( @@ -181,6 +186,7 @@ return array( '_' => 'Gestion des abonnements', 'add' => 'Ajouter un flux/une catégorie', 'add_category' => 'Ajouter une catégorie', + 'add_dynamic_opml' => 'Ajouter un OPML dynamique', 'add_feed' => 'Ajouter un flux', 'add_label' => 'Ajouter une étiquette', 'delete_label' => 'Supprimer une étiquette', diff --git a/app/i18n/he/gen.php b/app/i18n/he/gen.php index 0d0bcfec1..f42d2268f 100644 --- a/app/i18n/he/gen.php +++ b/app/i18n/he/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← חזרה להזנות הRSS שלך', 'cancel' => 'ביטול', 'create' => 'יצירה', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Demote', // TODO 'disable' => 'Disable', // TODO 'empty' => 'Empty', // TODO @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Promote', // TODO 'purge' => 'Purge', // TODO + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Remove', // TODO 'rename' => 'Rename', // TODO 'see_website' => 'ראו אתר', diff --git a/app/i18n/he/sub.php b/app/i18n/he/sub.php index 063a5da99..c024bb6f4 100644 --- a/app/i18n/he/sub.php +++ b/app/i18n/he/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'קטגוריה', 'add' => 'Add a category', // TODO 'archiving' => 'ארכוב', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Empty category', // TODO 'information' => 'מידע', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Display position', // TODO 'position_help' => 'To control category sort order', // TODO 'title' => 'כותרת', @@ -181,6 +186,7 @@ return array( '_' => 'ניהול הרשמות', 'add' => 'Add a feed or category', // TODO 'add_category' => 'Add a category', // TODO + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Add a feed', // TODO 'add_label' => 'Add a label', // TODO 'delete_label' => 'Delete a label', // TODO diff --git a/app/i18n/it/gen.php b/app/i18n/it/gen.php index 7708a9202..f8ad2bd48 100644 --- a/app/i18n/it/gen.php +++ b/app/i18n/it/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Indietro', 'cancel' => 'Annulla', 'create' => 'Crea', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Demote', // TODO 'disable' => 'Disabilita', 'empty' => 'Vuoto', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Promote', // TODO 'purge' => 'Purge', // TODO + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Rimuovi', 'rename' => 'Rename', // TODO 'see_website' => 'Vai al sito', diff --git a/app/i18n/it/sub.php b/app/i18n/it/sub.php index 9ffaae80b..e76037f98 100644 --- a/app/i18n/it/sub.php +++ b/app/i18n/it/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Categoria', 'add' => 'Aggiungi categoria', 'archiving' => 'Archiviazione', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Categoria vuota', 'information' => 'Informazioni', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Display position', // TODO 'position_help' => 'To control category sort order', // TODO 'title' => 'Titolo', @@ -181,6 +186,7 @@ return array( '_' => 'Gestione sottoscrizioni', 'add' => 'Add a feed or category', // TODO 'add_category' => 'Add a category', // TODO + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Add a feed', // TODO 'add_label' => 'Add a label', // TODO 'delete_label' => 'Delete a label', // TODO diff --git a/app/i18n/ja/gen.php b/app/i18n/ja/gen.php index a1bc830b6..19ee5ad07 100644 --- a/app/i18n/ja/gen.php +++ b/app/i18n/ja/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← RSSフィードに戻る', 'cancel' => 'キャンセル', 'create' => '作成', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => '寄付', 'disable' => '無効', 'empty' => '空', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'プロモート', 'purge' => '不要なデータの削除', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => '消去', 'rename' => 'リネーム', 'see_website' => 'webサイトを閲覧してください', diff --git a/app/i18n/ja/sub.php b/app/i18n/ja/sub.php index 273274d54..46c5e6a30 100644 --- a/app/i18n/ja/sub.php +++ b/app/i18n/ja/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'カテゴリ', 'add' => 'カテゴリを追加する', 'archiving' => 'アーカイブ', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'からのカテゴリ', 'information' => 'インフォメーション', + 'opml_url' => 'OPML URL', // TODO 'position' => '表示位置', 'position_help' => 'カテゴリの表示順を操作する', 'title' => 'タイトル', @@ -181,6 +186,7 @@ return array( '_' => '購読されたものの管理', 'add' => 'フィードあるいはカテゴリを追加します', 'add_category' => 'カテゴリの追加', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'フィードの追加', 'add_label' => 'ラベルの追加', 'delete_label' => 'ラベルの削除', diff --git a/app/i18n/ko/gen.php b/app/i18n/ko/gen.php index eabf43bc9..7070f105d 100644 --- a/app/i18n/ko/gen.php +++ b/app/i18n/ko/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← RSS 피드로 돌아가기', 'cancel' => '취소', 'create' => '생성', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => '목록 수준 내리기', 'disable' => '비활성화', 'empty' => '비우기', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => '목록 수준 올리기', 'purge' => '제거', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => '삭제', 'rename' => '이름 바꾸기', 'see_website' => '웹사이트 열기', diff --git a/app/i18n/ko/sub.php b/app/i18n/ko/sub.php index ae048244a..b865fe4db 100644 --- a/app/i18n/ko/sub.php +++ b/app/i18n/ko/sub.php @@ -24,8 +24,13 @@ return array( '_' => '카테고리', 'add' => '카테고리 추가', 'archiving' => '보관', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => '빈 카테고리', 'information' => '정보', + 'opml_url' => 'OPML URL', // TODO 'position' => '표시 위치', 'position_help' => '정렬 순서 제어', 'title' => '제목', @@ -181,6 +186,7 @@ return array( '_' => '구독 관리', 'add' => '피드 혹은 카테고리 추가', 'add_category' => '카테고리 추가', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => '피드 추가', 'add_label' => '라벨 추가', 'delete_label' => '라벨 삭제', diff --git a/app/i18n/nl/gen.php b/app/i18n/nl/gen.php index 41024f7e0..6d6e3360b 100644 --- a/app/i18n/nl/gen.php +++ b/app/i18n/nl/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Ga terug naar je RSS feeds', 'cancel' => 'Annuleren', 'create' => 'Opslaan', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Degraderen', 'disable' => 'Uitzetten', 'empty' => 'Leeg', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Bevorderen', 'purge' => 'Zuiveren', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Verwijderen', 'rename' => 'Hernoemen', 'see_website' => 'Bekijk website', diff --git a/app/i18n/nl/sub.php b/app/i18n/nl/sub.php index 10d158852..031f671a2 100644 --- a/app/i18n/nl/sub.php +++ b/app/i18n/nl/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Categorie', 'add' => 'Voeg categorie', 'archiving' => 'Archiveren', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Lege categorie', 'information' => 'Informatie', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Weergavepositie', 'position_help' => 'Om de categorieweergave-sorteervolgorde te controleren', 'title' => 'Titel', @@ -181,6 +186,7 @@ return array( '_' => 'Abonnementenbeheer', 'add' => 'Feed of categorie toevoegen', 'add_category' => 'Categorie toevoegen', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Feed toevoegen', 'add_label' => 'Label toevoegen', 'delete_label' => 'Label verwijderen', diff --git a/app/i18n/oc/gen.php b/app/i18n/oc/gen.php index 7028b9fa6..a641afeaa 100644 --- a/app/i18n/oc/gen.php +++ b/app/i18n/oc/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Tornar a vòstres fluxes RSS', 'cancel' => 'Anullar', 'create' => 'Crear', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Retrogradar', 'disable' => 'Desactivar', 'empty' => 'Voidar', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Promòure', 'purge' => 'Purgar', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Levar', 'rename' => 'Renomenar', 'see_website' => 'Veire lo site', diff --git a/app/i18n/oc/sub.php b/app/i18n/oc/sub.php index 2f36d5889..d3f341269 100644 --- a/app/i18n/oc/sub.php +++ b/app/i18n/oc/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Categoria', 'add' => 'Ajustar categoria', 'archiving' => 'Archivar', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Categoria voida', 'information' => 'Informacions', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Mostrar la posicion', 'position_help' => 'Per contrarotlar l’òrdre de tria de la categoria', 'title' => 'Títol', @@ -181,6 +186,7 @@ return array( '_' => 'Gestion dels abonaments', 'add' => 'Apondon de flux o categoria', 'add_category' => 'Ajustar una categoria', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Ajustar un flux', 'add_label' => 'Ajustar una etiqueta', 'delete_label' => 'Suprimir una etiqueta', diff --git a/app/i18n/pl/gen.php b/app/i18n/pl/gen.php index e771917d7..c8b57632f 100644 --- a/app/i18n/pl/gen.php +++ b/app/i18n/pl/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Wróć do subskrybowanych kanałów RSS', 'cancel' => 'Anuluj', 'create' => 'Stwórz', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Zdegraduj', 'disable' => 'Wyłącz', 'empty' => 'Opróżnij', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Awansuj', 'purge' => 'Oczyść', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Usuń', 'rename' => 'Zmień nazwę', 'see_website' => 'Przejdź na stronę', diff --git a/app/i18n/pl/sub.php b/app/i18n/pl/sub.php index 103d0500e..22cdcd170 100644 --- a/app/i18n/pl/sub.php +++ b/app/i18n/pl/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Kategoria', 'add' => 'Dodaj kategoria', 'archiving' => 'Archiwizacja', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Pusta kategoria', 'information' => 'Informacje', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Miejsce wyświetlania', 'position_help' => 'Kontrola porządku sortowania kategorii', 'title' => 'Tytuł', @@ -181,6 +186,7 @@ return array( '_' => 'Zarządzanie subskrypcjami', 'add' => 'Dodaj kanał lub kategorię', 'add_category' => 'Dodaj kategorię', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Dodaj kanał', 'add_label' => 'Dodaj etykietę', 'delete_label' => 'Usuń etykietę', diff --git a/app/i18n/pt-br/gen.php b/app/i18n/pt-br/gen.php index bbe4e2dda..bfe8a311b 100644 --- a/app/i18n/pt-br/gen.php +++ b/app/i18n/pt-br/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Volte para o seu feeds RSS', 'cancel' => 'Cancelar', 'create' => 'Criar', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Despromover', 'disable' => 'Desabilitar', 'empty' => 'Vazio', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Promover', 'purge' => 'Limpar', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Remover', 'rename' => 'Renomear', 'see_website' => 'Ver o site', diff --git a/app/i18n/pt-br/sub.php b/app/i18n/pt-br/sub.php index 3d6f86147..451ae5445 100644 --- a/app/i18n/pt-br/sub.php +++ b/app/i18n/pt-br/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Categoria', 'add' => 'Adicionar categoria', 'archiving' => 'Arquivar', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Categoria vazia', 'information' => 'Informações', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Posição de exibição', 'position_help' => 'Para controlar a ordem de exibição', 'title' => 'Título', @@ -181,6 +186,7 @@ return array( '_' => 'Gerenciamento de inscrições', 'add' => 'Adicionar um feed ou categoria', 'add_category' => 'Adicionar uma categoria', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Adicionar um feed', 'add_label' => 'Adicionar uma etiqueta', 'delete_label' => 'Deletar uma etiqueta', diff --git a/app/i18n/ru/gen.php b/app/i18n/ru/gen.php index d425baf18..18b4d1aea 100644 --- a/app/i18n/ru/gen.php +++ b/app/i18n/ru/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Вернуться к вашим RSS-лентам', 'cancel' => 'Отменить', 'create' => 'Создать', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Понизить', 'disable' => 'Отключить', 'empty' => 'Опустошить', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Открыть URL', 'promote' => 'Продвинуть', 'purge' => 'Запустить очистку', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Удалить', 'rename' => 'Переименовать', 'see_website' => 'Посмотреть на сайте', diff --git a/app/i18n/ru/sub.php b/app/i18n/ru/sub.php index 257e655ee..f5cdf7445 100644 --- a/app/i18n/ru/sub.php +++ b/app/i18n/ru/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Категория', 'add' => 'Добавить категория', 'archiving' => 'Архивирование', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Пустая категория', 'information' => 'Информация', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Положение отображения', 'position_help' => 'Влияет на порядок отображения категорий', 'title' => 'Заголовок', @@ -181,6 +186,7 @@ return array( '_' => 'Управление подписками', 'add' => 'Добавить ленту или категорию', 'add_category' => 'Добавить категорию', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Добавить ленту', 'add_label' => 'Добавить метку', 'delete_label' => 'Удалить метку', diff --git a/app/i18n/sk/gen.php b/app/i18n/sk/gen.php index 11a2a4c33..0709e64ce 100644 --- a/app/i18n/sk/gen.php +++ b/app/i18n/sk/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← Späť na vaše RSS kanály', 'cancel' => 'Zrušiť', 'create' => 'Vytvoriť', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Degradovať', 'disable' => 'Zakázať', 'empty' => 'Vyprázdniť', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Podporiť', 'purge' => 'Vymazať', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Odstrániť', 'rename' => 'Premenovať', 'see_website' => 'Zobraziť webovú stránku', diff --git a/app/i18n/sk/sub.php b/app/i18n/sk/sub.php index 8a5ede475..e4aed5516 100644 --- a/app/i18n/sk/sub.php +++ b/app/i18n/sk/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Kategória', 'add' => 'Pridať kategória', 'archiving' => 'Archív', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Prázdna kategória', 'information' => 'Informácia', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Zobrazť pozíciu', 'position_help' => 'Na kontrolu zoradenia kategórií', 'title' => 'Názov', @@ -181,6 +186,7 @@ return array( '_' => 'Správa odoberaných kanálov', 'add' => 'Pridať kanál alebo kategóriu', 'add_category' => 'Pridať kategóriu', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Pridať kanál', 'add_label' => 'Pridať štítok', 'delete_label' => 'Zmazať štítok', diff --git a/app/i18n/tr/gen.php b/app/i18n/tr/gen.php index 121469f55..04f1a16eb 100644 --- a/app/i18n/tr/gen.php +++ b/app/i18n/tr/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← RSS akışlarınız için geri gidin', 'cancel' => 'İptal', 'create' => 'Oluştur', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => 'Yöneticilikten al', 'disable' => 'Pasif', 'empty' => 'Boş', @@ -31,6 +32,7 @@ return array( 'open_url' => 'Open URL', // TODO 'promote' => 'Yöneticilik ata', 'purge' => 'Temizle', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => 'Sil', 'rename' => 'Yeniden adlandır', 'see_website' => 'Siteyi gör', diff --git a/app/i18n/tr/sub.php b/app/i18n/tr/sub.php index c883a6caa..40a233ecb 100644 --- a/app/i18n/tr/sub.php +++ b/app/i18n/tr/sub.php @@ -24,8 +24,13 @@ return array( '_' => 'Kategori', 'add' => 'Kategori ekle', 'archiving' => 'Arşiv', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => 'Boş kategori', 'information' => 'Bilgi', + 'opml_url' => 'OPML URL', // TODO 'position' => 'Konumu göster', 'position_help' => 'Kategori sıralama düzenini kontrol etmek için', 'title' => 'Başlık', @@ -181,6 +186,7 @@ return array( '_' => 'Abonelik yönetimi', 'add' => 'Kategori veya akış ekle', 'add_category' => 'Kategori ekle', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => 'Akış ekle', 'add_label' => 'Etiket ekle', 'delete_label' => 'Etiket sil', diff --git a/app/i18n/zh-cn/gen.php b/app/i18n/zh-cn/gen.php index a995f8f01..92d3874b8 100644 --- a/app/i18n/zh-cn/gen.php +++ b/app/i18n/zh-cn/gen.php @@ -18,6 +18,7 @@ return array( 'back_to_rss_feeds' => '← 返回订阅源', 'cancel' => '取消', 'create' => '创建', + 'delete_muted_feeds' => 'Delete muted feeds', // TODO 'demote' => '撤销管理员', 'disable' => '禁用', 'empty' => '清空', @@ -31,6 +32,7 @@ return array( 'open_url' => '打开链接', 'promote' => '设为管理员', 'purge' => '清理', + 'refresh_opml' => 'Refresh OPML', // TODO 'remove' => '删除', 'rename' => '重命名', 'see_website' => '网站中查看', diff --git a/app/i18n/zh-cn/sub.php b/app/i18n/zh-cn/sub.php index 82b660c79..aa88636fd 100644 --- a/app/i18n/zh-cn/sub.php +++ b/app/i18n/zh-cn/sub.php @@ -24,8 +24,13 @@ return array( '_' => '分类', 'add' => '添加分类', 'archiving' => '归档', + 'dynamic_opml' => array( + '_' => 'Dynamic OPML', // TODO + 'help' => 'Provide the URL to an <a href=http://opml.org/ target=_blank>OPML file</a> to dynamically populate this category with feeds', // TODO + ), 'empty' => '空分类', 'information' => '信息', + 'opml_url' => 'OPML URL', // TODO 'position' => '显示位置', 'position_help' => '控制分类排列顺序', 'title' => '标题', @@ -181,6 +186,7 @@ return array( '_' => '订阅管理', 'add' => '添加订阅源或分类', 'add_category' => '添加分类', + 'add_dynamic_opml' => 'Add dynamic OPML', // TODO 'add_feed' => '添加订阅源', 'add_label' => '添加标签', 'delete_label' => '删除标签', diff --git a/app/layout/aside_feed.phtml b/app/layout/aside_feed.phtml index e02af8727..ebaaeaa15 100644 --- a/app/layout/aside_feed.phtml +++ b/app/layout/aside_feed.phtml @@ -89,7 +89,9 @@ <div class="tree-folder-title"> <a class="dropdown-toggle" href="#"><?= _i($c_show ? 'up' : 'down') ?></a> <a class="title<?= $cat->hasFeedsWithError() ? ' error' : '' ?>" data-unread="<?= - format_number($cat->nbNotRead()) ?>" href="<?= _url('index', $actual_view, 'get', 'c_' . $cat->id()) . $state_filter_manual ?>"><?= $cat->name() ?></a> + format_number($cat->nbNotRead()) ?>" href="<?= _url('index', $actual_view, 'get', 'c_' . $cat->id()) . $state_filter_manual ?>"><?= + $cat->name() + ?><?php if ($cat->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { echo _i('opml-dyn'); } ?></a> </div> <ul class="tree-folder-items<?= $c_show ? ' active' : '' ?>"> diff --git a/app/layout/header.phtml b/app/layout/header.phtml index 21df02788..3abcfa999 100644 --- a/app/layout/header.phtml +++ b/app/layout/header.phtml @@ -2,7 +2,7 @@ <div class="item title"> <a href="<?= _url('index', 'index') ?>"> <?php if (FreshRSS_Context::$system_conf->logo_html == '') { ?> - <img class="logo" src="<?= _i('FreshRSS-logo', true) ?>" alt="FreshRSS" /> + <img class="logo" src="<?= _i('FreshRSS-logo', FreshRSS_Themes::ICON_URL) ?>" alt="FreshRSS" /> <?php } else { echo FreshRSS_Context::$system_conf->logo_html; diff --git a/app/layout/layout.phtml b/app/layout/layout.phtml index 7e3ffd34a..9965f5850 100644 --- a/app/layout/layout.phtml +++ b/app/layout/layout.phtml @@ -34,11 +34,18 @@ if (_t('gen.dir') === 'rtl') { if ($this->rss_title != '') { $url_rss = $url_base; $url_rss['a'] = 'rss'; + unset($url_rss['params']['rid']); if (FreshRSS_Context::$user_conf->since_hours_posts_per_rss) { $url_rss['params']['hours'] = FreshRSS_Context::$user_conf->since_hours_posts_per_rss; } ?> <link rel="alternate" type="application/rss+xml" title="<?= $this->rss_title ?>" href="<?= Minz_Url::display($url_rss) ?>" /> +<?php } if (FreshRSS_Context::isAll() || FreshRSS_Context::isCategory() || FreshRSS_Context::isFeed()) { + $opml_rss = $url_base; + $opml_rss['a'] = 'opml'; + unset($opml_rss['params']['rid']); +?> + <link rel="outline" type="text/x-opml" title="OPML" href="<?= Minz_Url::display($opml_rss) ?>" /> <?php } if (FreshRSS_Context::$system_conf->allow_robots) { ?> <meta name="description" content="<?= htmlspecialchars(FreshRSS_Context::$name . ' | ' . FreshRSS_Context::$description, ENT_COMPAT, 'UTF-8') ?>" /> <?php } else { ?> diff --git a/app/layout/simple.phtml b/app/layout/simple.phtml index cd17273cd..8a2ee14bb 100644 --- a/app/layout/simple.phtml +++ b/app/layout/simple.phtml @@ -31,7 +31,7 @@ <div class="item title"> <a href="<?= _url('index', 'index') ?>"> <?php if (FreshRSS_Context::$system_conf->logo_html == '') { ?> - <img class="logo" src="<?= _i('FreshRSS-logo', true) ?>" alt="FreshRSS" /> + <img class="logo" src="<?= _i('FreshRSS-logo', FreshRSS_Themes::ICON_URL) ?>" alt="FreshRSS" /> <?php } else { echo FreshRSS_Context::$system_conf->logo_html; diff --git a/app/views/category/actualize.phtml b/app/views/category/actualize.phtml new file mode 100644 index 000000000..d86bac9de --- /dev/null +++ b/app/views/category/actualize.phtml @@ -0,0 +1 @@ +OK diff --git a/app/views/category/refreshOpml.phtml b/app/views/category/refreshOpml.phtml new file mode 100644 index 000000000..d86bac9de --- /dev/null +++ b/app/views/category/refreshOpml.phtml @@ -0,0 +1 @@ +OK diff --git a/app/views/helpers/category/update.phtml b/app/views/helpers/category/update.phtml index 927010136..c08e6995a 100644 --- a/app/views/helpers/category/update.phtml +++ b/app/views/helpers/category/update.phtml @@ -1,6 +1,9 @@ <?php /** @var FreshRSS_View $this */ ?> <div class="post"> - <h2><?= $this->category->name() ?></h2> + <h2> + <?= $this->category->name() ?> + <?php if ($this->category->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { echo _i('opml-dyn'); } ?> + </h2> <div> <a href="<?= _url('index', 'index', 'get', 'c_' . $this->category->id()) ?>"><?= _i('link') ?> <?= _t('gen.action.filter') ?></a> @@ -31,9 +34,36 @@ <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> + <?php if (!$this->category->isDefault()): ?> + <legend><?= _t('sub.category.dynamic_opml') ?> <?= _i('opml-dyn') ?></legend> + <div class="form-group"> + <label class="group-name" for="opml_url"><?= _t('sub.category.opml_url') ?></label> + <div class="group-controls"> + <div class="stick"> + <input id="opml_url" name="opml_url" type="url" autocomplete="off" class="long" data-disable-update="refreshOpml" value="<?= $this->category->attributes('opml_url') ?>" /> + <button type="submit" class="btn" id="refreshOpml" formmethod="post" formaction="<?= _url('category', 'refreshOpml', 'id', $this->category->id()) ?>"> + <?= _i('refresh') ?> <?= _t('gen.action.refresh_opml') ?> + </button> + <a class="btn open-url" target="_blank" rel="noreferrer" href="" data-input="opml_url" title="<?= _t('gen.action.open_url') ?>"><?= _i('link') ?></a> + </div> + <p class="help"><?= _i('help') ?> <?= _t('gen.short.blank_to_disable') ?></p> + <p class="help"><?= _i('help') ?> <?= _t('sub.category.dynamic_opml.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> + <button type="submit" class="btn btn-attention confirm" + data-str-confirm="<?= _t('gen.js.confirm_action_feed_cat') ?>" + formaction="<?= _url('category', 'empty', 'id', $this->category->id(), 'muted', 1) ?>" + formmethod="post"><?= _t('gen.action.delete_muted_feeds') ?></button> </div> </div> + <?php endif; ?> <legend><?= _t('sub.category.archiving') ?></legend> <?php diff --git a/app/views/helpers/export/opml.phtml b/app/views/helpers/export/opml.phtml index 64c02c302..f99754138 100644 --- a/app/views/helpers/export/opml.phtml +++ b/app/views/helpers/export/opml.phtml @@ -1,21 +1,14 @@ <?php -/** @var FreshRSS_View $this */ -$opml_array = array( - 'head' => array( - 'title' => FreshRSS_Context::$system_conf->title, - 'dateCreated' => date('D, d M Y H:i:s') - ), - 'body' => array() -); - -foreach ($this->categories as $key => $cat) { - $opml_array['body'][$key] = array( - 'text' => htmlspecialchars_decode($cat->name(), ENT_QUOTES), - '@outlines' => array() - ); - - foreach ($cat->feeds() as $feed) { +/** + * @param array<FreshRSS_Feed> $feeds + */ +function feedsToOutlines($feeds, $excludeMutedFeeds = false): array { + $outlines = []; + foreach ($feeds as $feed) { + if ($feed->mute() && $excludeMutedFeeds) { + continue; + } $outline = [ 'text' => htmlspecialchars_decode($feed->name(), ENT_QUOTES), 'type' => FreshRSS_Export_Service::TYPE_RSS_ATOM, @@ -47,8 +40,36 @@ foreach ($this->categories as $key => $cat) { if ($feed->pathEntries() != '') { $outline['frss:cssFullContent'] = ['namespace' => FreshRSS_Export_Service::FRSS_NAMESPACE, 'value' => $feed->pathEntries()]; } - $opml_array['body'][$key]['@outlines'][] = $outline; + $outlines[] = $outline; } + return $outlines; +} + +/** @var FreshRSS_View $this */ + +$opml_array = array( + 'head' => array( + 'title' => FreshRSS_Context::$system_conf->title, + 'dateCreated' => date('D, d M Y H:i:s') + ), + 'body' => array() +); + +if (!empty($this->categories)) { + foreach ($this->categories as $key => $cat) { + $outline = [ + 'text' => htmlspecialchars_decode($cat->name(), ENT_QUOTES), + '@outlines' => feedsToOutlines($cat->feeds(), $this->excludeMutedFeeds), + ]; + if ($cat->kind() === FreshRSS_Category::KIND_DYNAMIC_OPML) { + $outline['frss:opmlUrl'] = ['namespace' => FreshRSS_Export_Service::FRSS_NAMESPACE, 'value' => $cat->attributes('opml_url')];; + } + $opml_array['body'][$key] = $outline; + } +} + +if (!empty($this->feeds)) { + $opml_array['body'][] = feedsToOutlines($this->feeds, $this->excludeMutedFeeds); } echo libopml_render($opml_array); diff --git a/app/views/importExport/index.phtml b/app/views/importExport/index.phtml index c5bc97446..adc236dc4 100644 --- a/app/views/importExport/index.phtml +++ b/app/views/importExport/index.phtml @@ -10,6 +10,15 @@ <h1><?= _t('sub.menu.import_export') ?></h1> + <h2><?= _t('sub.category.dynamic_opml') ?></h2> + <div class="form-group form-actions"> + <div class="group-controls"> + <ul> + <li><a href="<?= _url('subscription', 'add') ?>"><?= _t('sub.title.add_dynamic_opml') ?> <?= _i('opml-dyn') ?></a></li> + </ul> + </div> + </div> + <h2><?= _t('sub.import_export.import') ?></h2> <form method="post" action="<?= _url('importExport', 'import') ?>" enctype="multipart/form-data"> <input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" /> diff --git a/app/views/index/opml.phtml b/app/views/index/opml.phtml new file mode 100644 index 000000000..69dace924 --- /dev/null +++ b/app/views/index/opml.phtml @@ -0,0 +1,3 @@ +<?php +/** @var FreshRSS_View $this */ +$this->renderHelper('export/opml'); diff --git a/app/views/javascript/actualize.phtml b/app/views/javascript/actualize.phtml index c154137f6..73890dd40 100644 --- a/app/views/javascript/actualize.phtml +++ b/app/views/javascript/actualize.phtml @@ -1,5 +1,14 @@ -<?php /** @var FreshRSS_View $this */ ?> <?php +/** @var FreshRSS_View $this */ + +$categories = []; +foreach ($this->categories as $category) { + $categories[] = [ + 'url' => Minz_Url::display(array('c' => 'category', 'a' => 'refreshOpml', 'params' => array('id' => $category->id(), 'ajax' => '1')), 'php'), + 'title' => $category->name(), + ]; +} + $feeds = array(); foreach ($this->feeds as $feed) { $feeds[] = array( @@ -8,6 +17,7 @@ foreach ($this->feeds as $feed) { ); } echo json_encode(array( + 'categories' => $categories, 'feeds' => $feeds, 'feedback_no_refresh' => _t('feedback.sub.feed.no_refresh'), 'feedback_actualize' => _t('feedback.sub.actualize'), diff --git a/app/views/subscription/add.phtml b/app/views/subscription/add.phtml index 9e5b2a399..5aadc350b 100644 --- a/app/views/subscription/add.phtml +++ b/app/views/subscription/add.phtml @@ -15,7 +15,7 @@ <div class="form-group"> <label class="group-name" for="new-category"><?= _t('sub.category') ?></label> <div class="group-controls"> - <input id="new-category" name="new-category" type="text" autocomplete="off"/> + <input id="new-category" name="new-category" type="text" required="required" autocomplete="off" /> </div> </div> @@ -45,7 +45,12 @@ <label class="group-name" for="category"><?= _t('sub.category') ?></label> <div class="group-controls"> <select name="category" id="category"> - <?php foreach ($this->categories as $cat) { ?> + <?php + foreach ($this->categories as $cat) { + if ($cat->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { + continue; + } + ?> <option value="<?= $cat->id() ?>"<?= $cat->id() == ( Minz_Request::param('cat_id') ?: 1 ) ? ' selected="selected"' : '' ?>> <?= $cat->name() ?> </option> @@ -218,4 +223,35 @@ </div> </div> </form> + + <h2> + <?= _t('sub.title.add_dynamic_opml') ?> + <?= _i('opml-dyn') ?> + </h2> + <form action="<?= _url('category', 'create') ?>" method="post"> + <input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" /> + <div class="form-group"> + <label class="group-name" for="new-category"><?= _t('sub.category') ?></label> + <div class="group-controls"> + <input id="new-category" name="new-category" type="text" required="required" autocomplete="off" /> + </div> + </div> + + <div class="form-group"> + <label class="group-name" for="opml_url"><?= _t('sub.category.opml_url') ?></label> + <div class="group-controls"> + <div class="stick"> + <input id="opml_url" name="opml_url" type="url" required="required" autocomplete="off" class="long" /> + <a class="btn open-url" target="_blank" rel="noreferrer" href="" data-input="opml_url" title="<?= _t('gen.action.open_url') ?>"><?= _i('link') ?></a> + </div> + <p class="help"><?= _i('help') ?> <?= _t('sub.category.dynamic_opml.help') ?></p> + </div> + </div> + + <div class="form-group form-actions"> + <div class="group-controls"> + <button type="submit" class="btn btn-important"><?= _t('gen.action.add') ?></button> + </div> + </div> + </form> </main> diff --git a/app/views/subscription/index.phtml b/app/views/subscription/index.phtml index 139bb2de0..8b2411edf 100644 --- a/app/views/subscription/index.phtml +++ b/app/views/subscription/index.phtml @@ -36,6 +36,7 @@ <div class="box-title"> <a class="configure open-slider" href="<?= _url('subscription', 'category', 'id', $cat->id()) ?>"><?= _i('configure') ?></a> <?= $cat->name() ?> + <?php if ($cat->kind() == FreshRSS_Category::KIND_DYNAMIC_OPML) { echo _i('opml-dyn'); } ?> </div> <ul class="box-content drop-zone" dropzone="move" data-cat-id="<?= $cat->id() ?>"> <?php @@ -60,7 +61,9 @@ ?> <li class="item feed disabled"><div class="alert-warn"><?= _t('sub.category.empty') ?></div></li> <?php } ?> - <li class="item feed">✚ <a href="<?= _url('subscription', 'add', 'cat_id', $cat->id()) ?>"><?= _t('sub.feed.add') ?></a></li> + <?php if ($cat->kind() != FreshRSS_Category::KIND_DYNAMIC_OPML): ?> + <li class="item feed">✚ <a href="<?= _url('subscription', 'add', 'cat_id', $cat->id()) ?>"><?= _t('sub.feed.add') ?></a></li> + <?php endif; ?> </ul> </div> <?php } ?> diff --git a/cli/actualize-user.php b/cli/actualize-user.php index 347c9a953..512a7e50f 100755 --- a/cli/actualize-user.php +++ b/cli/actualize-user.php @@ -18,6 +18,16 @@ $username = cliInitUser($options['user']); fwrite(STDERR, 'FreshRSS actualizing user “' . $username . "”…\n"); +$result = FreshRSS_category_Controller::refreshDynamicOpmls(); +if (!empty($result['errors'])) { + $errors = $result['errors']; + fwrite(STDERR, "FreshRSS error refreshing $errors dynamic OPMLs!\n"); +} +if (!empty($result['successes'])) { + $successes = $result['successes']; + echo "FreshRSS refreshed $successes dynamic OPMLs for $username\n"; +} + list($nbUpdatedFeeds, $feed, $nbNewArticles) = FreshRSS_feed_Controller::actualizeFeed(0, '', true); echo "FreshRSS actualized $nbUpdatedFeeds feeds for $username ($nbNewArticles new articles)\n"; diff --git a/config-user.default.php b/config-user.default.php index 01f2764c0..110b3466f 100644 --- a/config-user.default.php +++ b/config-user.default.php @@ -16,6 +16,7 @@ return array ( 'keep_unreads' => false, ], 'ttl_default' => 3600, + 'dynamic_opml_ttl_default' => 43200, 'mail_login' => '', 'email_validation_token' => '', 'token' => '', diff --git a/data/cache/.gitignore b/data/cache/.gitignore index 6c43765c7..36e4317f8 100644 --- a/data/cache/.gitignore +++ b/data/cache/.gitignore @@ -1,3 +1,4 @@ *.spc *.html +*.xml !index.html diff --git a/docs/en/developers/OPML.md b/docs/en/developers/OPML.md index 59a59a748..3014dc457 100644 --- a/docs/en/developers/OPML.md +++ b/docs/en/developers/OPML.md @@ -46,6 +46,10 @@ The following attributes are using similar naming conventions than [RSS-Bridge]( * Example: `div.main` * `frss:filtersActionRead`: List (separated by a new line) of search queries to automatically mark a new article as read. +### Dynamic OPML (reading lists) + +* `frss:opmlUrl`: If non-empty, indicates that this outline (category) should be dynamically populated from a remote OPML at the specified URL. + ### Example ```xml diff --git a/lib/lib_rss.php b/lib/lib_rss.php index 648c9328c..b485d379b 100644 --- a/lib/lib_rss.php +++ b/lib/lib_rss.php @@ -377,19 +377,19 @@ function enforceHttpEncoding(string $html, string $contentType = ''): string { } /** + * @param string $type {html,opml} * @param array<string,mixed> $attributes */ -function getHtml(string $url, array $attributes = []): string { +function httpGet(string $url, string $cachePath, string $type = 'html', array $attributes = []): string { $limits = FreshRSS_Context::$system_conf->limits; $feed_timeout = empty($attributes['timeout']) ? 0 : intval($attributes['timeout']); - $cachePath = FreshRSS_Feed::cacheFilename($url, $attributes, FreshRSS_Feed::KIND_HTML_XPATH); $cacheMtime = @filemtime($cachePath); if ($cacheMtime !== false && $cacheMtime > time() - intval($limits['cache_duration'])) { - $html = @file_get_contents($cachePath); - if ($html != '') { + $body = @file_get_contents($cachePath); + if ($body != '') { syslog(LOG_DEBUG, 'FreshRSS uses cache for ' . SimplePie_Misc::url_remove_credentials($url)); - return $html; + return $body; } } @@ -398,14 +398,25 @@ function getHtml(string $url, array $attributes = []): string { } if (FreshRSS_Context::$system_conf->simplepie_syslog_enabled) { - syslog(LOG_INFO, 'FreshRSS GET ' . SimplePie_Misc::url_remove_credentials($url)); + syslog(LOG_INFO, 'FreshRSS GET ' . $type . ' ' . SimplePie_Misc::url_remove_credentials($url)); + } + + $accept = '*/*;q=0.8'; + switch ($type) { + case 'opml': + $accept = 'text/x-opml,text/xml;q=0.9,application/xml;q=0.9,*/*;q=0.8'; + break; + case 'html': + default: + $accept = 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'; + break; } // TODO: Implement HTTP 1.1 conditional GET If-Modified-Since $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, - CURLOPT_HTTPHEADER => array('Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'), + CURLOPT_HTTPHEADER => array('Accept: ' . $accept), CURLOPT_USERAGENT => FRESHRSS_USERAGENT, CURLOPT_CONNECTTIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], CURLOPT_TIMEOUT => $feed_timeout > 0 ? $feed_timeout : $limits['timeout'], @@ -428,27 +439,28 @@ function getHtml(string $url, array $attributes = []): string { curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'DEFAULT@SECLEVEL=1'); } } - $html = curl_exec($ch); + $body = curl_exec($ch); $c_status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $c_content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE); //TODO: Check if that may be null $c_error = curl_error($ch); curl_close($ch); - if ($c_status != 200 || $c_error != '' || $html === false) { + if ($c_status != 200 || $c_error != '' || $body === false) { Minz_Log::warning('Error fetching content: HTTP code ' . $c_status . ': ' . $c_error . ' ' . $url); + $body = ''; // TODO: Implement HTTP 410 Gone } - if ($html == false) { - $html = ''; + if ($body == false) { + $body = ''; } else { - $html = enforceHttpEncoding($html, $c_content_type); + $body = enforceHttpEncoding($body, $c_content_type); } - if (file_put_contents($cachePath, $html) === false) { + if (file_put_contents($cachePath, $body) === false) { Minz_Log::warning("Error saving cache $cachePath for $url"); } - return $html; + return $body; } /** @@ -770,8 +782,8 @@ function remove_query_by_get($get, $queries) { return $final_queries; } -function _i($icon, $url_only = false) { - return FreshRSS_Themes::icon($icon, $url_only); +function _i(string $icon, int $type = FreshRSS_Themes::ICON_DEFAULT): string { + return FreshRSS_Themes::icon($icon, $type); } diff --git a/p/api/greader.php b/p/api/greader.php index 43e3647d1..9a96823d7 100644 --- a/p/api/greader.php +++ b/p/api/greader.php @@ -307,8 +307,8 @@ function subscriptionExport() { function subscriptionImport($opml) { $user = Minz_Session::param('currentUser', '_'); $importService = new FreshRSS_Import_Service($user); - $ok = $importService->importOpml($opml); - if ($ok) { + $importService->importOpml($opml); + if ($importService->lastStatus()) { list($nbUpdatedFeeds, $feed, $nbNewArticles) = FreshRSS_feed_Controller::actualizeFeed(0, '', true); invalidateHttpCache($user); exit('OK'); diff --git a/p/scripts/extra.js b/p/scripts/extra.js index 39f9d049a..7be235aa4 100644 --- a/p/scripts/extra.js +++ b/p/scripts/extra.js @@ -202,8 +202,8 @@ function updateHref(ev) { } // set event listener on "show url" buttons -function init_url_observers() { - document.querySelectorAll('.open-url').forEach(function (btn) { +function init_url_observers(parent) { + parent.querySelectorAll('.open-url').forEach(function (btn) { btn.addEventListener('mouseover', updateHref); btn.addEventListener('click', updateHref); }); @@ -276,7 +276,6 @@ function init_extra_afterDOM() { if (!['normal', 'global', 'reader'].includes(context.current_view)) { init_crypto_form(); init_password_observers(document.body); - init_url_observers(); init_select_observers(); init_configuration_alert(); @@ -284,8 +283,10 @@ function init_extra_afterDOM() { if (slider) { init_slider(slider); init_archiving(slider); + init_url_observers(slider); } else { init_archiving(document.body); + init_url_observers(document.body); } } diff --git a/p/scripts/feed.js b/p/scripts/feed.js index 2a213b422..a5e43c614 100644 --- a/p/scripts/feed.js +++ b/p/scripts/feed.js @@ -1,6 +1,6 @@ // @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0 'use strict'; -/* globals init_archiving, init_configuration_alert, init_password_observers, init_slider */ +/* globals init_archiving, init_configuration_alert, init_password_observers, init_slider, init_url_observers */ // <popup> let popup = null; @@ -67,6 +67,22 @@ function init_popup_preview_selector() { /** * Allow a <select class="select-show"> to hide/show elements defined by <option data-show="elem-id"></option> */ +function init_disable_elements_on_update(parent) { + const inputs = parent.querySelectorAll('input[data-disable-update]'); + for (const input of inputs) { + input.addEventListener('input', (e) => { + const elem = document.getElementById(e.target.dataset.disableUpdate); + if (elem) { + elem.disabled = true; + elem.remove(); + } + }); + } +} + +/** + * Allow a <select class="select-show"> to hide/show elements defined by <option data-show="elem-id"></option> + */ function init_select_show(parent) { const listener = (select) => { const options = select.querySelectorAll('option[data-show]'); @@ -120,7 +136,9 @@ function init_feed_afterDOM() { init_popup(); init_popup_preview_selector(); init_select_show(slider); + init_disable_elements_on_update(slider); init_password_observers(slider); + init_url_observers(slider); init_valid_xpath(slider); }); init_slider(slider); @@ -130,6 +148,7 @@ function init_feed_afterDOM() { init_popup(); init_popup_preview_selector(); init_select_show(document.body); + init_disable_elements_on_update(document.body); init_password_observers(document.body); init_valid_xpath(document.body); } diff --git a/p/scripts/main.js b/p/scripts/main.js index 1eb8a1ff8..461dc1b10 100644 --- a/p/scripts/main.js +++ b/p/scripts/main.js @@ -115,9 +115,10 @@ function incUnreadsFeed(article, feed_id, nb) { } // Update unread: category - elem = document.getElementById(feed_id).closest('.category'); - feed_unreads = elem ? str2int(elem.getAttribute('data-unread')) : 0; + elem = document.getElementById(feed_id); + elem = elem ? elem.closest('.category') : null; if (elem) { + feed_unreads = str2int(elem.getAttribute('data-unread')); elem.setAttribute('data-unread', feed_unreads + nb); elem = elem.querySelector('.title'); if (elem) { @@ -147,7 +148,7 @@ function incUnreadsFeed(article, feed_id, nb) { // Update unread: title document.title = document.title.replace(/^((?:\([\s0-9]+\) )?)/, function (m, p1) { const feed = document.getElementById(feed_id); - if (article || feed.closest('.active')) { + if (article || (feed && feed.closest('.active'))) { isCurrentView = true; return incLabel(p1, nb, true); } else if (document.querySelector('.all.active')) { @@ -1287,9 +1288,11 @@ function loadDynamicTags(div) { } // <actualize> -let feed_processed = 0; +let feeds_processed = 0; +let categories_processed = 0; +let to_process = 0; -function updateFeed(feeds, feeds_count) { +function refreshFeed(feeds, feeds_count) { const feed = feeds.pop(); if (!feed) { return; @@ -1297,14 +1300,15 @@ function updateFeed(feeds, feeds_count) { const req = new XMLHttpRequest(); req.open('POST', feed.url, true); req.onloadend = function (e) { + feeds_processed++; if (this.status != 200) { - return badAjax(false); + badAjax(false); + } else { + const div = document.getElementById('actualizeProgress'); + div.querySelector('.progress').innerHTML = (categories_processed + feeds_processed) + ' / ' + to_process; + div.querySelector('.title').innerHTML = feed.title; } - feed_processed++; - const div = document.getElementById('actualizeProgress'); - div.querySelector('.progress').innerHTML = feed_processed + ' / ' + feeds_count; - div.querySelector('.title').innerHTML = feed.title; - if (feed_processed === feeds_count) { + if (feeds_processed === feeds_count) { // Empty request to commit new articles const req2 = new XMLHttpRequest(); req2.open('POST', './?c=feed&a=actualize&id=-1&ajax=1', true); @@ -1317,7 +1321,7 @@ function updateFeed(feeds, feeds_count) { noCommit: 0, })); } else { - updateFeed(feeds, feeds_count); + refreshFeed(feeds, feeds_count); } }; req.setRequestHeader('Content-Type', 'application/json'); @@ -1327,8 +1331,73 @@ function updateFeed(feeds, feeds_count) { })); } +function refreshFeeds(json) { + feeds_processed = 0; + if (!json.feeds || json.feeds.length === 0) { + // Empty request to commit new articles + const req2 = new XMLHttpRequest(); + req2.open('POST', './?c=feed&a=actualize&id=-1&ajax=1', true); + req2.onloadend = function (e) { + context.ajax_loading = false; + }; + req2.setRequestHeader('Content-Type', 'application/json'); + req2.send(JSON.stringify({ + _csrf: context.csrf, + noCommit: 0, + })); + } else { + const feeds_count = json.feeds.length; + for (let i = 10; i > 0; i--) { + refreshFeed(json.feeds, feeds_count); + } + } +} + +function refreshDynamicOpml(categories, categories_count, next) { + const category = categories.pop(); + if (!category) { + return; + } + const req = new XMLHttpRequest(); + req.open('POST', category.url, true); + req.onloadend = function (e) { + categories_processed++; + if (this.status != 200) { + badAjax(false); + } else { + const div = document.getElementById('actualizeProgress'); + div.querySelector('.progress').innerHTML = (categories_processed + feeds_processed) + ' / ' + to_process; + div.querySelector('.title').innerHTML = category.title; + } + if (categories_processed === categories_count) { + if (next) { next(); } + } else { + refreshDynamicOpml(categories, categories_count, next); + } + }; + req.setRequestHeader('Content-Type', 'application/json'); + req.send(JSON.stringify({ + _csrf: context.csrf, + noCommit: 1, + })); +} + +function refreshDynamicOpmls(json, next) { + categories_processed = 0; + if (json.categories && json.categories.length > 0) { + const categories_count = json.categories.length; + for (let i = 10; i > 0; i--) { + refreshDynamicOpml(json.categories, categories_count, next); + } + } else { + if (next) { next(); } + } +} + function init_actualize() { let auto = false; + let nbCategoriesFirstRound = 0; + let skipCategories = false; const actualize = document.getElementById('actualize'); if (!actualize) { @@ -1352,33 +1421,29 @@ function init_actualize() { if (!json) { return badAjax(false); } - if (auto && json.feeds.length < 1) { + if (auto && json.categories.length < 1 && json.feeds.length < 1) { auto = false; context.ajax_loading = false; return false; } - if (json.feeds.length === 0) { + to_process = json.categories.length + json.feeds.length + nbCategoriesFirstRound; + if (json.categories.length + json.feeds.length > 0 && !document.getElementById('actualizeProgress')) { + document.body.insertAdjacentHTML('beforeend', '<div id="actualizeProgress" class="notification good">' + + json.feedback_actualize + '<br /><span class="title">/</span><br /><span class="progress">0 / ' + + to_process + '</span></div>'); + } else { openNotification(json.feedback_no_refresh, 'good'); - // Empty request to commit new articles - const req2 = new XMLHttpRequest(); - req2.open('POST', './?c=feed&a=actualize&id=-1&ajax=1', true); - req2.onloadend = function (e) { - context.ajax_loading = false; - }; - req2.setRequestHeader('Content-Type', 'application/json'); - req2.send(JSON.stringify({ - _csrf: context.csrf, - noCommit: 0, - })); - return; } - // Progress bar - const feeds_count = json.feeds.length; - document.body.insertAdjacentHTML('beforeend', '<div id="actualizeProgress" class="notification good">' + - json.feedback_actualize + '<br /><span class="title">/</span><br /><span class="progress">0 / ' + - feeds_count + '</span></div>'); - for (let i = 10; i > 0; i--) { - updateFeed(json.feeds, feeds_count); + if (json.categories.length > 0 && !skipCategories) { + skipCategories = true; // To avoid risk of infinite loop + nbCategoriesFirstRound = json.categories.length; + // If some dynamic OPML categories are refreshed, need to reload the list of feeds before updating them + refreshDynamicOpmls(json, () => { + context.ajax_loading = false; + actualize.click(); + }); + } else { + refreshFeeds(json); } }; req.setRequestHeader('Content-Type', 'application/json'); diff --git a/p/themes/Dark-pink/pinkdark.css b/p/themes/Dark-pink/pinkdark.css index b3b10ce5e..e47a997f5 100644 --- a/p/themes/Dark-pink/pinkdark.css +++ b/p/themes/Dark-pink/pinkdark.css @@ -117,6 +117,7 @@ input:focus { .icon[src*="/sort-up"], .icon[src*="/sort-down"], .icon[src*="/key"], +.icon[src*="/opml-dyn"], .icon[src*="/configure"], .icon[src*="/category"] { /* Color light grey icons */ diff --git a/p/themes/Dark-pink/pinkdark.rtl.css b/p/themes/Dark-pink/pinkdark.rtl.css index 705c88b36..3ff3cb44d 100644 --- a/p/themes/Dark-pink/pinkdark.rtl.css +++ b/p/themes/Dark-pink/pinkdark.rtl.css @@ -117,6 +117,7 @@ input:focus { .icon[src*="/sort-up"], .icon[src*="/sort-down"], .icon[src*="/key"], +.icon[src*="/opml-dyn"], .icon[src*="/configure"], .icon[src*="/category"] { /* Color light grey icons */ diff --git a/p/themes/icons/opml-dyn.svg b/p/themes/icons/opml-dyn.svg new file mode 100644 index 000000000..4c0711367 --- /dev/null +++ b/p/themes/icons/opml-dyn.svg @@ -0,0 +1,8 @@ +<svg width="93.619" height="93.619" viewBox="0 0 24.77 24.77" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"> + <g transform="translate(33.09 -98.68)"> + <path style="fill:#000;stroke-width:.264583" d="M98.217 120.656V96.711H132.084v47.89H98.217Zm19.925 5.03c5.583-1.455 9.438-6.373 9.438-12.041 0-7.464-6.765-13.365-14.031-12.24-9.125 1.412-13.645 11.61-8.549 19.289 2.769 4.17 8.27 6.26 13.142 4.992zm-5.108-3.822c-1.295-.395-2.793-1.303-3.807-2.306-3.247-3.214-3.255-8.545-.018-11.837 1.622-1.65 3.547-2.412 6.074-2.402 2.59.009 4.158.678 5.999 2.558 1.733 1.77 2.423 3.685 2.272 6.297-.2 3.452-2.21 6.207-5.398 7.4-1.464.547-3.839.682-5.122.29zm3.713-5.181c.92-.476 1.578-1.634 1.578-2.774 0-2.794-3.33-4.117-5.331-2.117-.626.625-.754.977-.754 2.061 0 2.57 2.263 3.99 4.507 2.83z"/> + <path style="opacity:1;fill:#666;stroke:none;stroke-width:1.12498;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;stroke-opacity:1;paint-order:markers fill stroke" d="M-20.705 98.68a12.385 12.385 0 0 0-12.384 12.385 12.385 12.385 0 0 0 12.384 12.385 12.385 12.385 0 0 0 12.386-12.385A12.385 12.385 0 0 0-20.705 98.68zm0 3.616a8.77 8.77 0 0 1 8.77 8.77 8.77 8.77 0 0 1-8.77 8.77 8.77 8.77 0 0 1-8.77-8.77 8.77 8.77 0 0 1 8.77-8.77z"/> + <circle style="opacity:1;fill:#666;stroke:none;stroke-width:.276252;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;stroke-opacity:1;paint-order:markers fill stroke" cx="-20.704" cy="111.065" r="3.041"/> + <path style="fill:#666;stroke-width:.00362641;fill-opacity:1" d="M-17.936 117.039c-4.746 1.911-6.857-.321-8.826-3.398.058-.03.967-.532 1.018-.558 1.935 2.8 3.752 4.62 7.194 2.712l-.482-.983c.787.2 1.619.415 2.465.64-.322.801-.639 1.604-.966 2.402l-.403-.815zM-23.767 105.435c4.745-1.911 6.856.321 8.825 3.398-.058.03-.966.532-1.017.558-1.935-2.8-3.753-4.62-7.195-2.712l.482.983c-.787-.2-1.618-.415-2.465-.64.322-.801.64-1.604.967-2.402.134.272.267.543.403.815z"/> + </g> +</svg> diff --git a/p/themes/icons/opml.svg b/p/themes/icons/opml.svg new file mode 100644 index 000000000..b3e8a830c --- /dev/null +++ b/p/themes/icons/opml.svg @@ -0,0 +1,7 @@ +<svg width="93.619" height="93.619" viewBox="0 0 24.77 24.77" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"> + <g transform="translate(33.09 -98.68)"> + <path style="fill:#000;stroke-width:.264583" d="M98.217 120.656V96.711H132.084v47.89H98.217Zm19.925 5.03c5.583-1.455 9.438-6.373 9.438-12.041 0-7.464-6.765-13.365-14.031-12.24-9.125 1.412-13.645 11.61-8.549 19.289 2.769 4.17 8.27 6.26 13.142 4.992zm-5.108-3.822c-1.295-.395-2.793-1.303-3.807-2.306-3.247-3.214-3.255-8.545-.018-11.837 1.622-1.65 3.547-2.412 6.074-2.402 2.59.009 4.158.678 5.999 2.558 1.733 1.77 2.423 3.685 2.272 6.297-.2 3.452-2.21 6.207-5.398 7.4-1.464.547-3.839.682-5.122.29zm3.713-5.181c.92-.476 1.578-1.634 1.578-2.774 0-2.794-3.33-4.117-5.331-2.117-.626.625-.754.977-.754 2.061 0 2.57 2.263 3.99 4.507 2.83z"/> + <path style="opacity:1;fill:#666;stroke:none;stroke-width:1.12498;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;stroke-opacity:1;paint-order:markers fill stroke" d="M-20.705 98.68a12.385 12.385 0 0 0-12.384 12.385 12.385 12.385 0 0 0 12.384 12.385 12.385 12.385 0 0 0 12.386-12.385A12.385 12.385 0 0 0-20.705 98.68zm0 3.616a8.77 8.77 0 0 1 8.77 8.77 8.77 8.77 0 0 1-8.77 8.77 8.77 8.77 0 0 1-8.77-8.77 8.77 8.77 0 0 1 8.77-8.77z"/> + <circle style="opacity:1;fill:#666;stroke:none;stroke-width:.276252;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:2;stroke-opacity:1;paint-order:markers fill stroke" cx="-20.704" cy="111.065" r="3.041"/> + </g> +</svg> |
