Procházet zdrojové kódy

New feature: shareable user query (#6052)

* New feature: shareable user query
Share the output of a user query by RSS / HTML / OPML with other people through unique URLs.
Replaces the global admin token, which was the only option (but unsafe) to share RSS outputs with other people.
Also add a new HTML output for people without an RSS reader.

fix https://github.com/FreshRSS/FreshRSS/issues/3066#issuecomment-648977890
fix https://github.com/FreshRSS/FreshRSS/issues/3178#issuecomment-769435504

* Remove unused method

* Fix token saving

* Implement HTML view

* Update i18n for master token

* Revert i18n get_favorite

* Fix missing i18n for user queries from before this PR

* Remove irrelevant tests

* Add link to RSS version

* Fix getGet

* Fix getState

* Fix getSearch

* Alternative getSearch

* Default getOrder

* Explicit default state

* Fix test

* Add OPML sharing

* Remove many redundant SQL queries from original implementation of user queries

* Fix article tags

* Use default user settings

* Prepare public search

* Fixes

* Allow user search on article tags

* Implement user search

* Revert filter bug

* Revert wrong SQL left outer join change

* Implement checkboxes

* Safe check of OPML

* Fix label

* Remove RSS button to favour new sharing method
That sharing button was using a global admin token

* First version of HTTP 304

* Disallow some recusrivity
fix https://github.com/FreshRSS/FreshRSS/issues/6086

* Draft of nav

* Minor httpConditional

* Add support for offset for pagination

* Fix offset pagination

* Fix explicit order ASC

* Add documentation

* Help links i18n

* Note about deprecated master token

* Typo

* Doc about format
Alexandre Alapetite před 2 roky
rodič
revize
39cc1c11ec
100 změnil soubory, kde provedl 1392 přidání a 738 odebrání
  1. 5 1
      README.fr.md
  2. 5 1
      README.md
  3. 28 28
      app/Controllers/configureController.php
  4. 1 1
      app/Controllers/feedController.php
  5. 1 1
      app/Controllers/importExportController.php
  6. 30 29
      app/Controllers/indexController.php
  7. 2 2
      app/Controllers/statsController.php
  8. 1 1
      app/Controllers/subscriptionController.php
  9. 1 1
      app/Controllers/tagController.php
  10. 1 1
      app/FreshRSS.php
  11. 19 15
      app/Models/BooleanSearch.php
  12. 48 6
      app/Models/Category.php
  13. 21 51
      app/Models/CategoryDAO.php
  14. 38 16
      app/Models/Context.php
  15. 22 0
      app/Models/Entry.php
  16. 11 9
      app/Models/EntryDAO.php
  17. 3 2
      app/Models/Feed.php
  18. 10 10
      app/Models/FeedDAO.php
  19. 7 7
      app/Models/TagDAO.php
  20. 15 1
      app/Models/UserConfiguration.php
  21. 127 83
      app/Models/UserQuery.php
  22. 6 4
      app/Models/View.php
  23. 3 3
      app/Models/ViewJavascript.php
  24. 3 3
      app/Models/ViewStats.php
  25. 1 1
      app/Services/ExportService.php
  26. 2 1
      app/Utils/dotpathUtil.php
  27. 2 2
      app/i18n/cz/admin.php
  28. 13 0
      app/i18n/cz/conf.php
  29. 2 2
      app/i18n/de/admin.php
  30. 13 0
      app/i18n/de/conf.php
  31. 2 2
      app/i18n/el/admin.php
  32. 13 0
      app/i18n/el/conf.php
  33. 2 2
      app/i18n/en-us/admin.php
  34. 13 0
      app/i18n/en-us/conf.php
  35. 2 2
      app/i18n/en/admin.php
  36. 13 0
      app/i18n/en/conf.php
  37. 2 2
      app/i18n/es/admin.php
  38. 13 0
      app/i18n/es/conf.php
  39. 2 2
      app/i18n/fa/admin.php
  40. 13 0
      app/i18n/fa/conf.php
  41. 2 2
      app/i18n/fr/admin.php
  42. 13 0
      app/i18n/fr/conf.php
  43. 2 2
      app/i18n/he/admin.php
  44. 13 0
      app/i18n/he/conf.php
  45. 2 2
      app/i18n/hu/admin.php
  46. 13 0
      app/i18n/hu/conf.php
  47. 2 2
      app/i18n/id/admin.php
  48. 14 1
      app/i18n/id/conf.php
  49. 2 2
      app/i18n/it/admin.php
  50. 13 0
      app/i18n/it/conf.php
  51. 2 2
      app/i18n/ja/admin.php
  52. 13 0
      app/i18n/ja/conf.php
  53. 2 2
      app/i18n/ko/admin.php
  54. 13 0
      app/i18n/ko/conf.php
  55. 2 2
      app/i18n/lv/admin.php
  56. 13 0
      app/i18n/lv/conf.php
  57. 2 2
      app/i18n/nl/admin.php
  58. 13 0
      app/i18n/nl/conf.php
  59. 2 2
      app/i18n/oc/admin.php
  60. 13 0
      app/i18n/oc/conf.php
  61. 2 2
      app/i18n/pl/admin.php
  62. 13 0
      app/i18n/pl/conf.php
  63. 2 2
      app/i18n/pt-br/admin.php
  64. 13 0
      app/i18n/pt-br/conf.php
  65. 2 2
      app/i18n/ru/admin.php
  66. 13 0
      app/i18n/ru/conf.php
  67. 2 2
      app/i18n/sk/admin.php
  68. 13 0
      app/i18n/sk/conf.php
  69. 2 2
      app/i18n/tr/admin.php
  70. 13 0
      app/i18n/tr/conf.php
  71. 2 2
      app/i18n/zh-cn/admin.php
  72. 13 0
      app/i18n/zh-cn/conf.php
  73. 2 2
      app/i18n/zh-tw/admin.php
  74. 13 0
      app/i18n/zh-tw/conf.php
  75. 23 25
      app/layout/header.phtml
  76. 9 7
      app/layout/layout.phtml
  77. 9 34
      app/layout/nav_menu.phtml
  78. 32 10
      app/layout/simple.phtml
  79. 3 0
      app/views/configure/queries.phtml
  80. 53 14
      app/views/helpers/configure/query.phtml
  81. 1 1
      app/views/helpers/export/articles.phtml
  82. 0 3
      app/views/helpers/feed/update.phtml
  83. 21 0
      app/views/helpers/htmlPagination.phtml
  84. 117 0
      app/views/helpers/index/article.phtml
  85. 0 3
      app/views/helpers/index/normal/entry_header.phtml
  86. 42 0
      app/views/helpers/index/tags.phtml
  87. 32 0
      app/views/index/html.phtml
  88. 12 90
      app/views/index/normal.phtml
  89. 8 186
      app/views/index/reader.phtml
  90. 2 4
      app/views/index/rss.phtml
  91. 1 0
      app/views/user/profile.phtml
  92. 3 3
      config-user.default.php
  93. binární
      docs/en/img/users/user-query-share.png
  94. 1 0
      docs/en/users/03_Main_view.md
  95. 1 4
      docs/en/users/05_Configuration.md
  96. 6 22
      docs/en/users/10_filter.md
  97. 63 0
      docs/en/users/user_queries.md
  98. 3 2
      lib/Minz/Request.php
  99. 3 3
      p/api/greader.php
  100. 175 0
      p/api/query.php

+ 5 - 1
README.fr.md

@@ -15,7 +15,11 @@ Il y a une API pour les clients (mobiles), ainsi qu’une [interface en ligne de
 Grâce au standard [WebSub](https://freshrss.github.io/FreshRSS/fr/users/08_PubSubHubbub.html),
 FreshRSS est capable de recevoir des notifications push instantanées depuis les sources compatibles, [Friendica](https://friendi.ca), [WordPress](https://wordpress.org/plugins/pubsubhubbub/), Blogger, Medium, etc.
 
-FreshRSS supporte nativement le moissonnage du Web (Web Scraping) basique, basé sur [XPath](https://www.w3.org/TR/xpath-10/), pour les sites Web sans flux RSS / Atom.
+FreshRSS supporte nativement le [moissonnage du Web (Web Scraping)](https://freshrss.github.io/FreshRSS/en/users/11_website_scraping.html) basique,
+basé sur [XPath](https://www.w3.org/TR/xpath-10/), pour les sites Web sans flux RSS / Atom.
+Supporte aussi les documents JSON.
+
+FreshRSS permet de [repartager des sélections d’articles par HTML, RSS, et OPML](https://freshrss.github.io/FreshRSS/en/users/user_queries.html).
 
 Plusieurs [méthodes de connexion](https://freshrss.github.io/FreshRSS/en/admins/09_AccessControl.html) sont supportées : formulaire Web (avec un mode anonyme), Authentification HTTP (compatible avec proxy), OpenID Connect.
 

+ 5 - 1
README.md

@@ -15,7 +15,11 @@ There is an API for (mobile) clients, and a [Command-Line Interface](cli/README.
 Thanks to the [WebSub](https://freshrss.github.io/FreshRSS/en/users/WebSub.html) standard,
 FreshRSS is able to receive instant push notifications from compatible sources, such as [Friendica](https://friendi.ca), [WordPress](https://wordpress.org/plugins/pubsubhubbub/), Blogger, Medium, etc.
 
-FreshRSS natively supports basic Web scraping, based on [XPath](https://www.w3.org/TR/xpath-10/), for Web sites not providing any RSS / Atom feed.
+FreshRSS natively supports basic [Web scraping](https://freshrss.github.io/FreshRSS/en/users/11_website_scraping.html),
+based on [XPath](https://www.w3.org/TR/xpath-10/), for Web sites not providing any RSS / Atom feed.
+Also supports JSON documents.
+
+FreshRSS offers the ability to [reshare selections of articles by HTML, RSS, and OPML](https://freshrss.github.io/FreshRSS/en/users/user_queries.html).
 
 Different [login methods](https://freshrss.github.io/FreshRSS/en/admins/09_AccessControl.html) are supported: Web form (including an anonymous option), HTTP Authentication (compatible with proxy delegation), OpenID Connect.
 

+ 28 - 28
app/Controllers/configureController.php

@@ -301,12 +301,8 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 	public function queriesAction(): void {
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/draggable.js?' . @filemtime(PUBLIC_PATH . '/scripts/draggable.js')));
 
-		$category_dao = FreshRSS_Factory::createCategoryDao();
-		$feed_dao = FreshRSS_Factory::createFeedDao();
-		$tag_dao = FreshRSS_Factory::createTagDao();
-
 		if (Minz_Request::isPost()) {
-			/** @var array<int,array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> $params */
+			/** @var array<int,array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string}> $params */
 			$params = Minz_Request::paramArray('queries');
 
 			$queries = [];
@@ -318,7 +314,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 				if (!empty($query['search'])) {
 					$query['search'] = urldecode($query['search']);
 				}
-				$queries[$key] = (new FreshRSS_UserQuery($query, $feed_dao, $category_dao, $tag_dao))->toArray();
+				$queries[$key] = (new FreshRSS_UserQuery($query, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
 			}
 			FreshRSS_Context::userConf()->queries = $queries;
 			FreshRSS_Context::userConf()->save();
@@ -327,13 +323,13 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 		} else {
 			$this->view->queries = [];
 			foreach (FreshRSS_Context::userConf()->queries as $key => $query) {
-				$this->view->queries[intval($key)] = new FreshRSS_UserQuery($query, $feed_dao, $category_dao, $tag_dao);
+				$this->view->queries[intval($key)] = new FreshRSS_UserQuery($query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
 			}
 		}
 
-		$this->view->categories = $category_dao->listCategories(false) ?: [];
-		$this->view->feeds = $feed_dao->listFeeds();
-		$this->view->tags = $tag_dao->listTags() ?: [];
+		$this->view->categories = FreshRSS_Context::categories();
+		$this->view->feeds = FreshRSS_Context::feeds();
+		$this->view->tags = FreshRSS_Context::labels();
 
 		if (Minz_Request::paramTernary('id') !== null) {
 			$id = Minz_Request::paramInt('id');
@@ -363,20 +359,21 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 			return;
 		}
 
-		$category_dao = FreshRSS_Factory::createCategoryDao();
-		$feed_dao = FreshRSS_Factory::createFeedDao();
-		$tag_dao = FreshRSS_Factory::createTagDao();
-
-		$query = new FreshRSS_UserQuery(FreshRSS_Context::userConf()->queries[$id], $feed_dao, $category_dao, $tag_dao);
+		$query = new FreshRSS_UserQuery(FreshRSS_Context::userConf()->queries[$id], FreshRSS_Context::categories(), FreshRSS_Context::labels());
 		$this->view->query = $query;
 		$this->view->queryId = $id;
-		$this->view->categories = $category_dao->listCategories(false) ?: [];
-		$this->view->feeds = $feed_dao->listFeeds();
-		$this->view->tags = $tag_dao->listTags() ?: [];
+		$this->view->categories = FreshRSS_Context::categories();
+		$this->view->feeds = FreshRSS_Context::feeds();
+		$this->view->tags = FreshRSS_Context::labels();
 
 		if (Minz_Request::isPost()) {
 			$params = array_filter(Minz_Request::paramArray('query'));
 			$queryParams = [];
+			$name = Minz_Request::paramString('name') ?: _t('conf.query.number', $id + 1);
+			if ('' === $name) {
+				$name = _t('conf.query.number', $id + 1);
+			}
+			$queryParams['name'] = $name;
 			if (!empty($params['get']) && is_string($params['get'])) {
 				$queryParams['get'] = htmlspecialchars_decode($params['get'], ENT_QUOTES);
 			}
@@ -389,15 +386,21 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 			if (!empty($params['state']) && is_array($params['state'])) {
 				$queryParams['state'] = (int)(array_sum($params['state']));
 			}
-			$name = Minz_Request::paramString('name') ?: _t('conf.query.number', $id + 1);
-			if ('' === $name) {
-				$name = _t('conf.query.number', $id + 1);
+			if (empty($params['token']) || !is_string($params['token'])) {
+				$queryParams['token'] = FreshRSS_UserQuery::generateToken($name);
+			} else {
+				$queryParams['token'] = $params['token'];
+			}
+			if (!empty($params['shareRss']) && ctype_digit($params['shareRss'])) {
+				$queryParams['shareRss'] = (bool)$params['shareRss'];
+			}
+			if (!empty($params['shareOpml']) && ctype_digit($params['shareOpml'])) {
+				$queryParams['shareOpml'] = (bool)$params['shareOpml'];
 			}
-			$queryParams['name'] = $name;
 			$queryParams['url'] = Minz_Url::display(['params' => $queryParams]);
 
 			$queries = FreshRSS_Context::userConf()->queries;
-			$queries[$id] = (new FreshRSS_UserQuery($queryParams, $feed_dao, $category_dao, $tag_dao))->toArray();
+			$queries[$id] = (new FreshRSS_UserQuery($queryParams, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
 			FreshRSS_Context::userConf()->queries = $queries;
 			FreshRSS_Context::userConf()->save();
 
@@ -433,18 +436,15 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 	 * lean data.
 	 */
 	public function bookmarkQueryAction(): void {
-		$category_dao = FreshRSS_Factory::createCategoryDao();
-		$feed_dao = FreshRSS_Factory::createFeedDao();
-		$tag_dao = FreshRSS_Factory::createTagDao();
 		$queries = [];
 		foreach (FreshRSS_Context::userConf()->queries as $key => $query) {
-			$queries[$key] = (new FreshRSS_UserQuery($query, $feed_dao, $category_dao, $tag_dao))->toArray();
+			$queries[$key] = (new FreshRSS_UserQuery($query, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
 		}
 		$params = $_GET;
 		unset($params['rid']);
 		$params['url'] = Minz_Url::display(['params' => $params]);
 		$params['name'] = _t('conf.query.number', count($queries) + 1);
-		$queries[] = (new FreshRSS_UserQuery($params, $feed_dao, $category_dao, $tag_dao))->toArray();
+		$queries[] = (new FreshRSS_UserQuery($params, FreshRSS_Context::categories(), FreshRSS_Context::labels()))->toArray();
 
 		FreshRSS_Context::userConf()->queries = $queries;
 		FreshRSS_Context::userConf()->save();

+ 1 - 1
app/Controllers/feedController.php

@@ -776,7 +776,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 	 */
 	private static function applyLabelActions(int $nbNewEntries) {
 		$tagDAO = FreshRSS_Factory::createTagDao();
-		$labels = $tagDAO->listTags() ?: [];
+		$labels = FreshRSS_Context::labels();
 		$labels = array_filter($labels, static function (FreshRSS_Tag $label) {
 			return !empty($label->filtersAction('label'));
 		});

+ 1 - 1
app/Controllers/importExportController.php

@@ -364,7 +364,7 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
 		}
 
 		$tagDAO = FreshRSS_Factory::createTagDao();
-		$labels = $tagDAO->listTags() ?: [];
+		$labels = FreshRSS_Context::labels();
 		$knownLabels = [];
 		foreach ($labels as $label) {
 			$knownLabels[$label->name()]['id'] = $label->id();

+ 30 - 29
app/Controllers/indexController.php

@@ -6,6 +6,10 @@ declare(strict_types=1);
  */
 class FreshRSS_index_Controller extends FreshRSS_ActionController {
 
+	public function firstAction(): void {
+		$this->view->html_url = Minz_Url::display(['c' => 'index', 'a' => 'index'], 'html', 'root');
+	}
+
 	/**
 	 * This action only redirect on the default view mode (normal or global)
 	 */
@@ -36,7 +40,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		}
 
 		try {
-			FreshRSS_Context::updateUsingRequest();
+			FreshRSS_Context::updateUsingRequest(true);
 		} catch (FreshRSS_Context_Exception $e) {
 			Minz_Error::error(404);
 		}
@@ -48,7 +52,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 			'media-src' => '*',
 		]);
 
-		$this->view->categories = FreshRSS_Context::$categories;
+		$this->view->categories = FreshRSS_Context::categories();
 
 		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
 		$title = FreshRSS_Context::$name;
@@ -60,15 +64,10 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		FreshRSS_Context::$id_max = time() . '000000';
 
 		$this->view->callbackBeforeFeeds = static function (FreshRSS_View $view) {
-			try {
-				$tagDAO = FreshRSS_Factory::createTagDao();
-				$view->tags = $tagDAO->listTags(true) ?: [];
-				$view->nbUnreadTags = 0;
-				foreach ($view->tags as $tag) {
-					$view->nbUnreadTags += $tag->nbUnread();
-				}
-			} catch (Exception $e) {
-				Minz_Log::notice($e->getMessage());
+			$view->tags = FreshRSS_Context::labels(true);
+			$view->nbUnreadTags = 0;
+			foreach ($view->tags as $tag) {
+				$view->nbUnreadTags += $tag->nbUnread();
 			}
 		};
 
@@ -117,12 +116,12 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/global_view.js?' . @filemtime(PUBLIC_PATH . '/scripts/global_view.js')));
 
 		try {
-			FreshRSS_Context::updateUsingRequest();
+			FreshRSS_Context::updateUsingRequest(true);
 		} catch (FreshRSS_Context_Exception $e) {
 			Minz_Error::error(404);
 		}
 
-		$this->view->categories = FreshRSS_Context::$categories;
+		$this->view->categories = FreshRSS_Context::categories();
 
 		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
 		$title = _t('index.feed.title_global');
@@ -141,6 +140,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 
 	/**
 	 * This action displays the RSS feed of FreshRSS.
+	 * @deprecated See user query RSS sharing instead
 	 */
 	public function rssAction(): void {
 		$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
@@ -156,7 +156,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		}
 
 		try {
-			FreshRSS_Context::updateUsingRequest();
+			FreshRSS_Context::updateUsingRequest(false);
 		} catch (FreshRSS_Context_Exception $e) {
 			Minz_Error::error(404);
 		}
@@ -168,13 +168,19 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 			Minz_Error::error(404);
 		}
 
-		// No layout for RSS output.
-		$this->view->rss_url = PUBLIC_TO_INDEX_PATH . '/' . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']);
+		$this->view->html_url = Minz_Url::display('', 'html', true);
 		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . FreshRSS_View::title();
+		$this->view->rss_url = htmlspecialchars(
+			PUBLIC_TO_INDEX_PATH . '/' . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']), ENT_COMPAT, 'UTF-8');
+
+		// No layout for RSS output.
 		$this->view->_layout(null);
 		header('Content-Type: application/rss+xml; charset=utf-8');
 	}
 
+	/**
+	 * @deprecated See user query OPML sharing instead
+	 */
 	public function opmlAction(): void {
 		$allow_anonymous = FreshRSS_Context::systemConf()->allow_anonymous;
 		$token = FreshRSS_Context::userConf()->token;
@@ -187,7 +193,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		}
 
 		try {
-			FreshRSS_Context::updateUsingRequest();
+			FreshRSS_Context::updateUsingRequest(false);
 		} catch (FreshRSS_Context_Exception $e) {
 			Minz_Error::error(404);
 		}
@@ -196,25 +202,23 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		$type = (string)$get[0];
 		$id = (int)$get[1];
 
-		$catDAO = FreshRSS_Factory::createCategoryDao();
-		$categories = $catDAO->listCategories(true, true);
 		$this->view->excludeMutedFeeds = true;
 
 		switch ($type) {
 			case 'a':
-				$this->view->categories = $categories;
+				$this->view->categories = FreshRSS_Context::categories();
 				break;
 			case 'c':
-				$cat = $categories[$id] ?? null;
+				$cat = FreshRSS_Context::categories()[$id] ?? null;
 				if ($cat == null) {
 					Minz_Error::error(404);
 					return;
 				}
-				$this->view->categories = [ $cat ];
+				$this->view->categories = [ $cat->id() => $cat ];
 				break;
 			case 'f':
 				// We most likely already have the feed object in cache
-				$feed = FreshRSS_CategoryDAO::findFeed($categories, $id);
+				$feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $id);
 				if ($feed === null) {
 					$feedDAO = FreshRSS_Factory::createFeedDao();
 					$feed = $feedDAO->searchById($id);
@@ -223,7 +227,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 						return;
 					}
 				}
-				$this->view->feeds = [ $feed ];
+				$this->view->feeds = [ $feed->id() => $feed ];
 				break;
 			case 's':
 			case 't':
@@ -255,17 +259,14 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 			$id = 0;
 		}
 
-		$limit = FreshRSS_Context::$number;
-
 		$date_min = 0;
-		if (FreshRSS_Context::$sinceHours) {
+		if (FreshRSS_Context::$sinceHours > 0) {
 			$date_min = time() - (FreshRSS_Context::$sinceHours * 3600);
-			$limit = FreshRSS_Context::userConf()->max_posts_per_rss;
 		}
 
 		foreach ($entryDAO->listWhere(
 					$type, $id, FreshRSS_Context::$state, FreshRSS_Context::$order,
-					$limit, FreshRSS_Context::$first_id,
+					FreshRSS_Context::$number, FreshRSS_Context::$offset, FreshRSS_Context::$first_id,
 					FreshRSS_Context::$search, $date_min)
 				as $entry) {
 			yield $entry;

+ 2 - 2
app/Controllers/statsController.php

@@ -193,7 +193,7 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 		if ($id !== 0) {
 			$this->view->displaySlider = true;
 			$feedDAO = FreshRSS_Factory::createFeedDao();
-			$this->view->feed = $feedDAO->searchById($id);
+			$this->view->feed = $feedDAO->searchById($id) ?? FreshRSS_Feed::default();
 		}
 	}
 
@@ -222,7 +222,7 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 		}
 
 		$this->view->categories 	= $categoryDAO->listCategories(true) ?: [];
-		$this->view->feed 			= $id === null ? null : $feedDAO->searchById($id);
+		$this->view->feed 			= $id === null ? FreshRSS_Feed::default() : ($feedDAO->searchById($id) ?? FreshRSS_Feed::default());
 		$this->view->days 			= $statsDAO->getDays();
 		$this->view->months 		= $statsDAO->getMonths();
 

+ 1 - 1
app/Controllers/subscriptionController.php

@@ -59,7 +59,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 					break;
 				default:
 					$feedDAO = FreshRSS_Factory::createFeedDao();
-					$this->view->feed = $feedDAO->searchById($id);
+					$this->view->feed = $feedDAO->searchById($id) ?? FreshRSS_Feed::default();
 					break;
 			}
 		}

+ 1 - 1
app/Controllers/tagController.php

@@ -199,6 +199,6 @@ class FreshRSS_tag_Controller extends FreshRSS_ActionController {
 			Minz_Error::error(403);
 		}
 		$tagDAO = FreshRSS_Factory::createTagDao();
-		$this->view->tags = $tagDAO->listTags() ?: [];
+		$this->view->tags = $tagDAO->listTags(true) ?: [];
 	}
 }

+ 1 - 1
app/FreshRSS.php

@@ -143,7 +143,7 @@ class FreshRSS extends Minz_FrontController {
 			}
 		}
 		//Use prepend to insert before extensions. Added in reverse order.
-		if (Minz_Request::controllerName() !== 'index') {
+		if (!in_array(Minz_Request::controllerName(), ['index', ''], true)) {
 			FreshRSS_View::prependScript(Minz_Url::display('/scripts/extra.js?' . @filemtime(PUBLIC_PATH . '/scripts/extra.js')));
 		}
 		FreshRSS_View::prependScript(Minz_Url::display('/scripts/main.js?' . @filemtime(PUBLIC_PATH . '/scripts/main.js')));

+ 19 - 15
app/Models/BooleanSearch.php

@@ -16,14 +16,12 @@ class FreshRSS_BooleanSearch {
 	private string $operator;
 
 	/** @param 'AND'|'OR'|'AND NOT' $operator */
-	public function __construct(string $input, int $level = 0, string $operator = 'AND') {
+	public function __construct(string $input, int $level = 0, string $operator = 'AND', bool $allowUserQueries = true) {
 		$this->operator = $operator;
 		$input = trim($input);
 		if ($input === '') {
 			return;
 		}
-		$this->raw_input = $input;
-
 		if ($level === 0) {
 			$input = preg_replace('/:&quot;(.*?)&quot;/', ':"\1"', $input);
 			if (!is_string($input)) {
@@ -34,9 +32,11 @@ class FreshRSS_BooleanSearch {
 				return;
 			}
 
-			$input = $this->parseUserQueryNames($input);
-			$input = $this->parseUserQueryIds($input);
+			$input = $this->parseUserQueryNames($input, $allowUserQueries);
+			$input = $this->parseUserQueryIds($input, $allowUserQueries);
+			$input = trim($input);
 		}
+		$this->raw_input = $input;
 
 		// Either parse everything as a series of BooleanSearch’s combined by implicit AND
 		// or parse everything as a series of Search’s combined by explicit OR
@@ -46,7 +46,7 @@ class FreshRSS_BooleanSearch {
 	/**
 	 * Parse the user queries (saved searches) by name and expand them in the input string.
 	 */
-	private function parseUserQueryNames(string $input): string {
+	private function parseUserQueryNames(string $input, bool $allowUserQueries = true): string {
 		$all_matches = [];
 		if (preg_match_all('/\bsearch:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matchesFound)) {
 			$all_matches[] = $matchesFound;
@@ -60,7 +60,7 @@ class FreshRSS_BooleanSearch {
 			/** @var array<string,FreshRSS_UserQuery> */
 			$queries = [];
 			foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
-				$query = new FreshRSS_UserQuery($raw_query);
+				$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
 				$queries[$query->getName()] = $query;
 			}
 
@@ -74,7 +74,11 @@ class FreshRSS_BooleanSearch {
 					$name = trim($matches['search'][$i]);
 					if (!empty($queries[$name])) {
 						$fromS[] = $matches[0][$i];
-						$toS[] = '(' . trim($queries[$name]->getSearch()->getRawInput()) . ')';
+						if ($allowUserQueries) {
+							$toS[] = '(' . trim($queries[$name]->getSearch()->getRawInput()) . ')';
+						} else {
+							$toS[] = '';
+						}
 					}
 				}
 			}
@@ -87,7 +91,7 @@ class FreshRSS_BooleanSearch {
 	/**
 	 * Parse the user queries (saved searches) by ID and expand them in the input string.
 	 */
-	private function parseUserQueryIds(string $input): string {
+	private function parseUserQueryIds(string $input, bool $allowUserQueries = true): string {
 		$all_matches = [];
 
 		if (preg_match_all('/\bS:(?P<search>\d+)/', $input, $matchesFound)) {
@@ -95,14 +99,10 @@ class FreshRSS_BooleanSearch {
 		}
 
 		if (!empty($all_matches)) {
-			$category_dao = FreshRSS_Factory::createCategoryDao();
-			$feed_dao = FreshRSS_Factory::createFeedDao();
-			$tag_dao = FreshRSS_Factory::createTagDao();
-
 			/** @var array<string,FreshRSS_UserQuery> */
 			$queries = [];
 			foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
-				$query = new FreshRSS_UserQuery($raw_query, $feed_dao, $category_dao, $tag_dao);
+				$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
 				$queries[] = $query;
 			}
 
@@ -117,7 +117,11 @@ class FreshRSS_BooleanSearch {
 					$id = (int)(trim($matches['search'][$i])) - 1;
 					if (!empty($queries[$id])) {
 						$fromS[] = $matches[0][$i];
-						$toS[] = '(' . trim($queries[$id]->getSearch()->getRawInput()) . ')';
+						if ($allowUserQueries) {
+							$toS[] = '(' . trim($queries[$id]->getSearch()->getRawInput()) . ')';
+						} else {
+							$toS[] = '';
+						}
 					}
 				}
 			}

+ 48 - 6
app/Models/Category.php

@@ -95,7 +95,7 @@ class FreshRSS_Category extends Minz_Model {
 	}
 
 	/**
-	 * @return array<FreshRSS_Feed>
+	 * @return array<int,FreshRSS_Feed>
 	 * @throws Minz_ConfigurationNamespaceException
 	 * @throws Minz_PDOConnectionException
 	 */
@@ -110,10 +110,8 @@ class FreshRSS_Category extends Minz_Model {
 				$this->nbNotRead += $feed->nbNotRead();
 				$this->hasFeedsWithError |= ($feed->inError() && !$feed->mute());
 			}
-
 			$this->sortFeeds();
 		}
-
 		return $this->feeds ?? [];
 	}
 
@@ -143,7 +141,6 @@ class FreshRSS_Category extends Minz_Model {
 		if (!is_array($values)) {
 			$values = [$values];
 		}
-
 		$this->feeds = $values;
 		$this->sortFeeds();
 	}
@@ -157,7 +154,6 @@ class FreshRSS_Category extends Minz_Model {
 		}
 		$feed->_category($this);
 		$this->feeds[] = $feed;
-
 		$this->sortFeeds();
 	}
 
@@ -243,8 +239,54 @@ class FreshRSS_Category extends Minz_Model {
 		if ($this->feeds === null) {
 			return;
 		}
-		usort($this->feeds, static function (FreshRSS_Feed $a, FreshRSS_Feed $b) {
+		uasort($this->feeds, static function (FreshRSS_Feed $a, FreshRSS_Feed $b) {
 			return strnatcasecmp($a->name(), $b->name());
 		});
 	}
+
+	/**
+	 * Access cached feed
+	 * @param array<FreshRSS_Category> $categories
+	 */
+	public static function findFeed(array $categories, int $feed_id): ?FreshRSS_Feed {
+		foreach ($categories as $category) {
+			foreach ($category->feeds() as $feed) {
+				if ($feed->id() === $feed_id) {
+					$feed->_category($category);	// Should already be done; just to be safe
+					return $feed;
+				}
+			}
+		}
+		return null;
+	}
+
+	/**
+	 * Access cached feeds
+	 * @param array<FreshRSS_Category> $categories
+	 * @return array<int,FreshRSS_Feed>
+	 */
+	public static function findFeeds(array $categories): array {
+		$result = [];
+		foreach ($categories as $category) {
+			foreach ($category->feeds() as $feed) {
+				$result[$feed->id()] = $feed;
+			}
+		}
+		return $result;
+	}
+
+	/**
+	 * @param array<FreshRSS_Category> $categories
+	 */
+	public static function countUnread(array $categories, int $minPriority = 0): int {
+		$n = 0;
+		foreach ($categories as $category) {
+			foreach ($category->feeds() as $feed) {
+				if ($feed->priority() >= $minPriority) {
+					$n += $feed->nbNotRead();
+				}
+			}
+		}
+		return $n;
+	}
 }

+ 21 - 51
app/Models/CategoryDAO.php

@@ -245,19 +245,19 @@ SQL;
 		$sql = 'SELECT * FROM `_category` WHERE id=:id';
 		$res = $this->fetchAssoc($sql, ['id' => $id]) ?? [];
 		/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate':int,'error':int|bool,'attributes':string}> $res */
-		$cat = self::daoToCategory($res);
-		return $cat[0] ?? null;
+		$categories = self::daoToCategories($res);
+		return reset($categories) ?: null;
 	}
 
 	public function searchByName(string $name): ?FreshRSS_Category {
 		$sql = 'SELECT * FROM `_category` WHERE name=:name';
 		$res = $this->fetchAssoc($sql, ['name' => $name]) ?? [];
 		/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate':int,'error':int|bool,'attributes':string}> $res */
-		$cat = self::daoToCategory($res);
-		return $cat[0] ?? null;
+		$categories = self::daoToCategories($res);
+		return reset($categories) ?: null;
 	}
 
-	/** @return array<FreshRSS_Category> */
+	/** @return array<int,FreshRSS_Category> */
 	public function listSortedCategories(bool $prePopulateFeeds = true, bool $details = false): array {
 		$categories = $this->listCategories($prePopulateFeeds, $details);
 
@@ -277,7 +277,7 @@ SQL;
 		return $categories;
 	}
 
-	/** @return array<FreshRSS_Category> */
+	/** @return array<int,FreshRSS_Category> */
 	public function listCategories(bool $prePopulateFeeds = true, bool $details = false): array {
 		if ($prePopulateFeeds) {
 			$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, '
@@ -293,7 +293,7 @@ SQL;
 				$res = $stm->fetchAll(PDO::FETCH_ASSOC) ?: [];
 				/** @var array<array{'c_name':string,'c_id':int,'c_kind':int,'c_last_update':int,'c_error':int|bool,'c_attributes'?:string,
 				 * 	'id'?:int,'name'?:string,'url'?:string,'kind'?:int,'category'?:int,'website'?:string,'priority'?:int,'error'?:int|bool,'cache_nbEntries'?:int,'cache_nbUnreads'?:int,'ttl'?:int}> $res */
-				return self::daoToCategoryPrepopulated($res);
+				return self::daoToCategoriesPrepopulated($res);
 			} else {
 				$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 				if ($this->autoUpdateDb($info)) {
@@ -305,11 +305,11 @@ SQL;
 		} else {
 			$res = $this->fetchAssoc('SELECT * FROM `_category` ORDER BY name');
 			/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $res */
-			return $res == null ? [] : self::daoToCategory($res);
+			return empty($res) ? [] : self::daoToCategories($res);
 		}
 	}
 
-	/** @return array<FreshRSS_Category> */
+	/** @return array<int,FreshRSS_Category> */
 	public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0): array {
 		$sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`'
 			. ($limit < 1 ? '' : ' LIMIT ' . $limit);
@@ -318,7 +318,7 @@ SQL;
 			$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));
+			return self::daoToCategories($stm->fetchAll(PDO::FETCH_ASSOC));
 		} else {
 			$info = $stm ? $stm->errorInfo() : $this->pdo->errorInfo();
 			if ($this->autoUpdateDb($info)) {
@@ -333,9 +333,9 @@ SQL;
 		$sql = 'SELECT * FROM `_category` WHERE id=:id';
 		$res = $this->fetchAssoc($sql, [':id' => self::DEFAULTCATEGORYID]) ?? [];
 		/** @var array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $res */
-		$cat = self::daoToCategory($res);
-		if (isset($cat[0])) {
-			return $cat[0];
+		$categories = self::daoToCategories($res);
+		if (isset($categories[self::DEFAULTCATEGORYID])) {
+			return $categories[self::DEFAULTCATEGORYID];
 		} else {
 			if (FreshRSS_Context::$isCli) {
 				fwrite(STDERR, 'FreshRSS database error: Default category not found!' . "\n");
@@ -394,41 +394,13 @@ SQL;
 		return isset($res[0]) ? (int)$res[0] : -1;
 	}
 
-	/** @param array<FreshRSS_Category> $categories */
-	public static function findFeed(array $categories, int $feed_id): ?FreshRSS_Feed {
-		foreach ($categories as $category) {
-			foreach ($category->feeds() as $feed) {
-				if ($feed->id() === $feed_id) {
-					$feed->_category($category);	// Should already be done; just to be safe
-					return $feed;
-				}
-			}
-		}
-		return null;
-	}
-
-	/**
-	 * @param array<FreshRSS_Category> $categories
-	 */
-	public static function countUnread(array $categories, int $minPriority = 0): int {
-		$n = 0;
-		foreach ($categories as $category) {
-			foreach ($category->feeds() as $feed) {
-				if ($feed->priority() >= $minPriority) {
-					$n += $feed->nbNotRead();
-				}
-			}
-		}
-		return $n;
-	}
-
 	/**
 	 * @param array<array{'c_name':string,'c_id':int,'c_kind':int,'c_last_update':int,'c_error':int|bool,'c_attributes'?:string,
 	 * 	'id'?:int,'name'?:string,'url'?:string,'kind'?:int,'website'?:string,'priority'?:int,
 	 * 	'error'?:int|bool,'cache_nbEntries'?:int,'cache_nbUnreads'?:int,'ttl'?:int}> $listDAO
 	 * @return array<int,FreshRSS_Category>
 	 */
-	private static function daoToCategoryPrepopulated(array $listDAO): array {
+	private static function daoToCategoriesPrepopulated(array $listDAO): array {
 		$list = [];
 		$previousLine = [];
 		$feedsDao = [];
@@ -441,11 +413,11 @@ SQL;
 				$cat = new FreshRSS_Category(
 					$previousLine['c_name'],
 					$previousLine['c_id'],
-					$feedDao::daoToFeed($feedsDao, $previousLine['c_id'])
+					$feedDao::daoToFeeds($feedsDao, $previousLine['c_id'])
 				);
 				$cat->_kind($previousLine['c_kind']);
 				$cat->_attributes($previousLine['c_attributes'] ?? '[]');
-				$list[(int)$previousLine['c_id']] = $cat;
+				$list[$cat->id()] = $cat;
 
 				$feedsDao = [];	//Prepare for next category
 			}
@@ -459,13 +431,13 @@ SQL;
 			$cat = new FreshRSS_Category(
 				$previousLine['c_name'],
 				$previousLine['c_id'],
-				$feedDao::daoToFeed($feedsDao, $previousLine['c_id'])
+				$feedDao::daoToFeeds($feedsDao, $previousLine['c_id'])
 			);
 			$cat->_kind($previousLine['c_kind']);
 			$cat->_lastUpdate($previousLine['c_last_update'] ?? 0);
 			$cat->_error($previousLine['c_error'] ?? 0);
 			$cat->_attributes($previousLine['c_attributes'] ?? []);
-			$list[(int)$previousLine['c_id']] = $cat;
+			$list[$cat->id()] = $cat;
 		}
 
 		return $list;
@@ -473,11 +445,10 @@ SQL;
 
 	/**
 	 * @param array<array{'name':string,'id':int,'kind':int,'lastUpdate'?:int,'error'?:int|bool,'attributes'?:string}> $listDAO
-	 * @return array<FreshRSS_Category>
+	 * @return array<int,FreshRSS_Category>
 	 */
-	private static function daoToCategory(array $listDAO): array {
+	private static function daoToCategories(array $listDAO): array {
 		$list = [];
-
 		foreach ($listDAO as $dao) {
 			FreshRSS_DatabaseDAO::pdoInt($dao, ['id', 'kind', 'lastUpdate', 'error']);
 			$cat = new FreshRSS_Category(
@@ -488,9 +459,8 @@ SQL;
 			$cat->_lastUpdate($dao['lastUpdate'] ?? 0);
 			$cat->_error($dao['error'] ?? 0);
 			$cat->_attributes($dao['attributes'] ?? '');
-			$list[] = $cat;
+			$list[$cat->id()] = $cat;
 		}
-
 		return $list;
 	}
 }

+ 38 - 16
app/Models/Context.php

@@ -10,11 +10,11 @@ final class FreshRSS_Context {
 	/**
 	 * @var array<int,FreshRSS_Category>
 	 */
-	public static array $categories = [];
+	private static array $categories = [];
 	/**
 	 * @var array<int,FreshRSS_Tag>
 	 */
-	public static array $tags = [];
+	private static array $tags = [];
 	public static string $name = '';
 	public static string $description = '';
 	public static int $total_unread = 0;
@@ -47,6 +47,7 @@ final class FreshRSS_Context {
 	 */
 	public static string $order = 'DESC';
 	public static int $number = 0;
+	public static int $offset = 0;
 	public static FreshRSS_BooleanSearch $search;
 	public static string $first_id = '';
 	public static string $next_id = '';
@@ -173,10 +174,33 @@ final class FreshRSS_Context {
 		FreshRSS_Context::$user_conf = null;
 	}
 
+	/** @return array<int,FreshRSS_Category> */
+	public static function categories(): array {
+		if (empty(self::$categories)) {
+			$catDAO = FreshRSS_Factory::createCategoryDao();
+			self::$categories = $catDAO->listSortedCategories(true, false);
+		}
+		return self::$categories;
+	}
+
+	/** @return array<int,FreshRSS_Feed> */
+	public static function feeds(): array {
+		return FreshRSS_Category::findFeeds(self::categories());
+	}
+
+	/** @return array<int,FreshRSS_Tag> */
+	public static function labels(bool $precounts = false): array {
+		if (empty(self::$tags) || $precounts) {
+			$tagDAO = FreshRSS_Factory::createTagDao();
+			self::$tags = $tagDAO->listTags($precounts) ?: [];
+		}
+		return self::$tags;
+	}
+
 	/**
 	 * This action updates the Context object by using request parameters.
 	 *
-	 * Parameters are:
+	 * HTTP GET request parameters are:
 	 *   - state (default: conf->default_view)
 	 *   - search (default: empty string)
 	 *   - order (default: conf->sort_order)
@@ -187,18 +211,15 @@ final class FreshRSS_Context {
 	 * @throws Minz_ConfigurationNamespaceException
 	 * @throws Minz_PDOConnectionException
 	 */
-	public static function updateUsingRequest(): void {
-		if (empty(self::$categories)) {
-			$catDAO = FreshRSS_Factory::createCategoryDao();
-			self::$categories = $catDAO->listSortedCategories();
+	public static function updateUsingRequest(bool $computeStatistics): void {
+		if ($computeStatistics && self::$total_unread === 0) {
+			// Update number of read / unread variables.
+			$entryDAO = FreshRSS_Factory::createEntryDao();
+			self::$total_starred = $entryDAO->countUnreadReadFavorites();
+			self::$total_unread = FreshRSS_Category::countUnread(self::categories(), FreshRSS_Feed::PRIORITY_MAIN_STREAM);
+			self::$total_important_unread = FreshRSS_Category::countUnread(self::categories(), FreshRSS_Feed::PRIORITY_IMPORTANT);
 		}
 
-		// Update number of read / unread variables.
-		$entryDAO = FreshRSS_Factory::createEntryDao();
-		self::$total_starred = $entryDAO->countUnreadReadFavorites();
-		self::$total_unread = FreshRSS_CategoryDAO::countUnread(self::$categories, FreshRSS_Feed::PRIORITY_MAIN_STREAM);
-		self::$total_important_unread = FreshRSS_CategoryDAO::countUnread(self::$categories, FreshRSS_Feed::PRIORITY_IMPORTANT);
-
 		self::_get(Minz_Request::paramString('get') ?: 'a');
 
 		self::$state = Minz_Request::paramInt('state') ?: FreshRSS_Context::userConf()->default_state;
@@ -224,6 +245,7 @@ final class FreshRSS_Context {
 				FreshRSS_Context::userConf()->max_posts_per_rss,
 				FreshRSS_Context::userConf()->posts_per_page);
 		}
+		self::$offset = Minz_Request::paramInt('offset');
 		self::$first_id = Minz_Request::paramString('next');
 		self::$sinceHours = Minz_Request::paramInt('hours');
 	}
@@ -394,7 +416,7 @@ final class FreshRSS_Context {
 			break;
 		case 'f':
 			// We try to find the corresponding feed. When allowing robots, always retrieve the full feed including description
-			$feed = FreshRSS_Context::systemConf()->allow_robots ? null : FreshRSS_CategoryDAO::findFeed(self::$categories, $id);
+			$feed = FreshRSS_Context::systemConf()->allow_robots ? null : FreshRSS_Category::findFeed(self::$categories, $id);
 			if ($feed === null) {
 				$feedDAO = FreshRSS_Factory::createFeedDao();
 				$feed = $feedDAO->searchById($id);
@@ -417,7 +439,7 @@ final class FreshRSS_Context {
 				if ($cat === null) {
 					throw new FreshRSS_Context_Exception('Invalid category: ' . $id);
 				}
-				//self::$categories[$id] = $cat;
+				self::$categories[$id] = $cat;
 			} else {
 				$cat = self::$categories[$id];
 			}
@@ -433,7 +455,7 @@ final class FreshRSS_Context {
 				if ($tag === null) {
 					throw new FreshRSS_Context_Exception('Invalid tag: ' . $id);
 				}
-				//self::$tags[$id] = $tag;
+				self::$tags[$id] = $tag;
 			} else {
 				$tag = self::$tags[$id];
 			}

+ 22 - 0
app/Models/Entry.php

@@ -815,6 +815,28 @@ HTML;
 		];
 	}
 
+	/**
+	 * @return array{array<string>,array<string>} Array of first tags to show, then array of remaining tags
+	 */
+	public function tagsFormattingHelper(): array {
+		$firstTags = [];
+		$remainingTags = [];
+
+		if (FreshRSS_Context::hasUserConf() && in_array(FreshRSS_Context::userConf()->show_tags, ['b', 'f', 'h'], true)) {
+			$maxTagsDisplayed = (int)FreshRSS_Context::userConf()->show_tags_max;
+			$tags = $this->tags();
+			if (!empty($tags)) {
+				if ($maxTagsDisplayed > 0) {
+					$firstTags = array_slice($tags, 0, $maxTagsDisplayed);
+					$remainingTags = array_slice($tags, $maxTagsDisplayed);
+				} else {
+					$firstTags = $tags;
+				}
+			}
+		}
+		return [$firstTags,$remainingTags];
+	}
+
 	/**
 	 * Integer format conversion for Google Reader API format
 	 * @param string|int $dec Decimal number

+ 11 - 9
app/Models/EntryDAO.php

@@ -1063,7 +1063,7 @@ SQL;
 	 * @throws FreshRSS_EntriesGetter_Exception
 	 */
 	private function sqlListWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
-			string $order = 'DESC', int $limit = 1, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
+			string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
 			int $date_min = 0): array {
 		if (!$state) {
 			$state = FreshRSS_Entry::STATE_ALL;
@@ -1120,7 +1120,9 @@ SQL;
 			. 'WHERE ' . $where
 			. $search
 			. 'ORDER BY e.id ' . $order
-			. ($limit > 0 ? ' LIMIT ' . intval($limit) : '')];	//TODO: See http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/
+			. ($limit > 0 ? ' LIMIT ' . $limit : '')	// http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/
+			. ($offset > 0 ? ' OFFSET ' . $offset : '')
+		];
 	}
 
 	/**
@@ -1131,9 +1133,9 @@ SQL;
 	 * @throws FreshRSS_EntriesGetter_Exception
 	 */
 	private function listWhereRaw(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
-			string $order = 'DESC', int $limit = 1, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
+			string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null,
 			int $date_min = 0) {
-		[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
+		[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
 
 		if ($order !== 'DESC' && $order !== 'ASC') {
 			$order = 'DESC';
@@ -1152,7 +1154,7 @@ SQL;
 		} else {
 			$info = $stm == null ? $this->pdo->errorInfo() : $stm->errorInfo();
 			if ($this->autoUpdateDb($info)) {
-				return $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
+				return $this->listWhereRaw($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
 			}
 			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
 			return false;
@@ -1167,9 +1169,9 @@ SQL;
 	 * @throws FreshRSS_EntriesGetter_Exception
 	 */
 	public function listWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
-			string $order = 'DESC', int $limit = 1, string $firstId = '',
+			string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '',
 			?FreshRSS_BooleanSearch $filters = null, int $date_min = 0): Traversable {
-		$stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
+		$stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $offset, $firstId, $filters, $date_min);
 		if ($stm) {
 			while ($row = $stm->fetch(PDO::FETCH_ASSOC)) {
 				if (is_array($row)) {
@@ -1233,9 +1235,9 @@ SQL;
 	 * @throws FreshRSS_EntriesGetter_Exception
 	 */
 	public function listIdsWhere(string $type = 'a', int $id = 0, int $state = FreshRSS_Entry::STATE_ALL,
-		string $order = 'DESC', int $limit = 1, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null): ?array {
+		string $order = 'DESC', int $limit = 1, int $offset = 0, string $firstId = '', ?FreshRSS_BooleanSearch $filters = null): ?array {
 
-		[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filters);
+		[$values, $sql] = $this->sqlListWhere($type, $id, $state, $order, $limit, $offset, $firstId, $filters);
 		$stm = $this->pdo->prepare($sql);
 		if ($stm !== false && $stm->execute($values) && ($res = $stm->fetchAll(PDO::FETCH_COLUMN, 0)) !== false) {
 			/** @var array<numeric-string> $res */

+ 3 - 2
app/Models/Feed.php

@@ -76,7 +76,7 @@ class FreshRSS_Feed extends Minz_Model {
 		}
 	}
 
-	public static function example(): FreshRSS_Feed {
+	public static function default(): FreshRSS_Feed {
 		$f = new FreshRSS_Feed('http://example.net/', false);
 		$f->faviconPrepare();
 		return $f;
@@ -708,7 +708,8 @@ class FreshRSS_Feed extends Minz_Model {
 		$view = new FreshRSS_View();
 		$view->_path('index/rss.phtml');
 		$view->internal_rendering = true;
-		$view->rss_url = $feedSourceUrl;
+		$view->rss_url = htmlspecialchars($feedSourceUrl, ENT_COMPAT, 'UTF-8');
+		$view->html_url = $view->rss_url;
 		$view->entries = [];
 
 		try {

+ 10 - 10
app/Models/FeedDAO.php

@@ -322,7 +322,7 @@ SQL;
 		}
 		/** @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
 		 *	'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res */
-		$feeds = self::daoToFeed($res);
+		$feeds = self::daoToFeeds($res);
 		return $feeds[$id] ?? null;
 	}
 
@@ -331,7 +331,7 @@ SQL;
 		$res = $this->fetchAssoc($sql, [':url' => $url]);
 		/** @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
 		 *	'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res */
-		return empty($res[0]) ? null : (current(self::daoToFeed($res)) ?: null);
+		return empty($res[0]) ? null : (current(self::daoToFeeds($res)) ?: null);
 	}
 
 	/** @return array<int> */
@@ -343,14 +343,14 @@ SQL;
 	}
 
 	/**
-	 * @return array<FreshRSS_Feed>
+	 * @return array<int,FreshRSS_Feed>
 	 */
 	public function listFeeds(): array {
 		$sql = 'SELECT * FROM `_feed` ORDER BY name';
 		$res = $this->fetchAssoc($sql);
 		/** @var array<array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
 		 *	'priority':int,'pathEntries':string,'httpAuth':string,'error':int,'ttl':int,'attributes':string}>|null $res */
-		return $res == null ? [] : self::daoToFeed($res);
+		return $res == null ? [] : self::daoToFeeds($res);
 	}
 
 	/** @return array<string,string> */
@@ -375,7 +375,7 @@ SQL;
 
 	/**
 	 * @param int $defaultCacheDuration Use -1 to return all feeds, without filtering them by TTL.
-	 * @return array<FreshRSS_Feed>
+	 * @return array<int,FreshRSS_Feed>
 	 */
 	public function listFeedsOrderUpdate(int $defaultCacheDuration = 3600, int $limit = 0): array {
 		$sql = 'SELECT id, url, kind, category, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, ttl, attributes, `cache_nbEntries`, `cache_nbUnreads` '
@@ -387,7 +387,7 @@ SQL;
 			. ($limit < 1 ? '' : 'LIMIT ' . intval($limit));
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
-			return self::daoToFeed($stm->fetchAll(PDO::FETCH_ASSOC));
+			return self::daoToFeeds($stm->fetchAll(PDO::FETCH_ASSOC));
 		} else {
 			$info = $this->pdo->errorInfo();
 			if ($this->autoUpdateDb($info)) {
@@ -409,7 +409,7 @@ SQL;
 
 	/**
 	 * @param bool|null $muted to include only muted feeds
-	 * @return array<FreshRSS_Feed>
+	 * @return array<int,FreshRSS_Feed>
 	 */
 	public function listByCategory(int $cat, ?bool $muted = null): array {
 		$sql = 'SELECT * FROM `_feed` WHERE category=:category';
@@ -425,9 +425,9 @@ SQL;
 		 * @var array<int,array{'url':string,'kind':int,'category':int,'name':string,'website':string,'lastUpdate':int,
 		 *	'priority'?:int,'pathEntries'?:string,'httpAuth':string,'error':int,'ttl'?:int,'attributes'?:string}> $res
 		 */
-		$feeds = self::daoToFeed($res);
+		$feeds = self::daoToFeeds($res);
 
-		usort($feeds, static function (FreshRSS_Feed $a, FreshRSS_Feed $b) {
+		uasort($feeds, static function (FreshRSS_Feed $a, FreshRSS_Feed $b) {
 			return strnatcasecmp($a->name(), $b->name());
 		});
 
@@ -585,7 +585,7 @@ SQL;
 	 * 	'pathEntries'?:string,'httpAuth'?:string,'error'?:int|bool,'ttl'?:int,'attributes'?:string,'cache_nbUnreads'?:int,'cache_nbEntries'?:int}> $listDAO
 	 * @return array<int,FreshRSS_Feed>
 	 */
-	public static function daoToFeed(array $listDAO, ?int $catID = null): array {
+	public static function daoToFeeds(array $listDAO, ?int $catID = null): array {
 		$list = [];
 
 		foreach ($listDAO as $key => $dao) {

+ 7 - 7
app/Models/TagDAO.php

@@ -184,16 +184,16 @@ SQL;
 	public function searchById(int $id): ?FreshRSS_Tag {
 		$res = $this->fetchAssoc('SELECT * FROM `_tag` WHERE id=:id', [':id' => $id]);
 		/** @var array<array{'id':int,'name':string,'attributes'?:string}>|null $res */
-		return $res === null ? null : self::daoToTag($res)[0] ?? null;
+		return $res === null ? null : (current(self::daoToTags($res)) ?: null);
 	}
 
 	public function searchByName(string $name): ?FreshRSS_Tag {
 		$res = $this->fetchAssoc('SELECT * FROM `_tag` WHERE name=:name', [':name' => $name]);
 		/** @var array<array{'id':int,'name':string,'attributes'?:string}>|null $res */
-		return $res === null ? null : self::daoToTag($res)[0] ?? null;
+		return $res === null ? null : (current(self::daoToTags($res)) ?: null);
 	}
 
-	/** @return array<FreshRSS_Tag>|false */
+	/** @return array<int,FreshRSS_Tag>|false */
 	public function listTags(bool $precounts = false) {
 		if ($precounts) {
 			$sql = <<<'SQL'
@@ -211,7 +211,7 @@ SQL;
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
 			$res = $stm->fetchAll(PDO::FETCH_ASSOC) ?: [];
-			return self::daoToTag($res);
+			return self::daoToTags($res);
 		} else {
 			$info = $this->pdo->errorInfo();
 			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
@@ -430,9 +430,9 @@ SQL;
 
 	/**
 	 * @param iterable<array{'id':int,'name':string,'attributes'?:string}> $listDAO
-	 * @return array<FreshRSS_Tag>
+	 * @return array<int,FreshRSS_Tag>
 	 */
-	private static function daoToTag(iterable $listDAO): array {
+	private static function daoToTags(iterable $listDAO): array {
 		$list = [];
 		foreach ($listDAO as $dao) {
 			if (empty($dao['id']) || empty($dao['name'])) {
@@ -446,7 +446,7 @@ SQL;
 			if (isset($dao['unreads'])) {
 				$tag->_nbUnread($dao['unreads']);
 			}
-			$list[] = $tag;
+			$list[$tag->id()] = $tag;
 		}
 		return $list;
 	}

+ 15 - 1
app/Models/UserConfiguration.php

@@ -41,7 +41,7 @@ declare(strict_types=1);
  * @property bool $onread_jump_next
  * @property string $passwordHash
  * @property int $posts_per_page
- * @property array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}> $queries
+ * @property array<array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string}> $queries
  * @property bool $reading_confirm
  * @property int $since_hours_posts_per_rss
  * @property bool $show_fav_unread
@@ -81,6 +81,20 @@ final class FreshRSS_UserConfiguration extends Minz_Configuration {
 		return parent::get('user');
 	}
 
+	/**
+	 * Access the default configuration for users.
+	 * @throws Minz_FileNotExistException
+	 */
+	public static function default(): FreshRSS_UserConfiguration {
+		static $default_user_conf = null;
+		if ($default_user_conf == null) {
+			$namespace = 'user_default';
+			FreshRSS_UserConfiguration::register($namespace, '_', FRESHRSS_PATH . '/config-user.default.php');
+			$default_user_conf = FreshRSS_UserConfiguration::get($namespace);
+		}
+		return $default_user_conf;
+	}
+
 	/**
 	 * @param non-empty-string $key
 	 * @return array<int|string,mixed>|null

+ 127 - 83
app/Models/UserQuery.php

@@ -18,17 +18,34 @@ class FreshRSS_UserQuery {
 	private FreshRSS_BooleanSearch $search;
 	private int $state = 0;
 	private string $url = '';
-	private ?FreshRSS_FeedDAO $feed_dao;
-	private ?FreshRSS_CategoryDAO $category_dao;
-	private ?FreshRSS_TagDAO $tag_dao;
+	private string $token = '';
+	private bool $shareRss = false;
+	private bool $shareOpml = false;
+	/** @var array<int,FreshRSS_Category> $categories */
+	private array $categories;
+	/** @var array<int,FreshRSS_Tag> $labels */
+	private array $labels;
+
+	public static function generateToken(string $salt): string {
+		if (!FreshRSS_Context::hasSystemConf()) {
+			return '';
+		}
+		$hash = md5(FreshRSS_Context::systemConf()->salt . $salt . random_bytes(16));
+		if (function_exists('gmp_init')) {
+			// Shorten the hash if possible by converting from base 16 to base 62
+			$hash = gmp_strval(gmp_init($hash, 16), 62);
+		}
+		return $hash;
+	}
 
 	/**
-	 * @param array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string} $query
+	 * @param array{get?:string,name?:string,order?:string,search?:string,state?:int,url?:string,token?:string,shareRss?:bool,shareOpml?:bool} $query
+	 * @param array<int,FreshRSS_Category> $categories
+	 * @param array<int,FreshRSS_Tag> $labels
 	 */
-	public function __construct(array $query, FreshRSS_FeedDAO $feed_dao = null, FreshRSS_CategoryDAO $category_dao = null, FreshRSS_TagDAO $tag_dao = null) {
-		$this->category_dao = $category_dao;
-		$this->feed_dao = $feed_dao;
-		$this->tag_dao = $tag_dao;
+	public function __construct(array $query, array $categories, array $labels) {
+		$this->categories = $categories;
+		$this->labels = $labels;
 		if (isset($query['get'])) {
 			$this->parseGet($query['get']);
 		}
@@ -49,8 +66,18 @@ class FreshRSS_UserQuery {
 		if (!isset($query['search'])) {
 			$query['search'] = '';
 		}
+		if (!empty($query['token'])) {
+			$this->token = $query['token'];
+		}
+		if (isset($query['shareRss'])) {
+			$this->shareRss = $query['shareRss'];
+		}
+		if (isset($query['shareOpml'])) {
+			$this->shareOpml = $query['shareOpml'];
+		}
+
 		// linked too deeply with the search object, need to use dependency injection
-		$this->search = new FreshRSS_BooleanSearch($query['search']);
+		$this->search = new FreshRSS_BooleanSearch($query['search'], 0, 'AND', false);
 		if (!empty($query['state'])) {
 			$this->state = intval($query['state']);
 		}
@@ -59,16 +86,19 @@ class FreshRSS_UserQuery {
 	/**
 	 * Convert the current object to an array.
 	 *
-	 * @return array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string}
+	 * @return array{'get'?:string,'name'?:string,'order'?:string,'search'?:string,'state'?:int,'url'?:string,'token'?:string}
 	 */
 	public function toArray(): array {
 		return array_filter([
 			'get' => $this->get,
 			'name' => $this->name,
 			'order' => $this->order,
-			'search' => $this->search->__toString(),
+			'search' => $this->search->getRawInput(),
 			'state' => $this->state,
 			'url' => $this->url,
+			'token' => $this->token,
+			'shareRss' => $this->shareRss,
+			'shareOpml' => $this->shareOpml,
 		]);
 	}
 
@@ -77,92 +107,43 @@ class FreshRSS_UserQuery {
 	 */
 	private function parseGet(string $get): void {
 		$this->get = $get;
-		if (preg_match('/(?P<type>[acfst])(_(?P<id>\d+))?/', $get, $matches)) {
+		if (preg_match('/(?P<type>[acfistT])(_(?P<id>\d+))?/', $get, $matches)) {
 			$id = intval($matches['id'] ?? '0');
 			switch ($matches['type']) {
 				case 'a':
-					$this->parseAll();
+					$this->get_type = 'all';
 					break;
 				case 'c':
-					$this->parseCategory($id);
+					$this->get_type = 'category';
+					$c = $this->categories[$id] ?? null;
+					$this->get_name = $c === null ? '' : $c->name();
 					break;
 				case 'f':
-					$this->parseFeed($id);
+					$this->get_type = 'feed';
+					$f = FreshRSS_Category::findFeed($this->categories, $id);
+					$this->get_name = $f === null ? '' : $f->name();
+					break;
+				case 'i':
+					$this->get_type = 'important';
 					break;
 				case 's':
-					$this->parseFavorite();
+					$this->get_type = 'favorite';
 					break;
 				case 't':
-					$this->parseTag($id);
+					$this->get_type = 'label';
+					$l = $this->labels[$id] ?? null;
+					$this->get_name = $l === null ? '' : $l->name();
+					break;
+				case 'T':
+					$this->get_type = 'all_labels';
 					break;
 			}
+			if ($this->get_name === '' && in_array($matches['type'], ['c', 'f', 't'], true)) {
+				$this->deprecated = true;
+			}
 		}
 	}
 
-	/**
-	 * Parse the query string when it is an "all" query
-	 */
-	private function parseAll(): void {
-		$this->get_name = 'all';
-		$this->get_type = 'all';
-	}
-
-	/**
-	 * Parse the query string when it is a "category" query
-	 */
-	private function parseCategory(int $id): void {
-		if ($this->category_dao === null) {
-			$this->category_dao = FreshRSS_Factory::createCategoryDao();
-		}
-		$category = $this->category_dao->searchById($id);
-		if ($category !== null) {
-			$this->get_name = $category->name();
-		} else {
-			$this->deprecated = true;
-		}
-		$this->get_type = 'category';
-	}
-
-	/**
-	 * Parse the query string when it is a "feed" query
-	 */
-	private function parseFeed(int $id): void {
-		if ($this->feed_dao === null) {
-			$this->feed_dao = FreshRSS_Factory::createFeedDao();
-		}
-		$feed = $this->feed_dao->searchById($id);
-		if ($feed !== null) {
-			$this->get_name = $feed->name();
-		} else {
-			$this->deprecated = true;
-		}
-		$this->get_type = 'feed';
-	}
-
-	/**
-	 * Parse the query string when it is a "tag" query
-	 */
-	private function parseTag(int $id): void {
-		if ($this->tag_dao === null) {
-			$this->tag_dao = FreshRSS_Factory::createTagDao();
-		}
-		$tag = $this->tag_dao->searchById($id);
-		if ($tag !== null) {
-			$this->get_name = $tag->name();
-		} else {
-			$this->deprecated = true;
-		}
-		$this->get_type = 'tag';
-	}
-
-	/**
-	 * Parse the query string when it is a "favorite" query
-	 */
-	private function parseFavorite(): void {
-		$this->get_name = 'favorite';
-		$this->get_type = 'favorite';
-	}
-
 	/**
 	 * Check if the current user query is deprecated.
 	 * It is deprecated if the category or the feed used in the query are
@@ -219,7 +200,7 @@ class FreshRSS_UserQuery {
 	}
 
 	public function getOrder(): string {
-		return $this->order;
+		return $this->order ?: FreshRSS_Context::userConf()->sort_order;
 	}
 
 	public function getSearch(): FreshRSS_BooleanSearch {
@@ -227,11 +208,74 @@ class FreshRSS_UserQuery {
 	}
 
 	public function getState(): int {
-		return $this->state;
+		$state = $this->state;
+		if (!($state & FreshRSS_Entry::STATE_READ) && !($state & FreshRSS_Entry::STATE_NOT_READ)) {
+			$state |= FreshRSS_Entry::STATE_READ | FreshRSS_Entry::STATE_NOT_READ;
+		}
+		if (!($state & FreshRSS_Entry::STATE_FAVORITE) && !($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
+			$state |= FreshRSS_Entry::STATE_FAVORITE | FreshRSS_Entry::STATE_NOT_FAVORITE;
+		}
+		return $state;
 	}
 
 	public function getUrl(): string {
 		return $this->url;
 	}
 
+	public function getToken(): string {
+		return $this->token;
+	}
+
+	public function setToken(string $token): void {
+		$this->token = $token;
+	}
+
+	public function setShareRss(bool $shareRss): void {
+		$this->shareRss = $shareRss;
+	}
+
+	public function shareRss(): bool {
+		return $this->shareRss;
+	}
+
+	public function setShareOpml(bool $shareOpml): void {
+		$this->shareOpml = $shareOpml;
+	}
+
+	public function shareOpml(): bool {
+		return $this->shareOpml;
+	}
+
+	protected function sharedUrl(bool $xmlEscaped = true): string {
+		$currentUser = Minz_User::name() ?? '';
+		return Minz_Url::display("/api/query.php?user={$currentUser}&t={$this->token}", $xmlEscaped ? 'html' : '', true);
+	}
+
+	public function sharedUrlRss(bool $xmlEscaped = true): string {
+		if ($this->shareRss && $this->token !== '') {
+			return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=rss';
+		}
+		return '';
+	}
+
+	public function sharedUrlHtml(bool $xmlEscaped = true): string {
+		if ($this->shareRss && $this->token !== '') {
+			return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=html';
+		}
+		return '';
+	}
+
+	/**
+	 * OPML is only safe for some query types, otherwise it risks leaking unwanted feed information.
+	 */
+	public function safeForOpml(): bool {
+		return in_array($this->get_type, ['all', 'category', 'feed'], true);
+	}
+
+	public function sharedUrlOpml(bool $xmlEscaped = true): string {
+		if ($this->shareOpml && $this->token !== '' && $this->safeForOpml()) {
+			return $this->sharedUrl($xmlEscaped) . ($xmlEscaped ? '&amp;' : '&') . 'f=opml';
+		}
+		return '';
+	}
 }

+ 6 - 4
app/Models/View.php

@@ -10,7 +10,7 @@ class FreshRSS_View extends Minz_View {
 	public $callbackBeforeFeeds;
 	/** @var callable */
 	public $callbackBeforePagination;
-	/** @var array<FreshRSS_Category> */
+	/** @var array<int,FreshRSS_Category> */
 	public array $categories;
 	public ?FreshRSS_Category $category;
 	public ?FreshRSS_Tag $tag;
@@ -18,11 +18,11 @@ class FreshRSS_View extends Minz_View {
 	/** @var iterable<FreshRSS_Entry> */
 	public $entries;
 	public FreshRSS_Entry $entry;
-	public ?FreshRSS_Feed $feed;
-	/** @var array<FreshRSS_Feed> */
+	public FreshRSS_Feed $feed;
+	/** @var array<int,FreshRSS_Feed> */
 	public array $feeds;
 	public int $nbUnreadTags;
-	/** @var array<FreshRSS_Tag> */
+	/** @var array<int,FreshRSS_Tag> */
 	public array $tags;
 	/** @var array<int,array{'id':int,'name':string,'id_entry':string,'checked':bool}> */
 	public array $tagsForEntry;
@@ -100,6 +100,8 @@ class FreshRSS_View extends Minz_View {
 	public int $nbPage;
 
 	// RSS view
+	public FreshRSS_UserQuery $userQuery;
+	public string $html_url = '';
 	public string $rss_title = '';
 	public string $rss_url = '';
 	public string $rss_base = '';

+ 3 - 3
app/Models/ViewJavascript.php

@@ -3,11 +3,11 @@ declare(strict_types=1);
 
 final class FreshRSS_ViewJavascript extends FreshRSS_View {
 
-	/** @var array<FreshRSS_Category> */
+	/** @var array<int,FreshRSS_Category> */
 	public array $categories;
-	/** @var array<FreshRSS_Feed> */
+	/** @var array<int,FreshRSS_Feed> */
 	public array $feeds;
-	/** @var array<FreshRSS_Tag> */
+	/** @var array<int,FreshRSS_Tag> */
 	public array $tags;
 
 	public string $nonce;

+ 3 - 3
app/Models/ViewStats.php

@@ -3,10 +3,10 @@ declare(strict_types=1);
 
 final class FreshRSS_ViewStats extends FreshRSS_View {
 
-	/** @var array<FreshRSS_Category> */
+	/** @var array<int,FreshRSS_Category> */
 	public array $categories;
-	public ?FreshRSS_Feed $feed;
-	/** @var array<FreshRSS_Feed> */
+	public FreshRSS_Feed $feed;
+	/** @var array<int,FreshRSS_Feed> */
 	public array $feeds;
 	public bool $displaySlider = false;
 

+ 1 - 1
app/Services/ExportService.php

@@ -95,7 +95,7 @@ class FreshRSS_Export_Service {
 		$view = new FreshRSS_View();
 		$view->categories = $this->category_dao->listCategories(true) ?: [];
 
-		$feed = FreshRSS_CategoryDAO::findFeed($view->categories, $feed_id);
+		$feed = FreshRSS_Category::findFeed($view->categories, $feed_id);
 		if ($feed === null) {
 			return null;
 		}

+ 2 - 1
app/Utils/dotpathUtil.php

@@ -107,7 +107,8 @@ final class FreshRSS_dotpath_Util
 		$view = new FreshRSS_View();
 		$view->_path('index/rss.phtml');
 		$view->internal_rendering = true;
-		$view->rss_url = $feedSourceUrl;
+		$view->rss_url = htmlspecialchars($feedSourceUrl, ENT_COMPAT, 'UTF-8');
+		$view->html_url = $view->rss_url;
 		$view->entries = [];
 
 		$view->rss_title = isset($dotPaths['feedTitle'])

+ 2 - 2
app/i18n/cz/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (pro pokročilé uživatele s HTTPS)',
 		'none' => 'Žádný (nebezpečné)',
 		'title' => 'Ověřování',
-		'token' => 'Ověřovací token',
-		'token_help' => 'Umožňuje přístup k výstupu RSS výchozího uživatele bez ověřování:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Metoda ověřování',
 		'unsafe_autologin' => 'Povolit nebezpečné automatické přihlášení pomocí formátu: ',
 	),

+ 13 - 0
app/i18n/cz/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Zobrazit podle kanálu',
 			'order' => 'Seřadit podle data',
 			'search' => 'Výraz',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Stav',
 			'tags' => 'Zobrazit podle štítku',
 			'type' => 'Typ',
 		),
 		'get_all' => 'Zobrazit všechny články',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Zobrazit kategorii „%s“',
 		'get_favorite' => 'Zobrazit oblíbené články',
 		'get_feed' => 'Zobrazit kanál „%s“',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Název',
 		'no_filter' => 'Žádný filtr',
 		'number' => 'Dotaz č. %d',
 		'order_asc' => 'Zobrazit nejdříve nejstarší články',
 		'order_desc' => 'Zobrazit nejdříve nejnovější články',
 		'search' => 'Hledat „%s“',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Zobrazit všechny články',
 		'state_1' => 'Zobrazit přečtené články',
 		'state_2' => 'Zobrazit nepřečtené články',

+ 2 - 2
app/i18n/de/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (HTTPS für erfahrene Benutzer)',
 		'none' => 'Keine (gefährlich)',
 		'title' => 'Authentifizierung',
-		'token' => 'Authentifizierungs-Token',
-		'token_help' => 'Erlaubt den Zugriff auf die RSS-Ausgabe des Standardbenutzers ohne Authentifizierung.',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Authentifizierungsmethode',
 		'unsafe_autologin' => 'Erlaube unsicheres automatisches Anmelden mit folgendem Format: ',
 	),

+ 13 - 0
app/i18n/de/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Nach Feed filtern',
 			'order' => 'Nach Datum sortieren',
 			'search' => 'Suchbegriff',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Eigenschaft',
 			'tags' => 'Nach Labels filtern',
 			'type' => 'Filter-Typ',
 		),
 		'get_all' => 'Alle Artikel anzeigen',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Kategorie „%s“ anzeigen',
 		'get_favorite' => 'Lieblingsartikel anzeigen',
 		'get_feed' => 'Feed „%s“ anzeigen',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Name',	// IGNORE
 		'no_filter' => 'Kein Filter',
 		'number' => 'Abfrage Nr. %d',
 		'order_asc' => 'Älteste Artikel zuerst anzeigen',
 		'order_desc' => 'Neueste Artikel zuerst anzeigen',
 		'search' => 'Suche nach „%s“',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Alle Artikel anzeigen',
 		'state_1' => 'Gelesene Artikel anzeigen',
 		'state_2' => 'Ungelesene Artikel anzeigen',

+ 2 - 2
app/i18n/el/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (για έμπειρους χρήστες με )',
 		'none' => 'Καμία (ριψοκίνδυνο)',
 		'title' => 'Πιστοποίηση',
-		'token' => 'Διακριτικό Πιστοποίησης (token)',
-		'token_help' => 'Επιτρέπει την πρόσβαση στα RSS αποτελέσματα του προεπιλεγμένου χρήστη χωρίς έλεγχο ταυτότητας:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Μέθοδος Πιστοποίησης',
 		'unsafe_autologin' => 'Επιτρέψτε την μη ασφαλή αυτόματη σύνδεση με την χρήση της μορφής: ',
 	),

+ 13 - 0
app/i18n/el/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Display by feed',	// TODO
 			'order' => 'Sort by date',	// TODO
 			'search' => 'Expression',	// TODO
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'State',	// TODO
 			'tags' => 'Display by label',	// TODO
 			'type' => 'Type',	// TODO
 		),
 		'get_all' => 'Display all articles',	// TODO
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Display “%s” category',	// TODO
 		'get_favorite' => 'Display favourite articles',	// TODO
 		'get_feed' => 'Display “%s” feed',	// TODO
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Name',	// TODO
 		'no_filter' => 'No filter',	// TODO
 		'number' => 'Query n°%d',	// TODO
 		'order_asc' => 'Display oldest articles first',	// TODO
 		'order_desc' => 'Display newest articles first',	// TODO
 		'search' => 'Search for “%s”',	// TODO
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Display all articles',	// TODO
 		'state_1' => 'Display read articles',	// TODO
 		'state_2' => 'Display unread articles',	// TODO

+ 2 - 2
app/i18n/en-us/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (for advanced users with HTTPS)',	// IGNORE
 		'none' => 'None (dangerous)',	// IGNORE
 		'title' => 'Authentication',	// IGNORE
-		'token' => 'Authentication token',	// IGNORE
-		'token_help' => 'Allows access to RSS output of the default user without authentication:',	// IGNORE
+		'token' => 'Master authentication token',	// IGNORE
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// IGNORE
 		'type' => 'Authentication method',	// IGNORE
 		'unsafe_autologin' => 'Allow unsafe automatic login using the format: ',	// IGNORE
 	),

+ 13 - 0
app/i18n/en-us/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Display by feed',	// IGNORE
 			'order' => 'Sort by date',	// IGNORE
 			'search' => 'Expression',	// IGNORE
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// IGNORE
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// IGNORE
 			'state' => 'State',	// IGNORE
 			'tags' => 'Display by label',	// IGNORE
 			'type' => 'Type',	// IGNORE
 		),
 		'get_all' => 'Display all articles',	// IGNORE
+		'get_all_labels' => 'Display articles with any label',	// IGNORE
 		'get_category' => 'Display “%s” category',	// IGNORE
 		'get_favorite' => 'Display favorite articles',
 		'get_feed' => 'Display “%s” feed',	// IGNORE
+		'get_important' => 'Display articles from important feeds',	// IGNORE
+		'get_label' => 'Display articles with “%s” label',	// IGNORE
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// IGNORE
 		'name' => 'Name',	// IGNORE
 		'no_filter' => 'No filter',	// IGNORE
 		'number' => 'Query n°%d',	// IGNORE
 		'order_asc' => 'Display oldest articles first',	// IGNORE
 		'order_desc' => 'Display newest articles first',	// IGNORE
 		'search' => 'Search for “%s”',	// IGNORE
+		'share' => array(
+			'_' => 'Share this query by link',	// IGNORE
+			'help' => 'Give this link if you want to share this query with anyone',	// IGNORE
+			'html' => 'Shareable link to the HTML page',	// IGNORE
+			'opml' => 'Shareable link to the OPML list of feeds',	// IGNORE
+			'rss' => 'Shareable link to the RSS feed',	// IGNORE
+		),
 		'state_0' => 'Display all articles',	// IGNORE
 		'state_1' => 'Display read articles',	// IGNORE
 		'state_2' => 'Display unread articles',	// IGNORE

+ 2 - 2
app/i18n/en/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (for advanced users with HTTPS)',
 		'none' => 'None (dangerous)',
 		'title' => 'Authentication',
-		'token' => 'Authentication token',
-		'token_help' => 'Allows access to RSS output of the default user without authentication:',
+		'token' => 'Master authentication token',
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',
 		'type' => 'Authentication method',
 		'unsafe_autologin' => 'Allow unsafe automatic login using the format: ',
 	),

+ 13 - 0
app/i18n/en/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Display by feed',
 			'order' => 'Sort by date',
 			'search' => 'Expression',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',
 			'state' => 'State',
 			'tags' => 'Display by label',
 			'type' => 'Type',
 		),
 		'get_all' => 'Display all articles',
+		'get_all_labels' => 'Display articles with any label',
 		'get_category' => 'Display “%s” category',
 		'get_favorite' => 'Display favourite articles',
 		'get_feed' => 'Display “%s” feed',
+		'get_important' => 'Display articles from important feeds',
+		'get_label' => 'Display articles with “%s” label',
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',
 		'name' => 'Name',
 		'no_filter' => 'No filter',
 		'number' => 'Query n°%d',
 		'order_asc' => 'Display oldest articles first',
 		'order_desc' => 'Display newest articles first',
 		'search' => 'Search for “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',
+			'help' => 'Give this link if you want to share this query with anyone',
+			'html' => 'Shareable link to the HTML page',
+			'opml' => 'Shareable link to the OPML list of feeds',
+			'rss' => 'Shareable link to the RSS feed',
+		),
 		'state_0' => 'Display all articles',
 		'state_1' => 'Display read articles',
 		'state_2' => 'Display unread articles',

+ 2 - 2
app/i18n/es/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (para usuarios avanzados con HTTPS)',
 		'none' => 'Ninguno (peligroso)',
 		'title' => 'Identificación',
-		'token' => 'Clave de identificación',
-		'token_help' => 'Permite el acceso a la salida RSS del usuario por defecto sin necesidad de identificación:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Método de identificación',
 		'unsafe_autologin' => 'Permite la identificación automática insegura usando el formato: ',
 	),

+ 13 - 0
app/i18n/es/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Mostrar por feed',
 			'order' => 'Ordenar por fecha',
 			'search' => 'Expresión',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Estado',
 			'tags' => 'Mostrar por etiqueta',
 			'type' => 'Tipo',
 		),
 		'get_all' => 'Mostrar todos los artículos',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Mostrar la categoría “%s”',
 		'get_favorite' => 'Mostrar artículos favoritos',
 		'get_feed' => 'Mostrar fuente “%s”',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Nombre',
 		'no_filter' => 'Sin filtro',
 		'number' => 'Consulta n° %d',
 		'order_asc' => 'Mostrar primero los artículos más antiguos',
 		'order_desc' => 'Mostrar primero los artículos más recientes',
 		'search' => 'Buscar “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Mostrar todos los artículos',
 		'state_1' => 'Mostrar artículos leídos',
 		'state_2' => 'Mostrar artículos pendientes',

+ 2 - 2
app/i18n/fa/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => ' HTTP (برای کاربران پیشرفته با HTTPS)',
 		'none' => ' هیچ (خطرناک)',
 		'title' => ' احراز هویت',
-		'token' => ' نشانه احراز هویت',
-		'token_help' => ' امکان دسترسی به خروجی RSS کاربر پیش فرض بدون احراز هویت را می دهد:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => ' روش احراز هویت',
 		'unsafe_autologin' => ' اجازه ورود خودکار ناامن را با استفاده از قالب:',
 	),

+ 13 - 0
app/i18n/fa/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => ' نمایش با فید',
 			'order' => ' مرتب سازی بر اساس تاریخ',
 			'search' => ' بیان',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => ' ایالت',
 			'tags' => ' نمایش بر اساس برچسب',
 			'type' => ' نوع',
 		),
 		'get_all' => ' نمایش همه مقالات',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => ' دسته «%s» را نمایش دهید',
 		'get_favorite' => ' نمایش مقالات مورد علاقه',
 		'get_feed' => ' فید "%s" را نمایش دهید',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => ' نام',
 		'no_filter' => ' بدون فیلتر',
 		'number' => ' پرس و جو n°%d',
 		'order_asc' => ' ابتدا قدیمی ترین مقالات را نمایش دهید',
 		'order_desc' => ' ابتدا جدیدترین مقالات را نمایش دهید',
 		'search' => ' «%s» را جستجو کنید',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'نمایش همه مقالات',
 		'state_1' => 'نمایش مقالات خوانده شده',
 		'state_2' => 'نمایش مقالات خوانده نشده',

+ 2 - 2
app/i18n/fr/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (pour utilisateurs avancés avec HTTPS)',
 		'none' => 'Aucune (dangereux)',
 		'title' => 'Authentification',
-		'token' => 'Jeton d’identification',
-		'token_help' => 'Permet d’accéder à la sortie RSS de l’utilisateur par défaut sans besoin de s’authentifier :',
+		'token' => 'Jeton d’identification maître',
+		'token_help' => 'Permet d’accéder à toutes les sorties RSS de l’utilisateur et au rafraîchissement des flux sans besoin de s’authentifier :',
 		'type' => 'Méthode d’authentification',
 		'unsafe_autologin' => 'Autoriser les connexions automatiques non-sûres au format : ',
 	),

+ 13 - 0
app/i18n/fr/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Afficher par flux',
 			'order' => 'Tri par date',
 			'search' => 'Expression',	// IGNORE
+			'shareOpml' => 'Active le partage par OPML des catégories et flux correspondants',
+			'shareRss' => 'Active le partage par HTML &amp; RSS',
 			'state' => 'État',
 			'tags' => 'Afficher par étiquette',
 			'type' => 'Type',	// IGNORE
 		),
 		'get_all' => 'Afficher tous les articles',
+		'get_all_labels' => 'Afficher les articles avec une étiquette',
 		'get_category' => 'Afficher la catégorie <em>%s<em>',
 		'get_favorite' => 'Afficher les articles favoris',
 		'get_feed' => 'Afficher le flux <em>%s</em>',
+		'get_important' => 'Afficher les articles des flux importants',
+		'get_label' => 'Afficher les articles avec l’étiquette “%s”',
+		'help' => 'Voir la <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation pour les filtres utilisateurs et repartage par HTML / RSS / OPML</a>.',
 		'name' => 'Nom',
 		'no_filter' => 'Aucun filtre appliqué',
 		'number' => 'Filtre n°%d',
 		'order_asc' => 'Afficher les articles les plus anciens en premier',
 		'order_desc' => 'Afficher les articles les plus récents en premier',
 		'search' => 'Recherche de « %s »',
+		'share' => array(
+			'_' => 'Partager ce filtre par lien',
+			'help' => 'Donner ce lien pour partager le contenu du filtre avec d’autres personnes',
+			'html' => 'Lien partageable de la page HTML',
+			'opml' => 'Lien partageable de la liste des flux au format OPML',
+			'rss' => 'Lien partageable du flux RSS',
+		),
 		'state_0' => 'Afficher tous les articles',
 		'state_1' => 'Afficher les articles lus',
 		'state_2' => 'Afficher les articles non lus',

+ 2 - 2
app/i18n/he/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (למשתמשים מתקדמים עם HTTPS)',
 		'none' => 'ללא (מסוכן)',
 		'title' => 'Authentication',	// TODO
-		'token' => 'מחרוזת אימות',
-		'token_help' => 'Allows to access RSS output of the default user without authentication:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'שיטת אימות',
 		'unsafe_autologin' => 'הרשאה להתחברות אוטומטית בפורמט: ',
 	),

+ 13 - 0
app/i18n/he/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Display by feed',	// TODO
 			'order' => 'Sort by date',	// TODO
 			'search' => 'Expression',	// TODO
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'State',	// TODO
 			'tags' => 'Display by label',	// TODO
 			'type' => 'Type',	// TODO
 		),
 		'get_all' => 'הצגת כל המאמרים',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'הצגת קטגוריה “%s”',
 		'get_favorite' => 'הצגת מאמרים מועדפים',
 		'get_feed' => 'הצגת הזנה %s',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Name',	// TODO
 		'no_filter' => 'ללא סינון',
 		'number' => 'שאילתה מספר °%d',
 		'order_asc' => 'הצגת מאמרים ישנים בראש',
 		'order_desc' => 'הצגת מאמרים חדשים בראש',
 		'search' => 'חיפוש “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'הצגת כל המאמרים',
 		'state_1' => 'הצגת מאמרים שנקראו',
 		'state_2' => 'הצגת מאמרים שלא נקראו',

+ 2 - 2
app/i18n/hu/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (haladó felhasználóknak HTTPS-el)',
 		'none' => 'nincs (veszélyes)',
 		'title' => 'Hitelesítés',
-		'token' => 'Hitelesítő token',
-		'token_help' => 'Engedélyezi az alapértelmezett felhasználó RSS-ének olvasását hitelesítés nélkül:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Hitelesítési módszer',
 		'unsafe_autologin' => 'Engedélyezze a nem biztonságos automata bejelentkezést a következő formátummal: ',
 	),

+ 13 - 0
app/i18n/hu/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Rendezés hírforrás szerint',
 			'order' => 'Rendezés dátum szerint',
 			'search' => 'Kifejezés',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Státusz',
 			'tags' => 'Rendezés címke szerint',
 			'type' => 'Típus',
 		),
 		'get_all' => 'Minden cikk megjelenítése',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Listáz “%s” kategóriát',
 		'get_favorite' => 'Kedvenc cikkek megjelenítése',
 		'get_feed' => 'Listáz “%s” hírforrást',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Név',
 		'no_filter' => 'Nincs szűrés',
 		'number' => 'Lekérdezés %d',
 		'order_asc' => 'Régebbi cikkek előre',
 		'order_desc' => 'Újabb cikkek előre',
 		'search' => 'Keresse a “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Minden cikk',
 		'state_1' => 'Olvasott cikkek',
 		'state_2' => 'Olvasatlan cikkek',

+ 2 - 2
app/i18n/id/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (untuk pengguna tingkat lanjut HTTPS)',
 		'none' => 'None (dangerous)',	// TODO
 		'title' => 'Authentication',	// TODO
-		'token' => 'Authentication token',	// TODO
-		'token_help' => 'Memungkinkan akses ke output RSS dari pengguna default tanpa otentikasi:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Authentication method',	// TODO
 		'unsafe_autologin' => 'Izinkan login otomatis yang tidak aman menggunakan format: ',
 	),

+ 14 - 1
app/i18n/id/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Display by feed',	// TODO
 			'order' => 'Sort by date',	// TODO
 			'search' => 'Expression',	// TODO
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'State',	// TODO
 			'tags' => 'Display by label',	// TODO
 			'type' => 'Type',	// TODO
 		),
 		'get_all' => 'Display all articles',	// TODO
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Display “%s” category',	// TODO
-		'get_favorite' => 'Display favorite articles',
+		'get_favorite' => 'Display favourite articles',	// TODO
 		'get_feed' => 'Display “%s” feed',	// TODO
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Name',	// TODO
 		'no_filter' => 'No filter',	// TODO
 		'number' => 'Query n°%d',	// TODO
 		'order_asc' => 'Display oldest articles first',	// TODO
 		'order_desc' => 'Display newest articles first',	// TODO
 		'search' => 'Search for “%s”',	// TODO
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Display all articles',	// TODO
 		'state_1' => 'Display read articles',	// TODO
 		'state_2' => 'Display unread articles',	// TODO

+ 2 - 2
app/i18n/it/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (per gli utenti avanzati con HTTPS)',
 		'none' => 'Nessuno (pericoloso)',
 		'title' => 'Autenticazione',
-		'token' => 'Token di autenticazione',
-		'token_help' => 'Consenti accesso agli RSS dell utente predefinito senza autenticazione:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Metodo di autenticazione',
 		'unsafe_autologin' => 'Consenti accesso automatico non sicuro usando il formato: ',
 	),

+ 13 - 0
app/i18n/it/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Mostra per feed',
 			'order' => 'Ordina per data',
 			'search' => 'Espressione',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Stato',
 			'tags' => 'Mostra per tag',	// DIRTY
 			'type' => 'Tipo',
 		),
 		'get_all' => 'Mostra tutti gli articoli',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Mostra la categoria “%s” ',
 		'get_favorite' => 'Mostra articoli preferiti',
 		'get_feed' => 'Mostra feed “%s” ',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Nome',
 		'no_filter' => 'Nessun filtro',
 		'number' => 'Ricerca n°%d',
 		'order_asc' => 'Mostra prima gli articoli più vecchi',
 		'order_desc' => 'Mostra prima gli articoli più nuovi',
 		'search' => 'Cerca per “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Mostra tutti gli articoli',
 		'state_1' => 'Mostra gli articoli letti',
 		'state_2' => 'Mostra gli articoli non letti',

+ 2 - 2
app/i18n/ja/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (上級者はHTTPSでも)',
 		'none' => 'なし (危険)',
 		'title' => '認証',
-		'token' => '認証トークン',
-		'token_help' => 'ユーザーが承認無しで、RSSを出力できるようにします。:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => '認証メソッド',
 		'unsafe_autologin' => '危険な自動ログインを有効にします',
 	),

+ 13 - 0
app/i18n/ja/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'フィードごとに表示する',
 			'order' => '日付ごとにソートする',
 			'search' => '式',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => '状態',
 			'tags' => 'タグごとに表示する',
 			'type' => 'タイプ',
 		),
 		'get_all' => 'すべての著者を表示する',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => '“%s”カテゴリを表示する',
 		'get_favorite' => 'お気に入りの著者を表示する',
 		'get_feed' => '“%s”フィードを表示する',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => '名前',
 		'no_filter' => 'フィルターはありません',
 		'number' => 'クエリ n°%d',
 		'order_asc' => '古い著者を最初に表示する',
 		'order_desc' => '新しい著者を最初に表示する',
 		'search' => '“%s”で検索する',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'すべての記事を表示する',
 		'state_1' => '既読の記事を表示する',
 		'state_2' => '未読の記事を表示する',

+ 2 - 2
app/i18n/ko/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (HTTPS를 사용하는 고급 사용자용)',
 		'none' => '사용하지 않음 (위험)',
 		'title' => '인증',
-		'token' => '인증 토큰',
-		'token_help' => '기본 사용자의 RSS에 인증 없이 접근할 수 있도록 합니다:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => '인증',
 		'unsafe_autologin' => '다음과 같은 안전하지 않은 방식의 로그인을 허가합니다: ',
 	),

+ 13 - 0
app/i18n/ko/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => '피드별로 표시',
 			'order' => '날짜순으로 정렬',
 			'search' => '정규 표현식',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => '상태',
 			'tags' => '태그별로 표시',
 			'type' => '유형',
 		),
 		'get_all' => '모든 글 표시',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => '“%s” 카테고리 표시',
 		'get_favorite' => '즐겨찾기에 등록된 글 표시',
 		'get_feed' => '“%s” 피드 표시',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => '이름',
 		'no_filter' => '필터가 없습니다',
 		'number' => '쿼리 #%d',
 		'order_asc' => '오래된 글 먼저 표시',
 		'order_desc' => '최근 글 먼저 표시',
 		'search' => '“%s”의 검색 결과',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => '모든 글 표시',
 		'state_1' => '읽은 글 표시',
 		'state_2' => '읽지 않은 글 표시',

+ 2 - 2
app/i18n/lv/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (pieredzējušiem lietotājiem ar HTTPS)',
 		'none' => 'Nav (bīstami)',
 		'title' => 'Autentifikācija',
-		'token' => 'Autentifikācijas žetons',
-		'token_help' => 'Ļauj piekļūt noklusējuma lietotāja RSS izvadei bez autentifikācijas:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Autentifikācijas metode',
 		'unsafe_autologin' => 'Atļaut nedrošu automātisku pieteikšanos, izmantojot formātu: ',
 	),

+ 13 - 0
app/i18n/lv/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Rādīt pēc barotnes',
 			'order' => 'Kārtot pēc datuma',
 			'search' => 'Izteiksme',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Stāvoklis',
 			'tags' => 'Rādīt pēc birkas',
 			'type' => 'Veids',
 		),
 		'get_all' => 'Rādīt visus rakstus',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Rādīt kategoriju “%s”',
 		'get_favorite' => 'Rādīt mīļākos rakstus',
 		'get_feed' => 'Rādīt barotni “%s”',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Vārds',
 		'no_filter' => 'Bez filtra',
 		'number' => 'Pieprasījums nr. %d',
 		'order_asc' => 'Vispirms rādīt vecākos rakstus',
 		'order_desc' => 'Vispirms rādīt jaunākos rakstus',
 		'search' => 'Meklēt “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Rādīt visus rakstus',
 		'state_1' => 'Rādīt lasītos rakstus',
 		'state_2' => 'Rādīt nelasītos rakstus',

+ 2 - 2
app/i18n/nl/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (voor gevorderde gebruikers met HTTPS)',
 		'none' => 'Geen (gevaarlijk)',
 		'title' => 'Authenticatie',
-		'token' => 'Authenticatie teken',
-		'token_help' => 'Sta toegang toe tot de RSS uitvoer van de standaard gebruiker zonder authenticatie:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Authenticatie methode',
 		'unsafe_autologin' => 'Sta onveilige automatische log in toe met het volgende formaat: ',
 	),

+ 13 - 0
app/i18n/nl/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Weergeven op feed',
 			'order' => 'Sorteren op datum',
 			'search' => 'Expressie',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Status',
 			'tags' => 'Weergeven op label',
 			'type' => 'Type',	// IGNORE
 		),
 		'get_all' => 'Toon alle artikelen',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Toon „%s” categorie',
 		'get_favorite' => 'Toon favoriete artikelen',
 		'get_feed' => 'Toon „%s” feed',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Naam',
 		'no_filter' => 'Geen filter',
 		'number' => 'Query n°%d',	// IGNORE
 		'order_asc' => 'Toon oudste artikelen eerst',
 		'order_desc' => 'Toon nieuwste artikelen eerst',
 		'search' => 'Zoek naar „%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Toon alle artikelen',
 		'state_1' => 'Toon gelezen artikelen',
 		'state_2' => 'Toon ongelezen artikelen',

+ 2 - 2
app/i18n/oc/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (per utilizaires avançats amb HTTPS)',
 		'none' => 'Cap (perilhós)',
 		'title' => 'Autentificacion',
-		'token' => 'Geton d’autentificacion',
-		'token_help' => 'Permetre l’accès a la sortida RSS de l’utilizaire per defaut sens cap d’autentificacion :',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Mòde d’autentification',
 		'unsafe_autologin' => 'Autorizar las connexions automaticas pas seguras al format : ',
 	),

+ 13 - 0
app/i18n/oc/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Afichatge per flux',
 			'order' => 'Triar per data',
 			'search' => 'Expression',	// IGNORE
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Estat',
 			'tags' => 'Afichatge per etiqueta',
 			'type' => 'Tipe',
 		),
 		'get_all' => 'Mostrar totes los articles',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Mostrar la categoria « %s »',
 		'get_favorite' => 'Mostrar los articles favorits',
 		'get_feed' => 'Mostrar lo flux « %s »',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Nom',
 		'no_filter' => 'Cap de filtre aplicat',
 		'number' => 'Filtre n°%d',
 		'order_asc' => 'Mostrar los articles mai ancians en primièr',
 		'order_desc' => 'Mostrar los articles mai recents en primièr',
 		'search' => 'Recèrca de « %s »',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Mostrar totes los articles',
 		'state_1' => 'Mostrar los articles pas legits',
 		'state_2' => 'Mostrar los articles pas legits',

+ 2 - 2
app/i18n/pl/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (dla zaawansowanych użytkowników, z wykorzystaniem HTTPS)',
 		'none' => 'Brak (niebezpieczna)',
 		'title' => 'Uwierzytelnianie',
-		'token' => 'Token uwierzytelniania',
-		'token_help' => 'Pozwala na dostęp do treści RSS domyślnego użytkownika bez uwierzytelnienia:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Metoda uwierzytelniania',
 		'unsafe_autologin' => 'Pozwól na niebezpieczne automatyczne logowanie następującym schematem:	-> todo',
 	),

+ 13 - 0
app/i18n/pl/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Według kanału',
 			'order' => 'Sortowanie wg daty',
 			'search' => 'Wyrażenie',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Stan',
 			'tags' => 'Według tagu',
 			'type' => 'Rodzaj',
 		),
 		'get_all' => 'Wyświetlenie wszystkich wiadomości',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Wyświetlenie kategorii “%s”',
 		'get_favorite' => 'Wyświetlenie ulubionych wiadomości',
 		'get_feed' => 'Wyświetlenie kanału “%s”',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Nazwa',
 		'no_filter' => 'Brak filtrów',
 		'number' => 'Zapytanie nr %d',
 		'order_asc' => 'Wyświetl najpierw najstarsze wiadomości',
 		'order_desc' => 'Wyświetl najpierw najnowsze wiadomości',
 		'search' => 'Szukaj “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Wyświetl wszystkie wiadomości',
 		'state_1' => 'Wyświetl przeczytane wiadomości',
 		'state_2' => 'Wyświetl nieprzeczytane wiadomości',

+ 2 - 2
app/i18n/pt-br/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (Para usuários avançados com HTTPS)',
 		'none' => 'Nenhum (Perigoso)',
 		'title' => 'Autenticação',
-		'token' => 'Token de autenticação ',
-		'token_help' => 'Permitir acesso a saída RSS para o usuário padrão sem autenticação',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Método de autenticação',
 		'unsafe_autologin' => 'Permitir login automática insegura usando o seguinte formato: ',
 	),

+ 13 - 0
app/i18n/pt-br/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Exibir por feed',
 			'order' => 'Ordenar por data',
 			'search' => 'Expressão',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Estado',
 			'tags' => 'Exibir por tag',	// DIRTY
 			'type' => 'Tipo',
 		),
 		'get_all' => 'Mostrar todos os artigos',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Visualizar “%s” categoria',
 		'get_favorite' => 'Visualizar artigos favoritos',
 		'get_feed' => 'Visualizar “%s” feed',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Nome',
 		'no_filter' => 'Sem filtro',
 		'number' => 'Query n°%d',	// IGNORE
 		'order_asc' => 'Exibir artigos mais antigos primeiro',
 		'order_desc' => 'Exibir artigos mais novos primeiro',
 		'search' => 'Busca por “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Exibir todos os artigos',
 		'state_1' => 'Exibir artigos lidos',
 		'state_2' => 'Exibir artigos não lidos',

+ 2 - 2
app/i18n/ru/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (для опытных пользователей с HTTPS)',
 		'none' => 'Без аутентификации (небезопасно)',
 		'title' => 'Аутентификации',
-		'token' => 'Токен аутентификации',
-		'token_help' => 'Разрешает доступ к RSS-лентам пользователя по умолчанию без аутентификации:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Способ аутентификации',
 		'unsafe_autologin' => 'Разрешить небезопасный автоматический вход с использованием следующего формата: ',
 	),

+ 13 - 0
app/i18n/ru/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Отображение по ленте',
 			'order' => 'Сортировать по дате',
 			'search' => 'Выражение',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Состояние',
 			'tags' => 'Отображение по метке',
 			'type' => 'Тип',
 		),
 		'get_all' => 'Показать все статьи',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Показать категорию “%s”',
 		'get_favorite' => 'Показать избранные статьи',
 		'get_feed' => 'Показать ленту “%s”',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Название',
 		'no_filter' => 'Нет фильтров',
 		'number' => 'Запрос №%d',
 		'order_asc' => 'Показывать сначала старые статьи',
 		'order_desc' => 'Показывать сначала новые статьи',
 		'search' => 'Искать “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Показать все статьи',
 		'state_1' => 'Показать прочитанные статьи',
 		'state_2' => 'Показать непрочитанные статьи',

+ 2 - 2
app/i18n/sk/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (pre pokročilých používateľov s HTTPS)',
 		'none' => 'Žiadny (nebezpečné)',
 		'title' => 'Prihlásenie',
-		'token' => 'Token prihlásenia',
-		'token_help' => 'Povoliť prístup k výstupu RSS prednastaveného používateľa bez prihlásenia:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Spôsob prihlásenia',
 		'unsafe_autologin' => 'Povoliť nebezpečné automatické prihlásenie pomocou webového formulára: ',
 	),

+ 13 - 0
app/i18n/sk/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Zobraziť podľa kanála',
 			'order' => 'Zobraziť podľa dátumu',
 			'search' => 'Výraz',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Štát',
 			'tags' => 'Zobraziť podľa štítku',
 			'type' => 'Typ',
 		),
 		'get_all' => 'Zobraziť všetky články',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => 'Zobraziť kategóriu “%s”',
 		'get_favorite' => 'Zobraziť obľúbené články',
 		'get_feed' => 'Zobraziť kanál “%s”',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'Meno',
 		'no_filter' => 'Žiadny filter',
 		'number' => 'Dopyt číslo %d',
 		'order_asc' => 'Zobraziť staršie články hore',
 		'order_desc' => 'Zobraziť novšie články hore',
 		'search' => 'Vyhľadáva sa: “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Zobraziť všetky články',
 		'state_1' => 'Zobraziť prečítané články',
 		'state_2' => 'Zobraziť neprečítané články',

+ 2 - 2
app/i18n/tr/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP (ileri kullanıcılar için, HTTPS)',
 		'none' => 'Hiçbiri (tehlikeli)',
 		'title' => 'Kimlik doğrulama',
-		'token' => 'Kimlik doğrulama işareti',
-		'token_help' => 'Kimlik doğrulama olmaksızın öntanımlı kullanıcının RSS çıktısına erişime izin ver:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => 'Kimlik doğrulama yöntemi',
 		'unsafe_autologin' => 'Güvensiz otomatik girişe izin ver: ',
 	),

+ 13 - 0
app/i18n/tr/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => 'Akışa göre göster',
 			'order' => 'Tarihe göre göster',
 			'search' => 'İfade',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => 'Durum',
 			'tags' => 'Etikete göre göster',
 			'type' => 'Tür',
 		),
 		'get_all' => 'Tüm makaleleri göster',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => '“%s” kategorisini göster',
 		'get_favorite' => 'Favori makaleleri göster',
 		'get_feed' => '“%s” akışını göster',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => 'İsim',
 		'no_filter' => 'Filtre yok',
 		'number' => 'Sorgu n°%d',
 		'order_asc' => 'Önce eski makaleleri göster',
 		'order_desc' => 'Önce yeni makaleleri göster',
 		'search' => '“%s” için arama',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => 'Tüm makaleleri göster',
 		'state_1' => 'Okunmuş makaleleri göster',
 		'state_2' => 'Okunmamış makaleleri göster',

+ 2 - 2
app/i18n/zh-cn/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP(面向启用 HTTPS 的高级用户)',
 		'none' => '无(危险)',
 		'title' => '认证',
-		'token' => '认证口令',
-		'token_help' => '用于不经认证访问默认用户的 RSS 输出:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => '认证方式',
 		'unsafe_autologin' => '允许不安全的自动登陆方式:',
 	),

+ 13 - 0
app/i18n/zh-cn/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => '按订阅源显示',
 			'order' => '按日期排序',
 			'search' => '表达式',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => '状态',
 			'tags' => '按标签显示',
 			'type' => '类型',
 		),
 		'get_all' => '显示所有文章',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => '显示分类 “%s”',
 		'get_favorite' => '显示收藏文章',
 		'get_feed' => '显示订阅源 “%s”',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => '名称',
 		'no_filter' => '无过滤器',
 		'number' => '查询 n°%d',
 		'order_asc' => '由旧至新显示文章',
 		'order_desc' => '由新至旧显示文章',
 		'search' => '搜索 “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => '显示所有文章',
 		'state_1' => '显示已读文章',
 		'state_2' => '显示未读文章',

+ 2 - 2
app/i18n/zh-tw/admin.php

@@ -19,8 +19,8 @@ return array(
 		'http' => 'HTTP(面向啟用 HTTPS 的高級用戶)',
 		'none' => '無認證(危險)',
 		'title' => '認證',
-		'token' => '認證口令',
-		'token_help' => '用於不經認證訪問預設使用者的 RSS 輸出:',
+		'token' => 'Master authentication token',	// TODO
+		'token_help' => 'Allows access to all RSS outputs of the user as well as refreshing feeds without authentication:',	// TODO
 		'type' => '認證方式',
 		'unsafe_autologin' => '允許不安全的自動登入方式:',
 	),

+ 13 - 0
app/i18n/zh-tw/conf.php

@@ -120,20 +120,33 @@ return array(
 			'feeds' => '按訂閱源顯示',
 			'order' => '按日期排序',
 			'search' => '表達式',
+			'shareOpml' => 'Enable sharing by OPML of corresponding categories and feeds',	// TODO
+			'shareRss' => 'Enable sharing by HTML &amp; RSS',	// TODO
 			'state' => '狀態',
 			'tags' => '按標簽顯示',
 			'type' => '類型',
 		),
 		'get_all' => '顯示所有文章',
+		'get_all_labels' => 'Display articles with any label',	// TODO
 		'get_category' => '顯示分類 “%s”',
 		'get_favorite' => '顯示收藏文章',
 		'get_feed' => '顯示訂閱源 “%s”',
+		'get_important' => 'Display articles from important feeds',	// TODO
+		'get_label' => 'Display articles with “%s” label',	// TODO
+		'help' => 'See the <a href="https://freshrss.github.io/FreshRSS/en/users/user_queries.html" target="_blank">documentation for user queries and resharing by HTML / RSS / OPML</a>.',	// TODO
 		'name' => '名稱',
 		'no_filter' => '無過濾器',
 		'number' => '查詢 n°%d',
 		'order_asc' => '由舊至新顯示文章',
 		'order_desc' => '由新至舊顯示文章',
 		'search' => '搜尋 “%s”',
+		'share' => array(
+			'_' => 'Share this query by link',	// TODO
+			'help' => 'Give this link if you want to share this query with anyone',	// TODO
+			'html' => 'Shareable link to the HTML page',	// TODO
+			'opml' => 'Shareable link to the OPML list of feeds',	// TODO
+			'rss' => 'Shareable link to the RSS feed',	// TODO
+		),
 		'state_0' => '顯示所有文章',
 		'state_1' => '顯示已讀文章',
 		'state_2' => '顯示未讀文章',

+ 23 - 25
app/layout/header.phtml

@@ -1,9 +1,10 @@
 <?php
 	declare(strict_types=1);
+	/** @var FreshRSS_View $this */
 ?>
 <header class="header">
 	<div class="item title">
-		<a href="<?= _url('index', 'index') ?>">
+		<a href="<?= Minz_Url::display(['c' => 'index', 'a' => 'index'], 'html', 'root') ?>">
 			<?php if (FreshRSS_Context::systemConf()->logo_html == '') { ?>
 				<img class="logo" src="<?= _i('FreshRSS-logo', FreshRSS_Themes::ICON_URL) ?>" alt="FreshRSS" loading="lazy" />
 			<?php
@@ -16,32 +17,29 @@
 
 	<div class="item search">
 		<?php if (FreshRSS_Auth::hasAccess() || FreshRSS_Context::systemConf()->allow_anonymous) { ?>
-		<form action="<?= _url('index', 'index') ?>" method="get">
+		<form action="<?= $this->html_url ?>" method="get">
 			<div class="stick">
+				<?php if (Minz_Request::controllerName() === 'index'): ?>
+					<?php if (in_array(Minz_Request::actionName(), ['normal', 'global', 'reader'], true)) { ?>
+					<input type="hidden" name="a" value="<?= Minz_Request::actionName() ?>" />
+					<?php } if (Minz_Request::paramString('get') !== '') { ?>
+					<input type="hidden" name="get" value="<?= FreshRSS_Context::currentGet() ?>" />
+					<?php } if (Minz_Request::paramInt('state') !== 0) { ?>
+					<input type="hidden" name="state" value="<?= Minz_Request::paramInt('state') ?>" />
+					<?php } ?>
+				<?php endif; ?>
+				<?php if (Minz_Request::paramString('user') !== '') { ?>
+				<input type="hidden" name="user" value="<?= Minz_User::name() ?>" />
+				<?php } if (ctype_alnum(Minz_Request::paramString('t'))) { ?>
+				<input type="hidden" name="t" value="<?= Minz_Request::paramString('t') ?>" />
+				<?php } if (ctype_upper(Minz_Request::paramString('order'))) { ?>
+				<input type="hidden" name="order" value="<?= FreshRSS_Context::$order ?>" />
+				<?php } if (ctype_lower(Minz_Request::paramString('f'))) { ?>
+				<input type="hidden" name="f" value="<?= Minz_Request::paramString('f') ?>" />
+				<?php } ?>
 				<input type="search" name="search" id="search"
-					value="<?= htmlspecialchars(htmlspecialchars_decode(FreshRSS_Context::$search->getRawInput(), ENT_QUOTES), ENT_COMPAT, 'UTF-8') ?>"
+					value="<?= htmlspecialchars(htmlspecialchars_decode(Minz_Request::paramString('search'), ENT_QUOTES), ENT_COMPAT, 'UTF-8') ?>"
 					placeholder="<?= _t('gen.menu.search') ?>" />
-
-				<?php $param_a = Minz_Request::actionName(); ?>
-				<?php if (in_array($param_a, ['normal', 'global', 'reader'], true)) { ?>
-				<input type="hidden" name="a" value="<?= $param_a ?>" />
-				<?php } ?>
-
-				<?php $get = Minz_Request::paramString('get'); ?>
-				<?php if ($get !== '') { ?>
-				<input type="hidden" name="get" value="<?= $get ?>" />
-				<?php } ?>
-
-				<?php $order = Minz_Request::paramString('order'); ?>
-				<?php if ($order !== '') { ?>
-				<input type="hidden" name="order" value="<?= $order ?>" />
-				<?php } ?>
-
-				<?php $state = Minz_Request::paramString('state'); ?>
-				<?php if ($state !== '') { ?>
-				<input type="hidden" name="state" value="<?= $state ?>" />
-				<?php } ?>
-
 				<button class="btn" type="submit"><?= _i('search') ?></button>
 			</div>
 		</form>
@@ -120,7 +118,7 @@
 	</nav>
 	<?php } elseif (FreshRSS_Auth::accessNeedsAction()) { ?>
 	<div class="item configure">
-		<a class="signin" href="<?= _url('auth', 'login') ?>"><?= _i('login') ?><?= _t('gen.auth.login') ?></a>
+		<a class="signin" href="<?= Minz_Url::display(['c' => 'auth', 'a' => 'login'], 'html', 'root') ?>"><?= _i('login') ?><?= _t('gen.auth.login') ?></a>
 	</div>
 	<?php } ?>
 </header>

+ 9 - 7
app/layout/layout.phtml

@@ -2,15 +2,17 @@
 	declare(strict_types=1);
 	/** @var FreshRSS_View $this */
 	FreshRSS::preLayout();
+	$class = '';
+	if (_t('gen.dir') === 'rtl') {
+		echo ' dir="rtl"';
+		$class = 'rtl ';
+	}
+	if (FreshRSS_Context::userConf()->darkMode !== 'no') {
+		$class .= 'darkMode_' . FreshRSS_Context::userConf()->darkMode;
+	}
 ?>
 <!DOCTYPE html>
-<html lang="<?= FreshRSS_Context::userConf()->language ?>" xml:lang="<?= FreshRSS_Context::userConf()->language ?>"<?php
-$class = '';
-if (_t('gen.dir') === 'rtl') {
-	echo ' dir="rtl"';
-	$class = 'rtl ';
-}
-?> class="<?= $class ?><?= (FreshRSS_Context::userConf()->darkMode === 'no') ? '' : 'darkMode_' . FreshRSS_Context::userConf()->darkMode ?>">
+<html lang="<?= FreshRSS_Context::userConf()->language ?>" xml:lang="<?= FreshRSS_Context::userConf()->language ?>" class="<?= $class ?>">
 	<head>
 		<meta charset="UTF-8" />
 		<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />

+ 9 - 34
app/layout/nav_menu.phtml

@@ -41,26 +41,15 @@
 				<li class="item">
 					<span>
 						<form action="<?= _url('index', 'index') ?>" method="get">
-							<?php $param_a = Minz_Request::actionName(); ?>
-							<?php if (in_array($param_a, ['normal', 'global', 'reader'], true)) { ?>
-							<input type="hidden" name="a" value="<?= $param_a ?>" />
+							<?php if (in_array(Minz_Request::actionName(), ['normal', 'global', 'reader'], true)) { ?>
+							<input type="hidden" name="a" value="<?= Minz_Request::actionName() ?>" />
+							<?php } if (Minz_Request::paramString('get') !== '') { ?>
+							<input type="hidden" name="get" value="<?= FreshRSS_Context::currentGet() ?>" />
+							<?php } if (ctype_upper(Minz_Request::paramString('order'))) { ?>
+							<input type="hidden" name="order" value="<?= FreshRSS_Context::$order ?>" />
+							<?php } if (Minz_Request::paramInt('state') !== 0) { ?>
+							<input type="hidden" name="state" value="<?= FreshRSS_Context::$state ?>" />
 							<?php } ?>
-
-							<?php $get = Minz_Request::paramString('get'); ?>
-							<?php if ($get !== '') { ?>
-							<input type="hidden" name="get" value="<?= $get ?>" />
-							<?php } ?>
-
-							<?php $order = Minz_Request::paramString('order'); ?>
-							<?php if ($order !== '') { ?>
-							<input type="hidden" name="order" value="<?= $order ?>" />
-							<?php } ?>
-
-							<?php $state = Minz_Request::paramString('state'); ?>
-							<?php if ($state !== '') { ?>
-							<input type="hidden" name="state" value="<?= $state ?>" />
-							<?php } ?>
-
 							<div class="stick search">
 								<input type="search" name="search"
 									value="<?= htmlspecialchars(htmlspecialchars_decode(FreshRSS_Context::$search->getRawInput(), ENT_QUOTES), ENT_COMPAT, 'UTF-8'); ?>"
@@ -89,7 +78,7 @@
 						<?php if (!empty($raw_query['url'])): ?>
 							<a href="<?= $raw_query['url'] ?>"><?= $raw_query['name'] ?? $raw_query['url'] ?></a>
 						<?php else: ?>
-						<?php $query = new FreshRSS_UserQuery($raw_query); ?>
+						<?php $query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels()); ?>
 							<a href="<?= $query->getUrl() ?>"><?= $query->getName() ?></a>
 						<?php endif; ?>
 					</li>
@@ -210,20 +199,6 @@
 			<?php
 		}
 		?>
-
-		<?php
-			$url_output['a'] = 'rss';
-			if (FreshRSS_Context::userConf()->token) {
-				$url_output['params']['user'] = Minz_User::name();
-				$url_output['params']['token'] = FreshRSS_Context::userConf()->token;
-			}
-			if (FreshRSS_Context::userConf()->since_hours_posts_per_rss) {
-				$url_output['params']['hours'] = FreshRSS_Context::userConf()->since_hours_posts_per_rss;
-			}
-		?>
-		<a class="view-rss btn" target="_blank" rel="noreferrer" title="<?= _t('index.menu.rss_view') ?>" href="<?= Minz_Url::display($url_output) ?>">
-			<?= _i('rss') ?>
-		</a>
 	</div>
 
 	<?php $nav_menu_hooks = Minz_ExtensionManager::callHookString('nav_menu'); ?>

+ 32 - 10
app/layout/simple.phtml

@@ -2,17 +2,27 @@
 	declare(strict_types=1);
 	/** @var FreshRSS_View $this */
 	FreshRSS::preLayout();
+	$class = '';
+	if (_t('gen.dir') === 'rtl') {
+		echo ' dir="rtl"';
+		$class = 'rtl ';
+	}
+	if (FreshRSS_Context::userConf()->darkMode !== 'no') {
+		$class .= 'darkMode_' . FreshRSS_Context::userConf()->darkMode;
+	}
 ?>
 <!DOCTYPE html>
-<html lang="<?= FreshRSS_Context::userConf()->language ?>" xml:lang="<?= FreshRSS_Context::userConf()->language ?>">
+<html lang="<?= FreshRSS_Context::userConf()->language ?>" xml:lang="<?= FreshRSS_Context::userConf()->language ?>" class="<?= $class ?>">
 	<head>
 		<meta charset="UTF-8" />
-		<meta name="viewport" content="initial-scale=1.0" />
+		<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
+		<?= FreshRSS_View::metaThemeColor() ?>
 		<?= FreshRSS_View::headStyle() ?>
 		<script id="jsonVars" type="application/json">
 <?php $this->renderHelper('javascript_vars'); ?>
 		</script>
 		<?= FreshRSS_View::headScript() ?>
+		<link rel="manifest" href="<?= Minz_Url::display('/themes/manifest.json') ?>" />
 		<link rel="shortcut icon" id="favicon" type="image/x-icon" sizes="16x16 64x64" href="<?= Minz_Url::display('/favicon.ico') ?>" />
 		<link rel="icon msapplication-TileImage apple-touch-icon" type="image/png" sizes="256x256" href="<?= Minz_Url::display('/themes/icons/favicon-256.png') ?>" />
 		<link rel="apple-touch-icon" href="<?= Minz_Url::display('/themes/icons/apple-touch-icon.png') ?>" />
@@ -20,9 +30,15 @@
 		<meta name="apple-mobile-web-app-status-bar-style" content="black" />
 		<meta name="apple-mobile-web-app-title" content="<?= FreshRSS_Context::systemConf()->title ?>">
 		<meta name="msapplication-TileColor" content="#FFF" />
+		<meta name="theme-color" content="#FFF" />
+<?php if (!FreshRSS_Context::systemConf()->allow_referrer) { ?>
 		<meta name="referrer" content="never" />
-		<meta name="robots" content="noindex,nofollow" />
+<?php } ?>
 		<?= FreshRSS_View::headTitle() ?>
+		<?php if ($this->rss_url != ''): ?>
+		<link rel="alternate" type="application/rss+xml" title="<?= $this->rss_title ?>" href="<?= $this->rss_url ?>" />
+		<?php endif; ?>
+		<meta name="robots" content="noindex,nofollow" />
 	</head>
 	<body>
 
@@ -30,7 +46,7 @@
 <div class="app-layout app-layout-simple">
 	<div class="header">
 		<div class="item title">
-			<a href="<?= _url('index', 'index') ?>">
+			<a href="<?= Minz_Url::display(['c' => 'index', 'a' => 'index'], 'html', 'root') ?>">
 				<?php if (FreshRSS_Context::systemConf()->logo_html == '') { ?>
 					<img class="logo" src="<?= _i('FreshRSS-logo', FreshRSS_Themes::ICON_URL) ?>" alt="FreshRSS" loading="lazy" />
 				<?php
@@ -43,14 +59,20 @@
 
 		<div class="item"></div>
 
-		<div class="item">
-			<?php if (FreshRSS_Auth::accessNeedsAction()) { ?>
-				<a class="signout" href="<?= _url('auth', 'logout') ?>">
-					<?= _i('logout') . _t('gen.auth.logout') ?>
+		<?php if (FreshRSS_Auth::accessNeedsAction()): ?>
+			<div class="item configure">
+			<?php if (FreshRSS_Auth::hasAccess()): ?>
+				<a class="signout" href="<?= Minz_Url::display(['c' => 'auth', 'a' => 'logout'], 'html', 'root') ?>">
+					<?= _i('logout') ?><?= _t('gen.auth.logout') ?>
 					(<?= htmlspecialchars(Minz_User::name() ?? '', ENT_NOQUOTES, 'UTF-8') ?>)
 				</a>
-			<?php } ?>
-		</div>
+			<?php else: ?>
+				<a class="signin" href="<?= Minz_Url::display(['c' => 'auth', 'a' => 'login'], 'html', 'root') ?>">
+					<?= _i('login') ?><?= _t('gen.auth.login') ?>
+				</a>
+			<?php endif; ?>
+			</div>
+		<?php endif; ?>
 	</div>
 
 	<?php $this->render(); ?>

+ 3 - 0
app/views/configure/queries.phtml

@@ -18,6 +18,9 @@
 				<div class="box-title">
 					<a class="configure open-slider" href="<?= _url('configure', 'query', 'id', '' . $key) ?>"><?= _i('configure') ?></a><h2><?= $query->getName() ?></h2>
 					<input type="hidden" id="queries_<?= $key ?>_name" name="queries[<?= $key ?>][name]" value="<?= $query->getName() ?>"/>
+					<input type="hidden" id="queries_<?= $key ?>_token" name="queries[<?= $key ?>][token]" value="<?= $query->getToken() ?>"/>
+					<input type="hidden" id="queries_<?= $key ?>_shareRss" name="queries[<?= $key ?>][token]" value="<?= $query->shareRss() ?>"/>
+					<input type="hidden" id="queries_<?= $key ?>_shareOpml" name="queries[<?= $key ?>][token]" value="<?= $query->shareOpml() ?>"/>
 					<input type="hidden" id="queries_<?= $key ?>_url" name="queries[<?= $key ?>][url]" value="<?= $query->getUrl() ?>"/>
 					<input type="hidden" id="queries_<?= $key ?>_search" name="queries[<?= $key ?>][search]" value="<?= urlencode($query->getSearch()->getRawInput()) ?>"/>
 					<input type="hidden" id="queries_<?= $key ?>_state" name="queries[<?= $key ?>][state]" value="<?= $query->getState() ?>"/>

+ 53 - 14
app/views/helpers/configure/query.phtml

@@ -7,7 +7,6 @@
 ?>
 <div class="post">
 	<h2><?= $this->query->getName() ?></h2>
-
 	<div>
 		<a href="<?= $this->query->getUrl() ?>"><?= _i('link') ?> <?= _t('gen.action.filter') ?></a>
 	</div>
@@ -18,15 +17,53 @@
 		<div class="form-group">
 			<label class="group-name" for="name"><?= _t('conf.query.name') ?></label>
 			<div class="group-controls">
-				<input type="text" name="name" id="name" value="<?= $this->query->getName()  ?>" />
+				<input type="text" name="name" id="name" value="<?= $this->query->getName() ?>" />
+				<input type="hidden" name="query[token]" id="query_token" value="<?= $this->query->getToken() ?>" />
 			</div>
 		</div>
-		<legend><?= _t('conf.query.filter') ?></legend>
 
+		<legend><?= _t('conf.query.share') ?></legend>
+		<div class="form-group">
+			<div class="group-controls">
+				<label class="checkbox" for="shareRss">
+					<input type="checkbox" name="query[shareRss]" id="shareRss" value="1" <?= $this->query->shareRss() ? 'checked="checked"' : ''?> />
+					<?= _t('conf.query.filter.shareRss') ?>
+				</label>
+				<?php if ($this->query->sharedUrlRss() !== ''): ?>
+				<ul>
+					<li><a href="<?= $this->query->sharedUrlHtml() ?>"><?= _i('link') ?> <?= _t('conf.query.share.html') ?></a></li>
+					<li><a href="<?= $this->query->sharedUrlRss() ?>"><?= _i('link') ?> <?= _t('conf.query.share.rss') ?></a></li>
+				</ul>
+				<?php endif; ?>
+			</div>
+			<div class="group-controls">
+				<label class="checkbox" for="shareOpml">
+					<input type="checkbox" name="query[shareOpml]" id="shareOpml" value="1" <?= $this->query->shareOpml() && $this->query->safeForOpml() ? 'checked="checked"' : '' ?>
+						<?= $this->query->safeForOpml() ? '' : 'disabled="disabled"' ?> />
+					<?= _t('conf.query.filter.shareOpml') ?>
+				</label>
+				<?php if ($this->query->sharedUrlOpml() !== ''): ?>
+				<ul>
+					<li><a href="<?= $this->query->sharedUrlOpml() ?>"><?= _i('link') ?> <?= _t('conf.query.share.opml') ?></a></li>
+				</ul>
+				<?php endif; ?>
+			</div>
+			<p class="help"><?= _i('help') ?> <?= _t('conf.query.share.help') ?></a></p>
+			<p class="help"><?= _i('help') ?> <?= _t('conf.query.help') ?></a></p>
+		</div>
+
+		<div class="form-group form-actions">
+			<div class="group-controls">
+				<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
+			</div>
+		</div>
+
+		<legend><?= _t('conf.query.filter') ?></legend>
 		<div class="form-group">
 			<label class="group-name" for=""><?= _t('conf.query.filter.search') ?></label>
 			<div class="group-controls">
 				<input type="text" id="query_search" name="query[search]" value="<?= htmlspecialchars($this->query->getSearch()->getRawInput(), ENT_COMPAT, 'UTF-8') ?>"/>
+				<p class="help"><?= _i('help') ?> <?= _t('gen.menu.search_help') ?></a></p>
 			</div>
 		</div>
 		<div class="form-group">
@@ -58,22 +95,24 @@
 			<label class="group-name" for="query_get"><?= _t('conf.query.filter.type') ?></label>
 			<div class="group-controls">
 				<select name="query[get]" id="query_get" size="10">
-					<option value=""></option>
-					<option value="s" <?= 's' === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= _t('conf.query.get_favorite') ?></option>
+					<option value="a" <?= in_array($this->query->getGet(), ['', 'a'], true) ? 'selected="selected"' : '' ?>><?= _t('index.feed.title') ?></option>
+					<option value="i" <?= 'i' === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= _t('index.menu.important') ?></option>
+					<option value="s" <?= 's' === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= _t('index.feed.title_fav') ?></option>
+					<option value="T" <?= 'T' === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= _t('index.menu.tags') ?></option>
+					<optgroup label="<?= _t('conf.query.filter.tags') ?>">
+						<?php foreach ($this->tags as $tag): ?>
+							<option value="t_<?= $tag->id() ?>" <?= "t_{$tag->id()}" === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= $tag->name() ?></option>
+						<?php endforeach?>
+					</optgroup>
 					<optgroup label="<?= _t('conf.query.filter.categories') ?>">
 						<?php foreach ($this->categories as $category): ?>
 							<option value="c_<?= $category->id() ?>" <?= "c_{$category->id()}" === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= $category->name() ?></option>
 						<?php endforeach?>
 					</optgroup>
 					<optgroup label="<?= _t('conf.query.filter.feeds') ?>">
-					<?php foreach ($this->feeds as $feed): ?>
-						<option value="f_<?= $feed->id() ?>" <?= "f_{$feed->id()}" === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= $feed->name() ?></option>
-					<?php endforeach?>
-					</optgroup>
-					<optgroup label="<?= _t('conf.query.filter.tags') ?>">
-					<?php foreach ($this->tags as $tag): ?>
-						<option value="t_<?= $tag->id() ?>" <?= "t_{$tag->id()}" === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= $tag->name() ?></option>
-					<?php endforeach?>
+						<?php foreach ($this->feeds as $feed): ?>
+							<option value="f_<?= $feed->id() ?>" <?= "f_{$feed->id()}" === $this->query->getGet() ? 'selected="selected"' : '' ?>><?= $feed->name() ?></option>
+						<?php endforeach?>
 					</optgroup>
 				</select>
 			</div>
@@ -83,8 +122,8 @@
 			<div class="group-controls">
 				<select name="query[order]" id="query_order">
 					<option value=""></option>
-					<option value="ASC" <?= 'ASC' === $this->query->getOrder() ? 'selected="selected"' : '' ?>><?= _t('conf.query.order_asc') ?></option>
 					<option value="DESC" <?= 'DESC' === $this->query->getOrder() ? 'selected="selected"' : '' ?>><?= _t('conf.query.order_desc') ?></option>
+					<option value="ASC" <?= 'ASC' === $this->query->getOrder() ? 'selected="selected"' : '' ?>><?= _t('conf.query.order_asc') ?></option>
 				</select>
 			</div>
 		</div>

+ 1 - 1
app/views/helpers/export/articles.phtml

@@ -24,7 +24,7 @@ foreach ($this->entries as $entry) {
 		continue;
 	}
 
-	$feed = $this->feed ?? FreshRSS_CategoryDAO::findFeed($this->categories, $entry->feedId());
+	$feed = $this->feed ?? FreshRSS_Category::findFeed($this->categories, $entry->feedId());
 	$entry->_feed($feed);
 
 	$article = $entry->toGReader('freshrss', $this->entryIdsTagNames['e_' . $entry->id()] ?? []);

+ 0 - 3
app/views/helpers/feed/update.phtml

@@ -1,9 +1,6 @@
 <?php
 	declare(strict_types=1);
 	/** @var FreshRSS_View $this */
-	if ($this->feed === null) {
-		throw new FreshRSS_Context_Exception('Feed not initialised!');
-	}
 ?>
 <div class="post" id="feed_update">
 	<h1><?= $this->feed->name() ?></h1>

+ 21 - 0
app/views/helpers/htmlPagination.phtml

@@ -0,0 +1,21 @@
+<?php
+	declare(strict_types=1);
+	/** @var FreshRSS_View $this */
+?>
+<nav class="nav-pagination nav-list">
+	<ul class="pagination">
+		<?php if (FreshRSS_Context::$offset > 0): ?>
+		<li class="item pager-first">
+			<a href="<?= $this->userQuery->sharedUrlHtml() . '&nb=' . FreshRSS_Context::$number ?>">« <?= _t('conf.logs.pagination.first') ?></a>
+		</li>
+		<li class="item pager-previous">
+			<a href="<?= $this->userQuery->sharedUrlHtml() . '&nb=' . FreshRSS_Context::$number .
+				'&offset=' . max(0, FreshRSS_Context::$offset - FreshRSS_Context::$number) ?>">‹ <?= _t('conf.logs.pagination.previous') ?></a>
+		</li>
+		<?php endif; ?>
+		<li class="item pager-next">
+			<a href="<?= $this->userQuery->sharedUrlHtml() . '&nb=' . FreshRSS_Context::$number .
+				'&offset=' . (FreshRSS_Context::$offset + FreshRSS_Context::$number) ?>"><?= _t('conf.logs.pagination.next') ?> ›</a>
+		</li>
+	</ul>
+</nav>

+ 117 - 0
app/views/helpers/index/article.phtml

@@ -0,0 +1,117 @@
+<?php
+	declare(strict_types=1);
+	/** @var FreshRSS_View $this */
+	$entry = $this->entry;
+	$feed = $this->feed;
+?>
+<article class="flux_content" dir="auto">
+<div class="content <?= FreshRSS_Context::userConf()->content_width ?>">
+	<header>
+		<?php
+			$favoriteUrl = ['c' => 'entry', 'a' => 'bookmark', 'params' => ['id' => $entry->id()]];
+			if ($entry->isFavorite()) {
+				$favoriteUrl['params']['is_favorite'] = 0;
+			}
+			$readUrl = ['c' => 'entry', 'a' => 'read', 'params' => ['id' => $entry->id()]];
+			if ($entry->isRead()) {
+				$readUrl['params']['is_read'] = 0;
+			}
+		?>
+		<div class="article-header-topline">
+			<?php if (FreshRSS_Auth::hasAccess()) { ?>
+				<a class="read" href="<?= Minz_Url::display($readUrl) ?>" title="<?= _t('conf.shortcut.mark_read') ?>"><?= _i($entry->isRead() ? 'read' : 'unread') ?></a>
+				<a class="bookmark" href="<?= Minz_Url::display($favoriteUrl) ?>" title="<?= _t('conf.shortcut.mark_favorite') ?>"><?= _i($entry->isFavorite() ? 'starred' : 'non-starred') ?></a>
+			<?php } ?>
+			<?php if (FreshRSS_Context::userConf()->show_feed_name === 't') { ?>
+				<a class="website" href="<?= _url('index', 'reader', 'get', 'f_' . $feed->id()) ?>" title="<?= _t('gen.action.filter') ?>">
+					<?php if (FreshRSS_Context::userConf()->show_favicons): ?>
+						<img class="favicon" src="<?= $feed->favicon() ?>" alt="✇" loading="lazy" /><?php
+					endif; ?><span><?= $feed->name() ?></span></a>
+			<?php } ?>
+		</div>
+
+		<?php
+			if (in_array(FreshRSS_Context::userConf()->show_tags, ['b', 'h'], true)) {
+				$this->renderHelper('index/tags');
+			}
+		?>
+
+		<h1 class="title"><a target="_blank" rel="noreferrer" class="go_website" href="<?= $entry->link() ?>"><?= $entry->title() ?></a></h1>
+		<?php if (FreshRSS_Context::userConf()->show_author_date === 'h' || FreshRSS_Context::userConf()->show_author_date === 'b') { ?>
+			<div class="subtitle">
+				<?php if (FreshRSS_Context::userConf()->show_feed_name === 'a') { ?>
+					<div class="website"><a href="<?= $this->internal_rendering ? $feed->website() : _url('index', 'reader', 'get', 'f_' . $feed->id()) ?>" title="<?= _t('gen.action.filter') ?>">
+						<?php if (FreshRSS_Context::userConf()->show_favicons): ?>
+							<img class="favicon" src="<?= $feed->favicon() ?>" alt="✇" loading="lazy" /><?php
+						endif; ?><span><?= $feed->name() ?></span></a></div>
+				<?php } ?>
+				<div class="author"><?php
+					$authors = $entry->authors();
+					if (is_array($authors)) {
+						if ($this->internal_rendering):
+							foreach ($authors as $author): ?>
+								<?= $author ?>
+							<?php endforeach;
+						else:
+							foreach ($authors as $author): ?>
+								<a href="<?= Minz_Url::display(Minz_Request::modifiedCurrentRequest(['search' => 'author:' . str_replace(' ', '+', htmlspecialchars_decode($author, ENT_QUOTES))])) ?>">
+									<?= $author ?>
+								</a>
+							<?php endforeach;
+						endif;
+					} ?>
+				</div>
+				<div class="date">
+					<time datetime="<?= $entry->machineReadableDate() ?>"><?= $entry->date() ?></time>
+				</div>
+			</div>
+		<?php } ?>
+	</header>
+
+	<div class="text">
+		<?= $entry->content(true) ?>
+	</div>
+	<?php
+	$display_authors_date = in_array(FreshRSS_Context::userConf()->show_author_date, ['b', 'f'], true);
+	$display_tags = in_array(FreshRSS_Context::userConf()->show_tags, ['b', 'f'], true);
+
+	if ($display_authors_date || $display_tags) {
+		?>
+		<footer>
+			<?php if ($display_authors_date) { ?>
+				<div class="subtitle">
+					<?php if (FreshRSS_Context::userConf()->show_feed_name === 'a') { ?>
+						<div class="website"><a href="<?= _url('index', 'reader', 'get', 'f_' . $feed->id()) ?>" title="<?= _t('gen.action.filter') ?>">
+							<?php if (FreshRSS_Context::userConf()->show_favicons): ?>
+								<img class="favicon" src="<?= $feed->favicon() ?>" alt="✇" loading="lazy" /><?php
+							endif; ?><span><?= $feed->name() ?></span></a></div>
+					<?php } ?>
+					<div class="author"><?php
+						$authors = $entry->authors();
+						if (is_array($authors)) {
+							foreach ($authors as $author) {
+								?>
+								<a href="<?= Minz_Url::display(Minz_Request::modifiedCurrentRequest(['search' => 'author:' . str_replace(' ', '+', htmlspecialchars_decode($author, ENT_QUOTES))])) ?>">
+									<?= $author ?>
+								</a>
+								<?php
+							}
+						}
+						?>
+					</div>
+					<div class="date">
+						<time datetime="<?= $entry->machineReadableDate() ?>"><?= $entry->date() ?></time>
+					</div>
+				</div>
+				<?php
+			}
+
+			if ($display_tags) {
+				$this->renderHelper('index/tags');
+			}
+		?>
+		</footer>
+		<?php
+	} ?>
+</div>
+</article>

+ 0 - 3
app/views/helpers/index/normal/entry_header.phtml

@@ -1,9 +1,6 @@
 <?php
 	declare(strict_types=1);
 	/** @var FreshRSS_View $this */
-	if ($this->feed === null) {
-		throw new FreshRSS_Context_Exception('Feed not initialised!');
-	}
 	$topline_read = FreshRSS_Context::userConf()->topline_read;
 	$topline_favorite = FreshRSS_Context::userConf()->topline_favorite;
 	$topline_website = FreshRSS_Context::userConf()->topline_website;

+ 42 - 0
app/views/helpers/index/tags.phtml

@@ -0,0 +1,42 @@
+<?php
+	declare(strict_types=1);
+	/** @var FreshRSS_View $this */
+	[$firstTags,$remainingTags] = $this->entry->tagsFormattingHelper();
+?>
+<div class="tags">
+<?php if (!empty($firstTags)): ?>
+	<?= _i('tag') ?><ul class="list-tags">
+	<?php if (Minz_Request::controllerName() === 'index'): ?>
+		<?php foreach ($firstTags as $tag): ?>
+		<li class="item tag"><a class="link-tag" href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>">#<?= $tag ?></a></li>
+		<?php endforeach; ?>
+	<?php else: // API public access ?>
+		<?php foreach ($firstTags as $tag): ?>
+		<li class="item tag"><a class="link-tag" href="<?= $this->html_url . '&search=%23' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES)) ?>" title="<?= _t('gen.action.filter') ?>">#<?= $tag ?></a></li>
+		<?php endforeach; ?>
+	<?php endif; ?>
+
+	<?php if (!empty($remainingTags)): // more than 7 tags: show dropdown menu ?>
+		<li class="item tag">
+			<div class="dropdown">
+				<div id="dropdown-tags2-<?= $this->entry->id() ?>" class="dropdown-target"></div>
+				<a class="dropdown-toggle" href="#dropdown-tags2-<?= $this->entry->id() ?>"><?= _i('down') ?></a>
+				<ul class="dropdown-menu">
+					<li class="dropdown-header"><?= _t('index.tag.related') ?></li>
+					<?php if (Minz_Request::controllerName() === 'index'): ?>
+						<?php foreach ($remainingTags as $tag): ?>
+						<li class="item"><a href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>">#<?= $tag ?></a></li>
+						<?php endforeach; ?>
+					<?php else: ?>
+						<?php foreach ($remainingTags as $tag): ?>
+						<li class="item tag"><a class="link-tag" href="<?= $this->html_url . '&search=%23' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES)) ?>" title="<?= _t('gen.action.filter') ?>">#<?= $tag ?></a></li>
+						<?php endforeach; ?>
+					<?php endif; ?>
+				</ul>
+				<a class="dropdown-close" href="#close">❌</a>
+			</div>
+		</li>
+	<?php endif; ?>
+	</ul>
+<?php endif; ?>
+</div>

+ 32 - 0
app/views/index/html.phtml

@@ -0,0 +1,32 @@
+<?php
+	declare(strict_types=1);
+	/** @var FreshRSS_View $this */
+	// Override some layout preferences for the public API output
+	FreshRSS_Context::userConf()->content_width = 'large';
+	FreshRSS_Context::userConf()->show_author_date = FreshRSS_UserConfiguration::default()->show_author_date;
+	FreshRSS_Context::userConf()->show_favicons = FreshRSS_UserConfiguration::default()->show_favicons;
+	FreshRSS_Context::userConf()->show_feed_name = FreshRSS_UserConfiguration::default()->show_feed_name;
+	FreshRSS_Context::userConf()->show_tags = FreshRSS_UserConfiguration::default()->show_tags;
+	FreshRSS_Context::userConf()->show_tags_max = FreshRSS_UserConfiguration::default()->show_tags_max;
+?>
+<?php $this->renderHelper('htmlPagination'); ?>
+<main id="stream" class="reader api">
+	<h2>
+		<a href="<?= $this->html_url ?>"><?= FreshRSS_View::title() ?></a> ·
+		<a class="view-rss" href="<?= $this->rss_url ?>" title="<?= _t('index.menu.rss_view') ?>">
+			<?= _i('rss') ?>
+		</a>
+	</h2>
+	<?php
+		foreach ($this->entries as $entry):
+			$this->entry = $entry;
+			$this->feed = $this->feeds[$entry->feedId()] ??
+				FreshRSS_Category::findFeed($this->categories, $entry->feedId()) ??
+				FreshRSS_Feed::default();
+	?>
+		<div class="flux">
+			<?php $this->renderHelper('index/article'); ?>
+		</div>
+	<?php endforeach; ?>
+</main>
+<?php $this->renderHelper('htmlPagination'); ?>

+ 12 - 90
app/views/index/normal.phtml

@@ -11,23 +11,18 @@ call_user_func($this->callbackBeforeEntries, $this);
 $display_today = true;
 $display_yesterday = true;
 $display_others = true;
-$hidePosts = !FreshRSS_Context::userConf()->display_posts;
-$lazyload = FreshRSS_Context::userConf()->lazyload;
-$content_width = FreshRSS_Context::userConf()->content_width;
-$MAX_TAGS_DISPLAYED = (int)FreshRSS_Context::userConf()->show_tags_max;
 $useKeepUnreadImportant = !FreshRSS_Context::isImportant() && !FreshRSS_Context::isFeed();
 
 $today = @strtotime('today');
 ?>
 
-<main id="stream" class="normal<?= $hidePosts ? ' hide_posts' : '' ?>">
+<main id="stream" class="normal<?= FreshRSS_Context::userConf()->display_posts ? '' : ' hide_posts' ?>">
 	<h1 class="title_hidden"><?= _t('conf.reading.view.normal') ?></h1>
 	<div id="new-article">
 		<a href="<?= Minz_Url::display(Minz_Request::currentRequest()) ?>"><?= _t('gen.js.new_article'); /* TODO: move string in JS*/ ?></a>
 	</div><?php
 	$lastEntry = null;
 	$nbEntries = 0;
-	/** @var FreshRSS_Entry */
 	foreach ($this->entries as $item):
 		$lastEntry = $item;
 		$nbEntries++;
@@ -40,8 +35,8 @@ $today = @strtotime('today');
 		$this->entry = $item;
 
 		// We most likely already have the feed object in cache, otherwise make a request
-		$this->feed = FreshRSS_CategoryDAO::findFeed($this->categories, $this->entry->feedId()) ??
-			$this->entry->feed() ?? FreshRSS_Feed::example();
+		$this->feed = FreshRSS_Category::findFeed($this->categories, $this->entry->feedId()) ??
+			$this->entry->feed() ?? FreshRSS_Feed::default();
 
 		if ($display_today && $this->entry->isDay(FreshRSS_Days::TODAY, $today)) {
 			?><div class="day" id="day_today"><?php
@@ -74,27 +69,8 @@ $today = @strtotime('today');
 		?>" data-priority="<?= $this->feed->priority()
 		?>"><?php
 			$this->renderHelper('index/normal/entry_header');
-			if ($this->feed === null) {
-				throw new FreshRSS_Context_Exception('Feed not initialised!');
-			}
-
-			$tags = null;
-			$firstTags = array();
-			$remainingTags = array();
-
-			if (FreshRSS_Context::userConf()->show_tags === 'h' || FreshRSS_Context::userConf()->show_tags === 'f' || FreshRSS_Context::userConf()->show_tags === 'b') {
-				$tags = $this->entry->tags();
-				if (!empty($tags)) {
-					if ($MAX_TAGS_DISPLAYED > 0) {
-						$firstTags = array_slice($tags, 0, $MAX_TAGS_DISPLAYED);
-						$remainingTags = array_slice($tags, $MAX_TAGS_DISPLAYED);
-					} else {
-						$firstTags = $tags;
-					}
-				}
-			}
 		?><article class="flux_content" dir="auto">
-			<div class="content <?= $content_width ?>">
+			<div class="content <?= FreshRSS_Context::userConf()->content_width ?>">
 				<header>
 					<?php if (FreshRSS_Context::userConf()->show_feed_name === 't') { ?>
 						<div class="website"><a href="<?= _url('index', 'index', 'get', 'f_' . $this->feed->id()) ?>" title="<?= _t('gen.action.filter') ?>">
@@ -103,36 +79,8 @@ $today = @strtotime('today');
 							endif; ?><span><?= $this->feed->name() ?></span></a>
 						</div>
 					<?php } ?>
-					<?php if (FreshRSS_Context::userConf()->show_tags === 'h' || FreshRSS_Context::userConf()->show_tags === 'b') { ?>
-						<div class="tags">
-							<?php
-							if (!empty($tags)) {
-								?><?= _i('tag') ?><ul class="list-tags"><?php
-								foreach ($firstTags as $tag) {
-									?><li class="item tag"><a class="link-tag" href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>">#<?= $tag ?></a></li><?php
-								}
-
-								if (!empty($remainingTags)) { // more than 7 tags: show dropdown menu ?>
-									<li class="item tag">
-										<div class="dropdown">
-											<div id="dropdown-tags2-<?= $this->entry->id() ?>" class="dropdown-target"></div>
-											<a class="dropdown-toggle" href="#dropdown-tags2-<?= $this->entry->id() ?>"><?= _i('down') ?></a>
-											<ul class="dropdown-menu">
-												<li class="dropdown-header"><?= _t('index.tag.related') ?></li>
-												<?php
-												foreach ($remainingTags as $tag) {
-													?><li class="item"><a href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>"><?= $tag ?></a></li><?php
-												} ?>
-											</ul>
-											<a class="dropdown-close" href="#close">❌</a>
-										</div>
-									</li>
-									<?php
-								} ?>
-								</ul><?php
-							} ?>
-						</div>
-						<?php
+					<?php if (FreshRSS_Context::userConf()->show_tags === 'h' || FreshRSS_Context::userConf()->show_tags === 'b') {
+						$this->renderHelper('index/tags');
 					} ?>
 					<h1 class="title"><a target="_blank" rel="noreferrer" class="go_website" href="<?= $this->entry->link() ?>" title="<?= _t('conf.shortcut.see_on_website')?>"><?= $this->entry->title() ?></a></h1>
 					<?php if (FreshRSS_Context::userConf()->show_author_date === 'h' || FreshRSS_Context::userConf()->show_author_date === 'b') { ?>
@@ -163,8 +111,8 @@ $today = @strtotime('today');
 					</div>
 					<?php } ?>
 				</header>
-				<div class="text"><?php
-					echo $lazyload && $hidePosts ? lazyimg($this->entry->content(true)) : $this->entry->content(true);
+				<div class="text"><?=
+					FreshRSS_Context::userConf()->lazyload && !FreshRSS_Context::userConf()->display_posts ? lazyimg($this->entry->content(true)) : $this->entry->content(true)
 				?></div>
 				<?php
 				$display_authors_date = FreshRSS_Context::userConf()->show_author_date === 'f' || FreshRSS_Context::userConf()->show_author_date === 'b';
@@ -201,36 +149,10 @@ $today = @strtotime('today');
 							</div>
 						<?php
 						}
-						if ($display_tags) { ?>
-							<div class="tags">
-								<?php
-								if (!empty($tags)) {
-									?><?= _i('tag') ?><ul class="list-tags"><?php
-									foreach ($firstTags as $tag) {
-										?><li class="item tag"><a class="link-tag" href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>">#<?= $tag ?></a></li><?php
-									}
-									if (!empty($remainingTags)) { ?>
-										<li class="item tag">
-											<div class="dropdown">
-												<div id="dropdown-tags3-<?= $this->entry->id() ?>" class="dropdown-target"></div>
-												<a class="dropdown-toggle" href="#dropdown-tags3-<?= $this->entry->id() ?>"><?= _i('down') ?></a>
-												<ul class="dropdown-menu">
-													<li class="dropdown-header"><?= _t('index.tag.related') ?></li>
-													<?php
-													foreach ($remainingTags as $tag) {
-														?><li class="item"><a href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>"><?= $tag ?></a></li><?php
-													} ?>
-												</ul>
-												<a class="dropdown-close" href="#close">❌</a>
-											</div>
-										</li>
-										<?php
-									} ?>
-									</ul><?php
-								} ?>
-							</div>
-							<?php
-						} ?>
+						if ($display_tags) {
+							$this->renderHelper('index/tags');
+						}
+						?>
 					</footer>
 					<?php
 				} ?>

+ 8 - 186
app/views/index/reader.phtml

@@ -9,8 +9,6 @@ if (!Minz_Request::paramBoolean('ajax')) {
 call_user_func($this->callbackBeforeEntries, $this);
 
 $lazyload = FreshRSS_Context::userConf()->lazyload;
-$content_width = FreshRSS_Context::userConf()->content_width;
-$MAX_TAGS_DISPLAYED = (int)FreshRSS_Context::userConf()->show_tags_max;
 ?>
 <main id="stream" class="reader">
 	<h1 class="title_hidden"><?= _t('conf.reading.view.reader') ?></h1>
@@ -19,197 +17,21 @@ $MAX_TAGS_DISPLAYED = (int)FreshRSS_Context::userConf()->show_tags_max;
 	</div><?php
 	$lastEntry = null;
 	$nbEntries = 0;
-	/** @var FreshRSS_Entry */
-	foreach ($this->entries as $item):
-		$lastEntry = $item;
+	foreach ($this->entries as $entry):
+		$lastEntry = $entry;
 		$nbEntries++;
 		ob_flush();
 		/** @var FreshRSS_Entry */
-		$item = Minz_ExtensionManager::callHook('entry_before_display', $item);
-		if ($item == null) {
+		$entry = Minz_ExtensionManager::callHook('entry_before_display', $entry);
+		if ($entry == null) {
 			continue;
 		}
-		$this->entry = $item;
-
-		$tags = null;
-		$firstTags = array();
-		$remainingTags = array();
-
-		if (FreshRSS_Context::userConf()->show_tags == 'h' || FreshRSS_Context::userConf()->show_tags == 'f' || FreshRSS_Context::userConf()->show_tags == 'b') {
-			$tags = $this->entry->tags();
-			if (!empty($tags)) {
-				if ($MAX_TAGS_DISPLAYED > 0) {
-					$firstTags = array_slice($tags, 0, $MAX_TAGS_DISPLAYED);
-					$remainingTags = array_slice($tags, $MAX_TAGS_DISPLAYED);
-				} else {
-					$firstTags = $tags;
-				}
-			}
-		}
+		$this->entry = $entry;
 
 		//We most likely already have the feed object in cache, otherwise make a request
-		$feed = FreshRSS_CategoryDAO::findFeed($this->categories, $item->feedId()) ?? $item->feed() ?? FreshRSS_Feed::example();
-	?><div class="flux<?= !$item->isRead() ? ' not_read' : '' ?><?= $item->isFavorite() ? ' favorite' : '' ?>" id="flux_<?= $item->id() ?>" data-priority="<?= $feed->priority() ?>">
-		<article class="flux_content" dir="auto">
-
-			<div class="content <?= $content_width ?>">
-				<header>
-					<?php
-						$favoriteUrl = array('c' => 'entry', 'a' => 'bookmark', 'params' => array('id' => $item->id()));
-						if ($item->isFavorite()) {
-							$favoriteUrl['params']['is_favorite'] = 0;
-						}
-						$readUrl = array('c' => 'entry', 'a' => 'read', 'params' => array('id' => $item->id()));
-						if ($item->isRead()) {
-							$readUrl['params']['is_read'] = 0;
-						}
-					?>
-					<div class="article-header-topline">
-						<?php if (FreshRSS_Auth::hasAccess()) { ?>
-							<a class="read" href="<?= Minz_Url::display($readUrl) ?>" title="<?= _t('conf.shortcut.mark_read') ?>"><?= _i($item->isRead() ? 'read' : 'unread') ?></a>
-							<a class="bookmark" href="<?= Minz_Url::display($favoriteUrl) ?>" title="<?= _t('conf.shortcut.mark_favorite') ?>"><?= _i($item->isFavorite() ? 'starred' : 'non-starred') ?></a>
-						<?php } ?>
-						<?php if (FreshRSS_Context::userConf()->show_feed_name === 't') { ?>
-							<a class="website" href="<?= _url('index', 'reader', 'get', 'f_' . $feed->id()) ?>" title="<?= _t('gen.action.filter') ?>">
-								<?php if (FreshRSS_Context::userConf()->show_favicons): ?>
-									<img class="favicon" src="<?= $feed->favicon() ?>" alt="✇" loading="lazy" /><?php
-								endif; ?><span><?= $feed->name() ?></span></a>
-						<?php } ?>
-					</div>
-
-					<?php if (FreshRSS_Context::userConf()->show_tags === 'h' || FreshRSS_Context::userConf()->show_tags === 'b') { ?>
-						<div class="tags">
-							<?php
-							if (!empty($tags)) {
-								?><?= _i('tag') ?><ul class="list-tags"><?php
-								foreach ($firstTags as $tag) {
-									?><li class="item tag"><a class="link-tag" href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>">#<?= $tag ?></a></li><?php
-								}
-
-								if (!empty($remainingTags)) { // more than 7 tags: show dropdown menu ?>
-									<li class="item tag">
-										<div class="dropdown">
-											<div id="dropdown-tags-<?= $this->entry->id() ?>" class="dropdown-target"></div>
-											<a class="dropdown-toggle" href="#dropdown-tags-<?= $this->entry->id() ?>"><?= _i('down') ?></a>
-											<ul class="dropdown-menu">
-												<li class="dropdown-header"><?= _t('index.tag.related') ?></li>
-												<?php
-												foreach ($remainingTags as $tag) {
-													?><li class="item"><a href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>"><?= $tag ?></a></li><?php
-												} ?>
-											</ul>
-											<a class="dropdown-close" href="#close">❌</a>
-										</div>
-									</li>
-									<?php
-								} ?>
-								</ul><?php
-							} ?>
-						</div>
-					<?php } ?>
-
-					<h1 class="title"><a target="_blank" rel="noreferrer" class="go_website" href="<?= $item->link() ?>"><?= $item->title() ?></a></h1>
-					<?php if (FreshRSS_Context::userConf()->show_author_date === 'h' || FreshRSS_Context::userConf()->show_author_date === 'b') { ?>
-						<div class="subtitle">
-							<?php if (FreshRSS_Context::userConf()->show_feed_name === 'a') { ?>
-								<div class="website"><a href="<?= _url('index', 'reader', 'get', 'f_' . $feed->id()) ?>" title="<?= _t('gen.action.filter') ?>">
-									<?php if (FreshRSS_Context::userConf()->show_favicons): ?>
-										<img class="favicon" src="<?= $feed->favicon() ?>" alt="✇" loading="lazy" /><?php
-									endif; ?><span><?= $feed->name() ?></span></a></div>
-							<?php } ?>
-							<div class="author"><?php
-								$authors = $item->authors();
-								if (is_array($authors)) {
-									foreach ($authors as $author) {
-										?>
-										<a href="<?= Minz_Url::display(Minz_Request::modifiedCurrentRequest(['search' => 'author:' . str_replace(' ', '+', htmlspecialchars_decode($author, ENT_QUOTES))])) ?>">
-											<?= $author ?>
-										</a>
-										<?php
-									}
-								}
-								?>
-							</div>
-							<div class="date">
-								<time datetime="<?= $item->machineReadableDate() ?>"><?= $item->date() ?></time>
-							</div>
-						</div>
-					<?php } ?>
-				</header>
-
-				<div class="text">
-					<?= $item->content(true) ?>
-				</div>
-				<?php
-				$display_authors_date = FreshRSS_Context::userConf()->show_author_date === 'f' || FreshRSS_Context::userConf()->show_author_date === 'b';
-				$display_tags = FreshRSS_Context::userConf()->show_tags === 'f' || FreshRSS_Context::userConf()->show_tags === 'b';
-
-				if ($display_authors_date || $display_tags) {
-					?>
-					<footer>
-						<?php if ($display_authors_date) { ?>
-							<div class="subtitle">
-								<?php if (FreshRSS_Context::userConf()->show_feed_name === 'a') { ?>
-									<div class="website"><a href="<?= _url('index', 'reader', 'get', 'f_' . $feed->id()) ?>" title="<?= _t('gen.action.filter') ?>">
-										<?php if (FreshRSS_Context::userConf()->show_favicons): ?>
-											<img class="favicon" src="<?= $feed->favicon() ?>" alt="✇" loading="lazy" /><?php
-										endif; ?><span><?= $feed->name() ?></span></a></div>
-								<?php } ?>
-								<div class="author"><?php
-									$authors = $item->authors();
-									if (is_array($authors)) {
-										foreach ($authors as $author) {
-											?>
-											<a href="<?= Minz_Url::display(Minz_Request::modifiedCurrentRequest(['search' => 'author:' . str_replace(' ', '+', htmlspecialchars_decode($author, ENT_QUOTES))])) ?>">
-												<?= $author ?>
-											</a>
-											<?php
-										}
-									}
-									?>
-								</div>
-								<div class="date">
-									<time datetime="<?= $item->machineReadableDate() ?>"><?= $item->date() ?></time>
-								</div>
-							</div>
-							<?php
-						}
-
-						if ($display_tags) { ?>
-							<div class="tags">
-								<?php
-								if (!empty($tags)) {
-									?><?= _i('tag') ?><ul class="list-tags"><?php
-									foreach ($firstTags as $tag) {
-										?><li class="item tag"><a class="link-tag" href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>">#<?= $tag ?></a></li><?php
-									}
-
-									if (!empty($remainingTags)) { // more than 7 tags: show dropdown menu ?>
-										<li class="item tag">
-											<div class="dropdown">
-												<div id="dropdown-tags2-<?= $this->entry->id() ?>" class="dropdown-target"></div>
-												<a class="dropdown-toggle" href="#dropdown-tags2-<?= $this->entry->id() ?>"><?= _i('down') ?></a>
-												<ul class="dropdown-menu">
-													<li class="dropdown-header"><?= _t('index.tag.related') ?></li>
-													<?php
-													foreach ($remainingTags as $tag) {
-														?><li class="item"><a href="<?= _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))) ?>" title="<?= _t('gen.action.filter') ?>"><?= $tag ?></a></li><?php
-													} ?>
-												</ul>
-												<a class="dropdown-close" href="#close">❌</a>
-											</div>
-										</li>
-										<?php
-									} ?>
-									</ul><?php
-								} ?>
-							</div>
-						<?php } ?>
-					</footer>
-					<?php
-				} ?>
-			</div>
-		</article>
+		$this->feed = FreshRSS_Category::findFeed($this->categories, $entry->feedId()) ?? $entry->feed() ?? FreshRSS_Feed::default();
+	?><div class="flux<?= !$entry->isRead() ? ' not_read' : '' ?><?= $entry->isFavorite() ? ' favorite' : '' ?>" id="flux_<?= $entry->id() ?>" data-priority="<?= $this->feed->priority() ?>">
+		<?php $this->renderHelper('index/article'); ?>
 	</div><?php
 	endforeach;
 

+ 2 - 4
app/views/index/rss.phtml

@@ -8,14 +8,12 @@
 >
 	<channel>
 		<title><?= $this->rss_title ?></title>
-		<link><?= $this->internal_rendering ? htmlspecialchars($this->rss_url, ENT_NOQUOTES, 'UTF-8') : Minz_Url::display('', 'html', true) ?></link>
+		<link><?= $this->html_url ?></link>
 		<description><?= _t('index.feed.rss_of', $this->rss_title) ?></description>
 		<pubDate><?= date('D, d M Y H:i:s O') ?></pubDate>
 		<lastBuildDate><?= gmdate('D, d M Y H:i:s') ?> GMT</lastBuildDate>
-		<atom:link href="<?= $this->internal_rendering ? htmlspecialchars($this->rss_url, ENT_COMPAT, 'UTF-8') :
-			Minz_Url::display($this->rss_url, 'html', true) ?>" rel="self" type="application/rss+xml" />
+		<atom:link href="<?= $this->rss_url ?>" rel="self" type="application/rss+xml" />
 <?php
-/** @var FreshRSS_Entry */
 foreach ($this->entries as $item) {
 	if (!$this->internal_rendering) {
 		/** @var FreshRSS_Entry */

+ 1 - 0
app/views/user/profile.phtml

@@ -62,6 +62,7 @@
 				<p class="help"><?= _i('help') ?> <?= _t('admin.auth.token_help') ?></p>
 				<kbd><?= Minz_Url::display(array('a' => 'rss', 'params' => array('user' => Minz_User::name(),
 					'token' => $token, 'hours' => FreshRSS_Context::userConf()->since_hours_posts_per_rss)), 'html', true) ?></kbd>
+				<p class="help"><?= _i('help') ?> <?= _t('conf.query.help') ?></a></p>
 			</div>
 		</div>
 		<?php } ?>

+ 3 - 3
config-user.default.php

@@ -36,10 +36,10 @@ return array (
 	'auto_load_more' => true,
 	'display_posts' => false,
 	'display_categories' => 'active',	//{ active, remember, all, none }
-	'show_tags' => '0',
+	'show_tags' => 'f',	// {0 => none, b => both, f => footer, h => header}
 	'show_tags_max' => 7,
-	'show_author_date' => 'h',
-	'show_feed_name' => 'a',
+	'show_author_date' => 'h',	// {0 => none, b => both, f => footer, h => header}
+	'show_feed_name' => 'a',	// {0 => none, a => with authors, t => above title}
 	'hide_read_feeds' => true,
 	'onread_jump_next' => true,
 	'lazyload' => true,

binární
docs/en/img/users/user-query-share.png


+ 1 - 0
docs/en/users/03_Main_view.md

@@ -38,3 +38,4 @@ Reader view will display a feed will all articles already open for reading. Feed
 Read more:
 * [Refreshing the feeds](./09_refreshing_feeds.md)
 * [Filter the feeds and search](./10_filter.md)
+* [User queries](./user_queries.md)

+ 1 - 4
docs/en/users/05_Configuration.md

@@ -167,10 +167,7 @@ This means that if you assign a shortcut to more than one action, you’ll end u
 
 # User queries
 
-You can configure your [user queries](./03_Main_view.md) in that section. There is not much to say here as it is pretty straightforward.
-You can only change user query titles or drop them.
-
-At the moment, there is no helper to build a user query from here.
+You can configure your [user queries](./user_queries.md) in that section.
 
 # Profile
 

+ 6 - 22
docs/en/users/10_filter.md

@@ -119,34 +119,18 @@ Finally, parentheses may be used to express more complex queries, with basic neg
 
 You can change the sort order by clicking the toggle button available in the header.
 
-## Store your filters
+## Bookmark the current query
 
-Once you came up with your perfect filter, it would be a shame if you need to recreate it every time you need to use it.
+Once you came up with your perfect filter, it would be a shame if you had to recreate it every time you need to use it.
 
-Hopefully, there is a way to bookmark them for later use.
-We call them *user queries*.
+Luckily, there is a way to bookmark them for later use.
+We call them [*user queries*](./user_queries.md).
 You can create as many as you want, the only limit is how they will be displayed on your screen.
 
-### Bookmark the current query
-
-Display the user queries drop-down by clicking the button next to the state buttons.
-![User queries drop-down](../img/users/user.queries.drop-down.empty.png)
-
-Then click on the bookmark action.
-
-Congratulations, you’re done!
-
-### Using a bookmarked query
-
-Display the user queries drop-down by clicking the button next to the state buttons.
-![User queries drop-down](../img/users/user.queries.drop-down.not.empty.png)
-
-Then click on the bookmarked query, the previously stored query will be applied.
-
-> Note that only the query is stored, not the articles.
-> The results you are seeing now could be different from the results on the day you've created the query.
+Read more about [*user queries*](./user_queries.md) to learn how to create them, use them, and even reshare them via HTML / RSS / OPML.
 
 ---
 Read more:
 * [Normal, Global and Reader view](./03_Main_view.md)
 * [Refreshing the feeds](./09_refreshing_feeds.md)
+* [User queries](./user_queries.md)

+ 63 - 0
docs/en/users/user_queries.md

@@ -0,0 +1,63 @@
+# User queries
+
+*User queries* are a way to store any FreshRSS search query.
+
+Read about [the filters](./10_filter.md) to learn the different ways to search and filter
+articles in FreshRSS.
+
+## Bookmark the current query
+
+Once you have a search query with a filter, it can be saved.
+
+To do so, display the user queries drop-down menu by clicking the button next to the state buttons:
+
+![User queries drop-down](../img/users/user.queries.drop-down.empty.png)
+
+Then click on the bookmark action.
+
+## Using a bookmarked query
+
+Display the user queries drop-down menu by clicking the button next to the state buttons:
+
+![User queries drop-down](../img/users/user.queries.drop-down.not.empty.png)
+
+Then click on the bookmarked query, the previously stored query will be applied.
+
+> ℹ️ Note that only the search query is stored, not the articles.
+> So the results you are seeing one day might be different another day.
+
+## Share your user queries
+
+A prerequisite is that the FreshRSS API(s) must be enabled in FreshRSS authentication settings.
+
+From the configuration page of the user queries,
+it is possible to share the output of the user queries with external users,
+in the formats HTML, RSS, and OPML:
+
+![Share user query](../img/users/user-query-share.png)
+
+> ℹ️ Note that the sharing as OPML is only available for user queries based on all feeds, a category, or a feed.
+> Sharing by OPML is **not** available for queries based on user labels or favourites or important feeds,
+> to avoid leaking some feed details in an unintended manner.
+
+### Additional parameters for shared user queries
+
+Some parameters can be manually added to the URL:
+
+* `f`: Format of output. Can be `html`, `rss` (`atom` is an alias), or `opml`.
+* `hours`: Show only the articles newer than this number of hours.
+* `nb`: Number of articles to return. Limited by `max_posts_per_rss` in the user configuration. Can be used in combination with `offset` for pagination.
+* `offset`: Skip a number of articles. Used in particular by the HTML view for pagination.
+* `order`: Show the newest articles at the top with `DESC`, or the oldest articles at the top with `ASC`. By default, will use the sort order defined by the user query.
+
+## Sharing with a master token (deprecated)
+
+Before FreshRSS 1.24, the only option to reshare an RSS output was by using a master token,
+like `https://freshrss.example.net/?a=rss&user=alice&token1234`
+
+This was mostly intended for sharing between systems controlled by the same user, and not for sharing publicly.
+
+This method **is not advised anymore** as it is not safe to use the same master token for multiple outputs,
+especially not when shared with other persons.
+
+Now, sharing RSS outputs via user queries is the recommended approach for all scenarios.

+ 3 - 2
lib/Minz/Request.php

@@ -162,11 +162,11 @@ class Minz_Request {
 	 * Setteurs
 	 */
 	public static function _controllerName(string $controller_name): void {
-		self::$controller_name = $controller_name;
+		self::$controller_name = ctype_alnum($controller_name) ? $controller_name : '';
 	}
 
 	public static function _actionName(string $action_name): void {
-		self::$action_name = $action_name;
+		self::$action_name = ctype_alnum($action_name) ? $action_name : '';
 	}
 
 	/** @param array<string,string> $params */
@@ -187,6 +187,7 @@ class Minz_Request {
 	 * Initialise la Request
 	 */
 	public static function init(): void {
+		self::_params($_GET);
 		self::initJSON();
 	}
 

+ 3 - 3
p/api/greader.php

@@ -572,7 +572,7 @@ final class GReaderAPI {
 				continue;
 			}
 
-			$feed = FreshRSS_CategoryDAO::findFeed($categories, $entry->feedId());
+			$feed = FreshRSS_Category::findFeed($categories, $entry->feedId());
 			if ($feed === null) {
 				continue;
 			}
@@ -694,7 +694,7 @@ final class GReaderAPI {
 		}
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
-		$entries = $entryDAO->listWhere($type, $include_target, $state, $order === 'o' ? 'ASC' : 'DESC', $count, $continuation, $searches);
+		$entries = $entryDAO->listWhere($type, $include_target, $state, $order === 'o' ? 'ASC' : 'DESC', $count, 0, $continuation, $searches);
 		$entries = iterator_to_array($entries);	//TODO: Improve
 
 		$items = self::entriesToArray($entries);
@@ -746,7 +746,7 @@ final class GReaderAPI {
 		}
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
-		$ids = $entryDAO->listIdsWhere($type, $id, $state, $order === 'o' ? 'ASC' : 'DESC', $count, $continuation, $searches);
+		$ids = $entryDAO->listIdsWhere($type, $id, $state, $order === 'o' ? 'ASC' : 'DESC', $count, 0, $continuation, $searches);
 		if ($ids === null) {
 			self::internalServerError();
 		}

+ 175 - 0
p/api/query.php

@@ -0,0 +1,175 @@
+<?php
+declare(strict_types=1);
+require(__DIR__ . '/../../constants.php');
+require(LIB_PATH . '/lib_rss.php');	//Includes class autoloader
+
+Minz_Request::init();
+
+$token = Minz_Request::paramString('t');
+if (!ctype_alnum($token)) {
+	header('HTTP/1.1 422 Unprocessable Entity');
+	header('Content-Type: text/plain; charset=UTF-8');
+	die('Invalid token `t`!' . $token);
+}
+
+$format = Minz_Request::paramString('f');
+if (!in_array($format, ['atom', 'html', 'opml', 'rss'], true)) {
+	header('HTTP/1.1 422 Unprocessable Entity');
+	header('Content-Type: text/plain; charset=UTF-8');
+	die('Invalid format `f`!');
+}
+
+$user = Minz_Request::paramString('user');
+if (!FreshRSS_user_Controller::checkUsername($user)) {
+	header('HTTP/1.1 422 Unprocessable Entity');
+	header('Content-Type: text/plain; charset=UTF-8');
+	die('Invalid user!');
+}
+
+Minz_Session::init('FreshRSS', true);
+
+FreshRSS_Context::initSystem();
+if (!FreshRSS_Context::hasSystemConf() || !FreshRSS_Context::systemConf()->api_enabled) {
+	header('HTTP/1.1 503 Service Unavailable');
+	header('Content-Type: text/plain; charset=UTF-8');
+	die('Service Unavailable!');
+}
+
+FreshRSS_Context::initUser($user);
+if (!FreshRSS_Context::hasUserConf()) {
+	usleep(rand(100, 10000));	//Primitive mitigation of scanning for users
+	header('HTTP/1.1 404 Not Found');
+	header('Content-Type: text/plain; charset=UTF-8');
+	die('User not found!');
+} else {
+	usleep(rand(20, 200));
+}
+
+if (!file_exists(DATA_PATH . '/no-cache.txt')) {
+	require(LIB_PATH . '/http-conditional.php');
+	// TODO: Consider taking advantage of $feedMode, only for monotonous queries {all, categories, feeds} and not dynamic ones {read/unread, favourites, user labels}
+	if (httpConditional(FreshRSS_UserDAO::mtime($user) ?: time(), 0, 0, false, PHP_COMPRESSION, false)) {
+		exit();	//No need to send anything
+	}
+}
+
+Minz_Translate::init(FreshRSS_Context::userConf()->language);
+Minz_ExtensionManager::init();
+Minz_ExtensionManager::enableByList(FreshRSS_Context::userConf()->extensions_enabled, 'user');
+
+$query = null;
+$userSearch = null;
+foreach (FreshRSS_Context::userConf()->queries as $raw_query) {
+	if (!empty($raw_query['token']) && $raw_query['token'] === $token) {
+		switch ($format) {
+			case 'atom':
+			case 'html':
+			case 'rss':
+				if (empty($raw_query['shareRss'])) {
+					continue 2;
+				}
+				break;
+			case 'opml':
+				if (empty($raw_query['shareOpml'])) {
+					continue 2;
+				}
+				break;
+			default:
+				continue 2;
+		}
+		$query = new FreshRSS_UserQuery($raw_query, FreshRSS_Context::categories(), FreshRSS_Context::labels());
+		Minz_Request::_param('get', $query->getGet());
+		if (Minz_Request::paramString('order') === '') {
+			Minz_Request::_param('order', $query->getOrder());
+		}
+		Minz_Request::_param('state', $query->getState());
+
+		$search = $query->getSearch()->getRawInput();
+		// Note: we disallow references to user queries in public user search to avoid sniffing internal user queries
+		$userSearch = new FreshRSS_BooleanSearch(Minz_Request::paramString('search'), 0, 'AND', false);
+		if ($userSearch->getRawInput() !== '') {
+			if ($search === '') {
+				$search = $userSearch->getRawInput();
+			} else {
+				$search .= ' (' . $userSearch->getRawInput() . ')';
+			}
+		}
+		Minz_Request::_param('search', $search);
+		break;
+	}
+}
+if ($query === null || $userSearch === null) {
+	usleep(rand(100, 10000));
+	header('HTTP/1.1 404 Not Found');
+	header('Content-Type: text/plain; charset=UTF-8');
+	die('User query not found!');
+}
+
+$view = new FreshRSS_View();
+
+try {
+	FreshRSS_Context::updateUsingRequest(false);
+	Minz_Request::_param('search', $userSearch->getRawInput());	// Restore user search
+	$view->entries = FreshRSS_index_Controller::listEntriesByContext();
+} catch (Minz_Exception $e) {
+	Minz_Error::error(400, 'Bad user query!');
+	die();
+}
+
+$get = FreshRSS_Context::currentGet(true);
+$type = (string)$get[0];
+$id = (int)$get[1];
+
+switch ($type) {
+	case 'c':	// Category
+		$cat = FreshRSS_Context::categories()[$id] ?? null;
+		if ($cat === null) {
+			Minz_Error::error(404, "Category {$id} not found!");
+			die();
+		}
+		$view->categories = [ $cat->id() => $cat ];
+		break;
+	case 'f':	// Feed
+		$feed = FreshRSS_Category::findFeed(FreshRSS_Context::categories(), $id);
+		if ($feed === null) {
+			Minz_Error::error(404, "Feed {$id} not found!");
+			die();
+		}
+		$view->feeds = [ $feed->id() => $feed ];
+		$view->categories = [];
+		break;
+	default:
+		$view->categories = FreshRSS_Context::categories();
+		break;
+}
+
+$view->disable_aside = true;
+$view->excludeMutedFeeds = true;
+$view->internal_rendering = true;
+$view->userQuery = $query;
+$view->html_url = $query->sharedUrlHtml();
+$view->rss_url = $query->sharedUrlRss();
+$view->rss_title = $query->getName();
+if ($query->getName() != '') {
+	FreshRSS_View::_title($query->getName());
+}
+FreshRSS_Context::systemConf()->allow_anonymous = true;
+
+if (in_array($format, ['rss', 'atom'], true)) {
+	header('Content-Type: application/rss+xml; charset=utf-8');
+	$view->_layout(null);
+	$view->_path('index/rss.phtml');
+} elseif ($format === 'opml') {
+	if (!$query->safeForOpml()) {
+		Minz_Error::error(404, 'OPML not allowed for this user query!');
+		die();
+	}
+	header('Content-Type: application/xml; charset=utf-8');
+	$view->_layout(null);
+	$view->_path('index/opml.phtml');
+} else {
+	$view->_layout('layout');
+	$view->_path('index/html.phtml');
+}
+
+$view->build();

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů