4
0
Эх сурвалжийг харах

Dynamic OPML (#4407)

* Dynamic OPML draft
#fix https://github.com/FreshRSS/FreshRSS/issues/4191

* Export dynamic OPML
http://opml.org/spec2.opml#1629043127000

* Restart with simpler approach

* Minor revert

* Export dynamic OPML also for single feeds

* Special category type for importing dynamic OPML

* Parameter for excludeMutedFeeds

* Details

* More draft

* i18n

* Fix update

* Draft manual import working

* Working manual refresh

* Draft automatic update

* Working Web refresh + fixes

* Import/export dynamic OPML settings

* Annoying numerous lines in SQL logs

* Fix minor JavaScript error

* Fix auto adding new columns

* Add require

* Add missing 🗲

* Missing space

* Disable adding new feeds to dynamic categories

* Link from import

* i18n typo

* Improve theme icon function

* Fix pink-dark
Alexandre Alapetite 3 жил өмнө
parent
commit
509c8cae63
85 өөрчлөгдсөн 1136 нэмэгдсэн , 269 устгасан
  1. 3 0
      .editorconfig
  2. 3 0
      .typos.toml
  3. 77 9
      app/Controllers/categoryController.php
  4. 10 15
      app/Controllers/feedController.php
  5. 6 2
      app/Controllers/importExportController.php
  6. 70 0
      app/Controllers/indexController.php
  7. 4 0
      app/Controllers/javascriptController.php
  8. 17 10
      app/Controllers/subscriptionController.php
  9. 131 5
      app/Models/Category.php
  10. 87 16
      app/Models/CategoryDAO.php
  11. 1 1
      app/Models/CategoryDAOSQLite.php
  12. 15 2
      app/Models/Context.php
  13. 2 1
      app/Models/Entry.php
  14. 23 12
      app/Models/Feed.php
  15. 53 35
      app/Models/FeedDAO.php
  16. 35 6
      app/Models/Themes.php
  17. 1 0
      app/Models/UserConfiguration.php
  18. 2 0
      app/Models/View.php
  19. 4 1
      app/SQL/install.sql.mysql.php
  20. 3 0
      app/SQL/install.sql.pgsql.php
  21. 4 1
      app/SQL/install.sql.sqlite.php
  22. 2 1
      app/Services/ExportService.php
  23. 105 71
      app/Services/ImportService.php
  24. 2 0
      app/i18n/cz/gen.php
  25. 6 0
      app/i18n/cz/sub.php
  26. 2 0
      app/i18n/de/gen.php
  27. 6 0
      app/i18n/de/sub.php
  28. 2 0
      app/i18n/en-us/gen.php
  29. 6 0
      app/i18n/en-us/sub.php
  30. 2 0
      app/i18n/en/gen.php
  31. 6 0
      app/i18n/en/sub.php
  32. 2 0
      app/i18n/es/gen.php
  33. 6 0
      app/i18n/es/sub.php
  34. 2 0
      app/i18n/fr/gen.php
  35. 7 1
      app/i18n/fr/sub.php
  36. 2 0
      app/i18n/he/gen.php
  37. 6 0
      app/i18n/he/sub.php
  38. 2 0
      app/i18n/it/gen.php
  39. 6 0
      app/i18n/it/sub.php
  40. 2 0
      app/i18n/ja/gen.php
  41. 6 0
      app/i18n/ja/sub.php
  42. 2 0
      app/i18n/ko/gen.php
  43. 6 0
      app/i18n/ko/sub.php
  44. 2 0
      app/i18n/nl/gen.php
  45. 6 0
      app/i18n/nl/sub.php
  46. 2 0
      app/i18n/oc/gen.php
  47. 6 0
      app/i18n/oc/sub.php
  48. 2 0
      app/i18n/pl/gen.php
  49. 6 0
      app/i18n/pl/sub.php
  50. 2 0
      app/i18n/pt-br/gen.php
  51. 6 0
      app/i18n/pt-br/sub.php
  52. 2 0
      app/i18n/ru/gen.php
  53. 6 0
      app/i18n/ru/sub.php
  54. 2 0
      app/i18n/sk/gen.php
  55. 6 0
      app/i18n/sk/sub.php
  56. 2 0
      app/i18n/tr/gen.php
  57. 6 0
      app/i18n/tr/sub.php
  58. 2 0
      app/i18n/zh-cn/gen.php
  59. 6 0
      app/i18n/zh-cn/sub.php
  60. 3 1
      app/layout/aside_feed.phtml
  61. 1 1
      app/layout/header.phtml
  62. 7 0
      app/layout/layout.phtml
  63. 1 1
      app/layout/simple.phtml
  64. 1 0
      app/views/category/actualize.phtml
  65. 1 0
      app/views/category/refreshOpml.phtml
  66. 31 1
      app/views/helpers/category/update.phtml
  67. 38 17
      app/views/helpers/export/opml.phtml
  68. 9 0
      app/views/importExport/index.phtml
  69. 3 0
      app/views/index/opml.phtml
  70. 11 1
      app/views/javascript/actualize.phtml
  71. 38 2
      app/views/subscription/add.phtml
  72. 4 1
      app/views/subscription/index.phtml
  73. 10 0
      cli/actualize-user.php
  74. 1 0
      config-user.default.php
  75. 1 0
      data/cache/.gitignore
  76. 4 0
      docs/en/developers/OPML.md
  77. 28 16
      lib/lib_rss.php
  78. 2 2
      p/api/greader.php
  79. 4 3
      p/scripts/extra.js
  80. 20 1
      p/scripts/feed.js
  81. 98 33
      p/scripts/main.js
  82. 1 0
      p/themes/Dark-pink/pinkdark.css
  83. 1 0
      p/themes/Dark-pink/pinkdark.rtl.css
  84. 8 0
      p/themes/icons/opml-dyn.svg
  85. 7 0
      p/themes/icons/opml.svg

+ 3 - 0
.editorconfig

@@ -19,6 +19,9 @@ indent_style = tab
 indent_size = 4
 indent_style = tab
 
+[*.svg]
+indent_style = tab
+
 [*.xml]
 indent_style = tab
 

+ 3 - 0
.typos.toml

@@ -2,6 +2,9 @@
 ot = "ot"
 Ths2 = "Ths2"
 
+[default.extend-words]
+ba = "ba"
+
 [files]
 extend-exclude = [
 	"*.fr.md",

+ 77 - 9
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,
+		];
+	}
 }

+ 10 - 15
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);
 		}
 

+ 6 - 2
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

+ 70 - 0
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.
 	 *

+ 4 - 0
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);
 	}

+ 17 - 10
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(),
 			];

+ 131 - 5
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;
+	}
 }

+ 87 - 16
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;
 		}
 

+ 1 - 1
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);
 				}

+ 15 - 2
app/Models/Context.php

@@ -197,6 +197,20 @@ class FreshRSS_Context {
 		}
 	}
 
+	/**
+	 * @return bool true if the current request targets all feeds (main view), false otherwise.
+	 */
+	public static function isAll(): bool {
+		return self::$current_get['all'] != false;
+	}
+
+	/**
+	 * @return bool true if the current request targets a category, false otherwise.
+	 */
+	public static function isCategory(): bool {
+		return self::$current_get['category'] != false;
+	}
+
 	/**
 	 * @return bool true if the current request targets a feed (and not a category or all articles), false otherwise.
 	 */
@@ -251,8 +265,7 @@ class FreshRSS_Context {
 	 */
 	public static function _get($get) {
 		$type = $get[0];
-		$id = substr($get, 2);
-		$nb_unread = 0;
+		$id = intval(substr($get, 2));
 
 		if (empty(self::$categories)) {
 			$catDAO = FreshRSS_Factory::createCategoryDao();

+ 2 - 1
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);

+ 23 - 12
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;
 		}

+ 53 - 35
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));

+ 35 - 6
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>';
+		}
 	}
 }

+ 1 - 0
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

+ 2 - 0
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;

+ 4 - 1
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,

+ 3 - 0
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
 );
 

+ 4 - 1
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),

+ 2 - 1
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",

+ 105 - 71
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;
 	}
 }

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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

+ 6 - 0
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

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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',

+ 7 - 1
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',

+ 2 - 0
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' => 'ראו אתר',

+ 6 - 0
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

+ 2 - 0
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',

+ 6 - 0
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

+ 2 - 0
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サイトを閲覧してください',

+ 6 - 0
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' => 'ラベルの削除',

+ 2 - 0
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' => '웹사이트 열기',

+ 6 - 0
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' => '라벨 삭제',

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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ę',

+ 6 - 0
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ę',

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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' => 'Посмотреть на сайте',

+ 6 - 0
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' => 'Удалить метку',

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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',

+ 6 - 0
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',

+ 2 - 0
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' => '网站中查看',

+ 6 - 0
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' => '删除标签',

+ 3 - 1
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' : '' ?>">

+ 1 - 1
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;

+ 7 - 0
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 { ?>

+ 1 - 1
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;

+ 1 - 0
app/views/category/actualize.phtml

@@ -0,0 +1 @@
+OK

+ 1 - 0
app/views/category/refreshOpml.phtml

@@ -0,0 +1 @@
+OK

+ 31 - 1
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

+ 38 - 17
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);

+ 9 - 0
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() ?>" />

+ 3 - 0
app/views/index/opml.phtml

@@ -0,0 +1,3 @@
+<?php
+/** @var FreshRSS_View $this */
+$this->renderHelper('export/opml');

+ 11 - 1
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'),

+ 38 - 2
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>

+ 4 - 1
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 } ?>

+ 10 - 0
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";

+ 1 - 0
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' => '',

+ 1 - 0
data/cache/.gitignore

@@ -1,3 +1,4 @@
 *.spc
 *.html
+*.xml
 !index.html

+ 4 - 0
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

+ 28 - 16
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);
 }
 
 

+ 2 - 2
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');

+ 4 - 3
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);
 		}
 	}
 

+ 20 - 1
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;
@@ -64,6 +64,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>
  */
@@ -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);
 	}

+ 98 - 33
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');

+ 1 - 0
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 */

+ 1 - 0
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 */

+ 8 - 0
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>

+ 7 - 0
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>