aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.editorconfig3
-rw-r--r--.typos.toml3
-rw-r--r--app/Controllers/categoryController.php86
-rwxr-xr-xapp/Controllers/feedController.php25
-rw-r--r--app/Controllers/importExportController.php8
-rwxr-xr-xapp/Controllers/indexController.php70
-rwxr-xr-xapp/Controllers/javascriptController.php4
-rw-r--r--app/Controllers/subscriptionController.php27
-rw-r--r--app/Models/Category.php136
-rw-r--r--app/Models/CategoryDAO.php103
-rw-r--r--app/Models/CategoryDAOSQLite.php2
-rw-r--r--app/Models/Context.php17
-rw-r--r--app/Models/Entry.php3
-rw-r--r--app/Models/Feed.php35
-rw-r--r--app/Models/FeedDAO.php88
-rw-r--r--app/Models/Themes.php41
-rw-r--r--app/Models/UserConfiguration.php1
-rw-r--r--app/Models/View.php2
-rw-r--r--app/SQL/install.sql.mysql.php5
-rw-r--r--app/SQL/install.sql.pgsql.php3
-rw-r--r--app/SQL/install.sql.sqlite.php5
-rw-r--r--app/Services/ExportService.php3
-rw-r--r--app/Services/ImportService.php176
-rw-r--r--app/i18n/cz/gen.php2
-rw-r--r--app/i18n/cz/sub.php6
-rw-r--r--app/i18n/de/gen.php2
-rw-r--r--app/i18n/de/sub.php6
-rw-r--r--app/i18n/en-us/gen.php2
-rw-r--r--app/i18n/en-us/sub.php6
-rw-r--r--app/i18n/en/gen.php2
-rw-r--r--app/i18n/en/sub.php6
-rwxr-xr-xapp/i18n/es/gen.php2
-rwxr-xr-xapp/i18n/es/sub.php6
-rw-r--r--app/i18n/fr/gen.php2
-rw-r--r--app/i18n/fr/sub.php8
-rw-r--r--app/i18n/he/gen.php2
-rw-r--r--app/i18n/he/sub.php6
-rw-r--r--app/i18n/it/gen.php2
-rw-r--r--app/i18n/it/sub.php6
-rw-r--r--app/i18n/ja/gen.php2
-rw-r--r--app/i18n/ja/sub.php6
-rw-r--r--app/i18n/ko/gen.php2
-rw-r--r--app/i18n/ko/sub.php6
-rw-r--r--app/i18n/nl/gen.php2
-rw-r--r--app/i18n/nl/sub.php6
-rw-r--r--app/i18n/oc/gen.php2
-rw-r--r--app/i18n/oc/sub.php6
-rw-r--r--app/i18n/pl/gen.php2
-rw-r--r--app/i18n/pl/sub.php6
-rw-r--r--app/i18n/pt-br/gen.php2
-rw-r--r--app/i18n/pt-br/sub.php6
-rw-r--r--app/i18n/ru/gen.php2
-rw-r--r--app/i18n/ru/sub.php6
-rw-r--r--app/i18n/sk/gen.php2
-rw-r--r--app/i18n/sk/sub.php6
-rw-r--r--app/i18n/tr/gen.php2
-rw-r--r--app/i18n/tr/sub.php6
-rw-r--r--app/i18n/zh-cn/gen.php2
-rw-r--r--app/i18n/zh-cn/sub.php6
-rw-r--r--app/layout/aside_feed.phtml4
-rw-r--r--app/layout/header.phtml2
-rw-r--r--app/layout/layout.phtml7
-rw-r--r--app/layout/simple.phtml2
-rw-r--r--app/views/category/actualize.phtml1
-rw-r--r--app/views/category/refreshOpml.phtml1
-rw-r--r--app/views/helpers/category/update.phtml32
-rw-r--r--app/views/helpers/export/opml.phtml55
-rw-r--r--app/views/importExport/index.phtml9
-rw-r--r--app/views/index/opml.phtml3
-rw-r--r--app/views/javascript/actualize.phtml12
-rw-r--r--app/views/subscription/add.phtml40
-rw-r--r--app/views/subscription/index.phtml5
-rwxr-xr-xcli/actualize-user.php10
-rw-r--r--config-user.default.php1
-rw-r--r--data/cache/.gitignore1
-rw-r--r--docs/en/developers/OPML.md4
-rw-r--r--lib/lib_rss.php44
-rw-r--r--p/api/greader.php4
-rw-r--r--p/scripts/extra.js7
-rw-r--r--p/scripts/feed.js21
-rw-r--r--p/scripts/main.js131
-rw-r--r--p/themes/Dark-pink/pinkdark.css1
-rw-r--r--p/themes/Dark-pink/pinkdark.rtl.css1
-rw-r--r--p/themes/icons/opml-dyn.svg8
-rw-r--r--p/themes/icons/opml.svg7
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>