فهرست منبع

Implement custom feed favicons (#7646)

Closes #3789, #6503

Icon setting when no custom icon is set yet:

![image](https://github.com/user-attachments/assets/28b07dd0-7dac-4c76-b1d7-77035f91a87a)

- `Change...` button opens a file dialog, and after selecting a file shows the chosen icon in the preview on the left. `Submit` must be clicked after selecting the icon.
- `Reset to default` changes the preview icon to the default one, and also requires `Submit` to be clicked to apply the changes.

Full list of changes:
- CSP now includes `blob:` in `img-src` for
   - `indexAction()` and `feedAction()` in `subscriptionController.php`
   - all of the view actions in `indexController.php`
- Introduce new attribute `customFavicon (boolean)` for feeds that indicates if the feed has a custom favicon
   - `hashFavicon()` in `Feed.php` is dependent on this attribute
      - `hashFavicon()` has a new parameter called `skipCache (boolean)` that allows the reset of the favicon hash for the Feed object
      - `resetFaviconHash()` just calls `hashFavicon(skipCache: true)`
- `f.php` URLs now have the format of `/f.php?h=XXXXX&t=cachebuster`, where the `t` parameter is only used for serving custom favicons
   - if `t` parameter is set, `f.php` returns a `Cache-Control: immutable` header
- `stripos` and `strpos` were changed to `str_contains` in various places (refactor)
- JS for handling the custom favicon configuration logic is in `extra.js` inside `init_update_feed()` which is called when feed configuration is opened from the aside or when the subscription management page with the feed is loaded
   - Server-side code for uploading the icon in `subscriptionController.php` under `feedAction()`
   - Errors that may occur during the setting of a custom favicon:
      - Unsupported image file type (handled only server-side with `isImgMime()`)
      - When the file is bigger than 1 MiB (default), handled both client-side and server-side
      - Standard feed error when `updateFeed()` fails
- JS vars `javascript_vars.phtml` are no longer escaped with `htmlspecialchars()`, instead with json encoding,
- CSS for disabled buttons was added
- Max favicon file size is configurable with the `max_favicon_upload_size` option in `config.php` (not exposed via UI)
- Custom favicons are currently deleted only when they are either reset to the default icon, or the feed gets deleted. They do not get deleted when the user deletes their account without removing their feeds first.
- ` faviconPrepare()` and `faviconRebuild()` are not allowed to be called when the `customFavicon` attribute is `true`
- New i18n strings:
   - `'sub.feed.icon' => 'Icon'`
   - `'sub.feed.change_favicon' => 'Change…'`
   - `'sub.feed.reset_favicon' => 'Reset to default'`
   - `'sub.feed.favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.'`
   - `'feedback.sub.feed.favicon.too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.'`
   - `'feedback.sub.feed.favicon.unsupported_format' => 'Unsupported image file format!'`
- Extension hook `custom_favicon_hash`
   - `setCustomFavicon()` method
   - `resetCustomFavicon()` method
   - `customFaviconExt` and `customFaviconDisallowDel` attributes
   - example of usage: https://github.com/FreshRSS/Extensions/pull/337
- Extension hook `custom_favicon_btn_url`
   - Allows extensions to implement a button for setting a custom favicon for individual feeds by providing an URL. The URL will be sent a POST request with the `extAction` field set to either `query_icon_info` or `update_icon`, along with an `id` field which describes the feed's ID.
Inverle 9 ماه پیش
والد
کامیت
7915abd833
74فایلهای تغییر یافته به همراه788 افزوده شده و 46 حذف شده
  1. 9 1
      app/Controllers/feedController.php
  2. 2 2
      app/Controllers/indexController.php
  3. 43 1
      app/Controllers/subscriptionController.php
  4. 6 0
      app/Exceptions/UnsupportedImageFormatException.php
  5. 2 2
      app/Models/CategoryDAO.php
  6. 3 3
      app/Models/EntryDAO.php
  7. 2 2
      app/Models/EntryDAOPGSQL.php
  8. 172 15
      app/Models/Feed.php
  9. 2 2
      app/Models/FeedDAO.php
  10. 4 0
      app/i18n/cs/feedback.php
  11. 5 0
      app/i18n/cs/sub.php
  12. 4 0
      app/i18n/de/feedback.php
  13. 5 0
      app/i18n/de/sub.php
  14. 4 0
      app/i18n/el/feedback.php
  15. 5 0
      app/i18n/el/sub.php
  16. 4 0
      app/i18n/en-us/feedback.php
  17. 5 0
      app/i18n/en-us/sub.php
  18. 4 0
      app/i18n/en/feedback.php
  19. 5 0
      app/i18n/en/sub.php
  20. 4 0
      app/i18n/es/feedback.php
  21. 5 0
      app/i18n/es/sub.php
  22. 4 0
      app/i18n/fa/feedback.php
  23. 5 0
      app/i18n/fa/sub.php
  24. 4 0
      app/i18n/fi/feedback.php
  25. 5 0
      app/i18n/fi/sub.php
  26. 4 0
      app/i18n/fr/feedback.php
  27. 5 0
      app/i18n/fr/sub.php
  28. 4 0
      app/i18n/he/feedback.php
  29. 5 0
      app/i18n/he/sub.php
  30. 4 0
      app/i18n/hu/feedback.php
  31. 5 0
      app/i18n/hu/sub.php
  32. 4 0
      app/i18n/id/feedback.php
  33. 5 0
      app/i18n/id/sub.php
  34. 4 0
      app/i18n/it/feedback.php
  35. 5 0
      app/i18n/it/sub.php
  36. 4 0
      app/i18n/ja/feedback.php
  37. 5 0
      app/i18n/ja/sub.php
  38. 4 0
      app/i18n/ko/feedback.php
  39. 5 0
      app/i18n/ko/sub.php
  40. 4 0
      app/i18n/lv/feedback.php
  41. 5 0
      app/i18n/lv/sub.php
  42. 4 0
      app/i18n/nl/feedback.php
  43. 5 0
      app/i18n/nl/sub.php
  44. 4 0
      app/i18n/oc/feedback.php
  45. 5 0
      app/i18n/oc/sub.php
  46. 4 0
      app/i18n/pl/feedback.php
  47. 5 0
      app/i18n/pl/sub.php
  48. 4 0
      app/i18n/pt-br/feedback.php
  49. 5 0
      app/i18n/pt-br/sub.php
  50. 4 0
      app/i18n/pt-pt/feedback.php
  51. 5 0
      app/i18n/pt-pt/sub.php
  52. 4 0
      app/i18n/ru/feedback.php
  53. 5 0
      app/i18n/ru/sub.php
  54. 4 0
      app/i18n/sk/feedback.php
  55. 5 0
      app/i18n/sk/sub.php
  56. 4 0
      app/i18n/tr/feedback.php
  57. 5 0
      app/i18n/tr/sub.php
  58. 4 0
      app/i18n/zh-cn/feedback.php
  59. 5 0
      app/i18n/zh-cn/sub.php
  60. 4 0
      app/i18n/zh-tw/feedback.php
  61. 5 0
      app/i18n/zh-tw/sub.php
  62. 30 2
      app/views/helpers/feed/update.phtml
  63. 4 2
      app/views/helpers/javascript_vars.phtml
  64. 3 0
      config.default.php
  65. 6 0
      docs/en/developers/03_Backend/05_Extensions.md
  66. 21 1
      lib/Minz/ExtensionManager.php
  67. 4 4
      lib/favicons.php
  68. 4 1
      p/f.php
  69. 146 0
      p/scripts/extra.js
  70. 1 6
      p/scripts/main.js
  71. 1 1
      p/themes/Origine/origine.css
  72. 1 1
      p/themes/Origine/origine.rtl.css
  73. 46 0
      p/themes/base-theme/frss.css
  74. 46 0
      p/themes/base-theme/frss.rtl.css

+ 9 - 1
app/Controllers/feedController.php

@@ -998,8 +998,16 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 	public static function deleteFeed(int $feed_id): bool {
 	public static function deleteFeed(int $feed_id): bool {
 		FreshRSS_UserDAO::touch();
 		FreshRSS_UserDAO::touch();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
+		$feed = $feedDAO->searchById($feed_id);
+		if ($feed === null) {
+			return false;
+		}
+
 		if ($feedDAO->deleteFeed($feed_id)) {
 		if ($feedDAO->deleteFeed($feed_id)) {
-			// TODO: Delete old favicon
+			// TODO: Delete old favicon (non-custom)
+			if ($feed->customFavicon() && !$feed->attributeBoolean('customFaviconDisallowDel')) {
+				FreshRSS_Feed::faviconDelete($feed->hashFavicon());
+			}
 
 
 			// Remove related queries
 			// Remove related queries
 			$queries = remove_query_by_get('f_' . $feed_id, FreshRSS_Context::userConf()->queries);
 			$queries = remove_query_by_get('f_' . $feed_id, FreshRSS_Context::userConf()->queries);

+ 2 - 2
app/Controllers/indexController.php

@@ -57,7 +57,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		$this->_csp([
 		$this->_csp([
 			'default-src' => "'self'",
 			'default-src' => "'self'",
 			'frame-src' => '*',
 			'frame-src' => '*',
-			'img-src' => '* data:',
+			'img-src' => '* data: blob:',
 			'frame-ancestors' => "'none'",
 			'frame-ancestors' => "'none'",
 			'media-src' => '*',
 			'media-src' => '*',
 		]);
 		]);
@@ -146,7 +146,7 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 		$this->_csp([
 		$this->_csp([
 			'default-src' => "'self'",
 			'default-src' => "'self'",
 			'frame-src' => '*',
 			'frame-src' => '*',
-			'img-src' => '* data:',
+			'img-src' => '* data: blob:',
 			'frame-ancestors' => "'none'",
 			'frame-ancestors' => "'none'",
 			'media-src' => '*',
 			'media-src' => '*',
 		]);
 		]);

+ 43 - 1
app/Controllers/subscriptionController.php

@@ -46,6 +46,12 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
 		FreshRSS_View::prependTitle(_t('sub.title') . ' · ');
 		FreshRSS_View::prependTitle(_t('sub.title') . ' · ');
 
 
+		$this->_csp([
+			'default-src' => "'self'",
+			'frame-ancestors' => "'none'",
+			'img-src' => "'self' blob:",
+		]);
+
 		$this->view->onlyFeedsWithError = Minz_Request::paramBoolean('error');
 		$this->view->onlyFeedsWithError = Minz_Request::paramBoolean('error');
 
 
 		$id = Minz_Request::paramInt('id');
 		$id = Minz_Request::paramInt('id');
@@ -80,6 +86,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 	 *   - feed URL
 	 *   - feed URL
 	 *   - category id (default: default category id)
 	 *   - category id (default: default category id)
 	 *   - CSS path to article on website
 	 *   - CSS path to article on website
+	 *   - favicon
 	 *   - display in main stream (default: 0)
 	 *   - display in main stream (default: 0)
 	 *   - HTTP authentication
 	 *   - HTTP authentication
 	 *   - number of article to retain (default: -2)
 	 *   - number of article to retain (default: -2)
@@ -109,6 +116,12 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 
 
 		FreshRSS_View::prependTitle($feed->name() . ' · ' . _t('sub.title.feed_management') . ' · ');
 		FreshRSS_View::prependTitle($feed->name() . ' · ' . _t('sub.title.feed_management') . ' · ');
 
 
+		$this->_csp([
+			'default-src' => "'self'",
+			'frame-ancestors' => "'none'",
+			'img-src' => "'self' blob:",
+		]);
+
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
 			$unicityCriteria = Minz_Request::paramString('unicityCriteria');
 			$unicityCriteria = Minz_Request::paramString('unicityCriteria');
 			if (in_array($unicityCriteria, ['id', '', null], strict: true)) {
 			if (in_array($unicityCriteria, ['id', '', null], strict: true)) {
@@ -309,6 +322,18 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 			$feed->_attribute('path_entries_conditions', empty($conditions) ? null : $conditions);
 			$feed->_attribute('path_entries_conditions', empty($conditions) ? null : $conditions);
 			$feed->_attribute('path_entries_filter', Minz_Request::paramString('path_entries_filter', true));
 			$feed->_attribute('path_entries_filter', Minz_Request::paramString('path_entries_filter', true));
 
 
+			// @phpstan-ignore offsetAccess.nonOffsetAccessible
+			$favicon_path = isset($_FILES['newFavicon']) ? $_FILES['newFavicon']['tmp_name'] : '';
+			// @phpstan-ignore offsetAccess.nonOffsetAccessible
+			$favicon_size = isset($_FILES['newFavicon']) ? $_FILES['newFavicon']['size'] : 0;
+
+			$favicon_uploaded = $favicon_path !== '';
+
+			$resetFavicon = Minz_Request::paramBoolean('resetFavicon');
+			if ($resetFavicon) {
+				$feed->resetCustomFavicon();
+			}
+
 			$values = [
 			$values = [
 				'name' => Minz_Request::paramString('name'),
 				'name' => Minz_Request::paramString('name'),
 				'kind' => $feed->kind(),
 				'kind' => $feed->kind(),
@@ -343,7 +368,24 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 					$url_redirect = ['c' => 'subscription', 'params' => ['id' => $id]];
 					$url_redirect = ['c' => 'subscription', 'params' => ['id' => $id]];
 			}
 			}
 
 
-			if ($values['url'] != '' && $feedDAO->updateFeed($id, $values) !== false) {
+			if ($favicon_uploaded && !$resetFavicon) {
+				require_once(LIB_PATH . '/favicons.php');
+				$max_size = FreshRSS_Context::systemConf()->limits['max_favicon_upload_size'];
+				if ($favicon_size > $max_size) {
+					Minz_Request::bad(_t('feedback.sub.feed.favicon.too_large', format_bytes($max_size)), $url_redirect);
+					return;
+				}
+				try {
+					$feed->setCustomFavicon(tmpPath: is_string($favicon_path) ? $favicon_path : '', values: $values);
+				} catch (FreshRSS_UnsupportedImageFormat_Exception $_) {
+					Minz_Request::bad(_t('feedback.sub.feed.favicon.unsupported_format'), $url_redirect);
+					return;
+				} catch (FreshRSS_Feed_Exception $_) {
+					Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
+					return;
+				}
+				Minz_Request::good(_t('feedback.sub.feed.updated'), $url_redirect);
+			} elseif ($values['url'] != '' && $feedDAO->updateFeed($id, $values) !== false) {
 				$feed->_categoryId($values['category']);
 				$feed->_categoryId($values['category']);
 				// update url and website values for faviconPrepare
 				// update url and website values for faviconPrepare
 				$feed->_url($values['url'], false);
 				$feed->_url($values['url'], false);

+ 6 - 0
app/Exceptions/UnsupportedImageFormatException.php

@@ -0,0 +1,6 @@
+<?php
+declare(strict_types=1);
+
+class FreshRSS_UnsupportedImageFormat_Exception extends Minz_Exception {
+
+}

+ 2 - 2
app/Models/CategoryDAO.php

@@ -89,9 +89,9 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	protected function autoUpdateDb(array $errorInfo): bool {
 	protected function autoUpdateDb(array $errorInfo): bool {
 		if (isset($errorInfo[0])) {
 		if (isset($errorInfo[0])) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
-				$errorLines = explode("\n", (string)$errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
+				$errorLines = explode("\n", $errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
 				foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
 				foreach (['kind', 'lastUpdate', 'error', 'attributes'] as $column) {
-					if (stripos($errorLines[0], $column) !== false) {
+					if (str_contains($errorLines[0], $column)) {
 						return $this->addColumn($column);
 						return $this->addColumn($column);
 					}
 					}
 				}
 				}

+ 3 - 3
app/Models/EntryDAO.php

@@ -123,9 +123,9 @@ SQL;
 	protected function autoUpdateDb(array $errorInfo): bool {
 	protected function autoUpdateDb(array $errorInfo): bool {
 		if (isset($errorInfo[0])) {
 		if (isset($errorInfo[0])) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
-				$errorLines = explode("\n", (string)$errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
+				$errorLines = explode("\n", $errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
 				foreach (['attributes'] as $column) {
 				foreach (['attributes'] as $column) {
-					if (stripos($errorLines[0], $column) !== false) {
+					if (str_contains($errorLines[0], $column)) {
 						return $this->addColumn($column);
 						return $this->addColumn($column);
 					}
 					}
 				}
 				}
@@ -134,7 +134,7 @@ SQL;
 		if (isset($errorInfo[1])) {
 		if (isset($errorInfo[1])) {
 			// May be a string or an int
 			// May be a string or an int
 			if ($errorInfo[1] == FreshRSS_DatabaseDAO::ER_DATA_TOO_LONG) {
 			if ($errorInfo[1] == FreshRSS_DatabaseDAO::ER_DATA_TOO_LONG) {
-				if (stripos((string)$errorInfo[2], 'content_bin') !== false) {
+				if (str_contains($errorInfo[2], 'content_bin')) {
 					return $this->updateToMediumBlob();	//v1.15.0
 					return $this->updateToMediumBlob();	//v1.15.0
 				}
 				}
 			}
 			}

+ 2 - 2
app/Models/EntryDAOPGSQL.php

@@ -59,9 +59,9 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 	protected function autoUpdateDb(array $errorInfo): bool {
 	protected function autoUpdateDb(array $errorInfo): bool {
 		if (isset($errorInfo[0])) {
 		if (isset($errorInfo[0])) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
-				$errorLines = explode("\n", (string)$errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
+				$errorLines = explode("\n", $errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
 				foreach (['attributes'] as $column) {
 				foreach (['attributes'] as $column) {
-					if (stripos($errorLines[0], $column) !== false) {
+					if (str_contains($errorLines[0], $column)) {
 						return $this->addColumn($column);
 						return $this->addColumn($column);
 					}
 					}
 				}
 				}

+ 172 - 15
app/Models/Feed.php

@@ -92,27 +92,171 @@ class FreshRSS_Feed extends Minz_Model {
 	public function hash(): string {
 	public function hash(): string {
 		if ($this->hash == '') {
 		if ($this->hash == '') {
 			$salt = FreshRSS_Context::systemConf()->salt;
 			$salt = FreshRSS_Context::systemConf()->salt;
-			$params = $this->url;
-			$curl_params = $this->attributeArray('curl_params');
-			if (is_array($curl_params)) {
-				// Content provided through a proxy may be completely different
-				$params .= is_string($curl_params[CURLOPT_PROXY] ?? null) ? $curl_params[CURLOPT_PROXY] : '';
-			}
+			$params = $this->url . $this->proxyParam();
 			$this->hash = sha1($salt . $params);
 			$this->hash = sha1($salt . $params);
 		}
 		}
 		return $this->hash;
 		return $this->hash;
 	}
 	}
 
 
-	public function hashFavicon(): string {
-		if ($this->hashFavicon == '') {
+	public function resetFaviconHash(): void {
+		$this->hashFavicon(skipCache: true);
+	}
+
+	public function proxyParam(): string {
+		$curl_params = $this->attributeArray('curl_params');
+		if (is_array($curl_params)) {
+			// Content provided through a proxy may be completely different
+			return is_string($curl_params[CURLOPT_PROXY] ?? null) ? $curl_params[CURLOPT_PROXY] : '';
+		}
+		return '';
+	}
+
+	/**
+	* Resets the custom favicon to the default one. Also deletes the favicon when allowed by extension.
+	*
+	* @param array{'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
+	* 	'pathEntries'?:string,'httpAuth'?:string,'error'?:int,'ttl'?:int,'attributes'?:string|array<string,mixed>} &$values &$values
+	*
+	* @param bool $updateFeed Whether `updateFeed()` should be called immediately. If false, it must be handled manually.
+	*
+	* @return void
+	*
+	* @throws FreshRSS_Feed_Exception
+	*/
+	public function resetCustomFavicon(?array &$values = null, bool $updateFeed = true) {
+		if (!$this->customFavicon()) {
+			return;
+		}
+		if (!$this->attributeBoolean('customFaviconDisallowDel')) {
+			FreshRSS_Feed::faviconDelete($this->hashFavicon());
+		}
+		$this->_attribute('customFavicon', false);
+		$this->_attribute('customFaviconExt', null);
+		$this->_attribute('customFaviconDisallowDel', false);
+		if ($values !== null) {
+			$values['attributes'] = $this->attributes();
+			$feedDAO = FreshRSS_Factory::createFeedDao();
+			if ($updateFeed && !$feedDAO->updateFeed($this->id(), $values)) {
+				throw new FreshRSS_Feed_Exception();
+			}
+		}
+		$this->resetFaviconHash();
+	}
+
+	/**
+	* Set a custom favicon for the feed.
+	*
+	* @param string $contents Contents of the favicon file. Optional if $tmpPath is set.
+	* @param string $tmpPath Use only when handling file uploads. (value from `tmp_name` goes here)
+	*
+	* @param array{'url'?:string,'kind'?:int,'category'?:int,'name'?:string,'website'?:string,'description'?:string,'lastUpdate'?:int,'priority'?:int,
+	* 	'pathEntries'?:string,'httpAuth'?:string,'error'?:int,'ttl'?:int,'attributes'?:string|array<string,mixed>} &$values &$values
+	*
+	* @param bool $updateFeed Whether `updateFeed()` should be called immediately. If false, it must be handled manually.
+	* @param string $extName The extension name of the calling extension.
+	* @param bool $disallowDelete Whether the icon can be later deleted when it's being reset. Intended for use by extensions.
+	* @param bool $overrideCustomIcon Whether a custom favicon set by a user can be overridden.
+	*
+	* @return string|null Path where the favicon can be found. Useful for checking if the favicon already exists, before downloading it for example.
+	*
+	* @throws FreshRSS_UnsupportedImageFormat_Exception
+	* @throws FreshRSS_Feed_Exception
+	*/
+	public function setCustomFavicon(
+		?string $contents = null,
+		string $tmpPath = '',
+		?array &$values = null,
+		bool $updateFeed = true,
+		?string $extName = null,
+		bool $disallowDelete = false,
+		bool $overrideCustomIcon = false
+	): ?string {
+		if ($contents === null && $tmpPath !== '') {
+			$contents = file_get_contents($tmpPath);
+		}
+
+		$attributesOnly = $contents === null && $tmpPath === '';
+		if (!$attributesOnly && !isImgMime(is_string($contents) ? $contents : '')) {
+			throw new FreshRSS_UnsupportedImageFormat_Exception();
+		}
+
+		$oldHash = '';
+		$oldDisallowDelete = false;
+		if ($this->customFavicon()) {
+			/* If $overrideCustomFavicon is true, custom favicons set by extensions can be overridden,
+			 * but not ones explicitly set by the user */
+			if (!$overrideCustomIcon && $this->customFaviconExt() === null) {
+				return null;
+			}
+			$oldHash = $this->hashFavicon(skipCache: true);
+			$oldDisallowDelete = $this->attributeBoolean('customFaviconDisallowDel');
+		}
+		$this->_attribute('customFavicon', true);
+		$this->_attribute('customFaviconExt', $extName);
+		$this->_attribute('customFaviconDisallowDel', $disallowDelete);
+
+		require_once(LIB_PATH . '/favicons.php');
+		$newPath = FAVICONS_DIR . $this->hashFavicon(skipCache: true) . '.ico';
+		if ($attributesOnly && !file_exists($newPath)) {
+			$updateFeed = false;
+		}
+
+		if ($values !== null) {
+			$values['attributes'] = $this->attributes();
+			$feedDAO = FreshRSS_Factory::createFeedDao();
+			if ($updateFeed && !$feedDAO->updateFeed($this->id(), $values)) {
+				throw new FreshRSS_Feed_Exception();
+			}
+		}
+
+		if ($tmpPath !== '') {
+			move_uploaded_file($tmpPath, $newPath);
+		} elseif ($contents !== null) {
+			file_put_contents($newPath, $contents);
+		}
+
+		if ($oldHash !== '' && !$oldDisallowDelete) {
+			FreshRSS_Feed::faviconDelete($oldHash);
+		}
+
+		return $newPath;
+	}
+
+	/**
+	* Checks if the feed has a custom favicon set by an extension.
+	* Additionally, it also checks if the extension that set the icon is still enabled
+	* And if not, it resets attributes related to custom favicons.
+	*
+	* @return string|null The name of the extension that set the icon.
+	*/
+	public function customFaviconExt(): ?string {
+		$customFaviconExt = $this->attributeString('customFaviconExt');
+		if ($customFaviconExt !== null && !Minz_ExtensionManager::isExtensionEnabled($customFaviconExt)) {
+			$this->_attribute('customFavicon', false);
+			$this->_attribute('customFaviconExt', null);
+			$this->_attribute('customFaviconDisallowDel', false);
+			$customFaviconExt = null;
+		}
+		return $customFaviconExt;
+	}
+
+	public function customFavicon(): bool {
+		$this->customFaviconExt();
+		return $this->attributeBoolean('customFavicon') ?? false;
+	}
+
+	public function hashFavicon(bool $skipCache = false): string {
+		if ($this->hashFavicon == '' || $skipCache) {
 			$salt = FreshRSS_Context::systemConf()->salt;
 			$salt = FreshRSS_Context::systemConf()->salt;
-			$params = $this->website(fallback: true);
-			$curl_params = $this->attributeArray('curl_params');
-			if (is_array($curl_params)) {
-				// Content provided through a proxy may be completely different
-				$params .= is_string($curl_params[CURLOPT_PROXY] ?? null) ? $curl_params[CURLOPT_PROXY] : '';
+			$params = '';
+			if ($this->customFavicon()) {
+				$current = $this->id . Minz_User::name();
+				$hookParams = Minz_ExtensionManager::callHook('custom_favicon_hash', $this);
+				$params = $hookParams !== null ? $hookParams : $current;
+			} else {
+				$params = $this->website(fallback: true) . $this->proxyParam();
 			}
 			}
-			$this->hashFavicon = hash('crc32b', $salt . $params);
+			$this->hashFavicon = hash('crc32b', $salt . (is_string($params) ? $params : ''));
 		}
 		}
 		return $this->hashFavicon;
 		return $this->hashFavicon;
 	}
 	}
@@ -257,6 +401,9 @@ class FreshRSS_Feed extends Minz_Model {
 
 
 	public function faviconPrepare(bool $force = false): void {
 	public function faviconPrepare(bool $force = false): void {
 		require_once(LIB_PATH . '/favicons.php');
 		require_once(LIB_PATH . '/favicons.php');
+		if ($this->customFavicon()) {
+			return;
+		}
 		$url = $this->website(fallback: true);
 		$url = $this->website(fallback: true);
 		$txt = FAVICONS_DIR . $this->hashFavicon() . '.txt';
 		$txt = FAVICONS_DIR . $this->hashFavicon() . '.txt';
 		if (@file_get_contents($txt) !== $url) {
 		if (@file_get_contents($txt) !== $url) {
@@ -286,7 +433,14 @@ class FreshRSS_Feed extends Minz_Model {
 		@unlink($path . '.txt');
 		@unlink($path . '.txt');
 	}
 	}
 	public function favicon(): string {
 	public function favicon(): string {
-		return Minz_Url::display('/f.php?' . $this->hashFavicon());
+		$hash = $this->hashFavicon();
+		$url = '/f.php?h=' . $hash;
+		if ($this->customFavicon()
+			// when the below attribute is set, icon won't be changing frequently so cache buster is not needed
+			&& !$this->attributeBoolean('customFaviconDisallowDel')) {
+			$url .= '&t=' . @filemtime(DATA_PATH . '/favicons/' . $hash . '.ico');
+		}
+		return Minz_Url::display($url);
 	}
 	}
 
 
 	public function _id(int $value): void {
 	public function _id(int $value): void {
@@ -1069,6 +1223,9 @@ class FreshRSS_Feed extends Minz_Model {
 	}
 	}
 
 
 	private function faviconRebuild(): void {
 	private function faviconRebuild(): void {
+		if ($this->customFavicon()) {
+			return;
+		}
 		FreshRSS_Feed::faviconDelete($this->hashFavicon());
 		FreshRSS_Feed::faviconDelete($this->hashFavicon());
 		$this->faviconPrepare(true);
 		$this->faviconPrepare(true);
 	}
 	}

+ 2 - 2
app/Models/FeedDAO.php

@@ -22,9 +22,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 	protected function autoUpdateDb(array $errorInfo): bool {
 	protected function autoUpdateDb(array $errorInfo): bool {
 		if (isset($errorInfo[0])) {
 		if (isset($errorInfo[0])) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
 			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
-				$errorLines = explode("\n", (string)$errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
+				$errorLines = explode("\n", $errorInfo[2], 2);	// The relevant column name is on the first line, other lines are noise
 				foreach (['kind'] as $column) {
 				foreach (['kind'] as $column) {
-					if (stripos($errorLines[0], $column) !== false) {
+					if (str_contains($errorLines[0], $column)) {
 						return $this->addColumn($column);
 						return $this->addColumn($column);
 					}
 					}
 				}
 				}

+ 4 - 0
app/i18n/cs/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> mezipaměť byla vymazána',
 			'cache_cleared' => '<em>%s</em> mezipaměť byla vymazána',
 			'deleted' => 'Kanál byl odstraněn',
 			'deleted' => 'Kanál byl odstraněn',
 			'error' => 'Kanál nelze aktualizovat',
 			'error' => 'Kanál nelze aktualizovat',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Informační kanál nelze přidat. Pro podrobnosti <a href="%s">zkontrolujte protokoly FreshRSS</a>. Můžete zkusit vynucení přidání připojením <code>#force_feed</code> k adrese URL.',
 			'internal_problem' => 'Informační kanál nelze přidat. Pro podrobnosti <a href="%s">zkontrolujte protokoly FreshRSS</a>. Můžete zkusit vynucení přidání připojením <code>#force_feed</code> k adrese URL.',
 			'invalid_url' => 'Adresa URL <em>%s</em> je neplatná',
 			'invalid_url' => 'Adresa URL <em>%s</em> je neplatná',
 			'n_actualized' => '%d kanálů bylo aktualizováno',
 			'n_actualized' => '%d kanálů bylo aktualizováno',

+ 5 - 0
app/i18n/cs/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP heslo',
 			'password' => 'HTTP heslo',
 			'username' => 'HTTP uživatelské jméno',
 			'username' => 'HTTP uživatelské jméno',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Vždy vymazat mezipaměť',
 		'clear_cache' => 'Vždy vymazat mezipaměť',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Akce obsahu při načítání obsahu článku',
 			'_' => 'Akce obsahu při načítání obsahu článku',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'Soubor XML (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'help' => 'Soubor XML (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'label' => 'Exportovat jako OPML',
 			'label' => 'Exportovat jako OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Akce filtrování',
 			'_' => 'Akce filtrování',
 			'help' => 'Zapište jeden filtr hledání na řádek. Operátoři <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">viz dokumentace</a>.',
 			'help' => 'Zapište jeden filtr hledání na řádek. Operátoři <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">viz dokumentace</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informace',
 		'information' => 'Informace',
 		'keep_min' => 'Minimální počet článků pro ponechání',
 		'keep_min' => 'Minimální počet článků pro ponechání',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Nastavete proxy pro načítání tohoto kanálu',
 		'proxy' => 'Nastavete proxy pro načítání tohoto kanálu',
 		'proxy_help' => 'Vyberte protokol (např.: SOCKS5) a zadejte adresu proxy (např.: <kbd>127.0.0.1:1080</kbd> nebo <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Vyberte protokol (např.: SOCKS5) a zadejte adresu proxy (např.: <kbd>127.0.0.1:1080</kbd> nebo <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Zobrazit zdrojový kód',
 			'show_raw' => 'Zobrazit zdrojový kód',
 			'show_rendered' => 'Zobrazit obsah',
 			'show_rendered' => 'Zobrazit obsah',

+ 4 - 0
app/i18n/de/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> Zwischenspeicher wurde geleert',
 			'cache_cleared' => '<em>%s</em> Zwischenspeicher wurde geleert',
 			'deleted' => 'Der Feed ist gelöscht worden',
 			'deleted' => 'Der Feed ist gelöscht worden',
 			'error' => 'Der Feed kann nicht aktualisiert werden',
 			'error' => 'Der Feed kann nicht aktualisiert werden',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Der RSS-Feed konnte nicht hinzugefügt werden. Für Details <a href="%s">prüfen Sie die FreshRSS-Protokolle</a>. Mit <code>#force_feed</code> am Ende der Feed-URL kann das Hinzufügen erzwungen werden.',
 			'internal_problem' => 'Der RSS-Feed konnte nicht hinzugefügt werden. Für Details <a href="%s">prüfen Sie die FreshRSS-Protokolle</a>. Mit <code>#force_feed</code> am Ende der Feed-URL kann das Hinzufügen erzwungen werden.',
 			'invalid_url' => 'Die URL <em>%s</em> ist ungültig',
 			'invalid_url' => 'Die URL <em>%s</em> ist ungültig',
 			'n_actualized' => 'Die %d Feeds sind aktualisiert worden',
 			'n_actualized' => 'Die %d Feeds sind aktualisiert worden',

+ 5 - 0
app/i18n/de/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP-Passwort',
 			'password' => 'HTTP-Passwort',
 			'username' => 'HTTP-Nutzername',
 			'username' => 'HTTP-Nutzername',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Nicht cachen (für defekte Feeds)',
 		'clear_cache' => 'Nicht cachen (für defekte Feeds)',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Behandlung von Feed-Inhalt beim Herunterladen von Artikelinhalt',
 			'_' => 'Behandlung von Feed-Inhalt beim Herunterladen von Artikelinhalt',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML Datei (ausgewählte Daten. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Siehe Dokumentation</a>)',
 			'help' => 'XML Datei (ausgewählte Daten. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Siehe Dokumentation</a>)',
 			'label' => 'Export als OPML',
 			'label' => 'Export als OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filteraktionen',
 			'_' => 'Filteraktionen',
 			'help' => 'Ein Suchfilter pro Zeile. Operatoren <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">siehe Dokumentation</a>.',
 			'help' => 'Ein Suchfilter pro Zeile. Operatoren <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">siehe Dokumentation</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// IGNORE
 		'http_headers' => 'HTTP Headers',	// IGNORE
 		'http_headers_help' => 'Headers werden durch einen Zeilenumbruch getrennt. Name und Wert des Headers werden per Doppelpunkt getrennt (z.B: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'Headers werden durch einen Zeilenumbruch getrennt. Name und Wert des Headers werden per Doppelpunkt getrennt (z.B: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informationen',
 		'information' => 'Informationen',
 		'keep_min' => 'Minimale Anzahl an Artikeln, die behalten wird',
 		'keep_min' => 'Minimale Anzahl an Artikeln, die behalten wird',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Verwende einen Proxy, um den Feed abzuholen',
 		'proxy' => 'Verwende einen Proxy, um den Feed abzuholen',
 		'proxy_help' => 'Wähle ein Protokoll (z.B. SOCKS5) und einen Proxy mit Port (z.B. <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
 		'proxy_help' => 'Wähle ein Protokoll (z.B. SOCKS5) und einen Proxy mit Port (z.B. <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Quellcode anzeigen',
 			'show_raw' => 'Quellcode anzeigen',
 			'show_rendered' => 'Inhalt anzeigen',
 			'show_rendered' => 'Inhalt anzeigen',

+ 4 - 0
app/i18n/el/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> cache has been cleared',	// TODO
 			'cache_cleared' => '<em>%s</em> cache has been cleared',	// TODO
 			'deleted' => 'Feed has been deleted',	// TODO
 			'deleted' => 'Feed has been deleted',	// TODO
 			'error' => 'Feed cannot be updated',	// TODO
 			'error' => 'Feed cannot be updated',	// TODO
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'The newsfeed could not be added. <a href="%s">Check FreshRSS logs</a> for details. You can try force adding by appending <code>#force_feed</code> to the URL.',	// TODO
 			'internal_problem' => 'The newsfeed could not be added. <a href="%s">Check FreshRSS logs</a> for details. You can try force adding by appending <code>#force_feed</code> to the URL.',	// TODO
 			'invalid_url' => 'URL <em>%s</em> is invalid',	// TODO
 			'invalid_url' => 'URL <em>%s</em> is invalid',	// TODO
 			'n_actualized' => '%d feeds have been updated',	// TODO
 			'n_actualized' => '%d feeds have been updated',	// TODO

+ 5 - 0
app/i18n/el/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP password',	// TODO
 			'password' => 'HTTP password',	// TODO
 			'username' => 'HTTP username',	// TODO
 			'username' => 'HTTP username',	// TODO
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Always clear cache',	// TODO
 		'clear_cache' => 'Always clear cache',	// TODO
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Content action when fetching the article content',	// TODO
 			'_' => 'Content action when fetching the article content',	// TODO
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'label' => 'Export as OPML',	// TODO
 			'label' => 'Export as OPML',	// TODO
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filter actions',	// TODO
 			'_' => 'Filter actions',	// TODO
 			'help' => 'Write one search filter per line. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// TODO
 			'help' => 'Write one search filter per line. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// TODO
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Information',	// TODO
 		'information' => 'Information',	// TODO
 		'keep_min' => 'Minimum number of articles to keep',	// TODO
 		'keep_min' => 'Minimum number of articles to keep',	// TODO
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Set a proxy for fetching this feed',	// TODO
 		'proxy' => 'Set a proxy for fetching this feed',	// TODO
 		'proxy_help' => 'Select a protocol (e.g: SOCKS5) and enter the proxy address (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// TODO
 		'proxy_help' => 'Select a protocol (e.g: SOCKS5) and enter the proxy address (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// TODO
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Show source code',	// TODO
 			'show_raw' => 'Show source code',	// TODO
 			'show_rendered' => 'Show content',	// TODO
 			'show_rendered' => 'Show content',	// TODO

+ 4 - 0
app/i18n/en-us/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> cache has been cleared',	// IGNORE
 			'cache_cleared' => '<em>%s</em> cache has been cleared',	// IGNORE
 			'deleted' => 'Feed has been deleted',	// IGNORE
 			'deleted' => 'Feed has been deleted',	// IGNORE
 			'error' => 'Feed cannot be updated',	// IGNORE
 			'error' => 'Feed cannot be updated',	// IGNORE
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// IGNORE
+				'unsupported_format' => 'Unsupported image file format!',	// IGNORE
+			),
 			'internal_problem' => 'The newsfeed could not be added. <a href="%s">Check FreshRSS logs</a> for details. You can try force adding by appending <code>#force_feed</code> to the URL.',	// IGNORE
 			'internal_problem' => 'The newsfeed could not be added. <a href="%s">Check FreshRSS logs</a> for details. You can try force adding by appending <code>#force_feed</code> to the URL.',	// IGNORE
 			'invalid_url' => 'URL <em>%s</em> is invalid',	// IGNORE
 			'invalid_url' => 'URL <em>%s</em> is invalid',	// IGNORE
 			'n_actualized' => '%d feeds have been updated',	// IGNORE
 			'n_actualized' => '%d feeds have been updated',	// IGNORE

+ 5 - 0
app/i18n/en-us/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP password',	// IGNORE
 			'password' => 'HTTP password',	// IGNORE
 			'username' => 'HTTP username',	// IGNORE
 			'username' => 'HTTP username',	// IGNORE
 		),
 		),
+		'change_favicon' => 'Change…',	// IGNORE
 		'clear_cache' => 'Always clear cache',	// IGNORE
 		'clear_cache' => 'Always clear cache',	// IGNORE
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Content action when fetching the article content',	// IGNORE
 			'_' => 'Content action when fetching the article content',	// IGNORE
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// IGNORE
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// IGNORE
 			'label' => 'Export as OPML',	// IGNORE
 			'label' => 'Export as OPML',	// IGNORE
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// IGNORE
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// IGNORE
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filter actions',	// IGNORE
 			'_' => 'Filter actions',	// IGNORE
 			'help' => 'Write one search filter per line. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// IGNORE
 			'help' => 'Write one search filter per line. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// IGNORE
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// IGNORE
 		'http_headers' => 'HTTP Headers',	// IGNORE
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// IGNORE
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// IGNORE
+		'icon' => 'Icon',	// IGNORE
 		'information' => 'Information',	// IGNORE
 		'information' => 'Information',	// IGNORE
 		'keep_min' => 'Minimum number of articles to keep',	// IGNORE
 		'keep_min' => 'Minimum number of articles to keep',	// IGNORE
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Set a proxy for fetching this feed',	// IGNORE
 		'proxy' => 'Set a proxy for fetching this feed',	// IGNORE
 		'proxy_help' => 'Select a protocol (e.g: SOCKS5) and enter the proxy address (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// IGNORE
 		'proxy_help' => 'Select a protocol (e.g: SOCKS5) and enter the proxy address (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// IGNORE
+		'reset_favicon' => 'Reset to default',	// IGNORE
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Show source code',	// IGNORE
 			'show_raw' => 'Show source code',	// IGNORE
 			'show_rendered' => 'Show content',	// IGNORE
 			'show_rendered' => 'Show content',	// IGNORE

+ 4 - 0
app/i18n/en/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> cache has been cleared',
 			'cache_cleared' => '<em>%s</em> cache has been cleared',
 			'deleted' => 'Feed has been deleted',
 			'deleted' => 'Feed has been deleted',
 			'error' => 'Feed cannot be updated',
 			'error' => 'Feed cannot be updated',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',
+				'unsupported_format' => 'Unsupported image file format!',
+			),
 			'internal_problem' => 'The newsfeed could not be added. <a href="%s">Check FreshRSS logs</a> for details. You can try force adding by appending <code>#force_feed</code> to the URL.',
 			'internal_problem' => 'The newsfeed could not be added. <a href="%s">Check FreshRSS logs</a> for details. You can try force adding by appending <code>#force_feed</code> to the URL.',
 			'invalid_url' => 'URL <em>%s</em> is invalid',
 			'invalid_url' => 'URL <em>%s</em> is invalid',
 			'n_actualized' => '%d feeds have been updated',
 			'n_actualized' => '%d feeds have been updated',

+ 5 - 0
app/i18n/en/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP password',
 			'password' => 'HTTP password',
 			'username' => 'HTTP username',
 			'username' => 'HTTP username',
 		),
 		),
+		'change_favicon' => 'Change…',
 		'clear_cache' => 'Always clear cache',
 		'clear_cache' => 'Always clear cache',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Content action when fetching the article content',
 			'_' => 'Content action when fetching the article content',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',
 			'label' => 'Export as OPML',
 			'label' => 'Export as OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filter actions',
 			'_' => 'Filter actions',
 			'help' => 'Write one search filter per line. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',
 			'help' => 'Write one search filter per line. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',
 		'http_headers' => 'HTTP Headers',
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',
 		'information' => 'Information',
 		'information' => 'Information',
 		'keep_min' => 'Minimum number of articles to keep',
 		'keep_min' => 'Minimum number of articles to keep',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Set a proxy for fetching this feed',
 		'proxy' => 'Set a proxy for fetching this feed',
 		'proxy_help' => 'Select a protocol (e.g: SOCKS5) and enter the proxy address (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Select a protocol (e.g: SOCKS5) and enter the proxy address (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Show source code',
 			'show_raw' => 'Show source code',
 			'show_rendered' => 'Show content',
 			'show_rendered' => 'Show content',

+ 4 - 0
app/i18n/es/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> se ha borrado la caché',
 			'cache_cleared' => '<em>%s</em> se ha borrado la caché',
 			'deleted' => 'Fuente eliminada',
 			'deleted' => 'Fuente eliminada',
 			'error' => 'No es posible actualizar la fuente',
 			'error' => 'No es posible actualizar la fuente',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'No ha sido posible agregar la fuente RSS. <a href="%s">Revisa el registro de FreshRSS </a> para más información. Puedes probar de forzarlo añadiendo la etiqueta <code>#force_feed</code> a la URL.',
 			'internal_problem' => 'No ha sido posible agregar la fuente RSS. <a href="%s">Revisa el registro de FreshRSS </a> para más información. Puedes probar de forzarlo añadiendo la etiqueta <code>#force_feed</code> a la URL.',
 			'invalid_url' => 'La URL <em>%s</em> es inválida',
 			'invalid_url' => 'La URL <em>%s</em> es inválida',
 			'n_actualized' => 'Se han actualizado %d fuentes',
 			'n_actualized' => 'Se han actualizado %d fuentes',

+ 5 - 0
app/i18n/es/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Contraseña HTTP',
 			'password' => 'Contraseña HTTP',
 			'username' => 'Nombre de usuario HTTP',
 			'username' => 'Nombre de usuario HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Borrar siempre la memoria caché',
 		'clear_cache' => 'Borrar siempre la memoria caché',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Acción de contenido al obtener el contenido del artículo',
 			'_' => 'Acción de contenido al obtener el contenido del artículo',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'archivo XML (conjunto de datos. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Ver la documentación</a>)',
 			'help' => 'archivo XML (conjunto de datos. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Ver la documentación</a>)',
 			'label' => 'Exportar como OPML',
 			'label' => 'Exportar como OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filtrar acciones',
 			'_' => 'Filtrar acciones',
 			'help' => 'Escribir un filtro de búsqueda por línea. Ver <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">documentación de operadores de búsqueda</a>.',
 			'help' => 'Escribir un filtro de búsqueda por línea. Ver <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">documentación de operadores de búsqueda</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// IGNORE
 		'http_headers' => 'HTTP Headers',	// IGNORE
 		'http_headers_help' => 'Los Headers son separados por un salto de linea, y el nombre y valor de un Header son separados con dos puntos (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'Los Headers son separados por un salto de linea, y el nombre y valor de un Header son separados con dos puntos (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',	// TODO
 		'information' => 'Información',
 		'information' => 'Información',
 		'keep_min' => 'Número mínimo de artículos a conservar',
 		'keep_min' => 'Número mínimo de artículos a conservar',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Establecer un proxy para obtener esta fuente',
 		'proxy' => 'Establecer un proxy para obtener esta fuente',
 		'proxy_help' => 'Seleccione un protocolo (e.g: SOCKS5) e introduzca la dirección del proxy (e.g: <kbd>127.0.0.1:1080</kbd> o <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Seleccione un protocolo (e.g: SOCKS5) e introduzca la dirección del proxy (e.g: <kbd>127.0.0.1:1080</kbd> o <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Mostrar código fuente',
 			'show_raw' => 'Mostrar código fuente',
 			'show_rendered' => 'Mostrar contenido',
 			'show_rendered' => 'Mostrar contenido',

+ 4 - 0
app/i18n/fa/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> کش پاک شده است',
 			'cache_cleared' => '<em>%s</em> کش پاک شده است',
 			'deleted' => ' فید حذف شده است',
 			'deleted' => ' فید حذف شده است',
 			'error' => ' فید را نمی توان به روز کرد',
 			'error' => ' فید را نمی توان به روز کرد',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => ' فید خبری اضافه نشد. برای جزئیات <a href="%s">گزارش‌های FreshRSS</a> را بررسی کنید. می‌توانید با اضافه کردن <code>#force_feed</code> به URL',
 			'internal_problem' => ' فید خبری اضافه نشد. برای جزئیات <a href="%s">گزارش‌های FreshRSS</a> را بررسی کنید. می‌توانید با اضافه کردن <code>#force_feed</code> به URL',
 			'invalid_url' => ' URL <em>%s</em> نامعتبر است',
 			'invalid_url' => ' URL <em>%s</em> نامعتبر است',
 			'n_actualized' => ' %d فید به روز شده است',
 			'n_actualized' => ' %d فید به روز شده است',

+ 5 - 0
app/i18n/fa/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => ' رمز عبور HTTP',
 			'password' => ' رمز عبور HTTP',
 			'username' => ' نام کاربری HTTP',
 			'username' => ' نام کاربری HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => ' همیشه حافظه پنهان را پاک کنید',
 		'clear_cache' => ' همیشه حافظه پنهان را پاک کنید',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => ' اقدام محتوا هنگام واکشی محتوای مقاله',
 			'_' => ' اقدام محتوا هنگام واکشی محتوای مقاله',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'label' => 'Export as OPML',	// TODO
 			'label' => 'Export as OPML',	// TODO
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => ' اعمال فیلتر',
 			'_' => ' اعمال فیلتر',
 			'help' => ' در هر خط یک فیلتر جستجو بنویسید. اپراتورها <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">مستندات را ببینید</a>.',
 			'help' => ' در هر خط یک فیلتر جستجو بنویسید. اپراتورها <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">مستندات را ببینید</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => ' اطلاعات',
 		'information' => ' اطلاعات',
 		'keep_min' => ' حداقل تعداد مقالات برای نگهداری',
 		'keep_min' => ' حداقل تعداد مقالات برای نگهداری',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => ' یک پروکسی برای واکشی این فید تنظیم کنید',
 		'proxy' => ' یک پروکسی برای واکشی این فید تنظیم کنید',
 		'proxy_help' => ' یک پروتکل (به عنوان مثال: SOCKS5) انتخاب کنید و آدرس پراکسی را وارد کنید (به عنوان مثال: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
 		'proxy_help' => ' یک پروتکل (به عنوان مثال: SOCKS5) انتخاب کنید و آدرس پراکسی را وارد کنید (به عنوان مثال: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => ' نمایش کد منبع',
 			'show_raw' => ' نمایش کد منبع',
 			'show_rendered' => 'نمایش محتوا',
 			'show_rendered' => 'نمایش محتوا',

+ 4 - 0
app/i18n/fi/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => 'Välimuisti <em>%s</em> on tyhjennetty',
 			'cache_cleared' => 'Välimuisti <em>%s</em> on tyhjennetty',
 			'deleted' => 'Syöte on poistettu',
 			'deleted' => 'Syöte on poistettu',
 			'error' => 'Syötteen päivitys ei onnistu',
 			'error' => 'Syötteen päivitys ei onnistu',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Uutissyötettä ei voinut lisätä. Lisätietoja on <a href="%s">FreshRSS-lokeissa</a>. Voit yrittää pakottaa lisäämisen liittämällä tekstin <code>#force_feed</code> URL-osoitteen loppuun.',
 			'internal_problem' => 'Uutissyötettä ei voinut lisätä. Lisätietoja on <a href="%s">FreshRSS-lokeissa</a>. Voit yrittää pakottaa lisäämisen liittämällä tekstin <code>#force_feed</code> URL-osoitteen loppuun.',
 			'invalid_url' => 'URL-osoite <em>%s</em> on virheellinen',
 			'invalid_url' => 'URL-osoite <em>%s</em> on virheellinen',
 			'n_actualized' => '%d syötettä on päivitetty',
 			'n_actualized' => '%d syötettä on päivitetty',

+ 5 - 0
app/i18n/fi/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP-salasana',
 			'password' => 'HTTP-salasana',
 			'username' => 'HTTP-käyttäjätunnus',
 			'username' => 'HTTP-käyttäjätunnus',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Tyhjennä välimuisti aina',
 		'clear_cache' => 'Tyhjennä välimuisti aina',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Toiminto noudettaessa artikkelin sisältö',
 			'_' => 'Toiminto noudettaessa artikkelin sisältö',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML-tiedosto (osa tiedoista. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Katso ohje</a>)',
 			'help' => 'XML-tiedosto (osa tiedoista. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Katso ohje</a>)',
 			'label' => 'Vie OPML-tiedostoksi',
 			'label' => 'Vie OPML-tiedostoksi',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Suodatustoiminnot',
 			'_' => 'Suodatustoiminnot',
 			'help' => 'Kirjoita kukin hakusuodatin omalle rivilleen. Lisätietoja operaattoreista <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">ohjeissa</a>.',
 			'help' => 'Kirjoita kukin hakusuodatin omalle rivilleen. Lisätietoja operaattoreista <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">ohjeissa</a>.',
 		),
 		),
 		'http_headers' => 'HTTP-otsikot',
 		'http_headers' => 'HTTP-otsikot',
 		'http_headers_help' => 'Otsikot erotellaan rivinvaihdoin, ja nimi ja arvo erotellaan kaksoispisteellä. Esimerkki: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'Otsikot erotellaan rivinvaihdoin, ja nimi ja arvo erotellaan kaksoispisteellä. Esimerkki: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',	// TODO
 		'information' => 'Tiedot',
 		'information' => 'Tiedot',
 		'keep_min' => 'Säilytettävien artikkeleiden vähimmäismäärä',
 		'keep_min' => 'Säilytettävien artikkeleiden vähimmäismäärä',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Nouda syöte käyttämällä välityspalvelinta',
 		'proxy' => 'Nouda syöte käyttämällä välityspalvelinta',
 		'proxy_help' => 'Valitse protokolla (esimerkki: SOCKS5) ja kirjoita välityspalvelimen osoite (esimerkki: <kbd>127.0.0.1:1080</kbd> tai <kbd>käyttäjätunnus:salasana@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Valitse protokolla (esimerkki: SOCKS5) ja kirjoita välityspalvelimen osoite (esimerkki: <kbd>127.0.0.1:1080</kbd> tai <kbd>käyttäjätunnus:salasana@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Näytä lähdekoodi',
 			'show_raw' => 'Näytä lähdekoodi',
 			'show_rendered' => 'Näytä sisältö',
 			'show_rendered' => 'Näytä sisältö',

+ 4 - 0
app/i18n/fr/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => 'Le cache de <em>%s</em> a été vidée.',
 			'cache_cleared' => 'Le cache de <em>%s</em> a été vidée.',
 			'deleted' => 'Le flux a été supprimé.',
 			'deleted' => 'Le flux a été supprimé.',
 			'error' => 'Une erreur est survenue',
 			'error' => 'Une erreur est survenue',
+			'favicon' => array(
+				'too_large' => 'L’icône téléversée est trop grosse ! La taille maximale du fichier est de <em>%s</em>.',
+				'unsupported_format' => 'Format d’image non pris en charge !',
+			),
 			'internal_problem' => 'Le flux ne peut pas être ajouté. <a href="%s">Consulter les logs de FreshRSS</a> pour plus de détails. Vous pouvez essayer de forcer l’ajout par addition de <code>#force_feed</code> à l’URL.',
 			'internal_problem' => 'Le flux ne peut pas être ajouté. <a href="%s">Consulter les logs de FreshRSS</a> pour plus de détails. Vous pouvez essayer de forcer l’ajout par addition de <code>#force_feed</code> à l’URL.',
 			'invalid_url' => 'L’url <em>%s</em> est invalide.',
 			'invalid_url' => 'L’url <em>%s</em> est invalide.',
 			'n_actualized' => '%d flux ont été mis à jour.',
 			'n_actualized' => '%d flux ont été mis à jour.',

+ 5 - 0
app/i18n/fr/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Mot de passe HTTP',
 			'password' => 'Mot de passe HTTP',
 			'username' => 'Identifiant HTTP',
 			'username' => 'Identifiant HTTP',
 		),
 		),
+		'change_favicon' => 'Changer…',
 		'clear_cache' => 'Toujours vider le cache',
 		'clear_cache' => 'Toujours vider le cache',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Action à effectuer pour la réception du contenu des articles',
 			'_' => 'Action à effectuer pour la réception du contenu des articles',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'Fichier XML (données partielles. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Voir documentation</a>)',
 			'help' => 'Fichier XML (données partielles. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Voir documentation</a>)',
 			'label' => 'Exporter en OPML',
 			'label' => 'Exporter en OPML',
 		),
 		),
+		'ext_favicon' => 'Définie automatiquement',
+		'favicon_changed_by_ext' => 'L’icône a été définie par l’extension <b>%s</b>.',
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filtres d’action',
 			'_' => 'Filtres d’action',
 			'help' => 'Écrivez une recherche par ligne. Voir la <a href="https://freshrss.github.io/FreshRSS/fr/users/03_Main_view.html#gr%C3%A2ce-au-champ-de-recherche" target="_blank">documentation des opérateurs</a>.',
 			'help' => 'Écrivez une recherche par ligne. Voir la <a href="https://freshrss.github.io/FreshRSS/fr/users/03_Main_view.html#gr%C3%A2ce-au-champ-de-recherche" target="_blank">documentation des opérateurs</a>.',
 		),
 		),
 		'http_headers' => 'Entêtes HTTP',
 		'http_headers' => 'Entêtes HTTP',
 		'http_headers_help' => 'Un entête HTTP par ligne, avec le nom et la valeur séparés par un deux-points (ex. : <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'Un entête HTTP par ligne, avec le nom et la valeur séparés par un deux-points (ex. : <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icône',
 		'information' => 'Informations',
 		'information' => 'Informations',
 		'keep_min' => 'Nombre minimum d’articles à conserver',
 		'keep_min' => 'Nombre minimum d’articles à conserver',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Utiliser un proxy pour télécharger ce flux',
 		'proxy' => 'Utiliser un proxy pour télécharger ce flux',
 		'proxy_help' => 'Sélectionner un protocole (ex : SOCKS5) et entrer l’adresse du proxy (ex. : <kbd>127.0.0.1:1080</kbd> ou <kbd>utilisateur:mot-de-passe@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Sélectionner un protocole (ex : SOCKS5) et entrer l’adresse du proxy (ex. : <kbd>127.0.0.1:1080</kbd> ou <kbd>utilisateur:mot-de-passe@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Réinitialiser',
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Afficher le code source',
 			'show_raw' => 'Afficher le code source',
 			'show_rendered' => 'Afficher le contenu',
 			'show_rendered' => 'Afficher le contenu',

+ 4 - 0
app/i18n/he/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> cache has been cleared',	// TODO
 			'cache_cleared' => '<em>%s</em> cache has been cleared',	// TODO
 			'deleted' => 'ההזנה נמחקה',
 			'deleted' => 'ההזנה נמחקה',
 			'error' => 'Feed cannot be updated',	// TODO
 			'error' => 'Feed cannot be updated',	// TODO
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'אין אפשרות להוסיף את ההזנה. <a href="%s">בדקו את הלוגים</a> לפרטים. You can try force adding by appending <code>#force_feed</code> to the URL.',	// DIRTY
 			'internal_problem' => 'אין אפשרות להוסיף את ההזנה. <a href="%s">בדקו את הלוגים</a> לפרטים. You can try force adding by appending <code>#force_feed</code> to the URL.',	// DIRTY
 			'invalid_url' => 'URL <em>%s</em> אינו תקין',
 			'invalid_url' => 'URL <em>%s</em> אינו תקין',
 			'n_actualized' => '%d הזנות עודכנו',
 			'n_actualized' => '%d הזנות עודכנו',

+ 5 - 0
app/i18n/he/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP סיסמה',
 			'password' => 'HTTP סיסמה',
 			'username' => 'HTTP שם משתמש',
 			'username' => 'HTTP שם משתמש',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Always clear cache',	// TODO
 		'clear_cache' => 'Always clear cache',	// TODO
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Content action when fetching the article content',	// TODO
 			'_' => 'Content action when fetching the article content',	// TODO
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'label' => 'Export as OPML',	// TODO
 			'label' => 'Export as OPML',	// TODO
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filter actions',	// TODO
 			'_' => 'Filter actions',	// TODO
 			'help' => 'Write one search filter per line. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// TODO
 			'help' => 'Write one search filter per line. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// TODO
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'מידע',
 		'information' => 'מידע',
 		'keep_min' => 'מסםר מינימלי של מאמרים לשמור',
 		'keep_min' => 'מסםר מינימלי של מאמרים לשמור',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Set a proxy for fetching this feed',	// TODO
 		'proxy' => 'Set a proxy for fetching this feed',	// TODO
 		'proxy_help' => 'Select a protocol (e.g: SOCKS5) and enter the proxy address (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// TODO
 		'proxy_help' => 'Select a protocol (e.g: SOCKS5) and enter the proxy address (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// TODO
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Show source code',	// TODO
 			'show_raw' => 'Show source code',	// TODO
 			'show_rendered' => 'Show content',	// TODO
 			'show_rendered' => 'Show content',	// TODO

+ 4 - 0
app/i18n/hu/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> gyorsítótára kiürítve',
 			'cache_cleared' => '<em>%s</em> gyorsítótára kiürítve',
 			'deleted' => 'Hírforrás törlése megtörtént',
 			'deleted' => 'Hírforrás törlése megtörtént',
 			'error' => 'Hírforrás frissítése nem lehetséges',
 			'error' => 'Hírforrás frissítése nem lehetséges',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'A hírforrást nem sikerült hozzáadni. <a href="%s">Nézd meg a FreshRSS logokat</a> a részletekért. Megpróbálhatod mindenképp hozzáadni, ha az <code>#force_feed</code> szöveget az URL után írod.',
 			'internal_problem' => 'A hírforrást nem sikerült hozzáadni. <a href="%s">Nézd meg a FreshRSS logokat</a> a részletekért. Megpróbálhatod mindenképp hozzáadni, ha az <code>#force_feed</code> szöveget az URL után írod.',
 			'invalid_url' => 'URL <em>%s</em> érvénytelen',
 			'invalid_url' => 'URL <em>%s</em> érvénytelen',
 			'n_actualized' => '%d hírforrások frissítése kész',
 			'n_actualized' => '%d hírforrások frissítése kész',

+ 5 - 0
app/i18n/hu/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP jelszó',
 			'password' => 'HTTP jelszó',
 			'username' => 'HTTP felhasználónév',
 			'username' => 'HTTP felhasználónév',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Mindig törölje a cache-t',
 		'clear_cache' => 'Mindig törölje a cache-t',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Tartalom művelet, amikor cikk tartalma beszerzésre kerül',
 			'_' => 'Tartalom művelet, amikor cikk tartalma beszerzésre kerül',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML fájl (adat részhalmaz. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Lásd dokumentáció</a>)',
 			'help' => 'XML fájl (adat részhalmaz. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Lásd dokumentáció</a>)',
 			'label' => 'Exportálás OPML formátumban',
 			'label' => 'Exportálás OPML formátumban',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Szűrő műveletek',
 			'_' => 'Szűrő műveletek',
 			'help' => 'Írj egy szűrőt soronként. Műveletek <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">a dokumentációban</a>.',
 			'help' => 'Írj egy szűrőt soronként. Műveletek <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">a dokumentációban</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Fejlécek',
 		'http_headers' => 'HTTP Fejlécek',
 		'http_headers_help' => 'A fejléceket újsor választja el, a fejléc nevét és értékét kettőspont választja el (pl: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'A fejléceket újsor választja el, a fejléc nevét és értékét kettőspont választja el (pl: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',	// TODO
 		'information' => 'Információ',
 		'information' => 'Információ',
 		'keep_min' => 'Megtartandó cikkek minimális száma',
 		'keep_min' => 'Megtartandó cikkek minimális száma',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Állíts be egy proxy-t a hírforráshoz ',
 		'proxy' => 'Állíts be egy proxy-t a hírforráshoz ',
 		'proxy_help' => 'Válassz egy protokollt (pl.: SOCKS5) és add meg a proxy címét (pl.: <kbd>127.0.0.1:1080</kbd> vagy <kbd>felhasználónév:jelszó@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Válassz egy protokollt (pl.: SOCKS5) és add meg a proxy címét (pl.: <kbd>127.0.0.1:1080</kbd> vagy <kbd>felhasználónév:jelszó@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Forráskód mutatása',
 			'show_raw' => 'Forráskód mutatása',
 			'show_rendered' => 'Tartalom mutatása',
 			'show_rendered' => 'Tartalom mutatása',

+ 4 - 0
app/i18n/id/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> cache has been cleared',	// TODO
 			'cache_cleared' => '<em>%s</em> cache has been cleared',	// TODO
 			'deleted' => 'Feed has been deleted',	// TODO
 			'deleted' => 'Feed has been deleted',	// TODO
 			'error' => 'Feed cannot be updated',	// TODO
 			'error' => 'Feed cannot be updated',	// TODO
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'The newsfeed could not be added. <a href="%s">Check FreshRSS logs</a> for details. You can try force adding by appending <code>#force_feed</code> to the URL.',	// TODO
 			'internal_problem' => 'The newsfeed could not be added. <a href="%s">Check FreshRSS logs</a> for details. You can try force adding by appending <code>#force_feed</code> to the URL.',	// TODO
 			'invalid_url' => 'URL <em>%s</em> is invalid',	// TODO
 			'invalid_url' => 'URL <em>%s</em> is invalid',	// TODO
 			'n_actualized' => '%d feeds have been updated',	// TODO
 			'n_actualized' => '%d feeds have been updated',	// TODO

+ 5 - 0
app/i18n/id/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Kata sandi HTTP',
 			'password' => 'Kata sandi HTTP',
 			'username' => 'Nama pengguna HTTP',
 			'username' => 'Nama pengguna HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Selalu bersihkan tembolok',
 		'clear_cache' => 'Selalu bersihkan tembolok',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Yang dilakukan ketika mengambil konten artikel',
 			'_' => 'Yang dilakukan ketika mengambil konten artikel',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'Berkas XML (subset data. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Lihat dokumentasi</a>)',
 			'help' => 'Berkas XML (subset data. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Lihat dokumentasi</a>)',
 			'label' => 'Unduh dalam bentuk OPML',
 			'label' => 'Unduh dalam bentuk OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Tindakan penyaringan',
 			'_' => 'Tindakan penyaringan',
 			'help' => 'Tulis satu penyaringan pencarian per baris. Operator <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">lihat dokumentasi</a>.',
 			'help' => 'Tulis satu penyaringan pencarian per baris. Operator <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">lihat dokumentasi</a>.',
 		),
 		),
 		'http_headers' => 'Tajuk HTTP',
 		'http_headers' => 'Tajuk HTTP',
 		'http_headers_help' => 'Tajuk dipisahkan dengan baris baru dan nama dan nilai dari tajuk dipisahkan dengan titik dua (contoh: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'Tajuk dipisahkan dengan baris baru dan nama dan nilai dari tajuk dipisahkan dengan titik dua (contoh: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informasi',
 		'information' => 'Informasi',
 		'keep_min' => 'Jumlah minimum artikel yang harus disimpan',
 		'keep_min' => 'Jumlah minimum artikel yang harus disimpan',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Atur proksi untuk mengambil umpan ini',
 		'proxy' => 'Atur proksi untuk mengambil umpan ini',
 		'proxy_help' => 'Pilih protokol (contoh: SOCKS5) dan masukkan alamat proksi (contoh: <kbd>127.0.0.1:1080</kbd> atau <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Pilih protokol (contoh: SOCKS5) dan masukkan alamat proksi (contoh: <kbd>127.0.0.1:1080</kbd> atau <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Tampilkan kode sumber',
 			'show_raw' => 'Tampilkan kode sumber',
 			'show_rendered' => 'Tampilkan konten',
 			'show_rendered' => 'Tampilkan konten',

+ 4 - 0
app/i18n/it/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => 'La cache di <em>%s</em> è stata svuotata',
 			'cache_cleared' => 'La cache di <em>%s</em> è stata svuotata',
 			'deleted' => 'Feed cancellato',
 			'deleted' => 'Feed cancellato',
 			'error' => 'Feed non aggiornato',
 			'error' => 'Feed non aggiornato',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Feed RSS non aggiunto. <a href="%s">Verifica i log</a> per dettagli. Puoi provare l’aggiunta forzata aggiungendo <code>#force_feed</code> all’URL.',
 			'internal_problem' => 'Feed RSS non aggiunto. <a href="%s">Verifica i log</a> per dettagli. Puoi provare l’aggiunta forzata aggiungendo <code>#force_feed</code> all’URL.',
 			'invalid_url' => 'URL <em>%s</em> non valido',
 			'invalid_url' => 'URL <em>%s</em> non valido',
 			'n_actualized' => '%d feed aggiornati',
 			'n_actualized' => '%d feed aggiornati',

+ 5 - 0
app/i18n/it/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Password HTTP',
 			'password' => 'Password HTTP',
 			'username' => 'Nome utente HTTP',
 			'username' => 'Nome utente HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Cancella sempre la cache',
 		'clear_cache' => 'Cancella sempre la cache',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Azione da effettuare quando viene recuperato il contenuto di un articolo',
 			'_' => 'Azione da effettuare quando viene recuperato il contenuto di un articolo',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'File XML (sottoinsieme di dati. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Leggi la documentazione</a>)',
 			'help' => 'File XML (sottoinsieme di dati. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Leggi la documentazione</a>)',
 			'label' => 'Esporta come OPML',
 			'label' => 'Esporta come OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Azioni di filtro',
 			'_' => 'Azioni di filtro',
 			'help' => 'Scrivi un filtro di ricerca per riga. Per li operatori <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">vedi la documentazione</a>.',
 			'help' => 'Scrivi un filtro di ricerca per riga. Per li operatori <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">vedi la documentazione</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// IGNORE
 		'http_headers' => 'HTTP Headers',	// IGNORE
 		'http_headers_help' => 'Le intestazioni sono separate da una linea e il nome e il valore di un’intestazione sono separati da due punti (p.es: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'Le intestazioni sono separate da una linea e il nome e il valore di un’intestazione sono separati da due punti (p.es: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informazioni',
 		'information' => 'Informazioni',
 		'keep_min' => 'Numero minimo di articoli da mantenere',
 		'keep_min' => 'Numero minimo di articoli da mantenere',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Imposta un proxy per recuperare questo feed',
 		'proxy' => 'Imposta un proxy per recuperare questo feed',
 		'proxy_help' => 'Seleziona un protocollo (e.g: SOCKS5) ed inserisci l’indirizzo del proxy (es.: <kbd>127.0.0.1:1080</kbd> o <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Seleziona un protocollo (e.g: SOCKS5) ed inserisci l’indirizzo del proxy (es.: <kbd>127.0.0.1:1080</kbd> o <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Mostra codice sorgente',
 			'show_raw' => 'Mostra codice sorgente',
 			'show_rendered' => 'Mostra contenuto',
 			'show_rendered' => 'Mostra contenuto',

+ 4 - 0
app/i18n/ja/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em>キャッシュは作られました',
 			'cache_cleared' => '<em>%s</em>キャッシュは作られました',
 			'deleted' => 'フィードは消去されました',
 			'deleted' => 'フィードは消去されました',
 			'error' => 'フィードを更新することができません',
 			'error' => 'フィードを更新することができません',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'newsfeedを追加することはできません。<a href="%s">FreshRSSログの詳細を</a>確かめてください。強制的に追加することを試せます <code>#force_feed</code>このURLを確認ください。',
 			'internal_problem' => 'newsfeedを追加することはできません。<a href="%s">FreshRSSログの詳細を</a>確かめてください。強制的に追加することを試せます <code>#force_feed</code>このURLを確認ください。',
 			'invalid_url' => 'URL <em>%s</em>は無効です',
 			'invalid_url' => 'URL <em>%s</em>は無効です',
 			'n_actualized' => '%d フィードはアップデートされました',
 			'n_actualized' => '%d フィードはアップデートされました',

+ 5 - 0
app/i18n/ja/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP パスワード',
 			'password' => 'HTTP パスワード',
 			'username' => 'HTTP ユーザー名',
 			'username' => 'HTTP ユーザー名',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => '常にキャッシュをクリアする',
 		'clear_cache' => '常にキャッシュをクリアする',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => '記事のコンテンツを取得するときの動作',
 			'_' => '記事のコンテンツを取得するときの動作',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XMLファイル (データのサブセット。<a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">ドキュメントを参照してください</a>。)',
 			'help' => 'XMLファイル (データのサブセット。<a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">ドキュメントを参照してください</a>。)',
 			'label' => 'OPMLとしてエクスポート',
 			'label' => 'OPMLとしてエクスポート',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'フィルターアクション',
 			'_' => 'フィルターアクション',
 			'help' => '1行に1つの検索フィルターを設定してください。演算子は<a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">ドキュメントを参照してください</a>。',
 			'help' => '1行に1つの検索フィルターを設定してください。演算子は<a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">ドキュメントを参照してください</a>。',
 		),
 		),
 		'http_headers' => 'HTTPヘッダ',
 		'http_headers' => 'HTTPヘッダ',
 		'http_headers_help' => 'ヘッダは開業で区切られ、ヘッダの名前と値はコロンで区切られます (例: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'ヘッダは開業で区切られ、ヘッダの名前と値はコロンで区切られます (例: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',	// TODO
 		'information' => 'インフォメーション',
 		'information' => 'インフォメーション',
 		'keep_min' => '最小数の記事は保持されます',
 		'keep_min' => '最小数の記事は保持されます',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'フィードを取得するときのプロキシ',
 		'proxy' => 'フィードを取得するときのプロキシ',
 		'proxy_help' => 'プロトコルを選択し (例: SOCKS5) プロキシアドレスを入力してください (例: <kbd>127.0.0.1:1080</kbd> や <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'プロトコルを選択し (例: SOCKS5) プロキシアドレスを入力してください (例: <kbd>127.0.0.1:1080</kbd> や <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'ソースコードを表示する',
 			'show_raw' => 'ソースコードを表示する',
 			'show_rendered' => 'コンテンツを表示する',
 			'show_rendered' => 'コンテンツを表示する',

+ 4 - 0
app/i18n/ko/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> 캐쉬 지움',
 			'cache_cleared' => '<em>%s</em> 캐쉬 지움',
 			'deleted' => '피드가 삭제되었습니다',
 			'deleted' => '피드가 삭제되었습니다',
 			'error' => '피드를 변경할 수 없습니다',
 			'error' => '피드를 변경할 수 없습니다',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'RSS 피드를 추가할 수 없습니다. 자세한 내용은 <a href="%s">FreshRSS 로그</a>를 참고하세요. <code>#force_feed</code>를 URL에 추가하여 강제로 추가 시도 할 수 있습니다.',
 			'internal_problem' => 'RSS 피드를 추가할 수 없습니다. 자세한 내용은 <a href="%s">FreshRSS 로그</a>를 참고하세요. <code>#force_feed</code>를 URL에 추가하여 강제로 추가 시도 할 수 있습니다.',
 			'invalid_url' => 'URL (<em>%s</em>)이 유효하지 않습니다',
 			'invalid_url' => 'URL (<em>%s</em>)이 유효하지 않습니다',
 			'n_actualized' => '%d 개의 피드에서 새 글을 가져왔습니다',
 			'n_actualized' => '%d 개의 피드에서 새 글을 가져왔습니다',

+ 5 - 0
app/i18n/ko/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP 암호',
 			'password' => 'HTTP 암호',
 			'username' => 'HTTP 사용자 이름',
 			'username' => 'HTTP 사용자 이름',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => '항상 캐시 지우기',
 		'clear_cache' => '항상 캐시 지우기',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => '글 콘텐츠를 가져올 때의 동작',
 			'_' => '글 콘텐츠를 가져올 때의 동작',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML 파일 (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'help' => 'XML 파일 (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'label' => 'OPML로 내보내기',
 			'label' => 'OPML로 내보내기',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => '필터 동작',
 			'_' => '필터 동작',
 			'help' => '한 줄에 한 검색 필터를 작성해 주세요. 실행시 <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">문서 참고</a>.',
 			'help' => '한 줄에 한 검색 필터를 작성해 주세요. 실행시 <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">문서 참고</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => '정보',
 		'information' => '정보',
 		'keep_min' => '최소 유지 글 개수',
 		'keep_min' => '최소 유지 글 개수',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => '이 피드를 가져올 때 사용할 프록시 설정',
 		'proxy' => '이 피드를 가져올 때 사용할 프록시 설정',
 		'proxy_help' => '프로토콜 선택 (예: SOCKS5) 그리고 프록시 주소 입력 (예: <kbd>127.0.0.1:1080</kbd> 혹은 <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => '프로토콜 선택 (예: SOCKS5) 그리고 프록시 주소 입력 (예: <kbd>127.0.0.1:1080</kbd> 혹은 <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => '소스코드 표시',
 			'show_raw' => '소스코드 표시',
 			'show_rendered' => '콘텐츠 표시',
 			'show_rendered' => '콘텐츠 표시',

+ 4 - 0
app/i18n/lv/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> kešatmiņa tika iztukšota',
 			'cache_cleared' => '<em>%s</em> kešatmiņa tika iztukšota',
 			'deleted' => 'Barortne tika izdzēsta',
 			'deleted' => 'Barortne tika izdzēsta',
 			'error' => 'Barotne nevar būt atjaunināta',
 			'error' => 'Barotne nevar būt atjaunināta',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Barotni nevarēja pievienot. <a href="%s">Apskataties FreshRSS žurnālu</a> priekš papildus informācijas. Jūs varat izmēģināt piespiedu pievienošanu, URL pievienojot <code>#force_feed</code>.',
 			'internal_problem' => 'Barotni nevarēja pievienot. <a href="%s">Apskataties FreshRSS žurnālu</a> priekš papildus informācijas. Jūs varat izmēģināt piespiedu pievienošanu, URL pievienojot <code>#force_feed</code>.',
 			'invalid_url' => 'URL <em>%s</em> ir nepareizs',
 			'invalid_url' => 'URL <em>%s</em> ir nepareizs',
 			'n_actualized' => '%d barotnes tika atjaunotas',
 			'n_actualized' => '%d barotnes tika atjaunotas',

+ 5 - 0
app/i18n/lv/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP parole',
 			'password' => 'HTTP parole',
 			'username' => 'HTTP lietotājvārds',
 			'username' => 'HTTP lietotājvārds',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Vienmēr iztīrīt kešatmiņu',
 		'clear_cache' => 'Vienmēr iztīrīt kešatmiņu',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Satura darbība, kad tiek iegūts raksta saturs',
 			'_' => 'Satura darbība, kad tiek iegūts raksta saturs',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'label' => 'Export as OPML',	// TODO
 			'label' => 'Export as OPML',	// TODO
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filtra darbības',
 			'_' => 'Filtra darbības',
 			'help' => 'Uzrakstiet vienu meklēšanas filtru katrā rindā. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 			'help' => 'Uzrakstiet vienu meklēšanas filtru katrā rindā. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informācija',
 		'information' => 'Informācija',
 		'keep_min' => 'Minimālais saglabājamo izstrādājumu skaits',
 		'keep_min' => 'Minimālais saglabājamo izstrādājumu skaits',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Iestatīt starpniekserveri šīs plūsmas iegūšanai',
 		'proxy' => 'Iestatīt starpniekserveri šīs plūsmas iegūšanai',
 		'proxy_help' => 'Izvēlieties protokolu (piemēram, SOCKS5) un ievadiet starpniekservera adresi (piemēram, <kbd>127.0.0.0.1:1080</kbd>).',
 		'proxy_help' => 'Izvēlieties protokolu (piemēram, SOCKS5) un ievadiet starpniekservera adresi (piemēram, <kbd>127.0.0.0.1:1080</kbd>).',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Rādīt avota kodu',
 			'show_raw' => 'Rādīt avota kodu',
 			'show_rendered' => 'Rādīt saturu',
 			'show_rendered' => 'Rādīt saturu',

+ 4 - 0
app/i18n/nl/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> cache verwijderd',
 			'cache_cleared' => '<em>%s</em> cache verwijderd',
 			'deleted' => 'Feed verwijderd',
 			'deleted' => 'Feed verwijderd',
 			'error' => 'Feed kan niet worden vernieuwd',
 			'error' => 'Feed kan niet worden vernieuwd',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'De feed kon niet worden toegevoegd. <a href="%s">Controleer de FreshRSS-logbestanden</a> voor details. Toevoegen forceren kan worden geprobeerd door <code>#force_feed</code> aan de URL toe te voegen.',
 			'internal_problem' => 'De feed kon niet worden toegevoegd. <a href="%s">Controleer de FreshRSS-logbestanden</a> voor details. Toevoegen forceren kan worden geprobeerd door <code>#force_feed</code> aan de URL toe te voegen.',
 			'invalid_url' => 'URL <em>%s</em> is ongeldig',
 			'invalid_url' => 'URL <em>%s</em> is ongeldig',
 			'n_actualized' => '%d feeds zijn vernieuwd',
 			'n_actualized' => '%d feeds zijn vernieuwd',

+ 5 - 0
app/i18n/nl/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP wachtwoord',
 			'password' => 'HTTP wachtwoord',
 			'username' => 'HTTP gebruikers naam',
 			'username' => 'HTTP gebruikers naam',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Cache altijd leegmaken',
 		'clear_cache' => 'Cache altijd leegmaken',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Inhoudsactie bij ophalen artikelinhoud',
 			'_' => 'Inhoudsactie bij ophalen artikelinhoud',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML-bestand (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'help' => 'XML-bestand (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'label' => 'Als OPML exporteren',
 			'label' => 'Als OPML exporteren',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filteracties',
 			'_' => 'Filteracties',
 			'help' => 'Voer één zoekfilter per lijn in. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 			'help' => 'Voer één zoekfilter per lijn in. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informatie',
 		'information' => 'Informatie',
 		'keep_min' => 'Minimum aantal artikelen om te houden',
 		'keep_min' => 'Minimum aantal artikelen om te houden',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Proxy instellen om deze feed op te halen',
 		'proxy' => 'Proxy instellen om deze feed op te halen',
 		'proxy_help' => 'Selecteer een protocol (bv. SOCKS5) en voer een proxy-adres in (b.v. <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
 		'proxy_help' => 'Selecteer een protocol (bv. SOCKS5) en voer een proxy-adres in (b.v. <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Broncode tonen',
 			'show_raw' => 'Broncode tonen',
 			'show_rendered' => 'Inhoud tonen',
 			'show_rendered' => 'Inhoud tonen',

+ 4 - 0
app/i18n/oc/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> cache escafat',
 			'cache_cleared' => '<em>%s</em> cache escafat',
 			'deleted' => 'Lo flux es suprimit',
 			'deleted' => 'Lo flux es suprimit',
 			'error' => 'Error en actualizar',
 			'error' => 'Error en actualizar',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Lo flux pòt pas èsser ajustat. <a href="%s">Consultatz los jornals d’audit de FreshRSS</a> per ne saber mai. Podètz forçar l’apondon en ajustant <code>#force_feed</code> a l’URL.',
 			'internal_problem' => 'Lo flux pòt pas èsser ajustat. <a href="%s">Consultatz los jornals d’audit de FreshRSS</a> per ne saber mai. Podètz forçar l’apondon en ajustant <code>#force_feed</code> a l’URL.',
 			'invalid_url' => 'L’URL <em>%s</em> es invalida',
 			'invalid_url' => 'L’URL <em>%s</em> es invalida',
 			'n_actualized' => '%s fluxes son estats actualizats',
 			'n_actualized' => '%s fluxes son estats actualizats',

+ 5 - 0
app/i18n/oc/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Senhal HTTP',
 			'password' => 'Senhal HTTP',
 			'username' => 'Identificant HTTP',
 			'username' => 'Identificant HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Totjorn escafar lo cache',
 		'clear_cache' => 'Totjorn escafar lo cache',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Accion sul contengut en recuperant lo contengut de l’article',
 			'_' => 'Accion sul contengut en recuperant lo contengut de l’article',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'label' => 'Export as OPML',	// TODO
 			'label' => 'Export as OPML',	// TODO
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filtre d’accion',
 			'_' => 'Filtre d’accion',
 			'help' => 'Escrivètz una recèrca per linha. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 			'help' => 'Escrivètz una recèrca per linha. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informacions',
 		'information' => 'Informacions',
 		'keep_min' => 'Nombre minimum d’articles de servar',
 		'keep_min' => 'Nombre minimum d’articles de servar',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Definir un servidor proxy per trapar aqueste flux',
 		'proxy' => 'Definir un servidor proxy per trapar aqueste flux',
 		'proxy_help' => 'Seleccionatz un protocòl (ex : SOCKS5) e picatz l’adreça del proxy (ex : <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
 		'proxy_help' => 'Seleccionatz un protocòl (ex : SOCKS5) e picatz l’adreça del proxy (ex : <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Veire lo còdi font',
 			'show_raw' => 'Veire lo còdi font',
 			'show_rendered' => 'Veire lo contengut',
 			'show_rendered' => 'Veire lo contengut',

+ 4 - 0
app/i18n/pl/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => 'Pamięć podręczna kanału <em>%s</em> została wyczyszczona',
 			'cache_cleared' => 'Pamięć podręczna kanału <em>%s</em> została wyczyszczona',
 			'deleted' => 'Kanał został usunięty',
 			'deleted' => 'Kanał został usunięty',
 			'error' => 'Nie udało się zaktualizować kanału',
 			'error' => 'Nie udało się zaktualizować kanału',
+			'favicon' => array(
+				'too_large' => 'Przesłana ikona jest zbyt wielka. Maksymalny rozmiar pliku to <em>%s</em>.',
+				'unsupported_format' => 'Nieobsługiwany format pliku obrazka!',
+			),
 			'internal_problem' => 'Wystąpił błąd podczas dodawania kanału. <a href="%s">Sprawdź dziennik</a> w celu uzyskania szczegółowych informacji. Można spróbować wymusić dodanie kanału przez dodanie <code>#force_feed</code> na końcu adresu URL.',
 			'internal_problem' => 'Wystąpił błąd podczas dodawania kanału. <a href="%s">Sprawdź dziennik</a> w celu uzyskania szczegółowych informacji. Można spróbować wymusić dodanie kanału przez dodanie <code>#force_feed</code> na końcu adresu URL.',
 			'invalid_url' => 'Adres URL <em>%s</em> nie jest prawidłowy',
 			'invalid_url' => 'Adres URL <em>%s</em> nie jest prawidłowy',
 			'n_actualized' => 'Liczba zaktualizowanych kanałów: %d',
 			'n_actualized' => 'Liczba zaktualizowanych kanałów: %d',

+ 5 - 0
app/i18n/pl/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Hasło HTTP',
 			'password' => 'Hasło HTTP',
 			'username' => 'Użytkownik HTTP',
 			'username' => 'Użytkownik HTTP',
 		),
 		),
+		'change_favicon' => 'Zmień…',
 		'clear_cache' => 'Zawsze czyść pamięć podręczną',
 		'clear_cache' => 'Zawsze czyść pamięć podręczną',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Sposób zachowania zawartości pobranej z pierwotnej strony',
 			'_' => 'Sposób zachowania zawartości pobranej z pierwotnej strony',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'Plik XML (podzbiór danych. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Zobacz dokumentację</a>)',
 			'help' => 'Plik XML (podzbiór danych. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Zobacz dokumentację</a>)',
 			'label' => 'Eksportuj OPML',
 			'label' => 'Eksportuj OPML',
 		),
 		),
+		'ext_favicon' => 'Ustaw automatycznie',
+		'favicon_changed_by_ext' => 'Ikona została ustawiona przez rozszerzenie <b>%s</b>.',
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Akcje filtrowania',
 			'_' => 'Akcje filtrowania',
 			'help' => 'Jedno zapytanie na linię. Operatory opisane są w <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">dokumentacji</a>.',
 			'help' => 'Jedno zapytanie na linię. Operatory opisane są w <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">dokumentacji</a>.',
 		),
 		),
 		'http_headers' => 'Nagłówki HTTP',
 		'http_headers' => 'Nagłówki HTTP',
 		'http_headers_help' => 'Nagłówki są oddzielane przez nową linię, a nazwa i wartość nagłówka są oddzielane przez dwukropek (np: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer jakiś-token</code></kbd>).',
 		'http_headers_help' => 'Nagłówki są oddzielane przez nową linię, a nazwa i wartość nagłówka są oddzielane przez dwukropek (np: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer jakiś-token</code></kbd>).',
+		'icon' => 'Ikona',
 		'information' => 'Informacja',
 		'information' => 'Informacja',
 		'keep_min' => 'Minimalna liczba wiadomości do przechowywania',
 		'keep_min' => 'Minimalna liczba wiadomości do przechowywania',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Serwer proxy używany podczas pobierania kanału',
 		'proxy' => 'Serwer proxy używany podczas pobierania kanału',
 		'proxy_help' => 'Wybierz protokół (np. SOCKS5) i podaj adres serwera proxy (np. <kbd>127.0.0.1:1080</kbd> lub <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Wybierz protokół (np. SOCKS5) i podaj adres serwera proxy (np. <kbd>127.0.0.1:1080</kbd> lub <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Przywróć domyślną',
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Pokaż źródło',
 			'show_raw' => 'Pokaż źródło',
 			'show_rendered' => 'Pokaż zawartość',
 			'show_rendered' => 'Pokaż zawartość',

+ 4 - 0
app/i18n/pt-br/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => 'O cache do feed <em>%s</em> foi limpo',
 			'cache_cleared' => 'O cache do feed <em>%s</em> foi limpo',
 			'deleted' => 'o feed foi deletado',
 			'deleted' => 'o feed foi deletado',
 			'error' => 'O feed não pode ser atualizado',
 			'error' => 'O feed não pode ser atualizado',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'O feed RSS não pôde ser adicionado. <a href="%s">Verifique os logs do FreshRSS</a> para detalhes. You can try force adding by appending <code>#force_feed</code> to the URL.',	// DIRTY
 			'internal_problem' => 'O feed RSS não pôde ser adicionado. <a href="%s">Verifique os logs do FreshRSS</a> para detalhes. You can try force adding by appending <code>#force_feed</code> to the URL.',	// DIRTY
 			'invalid_url' => 'URL <em>%s</em> é inválida',
 			'invalid_url' => 'URL <em>%s</em> é inválida',
 			'n_actualized' => '%d feeds foram atualizados',
 			'n_actualized' => '%d feeds foram atualizados',

+ 5 - 0
app/i18n/pt-br/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Senha HTTP',
 			'password' => 'Senha HTTP',
 			'username' => 'Usuário HTTP',
 			'username' => 'Usuário HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Sempre limpar o cache',
 		'clear_cache' => 'Sempre limpar o cache',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Ações ao buscar pelo conteúdo de artigos',
 			'_' => 'Ações ao buscar pelo conteúdo de artigos',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'Arquivo XML (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'help' => 'Arquivo XML (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'label' => 'Exportar como OPML',
 			'label' => 'Exportar como OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Ações do filtro',
 			'_' => 'Ações do filtro',
 			'help' => 'Escreva um filtro de pesquisa por linha. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 			'help' => 'Escreva um filtro de pesquisa por linha. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informações',
 		'information' => 'Informações',
 		'keep_min' => 'Número mínimo de artigos para manter',
 		'keep_min' => 'Número mínimo de artigos para manter',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Defina um proxy para buscar esse feed',
 		'proxy' => 'Defina um proxy para buscar esse feed',
 		'proxy_help' => 'Selecione um protocolo (e.g: SOCKS5) e digite o endereço do proxy (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Selecione um protocolo (e.g: SOCKS5) e digite o endereço do proxy (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Mostrar fonte',
 			'show_raw' => 'Mostrar fonte',
 			'show_rendered' => 'Mostrar conteúdo',
 			'show_rendered' => 'Mostrar conteúdo',

+ 4 - 0
app/i18n/pt-pt/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => 'O cache do feed <em>%s</em> foi limpo',
 			'cache_cleared' => 'O cache do feed <em>%s</em> foi limpo',
 			'deleted' => 'o feed foi apagado',
 			'deleted' => 'o feed foi apagado',
 			'error' => 'O feed não pode ser atualizado',
 			'error' => 'O feed não pode ser atualizado',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'O feed RSS não pôde ser adicionado. <a href="%s">Verifique os logs do FreshRSS</a> para detalhes. Pode forçar a atualização no link <code>#force_feed</code> .',
 			'internal_problem' => 'O feed RSS não pôde ser adicionado. <a href="%s">Verifique os logs do FreshRSS</a> para detalhes. Pode forçar a atualização no link <code>#force_feed</code> .',
 			'invalid_url' => 'URL <em>%s</em> é inválida',
 			'invalid_url' => 'URL <em>%s</em> é inválida',
 			'n_actualized' => '%d feeds foram atualizados',
 			'n_actualized' => '%d feeds foram atualizados',

+ 5 - 0
app/i18n/pt-pt/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Senha HTTP',
 			'password' => 'Senha HTTP',
 			'username' => 'Utilizador HTTP',
 			'username' => 'Utilizador HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Sempre limpar o cache',
 		'clear_cache' => 'Sempre limpar o cache',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Ações ao buscar pelo conteúdo de artigos',
 			'_' => 'Ações ao buscar pelo conteúdo de artigos',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'Arquivo XML (. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Ver documentação</a>)',
 			'help' => 'Arquivo XML (. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Ver documentação</a>)',
 			'label' => 'Exportar como OPML',
 			'label' => 'Exportar como OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Ações do filtro',
 			'_' => 'Ações do filtro',
 			'help' => 'Escreva um filtro de pesquisa por linha. <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">Ver documentação</a>.',
 			'help' => 'Escreva um filtro de pesquisa por linha. <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">Ver documentação</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informações',
 		'information' => 'Informações',
 		'keep_min' => 'Número mínimo de artigos para manter',
 		'keep_min' => 'Número mínimo de artigos para manter',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Defina um proxy para buscar esse feed',
 		'proxy' => 'Defina um proxy para buscar esse feed',
 		'proxy_help' => 'Selecione um protocolo (e.g: SOCKS5) e digite o endereço do proxy (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => 'Selecione um protocolo (e.g: SOCKS5) e digite o endereço do proxy (e.g: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Mostrar fonte',
 			'show_raw' => 'Mostrar fonte',
 			'show_rendered' => 'Mostrar conteúdo',
 			'show_rendered' => 'Mostrar conteúdo',

+ 4 - 0
app/i18n/ru/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => 'Кэш <em>%s</em> очищен',
 			'cache_cleared' => 'Кэш <em>%s</em> очищен',
 			'deleted' => 'Лента удалена',
 			'deleted' => 'Лента удалена',
 			'error' => 'Лента не может быть изменена',
 			'error' => 'Лента не может быть изменена',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Новостная лента не может быть добавлена. <a href="%s">Проверьте логи FreshRSS</a> для подробностей. Вы можете попробовать принудительно добавить ленту, добавив <code>#force_feed</code> к URL.',
 			'internal_problem' => 'Новостная лента не может быть добавлена. <a href="%s">Проверьте логи FreshRSS</a> для подробностей. Вы можете попробовать принудительно добавить ленту, добавив <code>#force_feed</code> к URL.',
 			'invalid_url' => 'URL <em>%s</em> неверный',
 			'invalid_url' => 'URL <em>%s</em> неверный',
 			'n_actualized' => '%d лент обновлено',
 			'n_actualized' => '%d лент обновлено',

+ 5 - 0
app/i18n/ru/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Пароль HTTP',
 			'password' => 'Пароль HTTP',
 			'username' => 'Имя пользователя HTTP',
 			'username' => 'Имя пользователя HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Всегда очищать кэш',
 		'clear_cache' => 'Всегда очищать кэш',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Действие с содержимым, когда извлекается содержимое статьи',
 			'_' => 'Действие с содержимым, когда извлекается содержимое статьи',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML файл (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'help' => 'XML файл (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'label' => 'Экспортировать как OPML',
 			'label' => 'Экспортировать как OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Действия фильтрации',
 			'_' => 'Действия фильтрации',
 			'help' => 'Введите по одному поисковому фильтру в строке. См. <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">документацию</a>.',
 			'help' => 'Введите по одному поисковому фильтру в строке. См. <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">документацию</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Информация',
 		'information' => 'Информация',
 		'keep_min' => 'Оставлять статей не менее',
 		'keep_min' => 'Оставлять статей не менее',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Указать прокси для извлечения этой ленты',
 		'proxy' => 'Указать прокси для извлечения этой ленты',
 		'proxy_help' => 'Выберите протокол (например, SOCKS5) и введите адрес прокси (например, <kbd>127.0.0.1:1080</kbd> или <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
 		'proxy_help' => 'Выберите протокол (например, SOCKS5) и введите адрес прокси (например, <kbd>127.0.0.1:1080</kbd> или <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Показать исходный код',
 			'show_raw' => 'Показать исходный код',
 			'show_rendered' => 'Показать содержимое',
 			'show_rendered' => 'Показать содержимое',

+ 4 - 0
app/i18n/sk/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> vyrovnávacia pamäť bola vymazaná',
 			'cache_cleared' => '<em>%s</em> vyrovnávacia pamäť bola vymazaná',
 			'deleted' => 'Kanál bol vymazaný',
 			'deleted' => 'Kanál bol vymazaný',
 			'error' => 'Kanál sa nepodarilo aktualizovať',
 			'error' => 'Kanál sa nepodarilo aktualizovať',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Kanál sa nepodarilo pridať. <a href="%s">Prečítajte si záznamy FreshRSS</a>, ak chcete poznať podrobnosti. Skúste pridať kanál pomocou <code>#force_feed</code> v odkaze (URL).',
 			'internal_problem' => 'Kanál sa nepodarilo pridať. <a href="%s">Prečítajte si záznamy FreshRSS</a>, ak chcete poznať podrobnosti. Skúste pridať kanál pomocou <code>#force_feed</code> v odkaze (URL).',
 			'invalid_url' => 'Odkaz <em>%s</em> je neplatný',
 			'invalid_url' => 'Odkaz <em>%s</em> je neplatný',
 			'n_actualized' => 'Počet aktualizovaných kanálov: %d',
 			'n_actualized' => 'Počet aktualizovaných kanálov: %d',

+ 5 - 0
app/i18n/sk/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'Heslo pre HTTP',
 			'password' => 'Heslo pre HTTP',
 			'username' => 'Používateľské meno pre HTTP',
 			'username' => 'Používateľské meno pre HTTP',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Vždy vymazať vyrovnávaciu pamäť',
 		'clear_cache' => 'Vždy vymazať vyrovnávaciu pamäť',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Akcia obsahu pri sťahovaní obsahu článku',
 			'_' => 'Akcia obsahu pri sťahovaní obsahu článku',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML súbor (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'help' => 'XML súbor (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'label' => 'Exportovať ako OPML',
 			'label' => 'Exportovať ako OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filtrovať akcie',
 			'_' => 'Filtrovať akcie',
 			'help' => 'Napíšte jeden výraz hľadania na riadok. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 			'help' => 'Napíšte jeden výraz hľadania na riadok. Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => 'Informácia',
 		'information' => 'Informácia',
 		'keep_min' => 'Minimálny počet článkov na uchovanie',
 		'keep_min' => 'Minimálny počet článkov na uchovanie',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Na sťahovanie tohto kanálu nastaviť proxy',
 		'proxy' => 'Na sťahovanie tohto kanálu nastaviť proxy',
 		'proxy_help' => 'Vyberte protokol (napr.: SOCKS5) a zadajte adresu proxy servera (napr.: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
 		'proxy_help' => 'Vyberte protokol (napr.: SOCKS5) a zadajte adresu proxy servera (napr.: <kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Zobraziť zdrojový kód',
 			'show_raw' => 'Zobraziť zdrojový kód',
 			'show_rendered' => 'Zobraziť obsah',
 			'show_rendered' => 'Zobraziť obsah',

+ 4 - 0
app/i18n/tr/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> önbelleği temizlendi',
 			'cache_cleared' => '<em>%s</em> önbelleği temizlendi',
 			'deleted' => 'Besleme silindi',
 			'deleted' => 'Besleme silindi',
 			'error' => 'Besleme güncellenemedi',
 			'error' => 'Besleme güncellenemedi',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => 'Haber akışı eklenemedi. Ayrıntılar için <a href="%s">FreshRSS günlüklerini kontrol edin</a>. URL’ye <code>#force_feed</code> ekleyerek zorla eklemeyi deneyebilirsiniz.',
 			'internal_problem' => 'Haber akışı eklenemedi. Ayrıntılar için <a href="%s">FreshRSS günlüklerini kontrol edin</a>. URL’ye <code>#force_feed</code> ekleyerek zorla eklemeyi deneyebilirsiniz.',
 			'invalid_url' => '<em>%s</em> URL’si geçersiz',
 			'invalid_url' => '<em>%s</em> URL’si geçersiz',
 			'n_actualized' => '%d besleme güncellendi',
 			'n_actualized' => '%d besleme güncellendi',

+ 5 - 0
app/i18n/tr/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP parolası',
 			'password' => 'HTTP parolası',
 			'username' => 'HTTP kullanıcı adı',
 			'username' => 'HTTP kullanıcı adı',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => 'Önbelleği her zaman temizle',
 		'clear_cache' => 'Önbelleği her zaman temizle',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => 'Makale içeriği getirilirken içerik eylemi',
 			'_' => 'Makale içeriği getirilirken içerik eylemi',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML dosyası (veri alt kümesi. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Belgelere bakın</a>)',
 			'help' => 'XML dosyası (veri alt kümesi. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">Belgelere bakın</a>)',
 			'label' => 'OPML olarak dışa aktar',
 			'label' => 'OPML olarak dışa aktar',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => 'Filtre eylemleri',
 			'_' => 'Filtre eylemleri',
 			'help' => 'Her satıra bir arama filtresi yazın. Operatörler için <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">belgelere bakın</a>.',
 			'help' => 'Her satıra bir arama filtresi yazın. Operatörler için <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">belgelere bakın</a>.',
 		),
 		),
 		'http_headers' => 'HTTP Başlıkları',
 		'http_headers' => 'HTTP Başlıkları',
 		'http_headers_help' => 'Başlıklar yeni bir satırla ayrılır ve bir başlığın adı ile değeri iki nokta üst üste ile ayrılır (örneğin: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
 		'http_headers_help' => 'Başlıklar yeni bir satırla ayrılır ve bir başlığın adı ile değeri iki nokta üst üste ile ayrılır (örneğin: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',
+		'icon' => 'Icon',	// TODO
 		'information' => 'Bilgi',
 		'information' => 'Bilgi',
 		'keep_min' => 'Saklanacak minimum makale sayısı',
 		'keep_min' => 'Saklanacak minimum makale sayısı',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => 'Bu beslemeyi almak için bir proxy ayarlayın',
 		'proxy' => 'Bu beslemeyi almak için bir proxy ayarlayın',
 		'proxy_help' => 'Bir protokol seçin (örneğin: SOCKS5) ve proxy adresini girin (örneğin: <kbd>127.0.0.1:1080</kbd> veya <kbd>kullanıcıadı:parola@127.0.0.1:1080</kbd>).',
 		'proxy_help' => 'Bir protokol seçin (örneğin: SOCKS5) ve proxy adresini girin (örneğin: <kbd>127.0.0.1:1080</kbd> veya <kbd>kullanıcıadı:parola@127.0.0.1:1080</kbd>).',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => 'Kaynak kodu göster',
 			'show_raw' => 'Kaynak kodu göster',
 			'show_rendered' => 'İçeriği göster',
 			'show_rendered' => 'İçeriği göster',

+ 4 - 0
app/i18n/zh-cn/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> 缓存已清理',
 			'cache_cleared' => '<em>%s</em> 缓存已清理',
 			'deleted' => '已删除订阅源',
 			'deleted' => '已删除订阅源',
 			'error' => '订阅源更新失败',
 			'error' => '订阅源更新失败',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => '订阅源添加失败,<a href="%s">检查 FreshRSS 日志</a> 查看详情。你可以在 URL 后添加 <code>#force_feed</code> 尝试强制添加。',
 			'internal_problem' => '订阅源添加失败,<a href="%s">检查 FreshRSS 日志</a> 查看详情。你可以在 URL 后添加 <code>#force_feed</code> 尝试强制添加。',
 			'invalid_url' => 'URL <em>%s</em> 无效',
 			'invalid_url' => 'URL <em>%s</em> 无效',
 			'n_actualized' => '已更新 %d 个订阅源',
 			'n_actualized' => '已更新 %d 个订阅源',

+ 5 - 0
app/i18n/zh-cn/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP 密码',
 			'password' => 'HTTP 密码',
 			'username' => 'HTTP 用户名',
 			'username' => 'HTTP 用户名',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => '总是清除缓存',
 		'clear_cache' => '总是清除缓存',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => '获取原文后的操作',
 			'_' => '获取原文后的操作',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML 文件 (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'help' => 'XML 文件 (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// DIRTY
 			'label' => '导出为 OPML',
 			'label' => '导出为 OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => '过滤动作',
 			'_' => '过滤动作',
 			'help' => '每行写一条过滤规则,过滤规则可见 <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">文档</a>。',
 			'help' => '每行写一条过滤规则,过滤规则可见 <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">文档</a>。',
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => '信息',
 		'information' => '信息',
 		'keep_min' => '至少保存的文章数',
 		'keep_min' => '至少保存的文章数',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => '获取订阅源时的代理',
 		'proxy' => '获取订阅源时的代理',
 		'proxy_help' => '选择协议(例:SOCKS5)和代理地址(例:<kbd>127.0.0.1:1080</kbd> 或者 <kbd>username:password@127.0.0.1:1080</kbd>)',
 		'proxy_help' => '选择协议(例:SOCKS5)和代理地址(例:<kbd>127.0.0.1:1080</kbd> 或者 <kbd>username:password@127.0.0.1:1080</kbd>)',
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => '显示源码',
 			'show_raw' => '显示源码',
 			'show_rendered' => '显示内容',
 			'show_rendered' => '显示内容',

+ 4 - 0
app/i18n/zh-tw/feedback.php

@@ -95,6 +95,10 @@ return array(
 			'cache_cleared' => '<em>%s</em> 緩存已清理',
 			'cache_cleared' => '<em>%s</em> 緩存已清理',
 			'deleted' => '已刪除訂閱源',
 			'deleted' => '已刪除訂閱源',
 			'error' => '訂閱源更新失敗',
 			'error' => '訂閱源更新失敗',
+			'favicon' => array(
+				'too_large' => 'Uploaded icon is too large. The maximum file size is <em>%s</em>.',	// TODO
+				'unsupported_format' => 'Unsupported image file format!',	// TODO
+			),
 			'internal_problem' => '訂閱源添加失敗。<a href="%s">檢查 FreshRSS 日誌</a> 查看詳情。你可以在地址連結後附加 <code>#force_feed</code> 從而嘗試強制添加。',
 			'internal_problem' => '訂閱源添加失敗。<a href="%s">檢查 FreshRSS 日誌</a> 查看詳情。你可以在地址連結後附加 <code>#force_feed</code> 從而嘗試強制添加。',
 			'invalid_url' => '地址鏈接 <em>%s</em> 無效',
 			'invalid_url' => '地址鏈接 <em>%s</em> 無效',
 			'n_actualized' => '已更新 %d 個訂閱源',
 			'n_actualized' => '已更新 %d 個訂閱源',

+ 5 - 0
app/i18n/zh-tw/sub.php

@@ -50,6 +50,7 @@ return array(
 			'password' => 'HTTP 密碼',
 			'password' => 'HTTP 密碼',
 			'username' => 'HTTP 用戶名',
 			'username' => 'HTTP 用戶名',
 		),
 		),
+		'change_favicon' => 'Change…',	// TODO
 		'clear_cache' => '總是清除暫存',
 		'clear_cache' => '總是清除暫存',
 		'content_action' => array(
 		'content_action' => array(
 			'_' => '獲取原文後的操作',
 			'_' => '獲取原文後的操作',
@@ -74,12 +75,15 @@ return array(
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'help' => 'XML file (data subset. <a href="https://freshrss.github.io/FreshRSS/en/developers/OPML.html" target="_blank">See documentation</a>)',	// TODO
 			'label' => '匯出為OPML',
 			'label' => '匯出為OPML',
 		),
 		),
+		'ext_favicon' => 'Set automatically',	// TODO
+		'favicon_changed_by_ext' => 'The icon has been set by the <b>%s</b> extension.',	// TODO
 		'filteractions' => array(
 		'filteractions' => array(
 			'_' => '過濾動作',
 			'_' => '過濾動作',
 			'help' => '每行寫一條過濾搜尋 Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 			'help' => '每行寫一條過濾搜尋 Operators <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">see documentation</a>.',	// DIRTY
 		),
 		),
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers' => 'HTTP Headers',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
 		'http_headers_help' => 'Headers are separated by a newline, and the name and value of a header are separated by a colon (e.g: <kbd><code>Accept: application/atom+xml<br />Authorization: Bearer some-token</code></kbd>).',	// TODO
+		'icon' => 'Icon',	// TODO
 		'information' => '信息',
 		'information' => '信息',
 		'keep_min' => '至少保存的文章數',
 		'keep_min' => '至少保存的文章數',
 		'kind' => array(
 		'kind' => array(
@@ -212,6 +216,7 @@ return array(
 		),
 		),
 		'proxy' => '獲取訂閱源時的代理',
 		'proxy' => '獲取訂閱源時的代理',
 		'proxy_help' => '選擇協議(例:SOCKS5)和代理地址(例:<kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
 		'proxy_help' => '選擇協議(例:SOCKS5)和代理地址(例:<kbd>127.0.0.1:1080</kbd> or <kbd>username:password@127.0.0.1:1080</kbd>)',	// DIRTY
+		'reset_favicon' => 'Reset to default',	// TODO
 		'selector_preview' => array(
 		'selector_preview' => array(
 			'show_raw' => '顯示源碼',
 			'show_raw' => '顯示源碼',
 			'show_rendered' => '顯示內容',
 			'show_rendered' => '顯示內容',

+ 30 - 2
app/views/helpers/feed/update.phtml

@@ -5,7 +5,7 @@
 		return;
 		return;
 	}
 	}
 ?>
 ?>
-<div class="post" id="feed_update">
+<div class="post" id="feed_update" data-feed-id="<?= $this->feed->id() ?>">
 	<h1><?= $this->feed->name() ?></h1>
 	<h1><?= $this->feed->name() ?></h1>
 
 
 	<div>
 	<div>
@@ -35,10 +35,38 @@
 		}
 		}
 	}
 	}
 	?>
 	?>
-	<form method="post" action="<?= $url ?>" autocomplete="off">
+	<form method="post" action="<?= $url ?>" autocomplete="off" enctype="multipart/form-data">
 		<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
 		<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
 		<fieldset>
 		<fieldset>
 			<legend><?= _t('sub.feed.information') ?></legend>
 			<legend><?= _t('sub.feed.information') ?></legend>
+			<div class="form-group">
+				<?php
+					$currentIconUrl = $this->feed->favicon();
+					$originalIconUrl = '';
+					if ($this->feed->customFavicon()) {
+						$this->feed->_attribute('customFavicon', false);
+						$this->feed->resetFaviconHash();
+					}
+					$originalIconUrl = $this->feed->favicon();
+					$this->feed->_attribute('customFavicon', $currentIconUrl !== $originalIconUrl);
+					$this->feed->resetFaviconHash();
+					$ext_url = Minz_ExtensionManager::callHook('custom_favicon_btn_url', $this->feed);
+				?>
+				<label class="group-name"><?= _t('sub.feed.icon') ?></label>
+				<div class="group-controls">
+					<img class="favicon upload" src="<?= $currentIconUrl ?>" data-initial-src="<?= $currentIconUrl ?>" data-original-icon="<?= $originalIconUrl ?>" alt="✇" loading="lazy" />
+					<div class="favicon-controls">
+						<input id="favicon-upload" name="newFavicon" type="file" accept="image/*" />
+						<label for="favicon-upload" class="btn"><?= _t('sub.feed.change_favicon') ?></label>
+						<button id="reset-favicon" class="btn"<?= $this->feed->customFavicon() ? '' : ' disabled="disabled"'?>><?= _t('sub.feed.reset_favicon') ?></button>
+						<?php if (is_string($ext_url)) { ?>
+							<button id="favicon-ext-btn" class="btn" data-extension-url="<?= htmlspecialchars(html_entity_decode($ext_url), ENT_COMPAT, 'UTF-8') ?>"><?= _t('sub.feed.ext_favicon') ?></button>
+						<?php } ?>
+					</div>
+					<p id="favicon-ext" class="<?= $this->feed->customFaviconExt() !== null ? '' : 'hidden' ?>"><?= _t('sub.feed.favicon_changed_by_ext', htmlspecialchars($this->feed->customFaviconExt() ?? '', ENT_NOQUOTES, 'UTF-8')) ?></p>
+					<p id="favicon-error" class="error"></p>
+				</div>
+			</div>
 			<div class="form-group">
 			<div class="form-group">
 				<label class="group-name" for="name"><?= _t('sub.feed.title') ?></label>
 				<label class="group-name" for="name"><?= _t('sub.feed.title') ?></label>
 				<div class="group-controls">
 				<div class="group-controls">

+ 4 - 2
app/views/helpers/javascript_vars.phtml

@@ -4,7 +4,7 @@ declare(strict_types=1);
 $mark = FreshRSS_Context::userConf()->mark_when;
 $mark = FreshRSS_Context::userConf()->mark_when;
 $s = FreshRSS_Context::userConf()->shortcuts;
 $s = FreshRSS_Context::userConf()->shortcuts;
 $extData = Minz_ExtensionManager::callHook('js_vars', []);
 $extData = Minz_ExtensionManager::callHook('js_vars', []);
-echo htmlspecialchars(json_encode([
+echo json_encode([
 	'context' => [
 	'context' => [
 		'anonymous' => !FreshRSS_Auth::hasAccess(),
 		'anonymous' => !FreshRSS_Auth::hasAccess(),
 		'auto_remove_article' => !!FreshRSS_Context::isAutoRemoveAvailable(),
 		'auto_remove_article' => !!FreshRSS_Context::isAutoRemoveAvailable(),
@@ -30,6 +30,7 @@ echo htmlspecialchars(json_encode([
 			'extra.js' => @filemtime(PUBLIC_PATH . '/scripts/extra.js'),
 			'extra.js' => @filemtime(PUBLIC_PATH . '/scripts/extra.js'),
 			'feed.js' => @filemtime(PUBLIC_PATH . '/scripts/feed.js'),
 			'feed.js' => @filemtime(PUBLIC_PATH . '/scripts/feed.js'),
 		],
 		],
+		'max_favicon_upload_size' => FreshRSS_Context::systemConf()->limits['max_favicon_upload_size'],
 		'version' => FRESHRSS_VERSION,
 		'version' => FRESHRSS_VERSION,
 	],
 	],
 	'shortcuts' => [
 	'shortcuts' => [
@@ -73,6 +74,7 @@ echo htmlspecialchars(json_encode([
 		'notif_request_failed' => _t('gen.js.feedback.request_failed'),
 		'notif_request_failed' => _t('gen.js.feedback.request_failed'),
 		'category_empty' => _t('gen.js.category_empty'),
 		'category_empty' => _t('gen.js.category_empty'),
 		'labels_empty' => _t('gen.js.labels_empty'),
 		'labels_empty' => _t('gen.js.labels_empty'),
+		'favicon_size_exceeded' => _t('feedback.sub.feed.favicon.too_large', format_bytes(FreshRSS_Context::systemConf()->limits['max_favicon_upload_size'])),
 		'language' => FreshRSS_Context::userConf()->language,
 		'language' => FreshRSS_Context::userConf()->language,
 	],
 	],
 	'icons' => [
 	'icons' => [
@@ -81,4 +83,4 @@ echo htmlspecialchars(json_encode([
 		'spinner' => '../themes/icons/spinner.svg',
 		'spinner' => '../themes/icons/spinner.svg',
 	],
 	],
 	'extensions' => $extData,
 	'extensions' => $extData,
-], JSON_UNESCAPED_UNICODE) ?: '', ENT_NOQUOTES, 'UTF-8');
+], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP);

+ 3 - 0
config.default.php

@@ -125,6 +125,9 @@ return [
 		#   0 for an unlimited number of accounts
 		#   0 for an unlimited number of accounts
 		#   1 is to not allow user registrations (1 is corresponding to the admin account)
 		#   1 is to not allow user registrations (1 is corresponding to the admin account)
 		'max_registrations' => 1,
 		'max_registrations' => 1,
+
+		# Max amount of bytes that are allowed for upload of custom favicon
+		'max_favicon_upload_size' => 1048576,	# 1 MiB
 	],
 	],
 
 
 	# Options used by cURL when making HTTP requests, e.g. when the SimplePie library retrieves feeds.
 	# Options used by cURL when making HTTP requests, e.g. when the SimplePie library retrieves feeds.

+ 6 - 0
docs/en/developers/03_Backend/05_Extensions.md

@@ -167,6 +167,12 @@ The following events are available:
 * `api_misc` (`function(): void`): to allow extensions to have own API endpoint
 * `api_misc` (`function(): void`): to allow extensions to have own API endpoint
 	on `/api/misc.php/Extension%20Name/` or `/api/misc.php?ext=Extension%20Name`.
 	on `/api/misc.php/Extension%20Name/` or `/api/misc.php?ext=Extension%20Name`.
 * `check_url_before_add` (`function($url) -> Url | null`): will be executed every time a URL is added. The URL itself will be passed as parameter. This way a website known to have feeds which doesn’t advertise it in the header can still be automatically supported.
 * `check_url_before_add` (`function($url) -> Url | null`): will be executed every time a URL is added. The URL itself will be passed as parameter. This way a website known to have feeds which doesn’t advertise it in the header can still be automatically supported.
+* `custom_favicon_btn_url` (`function(FreshRSS_Feed $feed): string | null`): Allows extensions to implement a button for setting a custom favicon for individual feeds by providing an URL. The URL will be sent a POST request with the `extAction` field set to either `query_icon_info` or `update_icon`, along with an `id` field which describes the feed's ID.
+Example response for a `query_icon_info` request:
+```json
+{"extName":"YouTube Video Feed","iconUrl":"..\/f.php?h=40838a43"}
+```
+* `custom_favicon_hash` (`function(FreshRSS_Feed $feed): string | null`): Enables the modification of custom favicon hashes by returning params from the hook function. The hook should check if the `customFaviconExt` attribute of `$feed` is set to the extension's name before returning a custom value. Otherwise, the return value should be null.
 * `entry_auto_read` (`function(FreshRSS_Entry $entry, string $why): void`): Triggered when an entry is automatically marked as read. The *why* parameter supports the rules {`filter`, `upon_reception`, `same_title_in_feed`}.
 * `entry_auto_read` (`function(FreshRSS_Entry $entry, string $why): void`): Triggered when an entry is automatically marked as read. The *why* parameter supports the rules {`filter`, `upon_reception`, `same_title_in_feed`}.
 * `entry_auto_unread` (`function(FreshRSS_Entry $entry, string $why): void`): Triggered when an entry is automatically marked as unread. The *why* parameter supports the rules {`updated_article`}.
 * `entry_auto_unread` (`function(FreshRSS_Entry $entry, string $why): void`): Triggered when an entry is automatically marked as unread. The *why* parameter supports the rules {`updated_article`}.
 * `entry_before_display` (`function($entry) -> Entry | null`): will be executed every time an entry is rendered. The entry itself (instance of FreshRSS\_Entry) will be passed as parameter.
 * `entry_before_display` (`function($entry) -> Entry | null`): will be executed every time an entry is rendered. The entry itself (instance of FreshRSS\_Entry) will be passed as parameter.

+ 21 - 1
lib/Minz/ExtensionManager.php

@@ -30,6 +30,14 @@ final class Minz_ExtensionManager {
 			'list' => [],
 			'list' => [],
 			'signature' => 'OneToOne',
 			'signature' => 'OneToOne',
 		],
 		],
+		'custom_favicon_btn_url' => [ // function(FreshRSS_Feed $feed): string | null
+			'list' => [],
+			'signature' => 'PassArguments',
+		],
+		'custom_favicon_hash' => [ // function(FreshRSS_Feed $feed): string | null
+			'list' => [],
+			'signature' => 'PassArguments',
+		],
 		'entries_favorite' => [	// function(array $ids, bool $is_favorite): void
 		'entries_favorite' => [	// function(array $ids, bool $is_favorite): void
 			'list' => [],
 			'list' => [],
 			'signature' => 'PassArguments',
 			'signature' => 'PassArguments',
@@ -366,7 +374,10 @@ final class Minz_ExtensionManager {
 			return self::callOneToOne($hook_name, $args[0] ?? null);
 			return self::callOneToOne($hook_name, $args[0] ?? null);
 		} elseif ($signature === 'PassArguments') {
 		} elseif ($signature === 'PassArguments') {
 			foreach (self::$hook_list[$hook_name]['list'] as $function) {
 			foreach (self::$hook_list[$hook_name]['list'] as $function) {
-				call_user_func($function, ...$args);
+				$result = call_user_func($function, ...$args);
+				if ($result !== null) {
+					return $result;
+				}
 			}
 			}
 		} elseif ($signature === 'NoneToString') {
 		} elseif ($signature === 'NoneToString') {
 			return self::callHookString($hook_name);
 			return self::callHookString($hook_name);
@@ -451,4 +462,13 @@ final class Minz_ExtensionManager {
 		}
 		}
 		return false;
 		return false;
 	}
 	}
+
+	/**
+	 * Check if a extension is enabled
+	 *
+	 * @param string $ext_name is the extension's name as provided in metadata.json
+	 */
+	public static function isExtensionEnabled(string $ext_name): bool {
+		return isset(self::$ext_list_enabled[$ext_name]);
+	}
 }
 }

+ 4 - 4
lib/favicons.php

@@ -12,12 +12,12 @@ function isImgMime(string $content): bool {
 	if (!extension_loaded('fileinfo')) {
 	if (!extension_loaded('fileinfo')) {
 		return true;
 		return true;
 	}
 	}
-	$isImage = true;
-	/** @var finfo $fInfo */
 	$fInfo = finfo_open(FILEINFO_MIME_TYPE);
 	$fInfo = finfo_open(FILEINFO_MIME_TYPE);
-	/** @var string $content */
+	if ($fInfo === false) {
+		return true;
+	}
 	$content = finfo_buffer($fInfo, $content);
 	$content = finfo_buffer($fInfo, $content);
-	$isImage = strpos($content, 'image') !== false;
+	$isImage = str_contains($content ?: '', 'image');
 	finfo_close($fInfo);
 	finfo_close($fInfo);
 	return $isImage;
 	return $isImage;
 }
 }

+ 4 - 1
p/f.php

@@ -14,7 +14,7 @@ function show_default_favicon(int $cacheSeconds = 3600): void {
 	}
 	}
 }
 }
 
 
-$id = $_SERVER['QUERY_STRING'] ?? '0';
+$id = $_GET['h'] ?? '0';
 if (!is_string($id) || !ctype_xdigit($id)) {
 if (!is_string($id) || !ctype_xdigit($id)) {
 	$id = '0';
 	$id = '0';
 }
 }
@@ -53,5 +53,8 @@ if (!httpConditional($ico_mtime, mt_rand(14, 21) * 86400, 2)) {
 	$ico_content_type = contentType($ico);
 	$ico_content_type = contentType($ico);
 	header('Content-Type: ' . $ico_content_type);
 	header('Content-Type: ' . $ico_content_type);
 	header('Content-Disposition: inline; filename="' . $id . '.ico"');
 	header('Content-Disposition: inline; filename="' . $id . '.ico"');
+	if (isset($_GET['t'])) {
+		header('Cache-Control: immutable');
+	}
 	readfile($ico);
 	readfile($ico);
 }
 }

+ 146 - 0
p/scripts/extra.js

@@ -144,6 +144,150 @@ function init_archiving(parent) {
 	});
 	});
 }
 }
 
 
+function init_update_feed() {
+	const feed_update = document.querySelector('div.post#feed_update');
+	if (!feed_update) {
+		return;
+	}
+
+	const faviconUpload = feed_update.querySelector('#favicon-upload');
+	const resetFavicon = feed_update.querySelector('#reset-favicon');
+	const faviconError = feed_update.querySelector('#favicon-error');
+	const faviconExt = feed_update.querySelector('#favicon-ext');
+	const extension = faviconExt.querySelector('b');
+	const faviconExtBtn = feed_update.querySelector('#favicon-ext-btn');
+	const favicon = feed_update.querySelector('.favicon');
+
+	function clearUploadedIcon() {
+		faviconUpload.value = '';
+	}
+	function discardIconChange() {
+		const resetField = feed_update.querySelector('input[name="resetFavicon"]');
+		if (resetField) {
+			resetField.remove();
+		}
+		if (faviconExtBtn) {
+			faviconExtBtn.disabled = false;
+			extension.innerText = extension.dataset.initialExt ?? extension.innerText;
+		}
+		if (extension.innerText == '') {
+			faviconExt.classList.add('hidden');
+		}
+		clearUploadedIcon();
+		favicon.src = favicon.dataset.initialSrc;
+
+		const isCustomFavicon = favicon.getAttribute('src') !== favicon.dataset.originalIcon;
+		resetFavicon.disabled = !isCustomFavicon;
+	}
+
+	faviconUpload.onchange = function () {
+		if (faviconUpload.files.length === 0) {
+			return;
+		}
+
+		faviconExt.classList.add('hidden');
+		if (faviconUpload.files[0].size > context.max_favicon_upload_size) {
+			faviconError.innerHTML = context.i18n.favicon_size_exceeded;
+			discardIconChange();
+			return;
+		}
+		if (faviconExtBtn) {
+			faviconExtBtn.disabled = false;
+			extension.innerText = extension.dataset.initialExt ?? extension.innerText;
+		}
+		faviconError.innerHTML = '';
+
+		const resetField = feed_update.querySelector('input[name="resetFavicon"]');
+		if (resetField) {
+			resetField.remove();
+		}
+		resetFavicon.disabled = false;
+		favicon.src = URL.createObjectURL(faviconUpload.files[0]);
+	};
+
+	resetFavicon.onclick = function (e) {
+		e.preventDefault();
+		if (resetFavicon.disabled) {
+			return;
+		}
+		if (faviconExtBtn) {
+			faviconExtBtn.disabled = false;
+			extension.innerText = extension.dataset.initialExt ?? extension.innerText;
+		}
+
+		faviconExt.classList.add('hidden');
+		faviconError.innerHTML = '';
+		clearUploadedIcon();
+		resetFavicon.insertAdjacentHTML('afterend', '<input type="hidden" name="resetFavicon" value="1" />');
+		resetFavicon.disabled = true;
+
+		favicon.src = favicon.dataset.originalIcon;
+	};
+
+	// Discard the icon change when the "Cancel" button is clicked
+	feed_update.querySelectorAll('[type="reset"]').forEach(cancelBtn => {
+		cancelBtn.addEventListener('click', () => {
+			faviconExt.classList.remove('hidden');
+			faviconError.innerHTML = '';
+			discardIconChange();
+		});
+	});
+
+	if (faviconExtBtn) {
+		faviconExtBtn.onclick = function (e) {
+			e.preventDefault();
+			faviconExtBtn.disabled = true;
+			fetch(faviconExtBtn.dataset.extensionUrl, {
+				method: "POST",
+				body: new URLSearchParams({
+					'_csrf': context.csrf,
+					'extAction': 'query_icon_info',
+					'id': feed_update.dataset.feedId
+				}),
+			}).then(resp => {
+				if (!resp.ok) {
+					faviconExtBtn.disabled = false;
+					return Promise.reject(resp);
+				}
+				return resp.json();
+			}).then(json => {
+				clearUploadedIcon();
+				const resetField = feed_update.querySelector('input[name="resetFavicon"]');
+				if (resetField) {
+					resetField.remove();
+				}
+				resetFavicon.disabled = false;
+				faviconError.innerHTML = '';
+				faviconExt.classList.remove('hidden');
+				extension.dataset.initialExt = extension.innerText;
+				extension.innerText = json.extName;
+				favicon.src = json.iconUrl;
+			});
+		};
+		faviconExtBtn.form.onsubmit = async function (e) {
+			const extChanged = faviconExtBtn.disabled;
+			const isSubmit = !e.submitter.hasAttribute('formaction');
+
+			if (extChanged && isSubmit) {
+				e.preventDefault();
+				faviconExtBtn.form.querySelectorAll('[type="submit"]').forEach(el => {
+					el.disabled = true;
+				});
+				await fetch(faviconExtBtn.dataset.extensionUrl, {
+					method: "POST",
+					body: new URLSearchParams({
+						'_csrf': context.csrf,
+						'extAction': 'update_icon',
+						'id': feed_update.dataset.feedId
+					}),
+				});
+				faviconExtBtn.form.onsubmit = null;
+				faviconExtBtn.form.submit();
+			}
+		};
+	}
+}
+
 // <slider>
 // <slider>
 const freshrssSliderLoadEvent = new Event('freshrss:slider-load');
 const freshrssSliderLoadEvent = new Event('freshrss:slider-load');
 
 
@@ -169,6 +313,7 @@ function open_slider_listener(ev) {
 				slider.classList.add('active');
 				slider.classList.add('active');
 				slider.scrollTop = 0;
 				slider.scrollTop = 0;
 				slider_content.innerHTML = this.response.body.innerHTML;
 				slider_content.innerHTML = this.response.body.innerHTML;
+				init_update_feed();
 				slider_content.querySelectorAll('form').forEach(function (f) {
 				slider_content.querySelectorAll('form').forEach(function (f) {
 					f.insertAdjacentHTML('afterbegin', '<input type="hidden" name="slider" value="1" />');
 					f.insertAdjacentHTML('afterbegin', '<input type="hidden" name="slider" value="1" />');
 				});
 				});
@@ -308,6 +453,7 @@ function init_extra_afterDOM() {
 		init_select_observers();
 		init_select_observers();
 		init_configuration_alert();
 		init_configuration_alert();
 		init_2stateButton();
 		init_2stateButton();
+		init_update_feed();
 
 
 		const slider = document.getElementById('slider');
 		const slider = document.getElementById('slider');
 		if (slider) {
 		if (slider) {

+ 1 - 6
p/scripts/main.js

@@ -1033,11 +1033,6 @@ function init_column_categories() {
 function init_shortcuts() {
 function init_shortcuts() {
 	Object.keys(context.shortcuts).forEach(function (k) {
 	Object.keys(context.shortcuts).forEach(function (k) {
 		context.shortcuts[k] = (context.shortcuts[k] || '').toUpperCase();
 		context.shortcuts[k] = (context.shortcuts[k] || '').toUpperCase();
-		if (context.shortcuts[k].indexOf('&') >= 0) {
-			// Decode potential HTML entities <'&">
-			const parser = new DOMParser();
-			context.shortcuts[k] = parser.parseFromString(context.shortcuts[k], 'text/html').documentElement.textContent;
-		}
 	});
 	});
 
 
 	document.addEventListener('keydown', ev => {
 	document.addEventListener('keydown', ev => {
@@ -1156,7 +1151,7 @@ function init_shortcuts() {
 			return;
 			return;
 		}
 		}
 		if (ev.key === '?') {
 		if (ev.key === '?') {
-			window.location.href = context.urls.shortcuts.replace(/&amp;/g, '&');
+			window.location.href = context.urls.shortcuts;
 			return;
 			return;
 		}
 		}
 
 

+ 1 - 1
p/themes/Origine/origine.css

@@ -201,7 +201,7 @@ th {
 	margin-bottom: 0.25rem;
 	margin-bottom: 0.25rem;
 }
 }
 
 
-.form-group .group-controls label {
+.form-group .group-controls label:not(.btn) {
 	padding: 0;
 	padding: 0;
 }
 }
 
 

+ 1 - 1
p/themes/Origine/origine.rtl.css

@@ -201,7 +201,7 @@ th {
 	margin-bottom: 0.25rem;
 	margin-bottom: 0.25rem;
 }
 }
 
 
-.form-group .group-controls label {
+.form-group .group-controls label:not(.btn) {
 	padding: 0;
 	padding: 0;
 }
 }
 
 

+ 46 - 0
p/themes/base-theme/frss.css

@@ -7,6 +7,7 @@
 	--frss-font-color-grey-dark: #666;
 	--frss-font-color-grey-dark: #666;
 	--frss-font-color-grey-light: #aaa;
 	--frss-font-color-grey-light: #aaa;
 	--frss-font-color-light: #fff;
 	--frss-font-color-light: #fff;
+	--frss-font-color-disabled: #a6a6a6;
 	--frss-background-color-error-transparent: #ff000040;
 	--frss-background-color-error-transparent: #ff000040;
 	--frss-font-color-error: #f00;
 	--frss-font-color-error: #f00;
 
 
@@ -166,11 +167,27 @@ h6 {
 	display: none !important;
 	display: none !important;
 }
 }
 
 
+.hidden {
+	display: none;
+}
+
 /*=== Paragraphs */
 /*=== Paragraphs */
 p {
 p {
 	margin: 1rem 0 0.5rem;
 	margin: 1rem 0 0.5rem;
 }
 }
 
 
+p.error {
+	color: var(--frss-font-color-error);
+}
+
+p.error:empty {
+	display: none;
+}
+
+p#favicon-ext {
+	text-decoration: underline;
+}
+
 p.help, .prompt p.help {
 p.help, .prompt p.help {
 	margin: 0.25rem 0 0.5rem;
 	margin: 0.25rem 0 0.5rem;
 	text-align: left;
 	text-align: left;
@@ -205,6 +222,11 @@ img.favicon {
 	vertical-align: middle;
 	vertical-align: middle;
 }
 }
 
 
+img.favicon.upload {
+	width: 2rem;
+	height: 2rem;
+}
+
 .content_thin figure,
 .content_thin figure,
 .content_medium figure {
 .content_medium figure {
 	margin: 8px 0px;
 	margin: 8px 0px;
@@ -501,7 +523,24 @@ td.numeric {
 	}
 	}
 }
 }
 
 
+input#favicon-upload {
+	display: none;
+}
+
+.favicon-controls {
+	display: inline;
+}
+
 /*=== Buttons */
 /*=== Buttons */
+button[disabled] {
+	opacity: 0.5;
+	color: var(--frss-font-color-disabled);
+}
+
+button[disabled]:hover, input[disabled]:hover {
+	cursor: not-allowed;
+}
+
 .stick,
 .stick,
 .group {
 .group {
 	display: inline-flex;
 	display: inline-flex;
@@ -2508,6 +2547,13 @@ html.slider-active {
 		margin-left: 0;
 		margin-left: 0;
 	}
 	}
 
 
+	.favicon-controls {
+		display: flex;
+		flex-wrap: wrap;
+		margin-top: 0.8rem;
+		gap: 0.2em;
+	}
+
 	.dropdown {
 	.dropdown {
 		position: inherit;
 		position: inherit;
 	}
 	}

+ 46 - 0
p/themes/base-theme/frss.rtl.css

@@ -7,6 +7,7 @@
 	--frss-font-color-grey-dark: #666;
 	--frss-font-color-grey-dark: #666;
 	--frss-font-color-grey-light: #aaa;
 	--frss-font-color-grey-light: #aaa;
 	--frss-font-color-light: #fff;
 	--frss-font-color-light: #fff;
+	--frss-font-color-disabled: #a6a6a6;
 	--frss-background-color-error-transparent: #ff000040;
 	--frss-background-color-error-transparent: #ff000040;
 	--frss-font-color-error: #f00;
 	--frss-font-color-error: #f00;
 
 
@@ -166,11 +167,27 @@ h6 {
 	display: none !important;
 	display: none !important;
 }
 }
 
 
+.hidden {
+	display: none;
+}
+
 /*=== Paragraphs */
 /*=== Paragraphs */
 p {
 p {
 	margin: 1rem 0 0.5rem;
 	margin: 1rem 0 0.5rem;
 }
 }
 
 
+p.error {
+	color: var(--frss-font-color-error);
+}
+
+p.error:empty {
+	display: none;
+}
+
+p#favicon-ext {
+	text-decoration: underline;
+}
+
 p.help, .prompt p.help {
 p.help, .prompt p.help {
 	margin: 0.25rem 0 0.5rem;
 	margin: 0.25rem 0 0.5rem;
 	text-align: right;
 	text-align: right;
@@ -205,6 +222,11 @@ img.favicon {
 	vertical-align: middle;
 	vertical-align: middle;
 }
 }
 
 
+img.favicon.upload {
+	width: 2rem;
+	height: 2rem;
+}
+
 .content_thin figure,
 .content_thin figure,
 .content_medium figure {
 .content_medium figure {
 	margin: 8px 0px;
 	margin: 8px 0px;
@@ -501,7 +523,24 @@ td.numeric {
 	}
 	}
 }
 }
 
 
+input#favicon-upload {
+	display: none;
+}
+
+.favicon-controls {
+	display: inline;
+}
+
 /*=== Buttons */
 /*=== Buttons */
+button[disabled] {
+	opacity: 0.5;
+	color: var(--frss-font-color-disabled);
+}
+
+button[disabled]:hover, input[disabled]:hover {
+	cursor: not-allowed;
+}
+
 .stick,
 .stick,
 .group {
 .group {
 	display: inline-flex;
 	display: inline-flex;
@@ -2508,6 +2547,13 @@ html.slider-active {
 		margin-right: 0;
 		margin-right: 0;
 	}
 	}
 
 
+	.favicon-controls {
+		display: flex;
+		flex-wrap: wrap;
+		margin-top: 0.8rem;
+		gap: 0.2em;
+	}
+
 	.dropdown {
 	.dropdown {
 		position: inherit;
 		position: inherit;
 	}
 	}