Просмотр исходного кода

Merge pull request #2049 from FreshRSS/dev

FreshRSS 1.12.0
Alexandre Alapetite 7 лет назад
Родитель
Сommit
e04804d0f6
100 измененных файлов с 1383 добавлено и 395 удалено
  1. 41 0
      CHANGELOG.md
  2. 5 0
      CREDITS.md
  3. 1 1
      Docker/entrypoint.sh
  4. 4 9
      README.fr.md
  5. 4 9
      README.md
  6. 4 4
      app/Controllers/categoryController.php
  7. 6 4
      app/Controllers/configureController.php
  8. 17 0
      app/Controllers/entryController.php
  9. 9 3
      app/Controllers/feedController.php
  10. 24 11
      app/Controllers/indexController.php
  11. 4 1
      app/Controllers/javascriptController.php
  12. 1 1
      app/Controllers/statsController.php
  13. 2 1
      app/Controllers/subscriptionController.php
  14. 80 0
      app/Controllers/tagController.php
  15. 7 1
      app/Controllers/updateController.php
  16. 17 6
      app/Controllers/userController.php
  17. 0 1
      app/FreshRSS.php
  18. 3 3
      app/Models/Category.php
  19. 12 5
      app/Models/CategoryDAO.php
  20. 39 7
      app/Models/Context.php
  21. 66 23
      app/Models/DatabaseDAO.php
  22. 17 21
      app/Models/DatabaseDAOPGSQL.php
  23. 12 2
      app/Models/DatabaseDAOSQLite.php
  24. 32 21
      app/Models/Entry.php
  25. 86 17
      app/Models/EntryDAO.php
  26. 7 2
      app/Models/EntryDAOPGSQL.php
  27. 47 1
      app/Models/EntryDAOSQLite.php
  28. 16 0
      app/Models/Factory.php
  29. 27 7
      app/Models/Feed.php
  30. 6 3
      app/Models/FeedDAO.php
  31. 29 8
      app/Models/Search.php
  32. 76 0
      app/Models/Tag.php
  33. 315 0
      app/Models/TagDAO.php
  34. 9 0
      app/Models/TagDAOPGSQL.php
  35. 19 0
      app/Models/TagDAOSQLite.php
  36. 9 3
      app/Models/Themes.php
  37. 3 4
      app/Models/UserDAO.php
  38. 25 1
      app/Models/UserQuery.php
  39. 67 41
      app/SQL/install.sql.mysql.php
  40. 40 16
      app/SQL/install.sql.pgsql.php
  41. 55 69
      app/SQL/install.sql.sqlite.php
  42. 1 1
      app/i18n/cz/conf.php
  43. 1 1
      app/i18n/cz/gen.php
  44. 3 2
      app/i18n/cz/index.php
  45. 1 0
      app/i18n/cz/sub.php
  46. 2 2
      app/i18n/de/admin.php
  47. 2 2
      app/i18n/de/conf.php
  48. 2 2
      app/i18n/de/feedback.php
  49. 2 1
      app/i18n/de/gen.php
  50. 4 3
      app/i18n/de/index.php
  51. 2 2
      app/i18n/de/install.php
  52. 3 2
      app/i18n/de/sub.php
  53. 1 1
      app/i18n/en/conf.php
  54. 1 1
      app/i18n/en/gen.php
  55. 3 2
      app/i18n/en/index.php
  56. 1 0
      app/i18n/en/sub.php
  57. 1 1
      app/i18n/es/conf.php
  58. 1 1
      app/i18n/es/gen.php
  59. 3 2
      app/i18n/es/index.php
  60. 1 0
      app/i18n/es/sub.php
  61. 1 1
      app/i18n/fr/conf.php
  62. 1 1
      app/i18n/fr/gen.php
  63. 3 2
      app/i18n/fr/index.php
  64. 1 0
      app/i18n/fr/sub.php
  65. 1 1
      app/i18n/he/conf.php
  66. 1 1
      app/i18n/he/gen.php
  67. 3 2
      app/i18n/he/index.php
  68. 1 0
      app/i18n/he/sub.php
  69. 1 1
      app/i18n/it/conf.php
  70. 1 1
      app/i18n/it/gen.php
  71. 3 2
      app/i18n/it/index.php
  72. 1 0
      app/i18n/it/sub.php
  73. 1 1
      app/i18n/kr/conf.php
  74. 1 1
      app/i18n/kr/gen.php
  75. 3 2
      app/i18n/kr/index.php
  76. 1 0
      app/i18n/kr/sub.php
  77. 1 1
      app/i18n/nl/conf.php
  78. 1 1
      app/i18n/nl/gen.php
  79. 2 2
      app/i18n/nl/index.php
  80. 1 0
      app/i18n/nl/sub.php
  81. 1 1
      app/i18n/pt-br/conf.php
  82. 1 1
      app/i18n/pt-br/gen.php
  83. 2 2
      app/i18n/pt-br/index.php
  84. 1 0
      app/i18n/pt-br/sub.php
  85. 1 1
      app/i18n/ru/conf.php
  86. 1 1
      app/i18n/ru/gen.php
  87. 2 2
      app/i18n/ru/index.php
  88. 1 0
      app/i18n/ru/sub.php
  89. 1 1
      app/i18n/tr/conf.php
  90. 1 1
      app/i18n/tr/gen.php
  91. 3 2
      app/i18n/tr/index.php
  92. 1 0
      app/i18n/tr/sub.php
  93. 1 1
      app/i18n/zh-cn/conf.php
  94. 1 1
      app/i18n/zh-cn/gen.php
  95. 3 2
      app/i18n/zh-cn/index.php
  96. 1 0
      app/i18n/zh-cn/sub.php
  97. 3 3
      app/install.php
  98. 35 0
      app/layout/aside_feed.phtml
  99. 13 21
      app/layout/layout.phtml
  100. 3 3
      app/views/configure/display.phtml

+ 41 - 0
CHANGELOG.md

@@ -1,5 +1,46 @@
 # FreshRSS changelog
 
+## 2018-10-28 FreshRSS 1.12.0
+
+* Features
+	* Ability to add *labels* (custom tags) to articles [#928](https://github.com/FreshRSS/FreshRSS/issues/928)
+		* Also available through Google Reader API (full support in News+, partial in FeedMe, EasyRSS). No support in Fever API.
+	* 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-colon 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)
+	* New option to disable cache for feeds with invalid HTTP caching [#2052](https://github.com/FreshRSS/FreshRSS/pull/2052)
+* UI
+	* New theme *Swage* [#2069](https://github.com/FreshRSS/FreshRSS/pull/2069)
+	* Click on authors to initiate a search by author [#2025](https://github.com/FreshRSS/FreshRSS/pull/2025)
+	* Fix CSS for button alignments in older Chrome versions [#2020](https://github.com/FreshRSS/FreshRSS/pull/2020)
+	* Updated to jQuery 3.3.1 [#2021](https://github.com/FreshRSS/FreshRSS/pull/2021)
+	* Updated to bcrypt.js 2.4.4 [#2022](https://github.com/FreshRSS/FreshRSS/pull/2022)
+* Security
+	* Improved flow for password change (avoid error 403) [#2056](https://github.com/FreshRSS/FreshRSS/issues/2056)
+	* Allow dot `.` in username (best to avoid, though) [#2061](https://github.com/FreshRSS/FreshRSS/issues/2061)
+* Performance
+	* Remove some counterproductive preload / prefetch rules [#2040](https://github.com/FreshRSS/FreshRSS/pull/2040)
+	* Improved fast flush (earlier transfer, fetching of resources, and rendering) [#2045](https://github.com/FreshRSS/FreshRSS/pull/2045)
+		* Only available for Apache running PHP as module (not for NGINX, or PHP as CGI / FPM) because we want to keep compression
+* Deployment
+	* Fix Docker bug with some cron values [#2032](https://github.com/FreshRSS/FreshRSS/pull/2032)
+	* Perform `git clean -f -d -f` (removes unknown files and folders) before git auto-update method [#2036](https://github.com/FreshRSS/FreshRSS/pull/2036)
+* Bug fixing
+	* Make article GUIDs case-sensitive also with MySQL [#2077](https://github.com/FreshRSS/FreshRSS/issues/2077)
+	* Ask confirmation for important configuration actions [#2048](https://github.com/FreshRSS/FreshRSS/pull/2048)
+	* Fix database size in the Web UI for users about to be deleted [#2047](https://github.com/FreshRSS/FreshRSS/pull/2047)
+	* Fix actualize bug after install [#2044](https://github.com/FreshRSS/FreshRSS/pull/2044)
+	* Fix manual / Web actualize for which the final commit coud be done too early [#2081](https://github.com/FreshRSS/FreshRSS/pull/2081)
+	* Fix regression from version 1.11.2, which might have wrongly believed that the server address was private [#2084](https://github.com/FreshRSS/FreshRSS/pull/2084)
+		* Please check in `data/config.php` that you have `'pubsubhubbub_enabled' => true,` if your server has a public address
+* Extensions
+	* Update built-in extension to again fix Tumblr feeds from European Union due to GDPR [#2053](https://github.com/FreshRSS/FreshRSS/pull/2053)
+* I18n
+	* Fix missing German translations, e.g. for *Sharing with Known* [#2059](https://github.com/FreshRSS/FreshRSS/pull/2059)
+* Misc.
+	* Better port detection behind a proxy [#2031](https://github.com/FreshRSS/FreshRSS/issues/2031)
+
+
 ## 2018-09-09 FreshRSS 1.11.2
 
 * Features

+ 5 - 0
CREDITS.md

@@ -13,6 +13,7 @@ People are sorted by name so please keep this order.
 * [Amaury Carrade](https://github.com/AmauryCarrade): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=AmauryCarrade), [Web](https://amaury.carrade.eu/)
 * [Anton Smirnov](https://github.com/sandfoxme): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:sandfoxme), [Web](http://sandfox.me/)
 * [ASMfreaK](https://github.com/ASMfreaK): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=ASMfreaK)
+* [chemical1979](https://github.com/chemical1979): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=chemical1979)
 * [Craig Andrews](https://github.com/candrews): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:candrews), [Web](http://candrews.integralblue.com/)
 * [Crupuk](https://github.com/Crupuk): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Crupuk)
 * [Damstre](https://github.com/Damstre): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Damstre)
@@ -25,6 +26,7 @@ People are sorted by name so please keep this order.
 * [gsongsong](https://github.com/gsongsong): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:gsongsong)
 * [Guillaume Fillon](https://github.com/kokaz): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:kokaz), [Web](http://www.guillaume-fillon.com/)
 * [Guillaume Hayot](https://github.com/postblue): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:postblue), [Web](https://postblue.info/)
+* [Gregor Nathanael Meyer](https://github.com/spackmat): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:spackmat), [Web](https://der-meyer.de)
 * [hckweb](https://github.com/hckweb): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=hckweb)
 * [hoilc](https://github.com/hoilc): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:hoilc)
 * [Jan van den Berg](https://github.com/jan-vandenberg): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:jan-vandenberg), [Web](https://j11g.com/)
@@ -45,6 +47,7 @@ People are sorted by name so please keep this order.
 * [Nicolas Lœuillet](https://github.com/nicosomb): [contributions](https://github.com/FreshRSS/documentation/commits?author=nicosomb), [Web](http://www.loeuillet.org/)
 * [Nicola Spanti](https://github.com/RyDroid): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:RyDroid), [Web](http://www.nicola-spanti.info/)
 * [Olivier Dossmann](https://github.com/blankoworld): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=blankoworld), [Web](https://olivier.dossmann.net)
+* [Patrick Crandol](https://github.com/pattems): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:pattems)
 * [perrinjerome](https://github.com/perrinjerome): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:perrinjerome)
 * [plopoyop](https://github.com/plopoyop): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=plopoyop)
 * [Paulius Šukys](https://github.com/psukys): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:psukys), [Web](http://sukys.eu)
@@ -53,9 +56,11 @@ People are sorted by name so please keep this order.
 * [Quentin Dufour](https://github.com/superboum): [contributions](https://github.com/FreshRSS/documentation/commits?author=superboum), [Web](http://quentin.dufour.io/)
 * [Ramón Cutanda](https://github.com/rcutanda): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:rcutanda)
 * [romibi](https://github.com/romibi): [contributions](https://github.com/FreshRSS/FreshRSS/commits/dev?author=romibi)
+* [sirideain](https://github.com/sirideain): [contributions](https://github.com/FreshRSS/FreshRSS/commits/dev?author=sirideain)
 * [subic](https://github.com/subic): [contributions](https://github.com/FreshRSS/documentation/commits?author=subic)
 * [Tets42](https://github.com/Tets42): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Tets42)
 * [Thomas Citharel](https://github.com/tcitworld): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:tomgue), [Web](https://www.tcit.fr/)
 * [tomgue](https://github.com/tomgue): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=tomgue)
+* [Twilek-de](https://github.com/Twilek-de): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Twilek-de)
 * [Uncovery](https://github.com/uncovery): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:uncovery)
 * [Wanabo](https://github.com/Wanabo): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Wanabo)

+ 1 - 1
Docker/entrypoint.sh

@@ -6,7 +6,7 @@ chown -R :www-data .
 chmod -R g+r . && chmod -R g+w ./data/
 
 if [ -n "$CRON_MIN" ]; then
-	sed -r -i "/FreshRSS/s/^[^ ]+ /$CRON_MIN /" /var/spool/cron/crontabs/root
+	sed -r -i "\#FreshRSS#s#^[^ ]+ #$CRON_MIN #" /var/spool/cron/crontabs/root
 fi
 
 exec "$@"

+ 4 - 9
README.fr.md

@@ -4,12 +4,12 @@
 * [English version](README.md)
 
 # 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 permet de gérer plusieurs utilisateurs, et dispose d’un mode de lecture anonyme.
-Il supporte [PubSubHubbub](https://github.com/pubsubhubbub/PubSubHubbub) pour des notifications instantanées depuis les sites compatibles.
+Il supporte les étiquettes personnalisées, et [PubSubHubbub](https://github.com/pubsubhubbub/PubSubHubbub) pour des notifications instantanées depuis les sites compatibles.
 Il y a une API pour les clients (mobiles), ainsi qu’une [interface en ligne de commande](cli/README.md).
 Enfin, il permet l’ajout d’[extensions](#extensions) pour encore plus de personnalisation.
 
@@ -70,13 +70,7 @@ Plus d’informations sur l’installation et la configuration serveur peuvent 
 sudo apt-get install apache2
 sudo a2enmod headers expires rewrite ssl	#Modules Apache
 
-# Pour Ubuntu <= 15.10, Debian <= 8 Jessie
-sudo apt-get install php5 php5-curl php5-gmp php5-intl php5-json php5-sqlite
-sudo apt-get install libapache2-mod-php5	#Pour Apache
-sudo apt-get install mysql-server mysql-client php5-mysql	#Base de données MySQL optionnelle
-sudo apt-get install postgresql php5-pgsql	#Base de données PostgreSQL optionnelle
-
-# Pour Ubuntu >= 16.04, Debian >= 9 Stretch
+# Exemple pour Ubuntu >= 16.04, Debian >= 9 Stretch
 sudo apt install php php-curl php-gmp php-intl php-mbstring php-sqlite3 php-xml php-zip
 sudo apt install libapache2-mod-php	#Pour Apache
 sudo apt install mysql-server mysql-client php-mysql	#Base de données MySQL optionnelle
@@ -187,6 +181,7 @@ Tout client supportant une API de type Fever ; Sélection :
 * iOS
 	* [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303) (Propriétaire)
 	* [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153) (Propriétaire)
+	* [Reeder-3](https://itunes.apple.com/app/reeder-3/id697846300) (Propriétaire)
 * MacOS
 	* [Readkit](https://itunes.apple.com/app/readkit/id588726889) (Propriétaire)
 

+ 4 - 9
README.md

@@ -4,12 +4,12 @@
 * [Version française](README.fr.md)
 
 # 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 a multi-user application with an anonymous reading mode.
-It supports [PubSubHubbub](https://github.com/pubsubhubbub/PubSubHubbub) for instant notifications from compatible Web sites.
+It supports custom tags, and [PubSubHubbub](https://github.com/pubsubhubbub/PubSubHubbub) for instant notifications from compatible Web sites.
 There is an API for (mobile) clients, and a [Command-Line Interface](cli/README.md).
 Finally, it supports [extensions](#extensions) for further tuning.
 
@@ -70,13 +70,7 @@ More information about installation and server configuration can be found in [ou
 sudo apt-get install apache2
 sudo a2enmod headers expires rewrite ssl	#Apache modules
 
-# For Ubuntu <= 15.10, Debian <= 8 Jessie
-sudo apt-get install php5 php5-curl php5-gmp php5-intl php5-json php5-sqlite
-sudo apt-get install libapache2-mod-php5	#For Apache
-sudo apt-get install mysql-server mysql-client php5-mysql	#Optional MySQL database
-sudo apt-get install postgresql php5-pgsql	#Optional PostgreSQL database
-
-# For Ubuntu >= 16.04, Debian >= 9 Stretch
+# Example for Ubuntu >= 16.04, Debian >= 9 Stretch
 sudo apt install php php-curl php-gmp php-intl php-mbstring php-sqlite3 php-xml php-zip
 sudo apt install libapache2-mod-php	#For Apache
 sudo apt install mysql-server mysql-client php-mysql	#Optional MySQL database
@@ -187,6 +181,7 @@ Supported clients are:
 * iOS
 	* [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303) (Closed source)
 	* [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153) (Closed source)
+	* [Reeder-3](https://itunes.apple.com/app/reeder-3/id697846300) (Closed source)
 * MacOS
 	* [Readkit](https://itunes.apple.com/app/readkit/id588726889) (Closed source)
 

+ 4 - 4
app/Controllers/categoryController.php

@@ -16,7 +16,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 			Minz_Error::error(403);
 		}
 
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$catDAO->checkDefault();
 	}
 
@@ -27,7 +27,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	 *   - new-category
 	 */
 	public function createAction() {
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 		$limits = FreshRSS_Context::$system_conf->limits;
@@ -75,7 +75,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	 *   - name
 	 */
 	public function updateAction() {
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 		if (Minz_Request::isPost()) {
@@ -116,7 +116,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	 */
 	public function deleteAction() {
 		$feedDAO = FreshRSS_Factory::createFeedDao();
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 		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.
 	 */
 	public function queriesAction() {
-		$category_dao = new FreshRSS_CategoryDAO();
+		$category_dao = FreshRSS_Factory::createCategoryDao();
 		$feed_dao = FreshRSS_Factory::createFeedDao();
+		$tag_dao = FreshRSS_Factory::createTagDao();
 		if (Minz_Request::isPost()) {
 			$params = Minz_Request::param('queries', array());
 
@@ -277,16 +278,17 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 * lean data.
 	 */
 	public function addQueryAction() {
-		$category_dao = new FreshRSS_CategoryDAO();
+		$category_dao = FreshRSS_Factory::createCategoryDao();
 		$feed_dao = FreshRSS_Factory::createFeedDao();
+		$tag_dao = FreshRSS_Factory::createTagDao();
 		$queries = array();
 		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['url'] = Minz_Url::display(array('params' => $params));
 		$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->save();

+ 17 - 0
app/Controllers/entryController.php

@@ -53,6 +53,7 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 		}
 
 		$params = array();
+		$this->view->tags = array();
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		if ($id === false) {
@@ -81,6 +82,12 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 				case 'a':
 					$entryDAO->markReadEntries($id_max, false, 0, FreshRSS_Context::$search, FreshRSS_Context::$state, $is_read);
 					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') {
@@ -91,6 +98,13 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 			}
 		} else {
 			$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) {
@@ -193,6 +207,9 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 
 		$feedDAO->updateCachedValues();
 
+		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
+		$databaseDAO->minorDbMaintenance();
+
 		invalidateHttpCache();
 		Minz_Request::good(_t('feedback.sub.purge_completed', $nb_total), array(
 			'c' => 'configure',

+ 9 - 3
app/Controllers/feedController.php

@@ -43,7 +43,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		FreshRSS_UserDAO::touch();
 		@set_time_limit(300);
 
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 
 		$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.
 			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->feed = new FreshRSS_Feed($url);
 			try {
@@ -481,6 +481,9 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			if ($entryDAO->inTransaction()) {
 				$entryDAO->commit();
 			}
+
+			$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
+			$databaseDAO->minorDbMaintenance();
 		}
 		return array($updated_feeds, reset($feeds), $nb_new_articles);
 	}
@@ -511,6 +514,9 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			$entryDAO->commitNewEntries();
 			$feedDAO->updateCachedValues();
 			$entryDAO->commit();
+
+			$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
+			$databaseDAO->minorDbMaintenance();
 		} else {
 			list($updated_feeds, $feed, $nb_new_articles) = self::actualizeFeed($id, $url, $force, null, false, $noCommit);
 		}
@@ -556,7 +562,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 		FreshRSS_UserDAO::touch();
 
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		if ($cat_id > 0) {
 			$cat = $catDAO->searchById($cat_id);
 			$cat_id = $cat == null ? 0 : $cat->id();

+ 24 - 11
app/Controllers/indexController.php

@@ -32,7 +32,29 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 			Minz_Error::error(404);
 		}
 
-		$this->view->callbackBeforeContent = function($view) {
+		$this->view->categories = FreshRSS_Context::$categories;
+
+		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . Minz_View::title();
+		$title = FreshRSS_Context::$name;
+		if (FreshRSS_Context::$get_unread > 0) {
+			$title = '(' . FreshRSS_Context::$get_unread . ') ' . $title;
+		}
+		Minz_View::prependTitle($title . ' · ');
+
+		$this->view->callbackBeforeFeeds = function ($view) {
+			try {
+				$tagDAO = FreshRSS_Factory::createTagDao();
+				$view->tags = $tagDAO->listTags(true);
+				$view->nbUnreadTags = 0;
+				foreach ($view->tags as $tag) {
+					$view->nbUnreadTags += $tag->nbUnread();
+				}
+			} catch (Exception $e) {
+				Minz_Log::notice($e->getMessage());
+			}
+		};
+
+		$this->view->callbackBeforeEntries = function ($view) {
 			try {
 				FreshRSS_Context::$number++;	//+1 for pagination
 				$entries = FreshRSS_index_Controller::listEntriesByContext();
@@ -60,15 +82,6 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 				Minz_Log::notice($e->getMessage());
 				Minz_Error::error(404);
 			}
-
-			$view->categories = FreshRSS_Context::$categories;
-
-			$view->rss_title = FreshRSS_Context::$name . ' | ' . Minz_View::title();
-			$title = FreshRSS_Context::$name;
-			if (FreshRSS_Context::$get_unread > 0) {
-				$title = '(' . FreshRSS_Context::$get_unread . ') ' . $title;
-			}
-			Minz_View::prependTitle($title . ' · ');
 		};
 	}
 
@@ -158,7 +171,7 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 	 */
 	private function updateContext() {
 		if (empty(FreshRSS_Context::$categories)) {
-			$catDAO = new FreshRSS_CategoryDAO();
+			$catDAO = FreshRSS_Factory::createCategoryDao();
 			FreshRSS_Context::$categories = $catDAO->listCategories();
 		}
 

+ 4 - 1
app/Controllers/javascriptController.php

@@ -7,14 +7,17 @@ class FreshRSS_javascript_Controller extends Minz_ActionController {
 
 	public function actualizeAction() {
 		header('Content-Type: application/json; charset=UTF-8');
+		Minz_Session::_param('actualize_feeds', false);
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$user_conf->ttl_default);
 	}
 
 	public function nbUnreadsPerFeedAction() {
 		header('Content-Type: application/json; charset=UTF-8');
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$this->view->categories = $catDAO->listCategories(true, false);
+		$tagDAO = FreshRSS_Factory::createTagDao();
+		$this->view->tags = $tagDAO->listTags(true);
 	}
 
 	//For Web-form login

+ 1 - 1
app/Controllers/statsController.php

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

+ 2 - 1
app/Controllers/subscriptionController.php

@@ -14,7 +14,7 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 			Minz_Error::error(403);
 		}
 
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 
 		$catDAO->checkDefault();
@@ -98,6 +98,7 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 
 			$feed->_attributes('mark_updated_article_unread', Minz_Request::paramTernary('mark_updated_article_unread'));
 			$feed->_attributes('read_upon_reception', Minz_Request::paramTernary('read_upon_reception'));
+			$feed->_attributes('clear_cache', Minz_Request::paramTernary('clear_cache'));
 
 			if (FreshRSS_Auth::hasAccess('admin')) {
 				$feed->_attributes('ssl_verify', Minz_Request::paramTernary('ssl_verify'));

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

+ 7 - 1
app/Controllers/updateController.php

@@ -32,7 +32,13 @@ class FreshRSS_update_Controller extends Minz_ActionController {
 		$output = array();
 		$return = 1;
 		try {
-			exec('git pull --ff-only', $output, $return);
+			exec('git clean -f -d -f', $output, $return);
+			if ($return == 0) {
+				exec('git pull --ff-only', $output, $return);
+			} else {
+				$line = is_array($output) ? implode('; ', $output) : '' . $output;
+				Minz_Log::warning('git clean warning:' . $line);
+			}
 		} catch (Exception $e) {
 			Minz_Log::warning('git pull error:' . $e->getMessage());
 		}

+ 17 - 6
app/Controllers/userController.php

@@ -38,7 +38,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 	 * The username is also used as folder name, file name, and part of SQL table name.
 	 * '_' is a reserved internal username.
 	 */
-	const USERNAME_PATTERN = '[0-9a-zA-Z_]{2,38}|[0-9a-zA-Z]';
+	const USERNAME_PATTERN = '[0-9a-zA-Z_][0-9a-zA-Z_.]{1,38}|[0-9a-zA-Z]';
 
 	public static function checkUsername($username) {
 		return preg_match('/^' . self::USERNAME_PATTERN . '$/', $username) === 1;
@@ -91,6 +91,10 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 	}
 
 	public function updateAction() {
+		if (!FreshRSS_Auth::hasAccess('admin')) {
+			Minz_Error::error(403);
+		}
+
 		if (Minz_Request::isPost()) {
 			$passwordPlain = Minz_Request::param('newPasswordPlain', '', true);
 			Minz_Request::_param('newPasswordPlain');	//Discard plain-text password ASAP
@@ -104,8 +108,12 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 			));
 
 			if ($ok) {
-				Minz_Request::good(_t('feedback.user.updated', $username),
-				                   array('c' => 'user', 'a' => 'manage'));
+				$isSelfUpdate = Minz_Session::param('currentUser', '_') === $username;
+				if ($passwordPlain == '' || !$isSelfUpdate) {
+					Minz_Request::good(_t('feedback.user.updated', $username), array('c' => 'user', 'a' => 'manage'));
+				} else {
+					Minz_Request::good(_t('feedback.profile.updated'), array('c' => 'index', 'a' => 'index'));
+				}
 			} else {
 				Minz_Request::bad(_t('feedback.user.updated.error', $username),
 				                  array('c' => 'user', 'a' => 'manage'));
@@ -138,8 +146,11 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 			Minz_Session::_param('passwordHash', FreshRSS_Context::$user_conf->passwordHash);
 
 			if ($ok) {
-				Minz_Request::good(_t('feedback.profile.updated'),
-				                   array('c' => 'user', 'a' => 'profile'));
+				if ($passwordPlain == '') {
+					Minz_Request::good(_t('feedback.profile.updated'), array('c' => 'user', 'a' => 'profile'));
+				} else {
+					Minz_Request::good(_t('feedback.profile.updated'), array('c' => 'index', 'a' => 'index'));
+				}
 			} else {
 				Minz_Request::bad(_t('feedback.profile.error'),
 				                  array('c' => 'user', 'a' => 'profile'));
@@ -166,7 +177,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 			$entryDAO = FreshRSS_Factory::createEntryDao($this->view->current_user);
 			$this->view->nb_articles = $entryDAO->count();
 
-			$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
+			$databaseDAO = FreshRSS_Factory::createDatabaseDAO($this->view->current_user);
 			$this->view->size_user = $databaseDAO->size();
 		}
 	}

+ 0 - 1
app/FreshRSS.php

@@ -90,7 +90,6 @@ class FreshRSS extends Minz_FrontController {
 				}
 				$filetime = @filemtime(PUBLIC_PATH . '/themes/' . $theme_id . '/' . $filename);
 				$url = '/themes/' . $theme_id . '/' . $filename . '?' . $filetime;
-				header('Link: <' . Minz_Url::display($url, '', 'root') . '>;rel=preload', false);	//HTTP2
 				Minz_View::prependStyle(Minz_Url::display($url));
 			}
 		}

+ 3 - 3
app/Models/Category.php

@@ -30,7 +30,7 @@ class FreshRSS_Category extends Minz_Model {
 	}
 	public function nbFeed() {
 		if ($this->nbFeed < 0) {
-			$catDAO = new FreshRSS_CategoryDAO();
+			$catDAO = FreshRSS_Factory::createCategoryDao();
 			$this->nbFeed = $catDAO->countFeed($this->id());
 		}
 
@@ -38,7 +38,7 @@ class FreshRSS_Category extends Minz_Model {
 	}
 	public function nbNotRead() {
 		if ($this->nbNotRead < 0) {
-			$catDAO = new FreshRSS_CategoryDAO();
+			$catDAO = FreshRSS_Factory::createCategoryDao();
 			$this->nbNotRead = $catDAO->countNotRead($this->id());
 		}
 
@@ -68,7 +68,7 @@ class FreshRSS_Category extends Minz_Model {
 		$this->id = $value;
 	}
 	public function _name($value) {
-		$this->name = mb_strcut(trim($value), 0, 255, 'UTF-8');
+		$this->name = trim($value);
 	}
 	public function _feeds($values) {
 		if (!is_array($values)) {

+ 12 - 5
app/Models/CategoryDAO.php

@@ -5,11 +5,15 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	const DEFAULTCATEGORYID = 1;
 
 	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);
 
+		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
 		$values = array(
-			mb_strcut($valuesTmp['name'], 0, 255, 'UTF-8'),
+			$valuesTmp['name'],
+			$valuesTmp['name'],
 		);
 
 		if ($stm && $stm->execute($values)) {
@@ -35,12 +39,15 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	}
 
 	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);
 
+		$valuesTmp['name'] = mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8');
 		$values = array(
 			$valuesTmp['name'],
-			$id
+			$id,
+			$valuesTmp['name'],
 		);
 
 		if ($stm && $stm->execute($values)) {
@@ -151,7 +158,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 			$sql = 'INSERT INTO `' . $this->prefix . 'category`(id, name) VALUES(?, ?)';
 			if (parent::$sharedDbType === 'pgsql') {
 				//Force call to nextval()
-				$sql .= " RETURNING nextval('" . $this->prefix . "category_id_seq');";
+				$sql .= ' RETURNING nextval(\'"' . $this->prefix . 'category_id_seq"\');';
 			}
 			$stm = $this->bd->prepare($sql);
 

+ 39 - 7
app/Models/Context.php

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

+ 66 - 23
app/Models/DatabaseDAO.php

@@ -4,6 +4,16 @@
  * This class is used to test database is well-constructed.
  */
 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() {
 		$sql = 'SHOW TABLES';
 		$stm = $this->bd->prepare($sql);
@@ -14,6 +24,9 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 			$this->prefix . 'category' => false,
 			$this->prefix . 'feed' => false,
 			$this->prefix . 'entry' => false,
+			$this->prefix . 'entrytmp' => false,
+			$this->prefix . 'tag' => false,
+			$this->prefix . 'entrytag' => false,
 		);
 		foreach ($res as $value) {
 			$tables[array_pop($value)] = true;
@@ -43,7 +56,7 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 	public function categoryIsCorrect() {
 		return $this->checkTable('category', array(
-			'id', 'name'
+			'id', 'name',
 		));
 	}
 
@@ -51,14 +64,33 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 		return $this->checkTable('feed', array(
 			'id', 'url', 'category', 'name', 'website', 'description', 'lastUpdate',
 			'priority', 'pathEntries', 'httpAuth', 'error', 'keep_history', 'ttl', 'attributes',
-			'cache_nbEntries', 'cache_nbUnreads'
+			'cache_nbEntries', 'cache_nbUnreads',
 		));
 	}
 
 	public function entryIsCorrect() {
 		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,39 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 	public function optimize() {
 		$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();
+			}
 		}
+		return $ok;
+	}
 
-		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'category`';	//MySQL
-		$stm = $this->bd->prepare($sql);
-		$ok &= $stm != false;
-		if ($stm) {
-			$ok &= $stm->execute();
+	public function ensureCaseInsensitiveGuids() {
+		$ok = true;
+		$db = FreshRSS_Context::$system_conf->db;
+		if ($db['type'] === 'mysql') {
+			include_once(APP_PATH . '/SQL/install.sql.mysql.php');
+			if (defined('SQL_UPDATE_GUID_LATIN1_BIN')) {	//FreshRSS 1.12
+				try {
+					$sql = sprintf(SQL_UPDATE_GUID_LATIN1_BIN, $this->prefix);
+					$stm = $this->bd->prepare($sql);
+					$ok = $stm->execute();
+				} catch (Exception $e) {
+					$ok = false;
+					Minz_Log::error('FreshRSS_DatabaseDAO::ensureCaseInsensitiveGuids error: ' . $e->getMessage());
+				}
+			}
 		}
-
 		return $ok;
 	}
+
+	public function minorDbMaintenance() {
+		$this->ensureCaseInsensitiveGuids();
+	}
 }

+ 17 - 21
app/Models/DatabaseDAOPGSQL.php

@@ -3,7 +3,12 @@
 /**
  * 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() {
 		$db = FreshRSS_Context::$system_conf->db;
 		$dbowner = $db['user'];
@@ -17,6 +22,9 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO {
 			$this->prefix . 'category' => false,
 			$this->prefix . 'feed' => false,
 			$this->prefix . 'entry' => false,
+			$this->prefix . 'entrytmp' => false,
+			$this->prefix . 'tag' => false,
+			$this->prefix . 'entrytag' => false,
 		);
 		foreach ($res as $value) {
 			$tables[array_pop($value)] = true;
@@ -53,28 +61,16 @@ class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO {
 
 	public function optimize() {
 		$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;
 	}
 }

+ 12 - 2
app/Models/DatabaseDAOSQLite.php

@@ -14,6 +14,9 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 			'category' => false,
 			'feed' => false,
 			'entry' => false,
+			'entrytmp' => false,
+			'tag' => false,
+			'entrytag' => false,
 		);
 		foreach ($res as $value) {
 			$tables[$value['name']] = true;
@@ -32,8 +35,15 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 
 	public function entryIsCorrect() {
 		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',
 		));
 	}
 

+ 32 - 21
app/Models/Entry.php

@@ -10,7 +10,7 @@ class FreshRSS_Entry extends Minz_Model {
 	private $id = 0;
 	private $guid;
 	private $title;
-	private $author;
+	private $authors;
 	private $content;
 	private $link;
 	private $date;
@@ -21,18 +21,17 @@ class FreshRSS_Entry extends Minz_Model {
 	private $feed;
 	private $tags;
 
-	public function __construct($feedId = '', $guid = '', $title = '', $author = '', $content = '',
+	public function __construct($feedId = '', $guid = '', $title = '', $authors = '', $content = '',
 	                            $link = '', $pubdate = 0, $is_read = false, $is_favorite = false, $tags = '') {
 		$this->_title($title);
-		$this->_author($author);
+		$this->_authors($authors);
 		$this->_content($content);
 		$this->_link($link);
 		$this->_date($pubdate);
 		$this->_isRead($is_read);
 		$this->_isFavorite($is_favorite);
 		$this->_feedId($feedId);
-		$tags = mb_strcut($tags, 0, 1023, 'UTF-8');
-		$this->_tags(preg_split('/[\s#]/', $tags));
+		$this->_tags($tags);
 		$this->_guid($guid);
 	}
 
@@ -46,7 +45,15 @@ class FreshRSS_Entry extends Minz_Model {
 		return $this->title;
 	}
 	public function author() {
-		return $this->author === null ? '' : $this->author;
+		//Deprecated
+		return $this->authors(true);
+	}
+	public function authors($asString = false) {
+		if ($asString) {
+			return $this->authors == null ? '' : ';' . implode('; ', $this->authors);
+		} else {
+			return $this->authors;
+		}
 	}
 	public function content() {
 		return $this->content;
@@ -86,9 +93,9 @@ class FreshRSS_Entry extends Minz_Model {
 			return $this->feedId;
 		}
 	}
-	public function tags($inString = false) {
-		if ($inString) {
-			return empty($this->tags) ? '' : '#' . implode(' #', $this->tags);
+	public function tags($asString = false) {
+		if ($asString) {
+			return $this->tags == null ? '' : '#' . implode(' #', $this->tags);
 		} else {
 			return $this->tags;
 		}
@@ -97,7 +104,7 @@ class FreshRSS_Entry extends Minz_Model {
 	public function hash() {
 		if ($this->hash === null) {
 			//Do not include $this->date because it may be automatically generated when lacking
-			$this->hash = md5($this->link . $this->title . $this->author . $this->content . $this->tags(true));
+			$this->hash = md5($this->link . $this->title . $this->authors(true) . $this->content . $this->tags(true));
 		}
 		return $this->hash;
 	}
@@ -124,11 +131,22 @@ class FreshRSS_Entry extends Minz_Model {
 	}
 	public function _title($value) {
 		$this->hash = null;
-		$this->title = mb_strcut($value, 0, 255, 'UTF-8');
+		$this->title = $value;
 	}
 	public function _author($value) {
+		//Deprecated
+		$this->_authors($value);
+	}
+	public function _authors($value) {
 		$this->hash = null;
-		$this->author = mb_strcut($value, 0, 255, 'UTF-8');
+		if (!is_array($value)) {
+			if (strpos($value, ';') !== false) {
+				$value = preg_split('/\s*[;]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
+			} else {
+				$value = preg_split('/\s*[,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
+			}
+		}
+		$this->authors = $value;
 	}
 	public function _content($value) {
 		$this->hash = null;
@@ -162,15 +180,8 @@ class FreshRSS_Entry extends Minz_Model {
 	public function _tags($value) {
 		$this->hash = null;
 		if (!is_array($value)) {
-			$value = array($value);
-		}
-
-		foreach ($value as $key => $t) {
-			if (!$t) {
-				unset($value[$key]);
-			}
+			$value = preg_split('/\s*[#,]\s*/', $value, -1, PREG_SPLIT_NO_EMPTY);
 		}
-
 		$this->tags = $value;
 	}
 
@@ -287,7 +298,7 @@ class FreshRSS_Entry extends Minz_Model {
 			'id' => $this->id(),
 			'guid' => $this->guid(),
 			'title' => $this->title(),
-			'author' => $this->author(),
+			'author' => $this->authors(true),
 			'content' => $this->content(),
 			'link' => $this->link(),
 			'date' => $this->date(true),

+ 86 - 17
app/Models/EntryDAO.php

@@ -18,6 +18,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		return 'hex(' . $x . ')';
 	}
 
+	//TODO: Move the database auto-updates to DatabaseDAO
 	protected function addColumn($name) {
 		Minz_Log::warning('FreshRSS_EntryDAO::addColumn: ' . $name);
 		$hasTransaction = false;
@@ -56,6 +57,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 	private $triedUpdateToUtf8mb4 = false;
 
+	//TODO: Move the database auto-updates to DatabaseDAO
 	protected function updateToUtf8mb4() {
 		if ($this->triedUpdateToUtf8mb4) {
 			return false;
@@ -65,7 +67,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		if ($db['type'] === 'mysql') {
 			include_once(APP_PATH . '/SQL/install.sql.mysql.php');
 			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();
 				if ($hadTransaction) {
 					$this->bd->commit();
@@ -88,6 +90,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		return false;
 	}
 
+	//TODO: Move the database auto-updates to DatabaseDAO
 	protected function createEntryTempTable() {
 		$ok = false;
 		$hadTransaction = $this->bd->inTransaction();
@@ -120,22 +123,28 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		return $ok;
 	}
 
+	//TODO: Move the database auto-updates to DatabaseDAO
 	protected function autoUpdateDb($errorInfo) {
 		if (isset($errorInfo[0])) {
-			if ($errorInfo[0] === '42S22') {	//ER_BAD_FIELD_ERROR
+			if ($errorInfo[0] === FreshRSS_DatabaseDAO::ER_BAD_FIELD_ERROR) {
 				//autoAddColumn
 				foreach (array('lastSeen', 'hash') as $column) {
 					if (stripos($errorInfo[2], $column) !== false) {
 						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 ($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;
@@ -560,11 +569,52 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		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
 		$sql = 'DELETE FROM `' . $this->prefix . 'entry` '
 		     . 'WHERE id_feed=:id_feed AND id<=:id_max '
 		     . '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 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'
 		$stm = $this->bd->prepare($sql);
 
@@ -770,24 +820,31 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		$joinFeed = false;
 		$values = array();
 		switch ($type) {
-		case 'a':
+		case 'a':	//All PRIORITY_MAIN_STREAM
 			$where .= 'f.priority > ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
 			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 .= 'AND e.is_favorite=1 ';
 			break;
-		case 'c':
+		case 'c':	//Category
 			$where .= 'f.priority >= ' . FreshRSS_Feed::PRIORITY_NORMAL . ' ';
 			$where .= 'AND f.category=? ';
 			$values[] = intval($id);
 			break;
-		case 'f':
+		case 'f':	//Feed
 			$where .= 'e.id_feed=? ';
 			$values[] = intval($id);
 			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;
 		default:
 			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);
 
 		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 '
+			. ($type === 't' || $type === 'T' ? 'INNER JOIN `' . $this->prefix . 'entrytag` et ON et.id_entry = e.id ' : '')
 			. 'WHERE ' . $where
 			. $search
 			. 'ORDER BY e.id ' . $order
@@ -817,13 +877,22 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 			. 'ORDER BY e0.id ' . $order;
 
 		$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) {
 		$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') {
@@ -923,7 +992,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		$stm = $this->bd->prepare($sql);
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
-		return $res[0];
+		return isset($res[0]) ? $res[0] : 0;
 	}
 	public function countNotRead($minPriority = null) {
 		$sql = 'SELECT COUNT(e.id) AS count FROM `' . $this->prefix . 'entry` e';

+ 7 - 2
app/Models/EntryDAOPGSQL.php

@@ -12,8 +12,13 @@ class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
 
 	protected function autoUpdateDb($errorInfo) {
 		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;

+ 47 - 1
app/Models/EntryDAOSQLite.php

@@ -7,10 +7,17 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	}
 
 	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'")) {
 			$showCreate = $tableInfo->fetchColumn();
 			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'")) {
@@ -228,4 +235,43 @@ DROP TABLE IF EXISTS `tmp`;
 		}
 		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 {
 
+	public static function createCategoryDao($username = null) {
+		return new FreshRSS_CategoryDAO($username);
+	}
+
 	public static function createFeedDao($username = null) {
 		$conf = Minz_Configuration::get('system');
 		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) {
 		$conf = Minz_Configuration::get('system');
 		switch ($conf->db['type']) {

+ 27 - 7
app/Models/Feed.php

@@ -286,6 +286,10 @@ class FreshRSS_Feed extends Minz_Model {
 				if (!$loadDetails) {	//Only activates auto-discovery when adding a new feed
 					$feed->set_autodiscovery_level(SIMPLEPIE_LOCATOR_NONE);
 				}
+				if ($this->attributes('clear_cache')) {
+					// Do not use `$simplePie->enable_cache(false);` as it would prevent caching in multiuser context
+					$this->clearCache();
+				}
 				Minz_ExtensionManager::callHook('simplepie_before_init', $feed, $this);
 				$mtime = $feed->init();
 
@@ -345,13 +349,21 @@ class FreshRSS_Feed extends Minz_Model {
 			$link = $item->get_permalink();
 			$date = @strtotime($item->get_date());
 
-			// gestion des tags (catégorie == tag)
-			$tags_tmp = $item->get_categories();
+			//Tag processing (tag == category)
+			$categories = $item->get_categories();
 			$tags = array();
-			if ($tags_tmp !== null) {
-				foreach ($tags_tmp as $tag) {
-					$tags[] = html_only_entity_decode($tag->get_label());
+			if (is_array($categories)) {
+				foreach ($categories as $category) {
+					$text = html_only_entity_decode($category->get_label());
+					//Some feeds use a single category with comma-separated tags
+					$labels = explode(',', $text);
+					if (is_array($labels)) {
+						foreach ($labels as $label) {
+							$tags[] = trim($label);
+						}
+					}
 				}
+				$tags = array_unique($tags);
 			}
 
 			$content = html_only_entity_decode($item->get_content());
@@ -412,7 +424,7 @@ class FreshRSS_Feed extends Minz_Model {
 			$author_names = '';
 			if (is_array($authors)) {
 				foreach ($authors as $author) {
-					$author_names .= html_only_entity_decode(strip_tags($author->name == '' ? $author->email : $author->name)) . ', ';
+					$author_names .= html_only_entity_decode(strip_tags($author->name == '' ? $author->email : $author->name)) . '; ';
 				}
 			}
 			$author_names = substr($author_names, 0, -2);
@@ -457,8 +469,16 @@ class FreshRSS_Feed extends Minz_Model {
 		$this->entries = $entries;
 	}
 
+	protected function cacheFilename() {
+		return CACHE_PATH . '/' . md5($this->url) . '.spc';
+	}
+
+	public function clearCache() {
+		return @unlink($this->cacheFilename());
+	}
+
 	public function cacheModifiedTime() {
-		return @filemtime(CACHE_PATH . '/' . md5($this->url) . '.spc');
+		return @filemtime($this->cacheFilename());
 	}
 
 	public function lock() {

+ 6 - 3
app/Models/FeedDAO.php

@@ -17,7 +17,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 	protected function autoUpdateDb($errorInfo) {
 		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) {
 					if (stripos($errorInfo[2], $column) !== false) {
 						return $this->addColumn($column);
@@ -55,7 +55,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		$values = array(
 			substr($valuesTmp['url'], 0, 511),
 			$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),
 			mb_strcut($valuesTmp['description'], 0, 1023, 'UTF-8'),
 			$valuesTmp['lastUpdate'],
@@ -109,6 +109,9 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	}
 
 	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'])) {
 			$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
 		}
@@ -180,7 +183,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	}
 
 	public function changeCategory($idOldCat, $idNewCat) {
-		$catDAO = new FreshRSS_CategoryDAO();
+		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$newCat = $catDAO->searchById($idNewCat);
 		if (!$newCat) {
 			$newCat = $catDAO->getDefault();

+ 29 - 8
app/Models/Search.php

@@ -40,7 +40,7 @@ class FreshRSS_Search {
 		$input = $this->parseNotIntitleSearch($input);
 		$input = $this->parseNotAuthorSearch($input);
 		$input = $this->parseNotInurlSearch($input);
-		$input = $this->parseNotTagsSeach($input);
+		$input = $this->parseNotTagsSearch($input);
 
 		$input = $this->parsePubdateSearch($input);
 		$input = $this->parseDateSearch($input);
@@ -48,7 +48,7 @@ class FreshRSS_Search {
 		$input = $this->parseIntitleSearch($input);
 		$input = $this->parseAuthorSearch($input);
 		$input = $this->parseInurlSearch($input);
-		$input = $this->parseTagsSeach($input);
+		$input = $this->parseTagsSearch($input);
 
 		$input = $this->parseNotSearch($input);
 		$input = $this->parseSearch($input);
@@ -117,6 +117,17 @@ class FreshRSS_Search {
 		return is_array($anArray) ? array_filter($anArray, function($value) { return $value !== ''; }) : array();
 	}
 
+	private static function decodeSpaces($value) {
+		if (is_array($value)) {
+			for ($i = count($value) - 1; $i >= 0; $i--) {
+				$value[$i] = self::decodeSpaces($value[$i]);
+			}
+		} else {
+			$value = trim(str_replace('+', ' ', $value));
+		}
+		return $value;
+	}
+
 	/**
 	 * Parse the search string to find intitle keyword and the search related
 	 * to it.
@@ -130,11 +141,12 @@ class FreshRSS_Search {
 			$this->intitle = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
-		if (preg_match_all('/\bintitle:(?P<search>\w*)/', $input, $matches)) {
+		if (preg_match_all('/\bintitle:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$this->intitle = array_merge($this->intitle ? $this->intitle : array(), $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->intitle = self::removeEmptyValues($this->intitle);
+		$this->intitle = self::decodeSpaces($this->intitle);
 		return $input;
 	}
 
@@ -143,11 +155,12 @@ class FreshRSS_Search {
 			$this->not_intitle = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
-		if (preg_match_all('/[!-]intitle:(?P<search>\w*)/', $input, $matches)) {
+		if (preg_match_all('/[!-]intitle:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$this->not_intitle = array_merge($this->not_intitle ? $this->not_intitle : array(), $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->not_intitle = self::removeEmptyValues($this->not_intitle);
+		$this->not_intitle = self::decodeSpaces($this->not_intitle);
 		return $input;
 	}
 
@@ -166,11 +179,12 @@ class FreshRSS_Search {
 			$this->author = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
-		if (preg_match_all('/\bauthor:(?P<search>\w*)/', $input, $matches)) {
+		if (preg_match_all('/\bauthor:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$this->author = array_merge($this->author ? $this->author : array(), $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->author = self::removeEmptyValues($this->author);
+		$this->author = self::decodeSpaces($this->author);
 		return $input;
 	}
 
@@ -179,11 +193,12 @@ class FreshRSS_Search {
 			$this->not_author = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
-		if (preg_match_all('/[!-]author:(?P<search>\w*)/', $input, $matches)) {
+		if (preg_match_all('/[!-]author:(?P<search>[^\s"]*)/', $input, $matches)) {
 			$this->not_author = array_merge($this->not_author ? $this->not_author : array(), $matches['search']);
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->not_author = self::removeEmptyValues($this->not_author);
+		$this->not_author = self::decodeSpaces($this->not_author);
 		return $input;
 	}
 
@@ -201,6 +216,7 @@ class FreshRSS_Search {
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->inurl = self::removeEmptyValues($this->inurl);
+		$this->inurl = self::decodeSpaces($this->inurl);
 		return $input;
 	}
 
@@ -210,6 +226,7 @@ class FreshRSS_Search {
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->not_inurl = self::removeEmptyValues($this->not_inurl);
+		$this->not_inurl = self::decodeSpaces($this->not_inurl);
 		return $input;
 	}
 
@@ -259,21 +276,23 @@ class FreshRSS_Search {
 	 * @param string $input
 	 * @return string
 	 */
-	private function parseTagsSeach($input) {
+	private function parseTagsSearch($input) {
 		if (preg_match_all('/#(?P<search>[^\s]+)/', $input, $matches)) {
 			$this->tags = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->tags = self::removeEmptyValues($this->tags);
+		$this->tags = self::decodeSpaces($this->tags);
 		return $input;
 	}
 
-	private function parseNotTagsSeach($input) {
+	private function parseNotTagsSearch($input) {
 		if (preg_match_all('/[!-]#(?P<search>[^\s]+)/', $input, $matches)) {
 			$this->not_tags = $matches['search'];
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->not_tags = self::removeEmptyValues($this->not_tags);
+		$this->not_tags = self::decodeSpaces($this->not_tags);
 		return $input;
 	}
 
@@ -303,6 +322,7 @@ class FreshRSS_Search {
 		} else {
 			$this->search = explode(' ', $input);
 		}
+		$this->search = self::decodeSpaces($this->search);
 	}
 
 	private function parseNotSearch($input) {
@@ -322,6 +342,7 @@ class FreshRSS_Search {
 			$input = str_replace($matches[0], '', $input);
 		}
 		$this->not_search = self::removeEmptyValues($this->not_search);
+		$this->not_search = self::decodeSpaces($this->not_search);
 		return $input;
 	}
 

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

+ 315 - 0
app/Models/TagDAO.php

@@ -0,0 +1,315 @@
+<?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 ALTER GUID case sensitivity...');
+			$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
+			$databaseDAO->ensureCaseInsensitiveGuids();
+
+			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;
 	}
 
-	public static function icon($name, $urlOnly = false) {
+	public static function alt($name) {
 		static $alts = array(
 			'add' => '✚',
 			'all' => '☰',
@@ -84,6 +84,7 @@ class FreshRSS_Themes extends Minz_Model {
 			'icon' => '⊚',
 			'import' => '⤓',
 			'key' => '⚿',
+			'label' => '🏷️',
 			'link' => '↗',
 			'login' => '🔒',
 			'logout' => '🔓',
@@ -104,13 +105,18 @@ class FreshRSS_Themes extends Minz_Model {
 			'view-global' => '☷',
 			'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 '';
 		}
 
 		$url = $name . '.svg';
 		$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;
 			$bd_prefix_user = $db['prefix'] . $username . '_';
 			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);
 				$ok = $stm && $stm->execute();
 			} 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)) {
-					$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);
 					foreach ($instructions as $instruction) {
 						$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 $feed_dao;
 	private $category_dao;
+	private $tag_dao;
 
 	/**
 	 * @param array $query
 	 * @param FreshRSS_Searchable $feed_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->feed_dao = $feed_dao;
+		$this->tag_dao = $tag_dao;
 		if (isset($query['get'])) {
 			$this->parseGet($query['get']);
 		}
@@ -88,6 +90,9 @@ class FreshRSS_UserQuery {
 				case 's':
 					$this->parseFavorite();
 					break;
+				case 't':
+					$this->parseTag($matches['id']);
+					break;
 			}
 		}
 	}
@@ -138,6 +143,25 @@ class FreshRSS_UserQuery {
 		$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
 	 */

+ 67 - 41
app/SQL/install.sql.mysql.php

@@ -1,10 +1,10 @@
 <?php
-define('SQL_CREATE_DB', 'CREATE DATABASE IF NOT EXISTS %1$s DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
+define('SQL_CREATE_DB', 'CREATE DATABASE IF NOT EXISTS `%1$s` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
 
 define('SQL_CREATE_TABLES', '
 CREATE TABLE IF NOT EXISTS `%1$scategory` (
 	`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`),
 	UNIQUE KEY (`name`)	-- v0.7
 ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
@@ -12,21 +12,21 @@ ENGINE = INNODB;
 
 CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
-	`url` varchar(511) CHARACTER SET latin1 NOT NULL,
+	`url` VARCHAR(511) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
 	`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 COLLATE latin1_bin,
+	`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,	-- v0.7
 	`ttl` INT NOT NULL DEFAULT 0,	-- v0.7.3
 	`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`),
 	FOREIGN KEY (`category`) REFERENCES `%1$scategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
 	UNIQUE KEY (`url`),	-- v0.7
@@ -37,19 +37,19 @@ CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 ENGINE = INNODB;
 
 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 COLLATE latin1_bin 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 COLLATE latin1_bin NOT NULL,
+	`date` INT(11),	-- Until year 2038
 	`lastSeen` INT(11) DEFAULT 0,	-- v1.1.1, Until year 2038
 	`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
-	`tags` varchar(1023),
+	`tags` VARCHAR(1023),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	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', '
 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 COLLATE latin1_bin NOT NULL,
+	`title` VARCHAR(255) NOT NULL,
+	`author` VARCHAR(255),
+	`content_bin` BLOB,
+	`link` VARCHAR(1023) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+	`date` INT(11),
 	`lastSeen` INT(11) DEFAULT 0,
 	`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,
-	`tags` varchar(1023),
+	`tags` VARCHAR(1023),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	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
 ');
 
+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', '
-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);
 ');
 
-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', '
-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;
-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`;
 
 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`;
 
 ALTER TABLE `%1$sentry` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
@@ -115,3 +136,8 @@ ALTER TABLE `%1$sentry` MODIFY `author` VARCHAR(255) CHARACTER SET utf8mb4 COLLA
 ALTER TABLE `%1$sentry` MODIFY `tags` VARCHAR(1023) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
 OPTIMIZE TABLE `%1$sentry`;
 ');
+
+define('SQL_UPDATE_GUID_LATIN1_BIN', '	-- v1.12
+ALTER TABLE `%1$sentrytmp` MODIFY `guid` VARCHAR(760) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL;
+ALTER TABLE `%1$sentry` MODIFY `guid` VARCHAR(760) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL;
+');

+ 40 - 16
app/SQL/install.sql.pgsql.php

@@ -1,5 +1,5 @@
 <?php
-define('SQL_CREATE_DB', 'CREATE DATABASE %1$s ENCODING \'UTF8\';');
+define('SQL_CREATE_DB', 'CREATE DATABASE "%1$s" ENCODING \'UTF8\';');
 
 global $SQL_CREATE_TABLES;
 $SQL_CREATE_TABLES = array(
@@ -10,16 +10,16 @@ $SQL_CREATE_TABLES = array(
 
 'CREATE TABLE IF NOT EXISTS "%1$sfeed" (
 	"id" SERIAL PRIMARY KEY,
-	"url" varchar(511) UNIQUE NOT NULL,
+	"url" VARCHAR(511) UNIQUE NOT NULL,
 	"category" SMALLINT DEFAULT 0,
 	"name" VARCHAR(255) NOT NULL,
 	"website" VARCHAR(255),
-	"description" text,
+	"description" TEXT,
 	"lastUpdate" INT DEFAULT 0,
 	"priority" SMALLINT NOT NULL DEFAULT 10,
 	"pathEntries" VARCHAR(511) DEFAULT NULL,
 	"httpAuth" VARCHAR(511) DEFAULT NULL,
-	"error" smallint DEFAULT 0,
+	"error" SMALLINT DEFAULT 0,
 	"keep_history" INT NOT NULL DEFAULT -2,
 	"ttl" INT NOT NULL DEFAULT 0,
 	"attributes" TEXT,	-- v1.11.0
@@ -27,9 +27,9 @@ $SQL_CREATE_TABLES = array(
 	"cache_nbUnreads" INT DEFAULT 0,
 	FOREIGN KEY ("category") REFERENCES "%1$scategory" ("id") ON DELETE SET NULL ON UPDATE CASCADE
 );',
-'CREATE INDEX %1$sname_index ON "%1$sfeed" ("name");',
-'CREATE INDEX %1$spriority_index ON "%1$sfeed" ("priority");',
-'CREATE INDEX %1$skeep_history_index ON "%1$sfeed" ("keep_history");',
+'CREATE INDEX "%1$sname_index" ON "%1$sfeed" ("name");',
+'CREATE INDEX "%1$spriority_index" ON "%1$sfeed" ("priority");',
+'CREATE INDEX "%1$skeep_history_index" ON "%1$sfeed" ("keep_history");',
 
 'CREATE TABLE IF NOT EXISTS "%1$sentry" (
 	"id" BIGINT NOT NULL PRIMARY KEY,
@@ -48,11 +48,14 @@ $SQL_CREATE_TABLES = array(
 	FOREIGN KEY ("id_feed") REFERENCES "%1$sfeed" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE ("id_feed","guid")
 );',
-'CREATE INDEX %1$sis_favorite_index ON "%1$sentry" ("is_favorite");',
-'CREATE INDEX %1$sis_read_index ON "%1$sentry" ("is_read");',
-'CREATE INDEX %1$sentry_lastSeen_index ON "%1$sentry" ("lastSeen");',
+'CREATE INDEX "%1$sis_favorite_index" ON "%1$sentry" ("is_favorite");',
+'CREATE INDEX "%1$sis_read_index" ON "%1$sentry" ("is_read");',
+'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;
@@ -74,15 +77,36 @@ $SQL_CREATE_TABLE_ENTRYTMP = array(
 	FOREIGN KEY ("id_feed") REFERENCES "%1$sfeed" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE ("id_feed","guid")
 );',
-'CREATE INDEX %1$sentrytmp_date_index ON "%1$sentrytmp" ("date");',
+'CREATE INDEX "%1$sentrytmp_date_index" ON "%1$sentrytmp" ("date");',
 
-'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;
 $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$sentrytag", "%1$stag", "%1$sentrytmp", "%1$sentry", "%1$sfeed", "%1$scategory"');

+ 55 - 69
app/SQL/install.sql.sqlite.php

@@ -3,27 +3,27 @@ global $SQL_CREATE_TABLES;
 $SQL_CREATE_TABLES = array(
 'CREATE TABLE IF NOT EXISTS `category` (
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
-	`name` varchar(255) NOT NULL,
+	`name` VARCHAR(255) NOT NULL,
 	UNIQUE (`name`)
 );',
 
 'CREATE TABLE IF NOT EXISTS `feed` (
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
-	`url` varchar(511) NOT NULL,
+	`url` VARCHAR(511) NOT NULL,
 	`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,
 	`ttl` INT NOT NULL DEFAULT 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,
 	UNIQUE (`url`)
 );',
@@ -32,19 +32,19 @@ $SQL_CREATE_TABLES = array(
 'CREATE INDEX IF NOT EXISTS feed_keep_history_index ON `feed`(`keep_history`);',
 
 '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
 	`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,
-	`tags` varchar(1023),
+	`tags` VARCHAR(1023),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE (`id_feed`,`guid`)
@@ -59,19 +59,19 @@ $SQL_CREATE_TABLES = array(
 global $SQL_CREATE_TABLE_ENTRYTMP;
 $SQL_CREATE_TABLE_ENTRYTMP = array(
 '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,
 	`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,
-	`tags` varchar(1023),
+	`tags` VARCHAR(1023),
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	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
 );
 
+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;
 $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 `entrytag`, `tag`, `entrytmp`, `entry`, `feed`, `category`');

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'Upozornění!',
 		'blank_to_disable' => 'Zakázat - ponechte prázdné',
-		'by_author' => 'Od <em>%s</em>',
+		'by_author' => 'Od:',
 		'by_default' => 'Výchozí',
 		'damn' => 'Sakra!',
 		'default_category' => 'Nezařazeno',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Hlášení chyb',
 		'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>.',
-		'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>',
 		'license' => 'Licence',
 		'project_website' => 'Stránka projektu',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Zobrazit oblíbené',
 		'stats' => 'Statistika',
 		'subscription' => 'Správa subskripcí',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Zobrazovat nepřečtené',
 	),
 	'share' => 'Sdílet',
 	'tag' => array(
-		'related' => 'Související tagy',
+		'related' => 'Související tagy',	//TODO
 	),
 );

+ 1 - 0
app/i18n/cz/sub.php

@@ -27,6 +27,7 @@ return array(
 			'password' => 'Heslo',
 			'username' => 'Přihlašovací jméno',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => 'Stáhne zkrácenou verzi RSS kanálů (pozor, náročnější na čas!)',
 		'css_path' => 'Původní CSS soubor článku z webových stránek',
 		'description' => 'Popis',

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

@@ -67,8 +67,8 @@ return array(
 			'ok' => 'Sie haben die JSON-Erweiterung.',
 		),
 		'mbstring' => array(
-			'nok' => 'Cannot find the recommended library mbstring for Unicode.',	//TODO
-			'ok' => 'You have the recommended library mbstring for Unicode.',	//TODO
+			'nok' => 'Ihnen fehlt die mbstring-Bibliothek für Unicode.',	//TODO
+			'ok' => 'Sie haben die empfohlene mbstring-Bliothek für Unicode.',	//TODO
 		),
 		'minz' => array(
 			'nok' => 'Ihnen fehlt das Minz-Framework.',

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

@@ -19,7 +19,7 @@ return array(
 			'bottom_line' => 'Fußzeile',
 			'entry' => 'Artikel-Symbole',
 			'publication_date' => 'Datum der Veröffentlichung',
-			'related_tags' => 'Verwandte Tags',
+			'related_tags' => 'Verwandte Tags',	//TODO
 			'sharing' => 'Teilen',
 			'top_line' => 'Kopfzeile',
 		),
@@ -102,7 +102,7 @@ return array(
 		'read' => array(
 			'article_open_on_website' => 'wenn der Artikel auf der Original-Webseite geöffnet wird',
 			'article_viewed' => 'wenn der Artikel angesehen wird',
-			'scroll' => 'beim Blättern',
+			'scroll' => 'beim Scrollen bzw. Überspringen',
 			'upon_reception' => 'beim Empfang des Artikels',
 			'when' => 'Artikel als gelesen markieren…',
 		),

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

@@ -53,8 +53,8 @@ return array(
 	'sub' => array(
 		'actualize' => 'Aktualisieren',
 		'articles' => array(
-			'marked_read' => 'The selected articles have been marked as read.',	//TODO
-			'marked_unread' => 'The articles have been marked as unread.',	//TODO
+			'marked_read' => 'Die ausgewählten Artikel wurden als gelesen markiert.',
+			'marked_unread' => 'Die ausgewählten Artikel wurden als ungelesen markiert.',
 		),
 		'category' => array(
 			'created' => 'Die Kategorie %s ist erstellt worden.',

+ 2 - 1
app/i18n/de/gen.php

@@ -167,6 +167,7 @@ return array(
 		'g+' => 'Google+',
 		'gnusocial' => 'GNU social',
 		'jdh' => 'Journal du hacker',
+		'Known' => 'Known-Seite (https://withknown.com)',
 		'linkedin' => 'LinkedIn',
 		'mastodon' => 'Mastodon',
 		'movim' => 'Movim',
@@ -180,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'Achtung!',
 		'blank_to_disable' => 'Zum Deaktivieren frei lassen',
-		'by_author' => 'Von <em>%s</em>',
+		'by_author' => 'Von:',
 		'by_default' => 'standardmäßig',
 		'damn' => 'Verdammt!',
 		'default_category' => 'Unkategorisiert',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Fehlerberichte',
 		'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.',
-		'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>',
 		'license' => 'Lizenz',
 		'project_website' => 'Projekt-Webseite',
@@ -40,7 +40,7 @@ return array(
 		'mark_all_read' => 'Alle als gelesen markieren',
 		'mark_cat_read' => 'Kategorie als gelesen markieren',
 		'mark_feed_read' => 'Feed als gelesen markieren',
-		'mark_selection_unread' => 'Mark selection as unread',	//TODO
+		'mark_selection_unread' => 'Auswahl als ungelesen markieren',
 		'newer_first' => 'Neuere zuerst',
 		'non-starred' => 'Alle außer Favoriten zeigen',
 		'normal_view' => 'Normale Ansicht',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Nur Favoriten zeigen',
 		'stats' => 'Statistiken',
 		'subscription' => 'Abonnementverwaltung',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Nur ungelesene zeigen',
 	),
 	'share' => 'Teilen',
 	'tag' => array(
-		'related' => 'Verwandte Tags',
+		'related' => 'Verwandte Tags',	//TODO
 	),
 );

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

@@ -69,8 +69,8 @@ return array(
 			'ok' => 'Sie haben eine empfohlene Bibliothek um JSON zu parsen.',
 		),
 		'mbstring' => array(
-			'nok' => 'Cannot find the recommended library mbstring for Unicode.',	//TODO
-			'ok' => 'You have the recommended library mbstring for Unicode.',	//TODO
+			'nok' => 'Es fehlt die empfohlene mbstring-Bibliothek für Unicode.',
+			'ok' => 'Sie haben die empfohlene mbstring-Bibliothek für Unicode.',
 		),
 		'minz' => array(
 			'nok' => 'Ihnen fehlt das Minz-Framework.',

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP-Passwort',
 			'username' => 'HTTP-Nutzername',
 		),
+		'clear_cache' => 'Nicht cachen (für defekte Feeds)',
 		'css_help' => 'Ruft gekürzte RSS-Feeds ab (Achtung, benötigt mehr Zeit!)',
 		'css_path' => 'Pfad zur CSS-Datei des Artikels auf der Original-Webseite',
 		'description' => 'Beschreibung',
@@ -44,10 +45,10 @@ return array(
 			'main_stream' => 'In Haupt-Feeds zeigen',
 			'normal' => 'Zeige in eigener Kategorie',
 		),
-		'ssl_verify' => 'Verify SSL security',	//TODO
+		'ssl_verify' => 'Überprüfe SSL Sicherheit',
 		'stats' => 'Statistiken',
 		'think_to_add' => 'Sie können Feeds hinzufügen.',
-		'timeout' => 'Timeout in seconds',	//TODO
+		'timeout' => 'Zeitlimit in Sekunden',
 		'title' => 'Titel',
 		'title_add' => 'Einen RSS-Feed hinzufügen',
 		'ttl' => 'Aktualisiere automatisch nicht öfter als',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'Warning!',
 		'blank_to_disable' => 'Leave blank to disable',
-		'by_author' => 'By <em>%s</em>',
+		'by_author' => 'By:',
 		'by_default' => 'By default',
 		'damn' => 'Blast!',
 		'default_category' => 'Uncategorized',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Bugs reports',
 		'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.',
-		'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>',
 		'license' => 'License',
 		'project_website' => 'Project website',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Show favourites',
 		'stats' => 'Statistics',
 		'subscription' => 'Subscriptions management',
+		'tags' => 'My labels',
 		'unread' => 'Show unread',
 	),
 	'share' => 'Share',
 	'tag' => array(
-		'related' => 'Related tags',
+		'related' => 'Article tags',	//TODO
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP password',
 			'username' => 'HTTP username',
 		),
+		'clear_cache' => 'Always clear cache',
 		'css_help' => 'Retrieves truncated RSS feeds (caution, requires more time!)',
 		'css_path' => 'Articles CSS path on original website',
 		'description' => 'Description',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => '¡Aviso!',
 		'blank_to_disable' => 'Deja en blanco para desactivar',
-		'by_author' => 'Por <em>%s</em>',
+		'by_author' => 'Por:',
 		'by_default' => 'Por defecto',
 		'damn' => '¡Córcholis!',
 		'default_category' => 'Sin categorizar',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Informe de fallos',
 		'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>.',
-		'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>',
 		'license' => 'Licencia',
 		'project_website' => 'Web del proyecto',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Mostrar solo los favoritos',
 		'stats' => 'Estadísticas',
 		'subscription' => 'Administración de suscripciones',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Mostar solo no leídos',
 	),
 	'share' => 'Compartir',
 	'tag' => array(
-		'related' => 'Etiquetas relacionadas',
+		'related' => 'Etiquetas relacionadas',	//TODO
 	),
 );

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

@@ -22,6 +22,7 @@ return array(
 			'password' => 'Contraseña HTTP',
 			'username' => 'Nombre de usuario HTTP',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => 'Recibir fuentes RSS truncadas (aviso, ¡necesita más tiempo!)',
 		'css_path' => 'Ruta a la CSS de los artículos en la web original',
 		'description' => 'Descripción',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'Attention !',
 		'blank_to_disable' => 'Laissez vide pour désactiver',
-		'by_author' => 'Par <em>%s</em>',
+		'by_author' => 'Par :',
 		'by_default' => 'Par défaut',
 		'damn' => 'Arf !',
 		'default_category' => 'Sans catégorie',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Rapports de bugs',
 		'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.',
-		'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>',
 		'license' => 'Licence',
 		'project_website' => 'Site du projet',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Afficher les favoris',
 		'stats' => 'Statistiques',
 		'subscription' => 'Gestion des abonnements',
+		'tags' => 'Mes étiquettes',
 		'unread' => 'Afficher les non-lus',
 	),
 	'share' => 'Partager',
 	'tag' => array(
-		'related' => 'Tags associés',
+		'related' => 'Tags de l’article',
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'Mot de passe HTTP',
 			'username' => 'Identifiant HTTP',
 		),
+		'clear_cache' => 'Toujours vider le cache',
 		'css_help' => 'Permet de récupérer les flux tronqués (attention, demande plus de temps !)',
 		'css_path' => 'Sélecteur CSS des articles sur le site d’origine',
 		'description' => 'Description',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'זהירות!',
 		'blank_to_disable' => 'יש להשאיר ריק על מנת לנטרל',
-		'by_author' => 'מאת <em>%s</em>',
+		'by_author' => 'מאת :',
 		'by_default' => 'ברירת מחדל',
 		'damn' => 'הו לא!',
 		'default_category' => 'ללא קטגוריה',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'דיווח באגים',
 		'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.',
-		'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>',
 		'license' => 'רישיון',
 		'project_website' => 'אתר',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'הצגת מועדפים בלבד',
 		'stats' => 'סטטיסטיקות',
 		'subscription' => 'ניהול הרשמות',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'הצגת מאמרים שלא נקראו בלבד',
 	),
 	'share' => 'שיתוף',
 	'tag' => array(
-		'related' => 'תגיות קשורות',
+		'related' => 'תגיות קשורות',	//TODO
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP סיסמה',
 			'username' => 'HTTP שם משתמש',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => 'קבלת הזנות RSS קטומות  (זהירות, לוקח זמן רב יותר!)',
 		'css_path' => 'נתיב הCSS של המאמר באתר המקורי',
 		'description' => 'תיאור',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'Attenzione!',
 		'blank_to_disable' => 'Lascia vuoto per disabilitare',
-		'by_author' => 'di <em>%s</em>',
+		'by_author' => 'di:',
 		'by_default' => 'predefinito',
 		'damn' => 'Ops!',
 		'default_category' => 'Senza categoria',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Bugs',
 		'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.',
-		'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>',
 		'license' => 'Licenza',
 		'project_website' => 'Sito del progetto',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Mostra solo preferiti',
 		'stats' => 'Statistiche',
 		'subscription' => 'Gestione sottoscrizioni',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Mostra solo non letti',
 	),
 	'share' => 'Condividi',
 	'tag' => array(
-		'related' => 'Tags correlati',
+		'related' => 'Tags correlati',	//TODO
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP password',
 			'username' => 'HTTP username',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => 'In caso di RSS feeds troncati (attenzione, richiede molto tempo!)',
 		'css_path' => 'Percorso del foglio di stile CSS del sito di origine',
 		'description' => 'Descrizione',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => '경고!',
 		'blank_to_disable' => '빈 칸으로 두면 비활성화',
-		'by_author' => 'By <em>%s</em>',
+		'by_author' => 'By:',
 		'by_default' => '기본값',
 		'damn' => '이런!',
 		'default_category' => '분류 없음',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => '버그 제보하기',
 		'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>에 기반하고 있습니다.',
-		'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>',
 		'license' => '라이센스',
 		'project_website' => '프로젝트 웹사이트',
@@ -53,10 +53,11 @@ return array(
 		'starred' => '즐겨찾기만 표시',
 		'stats' => '통계',
 		'subscription' => '구독 관리',
+		'tags' => 'My labels',	//TODO
 		'unread' => '읽지 않은 글만 표시',
 	),
 	'share' => '공유',
 	'tag' => array(
-		'related' => '관련 태그',
+		'related' => '관련 태그',	//TODO
 	),
 );

+ 1 - 0
app/i18n/kr/sub.php

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP 암호',
 			'username' => 'HTTP 사용자 이름',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => '글의 일부가 포함된 RSS 피드를 가져옵니다 (주의, 시간이 좀 더 걸립니다!)',
 		'css_path' => '웹사이트 상의 글 본문에 해당하는 CSS 경로',
 		'description' => '설명',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'Attentie!',
 		'blank_to_disable' => 'Laat leeg om uit te zetten',
-		'by_author' => 'Door <em>%s</em>',
+		'by_author' => 'Door:',
 		'by_default' => 'Door standaard',
 		'damn' => 'Potverdorie!',
 		'default_category' => 'Niet ingedeeld',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Rapporteer fouten',
 		'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>.',
-		'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>',
 		'license' => 'License',
 		'project_website' => 'Project website',
@@ -57,6 +57,6 @@ return array(
 	),
 	'share' => 'Delen',
 	'tag' => array(
-		'related' => 'Verwante labels',
+		'related' => 'Verwante labels',	//TODO
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP wachtwoord',
 			'username' => 'HTTP gebruikers naam',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => 'Haalt verstoorde RSS feeds op (attentie, heeft meer tijd nodig!)',
 		'css_path' => 'Artikelen CSS pad op originele website',
 		'description' => 'Omschrijving',

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

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

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

@@ -180,7 +180,7 @@ return array(
 	'short' => array(
 		'attention' => 'Atencão!',
 		'blank_to_disable' => 'Deixe em branco para desativar',
-		'by_author' => 'Por <em>%s</em>',
+		'by_author' => 'Por:',
 		'by_default' => 'Por padrão',
 		'damn' => 'Buumm!',
 		'default_category' => 'Sem categoria',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Reportar Bugs',
 		'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.',
-		'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>',
 		'license' => 'licença',
 		'project_website' => 'Site do projeto',
@@ -57,6 +57,6 @@ return array(
 	),
 	'share' => 'Compartilhar',
 	'tag' => array(
-		'related' => 'Tags relacionadas',
+		'related' => 'Tags relacionadas',	//TODO
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'Senha HTTP',
 			'username' => 'Usuário HTTP',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => 'Retorna RSS feeds truncados (atenção, requer mais tempo!)',
 		'css_path' => 'Caminho do CSS do artigo no site original',
 		'description' => 'Descrição',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'Warning!',
 		'blank_to_disable' => 'Leave blank to disable',
-		'by_author' => 'By <em>%s</em>',
+		'by_author' => 'By:',
 		'by_default' => 'By default',
 		'damn' => 'Damn!',
 		'default_category' => 'Uncategorized',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Bugs reports',
 		'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.',
-		'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>',
 		'license' => 'License',
 		'project_website' => 'Project website',
@@ -57,6 +57,6 @@ return array(
 	),
 	'share' => 'Share',
 	'tag' => array(
-		'related' => 'Related tags',
+		'related' => 'Article tags',	//TODO
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP password',// TODO
 			'username' => 'HTTP username',// TODO
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => 'Retrieves truncated RSS feeds (caution, requires more time!)',// TODO
 		'css_path' => 'Articles CSS path on original website',// TODO
 		'description' => 'Description',// TODO

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => 'Tehlike!',
 		'blank_to_disable' => 'Devredışı bırakmak için boş bırakın',
-		'by_author' => '<em>%s</em> tarafından',
+		'by_author' => 'Tarafından:',
 		'by_default' => 'Öntanımlı',
 		'damn' => 'Hay aksi!',
 		'default_category' => 'Kategorisiz',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Hata raporu',
 		'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.',
-		'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>',
 		'license' => 'Lisans',
 		'project_website' => 'Proje sayfası',
@@ -53,10 +53,11 @@ return array(
 		'starred' => 'Favorileri göster',
 		'stats' => 'İstatistikler',
 		'subscription' => 'Abonelik yönetimi',
+		'tags' => 'My labels',	//TODO
 		'unread' => 'Okunmamışları göster',
 	),
 	'share' => 'Share',
 	'tag' => array(
-		'related' => 'İlgili etiketler',
+		'related' => 'İlgili etiketler',	//TODO
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP şifre',
 			'username' => 'HTTP kullanıcı adı',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => 'Dikkat, daha çok zaman gerekir!',
 		'css_path' => 'Makaleleri kendi CSS görünümü ile göster',
 		'description' => 'Tanım',

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

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

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

@@ -181,7 +181,7 @@ return array(
 	'short' => array(
 		'attention' => '警告!',
 		'blank_to_disable' => '留空以禁用',
-		'by_author' => '作者 <em>%s</em>',
+		'by_author' => '作者',
 		'by_default' => '默认',
 		'damn' => '错误!',
 		'default_category' => '未分类',

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

@@ -7,7 +7,7 @@ return array(
 		'bugs_reports' => 'Bug 报告',
 		'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>。',
-		'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>',
 		'license' => '授权',
 		'project_website' => '项目网站',
@@ -53,10 +53,11 @@ return array(
 		'starred' => '显示收藏',
 		'stats' => '统计',
 		'subscription' => '订阅管理',
+		'tags' => 'My labels',	//TODO
 		'unread' => '显示未读',
 	),
 	'share' => '分享',
 	'tag' => array(
-		'related' => '相关标签',
+		'related' => '相关标签',	//TODO
 	),
 );

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

@@ -27,6 +27,7 @@ return array(
 			'password' => 'HTTP 密码',
 			'username' => 'HTTP 用户名',
 		),
+		'clear_cache' => 'Always clear cache',	//TODO
 		'css_help' => '用于获取全文(注意,这将耗费更多时间!)',
 		'css_path' => '原文的 CSS 选择器',
 		'description' => '描述',

+ 3 - 3
app/install.php

@@ -343,13 +343,13 @@ function checkDbUser(&$dbOptions) {
 	try {
 		$c = new PDO($str, $dbOptions['user'], $dbOptions['password'], $driver_options);
 		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'));
 			$stm = $c->prepare($sql);
 			$ok = $stm && $stm->execute();
 		} 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);
 			foreach ($instructions as $instruction) {
 				$sql = sprintf($instruction, $dbOptions['prefix_user'], _t('gen.short.default_category'));

+ 35 - 0
app/layout/aside_feed.phtml

@@ -34,6 +34,30 @@
 			</div>
 		</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
 			foreach ($this->categories as $cat) {
 				$feeds = $cat->feeds();
@@ -72,6 +96,17 @@
 	</form>
 </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">
 	<ul class="dropdown-menu">
 		<li class="dropdown-close"><a href="#close">❌</a></li>

+ 13 - 21
app/layout/layout.phtml

@@ -11,10 +11,6 @@
 		<?php echo self::headScript(); ?>
 		<link rel="shortcut icon" id="favicon" type="image/x-icon" sizes="16x16 64x64" href="<?php echo Minz_Url::display('/favicon.ico'); ?>" />
 		<link rel="icon msapplication-TileImage apple-touch-icon" type="image/png" sizes="256x256" href="<?php echo Minz_Url::display('/themes/icons/favicon-256.png'); ?>" />
-		<link rel="prefetch" href="<?php echo FreshRSS_Themes::icon('starred', true); ?>" />
-		<link rel="prefetch" href="<?php echo FreshRSS_Themes::icon('non-starred', true); ?>" />
-		<link rel="prefetch" href="<?php echo FreshRSS_Themes::icon('read', true); ?>" />
-		<link rel="prefetch" href="<?php echo FreshRSS_Themes::icon('unread', true); ?>" />
 		<link rel="apple-touch-icon" href="<?php echo Minz_Url::display('/themes/icons/apple-touch-icon.png'); ?>" />
 		<meta name="apple-mobile-web-app-capable" content="yes" />
 		<meta name="apple-mobile-web-app-status-bar-style" content="black" />
@@ -22,24 +18,11 @@
 		<meta name="msapplication-TileColor" content="#FFF" />
 <?php if (!FreshRSS_Context::$system_conf->allow_referrer) { ?>
 		<meta name="referrer" content="never" />
-<?php
-	}
-	flush();
-	if (isset($this->callbackBeforeContent)) {
-		call_user_func($this->callbackBeforeContent, $this);
-	}
-?>
+<?php } ?>
 		<?php echo self::headTitle(); ?>
 <?php
 	$url_base = Minz_Request::currentRequest();
-	if (FreshRSS_Context::$next_id !== '') {
-		$url_next = $url_base;
-		$url_next['params']['next'] = FreshRSS_Context::$next_id;
-		$url_next['params']['ajax'] = 1;
-?>
-		<link id="prefetch" rel="next prefetch" href="<?php echo Minz_Url::display($url_next); ?>" />
-<?php
-	} if (isset($this->rss_title)) {
+	if (isset($this->rss_title)) {
 		$url_rss = $url_base;
 		$url_rss['a'] = 'rss';
 		if (FreshRSS_Context::$user_conf->since_hours_posts_per_rss) {
@@ -54,10 +37,19 @@
 <?php } ?>
 	</head>
 	<body class="<?php echo Minz_Request::actionName(); ?>">
-<?php $this->partial('header'); ?>
+<?php
+	flush();
+	$this->partial('header');
+?>
 
 <div id="global">
-	<?php $this->render(); ?>
+	<?php
+		flush();
+		if (isset($this->callbackBeforeFeeds)) {
+			call_user_func($this->callbackBeforeFeeds, $this);
+		}
+		$this->render();
+	?>
 </div>
 
 <?php

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

@@ -39,7 +39,7 @@
 								<?php } ?>
 							</div>
 							<div class="properties">
-								<div><?php echo sprintf('%s — %s', $theme['name'], _t('gen.short.by_author', $theme['author'])); ?></div>
+								<div><?php echo sprintf('%s — %s %s', $theme['name'], _t('gen.short.by_author'), $theme['author']); ?></div>
 								<div><?php echo $theme['description'] ?></div>
 								<div class="page-number"><?php echo sprintf('%d/%d', $i, $slides) ?></div>
 							</div>
@@ -79,8 +79,8 @@
 						<th> </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><?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.sharing'); ?></th>
 						<th><?php echo _t('conf.display.icon.publication_date'); ?></th>
 						<th><?php echo _i('link'); ?></th>
 					</tr>
@@ -98,8 +98,8 @@
 						<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_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_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_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>

Некоторые файлы не были показаны из-за большого количества измененных файлов