Procházet zdrojové kódy

Custom labels (#2027)

* First draft of custom tags

https://github.com/FreshRSS/FreshRSS/issues/928
https://github.com/FreshRSS/FreshRSS/issues/1367

* SMALLINT to BIGINT for id_entry

And uppercase SQL types

* Fix layout for unreads

* Start UI menu

* Change menu order

* Clean database helpers

https://github.com/FreshRSS/FreshRSS/pull/2027#discussion_r217971535

* Travis rules do not understand PostgreSQL constants

Grrr

* Tag controller + UI

* Add column attributes to tags

* Use only favicon for now, for label

* Fix styling for different themes

* Constant for maximum InnoDB index length in Unicode

https://github.com/FreshRSS/FreshRSS/pull/2027#discussion_r219052200
(I would have personnally prefered keeping the readability of a real
value instead of a constant, in this case of many SQL fields)

* Use FreshRSS_Factory::createCategoryDao

* Add view of all articles containing any tag

* Fix search in tags

* Mark as read tags

* Partial auto-update unread tags

* More auto update tag unreads

* Add tag deletion

* Do not purge tagged articles

* Minor comment

* Fix SQLite and UI bug

* Google Reader API support for user tags

Add SQL check that tag names must be distinct from category names

* whitespace

* Add missing API for EasyRSS

* Compatibility SQLite

Problematic parentheses

* Add SQL DISTINCT for cases with multiple tags

* Fix for PostgreSQL

PostgreSQL needs some additional type hint to avoid "could not determine
data type of parameter $1"

http://www.postgresql-archive.org/Could-not-determine-data-type-of-parameter-1-tp2171092p2171094.html
Alexandre Alapetite před 7 roky
rodič
revize
8ee8a573f1
77 změnil soubory, kde provedl 1421 přidání a 324 odebrání
  1. 2 1
      CHANGELOG.md
  2. 1 1
      README.fr.md
  3. 1 1
      README.md
  4. 4 4
      app/Controllers/categoryController.php
  5. 6 4
      app/Controllers/configureController.php
  6. 14 0
      app/Controllers/entryController.php
  7. 3 3
      app/Controllers/feedController.php
  8. 9 2
      app/Controllers/indexController.php
  9. 3 1
      app/Controllers/javascriptController.php
  10. 1 1
      app/Controllers/statsController.php
  11. 1 1
      app/Controllers/subscriptionController.php
  12. 80 0
      app/Controllers/tagController.php
  13. 2 2
      app/Models/Category.php
  14. 11 4
      app/Models/CategoryDAO.php
  15. 39 7
      app/Models/Context.php
  16. 45 25
      app/Models/DatabaseDAO.php
  17. 17 21
      app/Models/DatabaseDAOPGSQL.php
  18. 12 2
      app/Models/DatabaseDAOSQLite.php
  19. 85 16
      app/Models/EntryDAO.php
  20. 7 2
      app/Models/EntryDAOPGSQL.php
  21. 47 1
      app/Models/EntryDAOSQLite.php
  22. 16 0
      app/Models/Factory.php
  23. 6 3
      app/Models/FeedDAO.php
  24. 76 0
      app/Models/Tag.php
  25. 310 0
      app/Models/TagDAO.php
  26. 9 0
      app/Models/TagDAOPGSQL.php
  27. 19 0
      app/Models/TagDAOSQLite.php
  28. 9 3
      app/Models/Themes.php
  29. 3 4
      app/Models/UserDAO.php
  30. 25 1
      app/Models/UserQuery.php
  31. 61 40
      app/SQL/install.sql.mysql.php
  32. 30 6
      app/SQL/install.sql.pgsql.php
  33. 54 68
      app/SQL/install.sql.sqlite.php
  34. 1 1
      app/i18n/cz/conf.php
  35. 3 2
      app/i18n/cz/index.php
  36. 1 1
      app/i18n/de/conf.php
  37. 3 2
      app/i18n/de/index.php
  38. 1 1
      app/i18n/en/conf.php
  39. 3 2
      app/i18n/en/index.php
  40. 1 1
      app/i18n/es/conf.php
  41. 3 2
      app/i18n/es/index.php
  42. 1 1
      app/i18n/fr/conf.php
  43. 3 2
      app/i18n/fr/index.php
  44. 1 1
      app/i18n/he/conf.php
  45. 3 2
      app/i18n/he/index.php
  46. 1 1
      app/i18n/it/conf.php
  47. 3 2
      app/i18n/it/index.php
  48. 1 1
      app/i18n/kr/conf.php
  49. 3 2
      app/i18n/kr/index.php
  50. 1 1
      app/i18n/nl/conf.php
  51. 2 2
      app/i18n/nl/index.php
  52. 1 1
      app/i18n/pt-br/conf.php
  53. 2 2
      app/i18n/pt-br/index.php
  54. 1 1
      app/i18n/ru/conf.php
  55. 2 2
      app/i18n/ru/index.php
  56. 1 1
      app/i18n/tr/conf.php
  57. 3 2
      app/i18n/tr/index.php
  58. 1 1
      app/i18n/zh-cn/conf.php
  59. 3 2
      app/i18n/zh-cn/index.php
  60. 3 3
      app/install.php
  61. 35 0
      app/layout/aside_feed.phtml
  62. 2 2
      app/views/configure/display.phtml
  63. 2 1
      app/views/entry/read.phtml
  64. 36 20
      app/views/helpers/index/normal/entry_bottom.phtml
  65. 1 1
      app/views/index/normal.phtml
  66. 8 2
      app/views/javascript/nbUnreadsPerFeed.phtml
  67. 2 0
      app/views/tag/getTagsForEntry.phtml
  68. 1 1
      cli/README.md
  69. 4 1
      cli/user-info.php
  70. 6 0
      lib/lib_rss.php
  71. 2 2
      p/api/fever.php
  72. 145 22
      p/api/greader.php
  73. 102 6
      p/scripts/main.js
  74. 3 0
      p/themes/BlueLagoon/BlueLagoon.css
  75. 1 0
      p/themes/Flat/flat.css
  76. 3 0
      p/themes/Screwdriver/screwdriver.css
  77. 8 0
      p/themes/base-theme/template.css

+ 2 - 1
CHANGELOG.md

@@ -3,7 +3,8 @@
 ## 2018-XX-XX FreshRSS 1.11.3
 ## 2018-XX-XX FreshRSS 1.11.3
 
 
 * Features
 * Features
-	* Handle tags containing spaces, as well as comma-separated tags [#2023](https://github.com/FreshRSS/FreshRSS/pull/2023)
+	* Ability to add *labels* (custom tags) to articles [#928](https://github.com/FreshRSS/FreshRSS/issues/928)
+	* Handle article tags containing spaces, as well as comma-separated tags [#2023](https://github.com/FreshRSS/FreshRSS/pull/2023)
 	* Handle authors containing spaces, as well as comma or semi-colomn separated authors [#2025](https://github.com/FreshRSS/FreshRSS/pull/2025)
 	* Handle authors containing spaces, as well as comma or semi-colomn separated authors [#2025](https://github.com/FreshRSS/FreshRSS/pull/2025)
 	* Searches by tag, author, etc. accept Unicode characters [#2025](https://github.com/FreshRSS/FreshRSS/pull/2025)
 	* Searches by tag, author, etc. accept Unicode characters [#2025](https://github.com/FreshRSS/FreshRSS/pull/2025)
 * UI
 * UI

+ 1 - 1
README.fr.md

@@ -4,7 +4,7 @@
 * [English version](README.md)
 * [English version](README.md)
 
 
 # FreshRSS
 # FreshRSS
-FreshRSS est un agrégateur de flux RSS à auto-héberger à l’image de [Leed](http://projet.idleman.fr/leed/) ou de [Kriss Feed](https://tontof.net/kriss/feed/).
+FreshRSS est un agrégateur de flux RSS à auto-héberger à l’image de [Leed](http://leed.idleman.fr/) ou de [Kriss Feed](https://tontof.net/kriss/feed/).
 
 
 Il se veut léger et facile à prendre en main tout en étant un outil puissant et paramétrable.
 Il se veut léger et facile à prendre en main tout en étant un outil puissant et paramétrable.
 
 

+ 1 - 1
README.md

@@ -4,7 +4,7 @@
 * [Version française](README.fr.md)
 * [Version française](README.fr.md)
 
 
 # FreshRSS
 # FreshRSS
-FreshRSS is a self-hosted RSS feed aggregator such as [Leed](http://projet.idleman.fr/leed/) or [Kriss Feed](https://tontof.net/kriss/feed/).
+FreshRSS is a self-hosted RSS feed aggregator such as [Leed](http://leed.idleman.fr/) or [Kriss Feed](https://tontof.net/kriss/feed/).
 
 
 It is at the same time lightweight, easy to work with, powerful and customizable.
 It is at the same time lightweight, easy to work with, powerful and customizable.
 
 

+ 4 - 4
app/Controllers/categoryController.php

@@ -16,7 +16,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 			Minz_Error::error(403);
 			Minz_Error::error(403);
 		}
 		}
 
 
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$catDAO->checkDefault();
 		$catDAO->checkDefault();
 	}
 	}
 
 
@@ -27,7 +27,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	 *   - new-category
 	 *   - new-category
 	 */
 	 */
 	public function createAction() {
 	public function createAction() {
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 
 		$limits = FreshRSS_Context::$system_conf->limits;
 		$limits = FreshRSS_Context::$system_conf->limits;
@@ -75,7 +75,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	 *   - name
 	 *   - name
 	 */
 	 */
 	public function updateAction() {
 	public function updateAction() {
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
@@ -116,7 +116,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function deleteAction() {
 	public function deleteAction() {
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {

+ 6 - 4
app/Controllers/configureController.php

@@ -243,8 +243,9 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 * checking if categories and feeds are still in use.
 	 * checking if categories and feeds are still in use.
 	 */
 	 */
 	public function queriesAction() {
 	public function queriesAction() {
-		$category_dao = new FreshRSS_CategoryDAO();
+		$category_dao = FreshRSS_Factory::createCategoryDao();
 		$feed_dao = FreshRSS_Factory::createFeedDao();
 		$feed_dao = FreshRSS_Factory::createFeedDao();
+		$tag_dao = FreshRSS_Factory::createTagDao();
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
 			$params = Minz_Request::param('queries', array());
 			$params = Minz_Request::param('queries', array());
 
 
@@ -277,16 +278,17 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 * lean data.
 	 * lean data.
 	 */
 	 */
 	public function addQueryAction() {
 	public function addQueryAction() {
-		$category_dao = new FreshRSS_CategoryDAO();
+		$category_dao = FreshRSS_Factory::createCategoryDao();
 		$feed_dao = FreshRSS_Factory::createFeedDao();
 		$feed_dao = FreshRSS_Factory::createFeedDao();
+		$tag_dao = FreshRSS_Factory::createTagDao();
 		$queries = array();
 		$queries = array();
 		foreach (FreshRSS_Context::$user_conf->queries as $key => $query) {
 		foreach (FreshRSS_Context::$user_conf->queries as $key => $query) {
-			$queries[$key] = new FreshRSS_UserQuery($query, $feed_dao, $category_dao);
+			$queries[$key] = new FreshRSS_UserQuery($query, $feed_dao, $category_dao, $tag_dao);
 		}
 		}
 		$params = Minz_Request::fetchGET();
 		$params = Minz_Request::fetchGET();
 		$params['url'] = Minz_Url::display(array('params' => $params));
 		$params['url'] = Minz_Url::display(array('params' => $params));
 		$params['name'] = _t('conf.query.number', count($queries) + 1);
 		$params['name'] = _t('conf.query.number', count($queries) + 1);
-		$queries[] = new FreshRSS_UserQuery($params, $feed_dao, $category_dao);
+		$queries[] = new FreshRSS_UserQuery($params, $feed_dao, $category_dao, $tag_dao);
 
 
 		FreshRSS_Context::$user_conf->queries = $queries;
 		FreshRSS_Context::$user_conf->queries = $queries;
 		FreshRSS_Context::$user_conf->save();
 		FreshRSS_Context::$user_conf->save();

+ 14 - 0
app/Controllers/entryController.php

@@ -53,6 +53,7 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 		}
 		}
 
 
 		$params = array();
 		$params = array();
+		$this->view->tags = array();
 
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		if ($id === false) {
 		if ($id === false) {
@@ -81,6 +82,12 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 				case 'a':
 				case 'a':
 					$entryDAO->markReadEntries($id_max, false, 0, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
 					$entryDAO->markReadEntries($id_max, false, 0, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
 					break;
 					break;
+				case 't':
+					$entryDAO->markReadTag($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
+					break;
+				case 'T':
+					$entryDAO->markReadTag('', $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
+					break;
 				}
 				}
 
 
 				if ($next_get !== 'a') {
 				if ($next_get !== 'a') {
@@ -91,6 +98,13 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 			}
 			}
 		} else {
 		} else {
 			$entryDAO->markRead($id, $is_read);
 			$entryDAO->markRead($id, $is_read);
+
+			$tagDAO = FreshRSS_Factory::createTagDao();
+			foreach ($tagDAO->getTagsForEntry($id) as $tag) {
+				if (!empty($tag['checked'])) {
+					$this->view->tags[] = $tag['id'];
+				}
+			}
 		}
 		}
 
 
 		if (!$this->ajax) {
 		if (!$this->ajax) {

+ 3 - 3
app/Controllers/feedController.php

@@ -43,7 +43,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		FreshRSS_UserDAO::touch();
 		FreshRSS_UserDAO::touch();
 		@set_time_limit(300);
 		@set_time_limit(300);
 
 
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 
 
 		$url = trim($url);
 		$url = trim($url);
 
 
@@ -192,7 +192,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			// GET request: we must ask confirmation to user before adding feed.
 			// GET request: we must ask confirmation to user before adding feed.
 			Minz_View::prependTitle(_t('sub.feed.title_add') . ' · ');
 			Minz_View::prependTitle(_t('sub.feed.title_add') . ' · ');
 
 
-			$this->catDAO = new FreshRSS_CategoryDAO();
+			$this->catDAO = FreshRSS_Factory::createCategoryDao();
 			$this->view->categories = $this->catDAO->listCategories(false);
 			$this->view->categories = $this->catDAO->listCategories(false);
 			$this->view->feed = new FreshRSS_Feed($url);
 			$this->view->feed = new FreshRSS_Feed($url);
 			try {
 			try {
@@ -556,7 +556,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 		}
 		FreshRSS_UserDAO::touch();
 		FreshRSS_UserDAO::touch();
 
 
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		if ($cat_id > 0) {
 		if ($cat_id > 0) {
 			$cat = $catDAO->searchById($cat_id);
 			$cat = $catDAO->searchById($cat_id);
 			$cat_id = $cat == null ? 0 : $cat->id();
 			$cat_id = $cat == null ? 0 : $cat->id();

+ 9 - 2
app/Controllers/indexController.php

@@ -32,8 +32,15 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 			Minz_Error::error(404);
 			Minz_Error::error(404);
 		}
 		}
 
 
-		$this->view->callbackBeforeContent = function($view) {
+		$this->view->callbackBeforeContent = function ($view) {
 			try {
 			try {
+				$tagDAO = FreshRSS_Factory::createTagDao();
+				$view->tags = $tagDAO->listTags(true);
+				$view->nbUnreadTags = 0;
+				foreach ($view->tags as $tag) {
+					$view->nbUnreadTags += $tag->nbUnread();
+				}
+
 				FreshRSS_Context::$number++;	//+1 for pagination
 				FreshRSS_Context::$number++;	//+1 for pagination
 				$entries = FreshRSS_index_Controller::listEntriesByContext();
 				$entries = FreshRSS_index_Controller::listEntriesByContext();
 				FreshRSS_Context::$number--;
 				FreshRSS_Context::$number--;
@@ -158,7 +165,7 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 	 */
 	 */
 	private function updateContext() {
 	private function updateContext() {
 		if (empty(FreshRSS_Context::$categories)) {
 		if (empty(FreshRSS_Context::$categories)) {
-			$catDAO = new FreshRSS_CategoryDAO();
+			$catDAO = FreshRSS_Factory::createCategoryDao();
 			FreshRSS_Context::$categories = $catDAO->listCategories();
 			FreshRSS_Context::$categories = $catDAO->listCategories();
 		}
 		}
 
 

+ 3 - 1
app/Controllers/javascriptController.php

@@ -13,8 +13,10 @@ class FreshRSS_javascript_Controller extends Minz_ActionController {
 
 
 	public function nbUnreadsPerFeedAction() {
 	public function nbUnreadsPerFeedAction() {
 		header('Content-Type: application/json; charset=UTF-8');
 		header('Content-Type: application/json; charset=UTF-8');
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$this->view->categories = $catDAO->listCategories(true, false);
 		$this->view->categories = $catDAO->listCategories(true, false);
+		$tagDAO = FreshRSS_Factory::createTagDao();
+		$this->view->tags = $tagDAO->listTags(true);
 	}
 	}
 
 
 	//For Web-form login
 	//For Web-form login

+ 1 - 1
app/Controllers/statsController.php

@@ -131,7 +131,7 @@ class FreshRSS_stats_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function repartitionAction() {
 	public function repartitionAction() {
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
-		$categoryDAO = new FreshRSS_CategoryDAO();
+		$categoryDAO = FreshRSS_Factory::createCategoryDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		Minz_View::appendScript(Minz_Url::display('/scripts/flotr2.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/flotr2.min.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/flotr2.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/flotr2.min.js')));
 		$id = Minz_Request::param('id', null);
 		$id = Minz_Request::param('id', null);

+ 1 - 1
app/Controllers/subscriptionController.php

@@ -14,7 +14,7 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 			Minz_Error::error(403);
 			Minz_Error::error(403);
 		}
 		}
 
 
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 
 
 		$catDAO->checkDefault();
 		$catDAO->checkDefault();

+ 80 - 0
app/Controllers/tagController.php

@@ -0,0 +1,80 @@
+<?php
+
+/**
+ * Controller to handle every tag actions.
+ */
+class FreshRSS_tag_Controller extends Minz_ActionController {
+	/**
+	 * This action is called before every other action in that class. It is
+	 * the common boiler plate for every action. It is triggered by the
+	 * underlying framework.
+	 */
+	public function firstAction() {
+		if (!FreshRSS_Auth::hasAccess()) {
+			Minz_Error::error(403);
+		}
+		// If ajax request, we do not print layout
+		$this->ajax = Minz_Request::param('ajax');
+		if ($this->ajax) {
+			$this->view->_useLayout(false);
+			Minz_Request::_param('ajax');
+		}
+	}
+
+	/**
+	 * This action adds (checked=true) or removes (checked=false) a tag to an entry.
+	 */
+	public function tagEntryAction() {
+		if (Minz_Request::isPost()) {
+			$id_tag = Minz_Request::param('id_tag');
+			$name_tag = trim(Minz_Request::param('name_tag'));
+			$id_entry = Minz_Request::param('id_entry');
+			$checked = Minz_Request::paramTernary('checked');
+			if ($id_entry != false) {
+				$tagDAO = FreshRSS_Factory::createTagDao();
+				if ($id_tag == 0 && $name_tag != '' && $checked) {
+					//Create new tag
+					$id_tag = $tagDAO->addTag(array('name' => $name_tag));
+				}
+				if ($id_tag != 0) {
+					$tagDAO->tagEntry($id_tag, $id_entry, $checked);
+				}
+			}
+		} else {
+			Minz_Error::error(405);
+		}
+		if (!$this->ajax) {
+			Minz_Request::forward(array(
+				'c' => 'index',
+				'a' => 'index',
+			), true);
+		}
+	}
+
+	public function deleteAction() {
+		if (Minz_Request::isPost()) {
+			$id_tag = Minz_Request::param('id_tag');
+			if ($id_tag != false) {
+				$tagDAO = FreshRSS_Factory::createTagDao();
+				$tagDAO->deleteTag($id_tag);
+			}
+		} else {
+			Minz_Error::error(405);
+		}
+		if (!$this->ajax) {
+			Minz_Request::forward(array(
+				'c' => 'index',
+				'a' => 'index',
+			), true);
+		}
+	}
+
+	public function getTagsForEntryAction() {
+		$this->view->_useLayout(false);
+		header('Content-Type: application/json; charset=UTF-8');
+		header('Cache-Control: private, no-cache, no-store, must-revalidate');
+		$id_entry = Minz_Request::param('id_entry', 0);
+		$tagDAO = FreshRSS_Factory::createTagDao();
+		$this->view->tags = $tagDAO->getTagsForEntry($id_entry);
+	}
+}

+ 2 - 2
app/Models/Category.php

@@ -30,7 +30,7 @@ class FreshRSS_Category extends Minz_Model {
 	}
 	}
 	public function nbFeed() {
 	public function nbFeed() {
 		if ($this->nbFeed < 0) {
 		if ($this->nbFeed < 0) {
-			$catDAO = new FreshRSS_CategoryDAO();
+			$catDAO = FreshRSS_Factory::createCategoryDao();
 			$this->nbFeed = $catDAO->countFeed($this->id());
 			$this->nbFeed = $catDAO->countFeed($this->id());
 		}
 		}
 
 
@@ -38,7 +38,7 @@ class FreshRSS_Category extends Minz_Model {
 	}
 	}
 	public function nbNotRead() {
 	public function nbNotRead() {
 		if ($this->nbNotRead < 0) {
 		if ($this->nbNotRead < 0) {
-			$catDAO = new FreshRSS_CategoryDAO();
+			$catDAO = FreshRSS_Factory::createCategoryDao();
 			$this->nbNotRead = $catDAO->countNotRead($this->id());
 			$this->nbNotRead = $catDAO->countNotRead($this->id());
 		}
 		}
 
 

+ 11 - 4
app/Models/CategoryDAO.php

@@ -5,11 +5,15 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	const DEFAULTCATEGORYID = 1;
 	const DEFAULTCATEGORYID = 1;
 
 
 	public function addCategory($valuesTmp) {
 	public function addCategory($valuesTmp) {
-		$sql = 'INSERT INTO `' . $this->prefix . 'category`(name) VALUES(?)';
+		$sql = 'INSERT INTO `' . $this->prefix . 'category`(name) '
+		     . 'SELECT * FROM (SELECT TRIM(?)) c2 '	//TRIM() to provide a type hint as text for PostgreSQL
+		     . 'WHERE NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'tag` WHERE name = TRIM(?))';	//No tag of the same name
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
+		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
 		$values = array(
 		$values = array(
-			mb_strcut($valuesTmp['name'], 0, 255, 'UTF-8'),
+			$valuesTmp['name'],
+			$valuesTmp['name'],
 		);
 		);
 
 
 		if ($stm && $stm->execute($values)) {
 		if ($stm && $stm->execute($values)) {
@@ -35,12 +39,15 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	}
 	}
 
 
 	public function updateCategory($id, $valuesTmp) {
 	public function updateCategory($id, $valuesTmp) {
-		$sql = 'UPDATE `' . $this->prefix . 'category` SET name=? WHERE id=?';
+		$sql = 'UPDATE `' . $this->prefix . 'category` SET name=? WHERE id=? '
+		     . 'AND NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'tag` WHERE name = ?)';	//No tag of the same name
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
+		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
 		$values = array(
 		$values = array(
 			$valuesTmp['name'],
 			$valuesTmp['name'],
-			$id
+			$id,
+			$valuesTmp['name'],
 		);
 		);
 
 
 		if ($stm && $stm->execute($values)) {
 		if ($stm && $stm->execute($values)) {

+ 39 - 7
app/Models/Context.php

@@ -8,6 +8,7 @@ class FreshRSS_Context {
 	public static $user_conf = null;
 	public static $user_conf = null;
 	public static $system_conf = null;
 	public static $system_conf = null;
 	public static $categories = array();
 	public static $categories = array();
+	public static $tags = array();
 
 
 	public static $name = '';
 	public static $name = '';
 	public static $description = '';
 	public static $description = '';
@@ -25,6 +26,8 @@ class FreshRSS_Context {
 		'starred' => false,
 		'starred' => false,
 		'feed' => false,
 		'feed' => false,
 		'category' => false,
 		'category' => false,
+		'tag' => false,
+		'tags' => false,
 	);
 	);
 	public static $next_get = 'a';
 	public static $next_get = 'a';
 
 
@@ -91,6 +94,14 @@ class FreshRSS_Context {
 			} else {
 			} else {
 				return 'c_' . self::$current_get['category'];
 				return 'c_' . self::$current_get['category'];
 			}
 			}
+		} elseif (self::$current_get['tag']) {
+			if ($array) {
+				return array('t', self::$current_get['tag']);
+			} else {
+				return 't_' . self::$current_get['tag'];
+			}
+		} elseif (self::$current_get['tags']) {
+			return 'T';
 		}
 		}
 	}
 	}
 
 
@@ -117,6 +128,10 @@ class FreshRSS_Context {
 			return self::$current_get['feed'] == $id;
 			return self::$current_get['feed'] == $id;
 		case 'c':
 		case 'c':
 			return self::$current_get['category'] == $id;
 			return self::$current_get['category'] == $id;
+		case 't':
+			return self::$current_get['tag'] == $id;
+		case 'T':
+			return self::$current_get['tags'] || self::$current_get['tag'];
 		default:
 		default:
 			return false;
 			return false;
 		}
 		}
@@ -130,6 +145,7 @@ class FreshRSS_Context {
 	 *   - s
 	 *   - s
 	 *   - f_<feed id>
 	 *   - f_<feed id>
 	 *   - c_<category id>
 	 *   - c_<category id>
+	 *   - t_<tag id>
 	 *
 	 *
 	 * $name and $get_unread attributes are also updated as $next_get
 	 * $name and $get_unread attributes are also updated as $next_get
 	 * Raise an exception if id or $get is invalid.
 	 * Raise an exception if id or $get is invalid.
@@ -140,7 +156,7 @@ class FreshRSS_Context {
 		$nb_unread = 0;
 		$nb_unread = 0;
 
 
 		if (empty(self::$categories)) {
 		if (empty(self::$categories)) {
-			$catDAO = new FreshRSS_CategoryDAO();
+			$catDAO = FreshRSS_Factory::createCategoryDao();
 			self::$categories = $catDAO->listCategories();
 			self::$categories = $catDAO->listCategories();
 		}
 		}
 
 
@@ -166,12 +182,10 @@ class FreshRSS_Context {
 			if ($feed === null) {
 			if ($feed === null) {
 				$feedDAO = FreshRSS_Factory::createFeedDao();
 				$feedDAO = FreshRSS_Factory::createFeedDao();
 				$feed = $feedDAO->searchById($id);
 				$feed = $feedDAO->searchById($id);
-
 				if (!$feed) {
 				if (!$feed) {
 					throw new FreshRSS_Context_Exception('Invalid feed: ' . $id);
 					throw new FreshRSS_Context_Exception('Invalid feed: ' . $id);
 				}
 				}
 			}
 			}
-
 			self::$current_get['feed'] = $id;
 			self::$current_get['feed'] = $id;
 			self::$current_get['category'] = $feed->category();
 			self::$current_get['category'] = $feed->category();
 			self::$name = $feed->name();
 			self::$name = $feed->name();
@@ -182,19 +196,37 @@ class FreshRSS_Context {
 			// We try to find the corresponding category.
 			// We try to find the corresponding category.
 			self::$current_get['category'] = $id;
 			self::$current_get['category'] = $id;
 			if (!isset(self::$categories[$id])) {
 			if (!isset(self::$categories[$id])) {
-				$catDAO = new FreshRSS_CategoryDAO();
+				$catDAO = FreshRSS_Factory::createCategoryDao();
 				$cat = $catDAO->searchById($id);
 				$cat = $catDAO->searchById($id);
-
 				if (!$cat) {
 				if (!$cat) {
 					throw new FreshRSS_Context_Exception('Invalid category: ' . $id);
 					throw new FreshRSS_Context_Exception('Invalid category: ' . $id);
 				}
 				}
 			} else {
 			} else {
 				$cat = self::$categories[$id];
 				$cat = self::$categories[$id];
 			}
 			}
-
 			self::$name = $cat->name();
 			self::$name = $cat->name();
 			self::$get_unread = $cat->nbNotRead();
 			self::$get_unread = $cat->nbNotRead();
 			break;
 			break;
+		case 't':
+			// We try to find the corresponding tag.
+			self::$current_get['tag'] = $id;
+			if (!isset(self::$tags[$id])) {
+				$tagDAO = FreshRSS_Factory::createTagDao();
+				$tag = $tagDAO->searchById($id);
+				if (!$tag) {
+					throw new FreshRSS_Context_Exception('Invalid tag: ' . $id);
+				}
+			} else {
+				$tag = self::$tags[$id];
+			}
+			self::$name = $tag->name();
+			self::$get_unread = $tag->nbUnread();
+			break;
+		case 'T':
+			self::$current_get['tags'] = true;
+			self::$name = _t('index.menu.tags');
+			self::$get_unread = 0;
+			break;
 		default:
 		default:
 			throw new FreshRSS_Context_Exception('Invalid getter: ' . $get);
 			throw new FreshRSS_Context_Exception('Invalid getter: ' . $get);
 		}
 		}
@@ -211,7 +243,7 @@ class FreshRSS_Context {
 		self::$next_get = $get;
 		self::$next_get = $get;
 
 
 		if (empty(self::$categories)) {
 		if (empty(self::$categories)) {
-			$catDAO = new FreshRSS_CategoryDAO();
+			$catDAO = FreshRSS_Factory::createCategoryDao();
 			self::$categories = $catDAO->listCategories();
 			self::$categories = $catDAO->listCategories();
 		}
 		}
 
 

+ 45 - 25
app/Models/DatabaseDAO.php

@@ -4,6 +4,16 @@
  * This class is used to test database is well-constructed.
  * This class is used to test database is well-constructed.
  */
  */
 class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
+
+	//MySQL error codes
+	const ER_BAD_FIELD_ERROR = '42S22';
+	const ER_BAD_TABLE_ERROR = '42S02';
+	const ER_TRUNCATED_WRONG_VALUE_FOR_FIELD = '1366';
+
+	//MySQL InnoDB maximum index length for UTF8MB4
+	//https://dev.mysql.com/doc/refman/8.0/en/innodb-restrictions.html
+	const LENGTH_INDEX_UNICODE = 191;
+
 	public function tablesAreCorrect() {
 	public function tablesAreCorrect() {
 		$sql = 'SHOW TABLES';
 		$sql = 'SHOW TABLES';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
@@ -14,6 +24,9 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 			$this->prefix . 'category' => false,
 			$this->prefix . 'category' => false,
 			$this->prefix . 'feed' => false,
 			$this->prefix . 'feed' => false,
 			$this->prefix . 'entry' => false,
 			$this->prefix . 'entry' => false,
+			$this->prefix . 'entrytmp' => false,
+			$this->prefix . 'tag' => false,
+			$this->prefix . 'entrytag' => false,
 		);
 		);
 		foreach ($res as $value) {
 		foreach ($res as $value) {
 			$tables[array_pop($value)] = true;
 			$tables[array_pop($value)] = true;
@@ -43,7 +56,7 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 
 	public function categoryIsCorrect() {
 	public function categoryIsCorrect() {
 		return $this->checkTable('category', array(
 		return $this->checkTable('category', array(
-			'id', 'name'
+			'id', 'name',
 		));
 		));
 	}
 	}
 
 
@@ -51,14 +64,33 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 		return $this->checkTable('feed', array(
 		return $this->checkTable('feed', array(
 			'id', 'url', 'category', 'name', 'website', 'description', 'lastUpdate',
 			'id', 'url', 'category', 'name', 'website', 'description', 'lastUpdate',
 			'priority', 'pathEntries', 'httpAuth', 'error', 'keep_history', 'ttl', 'attributes',
 			'priority', 'pathEntries', 'httpAuth', 'error', 'keep_history', 'ttl', 'attributes',
-			'cache_nbEntries', 'cache_nbUnreads'
+			'cache_nbEntries', 'cache_nbUnreads',
 		));
 		));
 	}
 	}
 
 
 	public function entryIsCorrect() {
 	public function entryIsCorrect() {
 		return $this->checkTable('entry', array(
 		return $this->checkTable('entry', array(
-			'id', 'guid', 'title', 'author', 'content_bin', 'link', 'date', 'is_read',
-			'is_favorite', 'id_feed', 'tags'
+			'id', 'guid', 'title', 'author', 'content_bin', 'link', 'date', 'lastSeen', 'hash', 'is_read',
+			'is_favorite', 'id_feed', 'tags',
+		));
+	}
+
+	public function entrytmpIsCorrect() {
+		return $this->checkTable('entrytmp', array(
+			'id', 'guid', 'title', 'author', 'content_bin', 'link', 'date', 'lastSeen', 'hash', 'is_read',
+			'is_favorite', 'id_feed', 'tags',
+		));
+	}
+
+	public function tagIsCorrect() {
+		return $this->checkTable('tag', array(
+			'id', 'name', 'attributes',
+		));
+	}
+
+	public function entrytagIsCorrect() {
+		return $this->checkTable('entrytag', array(
+			'id_tag', 'id_entry',
 		));
 		));
 	}
 	}
 
 
@@ -97,28 +129,16 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 
 	public function optimize() {
 	public function optimize() {
 		$ok = true;
 		$ok = true;
-
-		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'entry`';	//MySQL
-		$stm = $this->bd->prepare($sql);
-		$ok &= $stm != false;
-		if ($stm) {
-			$ok &= $stm->execute();
-		}
-
-		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'feed`';	//MySQL
-		$stm = $this->bd->prepare($sql);
-		$ok &= $stm != false;
-		if ($stm) {
-			$ok &= $stm->execute();
+		$tables = array('category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag');
+
+		foreach ($tables as $table) {
+			$sql = 'OPTIMIZE TABLE `' . $this->prefix . $table . '`';	//MySQL
+			$stm = $this->bd->prepare($sql);
+			$ok &= $stm != false;
+			if ($stm) {
+				$ok &= $stm->execute();
+			}
 		}
 		}
-
-		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'category`';	//MySQL
-		$stm = $this->bd->prepare($sql);
-		$ok &= $stm != false;
-		if ($stm) {
-			$ok &= $stm->execute();
-		}
-
 		return $ok;
 		return $ok;
 	}
 	}
 }
 }

+ 17 - 21
app/Models/DatabaseDAOPGSQL.php

@@ -3,7 +3,12 @@
 /**
 /**
  * This class is used to test database is well-constructed.
  * This class is used to test database is well-constructed.
  */
  */
-class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO {
+class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAOSQLite {
+
+	//PostgreSQL error codes
+	const UNDEFINED_COLUMN = '42703';
+	const UNDEFINED_TABLE = '42P01';
+
 	public function tablesAreCorrect() {
 	public function tablesAreCorrect() {
 		$db = FreshRSS_Context::$system_conf->db;
 		$db = FreshRSS_Context::$system_conf->db;
 		$dbowner = $db['user'];
 		$dbowner = $db['user'];
@@ -17,6 +22,9 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO {
 			$this->prefix . 'category' => false,
 			$this->prefix . 'category' => false,
 			$this->prefix . 'feed' => false,
 			$this->prefix . 'feed' => false,
 			$this->prefix . 'entry' => false,
 			$this->prefix . 'entry' => false,
+			$this->prefix . 'entrytmp' => false,
+			$this->prefix . 'tag' => false,
+			$this->prefix . 'entrytag' => false,
 		);
 		);
 		foreach ($res as $value) {
 		foreach ($res as $value) {
 			$tables[array_pop($value)] = true;
 			$tables[array_pop($value)] = true;
@@ -53,28 +61,16 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO {
 
 
 	public function optimize() {
 	public function optimize() {
 		$ok = true;
 		$ok = true;
+		$tables = array('category', 'feed', 'entry', 'entrytmp', 'tag', 'entrytag');
 
 
-		$sql = 'VACUUM `' . $this->prefix . 'entry`';
-		$stm = $this->bd->prepare($sql);
-		$ok &= $stm != false;
-		if ($stm) {
-			$ok &= $stm->execute();
-		}
-
-		$sql = 'VACUUM `' . $this->prefix . 'feed`';
-		$stm = $this->bd->prepare($sql);
-		$ok &= $stm != false;
-		if ($stm) {
-			$ok &= $stm->execute();
+		foreach ($tables as $table) {
+			$sql = 'VACUUM `' . $this->prefix . $table . '`';
+			$stm = $this->bd->prepare($sql);
+			$ok &= $stm != false;
+			if ($stm) {
+				$ok &= $stm->execute();
+			}
 		}
 		}
-
-		$sql = 'VACUUM `' . $this->prefix . 'category`';
-		$stm = $this->bd->prepare($sql);
-		$ok &= $stm != false;
-		if ($stm) {
-			$ok &= $stm->execute();
-		}
-
 		return $ok;
 		return $ok;
 	}
 	}
 }
 }

+ 12 - 2
app/Models/DatabaseDAOSQLite.php

@@ -14,6 +14,9 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 			'category' => false,
 			'category' => false,
 			'feed' => false,
 			'feed' => false,
 			'entry' => false,
 			'entry' => false,
+			'entrytmp' => false,
+			'tag' => false,
+			'entrytag' => false,
 		);
 		);
 		foreach ($res as $value) {
 		foreach ($res as $value) {
 			$tables[$value['name']] = true;
 			$tables[$value['name']] = true;
@@ -32,8 +35,15 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 
 
 	public function entryIsCorrect() {
 	public function entryIsCorrect() {
 		return $this->checkTable('entry', array(
 		return $this->checkTable('entry', array(
-			'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'is_read',
-			'is_favorite', 'id_feed', 'tags'
+			'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'lastSeen', 'hash', 'is_read',
+			'is_favorite', 'id_feed', 'tags',
+		));
+	}
+
+	public function entrytmpIsCorrect() {
+		return $this->checkTable('entrytmp', array(
+			'id', 'guid', 'title', 'author', 'content', 'link', 'date', 'lastSeen', 'hash', 'is_read',
+			'is_favorite', 'id_feed', 'tags',
 		));
 		));
 	}
 	}
 
 

+ 85 - 16
app/Models/EntryDAO.php

@@ -18,6 +18,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		return 'hex(' . $x . ')';
 		return 'hex(' . $x . ')';
 	}
 	}
 
 
+	//TODO: Move the database auto-updates to DatabaseDAO
 	protected function addColumn($name) {
 	protected function addColumn($name) {
 		Minz_Log::warning('FreshRSS_EntryDAO::addColumn: ' . $name);
 		Minz_Log::warning('FreshRSS_EntryDAO::addColumn: ' . $name);
 		$hasTransaction = false;
 		$hasTransaction = false;
@@ -56,6 +57,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 
 	private $triedUpdateToUtf8mb4 = false;
 	private $triedUpdateToUtf8mb4 = false;
 
 
+	//TODO: Move the database auto-updates to DatabaseDAO
 	protected function updateToUtf8mb4() {
 	protected function updateToUtf8mb4() {
 		if ($this->triedUpdateToUtf8mb4) {
 		if ($this->triedUpdateToUtf8mb4) {
 			return false;
 			return false;
@@ -65,7 +67,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		if ($db['type'] === 'mysql') {
 		if ($db['type'] === 'mysql') {
 			include_once(APP_PATH . '/SQL/install.sql.mysql.php');
 			include_once(APP_PATH . '/SQL/install.sql.mysql.php');
 			if (defined('SQL_UPDATE_UTF8MB4')) {
 			if (defined('SQL_UPDATE_UTF8MB4')) {
-				Minz_Log::warning('Updating MySQL to UTF8MB4...');
+				Minz_Log::warning('Updating MySQL to UTF8MB4...');	//v1.5.0
 				$hadTransaction = $this->bd->inTransaction();
 				$hadTransaction = $this->bd->inTransaction();
 				if ($hadTransaction) {
 				if ($hadTransaction) {
 					$this->bd->commit();
 					$this->bd->commit();
@@ -88,6 +90,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		return false;
 		return false;
 	}
 	}
 
 
+	//TODO: Move the database auto-updates to DatabaseDAO
 	protected function createEntryTempTable() {
 	protected function createEntryTempTable() {
 		$ok = false;
 		$ok = false;
 		$hadTransaction = $this->bd->inTransaction();
 		$hadTransaction = $this->bd->inTransaction();
@@ -120,22 +123,28 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		return $ok;
 		return $ok;
 	}
 	}
 
 
+	//TODO: Move the database auto-updates to DatabaseDAO
 	protected function autoUpdateDb($errorInfo) {
 	protected function autoUpdateDb($errorInfo) {
 		if (isset($errorInfo[0])) {
 		if (isset($errorInfo[0])) {
-			if ($errorInfo[0] === '42S22') {	//ER_BAD_FIELD_ERROR
+			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR) {
 				//autoAddColumn
 				//autoAddColumn
 				foreach (array('lastSeen', 'hash') as $column) {
 				foreach (array('lastSeen', 'hash') as $column) {
 					if (stripos($errorInfo[2], $column) !== false) {
 					if (stripos($errorInfo[2], $column) !== false) {
 						return $this->addColumn($column);
 						return $this->addColumn($column);
 					}
 					}
 				}
 				}
-			} elseif ($errorInfo[0] === '42S02' && stripos($errorInfo[2], 'entrytmp') !== false) {	//ER_BAD_TABLE_ERROR
-				return $this->createEntryTempTable();	//v1.7
+			} elseif ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_TABLE_ERROR) {
+				if (stripos($errorInfo[2], 'tag') !== false) {
+					$tagDAO = FreshRSS_Factory::createTagDao();
+					return $tagDAO->createTagTable();	//v1.12.0
+				} elseif (stripos($errorInfo[2], 'entrytmp') !== false) {
+					return $this->createEntryTempTable();	//v1.7.0
+				}
 			}
 			}
 		}
 		}
 		if (isset($errorInfo[1])) {
 		if (isset($errorInfo[1])) {
-			if ($errorInfo[1] == '1366') {	//ER_TRUNCATED_WRONG_VALUE_FOR_FIELD
-				return $this->updateToUtf8mb4();
+			if ($errorInfo[1] == FreshRSS_DatabaseDAO::ER_TRUNCATED_WRONG_VALUE_FOR_FIELD) {
+				return $this->updateToUtf8mb4();	//v1.5.0
 			}
 			}
 		}
 		}
 		return false;
 		return false;
@@ -560,11 +569,52 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		return $affected;
 		return $affected;
 	}
 	}
 
 
+	/**
+	 * Mark all the articles in a tag as read.
+	 * @param integer $id tag ID, or empty for targetting any tag
+	 * @param integer $idMax max article ID
+	 * @return integer affected rows
+	 */
+	public function markReadTag($id = '', $idMax = 0, $filters = null, $state = 0, $is_read = true) {
+		FreshRSS_UserDAO::touch();
+		if ($idMax == 0) {
+			$idMax = time() . '000000';
+			Minz_Log::debug('Calling markReadTag(0) is deprecated!');
+		}
+
+		$sql = 'UPDATE `' . $this->prefix . 'entry` e INNER JOIN `' . $this->prefix . 'entrytag` et ON et.id_entry = e.id '
+			 . 'SET e.is_read = ? '
+			 . 'WHERE '
+			 . ($id == '' ? '' : 'et.id_tag = ? AND ')
+			 . 'e.is_read <> ? AND e.id <= ?';
+		$values = array($is_read ? 1 : 0);
+		if ($id != '') {
+			$values[] = $id;
+		}
+		$values[] = $is_read ? 1 : 0;
+		$values[] = $idMax;
+
+		list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filters, $state);
+
+		$stm = $this->bd->prepare($sql . $search);
+		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
+			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
+			Minz_Log::error('SQL error markReadTag: ' . $info[2]);
+			return false;
+		}
+		$affected = $stm->rowCount();
+		if (($affected > 0) && (!$this->updateCacheUnreads(false, false))) {
+			return false;
+		}
+		return $affected;
+	}
+
 	public function cleanOldEntries($id_feed, $date_min, $keep = 15) {	//Remember to call updateCachedValue($id_feed) or updateCachedValues() just after
 	public function cleanOldEntries($id_feed, $date_min, $keep = 15) {	//Remember to call updateCachedValue($id_feed) or updateCachedValues() just after
 		$sql = 'DELETE FROM `' . $this->prefix . 'entry` '
 		$sql = 'DELETE FROM `' . $this->prefix . 'entry` '
 		     . 'WHERE id_feed=:id_feed AND id<=:id_max '
 		     . 'WHERE id_feed=:id_feed AND id<=:id_max '
 		     . 'AND is_favorite=0 '	//Do not remove favourites
 		     . 'AND is_favorite=0 '	//Do not remove favourites
 		     . 'AND `lastSeen` < (SELECT maxLastSeen FROM (SELECT (MAX(e3.`lastSeen`)-99) AS maxLastSeen FROM `' . $this->prefix . 'entry` e3 WHERE e3.id_feed=:id_feed) recent) '	//Do not remove the most newly seen articles, plus a few seconds of tolerance
 		     . 'AND `lastSeen` < (SELECT maxLastSeen FROM (SELECT (MAX(e3.`lastSeen`)-99) AS maxLastSeen FROM `' . $this->prefix . 'entry` e3 WHERE e3.id_feed=:id_feed) recent) '	//Do not remove the most newly seen articles, plus a few seconds of tolerance
+		     . 'AND id NOT IN (SELECT id_entry FROM `' . $this->prefix . 'entrytag`) '	//Do not purge tagged entries
 		     . 'AND id NOT IN (SELECT id FROM (SELECT e2.id FROM `' . $this->prefix . 'entry` e2 WHERE e2.id_feed=:id_feed ORDER BY id DESC LIMIT :keep) keep)';	//Double select: MySQL doesn't support 'LIMIT & IN/ALL/ANY/SOME subquery'
 		     . 'AND id NOT IN (SELECT id FROM (SELECT e2.id FROM `' . $this->prefix . 'entry` e2 WHERE e2.id_feed=:id_feed ORDER BY id DESC LIMIT :keep) keep)';	//Double select: MySQL doesn't support 'LIMIT & IN/ALL/ANY/SOME subquery'
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
@@ -770,24 +820,31 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		$joinFeed = false;
 		$joinFeed = false;
 		$values = array();
 		$values = array();
 		switch ($type) {
 		switch ($type) {
-		case 'a':
+		case 'a':	//All PRIORITY_MAIN_STREAM
 			$where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
 			$where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
 			break;
 			break;
-		case 's':	//Deprecated: use $state instead
+		case 'A':	//All except PRIORITY_ARCHIVED
+			$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
+			break;
+		case 's':	//Starred. Deprecated: use $state instead
 			$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
 			$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
 			$where .= 'AND e.is_favorite=1 ';
 			$where .= 'AND e.is_favorite=1 ';
 			break;
 			break;
-		case 'c':
+		case 'c':	//Category
 			$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
 			$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
 			$where .= 'AND f.category=? ';
 			$where .= 'AND f.category=? ';
 			$values[] = intval($id);
 			$values[] = intval($id);
 			break;
 			break;
-		case 'f':
+		case 'f':	//Feed
 			$where .= 'e.id_feed=? ';
 			$where .= 'e.id_feed=? ';
 			$values[] = intval($id);
 			$values[] = intval($id);
 			break;
 			break;
-		case 'A':
-			$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
+		case 't':	//Tag
+			$where .= 'et.id_tag=? ';
+			$values[] = intval($id);
+			break;
+		case 'T':	//Any tag
+			$where .= '1=1 ';
 			break;
 			break;
 		default:
 		default:
 			throw new FreshRSS_EntriesGetter_Exception('Bad type in Entry->listByType: [' . $type . ']!');
 			throw new FreshRSS_EntriesGetter_Exception('Bad type in Entry->listByType: [' . $type . ']!');
@@ -796,8 +853,11 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filters, $state, $order, $firstId, $date_min);
 		list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filters, $state, $order, $firstId, $date_min);
 
 
 		return array(array_merge($values, $searchValues),
 		return array(array_merge($values, $searchValues),
-			'SELECT e.id FROM `' . $this->prefix . 'entry` e '
+			'SELECT '
+			. ($type === 'T' ? 'DISTINCT ' : '')
+			. 'e.id FROM `' . $this->prefix . 'entry` e '
 			. 'INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed = f.id '
 			. 'INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed = f.id '
+			. ($type === 't' || $type === 'T' ? 'INNER JOIN `' . $this->prefix . 'entrytag` et ON et.id_entry = e.id ' : '')
 			. 'WHERE ' . $where
 			. 'WHERE ' . $where
 			. $search
 			. $search
 			. 'ORDER BY e.id ' . $order
 			. 'ORDER BY e.id ' . $order
@@ -817,13 +877,22 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 			. 'ORDER BY e0.id ' . $order;
 			. 'ORDER BY e0.id ' . $order;
 
 
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
-		$stm->execute($values);
-		return $stm;
+		if ($stm && $stm->execute($values)) {
+			return $stm;
+		} else {
+			$info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo();
+			Minz_Log::error('SQL error listWhereRaw: ' . $info[2]);
+			return false;
+		}
 	}
 	}
 
 
 	public function listWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filters = null, $date_min = 0) {
 	public function listWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filters = null, $date_min = 0) {
 		$stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
 		$stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filters, $date_min);
-		return self::daoToEntries($stm->fetchAll(PDO::FETCH_ASSOC));
+		if ($stm) {
+			return self::daoToEntries($stm->fetchAll(PDO::FETCH_ASSOC));
+		} else {
+			return false;
+		}
 	}
 	}
 
 
 	public function listByIds($ids, $order = 'DESC') {
 	public function listByIds($ids, $order = 'DESC') {

+ 7 - 2
app/Models/EntryDAOPGSQL.php

@@ -12,8 +12,13 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 
 
 	protected function autoUpdateDb($errorInfo) {
 	protected function autoUpdateDb($errorInfo) {
 		if (isset($errorInfo[0])) {
 		if (isset($errorInfo[0])) {
-			if ($errorInfo[0] === '42P01' && stripos($errorInfo[2], 'entrytmp') !== false) {	//undefined_table
-				return $this->createEntryTempTable();
+			if ($errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_TABLE) {
+				if (stripos($errorInfo[2], 'tag') !== false) {
+					$tagDAO = FreshRSS_Factory::createTagDao();
+					return $tagDAO->createTagTable();	//v1.12.0
+				} elseif (stripos($errorInfo[2], 'entrytmp') !== false) {
+					return $this->createEntryTempTable();	//v1.7.0
+				}
 			}
 			}
 		}
 		}
 		return false;
 		return false;

+ 47 - 1
app/Models/EntryDAOSQLite.php

@@ -7,10 +7,17 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	}
 	}
 
 
 	protected function autoUpdateDb($errorInfo) {
 	protected function autoUpdateDb($errorInfo) {
+		if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='tag'")) {
+			$showCreate = $tableInfo->fetchColumn();
+			if (stripos($showCreate, 'tag') === false) {
+				$tagDAO = FreshRSS_Factory::createTagDao();
+				return $tagDAO->createTagTable();	//v1.12.0
+			}
+		}
 		if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entrytmp'")) {
 		if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entrytmp'")) {
 			$showCreate = $tableInfo->fetchColumn();
 			$showCreate = $tableInfo->fetchColumn();
 			if (stripos($showCreate, 'entrytmp') === false) {
 			if (stripos($showCreate, 'entrytmp') === false) {
-				return $this->createEntryTempTable();
+				return $this->createEntryTempTable();	//v1.7.0
 			}
 			}
 		}
 		}
 		if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entry'")) {
 		if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entry'")) {
@@ -228,4 +235,43 @@ DROP TABLE IF EXISTS `tmp`;
 		}
 		}
 		return $affected;
 		return $affected;
 	}
 	}
+
+	/**
+	 * Mark all the articles in a tag as read.
+	 * @param integer $id tag ID, or empty for targetting any tag
+	 * @param integer $idMax max article ID
+	 * @return integer affected rows
+	 */
+	public function markReadTag($id = '', $idMax = 0, $filters = null, $state = 0, $is_read = true) {
+		FreshRSS_UserDAO::touch();
+		if ($idMax == 0) {
+			$idMax = time() . '000000';
+			Minz_Log::debug('Calling markReadTag(0) is deprecated!');
+		}
+
+		$sql = 'UPDATE `' . $this->prefix . 'entry` e '
+			 . 'SET e.is_read = ? '
+			 . 'WHERE e.is_read <> ? AND e.id <= ? AND '
+			 . 'e.id IN (SELECT et.id_entry FROM `' . $this->prefix . 'entrytag` et '
+			 . ($id == '' ? '' : 'WHERE et.id = ?')
+			 . ')';
+		$values = array($is_read ? 1 : 0, $is_read ? 1 : 0, $idMax);
+		if ($id != '') {
+			$values[] = $id;
+		}
+
+		list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filters, $state);
+
+		$stm = $this->bd->prepare($sql . $search);
+		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
+			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
+			Minz_Log::error('SQL error markReadTag: ' . $info[2]);
+			return false;
+		}
+		$affected = $stm->rowCount();
+		if (($affected > 0) && (!$this->updateCacheUnreads(false, false))) {
+			return false;
+		}
+		return $affected;
+	}
 }
 }

+ 16 - 0
app/Models/Factory.php

@@ -2,6 +2,10 @@
 
 
 class FreshRSS_Factory {
 class FreshRSS_Factory {
 
 
+	public static function createCategoryDao($username = null) {
+		return new FreshRSS_CategoryDAO($username);
+	}
+
 	public static function createFeedDao($username = null) {
 	public static function createFeedDao($username = null) {
 		$conf = Minz_Configuration::get('system');
 		$conf = Minz_Configuration::get('system');
 		switch ($conf->db['type']) {
 		switch ($conf->db['type']) {
@@ -24,6 +28,18 @@ class FreshRSS_Factory {
 		}
 		}
 	}
 	}
 
 
+	public static function createTagDao($username = null) {
+		$conf = Minz_Configuration::get('system');
+		switch ($conf->db['type']) {
+			case 'sqlite':
+				return new FreshRSS_TagDAOSQLite($username);
+			case 'pgsql':
+				return new FreshRSS_TagDAOPGSQL($username);
+			default:
+				return new FreshRSS_TagDAO($username);
+		}
+	}
+
 	public static function createStatsDAO($username = null) {
 	public static function createStatsDAO($username = null) {
 		$conf = Minz_Configuration::get('system');
 		$conf = Minz_Configuration::get('system');
 		switch ($conf->db['type']) {
 		switch ($conf->db['type']) {

+ 6 - 3
app/Models/FeedDAO.php

@@ -17,7 +17,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 
 	protected function autoUpdateDb($errorInfo) {
 	protected function autoUpdateDb($errorInfo) {
 		if (isset($errorInfo[0])) {
 		if (isset($errorInfo[0])) {
-			if ($errorInfo[0] === '42S22' || $errorInfo[0] === '42703') {	//ER_BAD_FIELD_ERROR (Mysql), undefined_column (PostgreSQL)
+			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_COLUMN) {
 				foreach (array('attributes') as $column) {
 				foreach (array('attributes') as $column) {
 					if (stripos($errorInfo[2], $column) !== false) {
 					if (stripos($errorInfo[2], $column) !== false) {
 						return $this->addColumn($column);
 						return $this->addColumn($column);
@@ -55,7 +55,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		$values = array(
 		$values = array(
 			substr($valuesTmp['url'], 0, 511),
 			substr($valuesTmp['url'], 0, 511),
 			$valuesTmp['category'],
 			$valuesTmp['category'],
-			mb_strcut($valuesTmp['name'], 0, 255, 'UTF-8'),
+			mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'),
 			substr($valuesTmp['website'], 0, 255),
 			substr($valuesTmp['website'], 0, 255),
 			mb_strcut($valuesTmp['description'], 0, 1023, 'UTF-8'),
 			mb_strcut($valuesTmp['description'], 0, 1023, 'UTF-8'),
 			$valuesTmp['lastUpdate'],
 			$valuesTmp['lastUpdate'],
@@ -109,6 +109,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	}
 	}
 
 
 	public function updateFeed($id, $valuesTmp) {
 	public function updateFeed($id, $valuesTmp) {
+		if (isset($valuesTmp['name'])) {
+			$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
+		}
 		if (isset($valuesTmp['url'])) {
 		if (isset($valuesTmp['url'])) {
 			$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
 			$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
 		}
 		}
@@ -180,7 +183,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	}
 	}
 
 
 	public function changeCategory($idOldCat, $idNewCat) {
 	public function changeCategory($idOldCat, $idNewCat) {
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$newCat = $catDAO->searchById($idNewCat);
 		$newCat = $catDAO->searchById($idNewCat);
 		if (!$newCat) {
 		if (!$newCat) {
 			$newCat = $catDAO->getDefault();
 			$newCat = $catDAO->getDefault();

+ 76 - 0
app/Models/Tag.php

@@ -0,0 +1,76 @@
+<?php
+
+class FreshRSS_Tag extends Minz_Model {
+	private $id = 0;
+	private $name;
+	private $attributes = array();
+	private $nbEntries = -1;
+	private $nbUnread = -1;
+
+	public function __construct($name = '') {
+		$this->_name($name);
+	}
+
+	public function id() {
+		return $this->id;
+	}
+
+	public function _id($value) {
+		$this->id = (int)$value;
+	}
+
+	public function name() {
+		return $this->name;
+	}
+
+	public function _name($value) {
+		$this->name = trim($value);
+	}
+
+	public function attributes($key = '') {
+		if ($key == '') {
+			return $this->attributes;
+		} else {
+			return isset($this->attributes[$key]) ? $this->attributes[$key] : null;
+		}
+	}
+
+	public function _attributes($key, $value) {
+		if ($key == '') {
+			if (is_string($value)) {
+				$value = json_decode($value, true);
+			}
+			if (is_array($value)) {
+				$this->attributes = $value;
+			}
+		} elseif ($value === null) {
+			unset($this->attributes[$key]);
+		} else {
+			$this->attributes[$key] = $value;
+		}
+	}
+
+	public function nbEntries() {
+		if ($this->nbEntries < 0) {
+			$tagDAO = FreshRSS_Factory::createTagDao();
+			$this->nbEntries = $tagDAO->countEntries($this->id());
+		}
+		return $this->nbFeed;
+	}
+
+	public function _nbEntries($value) {
+		$this->nbEntries = (int)$value;
+	}
+
+	public function nbUnread() {
+		if ($this->nbUnread < 0) {
+			$tagDAO = FreshRSS_Factory::createTagDao();
+			$this->nbUnread = $tagDAO->countNotRead($this->id());
+		}
+		return $this->nbUnread;
+	}
+
+	public function _nbUnread($value) {
+		$this->nbUnread = (int)$value;
+	}
+}

+ 310 - 0
app/Models/TagDAO.php

@@ -0,0 +1,310 @@
+<?php
+
+class FreshRSS_TagDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
+
+	public function sqlIgnore() {
+		return 'IGNORE';
+	}
+
+	public function createTagTable() {
+		$ok = false;
+		$hadTransaction = $this->bd->inTransaction();
+		if ($hadTransaction) {
+			$this->bd->commit();
+		}
+		try {
+			$db = FreshRSS_Context::$system_conf->db;
+			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
+			Minz_Log::warning('SQL CREATE TABLE tag...');
+			if (defined('SQL_CREATE_TABLE_TAGS')) {
+				$sql = sprintf(SQL_CREATE_TABLE_TAGS, $this->prefix);
+				$stm = $this->bd->prepare($sql);
+				$ok = $stm && $stm->execute();
+			} else {
+				global $SQL_CREATE_TABLE_TAGS;
+				$ok = !empty($SQL_CREATE_TABLE_TAGS);
+				foreach ($SQL_CREATE_TABLE_TAGS as $instruction) {
+					$sql = sprintf($instruction, $this->prefix);
+					$stm = $this->bd->prepare($sql);
+					$ok &= $stm && $stm->execute();
+				}
+			}
+		} catch (Exception $e) {
+			Minz_Log::error('FreshRSS_EntryDAO::createTagTable error: ' . $e->getMessage());
+		}
+		if ($hadTransaction) {
+			$this->bd->beginTransaction();
+		}
+		return $ok;
+	}
+
+	protected function autoUpdateDb($errorInfo) {
+		if (isset($errorInfo[0])) {
+			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_TABLE_ERROR || $errorInfo[0] === FreshRSS_DatabaseDAOPGSQL::UNDEFINED_TABLE) {
+				if (stripos($errorInfo[2], 'tag') !== false) {
+					return $this->createTagTable();	//v1.12.0
+				}
+			}
+		}
+		return false;
+	}
+
+	public function addTag($valuesTmp) {
+		$sql = 'INSERT INTO `' . $this->prefix . 'tag`(name, attributes) '
+		     . 'SELECT * FROM (SELECT TRIM(?), TRIM(?)) t2 '	//TRIM() to provide a type hint as text for PostgreSQL
+		     . 'WHERE NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'category` WHERE name = TRIM(?))';	//No category of the same name
+		$stm = $this->bd->prepare($sql);
+
+		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, 63, 'UTF-8');
+		$values = array(
+			$valuesTmp['name'],
+			isset($valuesTmp['attributes']) ? json_encode($valuesTmp['attributes']) : '',
+			$valuesTmp['name'],
+		);
+
+		if ($stm && $stm->execute($values)) {
+			return $this->bd->lastInsertId('"' . $this->prefix . 'tag_id_seq"');
+		} else {
+			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
+			Minz_Log::error('SQL error addTag: ' . $info[2]);
+			return false;
+		}
+	}
+
+	public function addTagObject($tag) {
+		$tag = $this->searchByName($tag->name());
+		if (!$tag) {
+			$values = array(
+				'name' => $tag->name(),
+				'attributes' => $tag->attributes(),
+			);
+			return $this->addTag($values);
+		}
+		return $tag->id();
+	}
+
+	public function updateTag($id, $valuesTmp) {
+		$sql = 'UPDATE `' . $this->prefix . 'tag` SET name=?, attributes=? WHERE id=? '
+		     . 'AND NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'category` WHERE name = ?)';	//No category of the same name
+		$stm = $this->bd->prepare($sql);
+
+		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, 63, 'UTF-8');
+		$values = array(
+			$valuesTmp['name'],
+			isset($valuesTmp['attributes']) ? json_encode($valuesTmp['attributes']) : '',
+			$id,
+			$valuesTmp['name'],
+		);
+
+		if ($stm && $stm->execute($values)) {
+			return $stm->rowCount();
+		} else {
+			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
+			Minz_Log::error('SQL error updateTag: ' . $info[2]);
+			return false;
+		}
+	}
+
+	public function updateTagAttribute($tag, $key, $value) {
+		if ($tag instanceof FreshRSS_Tag) {
+			$tag->_attributes($key, $value);
+			return $this->updateFeed(
+					$tag->id(),
+					array('attributes' => $feed->attributes())
+				);
+		}
+		return false;
+	}
+
+	public function deleteTag($id) {
+		if ($id <= 0) {
+			return false;
+		}
+		$sql = 'DELETE FROM `' . $this->prefix . 'tag` WHERE id=?';
+		$stm = $this->bd->prepare($sql);
+
+		$values = array($id);
+
+		if ($stm && $stm->execute($values)) {
+			return $stm->rowCount();
+		} else {
+			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
+			Minz_Log::error('SQL error deleteTag: ' . $info[2]);
+			return false;
+		}
+	}
+
+	public function searchById($id) {
+		$sql = 'SELECT * FROM `' . $this->prefix . 'tag` WHERE id=?';
+		$stm = $this->bd->prepare($sql);
+		$values = array($id);
+		$stm->execute($values);
+		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		$tag = self::daoToTag($res);
+		return isset($tag[0]) ? $tag[0] : null;
+	}
+
+	public function searchByName($name) {
+		$sql = 'SELECT * FROM `' . $this->prefix . 'tag` WHERE name=?';
+		$stm = $this->bd->prepare($sql);
+		$values = array($name);
+		$stm->execute($values);
+		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		$tag = self::daoToTag($res);
+		return isset($tag[0]) ? $tag[0] : null;
+	}
+
+	public function listTags($precounts = false) {
+		if ($precounts) {
+			$sql = 'SELECT t.id, t.name, count(e.id) AS unreads '
+				 . 'FROM `' . $this->prefix . 'tag` t '
+				 . 'LEFT OUTER JOIN `' . $this->prefix . 'entrytag` et ON et.id_tag = t.id '
+				 . 'LEFT OUTER JOIN `' . $this->prefix . 'entry` e ON et.id_entry = e.id AND e.is_read = 0 '
+				 . 'GROUP BY t.id '
+				 . 'ORDER BY t.name';
+		} else {
+			$sql = 'SELECT * FROM `' . $this->prefix . 'tag` ORDER BY name';
+		}
+
+		$stm = $this->bd->prepare($sql);
+		if ($stm && $stm->execute()) {
+			return self::daoToTag($stm->fetchAll(PDO::FETCH_ASSOC));
+		} else {
+			$info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->listTags($precounts);
+			}
+			Minz_Log::error('SQL error listTags: ' . $info[2]);
+			return false;
+		}
+	}
+
+	public function count() {
+		$sql = 'SELECT COUNT(*) AS count FROM `' . $this->prefix . 'tag`';
+		$stm = $this->bd->prepare($sql);
+		$stm->execute();
+		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		return $res[0]['count'];
+	}
+
+	public function countEntries($id) {
+		$sql = 'SELECT COUNT(*) AS count FROM `' . $this->prefix . 'entrytag` WHERE id_tag=?';
+		$stm = $this->bd->prepare($sql);
+		$values = array($id);
+		$stm->execute($values);
+		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		return $res[0]['count'];
+	}
+
+	public function countNotRead($id) {
+		$sql = 'SELECT COUNT(*) AS count FROM `' . $this->prefix . 'entrytag` et '
+			 . 'INNER JOIN `' . $this->prefix . 'entry` e ON et.id_entry=e.id '
+			 . 'WHERE et.id_tag=? AND e.is_read=0';
+		$stm = $this->bd->prepare($sql);
+		$values = array($id);
+		$stm->execute($values);
+		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		return $res[0]['count'];
+	}
+
+	public function tagEntry($id_tag, $id_entry, $checked = true) {
+		if ($checked) {
+			$sql = 'INSERT ' . $this->sqlIgnore() . ' INTO `' . $this->prefix . 'entrytag`(id_tag, id_entry) VALUES(?, ?)';
+		} else {
+			$sql = 'DELETE FROM `' . $this->prefix . 'entrytag` WHERE id_tag=? AND id_entry=?';
+		}
+		$stm = $this->bd->prepare($sql);
+		$values = array($id_tag, $id_entry);
+
+		if ($stm && $stm->execute($values)) {
+			return true;
+		} else {
+			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
+			Minz_Log::error('SQL error tagEntry: ' . $info[2]);
+			return false;
+		}
+	}
+
+	public function getTagsForEntry($id_entry) {
+		$sql = 'SELECT t.id, t.name, et.id_entry IS NOT NULL as checked '
+			 . 'FROM `' . $this->prefix . 'tag` t '
+			 . 'LEFT OUTER JOIN `' . $this->prefix . 'entrytag` et ON et.id_tag = t.id AND et.id_entry=? '
+			 . 'ORDER BY t.name';
+
+		$stm = $this->bd->prepare($sql);
+		$values = array($id_entry);
+
+		if ($stm && $stm->execute($values)) {
+			$lines = $stm->fetchAll(PDO::FETCH_ASSOC);
+			for ($i = count($lines) - 1; $i >= 0; $i--) {
+				$lines[$i]['id'] = intval($lines[$i]['id']);
+				$lines[$i]['checked'] = !empty($lines[$i]['checked']);
+			}
+			return $lines;
+		} else {
+			$info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->getTagsForEntry($id_entry);
+			}
+			Minz_Log::error('SQL error getTagsForEntry: ' . $info[2]);
+			return false;
+		}
+	}
+
+	//For API
+	public function getEntryIdsTagNames($entries) {
+		$sql = 'SELECT et.id_entry, t.name '
+			 . 'FROM `' . $this->prefix . 'tag` t '
+			 . 'INNER JOIN `' . $this->prefix . 'entrytag` et ON et.id_tag = t.id';
+
+		$values = array();
+		if (is_array($entries) && count($entries) > 0) {
+			$sql .= ' AND et.id_entry IN (' . str_repeat('?,', count($entries) - 1). '?)';
+			foreach ($entries as $entry) {
+				$values[] = $entry->id();
+			}
+		}
+		$stm = $this->bd->prepare($sql);
+
+		if ($stm && $stm->execute($values)) {
+			$result = array();
+			foreach ($stm->fetchAll(PDO::FETCH_ASSOC) as $line) {
+				$entryId = 'e_' . $line['id_entry'];
+				$tagName = $line['name'];
+				if (empty($result[$entryId])) {
+					$result[$entryId] = array();
+				}
+				$result[$entryId][] = $tagName;
+			}
+			return $result;
+		} else {
+			$info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->getTagNamesEntryIds($id_entry);
+			}
+			Minz_Log::error('SQL error getTagNamesEntryIds: ' . $info[2]);
+			return false;
+		}
+	}
+
+	public static function daoToTag($listDAO) {
+		$list = array();
+		if (!is_array($listDAO)) {
+			$listDAO = array($listDAO);
+		}
+		foreach ($listDAO as $key => $dao) {
+			$tag = new FreshRSS_Tag(
+				$dao['name']
+			);
+			$tag->_id($dao['id']);
+			if (!empty($dao['attributes'])) {
+				$tag->_attributes('', $dao['attributes']);
+			}
+			if (isset($dao['unreads'])) {
+				$tag->_nbUnread($dao['unreads']);
+			}
+			$list[$key] = $tag;
+		}
+		return $list;
+	}
+}

+ 9 - 0
app/Models/TagDAOPGSQL.php

@@ -0,0 +1,9 @@
+<?php
+
+class FreshRSS_TagDAOPGSQL extends FreshRSS_TagDAO {
+
+	public function sqlIgnore() {
+		return '';	//TODO
+	}
+
+}

+ 19 - 0
app/Models/TagDAOSQLite.php

@@ -0,0 +1,19 @@
+<?php
+
+class FreshRSS_TagDAOSQLite extends FreshRSS_TagDAO {
+
+	public function sqlIgnore() {
+		return 'OR IGNORE';
+	}
+
+	protected function autoUpdateDb($errorInfo) {
+		if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='tag'")) {
+			$showCreate = $tableInfo->fetchColumn();
+			if (stripos($showCreate, 'tag') === false) {
+				return $this->createTagTable();	//v1.12.0
+			}
+		}
+		return false;
+	}
+
+}

+ 9 - 3
app/Models/Themes.php

@@ -68,7 +68,7 @@ class FreshRSS_Themes extends Minz_Model {
 		return $infos;
 		return $infos;
 	}
 	}
 
 
-	public static function icon($name, $urlOnly = false) {
+	public static function alt($name) {
 		static $alts = array(
 		static $alts = array(
 			'add' => '✚',
 			'add' => '✚',
 			'all' => '☰',
 			'all' => '☰',
@@ -84,6 +84,7 @@ class FreshRSS_Themes extends Minz_Model {
 			'icon' => '⊚',
 			'icon' => '⊚',
 			'import' => '⤓',
 			'import' => '⤓',
 			'key' => '⚿',
 			'key' => '⚿',
+			'label' => '🏷️',
 			'link' => '↗',
 			'link' => '↗',
 			'login' => '🔒',
 			'login' => '🔒',
 			'logout' => '🔓',
 			'logout' => '🔓',
@@ -104,13 +105,18 @@ class FreshRSS_Themes extends Minz_Model {
 			'view-global' => '☷',
 			'view-global' => '☷',
 			'view-reader' => '☕',
 			'view-reader' => '☕',
 		);
 		);
-		if (!isset($alts[$name])) {
+		return isset($name) ? $alts[$name] : '';
+	}
+
+	public static function icon($name, $urlOnly = false, $altOnly = false) {
+		$alt = self::alt($name);
+		if ($alt == '') {
 			return '';
 			return '';
 		}
 		}
 
 
 		$url = $name . '.svg';
 		$url = $name . '.svg';
 		$url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url);
 		$url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url);
 
 
-		return $urlOnly ? Minz_Url::display($url) : '<img class="icon" src="' . Minz_Url::display($url) . '" alt="' . $alts[$name] . '" />';
+		return $urlOnly ? Minz_Url::display($url) : '<img class="icon" src="' . Minz_Url::display($url) . '" alt="' . $alt . '" />';
 	}
 	}
 }
 }

+ 3 - 4
app/Models/UserDAO.php

@@ -14,14 +14,13 @@ class FreshRSS_UserDAO extends Minz_ModelPdo {
 			$ok = false;
 			$ok = false;
 			$bd_prefix_user = $db['prefix'] . $username . '_';
 			$bd_prefix_user = $db['prefix'] . $username . '_';
 			if (defined('SQL_CREATE_TABLES')) {	//E.g. MySQL
 			if (defined('SQL_CREATE_TABLES')) {	//E.g. MySQL
-				$sql = sprintf(SQL_CREATE_TABLES . SQL_CREATE_TABLE_ENTRYTMP, $bd_prefix_user, _t('gen.short.default_category'));
+				$sql = sprintf(SQL_CREATE_TABLES . SQL_CREATE_TABLE_ENTRYTMP . SQL_CREATE_TABLE_TAGS, $bd_prefix_user, _t('gen.short.default_category'));
 				$stm = $userPDO->bd->prepare($sql);
 				$stm = $userPDO->bd->prepare($sql);
 				$ok = $stm && $stm->execute();
 				$ok = $stm && $stm->execute();
 			} else {	//E.g. SQLite
 			} else {	//E.g. SQLite
-				global $SQL_CREATE_TABLES;
-				global $SQL_CREATE_TABLE_ENTRYTMP;
+				global $SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP, $SQL_CREATE_TABLE_TAGS;
 				if (is_array($SQL_CREATE_TABLES)) {
 				if (is_array($SQL_CREATE_TABLES)) {
-					$instructions = array_merge($SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP);
+					$instructions = array_merge($SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP, $SQL_CREATE_TABLE_TAGS);
 					$ok = !empty($instructions);
 					$ok = !empty($instructions);
 					foreach ($instructions as $instruction) {
 					foreach ($instructions as $instruction) {
 						$sql = sprintf($instruction, $bd_prefix_user, _t('gen.short.default_category'));
 						$sql = sprintf($instruction, $bd_prefix_user, _t('gen.short.default_category'));

+ 25 - 1
app/Models/UserQuery.php

@@ -19,15 +19,17 @@ class FreshRSS_UserQuery {
 	private $url;
 	private $url;
 	private $feed_dao;
 	private $feed_dao;
 	private $category_dao;
 	private $category_dao;
+	private $tag_dao;
 
 
 	/**
 	/**
 	 * @param array $query
 	 * @param array $query
 	 * @param FreshRSS_Searchable $feed_dao
 	 * @param FreshRSS_Searchable $feed_dao
 	 * @param FreshRSS_Searchable $category_dao
 	 * @param FreshRSS_Searchable $category_dao
 	 */
 	 */
-	public function __construct($query, FreshRSS_Searchable $feed_dao = null, FreshRSS_Searchable $category_dao = null) {
+	public function __construct($query, FreshRSS_Searchable $feed_dao = null, FreshRSS_Searchable $category_dao = null, FreshRSS_Searchable $tag_dao = null) {
 		$this->category_dao = $category_dao;
 		$this->category_dao = $category_dao;
 		$this->feed_dao = $feed_dao;
 		$this->feed_dao = $feed_dao;
+		$this->tag_dao = $tag_dao;
 		if (isset($query['get'])) {
 		if (isset($query['get'])) {
 			$this->parseGet($query['get']);
 			$this->parseGet($query['get']);
 		}
 		}
@@ -88,6 +90,9 @@ class FreshRSS_UserQuery {
 				case 's':
 				case 's':
 					$this->parseFavorite();
 					$this->parseFavorite();
 					break;
 					break;
+				case 't':
+					$this->parseTag($matches['id']);
+					break;
 			}
 			}
 		}
 		}
 	}
 	}
@@ -138,6 +143,25 @@ class FreshRSS_UserQuery {
 		$this->get_type = 'feed';
 		$this->get_type = 'feed';
 	}
 	}
 
 
+	/**
+	 * Parse the query string when it is a "tag" query
+	 *
+	 * @param integer $id
+	 * @throws FreshRSS_DAO_Exception
+	 */
+	private function parseTag($id) {
+		if ($this->tag_dao == null) {
+			throw new FreshRSS_DAO_Exception('Tag DAO is not loaded in UserQuery');
+		}
+		$category = $this->category_dao->searchById($id);
+		if ($tag) {
+			$this->get_name = $tag->name();
+		} else {
+			$this->deprecated = true;
+		}
+		$this->get_type = 'tag';
+	}
+
 	/**
 	/**
 	 * Parse the query string when it is a "favorite" query
 	 * Parse the query string when it is a "favorite" query
 	 */
 	 */

+ 61 - 40
app/SQL/install.sql.mysql.php

@@ -4,7 +4,7 @@ define('SQL_CREATE_DB', 'CREATE DATABASE IF NOT EXISTS %1$s DEFAULT CHARACTER SE
 define('SQL_CREATE_TABLES', '
 define('SQL_CREATE_TABLES', '
 CREATE TABLE IF NOT EXISTS `%1$scategory` (
 CREATE TABLE IF NOT EXISTS `%1$scategory` (
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
-	`name` varchar(191) NOT NULL,
+	`name` VARCHAR(' . FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE . ') NOT NULL,	-- Max index length for Unicode is 191 characters (767 bytes)
 	PRIMARY KEY (`id`),
 	PRIMARY KEY (`id`),
 	UNIQUE KEY (`name`)	-- v0.7
 	UNIQUE KEY (`name`)	-- v0.7
 ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
 ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
@@ -12,21 +12,21 @@ ENGINE = INNODB;
 
 
 CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
-	`url` varchar(511) CHARACTER SET latin1 NOT NULL,
+	`url` VARCHAR(511) CHARACTER SET latin1 NOT NULL,
 	`category` SMALLINT DEFAULT 0,	-- v0.7
 	`category` SMALLINT DEFAULT 0,	-- v0.7
-	`name` varchar(191) NOT NULL,
-	`website` varchar(255) CHARACTER SET latin1,
-	`description` text,
-	`lastUpdate` int(11) DEFAULT 0,	-- Until year 2038
-	`priority` tinyint(2) NOT NULL DEFAULT 10,
-	`pathEntries` varchar(511) DEFAULT NULL,
-	`httpAuth` varchar(511) DEFAULT NULL,
-	`error` boolean DEFAULT 0,
+	`name` VARCHAR(' . FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE . ') NOT NULL,
+	`website` VARCHAR(255) CHARACTER SET latin1,
+	`description` TEXT,
+	`lastUpdate` INT(11) DEFAULT 0,	-- Until year 2038
+	`priority` TINYNT(2) NOT NULL DEFAULT 10,
+	`pathEntries` VARCHAR(511) DEFAULT NULL,
+	`httpAuth` VARCHAR(511) DEFAULT NULL,
+	`error` BOOLEAN DEFAULT 0,
 	`keep_history` MEDIUMINT NOT NULL DEFAULT -2,	-- v0.7
 	`keep_history` MEDIUMINT NOT NULL DEFAULT -2,	-- v0.7
 	`ttl` INT NOT NULL DEFAULT 0,	-- v0.7.3
 	`ttl` INT NOT NULL DEFAULT 0,	-- v0.7.3
 	`attributes` TEXT,	-- v1.11.0
 	`attributes` TEXT,	-- v1.11.0
-	`cache_nbEntries` int DEFAULT 0,	-- v0.7
-	`cache_nbUnreads` int DEFAULT 0,	-- v0.7
+	`cache_nbEntries` INT DEFAULT 0,	-- v0.7
+	`cache_nbUnreads` INT DEFAULT 0,	-- v0.7
 	PRIMARY KEY (`id`),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`category`) REFERENCES `%1$scategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
 	FOREIGN KEY (`category`) REFERENCES `%1$scategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
 	UNIQUE KEY (`url`),	-- v0.7
 	UNIQUE KEY (`url`),	-- v0.7
@@ -37,19 +37,19 @@ CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 ENGINE = INNODB;
 ENGINE = INNODB;
 
 
 CREATE TABLE IF NOT EXISTS `%1$sentry` (
 CREATE TABLE IF NOT EXISTS `%1$sentry` (
-	`id` bigint NOT NULL,	-- v0.7
-	`guid` varchar(760) CHARACTER SET latin1 NOT NULL,	-- Maximum for UNIQUE is 767B
-	`title` varchar(255) NOT NULL,
-	`author` varchar(255),
-	`content_bin` blob,	-- v0.7
-	`link` varchar(1023) CHARACTER SET latin1 NOT NULL,
-	`date` int(11),	-- Until year 2038
+	`id` BIGINT NOT NULL,	-- v0.7
+	`guid` VARCHAR(760) CHARACTER SET latin1 NOT NULL,	-- Maximum for UNIQUE is 767B
+	`title` VARCHAR(255) NOT NULL,
+	`author` VARCHAR(255),
+	`content_bin` BLOB,	-- v0.7
+	`link` VARCHAR(1023) CHARACTER SET latin1 NOT NULL,
+	`date` INT(11),	-- Until year 2038
 	`lastSeen` INT(11) DEFAULT 0,	-- v1.1.1, Until year 2038
 	`lastSeen` INT(11) DEFAULT 0,	-- v1.1.1, Until year 2038
 	`hash` BINARY(16),	-- v1.1.1
 	`hash` BINARY(16),	-- v1.1.1
-	`is_read` boolean NOT NULL DEFAULT 0,
-	`is_favorite` boolean NOT NULL DEFAULT 0,
+	`is_read` BOOLEAN NOT NULL DEFAULT 0,
+	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
 	`id_feed` SMALLINT,	-- v0.7
 	`id_feed` SMALLINT,	-- v0.7
-	`tags` varchar(1023),
+	`tags` VARCHAR(1023),
 	PRIMARY KEY (`id`),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE KEY (`id_feed`,`guid`),	-- v0.7
 	UNIQUE KEY (`id_feed`,`guid`),	-- v0.7
@@ -65,19 +65,19 @@ INSERT IGNORE INTO `%1$scategory` (id, name) VALUES(1, "%2$s");
 
 
 define('SQL_CREATE_TABLE_ENTRYTMP', '
 define('SQL_CREATE_TABLE_ENTRYTMP', '
 CREATE TABLE IF NOT EXISTS `%1$sentrytmp` (	-- v1.7
 CREATE TABLE IF NOT EXISTS `%1$sentrytmp` (	-- v1.7
-	`id` bigint NOT NULL,
-	`guid` varchar(760) CHARACTER SET latin1 NOT NULL,
-	`title` varchar(255) NOT NULL,
-	`author` varchar(255),
-	`content_bin` blob,
-	`link` varchar(1023) CHARACTER SET latin1 NOT NULL,
-	`date` int(11),
+	`id` BIGINT NOT NULL,
+	`guid` VARCHAR(760) CHARACTER SET latin1 NOT NULL,
+	`title` VARCHAR(255) NOT NULL,
+	`author` VARCHAR(255),
+	`content_bin` BLOB,
+	`link` VARCHAR(1023) CHARACTER SET latin1 NOT NULL,
+	`date` INT(11),
 	`lastSeen` INT(11) DEFAULT 0,
 	`lastSeen` INT(11) DEFAULT 0,
 	`hash` BINARY(16),
 	`hash` BINARY(16),
-	`is_read` boolean NOT NULL DEFAULT 0,
-	`is_favorite` boolean NOT NULL DEFAULT 0,
+	`is_read` BOOLEAN NOT NULL DEFAULT 0,
+	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
 	`id_feed` SMALLINT,
 	`id_feed` SMALLINT,
-	`tags` varchar(1023),
+	`tags` VARCHAR(1023),
 	PRIMARY KEY (`id`),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE KEY (`id_feed`,`guid`),
 	UNIQUE KEY (`id_feed`,`guid`),
@@ -88,25 +88,46 @@ ENGINE = INNODB;
 CREATE INDEX `entry_feed_read_index` ON `%1$sentry`(`id_feed`,`is_read`);	-- v1.7 Located here to be auto-added
 CREATE INDEX `entry_feed_read_index` ON `%1$sentry`(`id_feed`,`is_read`);	-- v1.7 Located here to be auto-added
 ');
 ');
 
 
+define('SQL_CREATE_TABLE_TAGS', '
+CREATE TABLE IF NOT EXISTS `%1$stag` (	-- v1.12
+	`id` SMALLINT NOT NULL AUTO_INCREMENT,
+	`name` VARCHAR(63) NOT NULL,
+	`attributes` TEXT,
+	PRIMARY KEY (`id`),
+	UNIQUE KEY (`name`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
+ENGINE = INNODB;
+
+CREATE TABLE IF NOT EXISTS `%1$sentrytag` (	-- v1.12
+	`id_tag` SMALLINT,
+	`id_entry` BIGINT,
+	PRIMARY KEY (`id_tag`,`id_entry`),
+	FOREIGN KEY (`id_tag`) REFERENCES `%1$stag`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+	FOREIGN KEY (`id_entry`) REFERENCES `%1$sentry`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+	INDEX (`id_entry`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
+ENGINE = INNODB;
+');
+
 define('SQL_INSERT_FEEDS', '
 define('SQL_INSERT_FEEDS', '
-INSERT IGNORE INTO `%1$sfeed` (url, category, name, website, description, ttl) VALUES("http://freshrss.org/feeds/all.atom.xml", 1, "FreshRSS.org", "http://freshrss.org/", "FreshRSS, a free, self-hostable aggregator…", 86400);
+INSERT IGNORE INTO `%1$sfeed` (url, category, name, website, description, ttl) VALUES("https://freshrss.org/feeds/all.atom.xml", 1, "FreshRSS.org", "https://freshrss.org/", "FreshRSS, a free, self-hostable aggregator…", 86400);
 INSERT IGNORE INTO `%1$sfeed` (url, category, name, website, description, ttl) VALUES("https://github.com/FreshRSS/FreshRSS/releases.atom", 1, "FreshRSS @ GitHub", "https://github.com/FreshRSS/FreshRSS/", "FreshRSS releases @ GitHub", 86400);
 INSERT IGNORE INTO `%1$sfeed` (url, category, name, website, description, ttl) VALUES("https://github.com/FreshRSS/FreshRSS/releases.atom", 1, "FreshRSS @ GitHub", "https://github.com/FreshRSS/FreshRSS/", "FreshRSS releases @ GitHub", 86400);
 ');
 ');
 
 
-define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS `%1$sentrytmp`, `%1$sentry`, `%1$sfeed`, `%1$scategory`');
+define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS `%1$sentrytag`, `%1$stag`, `%1$sentrytmp`, `%1$sentry`, `%1$sfeed`, `%1$scategory`');
 
 
 define('SQL_UPDATE_UTF8MB4', '
 define('SQL_UPDATE_UTF8MB4', '
-ALTER DATABASE `%2$s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ALTER DATABASE `%2$s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;	-- v1.5.0
 
 
 ALTER TABLE `%1$scategory` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 ALTER TABLE `%1$scategory` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-UPDATE `%1$scategory` SET name=SUBSTRING(name,1,190) WHERE LENGTH(name) > 191;
-ALTER TABLE `%1$scategory` MODIFY `name` VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL;
+UPDATE `%1$scategory` SET name=SUBSTRING(name,1,' . FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE . ') WHERE LENGTH(name) > ' . FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE . ';
+ALTER TABLE `%1$scategory` MODIFY `name` VARCHAR(' . FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE . ') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL;
 OPTIMIZE TABLE `%1$scategory`;
 OPTIMIZE TABLE `%1$scategory`;
 
 
 ALTER TABLE `%1$sfeed` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 ALTER TABLE `%1$sfeed` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
-UPDATE `%1$sfeed` SET name=SUBSTRING(name,1,190) WHERE LENGTH(name) > 191;
-ALTER TABLE `%1$sfeed` MODIFY `name` VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL;
-ALTER TABLE `%1$sfeed` MODIFY `description` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+UPDATE `%1$sfeed` SET name=SUBSTRING(name,1,' . FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE . ') WHERE LENGTH(name) > ' . FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE . ';
+ALTER TABLE `%1$sfeed` MODIFY `name` VARCHAR(' . FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE . ') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL;
+ALTER TABLE `%1$sfeed` MODIFY `description` TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 OPTIMIZE TABLE `%1$sfeed`;
 OPTIMIZE TABLE `%1$sfeed`;
 
 
 ALTER TABLE `%1$sentry` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 ALTER TABLE `%1$sentry` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

+ 30 - 6
app/SQL/install.sql.pgsql.php

@@ -10,16 +10,16 @@ $SQL_CREATE_TABLES = array(
 
 
 'CREATE TABLE IF NOT EXISTS "%1$sfeed" (
 'CREATE TABLE IF NOT EXISTS "%1$sfeed" (
 	"id" SERIAL PRIMARY KEY,
 	"id" SERIAL PRIMARY KEY,
-	"url" varchar(511) UNIQUE NOT NULL,
+	"url" VARCHAR(511) UNIQUE NOT NULL,
 	"category" SMALLINT DEFAULT 0,
 	"category" SMALLINT DEFAULT 0,
 	"name" VARCHAR(255) NOT NULL,
 	"name" VARCHAR(255) NOT NULL,
 	"website" VARCHAR(255),
 	"website" VARCHAR(255),
-	"description" text,
+	"description" TEXT,
 	"lastUpdate" INT DEFAULT 0,
 	"lastUpdate" INT DEFAULT 0,
 	"priority" SMALLINT NOT NULL DEFAULT 10,
 	"priority" SMALLINT NOT NULL DEFAULT 10,
 	"pathEntries" VARCHAR(511) DEFAULT NULL,
 	"pathEntries" VARCHAR(511) DEFAULT NULL,
 	"httpAuth" VARCHAR(511) DEFAULT NULL,
 	"httpAuth" VARCHAR(511) DEFAULT NULL,
-	"error" smallint DEFAULT 0,
+	"error" SMALLINT DEFAULT 0,
 	"keep_history" INT NOT NULL DEFAULT -2,
 	"keep_history" INT NOT NULL DEFAULT -2,
 	"ttl" INT NOT NULL DEFAULT 0,
 	"ttl" INT NOT NULL DEFAULT 0,
 	"attributes" TEXT,	-- v1.11.0
 	"attributes" TEXT,	-- v1.11.0
@@ -52,7 +52,10 @@ $SQL_CREATE_TABLES = array(
 'CREATE INDEX %1$sis_read_index ON "%1$sentry" ("is_read");',
 'CREATE INDEX %1$sis_read_index ON "%1$sentry" ("is_read");',
 'CREATE INDEX %1$sentry_lastSeen_index ON "%1$sentry" ("lastSeen");',
 'CREATE INDEX %1$sentry_lastSeen_index ON "%1$sentry" ("lastSeen");',
 
 
-'INSERT INTO "%1$scategory" (id, name) SELECT 1, \'%2$s\' WHERE NOT EXISTS (SELECT id FROM "%1$scategory" WHERE id = 1) RETURNING nextval(\'%1$scategory_id_seq\');',
+'INSERT INTO "%1$scategory" (id, name)
+	SELECT 1, \'%2$s\'
+	WHERE NOT EXISTS (SELECT id FROM "%1$scategory" WHERE id = 1)
+	RETURNING nextval(\'%1$scategory_id_seq\');',
 );
 );
 
 
 global $SQL_CREATE_TABLE_ENTRYTMP;
 global $SQL_CREATE_TABLE_ENTRYTMP;
@@ -79,10 +82,31 @@ $SQL_CREATE_TABLE_ENTRYTMP = array(
 'CREATE INDEX %1$sentry_feed_read_index ON "%1$sentry" ("id_feed","is_read");',	//v1.7
 'CREATE INDEX %1$sentry_feed_read_index ON "%1$sentry" ("id_feed","is_read");',	//v1.7
 );
 );
 
 
+global $SQL_CREATE_TABLE_TAGS;
+$SQL_CREATE_TABLE_TAGS = array(
+'CREATE TABLE IF NOT EXISTS "%1$stag" (	-- v1.12
+	"id" SERIAL PRIMARY KEY,
+	"name" VARCHAR(63) UNIQUE NOT NULL,
+	"attributes" TEXT
+);',
+'CREATE TABLE IF NOT EXISTS "%1$sentrytag" (
+	"id_tag" SMALLINT,
+	"id_entry" BIGINT,
+	PRIMARY KEY ("id_tag","id_entry"),
+	FOREIGN KEY ("id_tag") REFERENCES "%1$stag" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+	FOREIGN KEY ("id_entry") REFERENCES "%1$sentry" ("id") ON DELETE CASCADE ON UPDATE CASCADE
+);',
+'CREATE INDEX %1$sentrytag_id_entry_index ON "%1$sentrytag" ("id_entry");',
+);
+
 global $SQL_INSERT_FEEDS;
 global $SQL_INSERT_FEEDS;
 $SQL_INSERT_FEEDS = array(
 $SQL_INSERT_FEEDS = array(
-'INSERT INTO "%1$sfeed" (url, category, name, website, description, ttl) SELECT \'http://freshrss.org/feeds/all.atom.xml\', 1, \'FreshRSS.org\', \'http://freshrss.org/\', \'FreshRSS, a free, self-hostable aggregator…\', 86400 WHERE NOT EXISTS (SELECT id FROM "%1$sfeed" WHERE url = \'http://freshrss.org/feeds/all.atom.xml\');',
-'INSERT INTO "%1$sfeed" (url, category, name, website, description, ttl) SELECT \'https://github.com/FreshRSS/FreshRSS/releases.atom\', 1, \'FreshRSS @ GitHub\', \'https://github.com/FreshRSS/FreshRSS/\', \'FreshRSS releases @ GitHub\', 86400 WHERE NOT EXISTS (SELECT id FROM "%1$sfeed" WHERE url = \'https://github.com/FreshRSS/FreshRSS/releases.atom\');',
+'INSERT INTO "%1$sfeed" (url, category, name, website, description, ttl)
+	SELECT \'https://freshrss.org/feeds/all.atom.xml\', 1, \'FreshRSS.org\', \'https://freshrss.org/\', \'FreshRSS, a free, self-hostable aggregator…\', 86400
+	WHERE NOT EXISTS (SELECT id FROM "%1$sfeed" WHERE url = \'https://freshrss.org/feeds/all.atom.xml\');',
+'INSERT INTO "%1$sfeed" (url, category, name, website, description, ttl)
+	SELECT \'https://github.com/FreshRSS/FreshRSS/releases.atom\', 1, \'FreshRSS @ GitHub\', \'https://github.com/FreshRSS/FreshRSS/\', \'FreshRSS releases @ GitHub\', 86400
+	WHERE NOT EXISTS (SELECT id FROM "%1$sfeed" WHERE url = \'https://github.com/FreshRSS/FreshRSS/releases.atom\');',
 );
 );
 
 
 define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS "%1$sentrytmp", "%1$sentry", "%1$sfeed", "%1$scategory"');
 define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS "%1$sentrytmp", "%1$sentry", "%1$sfeed", "%1$scategory"');

+ 54 - 68
app/SQL/install.sql.sqlite.php

@@ -3,27 +3,27 @@ global $SQL_CREATE_TABLES;
 $SQL_CREATE_TABLES = array(
 $SQL_CREATE_TABLES = array(
 'CREATE TABLE IF NOT EXISTS `category` (
 'CREATE TABLE IF NOT EXISTS `category` (
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
-	`name` varchar(255) NOT NULL,
+	`name` VARCHAR(255) NOT NULL,
 	UNIQUE (`name`)
 	UNIQUE (`name`)
 );',
 );',
 
 
 'CREATE TABLE IF NOT EXISTS `feed` (
 'CREATE TABLE IF NOT EXISTS `feed` (
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
-	`url` varchar(511) NOT NULL,
+	`url` VARCHAR(511) NOT NULL,
 	`category` SMALLINT DEFAULT 0,
 	`category` SMALLINT DEFAULT 0,
-	`name` varchar(255) NOT NULL,
-	`website` varchar(255),
-	`description` text,
-	`lastUpdate` int(11) DEFAULT 0,	-- Until year 2038
-	`priority` tinyint(2) NOT NULL DEFAULT 10,
-	`pathEntries` varchar(511) DEFAULT NULL,
-	`httpAuth` varchar(511) DEFAULT NULL,
-	`error` boolean DEFAULT 0,
+	`name` VARCHAR(255) NOT NULL,
+	`website` VARCHAR(255),
+	`description` TEXT,
+	`lastUpdate` INT(11) DEFAULT 0,	-- Until year 2038
+	`priority` TINYINT(2) NOT NULL DEFAULT 10,
+	`pathEntries` VARCHAR(511) DEFAULT NULL,
+	`httpAuth` VARCHAR(511) DEFAULT NULL,
+	`error` BOOLEAN DEFAULT 0,
 	`keep_history` MEDIUMINT NOT NULL DEFAULT -2,
 	`keep_history` MEDIUMINT NOT NULL DEFAULT -2,
 	`ttl` INT NOT NULL DEFAULT 0,
 	`ttl` INT NOT NULL DEFAULT 0,
 	`attributes` TEXT,	-- v1.11.0
 	`attributes` TEXT,	-- v1.11.0
-	`cache_nbEntries` int DEFAULT 0,
-	`cache_nbUnreads` int DEFAULT 0,
+	`cache_nbEntries` INT DEFAULT 0,
+	`cache_nbUnreads` INT DEFAULT 0,
 	FOREIGN KEY (`category`) REFERENCES `category`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
 	FOREIGN KEY (`category`) REFERENCES `category`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
 	UNIQUE (`url`)
 	UNIQUE (`url`)
 );',
 );',
@@ -32,19 +32,19 @@ $SQL_CREATE_TABLES = array(
 'CREATE INDEX IF NOT EXISTS feed_keep_history_index ON `feed`(`keep_history`);',
 'CREATE INDEX IF NOT EXISTS feed_keep_history_index ON `feed`(`keep_history`);',
 
 
 'CREATE TABLE IF NOT EXISTS `entry` (
 'CREATE TABLE IF NOT EXISTS `entry` (
-	`id` bigint NOT NULL,
-	`guid` varchar(760) NOT NULL,
-	`title` varchar(255) NOT NULL,
-	`author` varchar(255),
-	`content` text,
-	`link` varchar(1023) NOT NULL,
-	`date` int(11),	-- Until year 2038
+	`id` BIGINT NOT NULL,
+	`guid` VARCHAR(760) NOT NULL,
+	`title` VARCHAR(255) NOT NULL,
+	`author` VARCHAR(255),
+	`content` TEXT,
+	`link` VARCHAR(1023) NOT NULL,
+	`date` INT(11),	-- Until year 2038
 	`lastSeen` INT(11) DEFAULT 0,	-- v1.1.1, Until year 2038
 	`lastSeen` INT(11) DEFAULT 0,	-- v1.1.1, Until year 2038
 	`hash` BINARY(16),	-- v1.1.1
 	`hash` BINARY(16),	-- v1.1.1
-	`is_read` boolean NOT NULL DEFAULT 0,
-	`is_favorite` boolean NOT NULL DEFAULT 0,
+	`is_read` BOOLEAN NOT NULL DEFAULT 0,
+	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
 	`id_feed` SMALLINT,
 	`id_feed` SMALLINT,
-	`tags` varchar(1023),
+	`tags` VARCHAR(1023),
 	PRIMARY KEY (`id`),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE (`id_feed`,`guid`)
 	UNIQUE (`id_feed`,`guid`)
@@ -59,19 +59,19 @@ $SQL_CREATE_TABLES = array(
 global $SQL_CREATE_TABLE_ENTRYTMP;
 global $SQL_CREATE_TABLE_ENTRYTMP;
 $SQL_CREATE_TABLE_ENTRYTMP = array(
 $SQL_CREATE_TABLE_ENTRYTMP = array(
 'CREATE TABLE IF NOT EXISTS `entrytmp` (	-- v1.7
 'CREATE TABLE IF NOT EXISTS `entrytmp` (	-- v1.7
-	`id` bigint NOT NULL,
-	`guid` varchar(760) NOT NULL,
-	`title` varchar(255) NOT NULL,
-	`author` varchar(255),
-	`content` text,
-	`link` varchar(1023) NOT NULL,
-	`date` int(11),
+	`id` BIGINT NOT NULL,
+	`guid` VARCHAR(760) NOT NULL,
+	`title` VARCHAR(255) NOT NULL,
+	`author` VARCHAR(255),
+	`content` TEXT,
+	`link` VARCHAR(1023) NOT NULL,
+	`date` INT(11),
 	`lastSeen` INT(11) DEFAULT 0,
 	`lastSeen` INT(11) DEFAULT 0,
 	`hash` BINARY(16),
 	`hash` BINARY(16),
-	`is_read` boolean NOT NULL DEFAULT 0,
-	`is_favorite` boolean NOT NULL DEFAULT 0,
+	`is_read` BOOLEAN NOT NULL DEFAULT 0,
+	`is_favorite` BOOLEAN NOT NULL DEFAULT 0,
 	`id_feed` SMALLINT,
 	`id_feed` SMALLINT,
-	`tags` varchar(1023),
+	`tags` VARCHAR(1023),
 	PRIMARY KEY (`id`),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE (`id_feed`,`guid`)
 	UNIQUE (`id_feed`,`guid`)
@@ -81,44 +81,30 @@ $SQL_CREATE_TABLE_ENTRYTMP = array(
 'CREATE INDEX IF NOT EXISTS `entry_feed_read_index` ON `entry`(`id_feed`,`is_read`);',	//v1.7
 'CREATE INDEX IF NOT EXISTS `entry_feed_read_index` ON `entry`(`id_feed`,`is_read`);',	//v1.7
 );
 );
 
 
+global $SQL_CREATE_TABLE_TAGS;
+$SQL_CREATE_TABLE_TAGS = array(
+'CREATE TABLE IF NOT EXISTS `tag` (	-- v1.12
+	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+	`name` VARCHAR(63) NOT NULL,
+	`attributes` TEXT,
+	UNIQUE (`name`)
+);',
+'CREATE TABLE IF NOT EXISTS `entrytag` (
+	`id_tag` SMALLINT,
+	`id_entry` SMALLINT,
+	PRIMARY KEY (`id_tag`,`id_entry`),
+	FOREIGN KEY (`id_tag`) REFERENCES `tag` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+	FOREIGN KEY (`id_entry`) REFERENCES `entry` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
+);',
+'CREATE INDEX entrytag_id_entry_index ON `entrytag` (`id_entry`);',
+);
+
 global $SQL_INSERT_FEEDS;
 global $SQL_INSERT_FEEDS;
 $SQL_INSERT_FEEDS = array(
 $SQL_INSERT_FEEDS = array(
-'INSERT OR IGNORE INTO `feed`
-	(
-		url,
-		category,
-		name,
-		website,
-		description,
-		ttl
-	)
-	VALUES
-	(
-		"http://freshrss.org/feeds/all.atom.xml",
-		1,
-		"FreshRSS.org",
-		"http://freshrss.org/",
-		"FreshRSS, a free, self-hostable aggregator…",
-		86400
-	);',
-'INSERT OR IGNORE INTO `feed`
-	(
-		url,
-		category,
-		name,
-		website,
-		description,
-		ttl
-	)
-	VALUES
-	(
-		"https://github.com/FreshRSS/FreshRSS/releases.atom",
-		1,
-		"FreshRSS releases",
-		"https://github.com/FreshRSS/FreshRSS/",
-		"FreshRSS releases @ GitHub",
-		86400
-	);',
+'INSERT OR IGNORE INTO `feed` (url, category, name, website, description, ttl)
+	VALUES ("https://freshrss.org/feeds/all.atom.xml", 1, "FreshRSS.org", "https://freshrss.org/", "FreshRSS, a free, self-hostable aggregator…", 86400);',
+'INSERT OR IGNORE INTO `feed` (url, category, name, website, description, ttl)
+	VALUES ("https://github.com/FreshRSS/FreshRSS/releases.atom", 1, "FreshRSS releases", "https://github.com/FreshRSS/FreshRSS/", "FreshRSS releases @ GitHub", 86400);',
 );
 );
 
 
 define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS `entrytmp`, `entry`, `feed`, `category`');
 define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS `entrytmp`, `entry`, `feed`, `category`');

+ 1 - 1
app/i18n/cz/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Spodní řádek',
 			'bottom_line' => 'Spodní řádek',
 			'entry' => 'Ikony článků',
 			'entry' => 'Ikony článků',
 			'publication_date' => 'Datum vydání',
 			'publication_date' => 'Datum vydání',
-			'related_tags' => 'Související tagy',
+			'related_tags' => 'Související tagy',	//TODO
 			'sharing' => 'Sdílení',
 			'sharing' => 'Sdílení',
 			'top_line' => 'Horní řádek',
 			'top_line' => 'Horní řádek',
 		),
 		),

+ 3 - 2
app/i18n/cz/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Hlášení chyb',
 		'bugs_reports' => 'Hlášení chyb',
 		'credits' => 'Poděkování',
 		'credits' => 'Poděkování',
 		'credits_content' => 'Některé designové prvky pocházejí z <a href="http://twitter.github.io/bootstrap/">Bootstrap</a>, FreshRSS ale tuto platformu nevyužívá. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Ikony</a> pocházejí z <a href="https://www.gnome.org/">GNOME projektu</a>. Font <em>Open Sans</em> vytvořil <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS je založen na PHP framework <a href="https://github.com/marienfressinaud/MINZ">Minz</a>.',
 		'credits_content' => 'Některé designové prvky pocházejí z <a href="http://twitter.github.io/bootstrap/">Bootstrap</a>, FreshRSS ale tuto platformu nevyužívá. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Ikony</a> pocházejí z <a href="https://www.gnome.org/">GNOME projektu</a>. Font <em>Open Sans</em> vytvořil <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS je založen na PHP framework <a href="https://github.com/marienfressinaud/MINZ">Minz</a>.',
-		'freshrss_description' => 'FreshRSS je čtečka RSS kanálů určená k provozu na vlastním serveru, podobná <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> nebo <a href="http://projet.idleman.fr/leed/">Leed</a>. Je to nenáročný a jednoduchý, zároveň ale mocný a konfigurovatelný nástroj.',
+		'freshrss_description' => 'FreshRSS je čtečka RSS kanálů určená k provozu na vlastním serveru, podobná <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> nebo <a href="http://leed.idleman.fr/">Leed</a>. Je to nenáročný a jednoduchý, zároveň ale mocný a konfigurovatelný nástroj.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">na Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">na Github</a>',
 		'license' => 'Licence',
 		'license' => 'Licence',
 		'project_website' => 'Stránka projektu',
 		'project_website' => 'Stránka projektu',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Zobrazit oblíbené',
 		'starred' => 'Zobrazit oblíbené',
 		'stats' => 'Statistika',
 		'stats' => 'Statistika',
 		'subscription' => 'Správa subskripcí',
 		'subscription' => 'Správa subskripcí',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Zobrazovat nepřečtené',
 		'unread' => 'Zobrazovat nepřečtené',
 	),
 	),
 	'share' => 'Sdílet',
 	'share' => 'Sdílet',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Související tagy',
+		'related' => 'Související tagy',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/de/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Fußzeile',
 			'bottom_line' => 'Fußzeile',
 			'entry' => 'Artikel-Symbole',
 			'entry' => 'Artikel-Symbole',
 			'publication_date' => 'Datum der Veröffentlichung',
 			'publication_date' => 'Datum der Veröffentlichung',
-			'related_tags' => 'Verwandte Tags',
+			'related_tags' => 'Verwandte Tags',	//TODO
 			'sharing' => 'Teilen',
 			'sharing' => 'Teilen',
 			'top_line' => 'Kopfzeile',
 			'top_line' => 'Kopfzeile',
 		),
 		),

+ 3 - 2
app/i18n/de/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Fehlerberichte',
 		'bugs_reports' => 'Fehlerberichte',
 		'credits' => 'Credits',
 		'credits' => 'Credits',
 		'credits_content' => 'Einige Designelemente stammen von <a href="http://twitter.github.io/bootstrap/">Bootstrap</a>, obwohl FreshRSS dieses Framework nicht nutzt. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Icons</a> stammen vom <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> Font wurde von <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a> erstellt. FreshRSS basiert auf <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, einem PHP-Framework.',
 		'credits_content' => 'Einige Designelemente stammen von <a href="http://twitter.github.io/bootstrap/">Bootstrap</a>, obwohl FreshRSS dieses Framework nicht nutzt. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Icons</a> stammen vom <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> Font wurde von <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a> erstellt. FreshRSS basiert auf <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, einem PHP-Framework.',
-		'freshrss_description' => 'FreshRSS ist ein RSS-Feedsaggregator zum selbst hosten wie zum Beispiel <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> oder <a href="http://projet.idleman.fr/leed/">Leed</a>. Er ist leicht und einfach zu handhaben und gleichzeitig ein leistungsstarkes und konfigurierbares Werkzeug.',
+		'freshrss_description' => 'FreshRSS ist ein RSS-Feedsaggregator zum selbst hosten wie zum Beispiel <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> oder <a href="http://leed.idleman.fr/">Leed</a>. Er ist leicht und einfach zu handhaben und gleichzeitig ein leistungsstarkes und konfigurierbares Werkzeug.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
 		'license' => 'Lizenz',
 		'license' => 'Lizenz',
 		'project_website' => 'Projekt-Webseite',
 		'project_website' => 'Projekt-Webseite',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Nur Favoriten zeigen',
 		'starred' => 'Nur Favoriten zeigen',
 		'stats' => 'Statistiken',
 		'stats' => 'Statistiken',
 		'subscription' => 'Abonnementverwaltung',
 		'subscription' => 'Abonnementverwaltung',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Nur ungelesene zeigen',
 		'unread' => 'Nur ungelesene zeigen',
 	),
 	),
 	'share' => 'Teilen',
 	'share' => 'Teilen',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Verwandte Tags',
+		'related' => 'Verwandte Tags',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/en/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Bottom line',
 			'bottom_line' => 'Bottom line',
 			'entry' => 'Article icons',
 			'entry' => 'Article icons',
 			'publication_date' => 'Date of publication',
 			'publication_date' => 'Date of publication',
-			'related_tags' => 'Related tags',
+			'related_tags' => 'Article tags',
 			'sharing' => 'Sharing',
 			'sharing' => 'Sharing',
 			'top_line' => 'Top line',
 			'top_line' => 'Top line',
 		),
 		),

+ 3 - 2
app/i18n/en/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Bugs reports',
 		'bugs_reports' => 'Bugs reports',
 		'credits' => 'Credits',
 		'credits' => 'Credits',
 		'credits_content' => 'Some design elements come from <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> although FreshRSS doesn’t use this framework. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Icons</a> come from <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> font police has been created by <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS is based on <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, a PHP framework.',
 		'credits_content' => 'Some design elements come from <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> although FreshRSS doesn’t use this framework. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Icons</a> come from <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> font police has been created by <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS is based on <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, a PHP framework.',
-		'freshrss_description' => 'FreshRSS is a RSS feeds aggregator to self-host like <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> or <a href="http://projet.idleman.fr/leed/">Leed</a>. It is light and easy to take in hand while being powerful and configurable tool.',
+		'freshrss_description' => 'FreshRSS is a RSS feeds aggregator to self-host like <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> or <a href="http://leed.idleman.fr/">Leed</a>. It is light and easy to take in hand while being powerful and configurable tool.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
 		'license' => 'License',
 		'license' => 'License',
 		'project_website' => 'Project website',
 		'project_website' => 'Project website',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Show favourites',
 		'starred' => 'Show favourites',
 		'stats' => 'Statistics',
 		'stats' => 'Statistics',
 		'subscription' => 'Subscriptions management',
 		'subscription' => 'Subscriptions management',
+		'tags' => 'My labels',
 		'unread' => 'Show unread',
 		'unread' => 'Show unread',
 	),
 	),
 	'share' => 'Share',
 	'share' => 'Share',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Related tags',
+		'related' => 'Article tags',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/es/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Línea inferior',
 			'bottom_line' => 'Línea inferior',
 			'entry' => 'Iconos de artículos',
 			'entry' => 'Iconos de artículos',
 			'publication_date' => 'Fecha de publicación',
 			'publication_date' => 'Fecha de publicación',
-			'related_tags' => 'Etiquetas relacionadas',
+			'related_tags' => 'Etiquetas relacionadas',	//TODO
 			'sharing' => 'Compartir',
 			'sharing' => 'Compartir',
 			'top_line' => 'Línea superior',
 			'top_line' => 'Línea superior',
 		),
 		),

+ 3 - 2
app/i18n/es/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Informe de fallos',
 		'bugs_reports' => 'Informe de fallos',
 		'credits' => 'Créditos',
 		'credits' => 'Créditos',
 		'credits_content' => 'Aunque FreshRSS no usa ese entorno, algunos elementos del diseño están obtenidos de <a href="http://twitter.github.io/bootstrap/">Bootstrap</a>. Los <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Iconos</a> han sido obtenidos del <a href="https://www.gnome.org/">proyecto GNOME</a>. La fuente <em>Open Sans</em> es una creación de <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS usa el entorno PHP <a href="https://github.com/marienfressinaud/MINZ">Minz</a>.',
 		'credits_content' => 'Aunque FreshRSS no usa ese entorno, algunos elementos del diseño están obtenidos de <a href="http://twitter.github.io/bootstrap/">Bootstrap</a>. Los <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Iconos</a> han sido obtenidos del <a href="https://www.gnome.org/">proyecto GNOME</a>. La fuente <em>Open Sans</em> es una creación de <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS usa el entorno PHP <a href="https://github.com/marienfressinaud/MINZ">Minz</a>.',
-		'freshrss_description' => 'FreshRSS es un agregador de fuentes RSS de alojamiento privado al estilo de <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> o <a href="http://projet.idleman.fr/leed/">Leed</a>. Es una herramienta potente, pero ligera y fácil de usar y configurar.',
+		'freshrss_description' => 'FreshRSS es un agregador de fuentes RSS de alojamiento privado al estilo de <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> o <a href="http://leed.idleman.fr/">Leed</a>. Es una herramienta potente, pero ligera y fácil de usar y configurar.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">en Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">en Github</a>',
 		'license' => 'Licencia',
 		'license' => 'Licencia',
 		'project_website' => 'Web del proyecto',
 		'project_website' => 'Web del proyecto',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Mostrar solo los favoritos',
 		'starred' => 'Mostrar solo los favoritos',
 		'stats' => 'Estadísticas',
 		'stats' => 'Estadísticas',
 		'subscription' => 'Administración de suscripciones',
 		'subscription' => 'Administración de suscripciones',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Mostar solo no leídos',
 		'unread' => 'Mostar solo no leídos',
 	),
 	),
 	'share' => 'Compartir',
 	'share' => 'Compartir',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Etiquetas relacionadas',
+		'related' => 'Etiquetas relacionadas',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/fr/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Ligne du bas',
 			'bottom_line' => 'Ligne du bas',
 			'entry' => 'Icônes d’article',
 			'entry' => 'Icônes d’article',
 			'publication_date' => 'Date de publication',
 			'publication_date' => 'Date de publication',
-			'related_tags' => 'Tags associés',
+			'related_tags' => 'Tags de l’article',
 			'sharing' => 'Partage',
 			'sharing' => 'Partage',
 			'top_line' => 'Ligne du haut',
 			'top_line' => 'Ligne du haut',
 		),
 		),

+ 3 - 2
app/i18n/fr/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Rapports de bugs',
 		'bugs_reports' => 'Rapports de bugs',
 		'credits' => 'Crédits',
 		'credits' => 'Crédits',
 		'credits_content' => 'Des éléments de design sont issus du <a href="http://twitter.github.io/bootstrap/">projet Bootstrap</a> bien que FreshRSS n’utilise pas ce framework. Les <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">icônes</a> sont issues du <a href="https://www.gnome.org/">projet GNOME</a>. La police <em>Open Sans</em> utilisée a été créée par <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS repose sur <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, un framework PHP.',
 		'credits_content' => 'Des éléments de design sont issus du <a href="http://twitter.github.io/bootstrap/">projet Bootstrap</a> bien que FreshRSS n’utilise pas ce framework. Les <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">icônes</a> sont issues du <a href="https://www.gnome.org/">projet GNOME</a>. La police <em>Open Sans</em> utilisée a été créée par <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS repose sur <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, un framework PHP.',
-		'freshrss_description' => 'FreshRSS est un agrégateur de flux RSS à auto-héberger à l’image de <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> ou <a href="http://projet.idleman.fr/leed/">Leed</a>. Il se veut léger et facile à prendre en main tout en étant un outil puissant et paramétrable.',
+		'freshrss_description' => 'FreshRSS est un agrégateur de flux RSS à auto-héberger à l’image de <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> ou <a href="http://leed.idleman.fr/">Leed</a>. Il se veut léger et facile à prendre en main tout en étant un outil puissant et paramétrable.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">sur Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">sur Github</a>',
 		'license' => 'Licence',
 		'license' => 'Licence',
 		'project_website' => 'Site du projet',
 		'project_website' => 'Site du projet',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Afficher les favoris',
 		'starred' => 'Afficher les favoris',
 		'stats' => 'Statistiques',
 		'stats' => 'Statistiques',
 		'subscription' => 'Gestion des abonnements',
 		'subscription' => 'Gestion des abonnements',
+		'tags' => 'Mes étiquettes',
 		'unread' => 'Afficher les non-lus',
 		'unread' => 'Afficher les non-lus',
 	),
 	),
 	'share' => 'Partager',
 	'share' => 'Partager',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Tags associés',
+		'related' => 'Tags de l’article',
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/he/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'שורה תחתונה',
 			'bottom_line' => 'שורה תחתונה',
 			'entry' => 'סמלילי מאמרים',
 			'entry' => 'סמלילי מאמרים',
 			'publication_date' => 'תאריך הפרסום',
 			'publication_date' => 'תאריך הפרסום',
-			'related_tags' => 'תגיות קשורות',
+			'related_tags' => 'תגיות קשורות',	//TODO
 			'sharing' => 'שיתוף',
 			'sharing' => 'שיתוף',
 			'top_line' => 'שורה עליונה',
 			'top_line' => 'שורה עליונה',
 		),
 		),

+ 3 - 2
app/i18n/he/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'דיווח באגים',
 		'bugs_reports' => 'דיווח באגים',
 		'credits' => 'קרדיטים',
 		'credits' => 'קרדיטים',
 		'credits_content' => 'מאפייני עיצוב מסויימים הגיעו מ <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> אף על פי ש FreshRSS אינו משתמש בתשתית הזו. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">סמלילים</a> הגיעו מ <a href="https://www.gnome.org/"> פרוייקט GNOME </a>. <em>Open Sans</em> הגופן police נוצר על ידי <a href="https://www.google.com/webfonts/specimen/Open+Sans">Steve Matteson</a>. Favicons נאספים בעזרת <a href="https://getfavicon.appspot.com/">getFavicon API</a>. FreshRSS מבוסס על <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, תשתית PHP.',
 		'credits_content' => 'מאפייני עיצוב מסויימים הגיעו מ <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> אף על פי ש FreshRSS אינו משתמש בתשתית הזו. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">סמלילים</a> הגיעו מ <a href="https://www.gnome.org/"> פרוייקט GNOME </a>. <em>Open Sans</em> הגופן police נוצר על ידי <a href="https://www.google.com/webfonts/specimen/Open+Sans">Steve Matteson</a>. Favicons נאספים בעזרת <a href="https://getfavicon.appspot.com/">getFavicon API</a>. FreshRSS מבוסס על <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, תשתית PHP.',
-		'freshrss_description' => 'FreshRSS הוא קורא RSS לאחסון עצמי בדומה ל <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> או <a href="http://projet.idleman.fr/leed/">Leed</a>. אינו צורך משאבים רבים, וקל לתפעול אך בו בזמן חזק וניתן להתאמה.',
+		'freshrss_description' => 'FreshRSS הוא קורא RSS לאחסון עצמי בדומה ל <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> או <a href="http://leed.idleman.fr/">Leed</a>. אינו צורך משאבים רבים, וקל לתפעול אך בו בזמן חזק וניתן להתאמה.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">בגיטהאב</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">בגיטהאב</a>',
 		'license' => 'רישיון',
 		'license' => 'רישיון',
 		'project_website' => 'אתר',
 		'project_website' => 'אתר',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'הצגת מועדפים בלבד',
 		'starred' => 'הצגת מועדפים בלבד',
 		'stats' => 'סטטיסטיקות',
 		'stats' => 'סטטיסטיקות',
 		'subscription' => 'ניהול הרשמות',
 		'subscription' => 'ניהול הרשמות',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'הצגת מאמרים שלא נקראו בלבד',
 		'unread' => 'הצגת מאמרים שלא נקראו בלבד',
 	),
 	),
 	'share' => 'שיתוף',
 	'share' => 'שיתוף',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'תגיות קשורות',
+		'related' => 'תגיות קשורות',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/it/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Barra in fondo',
 			'bottom_line' => 'Barra in fondo',
 			'entry' => 'Icone degli articoli',
 			'entry' => 'Icone degli articoli',
 			'publication_date' => 'Data di pubblicazione',
 			'publication_date' => 'Data di pubblicazione',
-			'related_tags' => 'Tags correlati',
+			'related_tags' => 'Tags correlati',	//TODO
 			'sharing' => 'Condivisione',
 			'sharing' => 'Condivisione',
 			'top_line' => 'Barra in alto',
 			'top_line' => 'Barra in alto',
 		),
 		),

+ 3 - 2
app/i18n/it/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Bugs',
 		'bugs_reports' => 'Bugs',
 		'credits' => 'Crediti',
 		'credits' => 'Crediti',
 		'credits_content' => 'Alcuni elementi di design provengono da <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> sebbene FreshRSS non usi questo framework. Le <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">icone</a> provengono dal progetto <a href="https://www.gnome.org/">GNOME</a>. Il carattere <em>Open Sans</em> è stato creato da <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS è basato su <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, un framework PHP.',
 		'credits_content' => 'Alcuni elementi di design provengono da <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> sebbene FreshRSS non usi questo framework. Le <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">icone</a> provengono dal progetto <a href="https://www.gnome.org/">GNOME</a>. Il carattere <em>Open Sans</em> è stato creato da <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS è basato su <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, un framework PHP.',
-		'freshrss_description' => 'FreshRSS è un aggregatore di feeds RSS da installare sul proprio host come <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> o <a href="http://projet.idleman.fr/leed/">Leed</a>. Leggero e facile da mantenere pur essendo molto configurabile e potente.',
+		'freshrss_description' => 'FreshRSS è un aggregatore di feeds RSS da installare sul proprio host come <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> o <a href="http://leed.idleman.fr/">Leed</a>. Leggero e facile da mantenere pur essendo molto configurabile e potente.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">su Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">su Github</a>',
 		'license' => 'Licenza',
 		'license' => 'Licenza',
 		'project_website' => 'Sito del progetto',
 		'project_website' => 'Sito del progetto',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Mostra solo preferiti',
 		'starred' => 'Mostra solo preferiti',
 		'stats' => 'Statistiche',
 		'stats' => 'Statistiche',
 		'subscription' => 'Gestione sottoscrizioni',
 		'subscription' => 'Gestione sottoscrizioni',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Mostra solo non letti',
 		'unread' => 'Mostra solo non letti',
 	),
 	),
 	'share' => 'Condividi',
 	'share' => 'Condividi',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Tags correlati',
+		'related' => 'Tags correlati',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/kr/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => '하단',
 			'bottom_line' => '하단',
 			'entry' => '문서 아이콘',
 			'entry' => '문서 아이콘',
 			'publication_date' => '발행일',
 			'publication_date' => '발행일',
-			'related_tags' => '관련 태그',
+			'related_tags' => '관련 태그',	//TODO
 			'sharing' => '공유',
 			'sharing' => '공유',
 			'top_line' => '상단',
 			'top_line' => '상단',
 		),
 		),

+ 3 - 2
app/i18n/kr/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => '버그 제보하기',
 		'bugs_reports' => '버그 제보하기',
 		'credits' => '크레딧',
 		'credits' => '크레딧',
 		'credits_content' => 'FreshRSS는 <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> 프레임워크를 사용하진 않지만, 일부 디자인 요소를 가져왔습니다. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">아이콘들</a>은 <a href="https://www.gnome.org/">GNOME 프로젝트</a>에서 가져왔습니다. <em>Open Sans</em> 글꼴은 <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>가 제작하였습니다. FreshRSS는 PHP 프레임워크인 <a href="https://github.com/marienfressinaud/MINZ">Minz</a>에 기반하고 있습니다.',
 		'credits_content' => 'FreshRSS는 <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> 프레임워크를 사용하진 않지만, 일부 디자인 요소를 가져왔습니다. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">아이콘들</a>은 <a href="https://www.gnome.org/">GNOME 프로젝트</a>에서 가져왔습니다. <em>Open Sans</em> 글꼴은 <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>가 제작하였습니다. FreshRSS는 PHP 프레임워크인 <a href="https://github.com/marienfressinaud/MINZ">Minz</a>에 기반하고 있습니다.',
-		'freshrss_description' => 'FreshRSS는 <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> 또는 <a href="http://projet.idleman.fr/leed/">Leed</a>와 같은 셀프 호스팅 기반의 RSS 피드 수집기입니다. FreshRSS는 강력하고 다양한 설정을 할 수 있으면서 도 가볍고 사용하기 쉽습니다.',
+		'freshrss_description' => 'FreshRSS는 <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> 또는 <a href="http://leed.idleman.fr/">Leed</a>와 같은 셀프 호스팅 기반의 RSS 피드 수집기입니다. FreshRSS는 강력하고 다양한 설정을 할 수 있으면서 도 가볍고 사용하기 쉽습니다.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">Github 저장소에 제보</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">Github 저장소에 제보</a>',
 		'license' => '라이센스',
 		'license' => '라이센스',
 		'project_website' => '프로젝트 웹사이트',
 		'project_website' => '프로젝트 웹사이트',
@@ -53,10 +53,11 @@ return array(
 		'starred' => '즐겨찾기만 표시',
 		'starred' => '즐겨찾기만 표시',
 		'stats' => '통계',
 		'stats' => '통계',
 		'subscription' => '구독 관리',
 		'subscription' => '구독 관리',
+		'tags' => 'My labels',	//TODO
 		'unread' => '읽지 않은 글만 표시',
 		'unread' => '읽지 않은 글만 표시',
 	),
 	),
 	'share' => '공유',
 	'share' => '공유',
 	'tag' => array(
 	'tag' => array(
-		'related' => '관련 태그',
+		'related' => '관련 태그',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/nl/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Onderaan',
 			'bottom_line' => 'Onderaan',
 			'entry' => 'Artikel pictogrammen',
 			'entry' => 'Artikel pictogrammen',
 			'publication_date' => 'Publicatie datum',
 			'publication_date' => 'Publicatie datum',
-			'related_tags' => 'Gerelateerde labels',
+			'related_tags' => 'Gerelateerde labels',	//TODO
 			'sharing' => 'Delen',
 			'sharing' => 'Delen',
 			'top_line' => 'Bovenaan',
 			'top_line' => 'Bovenaan',
 		),
 		),

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Rapporteer fouten',
 		'bugs_reports' => 'Rapporteer fouten',
 		'credits' => 'Waarderingen',
 		'credits' => 'Waarderingen',
 		'credits_content' => 'Sommige ontwerp elementen komen van <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> alhoewel FreshRSS dit raamwerk niet gebruikt. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Pictogrammen</a> komen van het <a href="https://www.gnome.org/">GNOME project</a>. <em>De Open Sans</em> font police is gemaakt door <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS is gebaseerd op <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, een PHP raamwerk. Nederlandse vertaling door Wanabo, <a href="http://www.nieuwskop.be" title="NieuwsKop">NieuwsKop.be</a>. Link naar de Nederlandse vertaling, <a href="https://github.com/Wanabo/FreshRSS-Dutch-translation/tree/master">FreshRSS-Dutch-translation</a>.',
 		'credits_content' => 'Sommige ontwerp elementen komen van <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> alhoewel FreshRSS dit raamwerk niet gebruikt. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Pictogrammen</a> komen van het <a href="https://www.gnome.org/">GNOME project</a>. <em>De Open Sans</em> font police is gemaakt door <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS is gebaseerd op <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, een PHP raamwerk. Nederlandse vertaling door Wanabo, <a href="http://www.nieuwskop.be" title="NieuwsKop">NieuwsKop.be</a>. Link naar de Nederlandse vertaling, <a href="https://github.com/Wanabo/FreshRSS-Dutch-translation/tree/master">FreshRSS-Dutch-translation</a>.',
-		'freshrss_description' => 'FreshRSS is een RSS feed aggregator om zelf te hosten zoals <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> of <a href="http://projet.idleman.fr/leed/">Leed</a>. Het gebruikt weinig systeembronnen en is makkelijk te administreren terwijl het een krachtig en makkelijk te configureren programma is.',
+		'freshrss_description' => 'FreshRSS is een RSS feed aggregator om zelf te hosten zoals <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> of <a href="http://leed.idleman.fr/">Leed</a>. Het gebruikt weinig systeembronnen en is makkelijk te administreren terwijl het een krachtig en makkelijk te configureren programma is.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">op Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">op Github</a>',
 		'license' => 'License',
 		'license' => 'License',
 		'project_website' => 'Project website',
 		'project_website' => 'Project website',
@@ -57,6 +57,6 @@ return array(
 	),
 	),
 	'share' => 'Delen',
 	'share' => 'Delen',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Verwante labels',
+		'related' => 'Verwante labels',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/pt-br/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Linha inferior',
 			'bottom_line' => 'Linha inferior',
 			'entry' => 'Ícones de artigos',
 			'entry' => 'Ícones de artigos',
 			'publication_date' => 'Data da publicação',
 			'publication_date' => 'Data da publicação',
-			'related_tags' => 'Tags relacionadas',
+			'related_tags' => 'Tags relacionadas',	//TODO
 			'sharing' => 'Compartilhar',
 			'sharing' => 'Compartilhar',
 			'top_line' => 'Linha superior',
 			'top_line' => 'Linha superior',
 		),
 		),

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Reportar Bugs',
 		'bugs_reports' => 'Reportar Bugs',
 		'credits' => 'Créditos',
 		'credits' => 'Créditos',
 		'credits_content' => 'Alguns elementos de design vieram do <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> Embora FreshRRS não utiliza este framework. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Ícones</a> vieram do <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> font police foi criada por <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS é baseado no <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, um framework PHP.',
 		'credits_content' => 'Alguns elementos de design vieram do <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> Embora FreshRRS não utiliza este framework. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Ícones</a> vieram do <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> font police foi criada por <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS é baseado no <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, um framework PHP.',
-		'freshrss_description' => 'FreshRSS é um RSS feeds aggregator para um host próprio como o <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> ou <a href="http://projet.idleman.fr/leed/">Leed</a>. É leve e fácil de utilizar enquanto é uma ferramenta poderosa e configurável. ',
+		'freshrss_description' => 'FreshRSS é um RSS feeds aggregator para um host próprio como o <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> ou <a href="http://leed.idleman.fr/">Leed</a>. É leve e fácil de utilizar enquanto é uma ferramenta poderosa e configurável. ',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">no Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">no Github</a>',
 		'license' => 'licença',
 		'license' => 'licença',
 		'project_website' => 'Site do projeto',
 		'project_website' => 'Site do projeto',
@@ -57,6 +57,6 @@ return array(
 	),
 	),
 	'share' => 'Compartilhar',
 	'share' => 'Compartilhar',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Tags relacionadas',
+		'related' => 'Tags relacionadas',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/ru/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Bottom line',
 			'bottom_line' => 'Bottom line',
 			'entry' => 'Article icons',
 			'entry' => 'Article icons',
 			'publication_date' => 'Date of publication',
 			'publication_date' => 'Date of publication',
-			'related_tags' => 'Related tags',
+			'related_tags' => 'Related tags',	//TODO
 			'sharing' => 'Sharing',
 			'sharing' => 'Sharing',
 			'top_line' => 'Top line',
 			'top_line' => 'Top line',
 		),
 		),

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Bugs reports',
 		'bugs_reports' => 'Bugs reports',
 		'credits' => 'Credits',
 		'credits' => 'Credits',
 		'credits_content' => 'Some design elements come from <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> although FreshRSS doesn’t use this framework. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Icons</a> come from <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> font police has been created by <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS is based on <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, a PHP framework.',
 		'credits_content' => 'Some design elements come from <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> although FreshRSS doesn’t use this framework. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Icons</a> come from <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> font police has been created by <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS is based on <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, a PHP framework.',
-		'freshrss_description' => 'FreshRSS is a RSS feeds aggregator to self-host like <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> or <a href="http://projet.idleman.fr/leed/">Leed</a>. It is light and easy to take in hand while being powerful and configurable tool.',
+		'freshrss_description' => 'FreshRSS is a RSS feeds aggregator to self-host like <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> or <a href="http://leed.idleman.fr/">Leed</a>. It is light and easy to take in hand while being powerful and configurable tool.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
 		'license' => 'License',
 		'license' => 'License',
 		'project_website' => 'Project website',
 		'project_website' => 'Project website',
@@ -57,6 +57,6 @@ return array(
 	),
 	),
 	'share' => 'Share',
 	'share' => 'Share',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'Related tags',
+		'related' => 'Article tags',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/tr/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Alt çizgi',
 			'bottom_line' => 'Alt çizgi',
 			'entry' => 'Makale ikonları',
 			'entry' => 'Makale ikonları',
 			'publication_date' => 'Yayınlama Tarihi',
 			'publication_date' => 'Yayınlama Tarihi',
-			'related_tags' => 'İlgili etiketler',
+			'related_tags' => 'İlgili etiketler',	//TODO
 			'sharing' => 'Paylaşım',
 			'sharing' => 'Paylaşım',
 			'top_line' => 'Üst çizgi',
 			'top_line' => 'Üst çizgi',
 		),
 		),

+ 3 - 2
app/i18n/tr/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Hata raporu',
 		'bugs_reports' => 'Hata raporu',
 		'credits' => 'Tanıtım',
 		'credits' => 'Tanıtım',
 		'credits_content' => 'Bu frameworkü kullanmamasına rağmen FreshRSS bazı tasarım ögelerini <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> dan almıştır. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">İkonlar</a> <a href="https://www.gnome.org/">GNOME projesinden</a> alınmıştır. <em>Open Sans</em> yazı tipi <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a> tarafından oluşturulmuştur. FreshRSS bir PHP framework olan <a href="https://github.com/marienfressinaud/MINZ">Minz</a> i temel alır.',
 		'credits_content' => 'Bu frameworkü kullanmamasına rağmen FreshRSS bazı tasarım ögelerini <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> dan almıştır. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">İkonlar</a> <a href="https://www.gnome.org/">GNOME projesinden</a> alınmıştır. <em>Open Sans</em> yazı tipi <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a> tarafından oluşturulmuştur. FreshRSS bir PHP framework olan <a href="https://github.com/marienfressinaud/MINZ">Minz</a> i temel alır.',
-		'freshrss_description' => 'FreshRSS <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> veya <a href="http://projet.idleman.fr/leed/">Leed</a> gibi kendi hostunuzda çalışan bir RSS akış toplayıcısıdır. Güçlü ve yapılandırılabilir araçlarıyla basit ve kullanımı kolay bir uygulamadır.',
+		'freshrss_description' => 'FreshRSS <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> veya <a href="http://leed.idleman.fr/">Leed</a> gibi kendi hostunuzda çalışan bir RSS akış toplayıcısıdır. Güçlü ve yapılandırılabilir araçlarıyla basit ve kullanımı kolay bir uygulamadır.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">Github sayfası</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">Github sayfası</a>',
 		'license' => 'Lisans',
 		'license' => 'Lisans',
 		'project_website' => 'Proje sayfası',
 		'project_website' => 'Proje sayfası',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Favorileri göster',
 		'starred' => 'Favorileri göster',
 		'stats' => 'İstatistikler',
 		'stats' => 'İstatistikler',
 		'subscription' => 'Abonelik yönetimi',
 		'subscription' => 'Abonelik yönetimi',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Okunmamışları göster',
 		'unread' => 'Okunmamışları göster',
 	),
 	),
 	'share' => 'Share',
 	'share' => 'Share',
 	'tag' => array(
 	'tag' => array(
-		'related' => 'İlgili etiketler',
+		'related' => 'İlgili etiketler',	//TODO
 	),
 	),
 );
 );

+ 1 - 1
app/i18n/zh-cn/conf.php

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => '底栏',
 			'bottom_line' => '底栏',
 			'entry' => '文章图标',
 			'entry' => '文章图标',
 			'publication_date' => '更新日期',
 			'publication_date' => '更新日期',
-			'related_tags' => '相关标签',
+			'related_tags' => '相关标签',	//TODO
 			'sharing' => '分享',
 			'sharing' => '分享',
 			'top_line' => '顶栏',
 			'top_line' => '顶栏',
 		),
 		),

+ 3 - 2
app/i18n/zh-cn/index.php

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Bug 报告',
 		'bugs_reports' => 'Bug 报告',
 		'credits' => '致谢',
 		'credits' => '致谢',
 		'credits_content' => '某些设计元素来自于 <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> ,尽管 FreshRSS 并没有使用此框架。<a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">图标</a> 来自于 <a href="https://www.gnome.org/">GNOME 项目</a>。<em>Open Sans</em> 字体出自 <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a> 之手。FreshRSS 基于 PHP 框架 <a href="https://github.com/marienfressinaud/MINZ">Minz</a>。',
 		'credits_content' => '某些设计元素来自于 <a href="http://twitter.github.io/bootstrap/">Bootstrap</a> ,尽管 FreshRSS 并没有使用此框架。<a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">图标</a> 来自于 <a href="https://www.gnome.org/">GNOME 项目</a>。<em>Open Sans</em> 字体出自 <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a> 之手。FreshRSS 基于 PHP 框架 <a href="https://github.com/marienfressinaud/MINZ">Minz</a>。',
-		'freshrss_description' => 'FreshRSS 是一个自托管的 RSS 聚合服务,类似于 <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> 或 <a href="http://projet.idleman.fr/leed/">Leed</a>。 它不仅轻快又易用,而且强大又易于配置。',
+		'freshrss_description' => 'FreshRSS 是一个自托管的 RSS 聚合服务,类似于 <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> 或 <a href="http://leed.idleman.fr/">Leed</a>。 它不仅轻快又易用,而且强大又易于配置。',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">Github Issues</a>',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">Github Issues</a>',
 		'license' => '授权',
 		'license' => '授权',
 		'project_website' => '项目网站',
 		'project_website' => '项目网站',
@@ -53,10 +53,11 @@ return array(
 		'starred' => '显示收藏',
 		'starred' => '显示收藏',
 		'stats' => '统计',
 		'stats' => '统计',
 		'subscription' => '订阅管理',
 		'subscription' => '订阅管理',
+		'tags' => 'My labels',	//TODO
 		'unread' => '显示未读',
 		'unread' => '显示未读',
 	),
 	),
 	'share' => '分享',
 	'share' => '分享',
 	'tag' => array(
 	'tag' => array(
-		'related' => '相关标签',
+		'related' => '相关标签',	//TODO
 	),
 	),
 );
 );

+ 3 - 3
app/install.php

@@ -343,13 +343,13 @@ function checkDbUser(&$dbOptions) {
 	try {
 	try {
 		$c = new PDO($str, $dbOptions['user'], $dbOptions['password'], $driver_options);
 		$c = new PDO($str, $dbOptions['user'], $dbOptions['password'], $driver_options);
 		if (defined('SQL_CREATE_TABLES')) {
 		if (defined('SQL_CREATE_TABLES')) {
-			$sql = sprintf(SQL_CREATE_TABLES . SQL_CREATE_TABLE_ENTRYTMP . SQL_INSERT_FEEDS,
+			$sql = sprintf(SQL_CREATE_TABLES . SQL_CREATE_TABLE_ENTRYTMP . SQL_CREATE_TABLE_TAGS . SQL_INSERT_FEEDS,
 				$dbOptions['prefix_user'], _t('gen.short.default_category'));
 				$dbOptions['prefix_user'], _t('gen.short.default_category'));
 			$stm = $c->prepare($sql);
 			$stm = $c->prepare($sql);
 			$ok = $stm && $stm->execute();
 			$ok = $stm && $stm->execute();
 		} else {
 		} else {
-			global $SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP, $SQL_INSERT_FEEDS;
-			$instructions = array_merge($SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP, $SQL_INSERT_FEEDS);
+			global $SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP, $SQL_CREATE_TABLE_TAGS, $SQL_INSERT_FEEDS;
+			$instructions = array_merge($SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP, $SQL_CREATE_TABLE_TAGS, $SQL_INSERT_FEEDS);
 			$ok = !empty($instructions);
 			$ok = !empty($instructions);
 			foreach ($instructions as $instruction) {
 			foreach ($instructions as $instruction) {
 				$sql = sprintf($instruction, $dbOptions['prefix_user'], _t('gen.short.default_category'));
 				$sql = sprintf($instruction, $dbOptions['prefix_user'], _t('gen.short.default_category'));

+ 35 - 0
app/layout/aside_feed.phtml

@@ -34,6 +34,30 @@
 			</div>
 			</div>
 		</li>
 		</li>
 
 
+		<?php
+			$t_active = FreshRSS_Context::isCurrentGet('T');
+		?>
+		<li class="tree-folder category tags<?php echo $t_active ? ' active' : ''; ?>">
+			<div class="tree-folder-title">
+				<a class="dropdown-toggle" href="#"><?php echo _i($t_active ? 'up' : 'down'); ?></a>
+				<a class="title" data-unread="<?php echo format_number($this->nbUnreadTags); ?>" href="<?php echo _url('index', 'index', 'get', 'T'); ?>"><?php echo _t('index.menu.tags'); ?></a>
+			</div>
+			<ul class="tree-folder-items<?php echo $t_active ? ' active' : ''; ?>">
+				<?php
+					foreach ($this->tags as $tag):
+				?>
+				<li id="t_<?php echo $tag->id(); ?>" class="item feed<?php echo FreshRSS_Context::isCurrentGet('t_' . $tag->id()) ? ' active' : ''; ?>" data-unread="<?php echo $tag->nbUnread(); ?>">
+					<div class="dropdown no-mobile">
+						<div class="dropdown-target"></div>
+						<a class="dropdown-toggle"><?php echo _i('configure'); ?></a>
+						<?php /* tag_config_template */ ?>
+					</div>
+					<?php echo FreshRSS_Themes::alt('label'); ?> <a class="item-title" data-unread="<?php echo format_number($tag->nbUnread()); ?>" href="<?php echo _url('index', 'index', 'get', 't_' . $tag->id()); ?>"><?php echo $tag->name(); ?></a>
+				</li>
+				<?php endforeach; ?>
+			</ul>
+		</li>
+
 		<?php
 		<?php
 			foreach ($this->categories as $cat) {
 			foreach ($this->categories as $cat) {
 				$feeds = $cat->feeds();
 				$feeds = $cat->feeds();
@@ -72,6 +96,17 @@
 	</form>
 	</form>
 </div>
 </div>
 
 
+<script id="tag_config_template" type="text/html">
+	<ul class="dropdown-menu">
+		<li class="dropdown-close"><a href="#close">❌</a></li>
+		<li class="item">
+			<button class="as-link confirm" disabled="disabled"
+				form="mark-read-aside" formaction="<?php echo _url('tag', 'delete', 'id_tag', '------'); ?>"
+				type="submit"><?php echo _t('gen.action.remove'); ?></button>
+		</li>
+	</ul>
+</script>
+
 <script id="feed_config_template" type="text/html">
 <script id="feed_config_template" type="text/html">
 	<ul class="dropdown-menu">
 	<ul class="dropdown-menu">
 		<li class="dropdown-close"><a href="#close">❌</a></li>
 		<li class="dropdown-close"><a href="#close">❌</a></li>

+ 2 - 2
app/views/configure/display.phtml

@@ -79,8 +79,8 @@
 						<th> </th>
 						<th> </th>
 						<th title="<?php echo _t('gen.action.mark_read'); ?>"><?php echo _i('read'); ?></th>
 						<th title="<?php echo _t('gen.action.mark_read'); ?>"><?php echo _i('read'); ?></th>
 						<th title="<?php echo _t('gen.action.mark_favorite'); ?>"><?php echo _i('bookmark'); ?></th>
 						<th title="<?php echo _t('gen.action.mark_favorite'); ?>"><?php echo _i('bookmark'); ?></th>
-						<th><?php echo _t('conf.display.icon.sharing'); ?></th>
 						<th><?php echo _t('conf.display.icon.related_tags'); ?></th>
 						<th><?php echo _t('conf.display.icon.related_tags'); ?></th>
+						<th><?php echo _t('conf.display.icon.sharing'); ?></th>
 						<th><?php echo _t('conf.display.icon.publication_date'); ?></th>
 						<th><?php echo _t('conf.display.icon.publication_date'); ?></th>
 						<th><?php echo _i('link'); ?></th>
 						<th><?php echo _i('link'); ?></th>
 					</tr>
 					</tr>
@@ -98,8 +98,8 @@
 						<th><?php echo _t('conf.display.icon.bottom_line'); ?></th>
 						<th><?php echo _t('conf.display.icon.bottom_line'); ?></th>
 						<td><input type="checkbox" name="bottomline_read" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_read ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_read; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_read" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_read ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_read; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_favorite" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_favorite ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_favorite; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_favorite" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_favorite ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_favorite; ?>"/></td>
-						<td><input type="checkbox" name="bottomline_sharing" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_sharing ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_sharing; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_tags" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_tags ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_tags; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_tags" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_tags ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_tags; ?>"/></td>
+						<td><input type="checkbox" name="bottomline_sharing" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_sharing ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_sharing; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_date" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_date ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_date; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_date" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_date ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_date; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_link" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_link ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_link; ?>"/></td>
 						<td><input type="checkbox" name="bottomline_link" value="1"<?php echo FreshRSS_Context::$user_conf->bottomline_link ? ' checked="checked"' : ''; ?> data-leave-validation="<?php echo FreshRSS_Context::$user_conf->bottomline_link; ?>"/></td>
 					</tr>
 					</tr>

+ 2 - 1
app/views/entry/read.phtml

@@ -12,5 +12,6 @@ $url['params']['is_read'] = Minz_Request::param('is_read', true) ? '0' : '1';
 FreshRSS::loadStylesAndScripts();
 FreshRSS::loadStylesAndScripts();
 echo json_encode(array(
 echo json_encode(array(
 		'url' => str_ireplace('&amp;', '&', Minz_Url::display($url)),
 		'url' => str_ireplace('&amp;', '&', Minz_Url::display($url)),
-		'icon' => _i($url['params']['is_read'] === '1' ? 'unread' : 'read')
+		'icon' => _i($url['params']['is_read'] === '1' ? 'unread' : 'read'),
+		'tags' => $this->tags,
 	));
 	));

+ 36 - 20
app/views/helpers/index/normal/entry_bottom.phtml

@@ -7,6 +7,7 @@
 	$bottomline_read = FreshRSS_Context::$user_conf->bottomline_read;
 	$bottomline_read = FreshRSS_Context::$user_conf->bottomline_read;
 	$bottomline_favorite = FreshRSS_Context::$user_conf->bottomline_favorite;
 	$bottomline_favorite = FreshRSS_Context::$user_conf->bottomline_favorite;
 	$bottomline_sharing = FreshRSS_Context::$user_conf->bottomline_sharing && (count($sharing) > 0);
 	$bottomline_sharing = FreshRSS_Context::$user_conf->bottomline_sharing && (count($sharing) > 0);
+	$bottomline_labels = true;	//TODO
 	$bottomline_tags = FreshRSS_Context::$user_conf->bottomline_tags;
 	$bottomline_tags = FreshRSS_Context::$user_conf->bottomline_tags;
 	$bottomline_date = FreshRSS_Context::$user_conf->bottomline_date;
 	$bottomline_date = FreshRSS_Context::$user_conf->bottomline_date;
 	$bottomline_link = FreshRSS_Context::$user_conf->bottomline_link;
 	$bottomline_link = FreshRSS_Context::$user_conf->bottomline_link;
@@ -32,8 +33,41 @@
 					echo _i($this->entry->isFavorite() ? 'starred' : 'non-starred'); ?></a><?php
 					echo _i($this->entry->isFavorite() ? 'starred' : 'non-starred'); ?></a><?php
 			?></li><?php
 			?></li><?php
 		}
 		}
-	} ?>
-	<li class="item"><?php
+	}
+	if ($bottomline_labels) {
+	?><li class="item">
+		<div class="dropdown dynamictags">
+			<div id="dropdown-labels-<?php echo $this->entry->id();?>" class="dropdown-target"></div>
+			<?php echo FreshRSS_Themes::alt('label'); ?>
+			<a class="dropdown-toggle" href="#dropdown-labels-<?php echo $this->entry->id();?>"><?php
+				echo _t('index.menu.tags');
+			?></a>
+			<ul class="dropdown-menu">
+				<li class="dropdown-close"><a href="#close">❌</a></li>
+				<!-- Ajax -->
+			</ul>
+		</div>
+	</li><?php
+	}
+	$tags = $bottomline_tags ? $this->entry->tags() : null;
+	if (!empty($tags)) {
+	?><li class="item">
+		<div class="dropdown">
+			<div id="dropdown-tags-<?php echo $this->entry->id();?>" class="dropdown-target"></div>
+			<?php echo _i('tag'); ?>
+			<a class="dropdown-toggle" href="#dropdown-tags-<?php echo $this->entry->id();?>"><?php
+				echo _t('index.tag.related');
+			?></a>
+			<ul class="dropdown-menu">
+				<li class="dropdown-close"><a href="#close">❌</a></li><?php
+				foreach ($tags as $tag) {
+					?><li class="item"><a href="<?php echo _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))); ?>"><?php echo $tag; ?></a></li><?php
+				} ?>
+			</ul>
+		</div>
+	</li><?php
+	}
+	?><li class="item"><?php
 			if ($bottomline_sharing) {
 			if ($bottomline_sharing) {
 		?><div class="dropdown">
 		?><div class="dropdown">
 			<div id="dropdown-share-<?php echo $this->entry->id();?>" class="dropdown-target"></div>
 			<div id="dropdown-share-<?php echo $this->entry->id();?>" class="dropdown-target"></div>
@@ -69,24 +103,6 @@
 		</div>
 		</div>
 		<?php } ?>
 		<?php } ?>
 	</li><?php
 	</li><?php
-	$tags = $bottomline_tags ? $this->entry->tags() : null;
-	if (!empty($tags)) {
-	?><li class="item">
-		<div class="dropdown">
-			<div id="dropdown-tags-<?php echo $this->entry->id();?>" class="dropdown-target"></div>
-			<?php echo _i('tag'); ?>
-			<a class="dropdown-toggle" href="#dropdown-tags-<?php echo $this->entry->id();?>"><?php
-				echo _t('index.tag.related');
-			?></a>
-			<ul class="dropdown-menu">
-				<li class="dropdown-close"><a href="#close">❌</a></li><?php
-				foreach ($tags as $tag) {
-					?><li class="item"><a href="<?php echo _url('index', 'index', 'search', '#' . str_replace(' ', '+', htmlspecialchars_decode($tag, ENT_QUOTES))); ?>"><?php echo $tag; ?></a></li><?php
-				} ?>
-			</ul>
-		</div>
-	</li><?php
-	}
 	if ($bottomline_date) {
 	if ($bottomline_date) {
 		?><li class="item date"><?php echo $this->entry->date(); ?></li><?php
 		?><li class="item date"><?php echo $this->entry->date(); ?></li><?php
 	}
 	}

+ 1 - 1
app/views/index/normal.phtml

@@ -20,7 +20,7 @@ if (!empty($this->entries)) {
 	</div><?php
 	</div><?php
 	foreach ($this->entries as $item) {
 	foreach ($this->entries as $item) {
 		$this->entry = Minz_ExtensionManager::callHook('entry_before_display', $item);
 		$this->entry = Minz_ExtensionManager::callHook('entry_before_display', $item);
-		if (is_null($this->entry)) {
+		if ($this->entry == null) {
 			continue;
 			continue;
 		}
 		}
 
 

+ 8 - 2
app/views/javascript/nbUnreadsPerFeed.phtml

@@ -1,8 +1,14 @@
 <?php
 <?php
-$result = array();
+$result = array(
+	'feeds' => array(),
+	'tags' => array(),
+);
 foreach ($this->categories as $cat) {
 foreach ($this->categories as $cat) {
 	foreach ($cat->feeds() as $feed) {
 	foreach ($cat->feeds() as $feed) {
-		$result[$feed->id()] = $feed->nbNotRead();
+		$result['feeds'][$feed->id()] = $feed->nbNotRead();
 	}
 	}
 }
 }
+foreach ($this->tags as $tag) {
+	$result['tags'][$tag->id()] = $tag->nbUnread();
+}
 echo json_encode($result);
 echo json_encode($result);

+ 2 - 0
app/views/tag/getTagsForEntry.phtml

@@ -0,0 +1,2 @@
+<?php
+echo json_encode($this->tags);

+ 1 - 1
cli/README.md

@@ -72,7 +72,7 @@ cd /usr/share/FreshRSS
 # --user can be a username, or '*' to loop on all users
 # --user can be a username, or '*' to loop on all users
 # Returns: 1) a * iff the user is admin, 2) the name of the user,
 # Returns: 1) a * iff the user is admin, 2) the name of the user,
 #  3) the date/time of last user action, 4) the size occupied,
 #  3) the date/time of last user action, 4) the size occupied,
-#  and the number of: 5) categories, 6) feeds, 7) read articles, 8) unread articles, and 9) favourites
+#  and the number of: 5) categories, 6) feeds, 7) read articles, 8) unread articles, 9) favourites, and 10) tags
 
 
 ./cli/db-optimize.php --user username
 ./cli/db-optimize.php --user username
 # Optimize database (reduces the size) for a given user (perform `OPTIMIZE TABLE` in MySQL, `VACUUM` in SQLite)
 # Optimize database (reduces the size) for a given user (perform `OPTIMIZE TABLE` in MySQL, `VACUUM` in SQLite)

+ 4 - 1
cli/user-info.php

@@ -16,9 +16,10 @@ foreach ($users as $username) {
 	$username = cliInitUser($username);
 	$username = cliInitUser($username);
 	echo $username === FreshRSS_Context::$system_conf->default_user ? '*' : ' ', "\t";
 	echo $username === FreshRSS_Context::$system_conf->default_user ? '*' : ' ', "\t";
 
 
-	$catDAO = new FreshRSS_CategoryDAO();
+	$catDAO = FreshRSS_Factory::createCategoryDao();
 	$feedDAO = FreshRSS_Factory::createFeedDao($username);
 	$feedDAO = FreshRSS_Factory::createFeedDao($username);
 	$entryDAO = FreshRSS_Factory::createEntryDao($username);
 	$entryDAO = FreshRSS_Factory::createEntryDao($username);
+	$tagDAO = FreshRSS_Factory::createTagDao($username);
 	$databaseDAO = FreshRSS_Factory::createDatabaseDAO($username);
 	$databaseDAO = FreshRSS_Factory::createDatabaseDAO($username);
 
 
 	$nbEntries = $entryDAO->countUnreadRead();
 	$nbEntries = $entryDAO->countUnreadRead();
@@ -34,6 +35,7 @@ foreach ($users as $username) {
 			$nbEntries['read'], " reads\t",
 			$nbEntries['read'], " reads\t",
 			$nbEntries['unread'], " unreads\t",
 			$nbEntries['unread'], " unreads\t",
 			$nbFavorites['all'], " favourites\t",
 			$nbFavorites['all'], " favourites\t",
+			$tagDAO->count(), " tags\t",
 			"\n";
 			"\n";
 	} else {
 	} else {
 		echo
 		echo
@@ -45,6 +47,7 @@ foreach ($users as $username) {
 			$nbEntries['read'], "\t",
 			$nbEntries['read'], "\t",
 			$nbEntries['unread'], "\t",
 			$nbEntries['unread'], "\t",
 			$nbFavorites['all'], "\t",
 			$nbFavorites['all'], "\t",
+			$tagDAO->count(), "\t",
 			"\n";
 			"\n";
 	}
 	}
 }
 }

+ 6 - 0
lib/lib_rss.php

@@ -450,6 +450,9 @@ function check_install_database() {
 		'categories' => false,
 		'categories' => false,
 		'feeds' => false,
 		'feeds' => false,
 		'entries' => false,
 		'entries' => false,
+		'entrytmp' => false,
+		'tag' => false,
+		'entrytag' => false,
 	);
 	);
 
 
 	try {
 	try {
@@ -459,6 +462,9 @@ function check_install_database() {
 		$status['categories'] = $dbDAO->categoryIsCorrect();
 		$status['categories'] = $dbDAO->categoryIsCorrect();
 		$status['feeds'] = $dbDAO->feedIsCorrect();
 		$status['feeds'] = $dbDAO->feedIsCorrect();
 		$status['entries'] = $dbDAO->entryIsCorrect();
 		$status['entries'] = $dbDAO->entryIsCorrect();
+		$status['entrytmp'] = $dbDAO->entrytmpIsCorrect();
+		$status['tag'] = $dbDAO->tagIsCorrect();
+		$status['entrytag'] = $dbDAO->entrytagIsCorrect();
 	} catch(Minz_PDOConnectionException $e) {
 	} catch(Minz_PDOConnectionException $e) {
 		$status['connection'] = false;
 		$status['connection'] = false;
 	}
 	}

+ 2 - 2
p/api/fever.php

@@ -312,7 +312,7 @@ class FeverAPI
 	{
 	{
 		$groups = array();
 		$groups = array();
 
 
-		$categoryDAO = new FreshRSS_CategoryDAO();
+		$categoryDAO = FreshRSS_Factory::createCategoryDao();
 		$categories = $categoryDAO->listCategories(false, false);
 		$categories = $categoryDAO->listCategories(false, false);
 
 
 		/** @var FreshRSS_Category $category */
 		/** @var FreshRSS_Category $category */
@@ -457,7 +457,7 @@ class FeverAPI
 			}
 			}
 
 
 			if (isset($_REQUEST['group_ids'])) {
 			if (isset($_REQUEST['group_ids'])) {
-				$categoryDAO = new FreshRSS_CategoryDAO();
+				$categoryDAO = FreshRSS_Factory::createCategoryDao();
 				$group_ids = explode(',', $_REQUEST['group_ids']);
 				$group_ids = explode(',', $_REQUEST['group_ids']);
 				foreach ($group_ids as $id) {
 				foreach ($group_ids as $id) {
 					/** @var FreshRSS_Category $category */
 					/** @var FreshRSS_Category $category */

+ 145 - 22
p/api/greader.php

@@ -42,6 +42,12 @@ if (PHP_INT_SIZE < 8) {	//32-bit
 	}
 	}
 }
 }
 
 
+if (version_compare(PHP_VERSION, '5.4.0') >= 0) {
+	define('JSON_OPTIONS', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+} else {
+	define('JSON_OPTIONS', 0);
+}
+
 function headerVariable($headerName, $varName) {
 function headerVariable($headerName, $varName) {
 	$header = '';
 	$header = '';
 	$upName = 'HTTP_' . strtoupper($headerName);
 	$upName = 'HTTP_' . strtoupper($headerName);
@@ -234,7 +240,7 @@ function userInfo() {	//https://github.com/theoldreader/api#user-info
 			'userName' => $user,
 			'userName' => $user,
 			'userProfileId' => $user,
 			'userProfileId' => $user,
 			'userEmail' => FreshRSS_Context::$user_conf->mail_login,
 			'userEmail' => FreshRSS_Context::$user_conf->mail_login,
-		)));
+		), JSON_OPTIONS));
 }
 }
 
 
 function tagList() {
 function tagList() {
@@ -254,10 +260,24 @@ function tagList() {
 		$tags[] = array(
 		$tags[] = array(
 			'id' => 'user/-/label/' . $cName,
 			'id' => 'user/-/label/' . $cName,
 			//'sortid' => $cName,
 			//'sortid' => $cName,
+			'type' => 'folder',	//Inoreader
 		);
 		);
 	}
 	}
 
 
-	echo json_encode(array('tags' => $tags)), "\n";
+	unset($res);
+
+	$tagDAO = FreshRSS_Factory::createTagDao();
+	$labels = $tagDAO->listTags(true);
+	foreach ($labels as $label) {
+		$tags[] = array(
+			'id' => 'user/-/label/' . $label->name(),
+			//'sortid' => $cName,
+			'type' => 'tag',	//Inoreader
+			'unread_count' => $label->nbUnread(),
+		);
+	}
+
+	echo json_encode(array('tags' => $tags), JSON_OPTIONS), "\n";
 	exit();
 	exit();
 }
 }
 
 
@@ -293,7 +313,7 @@ function subscriptionList() {
 		);
 		);
 	}
 	}
 
 
-	echo json_encode(array('subscriptions' => $subscriptions)), "\n";
+	echo json_encode(array('subscriptions' => $subscriptions), JSON_OPTIONS), "\n";
 	exit();
 	exit();
 }
 }
 
 
@@ -310,7 +330,7 @@ function subscriptionEdit($streamNames, $titles, $action, $add = '', $remove = '
 	$addCatId = 0;
 	$addCatId = 0;
 	$categoryDAO = null;
 	$categoryDAO = null;
 	if ($add != '' || $remove != '') {
 	if ($add != '' || $remove != '') {
-		$categoryDAO = new FreshRSS_CategoryDAO();
+		$categoryDAO = FreshRSS_Factory::createCategoryDao();
 	}
 	}
 	$c_name = '';
 	$c_name = '';
 	if ($add != '' && strpos($add, 'user/') === 0) {	//user/-/label/Example ; user/username/label/Example
 	if ($add != '' && strpos($add, 'user/') === 0) {	//user/-/label/Example ; user/username/label/Example
@@ -391,13 +411,13 @@ function quickadd($url) {
 		exit(json_encode(array(
 		exit(json_encode(array(
 				'numResults' => 1,
 				'numResults' => 1,
 				'streamId' => $feed->id(),
 				'streamId' => $feed->id(),
-			)));
+			), JSON_OPTIONS));
 	} catch (Exception $e) {
 	} catch (Exception $e) {
 		Minz_Log::error('quickadd error: ' . $e->getMessage(), API_LOG);
 		Minz_Log::error('quickadd error: ' . $e->getMessage(), API_LOG);
 		die(json_encode(array(
 		die(json_encode(array(
 				'numResults' => 0,
 				'numResults' => 0,
 				'error' => $e->getMessage(),
 				'error' => $e->getMessage(),
-			)));
+			), JSON_OPTIONS));
 	}
 	}
 }
 }
 
 
@@ -407,7 +427,7 @@ function unreadCount() {	//http://blog.martindoms.com/2009/10/16/using-the-googl
 	$totalUnreads = 0;
 	$totalUnreads = 0;
 	$totalLastUpdate = 0;
 	$totalLastUpdate = 0;
 
 
-	$categoryDAO = new FreshRSS_CategoryDAO();
+	$categoryDAO = FreshRSS_Factory::createCategoryDao();
 	foreach ($categoryDAO->listCategories(true, true) as $cat) {
 	foreach ($categoryDAO->listCategories(true, true) as $cat) {
 		$catLastUpdate = 0;
 		$catLastUpdate = 0;
 		foreach ($cat->feeds() as $feed) {
 		foreach ($cat->feeds() as $feed) {
@@ -432,6 +452,14 @@ function unreadCount() {	//http://blog.martindoms.com/2009/10/16/using-the-googl
 		}
 		}
 	}
 	}
 
 
+	$tagDAO = FreshRSS_Factory::createTagDao();
+	foreach ($tagDAO->listTags(true) as $label) {
+		$unreadcounts[] = array(
+			'id' => 'user/-/label/' . $label->name(),
+			'count' => $label->nbUnread(),
+		);
+	}
+
 	$unreadcounts[] = array(
 	$unreadcounts[] = array(
 		'id' => 'user/-/state/com.google/reading-list',
 		'id' => 'user/-/state/com.google/reading-list',
 		'count' => $totalUnreads,
 		'count' => $totalUnreads,
@@ -441,13 +469,21 @@ function unreadCount() {	//http://blog.martindoms.com/2009/10/16/using-the-googl
 	echo json_encode(array(
 	echo json_encode(array(
 		'max' => $totalUnreads,
 		'max' => $totalUnreads,
 		'unreadcounts' => $unreadcounts,
 		'unreadcounts' => $unreadcounts,
-	)), "\n";
+	), JSON_OPTIONS), "\n";
 	exit();
 	exit();
 }
 }
 
 
 function entriesToArray($entries) {
 function entriesToArray($entries) {
+	if (empty($entries)) {
+		return array();
+	}
 	$feedDAO = FreshRSS_Factory::createFeedDao();
 	$feedDAO = FreshRSS_Factory::createFeedDao();
 	$arrayFeedCategoryNames = $feedDAO->arrayFeedCategoryNames();
 	$arrayFeedCategoryNames = $feedDAO->arrayFeedCategoryNames();
+	$tagDAO = FreshRSS_Factory::createTagDao();
+	$entryIdsTagNames = $tagDAO->getEntryIdsTagNames($entries);
+	if ($entryIdsTagNames == false) {
+		$entryIdsTagNames = array();
+	}
 
 
 	$items = array();
 	$items = array();
 	foreach ($entries as $entry) {
 	foreach ($entries as $entry) {
@@ -472,7 +508,6 @@ function entriesToArray($entries) {
 			'categories' => array(
 			'categories' => array(
 				'user/-/state/com.google/reading-list',
 				'user/-/state/com.google/reading-list',
 				'user/-/label/' . $c_name,
 				'user/-/label/' . $c_name,
-				//TODO: Add other tags
 			),
 			),
 			'origin' => array(
 			'origin' => array(
 				'streamId' => 'feed/' . $f_id,
 				'streamId' => 'feed/' . $f_id,
@@ -490,6 +525,10 @@ function entriesToArray($entries) {
 		if ($entry->isFavorite()) {
 		if ($entry->isFavorite()) {
 			$item['categories'][] = 'user/-/state/com.google/starred';
 			$item['categories'][] = 'user/-/state/com.google/starred';
 		}
 		}
+		$tagNames = isset($entryIdsTagNames['e_' . $entry->id()]) ? $entryIdsTagNames['e_' . $entry->id()] : array();
+		foreach ($tagNames as $tagName) {
+			$item['categories'][] = 'user/-/label/' . $tagName;
+		}
 		$items[] = $item;
 		$items[] = $item;
 	}
 	}
 	return $items;
 	return $items;
@@ -511,10 +550,22 @@ function streamContents($path, $include_target, $start_time, $count, $order, $ex
 			$type = 'f';
 			$type = 'f';
 			break;
 			break;
 		case 'label':
 		case 'label':
-			$type = 'c';
-			$categoryDAO = new FreshRSS_CategoryDAO();
+			$categoryDAO = FreshRSS_Factory::createCategoryDao();
 			$cat = $categoryDAO->searchByName($include_target);
 			$cat = $categoryDAO->searchByName($include_target);
-			$include_target = $cat == null ? -1 : $cat->id();
+			if ($cat != null) {
+				$type = 'c';
+				$include_target = $cat->id();
+			} else {
+				$tagDAO = FreshRSS_Factory::createTagDao();
+				$tag = $tagDAO->searchByName($include_target);
+				if ($tag != null) {
+					$type = 't';
+					$include_target = $tag->id();
+				} else {
+					$type = 'A';
+					$include_target = -1;
+				}
+			}
 			break;
 			break;
 		default:
 		default:
 			$type = 'A';
 			$type = 'A';
@@ -559,7 +610,7 @@ function streamContents($path, $include_target, $start_time, $count, $order, $ex
 		}
 		}
 	}
 	}
 
 
-	echo json_encode($response), "\n";
+	echo json_encode($response, JSON_OPTIONS), "\n";
 	exit();
 	exit();
 }
 }
 
 
@@ -579,9 +630,22 @@ function streamContentsItemsIds($streamId, $start_time, $count, $order, $exclude
 	} elseif (strpos($streamId, 'user/-/label/') === 0) {
 	} elseif (strpos($streamId, 'user/-/label/') === 0) {
 		$type = 'c';
 		$type = 'c';
 		$c_name = substr($streamId, 13);
 		$c_name = substr($streamId, 13);
-		$categoryDAO = new FreshRSS_CategoryDAO();
+		$categoryDAO = FreshRSS_Factory::createCategoryDao();
 		$cat = $categoryDAO->searchByName($c_name);
 		$cat = $categoryDAO->searchByName($c_name);
-		$id = $cat == null ? -1 : $cat->id();
+		if ($cat != null) {
+			$type = 'c';
+			$id = $cat->id();
+		} else {
+			$tagDAO = FreshRSS_Factory::createTagDao();
+			$tag = $tagDAO->searchByName($c_name);
+			if ($tag != null) {
+				$type = 't';
+				$id = $tag->id();
+			} else {
+				$type = 'A';
+				$id = -1;
+			}
+		}
 	}
 	}
 
 
 	switch ($exclude_target) {
 	switch ($exclude_target) {
@@ -625,7 +689,7 @@ function streamContentsItemsIds($streamId, $start_time, $count, $order, $exclude
 		}
 		}
 	}
 	}
 
 
-	echo json_encode($response), "\n";
+	echo json_encode($response, JSON_OPTIONS), "\n";
 	exit();
 	exit();
 }
 }
 
 
@@ -647,7 +711,7 @@ function streamContentsItems($e_ids, $order) {
 		'items' => $items,
 		'items' => $items,
 	);
 	);
 
 
-	echo json_encode($response), "\n";
+	echo json_encode($response, JSON_OPTIONS), "\n";
 	exit();
 	exit();
 }
 }
 
 
@@ -657,6 +721,7 @@ function editTag($e_ids, $a, $r) {
 	}
 	}
 
 
 	$entryDAO = FreshRSS_Factory::createEntryDao();
 	$entryDAO = FreshRSS_Factory::createEntryDao();
+	$tagDAO = FreshRSS_Factory::createTagDao();
 
 
 	switch ($a) {
 	switch ($a) {
 		case 'user/-/state/com.google/read':
 		case 'user/-/state/com.google/read':
@@ -671,6 +736,30 @@ function editTag($e_ids, $a, $r) {
 			break;
 			break;
 		case 'user/-/state/com.google/broadcast':
 		case 'user/-/state/com.google/broadcast':
 			break;*/
 			break;*/
+		default:
+			$tagName = '';
+			if (strpos($a, 'user/-/label/') === 0) {
+				$tagName = substr($a, 13);
+			} else {
+				$user = Minz_Session::param('currentUser', '_');
+				$prefix = 'user/' . $user . '/label/';
+				if (strpos($a, $prefix) === 0) {
+					$tagName = substr($a, strlen($prefix));
+				}
+			}
+			if ($tagName != '') {
+				$tag = $tagDAO->searchByName($tagName);
+				if ($tag == null) {
+					$tagDAO->addTag(array('name' => $tagName));
+					$tag = $tagDAO->searchByName($tagName);
+				}
+				if ($tag != null) {
+					foreach ($e_ids as $e_id) {
+						$tagDAO->tagEntry($tag->id(), $e_id, true);
+					}
+				}
+			}
+			break;
 	}
 	}
 	switch ($r) {
 	switch ($r) {
 		case 'user/-/state/com.google/read':
 		case 'user/-/state/com.google/read':
@@ -679,6 +768,17 @@ function editTag($e_ids, $a, $r) {
 		case 'user/-/state/com.google/starred':
 		case 'user/-/state/com.google/starred':
 			$entryDAO->markFavorite($e_ids, false);
 			$entryDAO->markFavorite($e_ids, false);
 			break;
 			break;
+		default:
+			if (strpos($r, 'user/-/label/') === 0) {
+				$tagName = substr($r, 13);
+				$tag = $tagDAO->searchByName($tagName);
+				if ($tag != null) {
+					foreach ($e_ids as $e_id) {
+						$tagDAO->tagEntry($tag->id(), $e_id, false);
+					}
+				}
+			}
+			break;
 	}
 	}
 
 
 	exit('OK');
 	exit('OK');
@@ -688,12 +788,20 @@ function renameTag($s, $dest) {
 	if ($s != '' && strpos($s, 'user/-/label/') === 0 &&
 	if ($s != '' && strpos($s, 'user/-/label/') === 0 &&
 		$dest != '' &&  strpos($dest, 'user/-/label/') === 0) {
 		$dest != '' &&  strpos($dest, 'user/-/label/') === 0) {
 		$s = substr($s, 13);
 		$s = substr($s, 13);
-		$categoryDAO = new FreshRSS_CategoryDAO();
+		$dest = substr($dest, 13);
+
+		$categoryDAO = FreshRSS_Factory::createCategoryDao();
 		$cat = $categoryDAO->searchByName($s);
 		$cat = $categoryDAO->searchByName($s);
 		if ($cat != null) {
 		if ($cat != null) {
-			$dest = substr($dest, 13);
 			$categoryDAO->updateCategory($cat->id(), array('name' => $dest));
 			$categoryDAO->updateCategory($cat->id(), array('name' => $dest));
 			exit('OK');
 			exit('OK');
+		} else {
+			$tagDAO = FreshRSS_Factory::createTagDao();
+			$tag = $tagDAO->searchByName($s);
+			if ($tag != null) {
+				$tagDAO->updateTag($tag->id(), array('name' => $dest));
+				exit('OK');
+			}
 		}
 		}
 	}
 	}
 	badRequest();
 	badRequest();
@@ -702,7 +810,7 @@ function renameTag($s, $dest) {
 function disableTag($s) {
 function disableTag($s) {
 	if ($s != '' && strpos($s, 'user/-/label/') === 0) {
 	if ($s != '' && strpos($s, 'user/-/label/') === 0) {
 		$s = substr($s, 13);
 		$s = substr($s, 13);
-		$categoryDAO = new FreshRSS_CategoryDAO();
+		$categoryDAO = FreshRSS_Factory::createCategoryDao();
 		$cat = $categoryDAO->searchByName($s);
 		$cat = $categoryDAO->searchByName($s);
 		if ($cat != null) {
 		if ($cat != null) {
 			$feedDAO = FreshRSS_Factory::createFeedDao();
 			$feedDAO = FreshRSS_Factory::createFeedDao();
@@ -711,6 +819,13 @@ function disableTag($s) {
 				$categoryDAO->deleteCategory($cat->id());
 				$categoryDAO->deleteCategory($cat->id());
 			}
 			}
 			exit('OK');
 			exit('OK');
+		} else {
+			$tagDAO = FreshRSS_Factory::createTagDao();
+			$tag = $tagDAO->searchByName($s);
+			if ($tag != null) {
+				$tagDAO->deleteTag($tag->id());
+				exit('OK');
+			}
 		}
 		}
 	}
 	}
 	badRequest();
 	badRequest();
@@ -723,9 +838,17 @@ function markAllAsRead($streamId, $olderThanId) {
 		$entryDAO->markReadFeed($f_id, $olderThanId);
 		$entryDAO->markReadFeed($f_id, $olderThanId);
 	} elseif (strpos($streamId, 'user/-/label/') === 0) {
 	} elseif (strpos($streamId, 'user/-/label/') === 0) {
 		$c_name = substr($streamId, 13);
 		$c_name = substr($streamId, 13);
-		$categoryDAO = new FreshRSS_CategoryDAO();
+		$categoryDAO = FreshRSS_Factory::createCategoryDao();
 		$cat = $categoryDAO->searchByName($c_name);
 		$cat = $categoryDAO->searchByName($c_name);
-		$entryDAO->markReadCat($cat === null ? -1 : $cat->id(), $olderThanId);
+		if ($cat != null) {
+			$entryDAO->markReadCat($cat->id(), $olderThanId);
+		} else {
+			$tagDAO = FreshRSS_Factory::createTagDao();
+			$tag = $tagDAO->searchByName($c_name);
+			if ($tag != null) {
+				$entryDAO->markReadTag($tag->id(), $olderThanId);
+			}
+		}
 	} elseif ($streamId === 'user/-/state/com.google/reading-list') {
 	} elseif ($streamId === 'user/-/state/com.google/reading-list') {
 		$entryDAO->markReadEntries($olderThanId, false, -1);
 		$entryDAO->markReadEntries($olderThanId, false, -1);
 	}
 	}

+ 102 - 6
p/scripts/main.js

@@ -114,6 +114,17 @@ function incUnreadsFeed(article, feed_id, nb) {
 	return isCurrentView;
 	return isCurrentView;
 }
 }
 
 
+function incUnreadsTag(tag_id, nb) {
+	var $t = $('#t_' + tag_id);
+	var unreads = str2int($t.attr('data-unread'));
+	$t.attr('data-unread', unreads + nb)
+		.children('.item-title').attr('data-unread', numberFormat(unreads + nb));
+
+	$t = $('.category.tags').find('.title');
+	unreads = str2int($t.attr('data-unread'));
+	$t.attr('data-unread', numberFormat(unreads + nb));
+}
+
 var pending_entries = {};
 var pending_entries = {};
 function mark_read(active, only_not_read) {
 function mark_read(active, only_not_read) {
 	if ((active.length === 0) || (!active.attr('id')) ||
 	if ((active.length === 0) || (!active.attr('id')) ||
@@ -157,6 +168,12 @@ function mark_read(active, only_not_read) {
 		}
 		}
 		faviconNbUnread();
 		faviconNbUnread();
 
 
+		if (data.tags) {
+			for (var i = data.tags.length - 1; i >= 0; i--) {
+				incUnreadsTag(data.tags[i], inc);
+			}
+		}
+
 		delete pending_entries[active.attr('id')];
 		delete pending_entries[active.attr('id')];
 	}).fail(function (data) {
 	}).fail(function (data) {
 		openNotification(i18n.notif_request_failed, 'bad');
 		openNotification(i18n.notif_request_failed, 'bad');
@@ -529,12 +546,16 @@ function init_column_categories() {
 		$(this).parent().next(".tree-folder-items").slideToggle(300 , function() { $(document.body).trigger("sticky_kit:recalc"); });
 		$(this).parent().next(".tree-folder-items").slideToggle(300 , function() { $(document.body).trigger("sticky_kit:recalc"); });
 		return false;
 		return false;
 	});
 	});
-	$('#aside_feed').on('click', '.tree-folder-items .item .dropdown-toggle', function () {
+	$('#aside_feed').on('click', '.tree-folder-items .feed .dropdown-toggle', function () {
 		if ($(this).nextAll('.dropdown-menu').length === 0) {
 		if ($(this).nextAll('.dropdown-menu').length === 0) {
-			var feed_id = $(this).closest('.item').attr('id').substr(2),
+			var itemId = $(this).closest('.item').attr('id'),
+				templateId = itemId.substring(0, 2) === 't_' ? 'tag_config_template' : 'feed_config_template',
+				id = itemId.substr(2),
 				feed_web = $(this).data('fweb'),
 				feed_web = $(this).data('fweb'),
-				template = $('#feed_config_template').html().replace(/------/g, feed_id).replace('http://example.net/', feed_web);
-			$(this).attr('href', '#dropdown-' + feed_id).prev('.dropdown-target').attr('id', 'dropdown-' + feed_id).parent().append(template);
+				template = $('#' + templateId)
+					.html().replace(/------/g, id).replace('http://example.net/', feed_web);
+			$(this).attr('href', '#dropdown-' + id).prev('.dropdown-target').attr('id', 'dropdown-' + id).parent()
+				.append(template).find('button.confirm').removeAttr('disabled');
 			$('.tree-folder-items .dropdown-close a').click(function(){
 			$('.tree-folder-items .dropdown-close a').click(function(){
 				$('.tree').removeClass('treepadding');
 				$('.tree').removeClass('treepadding');
 				$(document.body).trigger("sticky_kit:recalc");
 				$(document.body).trigger("sticky_kit:recalc");
@@ -606,7 +627,7 @@ function init_shortcuts() {
 			auto_share(String.fromCharCode(evt.keyCode));
 			auto_share(String.fromCharCode(evt.keyCode));
 		}
 		}
 	}
 	}
-	for(var i = 1; i < 10; i++) {
+	for (var i = 1; i < 10; i++) {
 		shortcut.add(i.toString(), addShortcut, {
 		shortcut.add(i.toString(), addShortcut, {
 			'disable_in_input': true
 			'disable_in_input': true
 		});
 		});
@@ -830,6 +851,69 @@ function init_nav_entries() {
 	});
 	});
 }
 }
 
 
+function loadDynamicTags($div) {
+	$div.removeClass('dynamictags');
+	$div.find('li.item').remove();
+	var entryId = $div.closest('div.flux').attr('id').replace(/^flux_/, '');
+	$.getJSON('./?c=tag&a=getTagsForEntry&id_entry=' + entryId)
+		.done(function (data) {
+			var $ul = $div.find('.dropdown-menu');
+			$ul.append('<li class="item"><label><input class="checkboxTag" name="t_0" type="checkbox" /> <input type="text" name="newTag" /></label></li>');
+			if (data && data.length) {
+				for (var i = 0; i < data.length; i++) {
+					var tag = data[i];
+					$ul.append('<li class="item"><label><input class="checkboxTag" name="t_' + tag.id + '" type="checkbox"' +
+						(tag.checked ? ' checked="checked"' : '') + '> ' + tag.name + '</label></li>');
+				}
+			}
+		})
+		.fail(function () {
+			$div.find('li.item').remove();
+			$div.addClass('dynamictags');
+		});
+}
+
+function init_dynamic_tags() {
+	$stream.on('click', '.dynamictags', function () {
+		loadDynamicTags($(this));
+	});
+
+	$stream.on('change', '.checkboxTag', function (ev) {
+		var $checkbox = $(this);
+		$checkbox.prop('disabled', true);
+		var isChecked = $checkbox.prop('checked');
+		var tagId = $checkbox.attr('name').replace(/^t_/, '');
+		var tagName = $checkbox.siblings('input[name]').val();
+		var $entry = $checkbox.closest('div.flux');
+		var entryId = $entry.attr('id').replace(/^flux_/, '');
+		$.ajax({
+				type: 'POST',
+				url: './?c=tag&a=tagEntry',
+				data: {
+					_csrf: context.csrf,
+					id_tag: tagId,
+					name_tag: tagId == 0 ? tagName : '',
+					id_entry: entryId,
+					checked: isChecked,
+				},
+			})
+			.done(function () {
+				if ($entry.hasClass('not_read')) {
+					incUnreadsTag(tagId, isChecked ? 1 : -1);
+				}
+			})
+			.fail(function () {
+				$checkbox.prop('checked', !isChecked);
+			})
+			.always(function () {
+				$checkbox.prop('disabled', false);
+				if (tagId == 0) {
+					loadDynamicTags($checkbox.closest('div.dropdown'));
+				}
+			});
+	});
+}
+
 // <actualize>
 // <actualize>
 var feed_processed = 0;
 var feed_processed = 0;
 
 
@@ -1004,7 +1088,7 @@ function refreshUnreads() {
 		var isAll = $('.category.all.active').length > 0,
 		var isAll = $('.category.all.active').length > 0,
 			new_articles = false;
 			new_articles = false;
 
 
-		$.each(data, function(feed_id, nbUnreads) {
+		$.each(data.feeds, function(feed_id, nbUnreads) {
 			feed_id = 'f_' + feed_id;
 			feed_id = 'f_' + feed_id;
 			var elem = $('#' + feed_id).get(0),
 			var elem = $('#' + feed_id).get(0),
 				feed_unreads = elem ? str2int(elem.getAttribute('data-unread')) : 0;
 				feed_unreads = elem ? str2int(elem.getAttribute('data-unread')) : 0;
@@ -1016,6 +1100,17 @@ function refreshUnreads() {
 			}
 			}
 		});
 		});
 
 
+		var nbUnreadTags = 0;
+
+		$.each(data.tags, function(tag_id, nbUnreads) {
+			nbUnreadTags += nbUnreads;
+			$('#t_' + tag_id).attr('data-unread', nbUnreads)
+				.children('.item-title').attr('data-unread', numberFormat(nbUnreads));
+		});
+
+		$('.category.tags').attr('data-unread', nbUnreadTags)
+			.find('.title').attr('data-unread', numberFormat(nbUnreadTags));
+
 		var nb_unreads = str2int($('.category.all .title').attr('data-unread'));
 		var nb_unreads = str2int($('.category.all .title').attr('data-unread'));
 
 
 		if (nb_unreads > 0 && new_articles) {
 		if (nb_unreads > 0 && new_articles) {
@@ -1432,6 +1527,7 @@ function init_afterDOM() {
 		init_load_more($stream);
 		init_load_more($stream);
 		init_posts();
 		init_posts();
 		init_nav_entries();
 		init_nav_entries();
+		init_dynamic_tags();
 		init_print_action();
 		init_print_action();
 		init_post_action();
 		init_post_action();
 		init_notifs_html5();
 		init_notifs_html5();

+ 3 - 0
p/themes/BlueLagoon/BlueLagoon.css

@@ -373,6 +373,9 @@ a.btn {
 	color: #ccc;
 	color: #ccc;
 	font-size: 0.8rem;
 	font-size: 0.8rem;
 }
 }
+.dropdown-menu > .item > label {
+	color: #ccc;
+}
 .dropdown-menu > .item:hover {
 .dropdown-menu > .item:hover {
 	background: linear-gradient(180deg,  #0090FF 0%, #0062BE 100%) #E4992C;
 	background: linear-gradient(180deg,  #0090FF 0%, #0062BE 100%) #E4992C;
 	background: -webkit-linear-gradient(top,  #0090FF 0%, #0062BE 100%);
 	background: -webkit-linear-gradient(top,  #0090FF 0%, #0062BE 100%);

+ 1 - 0
p/themes/Flat/flat.css

@@ -300,6 +300,7 @@ a.btn {
 
 
 /*=== Dropdown */
 /*=== Dropdown */
 .dropdown-menu {
 .dropdown-menu {
+	background: #fafafa;
 	margin: 5px 0 0;
 	margin: 5px 0 0;
 	padding: 5px 0;
 	padding: 5px 0;
 	font-size: 0.8rem;
 	font-size: 0.8rem;

+ 3 - 0
p/themes/Screwdriver/screwdriver.css

@@ -373,6 +373,9 @@ a.btn {
 	color: #ccc;
 	color: #ccc;
 	font-size: 0.8rem;
 	font-size: 0.8rem;
 }
 }
+.dropdown-menu > .item > label {
+	color: #ccc;
+}
 .dropdown-menu > .item:hover {
 .dropdown-menu > .item:hover {
 	background: #171717;
 	background: #171717;
 	color: #fff;
 	color: #fff;

+ 8 - 0
p/themes/base-theme/template.css

@@ -108,6 +108,14 @@ input[type="checkbox"] {
 	width: 15px !important;
 	width: 15px !important;
 	min-height: 15px !important;
 	min-height: 15px !important;
 }
 }
+.dropdown-menu label > input[type="text"] {
+	with: 150px;
+	width: calc(99% - 5em);
+}
+.dropdown-menu input[type="checkbox"] {
+	margin-left: 1em;
+	margin-right: .5em;
+}
 button.as-link,
 button.as-link,
 button.as-link:hover,
 button.as-link:hover,
 button.as-link:active {
 button.as-link:active {