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

Merge remote-tracking branch 'FreshRSS/master' into dev

Clément 9 жил өмнө
parent
commit
2d097bc855
100 өөрчлөгдсөн 10693 нэмэгдсэн , 3591 устгасан
  1. 0 322
      CHANGELOG
  2. 668 0
      CHANGELOG.md
  3. 57 0
      CONTRIBUTING.md
  4. 37 0
      CREDITS.md
  5. 109 31
      README.fr.md
  6. 115 37
      README.md
  7. 46 182
      app/Controllers/authController.php
  8. 33 27
      app/Controllers/categoryController.php
  9. 117 137
      app/Controllers/configureController.php
  10. 25 26
      app/Controllers/entryController.php
  11. 21 19
      app/Controllers/errorController.php
  12. 215 0
      app/Controllers/extensionController.php
  13. 303 193
      app/Controllers/feedController.php
  14. 320 152
      app/Controllers/importExportController.php
  15. 222 181
      app/Controllers/indexController.php
  16. 14 6
      app/Controllers/javascriptController.php
  17. 43 23
      app/Controllers/statsController.php
  18. 11 16
      app/Controllers/subscriptionController.php
  19. 148 63
      app/Controllers/updateController.php
  20. 163 122
      app/Controllers/userController.php
  21. 14 0
      app/Exceptions/AlreadySubscribedException.php
  22. 3 0
      app/Exceptions/BadUrlException.php
  23. 8 0
      app/Exceptions/ContextException.php
  24. 5 0
      app/Exceptions/DAOException.php
  25. 1 3
      app/Exceptions/EntriesGetterException.php
  26. 2 3
      app/Exceptions/FeedException.php
  27. 14 0
      app/Exceptions/FeedNotAddedException.php
  28. 14 0
      app/Exceptions/ZipException.php
  29. 4 0
      app/Exceptions/ZipMissingException.php
  30. 77 51
      app/FreshRSS.php
  31. 69 40
      app/Models/Auth.php
  32. 7 0
      app/Models/Category.php
  33. 18 11
      app/Models/CategoryDAO.php
  34. 0 336
      app/Models/Configuration.php
  35. 380 0
      app/Models/ConfigurationSetter.php
  36. 308 14
      app/Models/Context.php
  37. 43 0
      app/Models/DatabaseDAOPGSQL.php
  38. 19 4
      app/Models/Entry.php
  39. 492 198
      app/Models/EntryDAO.php
  40. 31 0
      app/Models/EntryDAOPGSQL.php
  41. 88 13
      app/Models/EntryDAOSQLite.php
  42. 31 20
      app/Models/Factory.php
  43. 197 23
      app/Models/Feed.php
  44. 51 27
      app/Models/FeedDAO.php
  45. 2 2
      app/Models/FeedDAOSQLite.php
  46. 7 2
      app/Models/LogDAO.php
  47. 229 0
      app/Models/Search.php
  48. 6 0
      app/Models/Searchable.php
  49. 212 18
      app/Models/Share.php
  50. 71 104
      app/Models/StatsDAO.php
  51. 67 0
      app/Models/StatsDAOPGSQL.php
  52. 4 55
      app/Models/StatsDAOSQLite.php
  53. 0 4
      app/Models/Themes.php
  54. 56 19
      app/Models/UserDAO.php
  55. 226 0
      app/Models/UserQuery.php
  56. 40 9
      app/SQL/install.sql.mysql.php
  57. 63 0
      app/SQL/install.sql.pgsql.php
  58. 24 15
      app/SQL/install.sql.sqlite.php
  59. 57 27
      app/actualize_script.php
  60. 181 0
      app/i18n/cz/admin.php
  61. 173 0
      app/i18n/cz/conf.php
  62. 109 0
      app/i18n/cz/feedback.php
  63. 181 0
      app/i18n/cz/gen.php
  64. 61 0
      app/i18n/cz/index.php
  65. 119 0
      app/i18n/cz/install.php
  66. 62 0
      app/i18n/cz/sub.php
  67. 181 0
      app/i18n/de/admin.php
  68. 173 0
      app/i18n/de/conf.php
  69. 109 0
      app/i18n/de/feedback.php
  70. 182 0
      app/i18n/de/gen.php
  71. 61 0
      app/i18n/de/index.php
  72. 119 0
      app/i18n/de/install.php
  73. 62 0
      app/i18n/de/sub.php
  74. 0 528
      app/i18n/en.php
  75. 181 0
      app/i18n/en/admin.php
  76. 173 0
      app/i18n/en/conf.php
  77. 109 0
      app/i18n/en/feedback.php
  78. 182 0
      app/i18n/en/gen.php
  79. 61 0
      app/i18n/en/index.php
  80. 119 0
      app/i18n/en/install.php
  81. 62 0
      app/i18n/en/sub.php
  82. 0 528
      app/i18n/fr.php
  83. 181 0
      app/i18n/fr/admin.php
  84. 173 0
      app/i18n/fr/conf.php
  85. 109 0
      app/i18n/fr/feedback.php
  86. 182 0
      app/i18n/fr/gen.php
  87. 61 0
      app/i18n/fr/index.php
  88. 119 0
      app/i18n/fr/install.php
  89. 62 0
      app/i18n/fr/sub.php
  90. 181 0
      app/i18n/it/admin.php
  91. 173 0
      app/i18n/it/conf.php
  92. 109 0
      app/i18n/it/feedback.php
  93. 182 0
      app/i18n/it/gen.php
  94. 61 0
      app/i18n/it/index.php
  95. 120 0
      app/i18n/it/install.php
  96. 62 0
      app/i18n/it/sub.php
  97. 186 0
      app/i18n/nl/admin.php
  98. 173 0
      app/i18n/nl/conf.php
  99. 110 0
      app/i18n/nl/feedback.php
  100. 182 0
      app/i18n/nl/gen.php

+ 0 - 322
CHANGELOG

@@ -1,322 +0,0 @@
-# Journal des modifications
-
-## 2014-09-26 FreshRSS 0.8.0 / 0.9.0 (beta)
-
-* UI
-	* New interface for statistics
-	* Fix filter buttons
-	* Number of articles divided by 2 in reading view
-	* Redesign of bigMarkAsRead
-* Features
-	* New automatic update system
-	* New reset auth system
-* Security
-	* "Mark as read" requires POST requests for several articles
-	* Test HTTP REFERER in install.php
-* Configuration
-	* New "Show all articles" / "Show only unread" / "Adjust viewing" option
-	* New notification timeout option
-* Misc.
-	* Improve coding style + comments
-	* Fix SQLite bug "ON DELETE CASCADE"
-	* Improve performance when importing articles
-
-
-## 2014-08-24 FreshRSS 0.7.4
-
-* UI
-	* Hide categories/feeds with unread articles when showing only unread articles
-	* Dynamic favicon showing the number of unread articles
-	* New theme: Screwdriver by Mister aiR
-* Statistics
-	* New page with article repartition
-	* Improvements
-* Security
-	* Basic protection against XSRF (Cross-Site Request Forgery) based on HTTP Referer (POST requests only)
-* API
-	* Compatible with lighttpd
-* Misc.
-	* Changed lazyload implementation
-	* Support of HTML5 notifications for new upcoming articles
-	* Add option to stay logged in
-* Bux fixes in export function, add/remove users, keyboard shortcuts, etc.
-
-
-## 2014-07-21 FreshRSS 0.7.3
-
-* New options
-	* Add system of user queries which are shortcuts to filter the view
-	* New TTL option to limit the frequency at which feeds are refreshed (by cron or manual refresh button).
-		It is still possible to manually refresh an individual feed at a higher frequency.
-* SQL
-	* Add support for SQLite (beta) in addition to MySQL
-* SimplePie
-	* Complies with HTTP "301 Moved Permanently" responses by automatically updating the URL of feeds that have changed address.
-* Themes
-	* Flat and Dark designs are based on same template file as Origine
-* Statistics
-	* Refactor code
-	* Add an idle feed page
-* Misc
-	* Several bug fixes
-	* Add confirmation option when marking all articles as read
-	* Fix some typo
-
-
-## 2014-06-13 FreshRSS 0.7.2
-
-* API compatible with Google Reader API level 2
-	* FreshRSS can now be used from e.g.:
-		* (Android) News+ https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus.extension.google_reader
-		* (Android) EasyRSS https://github.com/Alkarex/EasyRSS
-* Basic support for audio and video podcasts
-* Searching
-	* New search filters date: and pubdate: accepting ISO 8601 date intervals such as `date:2013-2014` or `pubdate:P1W`
-	* Possibility to combine search filters, e.g. `date:2014-05 intitle:FreshRSS intitle:Open great reader #Internet`
-* Change nav menu with more buttons instead of dropdown menus and add some filters
-* New system of import / export
-	* Support OPML, Json (like Google Reader) and Zip archives
-	* Can export and import articles (specific option for favorites)
-* Refactor "Origine" theme
-	* Some improvements
-	* Based on a template file (other themes will use it too)
-
-
-## 2014-02-19 FreshRSS 0.7.1
-
-* Mise à jour des flux plus rapide grâce à une meilleure utilisation du cache
-	* Utilisation d’une signature MD5 du contenu intéressant pour les flux n’implémentant pas les requêtes conditionnelles
-* Modification des raccourcis
-	* "s" partage directement si un seul moyen de partage
-	* Moyens de partage accessibles par "1", "2", "3", etc.
-	* Premier article : Home ; Dernier article : End
-	* Ajout du déplacement au sein des catégories / flux (via modificateurs shift et alt)
-* UI
-	* Séparation des descriptions des raccourcis par groupes
-	* Revue rapide de la page de connexion
-	* Amélioration de l'affichage des notifications sur mobile
-* Revue du système de rafraîchissement des flux
-	* Meilleure gestion de la file de flux à rafraîchir en JSON
-	* Rafraîchissement uniquement pour les flux non rafraîchis récemment
-	* Possibilité donnée aux anonymes de rafraîchir les flux
-* SimplePie
-	* Mise à jour de la lib
-	* Corrige fuite de mémoire
-	* Meilleure tolérance aux flux invalides
-* Corrections divers
-	* Ne déplie plus l'article lors du clic sur l'icône lien externe
-	* Ne boucle plus à la fin de la navigation dans les articles
-	* Suppression du champ category.color inutile
-	* Corrige bug redirection infinie (Persona)
-	* Amélioration vérification de la requête POST
-	* Ajout d'un verrou lorsqu'une action mark_read ou mark_favorite est en cours
-
-
-## 2014-01-29 FreshRSS 0.7
-
-* Nouveau mode multi-utilisateur
-	* L’utilisateur par défaut (administrateur) peut créer et supprimer d’autres utilisateurs
-	* Nécessite un contrôle d’accès, soit :
-		* par le nouveau mode de connexion par formulaire (nom d’utilisateur + mot de passe)
-			* relativement sûr même sans HTTPS (le mot de passe n’est pas transmis en clair)
-			* requiert JavaScript et PHP 5.3+
-		* par HTTP (par exemple sous Apache en créant un fichier ./p/i/.htaccess et .htpasswd)
-			* le nom d’utilisateur HTTP doit correspondre au nom d’utilisateur FreshRSS
-		* par Mozilla Persona, en renseignant l’adresse courriel des utilisateurs
-* Installateur supportant les mises à jour :
-	* Depuis une v0.6, placer application.ini et Configuration.array.php dans le nouveau répertoire “./data/”
-		(voir réorganisation ci-dessous)
-	* Pour les versions suivantes, juste garder le répertoire “./data/”
-* Rafraîchissement automatique du nombre d’articles non lus toutes les deux minutes (utilise le cache HTTP à bon escient)
-	* Permet aussi de conserver la session valide, surtout dans le cas de Persona
-* Nouvelle page de statistiques (nombres d’articles par jour / catégorie)
-* Importation OPML instantanée et plus tolérante
-* Nouvelle gestion des favicons avec téléchargement en parallèle
-* Nouvelles options
-	* Réorganisation des options
-	* Gestion des utilisateurs
-	* Améliorations partage vers Shaarli, Poche, Diaspora*, Facebook, Twitter, Google+, courriel
-		* Raccourci ‘s’ par défaut
-	* Permet la suppression de tous les articles d’un flux
-	* Option pour marquer les articles comme lus dès la réception
-	* Permet de configurer plus finement le nombre d’articles minimum à conserver par flux
-	* Permet de modifier la description et l’adresse d’un flux RSS ainsi que le site Web associé
-	* Nouveau raccourci pour ouvrir/fermer un article (‘c’ par défaut)
-	* Boutons pour effacer les logs et pour purger les vieux articles
-	* Nouveaux filtres d’affichage : seulement les articles favoris, et seulement les articles lus
-* SQL :
-	* Nouveau moteur de recherche, aussi accessible depuis la vue mobile
-		* Mots clefs de recherche “intitle:”, “inurl:”, “author:”
-	* Les articles sont triés selon la date de leur ajout dans FreshRSS plutôt que la date déclarée (souvent erronée)
-		* Permet de marquer tout comme lu sans affecter les nouveaux articles arrivés en cours de lecture
-		* Permet une pagination efficace
-	* Refactorisation
-		* Les tables sont préfixées avec le nom d’utilisateur afin de permettre le mode multi-utilisateurs
-		* Amélioration des performances
-		* Tolère un beaucoup plus grand nombre d’articles
-		* Compression des données côté MySQL plutôt que côté PHP
-		* Incompatible avec la version 0.6 (nécessite une mise à jour grâce à l’installateur) 
-	* Affichage de la taille de la base de données dans FreshRSS
-	* Correction problème de marquage de tous les favoris comme lus
-* HTML5 :
-	* Support des balises HTML5 audio, video, et éléments associés
-		* Utilisation de preload="none", et réécriture correcte des adresses, aussi en HTTPS
-	* Protection HTML5 des iframe (sandbox="allow-scripts allow-same-origin")
-	* Filtrage des object et embed
-	* Chargement différé HTML5 (postpone="") pour iframe et video
-	* Chargement différé JavaScript pour iframe
-* CSS :
-	* Nouveau thème sombre
-		* Chargement plus robuste des thèmes
-	* Meilleur support des longs titres d’articles sur des écrans étroits
-	* Meilleure accessibilité
-		* FreshRSS fonctionne aussi en mode dégradé sans images (alternatives Unicode) et/ou sans CSS
-	* Diverses améliorations
-* PHP :
-	* Encore plus tolérant pour les flux comportant des erreurs
-	* Mise à jour automatique de l’URL du flux (en base de données) lorsque SimplePie découvre qu’elle a changé
-	* Meilleure gestion des caractères spéciaux dans différents cas
-	* Compatibilité PHP 5.5+ avec OPcache
-	* Amélioration des performances
-	* Chargement automatique des classes
-	* Alternative dans le cas d’absence de librairie JSON
-	* Pour le développement, le cache HTTP peut être désactivé en créant un fichier “./data/no-cache.txt”
-* Réorganisation des fichiers et répertoires, en particulier :
-	* Tous les fichiers utilisateur sont dans “./data/” (y compris “cache”, “favicons”, et “log”)
-	* Déplacement de “./app/configuration/application.ini” vers “./data/config.php”
-		* Meilleure sécurité et compatibilité
-	* Déplacement de “./public/data/Configuration.array.php” vers “./data/*_user.php”
-	* Déplacement de “./public/” vers “./p/”
-		* Déplacement de “./public/index.php” vers “./p/i/index.php” (voir cookie ci-dessous)
-	* Déplacement de “./actualize_script.php” vers “./app/actualize_script.php” (pour une meilleure sécurité)
-		* Pensez à mettre à jour votre Cron !
-* Divers :
-	* Nouvelle politique de cookie de session (témoin de connexion)
-		* Utilise un nom poli “FreshRSS” (évite des problèmes avec certains filtres)
-		* Se limite au répertoire “./FreshRSS/p/i/” pour de meilleures performances HTTP
-			* Les images, CSS, scripts sont servis sans cookie
-		* Utilise “HttpOnly” pour plus de sécurité
-	* Nouvel “agent utilisateur” exposé lors du téléchargement des flux, par exemple :
-		* “FreshRSS/0.7 (Linux; http://freshrss.org) SimplePie/1.3.1”
-	* Script d’actualisation avec plus de messages
-		* Sur la sortie standard, ainsi que dans le log système (syslog)
-	* Affichage du numéro de version dans "À propos"
-
-
-## 2013-11-21 FreshRSS 0.6.1
-
-* Corrige bug chargement du JavaScript
-* Affiche un message d’erreur plus explicite si fichier de configuration inaccessible
-
-
-## 2013-11-17 FreshRSS 0.6
-
-* Nettoyage du code JavaScript + optimisations
-* Utilisation d’adresses relatives
-* Amélioration des performances coté client
-* Mise à jour automatique du nombre d’articles non lus
-* Corrections traductions
-* Mise en cache de FreshRSS
-* Amélioration des retours utilisateur lorsque la configuration n’est pas bonne
-* Actualisation des flux après une importation OPML
-* Meilleure prise en charge des flux RSS invalides
-* Amélioration de la vue globale
-* Possibilité de personnaliser les icônes de lecture
-* Suppression de champs lors de l’installation (base_url et sel)
-* Correction bugs divers
-
-
-## 2013-10-15 FreshRSS 0.5.1
-
-* Correction bug des catégories disparues
-* Correction traduction i18n/fr et i18n/en
-* Suppression de certains appels à la feuille de style fallback.css
-
-
-## 2013-10-12 FreshRSS 0.5.0
-
-* Possibilité d’interdire la lecture anonyme
-* Option pour garder l’historique d’un flux
-* Lors d’un clic sur “Marquer tous les articles comme lus”, FreshRSS peut désormais sauter à la prochaine catégorie / prochain flux avec des articles non lus.
-* Ajout d’un token pour accéder aux flux RSS générés par FreshRSS sans nécessiter de connexion
-* Possibilité de partager vers Facebook, Twitter et Google+
-* Possibilité de changer de thème
-* Le menu de navigation (article précédent / suivant / haut de page) a été ajouté à la vue non mobile
-* La police OpenSans est désormais appliquée
-* Amélioration de la page de configuration
-* Une meilleure sortie pour l’imprimante
-* Quelques retouches du design par défaut
-* Les vidéos ne dépassent plus du cadre de l’écran
-* Nouveau logo
-* Possibilité d’ajouter un préfixe aux tables lors de l’installation
-* Ajout d’un champ en base de données keep_history à la table feed
-* Si possible, création automatique de la base de données si elle n’existe pas lors de l’installation
-* L’utilisation d’UTF-8 est forcée
-* Le marquage automatique au défilement de la page a été amélioré
-* La vue globale a été énormément améliorée et est beaucoup plus utile
-* Amélioration des requêtes SQL
-* Amélioration du JavaScript
-* Correction bugs divers
-
-
-## 2013-07-02 FreshRSS 0.4.0
-
-* Correction bug et ajout notification lors de la phase d’installation
-* Affichage d’erreur si fichier OPML invalide
-* Les tags sont maintenant cliquables pour filtrer dessus
-* Amélioration vue mobile (boutons plus gros et ajout d’une barre de navigation)
-* Possibilité d’ajouter directement un flux dans une catégorie dès son ajout
-* Affichage des flux en erreur (injoignable par exemple) en rouge pour les différencier
-* Possibilité de changer les noms des flux
-* Ajout d’une option (désactivable donc) pour charger les images en lazyload permettant de ne pas charger toutes les images d’un coup
-* Le framework Minz est maintenant directement inclus dans l’archive (plus besoin de passer par ./build.sh)
-* Amélioration des performances pour la récupération des flux tronqués
-* Possibilité d’importer des flux sans catégorie lors de l’import OPML
-* Suppression de “l’API” (qui était de toute façon très basique) et de la fonctionnalité de “notes”
-* Amélioration de la recherche (garde en mémoire si l’on a sélectionné une catégorie) par exemple
-* Modification apparence des balises hr et pre
-* Meilleure vérification des champs de formulaire
-* Remise en place du mode “endless” (permettant de simplement charger les articles qui suivent plutôt que de charger une nouvelle page)
-* Ajout d’une page de visualisation des logs
-* Ajout d’une option pour optimiser la BDD (diminue sa taille)
-* Ajout des vues lecture et globale (assez basique)
-* Les vidéos YouTube ne débordent plus du cadre sur les petits écrans
-* Ajout d’une option pour marquer les articles comme lus lors du défilement (et suppression de celle au chargement de la page)
-
-
-## 2013-05-05 FreshRSS 0.3.0
-
-* Fallback pour les icônes SVG (utilisation de PNG à la place)
-* Fallback pour les propriétés CSS3 (utilisation de préfixes)
-* Affichage des tags associés aux articles
-* Internationalisation de l’application (gestion des langues anglaise et française)
-* Gestion des flux protégés par authentification HTTP
-* Mise en cache des favicons
-* Création d’un logo *temporaire*
-* Affichage des vidéos dans les articles
-* Gestion de la recherche et filtre par tags pleinement fonctionnels
-* Création d’un vrai script CRON permettant de mettre tous les flux à jour
-* Correction bugs divers
-
-
-## 2013-04-17 FreshRSS 0.2.0
-
-* Création d’un installateur
-* Actualisation des flux en Ajax
-* Partage par mail et Shaarli ajouté
-* Export par flux RSS
-* Possibilité de vider une catégorie
-* Possibilité de sélectionner les catégories en vue mobile
-* Les flux peuvent être sortis du flux principal (système de priorité)
-* Amélioration ajout / import / export des flux
-* Amélioration actualisation (meilleure gestion des erreurs)
-* Améliorations CSS
-* Changements dans la base de données
-* Màj de la librairie SimplePie
-* Flux sans auteurs gérés normalement
-* Correction bugs divers
-
-
-## 2013-04-08 FreshRSS 0.1.0
-
-* “Première” version

+ 668 - 0
CHANGELOG.md

@@ -0,0 +1,668 @@
+# Changelog
+
+## 2016-12-26 FreshRSS 1.6.2
+
+* Features
+	* Add git compatibility in Web update system [#1357](https://github.com/FreshRSS/FreshRSS/issues/1357)
+		* Requires that the initial installation is done with git
+	* New option `limits.cookie_duration` in `data/config.php` to set the login cookie duration [#1384](https://github.com/FreshRSS/FreshRSS/issues/1384)
+* SQL
+	* More robust export function in the case of large datasets [#1372](https://github.com/FreshRSS/FreshRSS/issues/1372)
+* CLI
+	* New command `./cli/user-info.php` to get some user information [#1345](https://github.com/FreshRSS/FreshRSS/issues/1345)
+* Bug fixing
+	* Fix bug in estimating last user activity [#1358](https://github.com/FreshRSS/FreshRSS/issues/1358)
+	* PostgreSQL: fix bug when updating cached values [#1360](https://github.com/FreshRSS/FreshRSS/issues/1360)
+	* Fix bug in confirmation before marking as read [#1348](https://github.com/FreshRSS/FreshRSS/issues/1348)
+	* Fix small bugs in installer [#1363](https://github.com/FreshRSS/FreshRSS/pull/1363)
+	* Allow slash in database hostname, when using sockets [#1364](https://github.com/FreshRSS/FreshRSS/issues/1364)
+	* Add curl user-agent to retrieve favicons [#1380](https://github.com/FreshRSS/FreshRSS/issues/1380)
+	* Send login cookie only once [#1398](https://github.com/FreshRSS/FreshRSS/pull/1398)
+	* Add a check for PHP extension fileinfo [#1375](https://github.com/FreshRSS/FreshRSS/issues/1375)
+
+
+## 2016-11-02 FreshRSS 1.6.1
+
+* Bug fixing
+	* Fix regression introduced in 1.6.0 when refreshing articles with *Mark updated articles as unread* [#1349](https://github.com/FreshRSS/FreshRSS/issues/1349)
+
+
+## 2016-10-30 FreshRSS 1.6.0
+
+* CLI
+	* New Command-Line Interface (CLI) [#1095](https://github.com/FreshRSS/FreshRSS/issues/1095)
+		* Install, add/delete users, actualize, import/export. See [CLI documentation](./cli/README.md).
+* API
+	* Support for editing feeds and categories from client applications [#1254](https://github.com/FreshRSS/FreshRSS/issues/1254)
+* Compatibility:
+	* Support for PostgreSQL [#416](https://github.com/FreshRSS/FreshRSS/issues/416)
+	* New client supporting FreshRSS on Linux: FeedReader 2.0+ [#1252](https://github.com/FreshRSS/FreshRSS/issues/1252)
+* Features
+	* Rework the “mark as read during scroll” option, enabled by default for new users [#1258](https://github.com/FreshRSS/FreshRSS/issues/1258), [#1309](https://github.com/FreshRSS/FreshRSS/pull/1309)
+		* Including a *keep unread* function [#1327](https://github.com/FreshRSS/FreshRSS/pull/1327)
+	* In a multi-user context, take better advantage of other users’ refreshes [#1280](https://github.com/FreshRSS/FreshRSS/pull/1280)
+	* Better control of number of entries per page or RSS feed [#1249](https://github.com/FreshRSS/FreshRSS/issues/1249)
+		* Since X hours: `https://freshrss.example/i/?a=rss&hours=3`
+		* Explicit number: `https://freshrss.example/i/?a=rss&nb=10`
+		* Limited by `min_posts_per_rss` and `max_posts_per_rss` in user config
+	* Support custom ports `localhost:3306` for database servers [#1241](https://github.com/FreshRSS/FreshRSS/issues/1241)
+	* Add date to exported files [#1240](https://github.com/FreshRSS/FreshRSS/issues/1240)
+	* Auto-refresh favicons once or twice a month [#1181](https://github.com/FreshRSS/FreshRSS/issues/1181), [#1298](https://github.com/FreshRSS/FreshRSS/issues/1298)
+		* Cron updates will also refresh favicons every 2 weeks [#1306](https://github.com/FreshRSS/FreshRSS/pull/1306)
+* Bug fixing
+	* Correction of bugs related to CSRF tokens introduced in version 1.5.0 [#1253](https://github.com/FreshRSS/FreshRSS/issues/1253), [44f22ab](https://github.com/FreshRSS/FreshRSS/pull/1261/commits/d9bf9b2c6f0b2cc9dec3b638841b7e3040dcf46f)
+	* Fix bug in Global view introduced in version 1.5.0 [#1269](https://github.com/FreshRSS/FreshRSS/pull/1269)
+	* Fix sharing bug [#1289](https://github.com/FreshRSS/FreshRSS/issues/1289)
+	* Fix bug in auto-loading more articles after marking an article as un-read [#1318](https://github.com/FreshRSS/FreshRSS/issues/1318)
+	* Fix bug during import of favourites [#1315](https://github.com/FreshRSS/FreshRSS/pull/1315), [#1312](https://github.com/FreshRSS/FreshRSS/issues/1312)
+	* Fix bug not respecting language option for new users [#1273](https://github.com/FreshRSS/FreshRSS/issues/1273)
+	* Bug in example of URL for FreshRSS RSS output with token [#1274](https://github.com/FreshRSS/FreshRSS/issues/1274)
+* Security
+	* Prevent `<a target="_blank">` attacks with `window.opener` [#1245](https://github.com/FreshRSS/FreshRSS/issues/1245)
+	* Updated gitignore rules to keep user directories during a `git clean -f -d` [#1307](https://github.com/FreshRSS/FreshRSS/pull/1307)
+* Extensions
+	* Allow extensions for default account in anonymous mode [#1288](https://github.com/FreshRSS/FreshRSS/pull/1288)
+	* Trigger a `freshrss:load-more` JavaScript event to help extensions [#1278](https://github.com/FreshRSS/FreshRSS/issues/1278)
+* SQL
+	* Slightly modified several SQL requests (MySQL, SQLite) to simplify support of PostgreSQL [#1195](https://github.com/FreshRSS/FreshRSS/pull/1195)
+	* Increase performances by removing a superfluous category request [#1316](https://github.com/FreshRSS/FreshRSS/pull/1316)
+* I18n
+	* Fix some messages during installation [#1339](https://github.com/FreshRSS/FreshRSS/pull/1339)
+* UI
+	* Fix CSS line-height bug with `<sup>` in dates (English, Russian, Turkish) [#1340](https://github.com/FreshRSS/FreshRSS/pull/1340)
+	* Disable *Mark all as read* before confirmation script is loaded [#1342](https://github.com/FreshRSS/FreshRSS/issues/1342)
+	* Download icon 💾 for podcasts [#1236](https://github.com/FreshRSS/FreshRSS/issues/1236)
+* SimplePie
+	* Fix auto-discovery of RSS feeds in Web pages served as `text/xml` [#1264](https://github.com/FreshRSS/FreshRSS/issues/1264)
+* Misc.
+	* Removed *resource-priorities* attributes (`defer`, `lazyload`), deprecated by W3C [#1222](https://github.com/FreshRSS/FreshRSS/pull/1222)
+
+
+## 2016-08-29 FreshRSS 1.5.0
+
+* Compatibility
+	* Require at least MySQL 5.5.3+ [#1153](https://github.com/FreshRSS/FreshRSS/issues/1153)
+	* Require at least PHP 5.3.3+ [#1183](https://github.com/FreshRSS/FreshRSS/pull/1183)
+		* Restore compatibility with PHP 5.3.3 [#1208](https://github.com/FreshRSS/FreshRSS/issues/1208)
+	* Restore compatibility with Microsoft Internet Explorer 11 / Edge [#772](https://github.com/FreshRSS/FreshRSS/issues/772)
+* Features
+	* Mark a search as read [#608](https://github.com/FreshRSS/FreshRSS/issues/608)
+	* Support for full Unicode such as emoji 💕 in MySQL with utf8mb4 [#1153](https://github.com/FreshRSS/FreshRSS/issues/1153)
+		* FreshRSS will automatically migrate MySQL tables to utf8mb4 the first time it is needed.
+* Security
+	* Remove Mozilla Persona login (the service closes on 2016-11-30) [#1052](https://github.com/FreshRSS/FreshRSS/issues/1052)
+	* Use Referrer Policy `<meta name="referrer" content="never" />` for anonymizing HTTP Referer [#955](https://github.com/FreshRSS/FreshRSS/issues/955)
+	* Implement CSRF tokens for POST security [#570](https://github.com/FreshRSS/FreshRSS/issues/570)
+* Bug fixing
+	* Fixed scroll in log view [#1178](https://github.com/FreshRSS/FreshRSS/issues/1178)
+	* Fixed JavaScript bug when articles were not always marked as read [#1123](https://github.com/FreshRSS/FreshRSS/issues/1123)
+	* Fixed Apache Etag issue that prevented caching [#1199](https://github.com/FreshRSS/FreshRSS/pull/1199)
+	* Fixed OPML import of categories [#1202](https://github.com/FreshRSS/FreshRSS/issues/1202)
+	* Fixed PubSubHubbub callback address bug on some configurations [1229](https://github.com/FreshRSS/FreshRSS/pull/1229)
+* UI
+	* Use sticky category column [#1172](https://github.com/FreshRSS/FreshRSS/pull/1172)
+	* Updated to jQuery 3.1.0 and several JavaScript fixes (e.g. drag & drop) [#1197](https://github.com/FreshRSS/FreshRSS/pull/1197)
+* API
+	* Add API link in FreshRSS profile settings to ease set-up [#1186](https://github.com/FreshRSS/FreshRSS/pull/1186)
+* Misc.
+	* Work-around for SuperFeeder time-outs during PubSubHubbub registration [#1184](https://github.com/FreshRSS/FreshRSS/pull/1184)
+	* JSHint of JavaScript code and better initialisation [#1196](https://github.com/FreshRSS/FreshRSS/pull/1196)
+	* Updated credits, and images in README [#1201](https://github.com/FreshRSS/FreshRSS/issues/1201)
+
+
+## 2016-07-23 FreshRSS 1.4.0
+## 2016-06-12 FreshRSS 1.3.2-beta
+
+* Compatibility
+	* Require at least PHP 5.3+ (drop PHP 5.2) [#1133](https://github.com/FreshRSS/FreshRSS/pull/1133)
+* Features
+	* Support for MySQL 5.7+ (e.g. Ubuntu 16.04 LTS) [#1132](https://github.com/FreshRSS/FreshRSS/pull/1132)
+	* Speed optimization for HTTP/2 [#1133](https://github.com/FreshRSS/FreshRSS/pull/1133)
+	* API support for REDIRECT_* HTTP headers (fcgi) [#1128](https://github.com/FreshRSS/FreshRSS/issues/1128)
+* SimplePie
+	* Support for feeds with invalid whitespace [#1142](https://github.com/FreshRSS/FreshRSS/issues/1142)
+* Bug fixing
+	* Fix bug when adding feeds with passwords [#1137](https://github.com/FreshRSS/FreshRSS/pull/1137)
+	* Fix validator link [#1147](https://github.com/FreshRSS/FreshRSS/pull/1147)
+	* Fix Favicon small bugs [#1135](https://github.com/FreshRSS/FreshRSS/pull/1135)
+* Security
+	* CSP compatibility for homepage [#1120](https://github.com/FreshRSS/FreshRSS/pull/1120)
+* I18n
+	* Draft of Russian [#1085](https://github.com/FreshRSS/FreshRSS/pull/1085)
+* Misc.
+	* Change default feed timeout to 15 seconds [#1146](https://github.com/FreshRSS/FreshRSS/pull/1146)
+	* Updated Wallabag v2 [#1150](https://github.com/FreshRSS/FreshRSS/pull/1150)
+
+
+## 2016-03-11 FreshRSS 1.3.1-beta
+
+* Security
+	* Added CSP `Content-Security-Policy: default-src 'self'; child-src *; frame-src *; img-src * data:; media-src *` [#1075](https://github.com/FreshRSS/FreshRSS/issues/1075), [#1114](https://github.com/FreshRSS/FreshRSS/issues/1114)
+	* Added `X-Content-Type-Options: nosniff` [#1116](https://github.com/FreshRSS/FreshRSS/pull/1116)
+	* Cookie with `Secure` tag when used over HTTPS [#1117](https://github.com/FreshRSS/FreshRSS/pull/1117)
+	* Limit API post input to 1MB [#1118](https://github.com/FreshRSS/FreshRSS/pull/1118)
+* Features
+	* New list of domains for which to force HTTPS (for images, videos, iframes…) defined in `./data/force-https.default.txt` and `./data/force-https.txt` [#1083](https://github.com/FreshRSS/FreshRSS/issues/1083)
+		* In particular useful for privacy and to avoid mixed content errors, e.g. to see YouTube videos when FreshRSS is in HTTPS
+	* Add sharing with “Journal du Hacker” [#1056](https://github.com/FreshRSS/FreshRSS/pull/1056)
+* UI
+	* Updated to jQuery 2.2.1 and changed code for auto-load on scroll [#1050](https://github.com/FreshRSS/FreshRSS/pull/1050), [#1091](https://github.com/FreshRSS/FreshRSS/pull/1091)
+* I18n
+	* Turkish [#1073](https://github.com/FreshRSS/FreshRSS/issues/1073)
+* Bug fixing
+	* Fixed OPML import title bug [#1048](https://github.com/FreshRSS/FreshRSS/issues/1048)
+	* Fixed upgrade bug with SQLite when articles were marked as unread [#1049](https://github.com/FreshRSS/FreshRSS/issues/1049)
+	* Fixed error when deleting feeds from statistics page [#1047](https://github.com/FreshRSS/FreshRSS/issues/1047)
+	* Fixed several small bugs in global and reader view [#1050](https://github.com/FreshRSS/FreshRSS/pull/1050)
+	* Fixed sharing bug with PHP7 [#1072](https://github.com/FreshRSS/FreshRSS/issues/1072)
+	* Fixed fall-back when php-json is not installed [#1092](https://github.com/FreshRSS/FreshRSS/issues/1092)
+* API
+	* Possibility to show only read items [#1035](https://github.com/FreshRSS/FreshRSS/pull/1035)
+* Misc.
+	* Filters `<img />` attributes `srcset` and `sizes` [#1077](https://github.com/FreshRSS/FreshRSS/issues/1077), [#1086](https://github.com/FreshRSS/FreshRSS/pull/1086)
+	* Implement PubSubHubbub unsubscribe responses [#1058](https://github.com/FreshRSS/FreshRSS/issues/1058)
+	* Restored some compatibility with PHP 5.2 [#1055](https://github.com/FreshRSS/FreshRSS/issues/1055)
+	* Check for extension php-xml during install [#1094](https://github.com/FreshRSS/FreshRSS/issues/1094)
+	* Updated the sharing with Movim [#1030](https://github.com/FreshRSS/FreshRSS/pull/1030)
+
+
+## 2015-11-03 FreshRSS 1.2.0 / 1.3.0-beta
+
+* Features
+	* Share with Movim [#992](https://github.com/FreshRSS/FreshRSS/issues/992)
+	* New option to allow robots / search engines [#938](https://github.com/FreshRSS/FreshRSS/issues/938)
+* Security
+	* Invalid logins now return HTTP 403, to be easier to catch (e.g. fail2ban) [#1015](https://github.com/FreshRSS/FreshRSS/issues/1015)
+* UI
+	* Remove "title" field during installation [#858](https://github.com/FreshRSS/FreshRSS/issues/858)
+	* Visual alert on categories containing feeds in error [#984](https://github.com/FreshRSS/FreshRSS/pull/984)
+* I18n
+	* Italian [#1003](https://github.com/FreshRSS/FreshRSS/issues/1003)
+* Misc.
+	* Support reverse proxy [#975](https://github.com/FreshRSS/FreshRSS/issues/975)
+	* Make auto-update server URL alterable [#1019](https://github.com/FreshRSS/FreshRSS/issues/1019)
+
+
+## 2015-09-12 FreshRSS 1.1.3-beta
+
+* UI
+	* Configuration page for global settings such as limits [#958](https://github.com/FreshRSS/FreshRSS/pull/958)
+	* Add feed ID in articles to ease styling [#953](https://github.com/FreshRSS/FreshRSS/issues/953)
+* I18n
+	* Dutch [#949](https://github.com/FreshRSS/FreshRSS/issues/949)
+* Bug fixing
+	* Session cookie bug [#924](https://github.com/FreshRSS/FreshRSS/issues/924)
+	* Better error handling for PubSubHubbub [#939](https://github.com/FreshRSS/FreshRSS/issues/939)
+	* Fix tag search link from articles [#970](https://github.com/FreshRSS/FreshRSS/issues/970)
+	* Fix all queries deleted when deleting a feed or category [#982](https://github.com/FreshRSS/FreshRSS/pull/982)
+
+
+## 2015-07-30 FreshRSS 1.1.2-beta
+
+* Features
+	* Support for PubSubHubbub for instant notifications from compatible Web sites. [#312](https://github.com/FreshRSS/FreshRSS/issues/312)
+	* cURL options to use a proxy for retrieving feeds. [#897](https://github.com/FreshRSS/FreshRSS/issues/897) [#675](https://github.com/FreshRSS/FreshRSS/issues/675)
+	* Allow anonymous users to create an account. [#679](https://github.com/FreshRSS/FreshRSS/issues/679)
+* Security
+	* cURL options to verify or not SSL/TLS certificates (now enabled by default). [#897](https://github.com/FreshRSS/FreshRSS/issues/897) [#502](https://github.com/FreshRSS/FreshRSS/issues/502)
+	* Support for SSL connection to MySQL. [#868](https://github.com/FreshRSS/FreshRSS/issues/868)
+	* Workaround for browsers that have disabled support for `<form autocomplete="off">`. [#880](https://github.com/FreshRSS/FreshRSS/issues/880)
+* UI
+	* Force UTF-8 for responses. [#870](https://github.com/FreshRSS/FreshRSS/issues/870)
+	* Increased pagination limit to 500 articles. [#872](https://github.com/FreshRSS/FreshRSS/issues/872)
+	* Improved UI for installation. [#855](https://github.com/FreshRSS/FreshRSS/issues/855)
+* Misc.
+	* PHP 7 officially supported (~70% speed improvements on early tests). [#889](https://github.com/FreshRSS/FreshRSS/issues/889)
+	* Restore support for PHP 5.2.1+. [#214a5cc](https://github.com/Alkarex/FreshRSS/commit/214a5cc9a4c2b821961bc21f22b4b08e34b5be68) [#894](https://github.com/FreshRSS/FreshRSS/issues/894)
+	* Support for data-src for images of articles retrieved via the full-content module. [#877](https://github.com/FreshRSS/FreshRSS/issues/877)
+	* Add a couple of default feeds for fresh installations. [#886](https://github.com/FreshRSS/FreshRSS/issues/886)
+	* Changed some log visibilities. [#885](https://github.com/FreshRSS/FreshRSS/issues/885)
+	* Fix broken links for extension script / style files. [#862](https://github.com/FreshRSS/FreshRSS/issues/862)
+	* Load default configuration during installation to avoid hard-coded values. [#890](https://github.com/FreshRSS/FreshRSS/issues/890)
+	* Fix non-consistent behaviour in Minz_Request::getBaseUrl() and introduce Minz_Request::guessBaseUrl(). [#906](https://github.com/FreshRSS/FreshRSS/issues/906)
+	* Generate `base_url` during the installation and add a `pubsubhubbub_enabled` configuration key. [#865](https://github.com/FreshRSS/FreshRSS/issues/865)
+	* Load configuration by recursion to overwrite array values. [#923](https://github.com/FreshRSS/FreshRSS/issues/923)
+	* Cast `$limits` configuration values in integer. [#925](https://github.com/FreshRSS/FreshRSS/issues/925)
+	* Don't hide errors in configuration. [#920](https://github.com/FreshRSS/FreshRSS/issues/920)
+
+
+## 2015-05-31 FreshRSS 1.1.1 (beta)
+
+* Features
+	* New option to detect and mark updated articles as unread.
+	* Support for internationalized domain name (IDN).
+	* Improved logic for automatic deletion of old articles.
+* API
+	* Work-around for News+ bug when there is no unread article on the server.
+* UI
+	* New confirmation message when leaving a configuration page without saving the changes.
+* Bug fixing
+	* Corrected bug introduced in previous beta about handling of HTTP 301 (feeds that have changed address)
+	* Corrected bug in FreshRSS RSS feeds.
+* Security
+	* Sanitize HTTP request header `Host`.
+* Misc.
+	* Attempt to better handle encoded article titles.
+
+
+## 2015-01-31 FreshRSS 1.0.0 / 1.1.0 (beta)
+
+* UI
+	* Slider math with Dark theme
+	* Add a message if request failed for mark as read / favourite
+* I18n
+	* Fix some sentences
+	* Add German as a supported language
+	* Add some indications on password format
+* Bug fixing
+	* Some shortcuts was never saved
+	* Global view didn't work if set by default
+	* Minz_Error was badly raised
+	* Feed update failed if nothing had changed (MySQL only)
+	* CRON task failed with multiple users
+	* Tricky bug caused by cookie path
+	* Email sharing was badly supported (no urlencode())
+* Misc.
+	* Add a CREDIT file with contributor names
+	* Update lib_opml
+	* Default favicon is now served by HTTP code 200
+	* Change calls to syslog by Minz_Log::notice
+	* HTTP credentials are no longer logged
+
+
+## 2015-01-15 FreshRSS 0.9.4 (beta)
+
+* Feature
+	* Extension system (!!): some extensions are available at https://github.com/FreshRSS/Extensions
+* Refactoring
+	* Front controller (FreshRSS class)
+	* Configuration system
+	* Sharing system
+	* New data files organization
+* Updates
+	* Remove restriction of 1h for updates
+	* Show the current version of FreshRSS and the next one
+* UI
+	* Remove the "sticky position" of the feed aside (moved into an extension)
+	* "Show password" shows the password only while the user is pressing the mouse.
+
+
+## 2014-12-12 FreshRSS 0.9.3 (beta)
+
+* SimplePie
+	* Support for content-type application/x-rss+xml
+	* New force_feed option (for feeds sent with the wrong content-type / MIME) by adding #force_feed at the end of the feed URL
+	* Improved error messages
+* Statistics
+	* Add information on feed repartition pages
+	* Add percent repartition for the bigger feeds
+* UI
+	* New theme selector
+	* Update Screwdriver theme
+	* Add BlueLagoon theme by Mister aiR
+* Misc.
+	* Add option to remove articles after reading them
+	* Add comments
+	* Refactor i18n system to avoid loading unnecessary strings
+	* Fix security issue in Minz_Error::error() method
+	* Fix redirection after refreshing a given feed
+
+
+## 2014-10-31 FreshRSS 0.9.2 (beta)
+
+* UI
+	* New subscription page (introduce .box items)
+	* Change feed category by drag and drop
+	* New feed aside on the main page
+	* New configuration / administration organization
+* Configuration
+	* New options in config.php for cache duration, timeout, max inactivity, max number of feeds and categories per user.
+* Refactoring
+	* Refactor authentication system (introduce FreshRSS_Auth model)
+	* Refactor indexController (introduce FreshRSS_Context model)
+	* Use ```_t()```, ```_i()```, ```_url()```, ```Minz_Request::good()``` and ```Minz_Request::bad()``` as much as possible
+	* Refactor javascript_vars.phtml
+	* Better coding style
+* I18n
+	* Introduce a new system for i18n keys (not finished yet)
+* Misc.
+	* Fix global view (did not work anymore)
+	* Add do_post_update for update system
+	* Introduce ```checkInstallAction``` to test if FreshRSS installation is ok
+
+
+## 2014-10-09 FreshRSS 0.8.1 / 0.9.1 (beta)
+
+* UI
+	* Add a space after tag icon
+* Statistics
+	* Add an average per day on the 30-day period graph
+	* Add percent of total on top 10 feed
+* Bug fixes
+	* Fix "mark as read" in global view
+	* Fix "read all" shortcut
+	* Fix categories not appearing when adding a new feed (GET action)
+	* Fix enclosure problem
+	* Fix getExtension() on PHP < 5.3.7
+
+
+## 2014-09-26 FreshRSS 0.8.0 / 0.9.0 (beta)
+
+* UI
+	* New interface for statistics
+	* Fix filter buttons
+	* Number of articles divided by 2 in reading view
+	* Redesign of bigMarkAsRead
+* Features
+	* New automatic update system
+	* New reset auth system
+* Security
+	* "Mark as read" requires POST requests for several articles
+	* Test HTTP REFERER in install.php
+* Configuration
+	* New "Show all articles" / "Show only unread" / "Adjust viewing" option
+	* New notification timeout option
+* Misc.
+	* Improve coding style + comments
+	* Fix SQLite bug "ON DELETE CASCADE"
+	* Improve performance when importing articles
+
+
+## 2014-08-24 FreshRSS 0.7.4
+
+* UI
+	* Hide categories/feeds with unread articles when showing only unread articles
+	* Dynamic favicon showing the number of unread articles
+	* New theme: Screwdriver by Mister aiR
+* Statistics
+	* New page with article repartition
+	* Improvements
+* Security
+	* Basic protection against XSRF (Cross-Site Request Forgery) based on HTTP Referer (POST requests only)
+* API
+	* Compatible with lighttpd
+* Misc.
+	* Changed lazyload implementation
+	* Support of HTML5 notifications for new upcoming articles
+	* Add option to stay logged in
+* Bug fixes in export function, add/remove users, keyboard shortcuts, etc.
+
+
+## 2014-07-21 FreshRSS 0.7.3
+
+* New options
+	* Add system of user queries which are shortcuts to filter the view
+	* New TTL option to limit the frequency at which feeds are refreshed (by cron or manual refresh button).
+		It is still possible to manually refresh an individual feed at a higher frequency.
+* SQL
+	* Add support for SQLite (beta) in addition to MySQL
+* SimplePie
+	* Complies with HTTP "301 Moved Permanently" responses by automatically updating the URL of feeds that have changed address.
+* Themes
+	* Flat and Dark designs are based on same template file as Origine
+* Statistics
+	* Refactor code
+	* Add an idle feed page
+* Misc
+	* Several bug fixes
+	* Add confirmation option when marking all articles as read
+	* Fix some typo
+
+
+## 2014-06-13 FreshRSS 0.7.2
+
+* API compatible with Google Reader API level 2
+	* FreshRSS can now be used from e.g.:
+		* (Android) News+ https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus.extension.google_reader
+		* (Android) EasyRSS https://github.com/Alkarex/EasyRSS
+* Basic support for audio and video podcasts
+* Searching
+	* New search filters date: and pubdate: accepting ISO 8601 date intervals such as `date:2013-2014` or `pubdate:P1W`
+	* Possibility to combine search filters, e.g. `date:2014-05 intitle:FreshRSS intitle:Open great reader #Internet`
+* Change nav menu with more buttons instead of dropdown menus and add some filters
+* New system of import / export
+	* Support OPML, Json (like Google Reader) and ZIP archives
+	* Can export and import articles (specific option for favorites)
+* Refactor "Origine" theme
+	* Some improvements
+	* Based on a template file (other themes will use it too)
+
+
+## 2014-02-19 FreshRSS 0.7.1
+
+* Mise à jour des flux plus rapide grâce à une meilleure utilisation du cache
+	* Utilisation d’une signature MD5 du contenu intéressant pour les flux n’implémentant pas les requêtes conditionnelles
+* Modification des raccourcis
+	* "s" partage directement si un seul moyen de partage
+	* Moyens de partage accessibles par "1", "2", "3", etc.
+	* Premier article : Home ; Dernier article : End
+	* Ajout du déplacement au sein des catégories / flux (via modificateurs shift et alt)
+* UI
+	* Séparation des descriptions des raccourcis par groupes
+	* Revue rapide de la page de connexion
+	* Amélioration de l'affichage des notifications sur mobile
+* Revue du système de rafraîchissement des flux
+	* Meilleure gestion de la file de flux à rafraîchir en JSON
+	* Rafraîchissement uniquement pour les flux non rafraîchis récemment
+	* Possibilité donnée aux anonymes de rafraîchir les flux
+* SimplePie
+	* Mise à jour de la lib
+	* Corrige fuite de mémoire
+	* Meilleure tolérance aux flux invalides
+* Corrections divers
+	* Ne déplie plus l'article lors du clic sur l'icône lien externe
+	* Ne boucle plus à la fin de la navigation dans les articles
+	* Suppression du champ category.color inutile
+	* Corrige bug redirection infinie (Persona)
+	* Amélioration vérification de la requête POST
+	* Ajout d'un verrou lorsqu'une action mark_read ou mark_favorite est en cours
+
+
+## 2014-01-29 FreshRSS 0.7
+
+* Nouveau mode multi-utilisateur
+	* L’utilisateur par défaut (administrateur) peut créer et supprimer d’autres utilisateurs
+	* Nécessite un contrôle d’accès, soit :
+		* par le nouveau mode de connexion par formulaire (nom d’utilisateur + mot de passe)
+			* relativement sûr même sans HTTPS (le mot de passe n’est pas transmis en clair)
+			* requiert JavaScript et PHP 5.3+
+		* par HTTP (par exemple sous Apache en créant un fichier ./p/i/.htaccess et .htpasswd)
+			* le nom d’utilisateur HTTP doit correspondre au nom d’utilisateur FreshRSS
+		* par Mozilla Persona, en renseignant l’adresse courriel des utilisateurs
+* Installateur supportant les mises à jour :
+	* Depuis une v0.6, placer application.ini et Configuration.array.php dans le nouveau répertoire “./data/”
+		(voir réorganisation ci-dessous)
+	* Pour les versions suivantes, juste garder le répertoire “./data/”
+* Rafraîchissement automatique du nombre d’articles non lus toutes les deux minutes (utilise le cache HTTP à bon escient)
+	* Permet aussi de conserver la session valide, surtout dans le cas de Persona
+* Nouvelle page de statistiques (nombres d’articles par jour / catégorie)
+* Importation OPML instantanée et plus tolérante
+* Nouvelle gestion des favicons avec téléchargement en parallèle
+* Nouvelles options
+	* Réorganisation des options
+	* Gestion des utilisateurs
+	* Améliorations partage vers Shaarli, Poche, Diaspora*, Facebook, Twitter, Google+, courriel
+		* Raccourci ‘s’ par défaut
+	* Permet la suppression de tous les articles d’un flux
+	* Option pour marquer les articles comme lus dès la réception
+	* Permet de configurer plus finement le nombre d’articles minimum à conserver par flux
+	* Permet de modifier la description et l’adresse d’un flux RSS ainsi que le site Web associé
+	* Nouveau raccourci pour ouvrir/fermer un article (‘c’ par défaut)
+	* Boutons pour effacer les logs et pour purger les vieux articles
+	* Nouveaux filtres d’affichage : seulement les articles favoris, et seulement les articles lus
+* SQL :
+	* Nouveau moteur de recherche, aussi accessible depuis la vue mobile
+		* Mots clefs de recherche “intitle:”, “inurl:”, “author:”
+	* Les articles sont triés selon la date de leur ajout dans FreshRSS plutôt que la date déclarée (souvent erronée)
+		* Permet de marquer tout comme lu sans affecter les nouveaux articles arrivés en cours de lecture
+		* Permet une pagination efficace
+	* Refactorisation
+		* Les tables sont préfixées avec le nom d’utilisateur afin de permettre le mode multi-utilisateurs
+		* Amélioration des performances
+		* Tolère un beaucoup plus grand nombre d’articles
+		* Compression des données côté MySQL plutôt que côté PHP
+		* Incompatible avec la version 0.6 (nécessite une mise à jour grâce à l’installateur)
+	* Affichage de la taille de la base de données dans FreshRSS
+	* Correction problème de marquage de tous les favoris comme lus
+* HTML5 :
+	* Support des balises HTML5 audio, video, et éléments associés
+		* Utilisation de preload="none", et réécriture correcte des adresses, aussi en HTTPS
+	* Protection HTML5 des iframe (sandbox="allow-scripts allow-same-origin")
+	* Filtrage des object et embed
+	* Chargement différé HTML5 (postpone="") pour iframe et video
+	* Chargement différé JavaScript pour iframe
+* CSS :
+	* Nouveau thème sombre
+		* Chargement plus robuste des thèmes
+	* Meilleur support des longs titres d’articles sur des écrans étroits
+	* Meilleure accessibilité
+		* FreshRSS fonctionne aussi en mode dégradé sans images (alternatives Unicode) et/ou sans CSS
+	* Diverses améliorations
+* PHP :
+	* Encore plus tolérant pour les flux comportant des erreurs
+	* Mise à jour automatique de l’URL du flux (en base de données) lorsque SimplePie découvre qu’elle a changé
+	* Meilleure gestion des caractères spéciaux dans différents cas
+	* Compatibilité PHP 5.5+ avec OPcache
+	* Amélioration des performances
+	* Chargement automatique des classes
+	* Alternative dans le cas d’absence de librairie JSON
+	* Pour le développement, le cache HTTP peut être désactivé en créant un fichier “./data/no-cache.txt”
+* Réorganisation des fichiers et répertoires, en particulier :
+	* Tous les fichiers utilisateur sont dans “./data/” (y compris “cache”, “favicons”, et “log”)
+	* Déplacement de “./app/configuration/application.ini” vers “./data/config.php”
+		* Meilleure sécurité et compatibilité
+	* Déplacement de “./public/data/Configuration.array.php” vers “./data/*_user.php”
+	* Déplacement de “./public/” vers “./p/”
+		* Déplacement de “./public/index.php” vers “./p/i/index.php” (voir cookie ci-dessous)
+	* Déplacement de “./actualize_script.php” vers “./app/actualize_script.php” (pour une meilleure sécurité)
+		* Pensez à mettre à jour votre Cron !
+* Divers :
+	* Nouvelle politique de cookie de session (témoin de connexion)
+		* Utilise un nom poli “FreshRSS” (évite des problèmes avec certains filtres)
+		* Se limite au répertoire “./FreshRSS/p/i/” pour de meilleures performances HTTP
+			* Les images, CSS, scripts sont servis sans cookie
+		* Utilise “HttpOnly” pour plus de sécurité
+	* Nouvel “agent utilisateur” exposé lors du téléchargement des flux, par exemple :
+		* “FreshRSS/0.7 (Linux; http://freshrss.org) SimplePie/1.3.1”
+	* Script d’actualisation avec plus de messages
+		* Sur la sortie standard, ainsi que dans le log système (syslog)
+	* Affichage du numéro de version dans "À propos"
+
+
+## 2013-11-21 FreshRSS 0.6.1
+
+* Corrige bug chargement du JavaScript
+* Affiche un message d’erreur plus explicite si fichier de configuration inaccessible
+
+
+## 2013-11-17 FreshRSS 0.6
+
+* Nettoyage du code JavaScript + optimisations
+* Utilisation d’adresses relatives
+* Amélioration des performances coté client
+* Mise à jour automatique du nombre d’articles non lus
+* Corrections traductions
+* Mise en cache de FreshRSS
+* Amélioration des retours utilisateur lorsque la configuration n’est pas bonne
+* Actualisation des flux après une importation OPML
+* Meilleure prise en charge des flux RSS invalides
+* Amélioration de la vue globale
+* Possibilité de personnaliser les icônes de lecture
+* Suppression de champs lors de l’installation (base_url et sel)
+* Correction bugs divers
+
+
+## 2013-10-15 FreshRSS 0.5.1
+
+* Correction bug des catégories disparues
+* Correction traduction i18n/fr et i18n/en
+* Suppression de certains appels à la feuille de style fallback.css
+
+
+## 2013-10-12 FreshRSS 0.5.0
+
+* Possibilité d’interdire la lecture anonyme
+* Option pour garder l’historique d’un flux
+* Lors d’un clic sur “Marquer tous les articles comme lus”, FreshRSS peut désormais sauter à la prochaine catégorie / prochain flux avec des articles non lus.
+* Ajout d’un token pour accéder aux flux RSS générés par FreshRSS sans nécessiter de connexion
+* Possibilité de partager vers Facebook, Twitter et Google+
+* Possibilité de changer de thème
+* Le menu de navigation (article précédent / suivant / haut de page) a été ajouté à la vue non mobile
+* La police OpenSans est désormais appliquée
+* Amélioration de la page de configuration
+* Une meilleure sortie pour l’imprimante
+* Quelques retouches du design par défaut
+* Les vidéos ne dépassent plus du cadre de l’écran
+* Nouveau logo
+* Possibilité d’ajouter un préfixe aux tables lors de l’installation
+* Ajout d’un champ en base de données keep_history à la table feed
+* Si possible, création automatique de la base de données si elle n’existe pas lors de l’installation
+* L’utilisation d’UTF-8 est forcée
+* Le marquage automatique au défilement de la page a été amélioré
+* La vue globale a été énormément améliorée et est beaucoup plus utile
+* Amélioration des requêtes SQL
+* Amélioration du JavaScript
+* Correction bugs divers
+
+
+## 2013-07-02 FreshRSS 0.4.0
+
+* Correction bug et ajout notification lors de la phase d’installation
+* Affichage d’erreur si fichier OPML invalide
+* Les tags sont maintenant cliquables pour filtrer dessus
+* Amélioration vue mobile (boutons plus gros et ajout d’une barre de navigation)
+* Possibilité d’ajouter directement un flux dans une catégorie dès son ajout
+* Affichage des flux en erreur (injoignable par exemple) en rouge pour les différencier
+* Possibilité de changer les noms des flux
+* Ajout d’une option (désactivable donc) pour charger les images en lazyload permettant de ne pas charger toutes les images d’un coup
+* Le framework Minz est maintenant directement inclus dans l’archive (plus besoin de passer par ./build.sh)
+* Amélioration des performances pour la récupération des flux tronqués
+* Possibilité d’importer des flux sans catégorie lors de l’import OPML
+* Suppression de “l’API” (qui était de toute façon très basique) et de la fonctionnalité de “notes”
+* Amélioration de la recherche (garde en mémoire si l’on a sélectionné une catégorie) par exemple
+* Modification apparence des balises hr et pre
+* Meilleure vérification des champs de formulaire
+* Remise en place du mode “endless” (permettant de simplement charger les articles qui suivent plutôt que de charger une nouvelle page)
+* Ajout d’une page de visualisation des logs
+* Ajout d’une option pour optimiser la BDD (diminue sa taille)
+* Ajout des vues lecture et globale (assez basique)
+* Les vidéos YouTube ne débordent plus du cadre sur les petits écrans
+* Ajout d’une option pour marquer les articles comme lus lors du défilement (et suppression de celle au chargement de la page)
+
+
+## 2013-05-05 FreshRSS 0.3.0
+
+* Fallback pour les icônes SVG (utilisation de PNG à la place)
+* Fallback pour les propriétés CSS3 (utilisation de préfixes)
+* Affichage des tags associés aux articles
+* Internationalisation de l’application (gestion des langues anglaise et française)
+* Gestion des flux protégés par authentification HTTP
+* Mise en cache des favicons
+* Création d’un logo *temporaire*
+* Affichage des vidéos dans les articles
+* Gestion de la recherche et filtre par tags pleinement fonctionnels
+* Création d’un vrai script CRON permettant de mettre tous les flux à jour
+* Correction bugs divers
+
+
+## 2013-04-17 FreshRSS 0.2.0
+
+* Création d’un installateur
+* Actualisation des flux en Ajax
+* Partage par mail et Shaarli ajouté
+* Export par flux RSS
+* Possibilité de vider une catégorie
+* Possibilité de sélectionner les catégories en vue mobile
+* Les flux peuvent être sortis du flux principal (système de priorité)
+* Amélioration ajout / import / export des flux
+* Amélioration actualisation (meilleure gestion des erreurs)
+* Améliorations CSS
+* Changements dans la base de données
+* Màj de la librairie SimplePie
+* Flux sans auteurs gérés normalement
+* Correction bugs divers
+
+
+## 2013-04-08 FreshRSS 0.1.0
+
+* “Première” version

+ 57 - 0
CONTRIBUTING.md

@@ -0,0 +1,57 @@
+# How to contribute to FreshRSS?
+
+## Join us on the mailing lists
+
+Do you want to ask us some questions? Do you want to discuss with us? Don't hesitate to subscribe to our mailing lists!
+
+- The first mailing is destined to generic information, it should be adapted to users. [Join mailing@freshrss.org](https://freshrss.org/mailman/listinfo/mailing).
+- The second mailing is mainly for developers. [Join dev@freshrss.org](https://freshrss.org/mailman/listinfo/dev)
+
+## Report a bug
+
+You found a bug? Don't panic, here are some steps to report it easily:
+
+1. Search for it on [the bug tracker](https://github.com/FreshRSS/FreshRSS/issues) (don't forget to use the search bar).
+2. If you find a similar bug, don't hesitate to post a comment to add more importance to the related ticket.
+3. If you didn't find it, [open a new ticket](https://github.com/FreshRSS/FreshRSS/issues/new).
+
+If you have to create a new ticket, try to apply the following advices:
+
+- Give an explicit title to the ticket so it will be easier to find it later.
+- Be as exhaustive as possible in the description: what did you do? What is the bug? What are the steps to reproduce the bug?
+- We also need some information:
+    + Your FreshRSS version (on about page or `constants.php` file)
+    + Your server configuration: type of hosting, PHP version
+    + Your storage system (MySQL / MariaDB or SQLite)
+    + If possible, the related logs (PHP logs and FreshRSS logs under `data/users/your_user/log.txt`)
+
+## Fix a bug
+
+Did you want to fix a bug? To keep a great coordination between collaborators, you will have to follow these indications:
+
+1. Be sure the bug is associated to a ticket and say you work on it.
+2. [Fork this project repository](https://help.github.com/articles/fork-a-repo/).
+3. [Create a new branch](https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/). The name of the branch must be explicit and being prefixed by the related ticket id. For instance, `783-contributing-file` to fix [ticket #783](https://github.com/FreshRSS/FreshRSS/issues/783).
+4. Make your changes to your fork and [send a pull request](https://help.github.com/articles/using-pull-requests/) on the **dev branch**.
+
+If you have to write code, please follow [our coding style recommendations](http://doc2.freshrss.org/en/Developer_documentation/First_steps/Coding_style).
+
+**Tip:** if you are searching for bugs easy to fix, have a look at the « [New comers](https://github.com/FreshRSS/FreshRSS/labels/New%20comers) » ticket label.
+
+## Submit an idea
+
+You have great ideas, yes! Don't be shy and open [a new ticket](https://github.com/FreshRSS/FreshRSS/issues/new) on our bug tracker to ask if we can implement it. The greatest ideas often come from the shyest suggestions!
+
+If your idea is nice, we'll have a look at it.
+
+## Contribute to internationalization (i18n)
+
+If you want to improve internationalization, please open a new ticket first and follow indications from « Fix a bug » section.
+
+Translations are present in the subdirectories of `./app/i18n/`.
+
+We are working on a better way to handle internationalization but don't hesitate to suggest any idea!
+
+## Contribute to documentation
+
+The documentation needs a lot of improvements in order to be more useful to new contributors and we are working on it. If you want to give some help, meet us on [the dedicated repository](https://github.com/FreshRSS/documentation)!

+ 37 - 0
CREDITS.md

@@ -0,0 +1,37 @@
+This is a credit file of people who have [contributed to FreshRSS](https://github.com/FreshRSS/FreshRSS/graphs/contributors) with, at least,
+one commit on one of the FreshRSS repositories (at https://github.com/FreshRSS).
+Please note a commit on THIS specific file is not considered as a contribution
+(too easy!). Its purpose is to show that even the smallest contribution is important.
+People are sorted by name so please keep this order.
+
+---
+
+* [Alexandre Alapetite](https://github.com/Alkarex): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Alkarex), [Web](http://alexandre.alapetite.fr/)
+* [Alexis Degrugillier](https://github.com/aledeg): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=aledeg)
+* [Alwaysin](https://github.com/Alwaysin): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Alwaysin)
+* [Amaury Carrade](https://github.com/AmauryCarrade): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=AmauryCarrade), [Web](https://amaury.carrade.eu/)
+* [ASMfreaK](https://github.com/ASMfreaK): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=ASMfreaK)
+* [Damstre](https://github.com/Damstre): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Damstre),
+* [danc](https://github.com/danc): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=danc), [Web](http://tintouli.free.fr/)
+* [ealdraed](https://github.com/ealdraed): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=ealdraed)
+* [Frans de Jonge](https://github.com/Frenzie): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Frenzie), [Web](http://fransdejonge.com/)
+* [Guillaume Fillon](https://github.com/kokaz): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:kokaz), [Web](http://www.guillaume-fillon.com/)
+* [hckweb](https://github.com/hckweb): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=hckweb)
+* [Jaussoin Timothée](https://github.com/edhelas): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=edhelas), [Web](http://edhelas.movim.eu/)
+* [Julien Reichardt](https://github.com/j8r): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=j8r), [Web](https://blog.jrei.ch/)
+* [Kevin Papst](https://github.com/kevinpapst): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=kevinpapst), [Web](http://www.kevinpapst.de/)
+* [Luc Didry](https://github.com/ldidry): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=ldidry), [Web](https://www.fiat-tux.fr/)
+* [marcomrc](https://github.com/marcomrc): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=marcomrc)
+* [Marcus Rohrmoser](https://github.com/mro): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=mro), [Web](http://mro.name/~me)
+* [Marien Fressinaud](https://github.com/marienfressinaud): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=marienfressinaud), [Web](http://marienfressinaud.fr/)
+* [Melvyn Laïly](https://github.com/yaurthek): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=yaurthek), [Web](http://x2a.yt/)
+* [Nicolas Elie](https://github.com/nicolaselie): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=nicolaselie)
+* [Nicolas Lœuillet](https://github.com/nicosomb): [contributions](https://github.com/FreshRSS/documentation/commits?author=nicosomb), [Web](http://www.loeuillet.org/)
+* [plopoyop](https://github.com/plopoyop): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=plopoyop)
+* [purexo](https://github.com/purexo): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:purexo), [Web](https://purexo.mom/)
+* [Quentin Dufour](https://github.com/superboum): [contributions](https://github.com/FreshRSS/documentation/commits?author=superboum), [Web](http://quentin.dufour.io/)
+* [romibi](https://github.com/romibi): [contributions](https://github.com/FreshRSS/FreshRSS/commits/dev?author=romibi)
+* [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)
+* [tomgue](https://github.com/tomgue): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=tomgue)
+* [Wanabo](https://github.com/Wanabo): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Wanabo)

+ 109 - 31
README.fr.md

@@ -6,54 +6,104 @@ FreshRSS est un agrégateur de flux RSS à auto-héberger à l’image de [Leed]
 Il se veut léger et facile à prendre en main tout en étant un outil puissant et paramétrable.
 Il se veut léger et facile à prendre en main tout en étant un outil puissant et paramétrable.
 
 
 Il permet de gérer plusieurs utilisateurs, et dispose d’un mode de lecture anonyme.
 Il permet de gérer plusieurs utilisateurs, et dispose d’un mode de lecture anonyme.
+Il supporte [PubSubHubbub](https://code.google.com/p/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.
 
 
 * Site officiel : http://freshrss.org
 * Site officiel : http://freshrss.org
 * Démo : http://demo.freshrss.org/
 * Démo : http://demo.freshrss.org/
-* Développeur : Marien Fressinaud <dev@marienfressinaud.fr>
-* Version actuelle : 0.10-dev
-* Date de publication 2014-xx-xx
-* License [GNU AGPL 3](http://www.gnu.org/licenses/agpl-3.0.html)
+* Licence : [GNU AGPL 3](http://www.gnu.org/licenses/agpl-3.0.html)
 
 
-![Logo de FreshRSS](http://marienfressinaud.fr/data/images/freshrss/freshrss_title.png)
+![Logo de FreshRSS](./doc/FreshRSS-logo.png)
 
 
-# Note sur les branches
-**Ce logiciel est encore en développement !** Veuillez vous assurer d'utiliser la branche qui vous correspond :
+# Téléchargement
+Voir la [liste des versions](../../releases).
 
 
-* Utilisez [la branche master](https://github.com/marienfressinaud/FreshRSS/tree/master/) si vous visez la stabilité.
-* [La branche beta](https://github.com/marienfressinaud/FreshRSS/tree/beta) est celle par défaut : les nouveautés y sont ajoutées environ tous les mois.
-* Pour les développeurs et ceux qui savent ce qu'ils font, [la branche dev](https://github.com/marienfressinaud/FreshRSS/tree/dev) vous ouvre les bras !
+## À propos des branches
+* Utilisez [la branche master](https://github.com/FreshRSS/FreshRSS/tree/master/) si vous visez la stabilité.
+* Pour ceux qui veulent bien aider à tester ou déveloper les dernières fonctionnalités, [la branche dev](https://github.com/FreshRSS/FreshRSS/tree/dev) vous ouvre les bras !
 
 
-# Disclaimer
-Cette application a été développée pour s’adapter à des besoins personnels et non professionnels.
-Je ne garantis en aucun cas la sécurité de celle-ci, ni son bon fonctionnement.
-Je m’engage néanmoins à répondre dans la mesure du possible aux demandes d’évolution si celles-ci me semblent justifiées.
-Privilégiez pour cela des demandes sur GitHub
-(https://github.com/marienfressinaud/FreshRSS/issues) ou par mail (dev@marienfressinaud.fr)
+# Avertissements
+Cette application a été développée pour s’adapter principalement à des besoins personnels, et aucune garantie n’est fournie.
+Les demandes de fonctionnalités, rapports de bugs, et autres contributions sont les bienvenues. Privilégiez pour cela des [demandes sur GitHub](https://github.com/FreshRSS/FreshRSS/issues).
+Nous sommes une communauté amicale.
 
 
-# Pré-requis
+# Prérequis
 * Serveur modeste, par exemple sous Linux ou Windows
 * Serveur modeste, par exemple sous Linux ou Windows
-	* Fonctionne même sur un Raspberry Pi avec des temps de réponse < 1s (testé sur 150 flux, 22k articles, soit 32Mo de données partiellement compressées)
+	* Fonctionne même sur un Raspberry Pi 1 avec des temps de réponse < 1s (testé sur 150 flux, 22k articles)
 * Serveur Web Apache2 (recommandé), ou nginx, lighttpd (non testé sur les autres)
 * Serveur Web Apache2 (recommandé), ou nginx, lighttpd (non testé sur les autres)
-* PHP 5.2.1+ (PHP 5.3.7+ recommandé)
-	* Requis : [PDO_MySQL](http://php.net/pdo-mysql) ou [PDO_SQLite](http://php.net/pdo-sqlite), [cURL](http://php.net/curl), [GMP](http://php.net/gmp) (seulement pour accès API sur platformes < 64 bits)
-	* Recommandés : [JSON](http://php.net/json), [mbstring](http://php.net/mbstring), [zlib](http://php.net/zlib), [Zip](http://php.net/zip)
-* MySQL 5.0.3+ (recommandé) ou SQLite 3.7.4+
-* Un navigateur Web récent tel Firefox 4+, Chrome, Opera, Safari, Internet Explorer 9+
+* PHP 5.3.3+ (PHP 5.4+ recommandé, et PHP 5.5+ pour les performances, et PHP 7+ pour d’encore meilleures performances)
+	* Requis : [cURL](http://php.net/curl), [DOM](http://php.net/dom), [XML](http://php.net/xml), et [PDO_MySQL](http://php.net/pdo-mysql) ou [PDO_SQLite](http://php.net/pdo-sqlite) ou [PDO_PGSQL](http://php.net/pdo-pgsql)
+	* Recommandés : [JSON](http://php.net/json), [GMP](http://php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](http://php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](http://php.net/mbstring) et/ou [iconv](http://php.net/iconv) (pour conversion d’encodages), [ZIP](http://php.net/zip) (pour import/export), [zlib](http://php.net/zlib) (pour les flux compressés)
+* MySQL 5.5.3+ (recommandé), ou SQLite 3.7.4+, ou PostgreSQL (experimental)
+* Un navigateur Web récent tel Firefox, Internet Explorer 11 / Edge, Chrome, Opera, Safari.
 	* Fonctionne aussi sur mobile
 	* Fonctionne aussi sur mobile
 
 
-![Capture d’écran de FreshRSS](http://marienfressinaud.fr/data/images/freshrss/freshrss_default-design.png)
+![Capture d’écran de FreshRSS](./doc/FreshRSS-screenshot.png)
 
 
 # Installation
 # Installation
-1. Récupérez l’application FreshRSS via la commande git ou [en téléchargeant l’archive](https://github.com/marienfressinaud/FreshRSS/archive/master.zip)
+1. Récupérez l’application FreshRSS via la commande git ou [en téléchargeant l’archive](../releases)
 2. Placez l’application sur votre serveur (la partie à exposer au Web est le répertoire `./p/`)
 2. Placez l’application sur votre serveur (la partie à exposer au Web est le répertoire `./p/`)
 3. Le serveur Web doit avoir les droits d’écriture dans le répertoire `./data/`
 3. Le serveur Web doit avoir les droits d’écriture dans le répertoire `./data/`
 4. Accédez à FreshRSS à travers votre navigateur Web et suivez les instructions d’installation
 4. Accédez à FreshRSS à travers votre navigateur Web et suivez les instructions d’installation
-5. Tout devrait fonctionner :) En cas de problème, n’hésitez pas à me contacter.
+	* ou utilisez [l’interface en ligne de commande](./cli/README.md)
+5. Tout devrait fonctionner :) En cas de problème, n’hésitez pas à [nous contacter](https://github.com/FreshRSS/FreshRSS/issues).
+6. Des paramètres de configuration avancée peuvent être accédés depuis [config.php](./data/config.default.php).
+
+## Installation automatisée
+* [![DP deploy](https://raw.githubusercontent.com/DFabric/DPlatform-ShellCore/gh-pages/img/deploy.png)](https://dfabric.github.io/DPlatform-ShellCore)
+* [YunoHost](https://github.com/YunoHost-Apps/freshrss_ynh)
+
+## Exemple d’installation complète sur Linux Debian/Ubuntu
+```sh
+# Si vous utilisez le serveur Web Apache (sinon il faut un autre serveur Web)
+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
+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
+sudo apt install postgresql php-pgsql	#Base de données PostgreSQL optionnelle
+
+## Redémarrage du serveur Web
+sudo service apache2 restart
+
+# Pour FreshRSS lui-même (git est optionnel si vous déployez manuellement les fichiers d’installation)
+cd /usr/share/
+sudo apt-get install git
+sudo git clone https://github.com/FreshRSS/FreshRSS.git
+cd FreshRSS
+
+# Si vous souhaitez utiliser la branche développement de FreshRSS
+sudo git checkout -b dev origin/dev
+
+# Mettre les droits d’accès pour le serveur Web
+sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
+# Si vous souhaitez permettre les mises à jour par l’interface Web
+sudo chmod -R g+w .
+
+# Publier FreshRSS dans votre répertoire HTML public
+sudo ln -s /usr/share/FreshRSS/p /var/www/html/FreshRSS
+# Naviguez vers http://example.net/FreshRSS pour terminer l’installation
+# (Si vous le faite depuis localhost, vous pourrez avoir à ajuster le réglage de votre adresse publique)
+# ou utilisez l’interface en ligne de commande
+
+# Mettre à jour FreshRSS vers une nouvelle version par git
+cd /usr/share/FreshRSS
+sudo git pull
+sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
+```
 
 
-# Contrôle d’accès
+## Contrôle d’accès
 Il est requis pour le mode multi-utilisateur, et recommandé dans tous les cas, de limiter l’accès à votre FreshRSS. Au choix :
 Il est requis pour le mode multi-utilisateur, et recommandé dans tous les cas, de limiter l’accès à votre FreshRSS. Au choix :
 * En utilisant l’identification par formulaire (requiert JavaScript, et PHP 5.3.7+ recommandé – fonctionne avec certaines versions de PHP 5.3.3+)
 * En utilisant l’identification par formulaire (requiert JavaScript, et PHP 5.3.7+ recommandé – fonctionne avec certaines versions de PHP 5.3.3+)
-* En utilisant l’identification par [Mozilla Persona](https://login.persona.org/about) incluse dans FreshRSS
 * En utilisant un contrôle d’accès HTTP défini par votre serveur Web
 * En utilisant un contrôle d’accès HTTP défini par votre serveur Web
 	* Voir par exemple la [documentation d’Apache sur l’authentification](http://httpd.apache.org/docs/trunk/howto/auth.html)
 	* Voir par exemple la [documentation d’Apache sur l’authentification](http://httpd.apache.org/docs/trunk/howto/auth.html)
 		* Créer dans ce cas un fichier `./p/i/.htaccess` avec un fichier `.htpasswd` correspondant.
 		* Créer dans ce cas un fichier `./p/i/.htaccess` avec un fichier `.htpasswd` correspondant.
@@ -65,30 +115,48 @@ C’est une bonne idée d’utiliser le même utilisateur que votre serveur Web
 Par exemple, pour exécuter le script toutes les heures :
 Par exemple, pour exécuter le script toutes les heures :
 
 
 ```
 ```
-7 * * * * php /chemin/vers/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
+8 * * * * php /usr/share/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
+```
+
+### Exemple pour Debian / Ubuntu
+Créer `/etc/cron.d/FreshRSS` avec :
+
+```
+7,37 * * * * www-data php -f /usr/share/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
 ```
 ```
 
 
+
 # Conseils
 # Conseils
 * Pour une meilleure sécurité, faites en sorte que seul le répertoire `./p/` soit accessible depuis le Web, par exemple en faisant pointer un sous-domaine sur le répertoire `./p/`.
 * Pour une meilleure sécurité, faites en sorte que seul le répertoire `./p/` soit accessible depuis le Web, par exemple en faisant pointer un sous-domaine sur le répertoire `./p/`.
 	* En particulier, les données personnelles se trouvent dans le répertoire `./data/`.
 	* En particulier, les données personnelles se trouvent dans le répertoire `./data/`.
 * Le fichier `./constants.php` définit les chemins d’accès aux répertoires clés de l’application. Si vous les bougez, tout se passe ici.
 * Le fichier `./constants.php` définit les chemins d’accès aux répertoires clés de l’application. Si vous les bougez, tout se passe ici.
 * En cas de problème, les logs peuvent être utile à lire, soit depuis l’interface de FreshRSS, soit manuellement depuis `./data/log/*.log`.
 * En cas de problème, les logs peuvent être utile à lire, soit depuis l’interface de FreshRSS, soit manuellement depuis `./data/log/*.log`.
 
 
+
 # Sauvegarde
 # Sauvegarde
-* Il faut conserver vos fichiers `./data/config.php` ainsi que `./data/*_user.php` et éventuellement `./data/persona/`
+* Il faut conserver vos fichiers `./data/config.php` ainsi que `./data/*_user.php`
 * Vous pouvez exporter votre liste de flux depuis FreshRSS au format OPML
 * Vous pouvez exporter votre liste de flux depuis FreshRSS au format OPML
-* Pour sauvegarder les articles eux-même, vous pouvez utiliser [phpMyAdmin](http://www.phpmyadmin.net) ou les outils de MySQL :
+	* soit depuis l’interface Web, soit [en ligne de commande](./cli/README.md)
+* Pour sauvegarder les articles eux-mêmes, vous pouvez utiliser [phpMyAdmin](http://www.phpmyadmin.net) ou les outils de MySQL :
 
 
 ```bash
 ```bash
 mysqldump -u utilisateur -p --databases freshrss > freshrss.sql
 mysqldump -u utilisateur -p --databases freshrss > freshrss.sql
 ```
 ```
 
 
 
 
+# Extensions 
+FreshRSS permet l’ajout d’extensions en plus des fonctionnalités natives.
+Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensions).
+
+
 # Bibliothèques incluses
 # Bibliothèques incluses
 * [SimplePie](http://simplepie.org/)
 * [SimplePie](http://simplepie.org/)
 * [MINZ](https://github.com/marienfressinaud/MINZ)
 * [MINZ](https://github.com/marienfressinaud/MINZ)
 * [php-http-304](http://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [php-http-304](http://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [jQuery](http://jquery.com/)
 * [jQuery](http://jquery.com/)
+* [ArthurHoaro/favicon](https://github.com/ArthurHoaro/favicon)
+* [lib_opml](https://github.com/marienfressinaud/lib_opml)
+* [jQuery Plugin Sticky-Kit](http://leafo.net/sticky-kit/)
 * [keyboard_shortcuts](http://www.openjs.com/scripts/events/keyboard_shortcuts/)
 * [keyboard_shortcuts](http://www.openjs.com/scripts/events/keyboard_shortcuts/)
 * [flotr2](http://www.humblesoftware.com/flotr2)
 * [flotr2](http://www.humblesoftware.com/flotr2)
 
 
@@ -99,3 +167,13 @@ mysqldump -u utilisateur -p --databases freshrss > freshrss.sql
 ## Si les fonctions natives ne sont pas disponibles
 ## Si les fonctions natives ne sont pas disponibles
 * [Services_JSON](http://pear.php.net/pepr/pepr-proposal-show.php?id=198)
 * [Services_JSON](http://pear.php.net/pepr/pepr-proposal-show.php?id=198)
 * [password_compat](https://github.com/ircmaxell/password_compat)
 * [password_compat](https://github.com/ircmaxell/password_compat)
+
+
+# Clients compatibles
+Tout client supportant une API de type Google Reader. Sélection :
+
+* Android
+	* [News+](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus) avec [News+ Google Reader extension](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus.extension.google_reader) (Propriétaire)
+	* [EasyRSS](https://github.com/Alkarex/EasyRSS) (Libre, F-Droid)
+* Linux
+	* [FeedReader 2.0+](https://jangernert.github.io/FeedReader/) (Libre)

+ 115 - 37
README.md

@@ -1,82 +1,142 @@
 * [Version française](README.fr.md)
 * [Version française](README.fr.md)
 
 
 # FreshRSS
 # FreshRSS
-FreshRSS is a self-hosted RSS feed agregator like [Leed](http://projet.idleman.fr/leed/) or [Kriss Feed](http://tontof.net/kriss/feed/).
+FreshRSS is a self-hosted RSS feed aggregator such as [Leed](http://projet.idleman.fr/leed/) or [Kriss Feed](http://tontof.net/kriss/feed/).
 
 
-It is at the same time light-weight, easy to work with, powerful and customizable.
+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 is a multi-user application with an anonymous reading mode.
+It supports [PubSubHubbub](https://code.google.com/p/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.
 
 
 * Official website: http://freshrss.org
 * Official website: http://freshrss.org
 * Demo: http://demo.freshrss.org/
 * Demo: http://demo.freshrss.org/
-* Developer: Marien Fressinaud <dev@marienfressinaud.fr>
-* Current version: 0.10-dev
-* Publication date: 2014-xx-xx
-* License [GNU AGPL 3](http://www.gnu.org/licenses/agpl-3.0.html)
+* License: [GNU AGPL 3](http://www.gnu.org/licenses/agpl-3.0.html)
 
 
-![FreshRSS logo](http://marienfressinaud.fr/data/images/freshrss/freshrss_title.png)
+![FreshRSS logo](./doc/FreshRSS-logo.png)
 
 
-# Note on branches
-**This application is still in development!** Please use the branch that suits your needs:
+# Releases
+See the [list of releases](../../releases).
 
 
-* Use [the master branch](https://github.com/marienfressinaud/FreshRSS/tree/master/) if you need a stable version.
-* [The beta branch](https://github.com/marienfressinaud/FreshRSS/tree/beta) is the default branch: new features are added on a monthly basis.
-* For developers and tech savvy persons, [the dev branch](https://github.com/marienfressinaud/FreshRSS/tree/dev) is waiting for you!
+## About branches
+* Use [the master branch](https://github.com/FreshRSS/FreshRSS/tree/master/) if you need a stable version.
+* For those willing to help testing or developing the latest features, [the dev branch](https://github.com/FreshRSS/FreshRSS/tree/dev) is waiting for you!
 
 
 # Disclaimer
 # Disclaimer
-This application was developed to fulfill personal needs not professional needs.
-There is no guarantee neither on its security nor its proper functioning.
-If there is feature requests which I think are good for the project, I'll do my best to include them.
-The best way is to open issues on GitHub
-(https://github.com/marienfressinaud/FreshRSS/issues) or by email (dev@marienfressinaud.fr)
+This application was developed to fulfil personal needs primarily, and comes with absolutely no warranty.
+Feature requests, bug reports, and other contributions are welcome. The best way is to [open an issue on GitHub](https://github.com/FreshRSS/FreshRSS/issues).
+We are a friendly community.
 
 
 # Requirements
 # Requirements
 * Light server running Linux or Windows
 * Light server running Linux or Windows
-	* It even works on Raspberry Pi with response time under a second (tested with 150 feeds, 22k articles, or 32Mo of compressed data)
-* A web server: Apache2 (recommanded), nginx, lighttpd (not tested on others)
-* PHP 5.2.1+ (PHP 5.3.7+ recommanded)
-	* Required extensions: [PDO_MySQL](http://php.net/pdo-mysql) or [PDO_SQLite](http://php.net/pdo-sqlite), [cURL](http://php.net/curl), [GMP](http://php.net/gmp) (only for API access on platforms under 64 bits)
-	* Recommanded extensions : [JSON](http://php.net/json), [mbstring](http://php.net/mbstring), [zlib](http://php.net/zlib), [Zip](http://php.net/zip)
-* MySQL 5.0.3+ (recommanded) ou SQLite 3.7.4+
-* A recent browser like Firefox 4+, Chrome, Opera, Safari, Internet Explorer 9+
+	* It even works on Raspberry Pi 1 with response time under a second (tested with 150 feeds, 22k articles)
+* A web server: Apache2 (recommended), nginx, lighttpd (not tested on others)
+* PHP 5.3.3+ (PHP 5.4+ recommended, and PHP 5.5+ for performance, and PHP 7 for even higher performance)
+	* Required extensions: [cURL](http://php.net/curl), [DOM](http://php.net/dom), [XML](http://php.net/xml), and [PDO_MySQL](http://php.net/pdo-mysql) or [PDO_SQLite](http://php.net/pdo-sqlite) or [PDO_PGSQL](http://php.net/pdo-pgsql)
+	* Recommended extensions: [JSON](http://php.net/json), [GMP](http://php.net/gmp) (for API access on platforms < 64 bits), [IDN](http://php.net/intl.idn) (for Internationalized Domain Names), [mbstring](http://php.net/mbstring) and/or [iconv](http://php.net/iconv) (for charset conversion), [ZIP](http://php.net/zip) (for import/export), [zlib](http://php.net/zlib) (for compressed feeds)
+* MySQL 5.5.3+ (recommended), or SQLite 3.7.4+, or PostgreSQL (experimental)
+* A recent browser like Firefox, Internet Explorer 11 / Edge, Chrome, Opera, Safari.
 	* Works on mobile
 	* Works on mobile
 
 
-![FreshRSS screenshot](http://marienfressinaud.fr/data/images/freshrss/freshrss_default-design.png)
+![FreshRSS screenshot](./doc/FreshRSS-screenshot.png)
 
 
 # Installation
 # Installation
-1. Get FreshRSS with git or [by downloading the archive](https://github.com/marienfressinaud/FreshRSS/archive/master.zip)
+1. Get FreshRSS with git or [by downloading the archive](https://github.com/FreshRSS/FreshRSS/archive/master.zip)
 2. Dump the application on your server (expose only the `./p/` folder)
 2. Dump the application on your server (expose only the `./p/` folder)
 3. Add write access on `./data/` folder to the webserver user
 3. Add write access on `./data/` folder to the webserver user
 4. Access FreshRSS with your browser and follow the installation process
 4. Access FreshRSS with your browser and follow the installation process
-5. Every thing should be working :) If you encounter any problem, feel free to contact me.
+	* or use the [Command-Line Interface](./cli/README.md)
+5. Everything should be working :) If you encounter any problem, feel free [contact us](https://github.com/FreshRSS/FreshRSS/issues).
+6. Advanced configuration settings can be seen in [config.php](./data/config.default.php).
+
+## Automated install
+* [![DP deploy](https://raw.githubusercontent.com/DFabric/DPlatform-ShellCore/gh-pages/img/deploy.png)](https://dfabric.github.io/DPlatform-ShellCore)
+* [YunoHost](https://github.com/YunoHost-Apps/freshrss_ynh)
+
+## Example of full installation on Linux Debian/Ubuntu
+```sh
+# If you use an Apache Web server (otherwise you need another Web server)
+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
+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
+sudo apt install postgresql php-pgsql	#Optional PostgreSQL database
+
+# Restart Web server
+sudo service apache2 restart
+
+# For FreshRSS itself (git is optional if you manually download the installation files)
+cd /usr/share/
+sudo apt-get install git
+sudo git clone https://github.com/FreshRSS/FreshRSS.git
+cd FreshRSS
+
+# If you want to use the development version of FreshRSS
+sudo git checkout -b dev origin/dev
+
+# Set the rights so that your Web server can access the files
+sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
+# If you would like to allow updates from the Web interface
+sudo chmod -R g+w .
+
+# Publish FreshRSS in your public HTML directory
+sudo ln -s /usr/share/FreshRSS/p /var/www/html/FreshRSS
+# Navigate to http://example.net/FreshRSS to complete the installation
+# (If you do it from localhost, you may have to adjust the setting of your public address later)
+# or use the Command-Line Interface
+
+# Update to a newer version of FreshRSS with git
+cd /usr/share/FreshRSS
+sudo git pull
+sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
+```
 
 
-# Access control
+## Access control
 It is needed for the multi-user mode to limit access to FreshRSS. You can:
 It is needed for the multi-user mode to limit access to FreshRSS. You can:
 * use form authentication (need JavaScript and PHP 5.3.7+, works with some PHP 5.3.3+)
 * use form authentication (need JavaScript and PHP 5.3.7+, works with some PHP 5.3.3+)
-* use [Mozilla Persona](https://login.persona.org/about) authentication included in FreshRSS
 * use HTTP authentication supported by your web server
 * use HTTP authentication supported by your web server
 	* See [Apache documentation](http://httpd.apache.org/docs/trunk/howto/auth.html)
 	* See [Apache documentation](http://httpd.apache.org/docs/trunk/howto/auth.html)
 		* In that case, create a `./p/i/.htaccess` file with a matching `.htpasswd` file.
 		* In that case, create a `./p/i/.htaccess` file with a matching `.htpasswd` file.
 
 
-# Automatic feed update
+## Automatic feed update
 * You can add a Cron job to launch the update script.
 * You can add a Cron job to launch the update script.
 Check the Cron documentation related to your distribution ([Debian/Ubuntu](https://help.ubuntu.com/community/CronHowto), [Red Hat/Fedora](https://fedoraproject.org/wiki/Administration_Guide_Draft/Cron), [Slackware](http://docs.slackware.com/fr:slackbook:process_control?#cron), [Gentoo](https://wiki.gentoo.org/wiki/Cron), [Arch Linux](https://wiki.archlinux.org/index.php/Cron)…).
 Check the Cron documentation related to your distribution ([Debian/Ubuntu](https://help.ubuntu.com/community/CronHowto), [Red Hat/Fedora](https://fedoraproject.org/wiki/Administration_Guide_Draft/Cron), [Slackware](http://docs.slackware.com/fr:slackbook:process_control?#cron), [Gentoo](https://wiki.gentoo.org/wiki/Cron), [Arch Linux](https://wiki.archlinux.org/index.php/Cron)…).
-It’s a good idea to use the web server user .
-For example, if you want to run the script every hour:
+It is a good idea to use the Web server user.
+For instance, if you want to run the script every hour:
+
+```
+9 * * * * php /usr/share/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
+```
+
+### Example on Debian / Ubuntu
+Create `/etc/cron.d/FreshRSS` with:
 
 
 ```
 ```
-7 * * * * php /chemin/vers/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
+6,36 * * * * www-data php -f /usr/share/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
 ```
 ```
 
 
+
 # Advices
 # Advices
 * For a better security, expose only the `./p/` folder on the web.
 * For a better security, expose only the `./p/` folder on the web.
-	* Be aware that the `./data/` folder contain all personal data, so it is a bad idea to expose it.
-* The `./constants.php` file define access to application folder. If you want to customize your installation, every thing happens here.
-* If you encounter some problem, logs are accessibles from the interface or manually in `./data/log/*.log` files.
+	* Be aware that the `./data/` folder contains all personal data, so it is a bad idea to expose it.
+* The `./constants.php` file defines access to application folder. If you want to customize your installation, every thing happens here.
+* If you encounter any problem, logs are accessible from the interface or manually in `./data/log/*.log` files.
+
 
 
 # Backup
 # Backup
-* You need to keep `./data/config.php`, `./data/*_user.php` and `./data/persona/` files
+* You need to keep `./data/config.php`, and `./data/*_user.php` files
 * You can export your feed list in OPML format from FreshRSS
 * You can export your feed list in OPML format from FreshRSS
+	* either from the Web interface, or from the [Command-Line Interface](./cli/README.md)
 * To save articles, you can use [phpMyAdmin](http://www.phpmyadmin.net) or MySQL tools:
 * To save articles, you can use [phpMyAdmin](http://www.phpmyadmin.net) or MySQL tools:
 
 
 ```bash
 ```bash
@@ -84,11 +144,19 @@ mysqldump -u user -p --databases freshrss > freshrss.sql
 ```
 ```
 
 
 
 
+# Extensions 
+FreshRSS supports further customizations by adding extensions on top of its core functionality.
+See the [repository dedicated to those extensions](https://github.com/FreshRSS/Extensions). 
+
+
 # Included libraries
 # Included libraries
 * [SimplePie](http://simplepie.org/)
 * [SimplePie](http://simplepie.org/)
 * [MINZ](https://github.com/marienfressinaud/MINZ)
 * [MINZ](https://github.com/marienfressinaud/MINZ)
 * [php-http-304](http://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [php-http-304](http://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [jQuery](http://jquery.com/)
 * [jQuery](http://jquery.com/)
+* [ArthurHoaro/favicon](https://github.com/ArthurHoaro/favicon)
+* [lib_opml](https://github.com/marienfressinaud/lib_opml)
+* [jQuery Plugin Sticky-Kit](http://leafo.net/sticky-kit/)
 * [keyboard_shortcuts](http://www.openjs.com/scripts/events/keyboard_shortcuts/)
 * [keyboard_shortcuts](http://www.openjs.com/scripts/events/keyboard_shortcuts/)
 * [flotr2](http://www.humblesoftware.com/flotr2)
 * [flotr2](http://www.humblesoftware.com/flotr2)
 
 
@@ -99,3 +167,13 @@ mysqldump -u user -p --databases freshrss > freshrss.sql
 ## If native functions are not available
 ## If native functions are not available
 * [Services_JSON](http://pear.php.net/pepr/pepr-proposal-show.php?id=198)
 * [Services_JSON](http://pear.php.net/pepr/pepr-proposal-show.php?id=198)
 * [password_compat](https://github.com/ircmaxell/password_compat)
 * [password_compat](https://github.com/ircmaxell/password_compat)
+
+
+# Compatible clients
+Any client supporting a Google Reader-like API. Selection:
+
+* Android
+	* [News+](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus) with [News+ Google Reader extension](https://play.google.com/store/apps/details?id=com.noinnion.android.newsplus.extension.google_reader) (Closed source)
+	* [EasyRSS](https://github.com/Alkarex/EasyRSS) (Open source, F-Droid)
+* Linux
+	* [FeedReader 2.0+](https://jangernert.github.io/FeedReader/) (Open source)

+ 46 - 182
app/Controllers/authController.php

@@ -19,17 +19,18 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function indexAction() {
 	public function indexAction() {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
-			Minz_Error::error(403,
-			                  array('error' => array(_t('access_denied'))));
+			Minz_Error::error(403);
 		}
 		}
 
 
+		Minz_View::prependTitle(_t('admin.auth.title') . ' · ');
+
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
 			$ok = true;
 			$ok = true;
 
 
-			$current_token = FreshRSS_Context::$conf->token;
+			$current_token = FreshRSS_Context::$user_conf->token;
 			$token = Minz_Request::param('token', $current_token);
 			$token = Minz_Request::param('token', $current_token);
-			FreshRSS_Context::$conf->_token($token);
-			$ok &= FreshRSS_Context::$conf->save();
+			FreshRSS_Context::$user_conf->token = $token;
+			$ok &= FreshRSS_Context::$user_conf->save();
 
 
 			$anon = Minz_Request::param('anon_access', false);
 			$anon = Minz_Request::param('anon_access', false);
 			$anon = ((bool)$anon) && ($anon !== 'no');
 			$anon = ((bool)$anon) && ($anon !== 'no');
@@ -38,27 +39,29 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 			$auth_type = Minz_Request::param('auth_type', 'none');
 			$auth_type = Minz_Request::param('auth_type', 'none');
 			$unsafe_autologin = Minz_Request::param('unsafe_autologin', false);
 			$unsafe_autologin = Minz_Request::param('unsafe_autologin', false);
 			$api_enabled = Minz_Request::param('api_enabled', false);
 			$api_enabled = Minz_Request::param('api_enabled', false);
-			if ($anon != Minz_Configuration::allowAnonymous() ||
-				$auth_type != Minz_Configuration::authType() ||
-				$anon_refresh != Minz_Configuration::allowAnonymousRefresh() ||
-				$unsafe_autologin != Minz_Configuration::unsafeAutologinEnabled() ||
-				$api_enabled != Minz_Configuration::apiEnabled()) {
-
-				Minz_Configuration::_authType($auth_type);
-				Minz_Configuration::_allowAnonymous($anon);
-				Minz_Configuration::_allowAnonymousRefresh($anon_refresh);
-				Minz_Configuration::_enableAutologin($unsafe_autologin);
-				Minz_Configuration::_enableApi($api_enabled);
-				$ok &= Minz_Configuration::writeFile();
+			if ($anon != FreshRSS_Context::$system_conf->allow_anonymous ||
+				$auth_type != FreshRSS_Context::$system_conf->auth_type ||
+				$anon_refresh != FreshRSS_Context::$system_conf->allow_anonymous_refresh ||
+				$unsafe_autologin != FreshRSS_Context::$system_conf->unsafe_autologin_enabled ||
+				$api_enabled != FreshRSS_Context::$system_conf->api_enabled) {
+
+				// TODO: test values from form
+				FreshRSS_Context::$system_conf->auth_type = $auth_type;
+				FreshRSS_Context::$system_conf->allow_anonymous = $anon;
+				FreshRSS_Context::$system_conf->allow_anonymous_refresh = $anon_refresh;
+				FreshRSS_Context::$system_conf->unsafe_autologin_enabled = $unsafe_autologin;
+				FreshRSS_Context::$system_conf->api_enabled = $api_enabled;
+
+				$ok &= FreshRSS_Context::$system_conf->save();
 			}
 			}
 
 
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
 			if ($ok) {
 			if ($ok) {
-				Minz_Request::good('configuration_updated',
+				Minz_Request::good(_t('feedback.conf.updated'),
 				                   array('c' => 'auth', 'a' => 'index'));
 				                   array('c' => 'auth', 'a' => 'index'));
 			} else {
 			} else {
-				Minz_Request::bad('error_occurred',
+				Minz_Request::bad(_t('feedback.conf.error'),
 				                  array('c' => 'auth', 'a' => 'index'));
 				                  array('c' => 'auth', 'a' => 'index'));
 			}
 			}
 		}
 		}
@@ -67,7 +70,7 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 	/**
 	/**
 	 * This action handles the login page.
 	 * This action handles the login page.
 	 *
 	 *
-	 * It forwards to the correct login page (form or Persona) or main page if
+	 * It forwards to the correct login page (form) or main page if
 	 * the user is already connected.
 	 * the user is already connected.
 	 */
 	 */
 	public function loginAction() {
 	public function loginAction() {
@@ -75,14 +78,11 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 			Minz_Request::forward(array('c' => 'index', 'a' => 'index'), true);
 			Minz_Request::forward(array('c' => 'index', 'a' => 'index'), true);
 		}
 		}
 
 
-		$auth_type = Minz_Configuration::authType();
+		$auth_type = FreshRSS_Context::$system_conf->auth_type;
 		switch ($auth_type) {
 		switch ($auth_type) {
 		case 'form':
 		case 'form':
 			Minz_Request::forward(array('c' => 'auth', 'a' => 'formLogin'));
 			Minz_Request::forward(array('c' => 'auth', 'a' => 'formLogin'));
 			break;
 			break;
-		case 'persona':
-			Minz_Request::forward(array('c' => 'auth', 'a' => 'personaLogin'));
-			break;
 		case 'http_auth':
 		case 'http_auth':
 		case 'none':
 		case 'none':
 			// It should not happened!
 			// It should not happened!
@@ -113,17 +113,19 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 		$file_mtime = @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js');
 		$file_mtime = @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js');
 		Minz_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . $file_mtime));
 		Minz_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . $file_mtime));
 
 
+		$conf = Minz_Configuration::get('system');
+		$limits = $conf->limits;
+		$this->view->cookie_days = round($limits['cookie_duration'] / 86400, 1);
+
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
 			$nonce = Minz_Session::param('nonce');
 			$nonce = Minz_Session::param('nonce');
 			$username = Minz_Request::param('username', '');
 			$username = Minz_Request::param('username', '');
 			$challenge = Minz_Request::param('challenge', '');
 			$challenge = Minz_Request::param('challenge', '');
-			try {
-				$conf = new FreshRSS_Configuration($username);
-			} catch(Minz_Exception $e) {
-				// $username is not a valid user, nor the configuration file!
-				Minz_Log::warning('Login failure: ' . $e->getMessage());
-				Minz_Request::bad(_t('invalid_login'),
-				                  array('c' => 'auth', 'a' => 'login'));
+
+			$conf = get_user_configuration($username);
+			if (is_null($conf)) {
+				Minz_Error::error(403, array(_t('feedback.auth.login.invalid')), false);
+				return;
 			}
 			}
 
 
 			$ok = FreshRSS_FormAuth::checkCredentials(
 			$ok = FreshRSS_FormAuth::checkCredentials(
@@ -143,17 +145,16 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 				}
 				}
 
 
 				// All is good, go back to the index.
 				// All is good, go back to the index.
-				Minz_Request::good(_t('login'),
+				Minz_Request::good(_t('feedback.auth.login.success'),
 				                   array('c' => 'index', 'a' => 'index'));
 				                   array('c' => 'index', 'a' => 'index'));
 			} else {
 			} else {
 				Minz_Log::warning('Password mismatch for' .
 				Minz_Log::warning('Password mismatch for' .
 				                  ' user=' . $username .
 				                  ' user=' . $username .
 				                  ', nonce=' . $nonce .
 				                  ', nonce=' . $nonce .
 				                  ', c=' . $challenge);
 				                  ', c=' . $challenge);
-				Minz_Request::bad(_t('invalid_login'),
-				                  array('c' => 'auth', 'a' => 'login'));
+				Minz_Error::error(403, array(_t('feedback.auth.login.invalid')), false);
 			}
 			}
-		} elseif (Minz_Configuration::unsafeAutologinEnabled()) {
+		} elseif (FreshRSS_Context::$system_conf->unsafe_autologin_enabled) {
 			$username = Minz_Request::param('u', '');
 			$username = Minz_Request::param('u', '');
 			$password = Minz_Request::param('p', '');
 			$password = Minz_Request::param('p', '');
 			Minz_Request::_param('p');
 			Minz_Request::_param('p');
@@ -162,11 +163,8 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 				return;
 				return;
 			}
 			}
 
 
-			try {
-				$conf = new FreshRSS_Configuration($username);
-			} catch(Minz_Exception $e) {
-				// $username is not a valid user, nor the configuration file!
-				Minz_Log::warning('Login failure: ' . $e->getMessage());
+			$conf = get_user_configuration($username);
+			if (is_null($conf)) {
 				return;
 				return;
 			}
 			}
 
 
@@ -182,89 +180,12 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 				Minz_Session::_param('passwordHash', $s);
 				Minz_Session::_param('passwordHash', $s);
 				FreshRSS_Auth::giveAccess();
 				FreshRSS_Auth::giveAccess();
 
 
-				Minz_Request::good(_t('login'),
+				Minz_Request::good(_t('feedback.auth.login.success'),
 				                   array('c' => 'index', 'a' => 'index'));
 				                   array('c' => 'index', 'a' => 'index'));
 			} else {
 			} else {
 				Minz_Log::warning('Unsafe password mismatch for user ' . $username);
 				Minz_Log::warning('Unsafe password mismatch for user ' . $username);
-				Minz_Request::bad(_t('invalid_login'),
-				                  array('c' => 'auth', 'a' => 'login'));
-			}
-		}
-	}
-
-	/**
-	 * This action handles Persona login page.
-	 *
-	 * If this action is reached through a POST request, assertion from Persona
-	 * is verificated and user connected if all is ok.
-	 *
-	 * Parameter is:
-	 *   - assertion (default: false)
-	 *
-	 * @todo: Persona system should be moved to a plugin
-	 */
-	public function personaLoginAction() {
-		$this->view->res = false;
-
-		if (Minz_Request::isPost()) {
-			$this->view->_useLayout(false);
-
-			$assert = Minz_Request::param('assertion');
-			$url = 'https://verifier.login.persona.org/verify';
-			$params = 'assertion=' . $assert . '&audience=' .
-			          urlencode(Minz_Url::display(null, 'php', true));
-			$ch = curl_init();
-			$options = array(
-				CURLOPT_URL => $url,
-				CURLOPT_RETURNTRANSFER => TRUE,
-				CURLOPT_POST => 2,
-				CURLOPT_POSTFIELDS => $params
-			);
-			curl_setopt_array($ch, $options);
-			$result = curl_exec($ch);
-			curl_close($ch);
-
-			$res = json_decode($result, true);
-
-			$login_ok = false;
-			$reason = '';
-			if ($res['status'] === 'okay') {
-				$email = filter_var($res['email'], FILTER_VALIDATE_EMAIL);
-				if ($email != '') {
-					$persona_file = DATA_PATH . '/persona/' . $email . '.txt';
-					if (($current_user = @file_get_contents($persona_file)) !== false) {
-						$current_user = trim($current_user);
-						try {
-							$conf = new FreshRSS_Configuration($current_user);
-							$login_ok = strcasecmp($email, $conf->mail_login) === 0;
-						} catch (Minz_Exception $e) {
-							//Permission denied or conf file does not exist
-							$reason = 'Invalid configuration for user ' .
-							          '[' . $current_user . '] ' . $e->getMessage();
-						}
-					}
-				} else {
-					$reason = 'Invalid email format [' . $res['email'] . ']';
-				}
-			} else {
-				$reason = $res['reason'];
+				Minz_Error::error(403, array(_t('feedback.auth.login.invalid')), false);
 			}
 			}
-
-			if ($login_ok) {
-				Minz_Session::_param('currentUser', $current_user);
-				Minz_Session::_param('mail', $email);
-				FreshRSS_Auth::giveAccess();
-				invalidateHttpCache();
-			} else {
-				Minz_Log::error($reason);
-
-				$res = array();
-				$res['status'] = 'failure';
-				$res['reason'] = _t('invalid_login');
-			}
-
-			header('Content-Type: application/json; charset=UTF-8');
-			$this->view->res = $res;
 		}
 		}
 	}
 	}
 
 
@@ -274,75 +195,18 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 	public function logoutAction() {
 	public function logoutAction() {
 		invalidateHttpCache();
 		invalidateHttpCache();
 		FreshRSS_Auth::removeAccess();
 		FreshRSS_Auth::removeAccess();
-		Minz_Request::good(_t('disconnected'),
+		Minz_Request::good(_t('feedback.auth.logout.success'),
 		                   array('c' => 'index', 'a' => 'index'));
 		                   array('c' => 'index', 'a' => 'index'));
 	}
 	}
 
 
 	/**
 	/**
-	 * This action resets the authentication system.
-	 *
-	 * After reseting, form auth is set by default.
+	 * This action gives possibility to a user to create an account.
 	 */
 	 */
-	public function resetAction() {
-		Minz_View::prependTitle(_t('auth_reset') . ' · ');
-
-		Minz_View::appendScript(Minz_Url::display(
-			'/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')
-		));
-
-		$this->view->no_form = false;
-		// Enable changement of auth only if Persona!
-		if (Minz_Configuration::authType() != 'persona') {
-			$this->view->message = array(
-				'status' => 'bad',
-				'title' => _t('damn'),
-				'body' => _t('auth_not_persona')
-			);
-			$this->view->no_form = true;
-			return;
+	public function registerAction() {
+		if (max_registrations_reached()) {
+			Minz_Error::error(403);
 		}
 		}
 
 
-		$conf = new FreshRSS_Configuration(Minz_Configuration::defaultUser());
-		// Admin user must have set its master password.
-		if (!$conf->passwordHash) {
-			$this->view->message = array(
-				'status' => 'bad',
-				'title' => _t('damn'),
-				'body' => _t('auth_no_password_set')
-			);
-			$this->view->no_form = true;
-			return;
-		}
-
-		invalidateHttpCache();
-
-		if (Minz_Request::isPost()) {
-			$nonce = Minz_Session::param('nonce');
-			$username = Minz_Request::param('username', '');
-			$challenge = Minz_Request::param('challenge', '');
-
-			$ok = FreshRSS_FormAuth::checkCredentials(
-				$username, $conf->passwordHash, $nonce, $challenge
-			);
-
-			if ($ok) {
-				Minz_Configuration::_authType('form');
-				$ok = Minz_Configuration::writeFile();
-
-				if ($ok) {
-					Minz_Request::good(_t('auth_form_set'));
-				} else {
-					Minz_Request::bad(_t('auth_form_not_set'),
-				                      array('c' => 'auth', 'a' => 'reset'));
-				}
-			} else {
-				Minz_Log::warning('Password mismatch for' .
-				                  ' user=' . $username .
-				                  ', nonce=' . $nonce .
-				                  ', c=' . $challenge);
-				Minz_Request::bad(_t('invalid_login'),
-				                  array('c' => 'auth', 'a' => 'reset'));
-			}
-		}
+		Minz_View::prependTitle(_t('gen.auth.registration.title') . ' · ');
 	}
 	}
 }
 }

+ 33 - 27
app/Controllers/categoryController.php

@@ -13,10 +13,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function firstAction() {
 	public function firstAction() {
 		if (!FreshRSS_Auth::hasAccess()) {
 		if (!FreshRSS_Auth::hasAccess()) {
-			Minz_Error::error(
-				403,
-				array('error' => array(_t('access_denied')))
-			);
+			Minz_Error::error(403);
 		}
 		}
 
 
 		$catDAO = new FreshRSS_CategoryDAO();
 		$catDAO = new FreshRSS_CategoryDAO();
@@ -33,18 +30,26 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 		$catDAO = new FreshRSS_CategoryDAO();
 		$catDAO = new FreshRSS_CategoryDAO();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 
+		$limits = FreshRSS_Context::$system_conf->limits;
+		$this->view->categories = $catDAO->listCategories(false);
+
+		if (count($this->view->categories) >= $limits['max_categories']) {
+			Minz_Request::bad(_t('feedback.sub.category.over_max', $limits['max_categories']),
+			                  $url_redirect);
+		}
+
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
 			$cat_name = Minz_Request::param('new-category');
 			$cat_name = Minz_Request::param('new-category');
 			if (!$cat_name) {
 			if (!$cat_name) {
-				Minz_Request::bad(_t('category_no_name'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.no_name'), $url_redirect);
 			}
 			}
 
 
 			$cat = new FreshRSS_Category($cat_name);
 			$cat = new FreshRSS_Category($cat_name);
 
 
 			if ($catDAO->searchByName($cat->name()) != null) {
 			if ($catDAO->searchByName($cat->name()) != null) {
-				Minz_Request::bad(_t('category_name_exists'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.name_exists'), $url_redirect);
 			}
 			}
 
 
 			$values = array(
 			$values = array(
@@ -53,9 +58,9 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 			);
 			);
 
 
 			if ($catDAO->addCategory($values)) {
 			if ($catDAO->addCategory($values)) {
-				Minz_Request::good(_t('category_created', $cat->name()), $url_redirect);
+				Minz_Request::good(_t('feedback.sub.category.created', $cat->name()), $url_redirect);
 			} else {
 			} else {
-				Minz_Request::bad(_t('error_occurred'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
 			}
 			}
 		}
 		}
 
 
@@ -79,11 +84,11 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 			$id = Minz_Request::param('id');
 			$id = Minz_Request::param('id');
 			$name = Minz_Request::param('name', '');
 			$name = Minz_Request::param('name', '');
 			if (strlen($name) <= 0) {
 			if (strlen($name) <= 0) {
-				Minz_Request::bad(_t('category_no_name'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.no_name'), $url_redirect);
 			}
 			}
 
 
 			if ($catDAO->searchById($id) == null) {
 			if ($catDAO->searchById($id) == null) {
-				Minz_Request::bad(_t('category_not_exist'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.not_exist'), $url_redirect);
 			}
 			}
 
 
 			$cat = new FreshRSS_Category($name);
 			$cat = new FreshRSS_Category($name);
@@ -92,9 +97,9 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 			);
 			);
 
 
 			if ($catDAO->updateCategory($id, $values)) {
 			if ($catDAO->updateCategory($id, $values)) {
-				Minz_Request::good(_t('category_updated'), $url_redirect);
+				Minz_Request::good(_t('feedback.sub.category.updated'), $url_redirect);
 			} else {
 			} else {
-				Minz_Request::bad(_t('error_occurred'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
 			}
 			}
 		}
 		}
 
 
@@ -112,7 +117,6 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	public function deleteAction() {
 	public function deleteAction() {
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$catDAO = new FreshRSS_CategoryDAO();
 		$catDAO = new FreshRSS_CategoryDAO();
-		$default_category = $catDAO->getDefault();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
@@ -120,26 +124,27 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 
 
 			$id = Minz_Request::param('id');
 			$id = Minz_Request::param('id');
 			if (!$id) {
 			if (!$id) {
-				Minz_Request::bad(_t('category_no_id'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
 			}
 			}
 
 
-			if ($id === $default_category->id()) {
-				Minz_Request::bad(_t('category_not_delete_default'), $url_redirect);
+			if ($id === FreshRSS_CategoryDAO::defaultCategoryId) {
+				Minz_Request::bad(_t('feedback.sub.category.not_delete_default'), $url_redirect);
 			}
 			}
 
 
-			if ($feedDAO->changeCategory($id, $default_category->id()) === false) {
-				Minz_Request::bad(_t('error_occurred'), $url_redirect);
+			if ($feedDAO->changeCategory($id, FreshRSS_CategoryDAO::defaultCategoryId) === false) {
+				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
 			}
 			}
 
 
 			if ($catDAO->deleteCategory($id) === false) {
 			if ($catDAO->deleteCategory($id) === false) {
-				Minz_Request::bad(_t('error_occurred'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
 			}
 			}
 
 
 			// Remove related queries.
 			// Remove related queries.
-			FreshRSS_Context::$conf->remove_query_by_get('c_' . $id);
-			FreshRSS_Context::$conf->save();
+			FreshRSS_Context::$user_conf->queries = remove_query_by_get(
+				'c_' . $id, FreshRSS_Context::$user_conf->queries);
+			FreshRSS_Context::$user_conf->save();
 
 
-			Minz_Request::good(_t('category_deleted'), $url_redirect);
+			Minz_Request::good(_t('feedback.sub.category.deleted'), $url_redirect);
 		}
 		}
 
 
 		Minz_Request::forward($url_redirect, true);
 		Minz_Request::forward($url_redirect, true);
@@ -161,7 +166,7 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 
 
 			$id = Minz_Request::param('id');
 			$id = Minz_Request::param('id');
 			if (!$id) {
 			if (!$id) {
-				Minz_Request::bad(_t('category_no_id'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
 			}
 			}
 
 
 			// List feeds to remove then related user queries.
 			// List feeds to remove then related user queries.
@@ -172,13 +177,14 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 
 
 				// Remove related queries
 				// Remove related queries
 				foreach ($feeds as $feed) {
 				foreach ($feeds as $feed) {
-					FreshRSS_Context::$conf->remove_query_by_get('f_' . $feed->id());
+					FreshRSS_Context::$user_conf->queries = remove_query_by_get(
+						'f_' . $feed->id(), FreshRSS_Context::$user_conf->queries);
 				}
 				}
-				FreshRSS_Context::$conf->save();
+				FreshRSS_Context::$user_conf->save();
 
 
-				Minz_Request::good(_t('category_emptied'), $url_redirect);
+				Minz_Request::good(_t('feedback.sub.category.emptied'), $url_redirect);
 			} else {
 			} else {
-				Minz_Request::bad(_t('error_occurred'), $url_redirect);
+				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
 			}
 			}
 		}
 		}
 
 

+ 117 - 137
app/Controllers/configureController.php

@@ -11,10 +11,7 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function firstAction() {
 	public function firstAction() {
 		if (!FreshRSS_Auth::hasAccess()) {
 		if (!FreshRSS_Auth::hasAccess()) {
-			Minz_Error::error(
-				403,
-				array('error' => array(_t('access_denied')))
-			);
+			Minz_Error::error(403);
 		}
 		}
 	}
 	}
 
 
@@ -44,33 +41,33 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function displayAction() {
 	public function displayAction() {
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
-			FreshRSS_Context::$conf->_language(Minz_Request::param('language', 'en'));
-			FreshRSS_Context::$conf->_theme(Minz_Request::param('theme', FreshRSS_Themes::$defaultTheme));
-			FreshRSS_Context::$conf->_content_width(Minz_Request::param('content_width', 'thin'));
-			FreshRSS_Context::$conf->_topline_read(Minz_Request::param('topline_read', false));
-			FreshRSS_Context::$conf->_topline_favorite(Minz_Request::param('topline_favorite', false));
-			FreshRSS_Context::$conf->_topline_date(Minz_Request::param('topline_date', false));
-			FreshRSS_Context::$conf->_topline_link(Minz_Request::param('topline_link', false));
-			FreshRSS_Context::$conf->_bottomline_read(Minz_Request::param('bottomline_read', false));
-			FreshRSS_Context::$conf->_bottomline_favorite(Minz_Request::param('bottomline_favorite', false));
-			FreshRSS_Context::$conf->_bottomline_sharing(Minz_Request::param('bottomline_sharing', false));
-			FreshRSS_Context::$conf->_bottomline_tags(Minz_Request::param('bottomline_tags', false));
-			FreshRSS_Context::$conf->_bottomline_date(Minz_Request::param('bottomline_date', false));
-			FreshRSS_Context::$conf->_bottomline_link(Minz_Request::param('bottomline_link', false));
-			FreshRSS_Context::$conf->_html5_notif_timeout(Minz_Request::param('html5_notif_timeout', 0));
-			FreshRSS_Context::$conf->save();
-
-			Minz_Session::_param('language', FreshRSS_Context::$conf->language);
-			Minz_Translate::reset();
+			FreshRSS_Context::$user_conf->language = Minz_Request::param('language', 'en');
+			FreshRSS_Context::$user_conf->theme = Minz_Request::param('theme', FreshRSS_Themes::$defaultTheme);
+			FreshRSS_Context::$user_conf->content_width = Minz_Request::param('content_width', 'thin');
+			FreshRSS_Context::$user_conf->topline_read = Minz_Request::param('topline_read', false);
+			FreshRSS_Context::$user_conf->topline_favorite = Minz_Request::param('topline_favorite', false);
+			FreshRSS_Context::$user_conf->topline_date = Minz_Request::param('topline_date', false);
+			FreshRSS_Context::$user_conf->topline_link = Minz_Request::param('topline_link', false);
+			FreshRSS_Context::$user_conf->bottomline_read = Minz_Request::param('bottomline_read', false);
+			FreshRSS_Context::$user_conf->bottomline_favorite = Minz_Request::param('bottomline_favorite', false);
+			FreshRSS_Context::$user_conf->bottomline_sharing = Minz_Request::param('bottomline_sharing', false);
+			FreshRSS_Context::$user_conf->bottomline_tags = Minz_Request::param('bottomline_tags', false);
+			FreshRSS_Context::$user_conf->bottomline_date = Minz_Request::param('bottomline_date', false);
+			FreshRSS_Context::$user_conf->bottomline_link = Minz_Request::param('bottomline_link', false);
+			FreshRSS_Context::$user_conf->html5_notif_timeout = Minz_Request::param('html5_notif_timeout', 0);
+			FreshRSS_Context::$user_conf->save();
+
+			Minz_Session::_param('language', FreshRSS_Context::$user_conf->language);
+			Minz_Translate::reset(FreshRSS_Context::$user_conf->language);
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
-			Minz_Request::good(_t('configuration_updated'),
+			Minz_Request::good(_t('feedback.conf.updated'),
 			                   array('c' => 'configure', 'a' => 'display'));
 			                   array('c' => 'configure', 'a' => 'display'));
 		}
 		}
 
 
 		$this->view->themes = FreshRSS_Themes::get();
 		$this->view->themes = FreshRSS_Themes::get();
 
 
-		Minz_View::prependTitle(_t('display_configuration') . ' · ');
+		Minz_View::prependTitle(_t('conf.display.title') . ' · ');
 	}
 	}
 
 
 	/**
 	/**
@@ -92,6 +89,7 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 *   - image lazy loading
 	 *   - image lazy loading
 	 *   - stick open articles to the top
 	 *   - stick open articles to the top
 	 *   - display a confirmation when reading all articles
 	 *   - display a confirmation when reading all articles
+	 *   - auto remove article after reading
 	 *   - article order (default: DESC)
 	 *   - article order (default: DESC)
 	 *   - mark articles as read when:
 	 *   - mark articles as read when:
 	 *       - displayed
 	 *       - displayed
@@ -102,35 +100,34 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function readingAction() {
 	public function readingAction() {
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
-			FreshRSS_Context::$conf->_posts_per_page(Minz_Request::param('posts_per_page', 10));
-			FreshRSS_Context::$conf->_view_mode(Minz_Request::param('view_mode', 'normal'));
-			FreshRSS_Context::$conf->_default_view((int)Minz_Request::param('default_view', FreshRSS_Entry::STATE_ALL));
-			FreshRSS_Context::$conf->_auto_load_more(Minz_Request::param('auto_load_more', false));
-			FreshRSS_Context::$conf->_display_posts(Minz_Request::param('display_posts', false));
-			FreshRSS_Context::$conf->_display_categories(Minz_Request::param('display_categories', false));
-			FreshRSS_Context::$conf->_hide_read_feeds(Minz_Request::param('hide_read_feeds', false));
-			FreshRSS_Context::$conf->_onread_jump_next(Minz_Request::param('onread_jump_next', false));
-			FreshRSS_Context::$conf->_lazyload(Minz_Request::param('lazyload', false));
-			FreshRSS_Context::$conf->_sticky_post(Minz_Request::param('sticky_post', false));
-			FreshRSS_Context::$conf->_reading_confirm(Minz_Request::param('reading_confirm', false));
-			FreshRSS_Context::$conf->_sort_order(Minz_Request::param('sort_order', 'DESC'));
-			FreshRSS_Context::$conf->_mark_when(array(
+			FreshRSS_Context::$user_conf->posts_per_page = Minz_Request::param('posts_per_page', 10);
+			FreshRSS_Context::$user_conf->view_mode = Minz_Request::param('view_mode', 'normal');
+			FreshRSS_Context::$user_conf->default_view = Minz_Request::param('default_view', 'adaptive');
+			FreshRSS_Context::$user_conf->auto_load_more = Minz_Request::param('auto_load_more', false);
+			FreshRSS_Context::$user_conf->display_posts = Minz_Request::param('display_posts', false);
+			FreshRSS_Context::$user_conf->display_categories = Minz_Request::param('display_categories', false);
+			FreshRSS_Context::$user_conf->hide_read_feeds = Minz_Request::param('hide_read_feeds', false);
+			FreshRSS_Context::$user_conf->onread_jump_next = Minz_Request::param('onread_jump_next', false);
+			FreshRSS_Context::$user_conf->lazyload = Minz_Request::param('lazyload', false);
+			FreshRSS_Context::$user_conf->sticky_post = Minz_Request::param('sticky_post', false);
+			FreshRSS_Context::$user_conf->reading_confirm = Minz_Request::param('reading_confirm', false);
+			FreshRSS_Context::$user_conf->auto_remove_article = Minz_Request::param('auto_remove_article', false);
+			FreshRSS_Context::$user_conf->mark_updated_article_unread = Minz_Request::param('mark_updated_article_unread', false);
+			FreshRSS_Context::$user_conf->sort_order = Minz_Request::param('sort_order', 'DESC');
+			FreshRSS_Context::$user_conf->mark_when = array(
 				'article' => Minz_Request::param('mark_open_article', false),
 				'article' => Minz_Request::param('mark_open_article', false),
 				'site' => Minz_Request::param('mark_open_site', false),
 				'site' => Minz_Request::param('mark_open_site', false),
 				'scroll' => Minz_Request::param('mark_scroll', false),
 				'scroll' => Minz_Request::param('mark_scroll', false),
 				'reception' => Minz_Request::param('mark_upon_reception', false),
 				'reception' => Minz_Request::param('mark_upon_reception', false),
-			));
-			FreshRSS_Context::$conf->save();
-
-			Minz_Session::_param('language', FreshRSS_Context::$conf->language);
-			Minz_Translate::reset();
+			);
+			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
-			Minz_Request::good(_t('configuration_updated'),
+			Minz_Request::good(_t('feedback.conf.updated'),
 			                   array('c' => 'configure', 'a' => 'reading'));
 			                   array('c' => 'configure', 'a' => 'reading'));
 		}
 		}
 
 
-		Minz_View::prependTitle(_t('reading_configuration') . ' · ');
+		Minz_View::prependTitle(_t('conf.reading.title') . ' · ');
 	}
 	}
 
 
 	/**
 	/**
@@ -142,16 +139,16 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function sharingAction() {
 	public function sharingAction() {
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
-			$params = Minz_Request::params();
-			FreshRSS_Context::$conf->_sharing($params['share']);
-			FreshRSS_Context::$conf->save();
+			$params = Minz_Request::fetchPOST();
+			FreshRSS_Context::$user_conf->sharing = $params['share'];
+			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
-			Minz_Request::good(_t('configuration_updated'),
+			Minz_Request::good(_t('feedback.conf.updated'),
 			                   array('c' => 'configure', 'a' => 'sharing'));
 			                   array('c' => 'configure', 'a' => 'sharing'));
 		}
 		}
 
 
-		Minz_View::prependTitle(_t('sharing') . ' · ');
+		Minz_View::prependTitle(_t('conf.sharing.title') . ' · ');
 	}
 	}
 
 
 	/**
 	/**
@@ -185,15 +182,15 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 				}
 				}
 			}
 			}
 
 
-			FreshRSS_Context::$conf->_shortcuts($shortcuts_ok);
-			FreshRSS_Context::$conf->save();
+			FreshRSS_Context::$user_conf->shortcuts = $shortcuts_ok;
+			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
-			Minz_Request::good(_t('shortcuts_updated'),
+			Minz_Request::good(_t('feedback.conf.shortcuts_updated'),
 			                   array('c' => 'configure', 'a' => 'shortcut'));
 			                   array('c' => 'configure', 'a' => 'shortcut'));
 		}
 		}
 
 
-		Minz_View::prependTitle(_t('shortcuts') . ' · ');
+		Minz_View::prependTitle(_t('conf.shortcut.title') . ' · ');
 	}
 	}
 
 
 	/**
 	/**
@@ -213,17 +210,17 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function archivingAction() {
 	public function archivingAction() {
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
-			FreshRSS_Context::$conf->_old_entries(Minz_Request::param('old_entries', 3));
-			FreshRSS_Context::$conf->_keep_history_default(Minz_Request::param('keep_history_default', 0));
-			FreshRSS_Context::$conf->_ttl_default(Minz_Request::param('ttl_default', -2));
-			FreshRSS_Context::$conf->save();
+			FreshRSS_Context::$user_conf->old_entries = Minz_Request::param('old_entries', 3);
+			FreshRSS_Context::$user_conf->keep_history_default = Minz_Request::param('keep_history_default', 0);
+			FreshRSS_Context::$user_conf->ttl_default = Minz_Request::param('ttl_default', -2);
+			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
-			Minz_Request::good(_t('configuration_updated'),
+			Minz_Request::good(_t('feedback.conf.updated'),
 			                   array('c' => 'configure', 'a' => 'archiving'));
 			                   array('c' => 'configure', 'a' => 'archiving'));
 		}
 		}
 
 
-		Minz_View::prependTitle(_t('archiving_configuration') . ' · ');
+		Minz_View::prependTitle(_t('conf.archiving.title') . ' · ');
 
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$this->view->nb_total = $entryDAO->count();
 		$this->view->nb_total = $entryDAO->count();
@@ -245,80 +242,30 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 * checking if categories and feeds are still in use.
 	 * checking if categories and feeds are still in use.
 	 */
 	 */
 	public function queriesAction() {
 	public function queriesAction() {
+		$category_dao = new FreshRSS_CategoryDAO();
+		$feed_dao = FreshRSS_Factory::createFeedDao();
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
-			$queries = Minz_Request::param('queries', array());
+			$params = Minz_Request::param('queries', array());
 
 
-			foreach ($queries as $key => $query) {
+			foreach ($params as $key => $query) {
 				if (!$query['name']) {
 				if (!$query['name']) {
-					$query['name'] = _t('query_number', $key + 1);
+					$query['name'] = _t('conf.query.number', $key + 1);
 				}
 				}
+				$queries[] = new FreshRSS_UserQuery($query, $feed_dao, $category_dao);
 			}
 			}
-			FreshRSS_Context::$conf->_queries($queries);
-			FreshRSS_Context::$conf->save();
+			FreshRSS_Context::$user_conf->queries = $queries;
+			FreshRSS_Context::$user_conf->save();
 
 
-			Minz_Request::good(_t('configuration_updated'),
+			Minz_Request::good(_t('feedback.conf.updated'),
 			                   array('c' => 'configure', 'a' => 'queries'));
 			                   array('c' => 'configure', 'a' => 'queries'));
 		} else {
 		} else {
-			$this->view->query_get = array();
-			$cat_dao = new FreshRSS_CategoryDAO();
-			$feed_dao = FreshRSS_Factory::createFeedDao();
-			foreach (FreshRSS_Context::$conf->queries as $key => $query) {
-				if (!isset($query['get'])) {
-					continue;
-				}
-
-				switch ($query['get'][0]) {
-				case 'c':
-					$category = $cat_dao->searchById(substr($query['get'], 2));
-
-					$deprecated = true;
-					$cat_name = '';
-					if ($category) {
-						$cat_name = $category->name();
-						$deprecated = false;
-					}
-
-					$this->view->query_get[$key] = array(
-						'type' => 'category',
-						'name' => $cat_name,
-						'deprecated' => $deprecated,
-					);
-					break;
-				case 'f':
-					$feed = $feed_dao->searchById(substr($query['get'], 2));
-
-					$deprecated = true;
-					$feed_name = '';
-					if ($feed) {
-						$feed_name = $feed->name();
-						$deprecated = false;
-					}
-
-					$this->view->query_get[$key] = array(
-						'type' => 'feed',
-						'name' => $feed_name,
-						'deprecated' => $deprecated,
-					);
-					break;
-				case 's':
-					$this->view->query_get[$key] = array(
-						'type' => 'favorite',
-						'name' => 'favorite',
-						'deprecated' => false,
-					);
-					break;
-				case 'a':
-					$this->view->query_get[$key] = array(
-						'type' => 'all',
-						'name' => 'all',
-						'deprecated' => false,
-					);
-					break;
-				}
+			$this->view->queries = array();
+			foreach (FreshRSS_Context::$user_conf->queries as $key => $query) {
+				$this->view->queries[$key] = new FreshRSS_UserQuery($query, $feed_dao, $category_dao);
 			}
 			}
 		}
 		}
 
 
-		Minz_View::prependTitle(_t('queries') . ' · ');
+		Minz_View::prependTitle(_t('conf.query.title') . ' · ');
 	}
 	}
 
 
 	/**
 	/**
@@ -329,23 +276,56 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 * lean data.
 	 * lean data.
 	 */
 	 */
 	public function addQueryAction() {
 	public function addQueryAction() {
-		$whitelist = array('get', 'order', 'name', 'search', 'state');
-		$queries = FreshRSS_Context::$conf->queries;
-		$query = Minz_Request::params();
-		$query['name'] = _t('query_number', count($queries) + 1);
-		foreach ($query as $key => $value) {
-			if (!in_array($key, $whitelist)) {
-				unset($query[$key]);
-			}
-		}
-		if (!empty($query['state']) && $query['state'] & FreshRSS_Entry::STATE_STRICT) {
-			$query['state'] -= FreshRSS_Entry::STATE_STRICT;
+		$category_dao = new FreshRSS_CategoryDAO();
+		$feed_dao = FreshRSS_Factory::createFeedDao();
+		$queries = array();
+		foreach (FreshRSS_Context::$user_conf->queries as $key => $query) {
+			$queries[$key] = new FreshRSS_UserQuery($query, $feed_dao, $category_dao);
 		}
 		}
-		$queries[] = $query;
-		FreshRSS_Context::$conf->_queries($queries);
-		FreshRSS_Context::$conf->save();
+		$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);
+
+		FreshRSS_Context::$user_conf->queries = $queries;
+		FreshRSS_Context::$user_conf->save();
 
 
-		Minz_Request::good(_t('query_created', $query['name']),
+		Minz_Request::good(_t('feedback.conf.query_created', $query['name']),
 		                   array('c' => 'configure', 'a' => 'queries'));
 		                   array('c' => 'configure', 'a' => 'queries'));
 	}
 	}
+
+	/**
+	 * This action handles the system configuration page.
+	 *
+	 * It displays the system configuration page.
+	 * If this action is reach through a POST request, it stores all new
+	 * configuration values then sends a notification to the user.
+	 *
+	 * The options available on the page are:
+	 *   - user limit (default: 1)
+	 *   - user category limit (default: 16384)
+	 *   - user feed limit (default: 16384)
+	 */
+	public function systemAction() {
+		if (!FreshRSS_Auth::hasAccess('admin')) {
+			Minz_Error::error(403);
+		}
+		if (Minz_Request::isPost()) {
+			$limits = FreshRSS_Context::$system_conf->limits;
+			$limits['max_registrations'] = Minz_Request::param('max-registrations', 1);
+			$limits['max_feeds'] = Minz_Request::param('max-feeds', 16384);
+			$limits['max_categories'] = Minz_Request::param('max-categories', 16384);
+			FreshRSS_Context::$system_conf->limits = $limits;
+			FreshRSS_Context::$system_conf->title = Minz_Request::param('instance-name', 'FreshRSS');
+			FreshRSS_Context::$system_conf->auto_update_url = Minz_Request::param('auto-update-url', false);
+			FreshRSS_Context::$system_conf->save();
+
+			invalidateHttpCache();
+
+			Minz_Session::_param('notification', array(
+				'type' => 'good',
+				'content' => _t('feedback.conf.updated')
+			));
+		}
+	}
 }
 }

+ 25 - 26
app/Controllers/entryController.php

@@ -11,18 +11,7 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function firstAction() {
 	public function firstAction() {
 		if (!FreshRSS_Auth::hasAccess()) {
 		if (!FreshRSS_Auth::hasAccess()) {
-			Minz_Error::error(
-				403,
-				array('error' => array(_t('access_denied')))
-			);
-		}
-
-		// Keep parameter information (output) to do a correct redirection at
-		// the end.
-		$this->params = array();
-		$output = Minz_Request::param('output', '');
-		if ($output != '' && FreshRSS_Context::$conf->view_mode !== $output) {
-			$this->params['output'] = $output;
+			Minz_Error::error(403);
 		}
 		}
 
 
 		// If ajax request, we do not print layout
 		// If ajax request, we do not print layout
@@ -45,19 +34,30 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 	 *   - nextGet (default: $get)
 	 *   - nextGet (default: $get)
 	 *   - idMax (default: 0)
 	 *   - idMax (default: 0)
 	 *   - is_read (default: true)
 	 *   - is_read (default: true)
-	 *
-	 * @todo nextGet system should not be present here... or should be?
 	 */
 	 */
 	public function readAction() {
 	public function readAction() {
 		$id = Minz_Request::param('id');
 		$id = Minz_Request::param('id');
 		$get = Minz_Request::param('get');
 		$get = Minz_Request::param('get');
 		$next_get = Minz_Request::param('nextGet', $get);
 		$next_get = Minz_Request::param('nextGet', $get);
 		$id_max = Minz_Request::param('idMax', 0);
 		$id_max = Minz_Request::param('idMax', 0);
+		FreshRSS_Context::$search = new FreshRSS_Search(Minz_Request::param('search', ''));
+
+		FreshRSS_Context::$state = Minz_Request::param('state', 0);
+		if (FreshRSS_Context::isStateEnabled(FreshRSS_Entry::STATE_FAVORITE)) {
+			FreshRSS_Context::$state = FreshRSS_Entry::STATE_FAVORITE;
+		} elseif (FreshRSS_Context::isStateEnabled(FreshRSS_Entry::STATE_NOT_FAVORITE)) {
+			FreshRSS_Context::$state = FreshRSS_Entry::STATE_NOT_FAVORITE;
+		} else {
+			FreshRSS_Context::$state = 0;
+		}
+
+		$params = array();
 
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		if ($id === false) {
 		if ($id === false) {
 			// id is false? It MUST be a POST request!
 			// id is false? It MUST be a POST request!
 			if (!Minz_Request::isPost()) {
 			if (!Minz_Request::isPost()) {
+				Minz_Request::bad(_t('feedback.access.not_found'), array('c' => 'index', 'a' => 'index'));
 				return;
 				return;
 			}
 			}
 
 
@@ -69,16 +69,16 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 				$get = substr($get, 2);
 				$get = substr($get, 2);
 				switch($type_get) {
 				switch($type_get) {
 				case 'c':
 				case 'c':
-					$entryDAO->markReadCat($get, $id_max);
+					$entryDAO->markReadCat($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state);
 					break;
 					break;
 				case 'f':
 				case 'f':
-					$entryDAO->markReadFeed($get, $id_max);
+					$entryDAO->markReadFeed($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state);
 					break;
 					break;
 				case 's':
 				case 's':
-					$entryDAO->markReadEntries($id_max, true);
+					$entryDAO->markReadEntries($id_max, true, 0, FreshRSS_Context::$search);
 					break;
 					break;
 				case 'a':
 				case 'a':
-					$entryDAO->markReadEntries($id_max);
+					$entryDAO->markReadEntries($id_max, false, 0, FreshRSS_Context::$search, FreshRSS_Context::$state);
 					break;
 					break;
 				}
 				}
 
 
@@ -86,7 +86,7 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 					// Redirect to the correct page (category, feed or starred)
 					// Redirect to the correct page (category, feed or starred)
 					// Not "a" because it is the default value if nothing is
 					// Not "a" because it is the default value if nothing is
 					// given.
 					// given.
-					$this->params['get'] = $next_get;
+					$params['get'] = $next_get;
 				}
 				}
 			}
 			}
 		} else {
 		} else {
@@ -95,10 +95,10 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 		}
 		}
 
 
 		if (!$this->ajax) {
 		if (!$this->ajax) {
-			Minz_Request::good(_t('feeds_marked_read'), array(
+			Minz_Request::good(_t('feedback.sub.feed.marked_read'), array(
 				'c' => 'index',
 				'c' => 'index',
 				'a' => 'index',
 				'a' => 'index',
-				'params' => $this->params,
+				'params' => $params,
 			), true);
 			), true);
 		}
 		}
 	}
 	}
@@ -123,7 +123,6 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 			Minz_Request::forward(array(
 			Minz_Request::forward(array(
 				'c' => 'index',
 				'c' => 'index',
 				'a' => 'index',
 				'a' => 'index',
-				'params' => $this->params,
 			), true);
 			), true);
 		}
 		}
 	}
 	}
@@ -155,7 +154,7 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 		$feedDAO->updateCachedValues();
 		$feedDAO->updateCachedValues();
 
 
 		invalidateHttpCache();
 		invalidateHttpCache();
-		Minz_Request::good(_t('optimization_complete'), $url_redirect);
+		Minz_Request::good(_t('feedback.admin.optimization_complete'), $url_redirect);
 	}
 	}
 
 
 	/**
 	/**
@@ -167,7 +166,7 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 	public function purgeAction() {
 	public function purgeAction() {
 		@set_time_limit(300);
 		@set_time_limit(300);
 
 
-		$nb_month_old = max(FreshRSS_Context::$conf->old_entries, 1);
+		$nb_month_old = max(FreshRSS_Context::$user_conf->old_entries, 1);
 		$date_min = time() - (3600 * 24 * 30 * $nb_month_old);
 		$date_min = time() - (3600 * 24 * 30 * $nb_month_old);
 
 
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
@@ -181,7 +180,7 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 			if ($feed_history == -2) {
 			if ($feed_history == -2) {
 				// TODO: -2 must be a constant!
 				// TODO: -2 must be a constant!
 				// -2 means we take the default value from configuration
 				// -2 means we take the default value from configuration
-				$feed_history = FreshRSS_Context::$conf->keep_history_default;
+				$feed_history = FreshRSS_Context::$user_conf->keep_history_default;
 			}
 			}
 
 
 			if ($feed_history >= 0) {
 			if ($feed_history >= 0) {
@@ -196,7 +195,7 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 		$feedDAO->updateCachedValues();
 		$feedDAO->updateCachedValues();
 
 
 		invalidateHttpCache();
 		invalidateHttpCache();
-		Minz_Request::good(_t('purge_completed', $nb_total), array(
+		Minz_Request::good(_t('feedback.sub.purge_completed', $nb_total), array(
 			'c' => 'configure',
 			'c' => 'configure',
 			'a' => 'archiving'
 			'a' => 'archiving'
 		));
 		));

+ 21 - 19
app/Controllers/errorController.php

@@ -9,41 +9,43 @@ class FreshRSS_error_Controller extends Minz_ActionController {
 	 *
 	 *
 	 * It is called by Minz_Error::error() method.
 	 * It is called by Minz_Error::error() method.
 	 *
 	 *
-	 * Parameters are:
-	 *   - code (default: 404)
-	 *   - logs (default: array())
+	 * Parameters are passed by Minz_Session to have a proper url:
+	 *   - error_code (default: 404)
+	 *   - error_logs (default: array())
 	 */
 	 */
 	public function indexAction() {
 	public function indexAction() {
-		$code_int = Minz_Request::param('code', 404);
+		$code_int = Minz_Session::param('error_code', 404);
+		$error_logs = Minz_Session::param('error_logs', array());
+		Minz_Session::_param('error_code');
+		Minz_Session::_param('error_logs');
+
 		switch ($code_int) {
 		switch ($code_int) {
+		case 200 :
+			header('HTTP/1.1 200 OK');
+			break;
 		case 403:
 		case 403:
+			header('HTTP/1.1 403 Forbidden');
 			$this->view->code = 'Error 403 - Forbidden';
 			$this->view->code = 'Error 403 - Forbidden';
-			break;
-		case 404:
-			$this->view->code = 'Error 404 - Not found';
+			$this->view->errorMessage = _t('feedback.access.denied');
 			break;
 			break;
 		case 500:
 		case 500:
+			header('HTTP/1.1 500 Internal Server Error');
 			$this->view->code = 'Error 500 - Internal Server Error';
 			$this->view->code = 'Error 500 - Internal Server Error';
 			break;
 			break;
 		case 503:
 		case 503:
+			header('HTTP/1.1 503 Service Unavailable');
 			$this->view->code = 'Error 503 - Service Unavailable';
 			$this->view->code = 'Error 503 - Service Unavailable';
 			break;
 			break;
+		case 404:
 		default:
 		default:
+			header('HTTP/1.1 404 Not Found');
 			$this->view->code = 'Error 404 - Not found';
 			$this->view->code = 'Error 404 - Not found';
+			$this->view->errorMessage = _t('feedback.access.not_found');
 		}
 		}
 
 
-		$errors = Minz_Request::param('logs', array());
-		$this->view->errorMessage = trim(implode($errors));
-		if ($this->view->errorMessage == '') {
-			switch($code_int) {
-			case 403:
-				$this->view->errorMessage = _t('forbidden_access');
-				break;
-			case 404:
-			default:
-				$this->view->errorMessage = _t('page_not_found');
-				break;
-			}
+		$error_message = trim(implode($error_logs));
+		if ($error_message !== '') {
+			$this->view->errorMessage = $error_message;
 		}
 		}
 
 
 		Minz_View::prependTitle($this->view->code . ' · ');
 		Minz_View::prependTitle($this->view->code . ' · ');

+ 215 - 0
app/Controllers/extensionController.php

@@ -0,0 +1,215 @@
+<?php
+
+/**
+ * The controller to manage extensions.
+ */
+class FreshRSS_extension_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);
+		}
+	}
+
+	/**
+	 * This action lists all the extensions available to the current user.
+	 */
+	public function indexAction() {
+		Minz_View::prependTitle(_t('admin.extensions.title') . ' · ');
+		$this->view->extension_list = array(
+			'system' => array(),
+			'user' => array(),
+		);
+
+		$extensions = Minz_ExtensionManager::listExtensions();
+		foreach ($extensions as $ext) {
+			$this->view->extension_list[$ext->getType()][] = $ext;
+		}
+	}
+
+	/**
+	 * This action handles configuration of a given extension.
+	 *
+	 * Only administrator can configure a system extension.
+	 *
+	 * Parameters are:
+	 * - e: the extension name (urlencoded)
+	 * - additional parameters which should be handle by the extension
+	 *   handleConfigureAction() method (POST request).
+	 */
+	public function configureAction() {
+		if (Minz_Request::param('ajax')) {
+			$this->view->_useLayout(false);
+		} else {
+			$this->indexAction();
+			$this->view->change_view('extension', 'index');
+		}
+
+		$ext_name = urldecode(Minz_Request::param('e'));
+		$ext = Minz_ExtensionManager::findExtension($ext_name);
+
+		if (is_null($ext)) {
+			Minz_Error::error(404);
+		}
+		if ($ext->getType() === 'system' && !FreshRSS_Auth::hasAccess('admin')) {
+			Minz_Error::error(403);
+		}
+
+		$this->view->extension = $ext;
+		$this->view->extension->handleConfigureAction();
+	}
+
+	/**
+	 * This action enables a disabled extension for the current user.
+	 *
+	 * System extensions can only be enabled by an administrator.
+	 * This action must be reached by a POST request.
+	 *
+	 * Parameter is:
+	 * - e: the extension name (urlencoded).
+	 */
+	public function enableAction() {
+		$url_redirect = array('c' => 'extension', 'a' => 'index');
+
+		if (Minz_Request::isPost()) {
+			$ext_name = urldecode(Minz_Request::param('e'));
+			$ext = Minz_ExtensionManager::findExtension($ext_name);
+
+			if (is_null($ext)) {
+				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name),
+				                  $url_redirect);
+			}
+
+			if ($ext->isEnabled()) {
+				Minz_Request::bad(_t('feedback.extensions.already_enabled', $ext_name),
+				                  $url_redirect);
+			}
+
+			$conf = null;
+			if ($ext->getType() === 'system' && FreshRSS_Auth::hasAccess('admin')) {
+				$conf = FreshRSS_Context::$system_conf;
+			} elseif ($ext->getType() === 'user') {
+				$conf = FreshRSS_Context::$user_conf;
+			} else {
+				Minz_Request::bad(_t('feedback.extensions.no_access', $ext_name),
+				                  $url_redirect);
+			}
+
+			$res = $ext->install();
+
+			if ($res === true) {
+				$ext_list = $conf->extensions_enabled;
+				array_push_unique($ext_list, $ext_name);
+				$conf->extensions_enabled = $ext_list;
+				$conf->save();
+
+				Minz_Request::good(_t('feedback.extensions.enable.ok', $ext_name),
+				                   $url_redirect);
+			} else {
+				Minz_Log::warning('Can not enable extension ' . $ext_name . ': ' . $res);
+				Minz_Request::bad(_t('feedback.extensions.enable.ko', $ext_name, _url('index', 'logs')),
+				                  $url_redirect);
+			}
+		}
+
+		Minz_Request::forward($url_redirect, true);
+	}
+
+	/**
+	 * This action disables an enabled extension for the current user.
+	 *
+	 * System extensions can only be disabled by an administrator.
+	 * This action must be reached by a POST request.
+	 *
+	 * Parameter is:
+	 * - e: the extension name (urlencoded).
+	 */
+	public function disableAction() {
+		$url_redirect = array('c' => 'extension', 'a' => 'index');
+
+		if (Minz_Request::isPost()) {
+			$ext_name = urldecode(Minz_Request::param('e'));
+			$ext = Minz_ExtensionManager::findExtension($ext_name);
+
+			if (is_null($ext)) {
+				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name),
+				                  $url_redirect);
+			}
+
+			if (!$ext->isEnabled()) {
+				Minz_Request::bad(_t('feedback.extensions.not_enabled', $ext_name),
+				                  $url_redirect);
+			}
+
+			$conf = null;
+			if ($ext->getType() === 'system' && FreshRSS_Auth::hasAccess('admin')) {
+				$conf = FreshRSS_Context::$system_conf;
+			} elseif ($ext->getType() === 'user') {
+				$conf = FreshRSS_Context::$user_conf;
+			} else {
+				Minz_Request::bad(_t('feedback.extensions.no_access', $ext_name),
+				                  $url_redirect);
+			}
+
+			$res = $ext->uninstall();
+
+			if ($res === true) {
+				$ext_list = $conf->extensions_enabled;
+				array_remove($ext_list, $ext_name);
+				$conf->extensions_enabled = $ext_list;
+				$conf->save();
+
+				Minz_Request::good(_t('feedback.extensions.disable.ok', $ext_name),
+				                   $url_redirect);
+			} else {
+				Minz_Log::warning('Can not unable extension ' . $ext_name . ': ' . $res);
+				Minz_Request::bad(_t('feedback.extensions.disable.ko', $ext_name, _url('index', 'logs')),
+				                  $url_redirect);
+			}
+		}
+
+		Minz_Request::forward($url_redirect, true);
+	}
+
+	/**
+	 * This action handles deletion of an extension.
+	 *
+	 * Only administrator can remove an extension.
+	 * This action must be reached by a POST request.
+	 *
+	 * Parameter is:
+	 * -e: extension name (urlencoded)
+	 */
+	public function removeAction() {
+		if (!FreshRSS_Auth::hasAccess('admin')) {
+			Minz_Error::error(403);
+		}
+
+		$url_redirect = array('c' => 'extension', 'a' => 'index');
+
+		if (Minz_Request::isPost()) {
+			$ext_name = urldecode(Minz_Request::param('e'));
+			$ext = Minz_ExtensionManager::findExtension($ext_name);
+
+			if (is_null($ext)) {
+				Minz_Request::bad(_t('feedback.extensions.not_found', $ext_name),
+				                  $url_redirect);
+			}
+
+			$res = recursive_unlink($ext->getPath());
+			if ($res) {
+				Minz_Request::good(_t('feedback.extensions.removed', $ext_name),
+				                  $url_redirect);
+			} else {
+				Minz_Request::bad(_t('feedback.extensions.cannot_delete', $ext_name),
+				                  $url_redirect);
+			}
+		}
+
+		Minz_Request::forward($url_redirect, true);
+	}
+}

+ 303 - 193
app/Controllers/feedController.php

@@ -14,20 +14,75 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			// Token is useful in the case that anonymous refresh is forbidden
 			// Token is useful in the case that anonymous refresh is forbidden
 			// and CRON task cannot be used with php command so the user can
 			// and CRON task cannot be used with php command so the user can
 			// set a CRON task to refresh his feeds by using token inside url
 			// set a CRON task to refresh his feeds by using token inside url
-			$token = FreshRSS_Context::$conf->token;
+			$token = FreshRSS_Context::$user_conf->token;
 			$token_param = Minz_Request::param('token', '');
 			$token_param = Minz_Request::param('token', '');
 			$token_is_ok = ($token != '' && $token == $token_param);
 			$token_is_ok = ($token != '' && $token == $token_param);
 			$action = Minz_Request::actionName();
 			$action = Minz_Request::actionName();
+			$allow_anonymous_refresh = FreshRSS_Context::$system_conf->allow_anonymous_refresh;
 			if ($action !== 'actualize' ||
 			if ($action !== 'actualize' ||
-					!(Minz_Configuration::allowAnonymousRefresh() || $token_is_ok)) {
-				Minz_Error::error(
-					403,
-					array('error' => array(_t('access_denied')))
-				);
+					!($allow_anonymous_refresh || $token_is_ok)) {
+				Minz_Error::error(403);
 			}
 			}
 		}
 		}
 	}
 	}
 
 
+	public static function addFeed($url, $title = '', $cat_id = 0, $new_cat_name = '', $http_auth = '') {
+		FreshRSS_UserDAO::touch();
+		@set_time_limit(300);
+
+		$catDAO = new FreshRSS_CategoryDAO();
+
+		$cat = null;
+		if ($cat_id > 0) {
+			$cat = $catDAO->searchById($cat_id);
+		}
+		if ($cat == null && $new_cat_name != '') {
+			$cat = $catDAO->addCategory(array('name' => $new_cat_name));
+		}
+		if ($cat == null) {
+			$catDAO->checkDefault();
+		}
+		$cat_id = $cat == null ? FreshRSS_CategoryDAO::defaultCategoryId : $cat->id();
+
+		$feed = new FreshRSS_Feed($url);	//Throws FreshRSS_BadUrl_Exception
+		$feed->_httpAuth($http_auth);
+		$feed->load(true);	//Throws FreshRSS_Feed_Exception, Minz_FileNotExistException
+		$feed->_category($cat_id);
+
+		$feedDAO = FreshRSS_Factory::createFeedDao();
+		if ($feedDAO->searchByUrl($feed->url())) {
+			throw new FreshRSS_AlreadySubscribed_Exception($url, $feed->name());
+		}
+
+		// Call the extension hook
+		$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
+		if ($feed === null) {
+			throw new FreshRSS_FeedNotAdded_Exception($url, $feed->name());
+		}
+
+		$values = array(
+			'url' => $feed->url(),
+			'category' => $feed->category(),
+			'name' => $title != '' ? $title : $feed->name(),
+			'website' => $feed->website(),
+			'description' => $feed->description(),
+			'lastUpdate' => time(),
+			'httpAuth' => $feed->httpAuth(),
+		);
+
+		$id = $feedDAO->addFeed($values);
+		if (!$id) {
+			// There was an error in database... we cannot say what here.
+			throw new FreshRSS_FeedNotAdded_Exception($url, $feed->name());
+		}
+		$feed->_id($id);
+
+		// Ok, feed has been added in database. Now we have to refresh entries.
+		self::actualizeFeed($id, $url, false, null, true);
+
+		return $feed;
+	}
+
 	/**
 	/**
 	 * This action subscribes to a feed.
 	 * This action subscribes to a feed.
 	 *
 	 *
@@ -61,131 +116,66 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 		}
 
 
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
-		$this->catDAO = new FreshRSS_CategoryDAO();
 		$url_redirect = array(
 		$url_redirect = array(
 			'c' => 'subscription',
 			'c' => 'subscription',
 			'a' => 'index',
 			'a' => 'index',
 			'params' => array(),
 			'params' => array(),
 		);
 		);
 
 
-		if (Minz_Request::isPost()) {
-			@set_time_limit(300);
+		$limits = FreshRSS_Context::$system_conf->limits;
+		$this->view->feeds = $feedDAO->listFeeds();
+		if (count($this->view->feeds) >= $limits['max_feeds']) {
+			Minz_Request::bad(_t('feedback.sub.feed.over_max', $limits['max_feeds']),
+			                  $url_redirect);
+		}
 
 
+		if (Minz_Request::isPost()) {
 			$cat = Minz_Request::param('category');
 			$cat = Minz_Request::param('category');
+			$new_cat_name = '';
 			if ($cat === 'nc') {
 			if ($cat === 'nc') {
 				// User want to create a new category, new_category parameter
 				// User want to create a new category, new_category parameter
 				// must exist
 				// must exist
 				$new_cat = Minz_Request::param('new_category');
 				$new_cat = Minz_Request::param('new_category');
-				if (empty($new_cat['name'])) {
-					$cat = false;
-				} else {
-					$cat = $this->catDAO->addCategory($new_cat);
-				}
-			}
-
-			if ($cat === false) {
-				// If category was not given or if creating new category failed,
-				// get the default category
-				$this->catDAO->checkDefault();
-				$def_cat = $this->catDAO->getDefault();
-				$cat = $def_cat->id();
+				$new_cat_name = isset($new_cat['name']) ? $new_cat['name'] : '';
 			}
 			}
 
 
 			// HTTP information are useful if feed is protected behind a
 			// HTTP information are useful if feed is protected behind a
 			// HTTP authentication
 			// HTTP authentication
-			$user = Minz_Request::param('http_user');
-			$pass = Minz_Request::param('http_pass');
+			$user = trim(Minz_Request::param('http_user', ''));
+			$pass = Minz_Request::param('http_pass', '');
 			$http_auth = '';
 			$http_auth = '';
-			if ($user != '' || $pass != '') {
+			if ($user != '' && $pass != '') {	//TODO: Sanitize
 				$http_auth = $user . ':' . $pass;
 				$http_auth = $user . ':' . $pass;
 			}
 			}
 
 
-			$transaction_started = false;
 			try {
 			try {
-				$feed = new FreshRSS_Feed($url);
+				$feed = self::addFeed($url, '', $cat, $new_cat_name, $http_auth);
 			} catch (FreshRSS_BadUrl_Exception $e) {
 			} catch (FreshRSS_BadUrl_Exception $e) {
 				// Given url was not a valid url!
 				// Given url was not a valid url!
 				Minz_Log::warning($e->getMessage());
 				Minz_Log::warning($e->getMessage());
-				Minz_Request::bad(_t('invalid_url', $url), $url_redirect);
-			}
-
-			try {
-				$feed->load(true);
+				Minz_Request::bad(_t('feedback.sub.feed.invalid_url', $url), $url_redirect);
 			} catch (FreshRSS_Feed_Exception $e) {
 			} catch (FreshRSS_Feed_Exception $e) {
 				// Something went bad (timeout, server not found, etc.)
 				// Something went bad (timeout, server not found, etc.)
 				Minz_Log::warning($e->getMessage());
 				Minz_Log::warning($e->getMessage());
-				Minz_Request::bad(
-					_t('internal_problem_feed', _url('index', 'logs')),
-					$url_redirect
-				);
+				Minz_Request::bad(_t('feedback.sub.feed.internal_problem', _url('index', 'logs')), $url_redirect);
 			} catch (Minz_FileNotExistException $e) {
 			} catch (Minz_FileNotExistException $e) {
 				// Cache directory doesn't exist!
 				// Cache directory doesn't exist!
 				Minz_Log::error($e->getMessage());
 				Minz_Log::error($e->getMessage());
-				Minz_Request::bad(
-					_t('internal_problem_feed', _url('index', 'logs')),
-					$url_redirect
-				);
+				Minz_Request::bad(_t('feedback.sub.feed.internal_problem', _url('index', 'logs')), $url_redirect);
+			} catch (FreshRSS_AlreadySubscribed_Exception $e) {
+				Minz_Request::bad(_t('feedback.sub.feed.already_subscribed', $e->feedName()), $url_redirect);
+			} catch (FreshRSS_FeedNotAdded_Exception $e) {
+				Minz_Request::bad(_t('feedback.sub.feed.not_added', $e->feedName()), $url_redirect);
 			}
 			}
 
 
-			if ($feedDAO->searchByUrl($feed->url())) {
-				Minz_Request::bad(_t('already_subscribed', $feed->name()), $url_redirect);
-			}
-
-			$feed->_category($cat);
-			$feed->_httpAuth($http_auth);
-
-			$values = array(
-				'url' => $feed->url(),
-				'category' => $feed->category(),
-				'name' => $feed->name(),
-				'website' => $feed->website(),
-				'description' => $feed->description(),
-				'lastUpdate' => time(),
-				'httpAuth' => $feed->httpAuth(),
-			);
-
-			$id = $feedDAO->addFeed($values);
-			if (!$id) {
-				// There was an error in database... we cannot say what here.
-				Minz_Request::bad(_t('feed_not_added', $feed->name()), $url_redirect);
-			}
-
-			// Ok, feed has been added in database. Now we have to refresh entries.
-			$feed->_id($id);
-			$feed->faviconPrepare();
-
-			$is_read = FreshRSS_Context::$conf->mark_when['reception'] ? 1 : 0;
-
-			$entryDAO = FreshRSS_Factory::createEntryDao();
-			// We want chronological order and SimplePie uses reverse order.
-			$entries = array_reverse($feed->entries());
-
-			// Calculate date of oldest entries we accept in DB.
-			$nb_month_old = FreshRSS_Context::$conf->old_entries;
-			$date_min = time() - (3600 * 24 * 30 * $nb_month_old);
-
-			// Use a shared statement and a transaction to improve a LOT the
-			// performances.
-			$prepared_statement = $entryDAO->addEntryPrepare();
-			$feedDAO->beginTransaction();
-			foreach ($entries as $entry) {
-				// Entries are added without any verification.
-				$values = $entry->toArray();
-				$values['id_feed'] = $feed->id();
-				$values['id'] = min(time(), $entry->date(true)) . uSecString();
-				$values['is_read'] = $is_read;
-				$entryDAO->addEntry($values, $prepared_statement);
-			}
-			$feedDAO->updateLastUpdate($feed->id());
-			$feedDAO->commit();
-
 			// Entries are in DB, we redirect to feed configuration page.
 			// Entries are in DB, we redirect to feed configuration page.
 			$url_redirect['params']['id'] = $feed->id();
 			$url_redirect['params']['id'] = $feed->id();
-			Minz_Request::good(_t('feed_added', $feed->name()), $url_redirect);
+			Minz_Request::good(_t('feedback.sub.feed.added', $feed->name()), $url_redirect);
 		} else {
 		} else {
 			// GET request: we must ask confirmation to user before adding feed.
 			// GET request: we must ask confirmation to user before adding feed.
-			Minz_View::prependTitle(_t('add_rss_feed') . ' · ');
+			Minz_View::prependTitle(_t('sub.feed.title_add') . ' · ');
 
 
+			$this->catDAO = new FreshRSS_CategoryDAO();
 			$this->view->categories = $this->catDAO->listCategories(false);
 			$this->view->categories = $this->catDAO->listCategories(false);
 			$this->view->feed = new FreshRSS_Feed($url);
 			$this->view->feed = new FreshRSS_Feed($url);
 			try {
 			try {
@@ -200,7 +190,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			if ($feed) {
 			if ($feed) {
 				// Already subscribe so we redirect to the feed configuration page.
 				// Already subscribe so we redirect to the feed configuration page.
 				$url_redirect['params']['id'] = $feed->id();
 				$url_redirect['params']['id'] = $feed->id();
-				Minz_Request::good(_t('already_subscribed', $feed->name()), $url_redirect);
+				Minz_Request::good(_t('feedback.sub.feed.already_subscribed', $feed->name()), $url_redirect);
 			}
 			}
 		}
 		}
 	}
 	}
@@ -230,112 +220,168 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 
 
 		invalidateHttpCache();
 		invalidateHttpCache();
 		if ($n === false) {
 		if ($n === false) {
-			Minz_Request::bad(_t('error_occurred'), $url_redirect);
+			Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
 		} else {
 		} else {
-			Minz_Request::good(_t('n_entries_deleted', $n), $url_redirect);
+			Minz_Request::good(_t('feedback.sub.feed.n_entries_deleted', $n), $url_redirect);
 		}
 		}
 	}
 	}
 
 
-	/**
-	 * This action actualizes entries from one or several feeds.
-	 *
-	 * Parameters are:
-	 *   - id (default: false)
-	 *   - force (default: false)
-	 * If id is not specified, all the feeds are actualized. But if force is
-	 * false, process stops at 10 feeds to avoid time execution problem.
-	 */
-	public function actualizeAction() {
+	public static function actualizeFeed($feed_id, $feed_url, $force, $simplePiePush = null, $isNewFeed = false) {
 		@set_time_limit(300);
 		@set_time_limit(300);
 
 
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 
 
-		Minz_Session::_param('actualize_feeds', false);
-		$id = Minz_Request::param('id');
-		$force = Minz_Request::param('force');
-
 		// Create a list of feeds to actualize.
 		// Create a list of feeds to actualize.
-		// If id is set and valid, corresponding feed is added to the list but
+		// If feed_id is set and valid, corresponding feed is added to the list but
 		// alone in order to automatize further process.
 		// alone in order to automatize further process.
 		$feeds = array();
 		$feeds = array();
-		if ($id) {
-			$feed = $feedDAO->searchById($id);
+		if ($feed_id > 0 || $feed_url) {
+			$feed = $feed_id > 0 ? $feedDAO->searchById($feed_id) : $feedDAO->searchByUrl($feed_url);
 			if ($feed) {
 			if ($feed) {
 				$feeds[] = $feed;
 				$feeds[] = $feed;
 			}
 			}
 		} else {
 		} else {
-			$feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$conf->ttl_default);
+			$feeds = $feedDAO->listFeedsOrderUpdate(-1);
 		}
 		}
 
 
 		// Calculate date of oldest entries we accept in DB.
 		// Calculate date of oldest entries we accept in DB.
-		$nb_month_old = max(FreshRSS_Context::$conf->old_entries, 1);
+		$nb_month_old = max(FreshRSS_Context::$user_conf->old_entries, 1);
 		$date_min = time() - (3600 * 24 * 30 * $nb_month_old);
 		$date_min = time() - (3600 * 24 * 30 * $nb_month_old);
 
 
+		// PubSubHubbub support
+		$pubsubhubbubEnabledGeneral = FreshRSS_Context::$system_conf->pubsubhubbub_enabled;
+		$pshbMinAge = time() - (3600 * 24);  //TODO: Make a configuration.
+
 		$updated_feeds = 0;
 		$updated_feeds = 0;
-		$is_read = FreshRSS_Context::$conf->mark_when['reception'] ? 1 : 0;
+		$is_read = FreshRSS_Context::$user_conf->mark_when['reception'] ? 1 : 0;
 		foreach ($feeds as $feed) {
 		foreach ($feeds as $feed) {
+			$url = $feed->url();	//For detection of HTTP 301
+
+			$pubSubHubbubEnabled = $pubsubhubbubEnabledGeneral && $feed->pubSubHubbubEnabled();
+			if ((!$simplePiePush) && (!$feed_id) && $pubSubHubbubEnabled && ($feed->lastUpdate() > $pshbMinAge)) {
+				//$text = 'Skip pull of feed using PubSubHubbub: ' . $url;
+				//Minz_Log::debug($text);
+				//file_put_contents(USERS_PATH . '/_/log_pshb.txt', date('c') . "\t" . $text . "\n", FILE_APPEND);
+				continue;	//When PubSubHubbub is used, do not pull refresh so often
+			}
+
+			$mtime = 0;
+			$ttl = $feed->ttl();
+			if ($ttl == -1) {
+				continue;	//Feed refresh is disabled
+			}
+			if ((!$simplePiePush) && (!$feed_id) &&
+				($feed->lastUpdate() + 10 >= time() - ($ttl == -2 ? FreshRSS_Context::$user_conf->ttl_default : $ttl))) {
+				//Too early to refresh from source, but check whether the feed was updated by another user
+				$mtime = $feed->cacheModifiedTime();
+				if ($feed->lastUpdate() + 10 >= $mtime) {
+					continue;	//Nothing newer from other users
+				}
+				//Minz_Log::debug($feed->url() . ' was updated at ' . date('c', $mtime) . ' by another user');
+				//Will take advantage of the newer cache
+			}
+
 			if (!$feed->lock()) {
 			if (!$feed->lock()) {
 				Minz_Log::notice('Feed already being actualized: ' . $feed->url());
 				Minz_Log::notice('Feed already being actualized: ' . $feed->url());
 				continue;
 				continue;
 			}
 			}
 
 
 			try {
 			try {
-				// Load entries
-				$feed->load(false);
+				if ($simplePiePush) {
+					$feed->loadEntries($simplePiePush);	//Used by PubSubHubbub
+				} else {
+					$feed->load(false, $isNewFeed);
+				}
 			} catch (FreshRSS_Feed_Exception $e) {
 			} catch (FreshRSS_Feed_Exception $e) {
-				Minz_Log::notice($e->getMessage());
-				$feedDAO->updateLastUpdate($feed->id(), 1);
+				Minz_Log::warning($e->getMessage());
+				$feedDAO->updateLastUpdate($feed->id(), true);
+				$feed->unlock();
 				continue;
 				continue;
 			}
 			}
 
 
-			$url = $feed->url();
 			$feed_history = $feed->keepHistory();
 			$feed_history = $feed->keepHistory();
-			if ($feed_history == -2) {
+			if ($isNewFeed) {
+				$feed_history = -1; //∞
+			} elseif ($feed_history == -2) {
 				// TODO: -2 must be a constant!
 				// TODO: -2 must be a constant!
 				// -2 means we take the default value from configuration
 				// -2 means we take the default value from configuration
-				$feed_history = FreshRSS_Context::$conf->keep_history_default;
+				$feed_history = FreshRSS_Context::$user_conf->keep_history_default;
 			}
 			}
 
 
 			// We want chronological order and SimplePie uses reverse order.
 			// We want chronological order and SimplePie uses reverse order.
 			$entries = array_reverse($feed->entries());
 			$entries = array_reverse($feed->entries());
 			if (count($entries) > 0) {
 			if (count($entries) > 0) {
-				// For this feed, check last n entry GUIDs already in database.
-				$existing_guids = array_fill_keys($entryDAO->listLastGuidsByFeed(
-					$feed->id(), count($entries) + 10
-				), 1);
-				$use_declared_date = empty($existing_guids);
+				$newGuids = array();
+				foreach ($entries as $entry) {
+					$newGuids[] = safe_ascii($entry->guid());
+				}
+				// For this feed, check existing GUIDs already in database.
+				$existingHashForGuids = $entryDAO->listHashForFeedGuids($feed->id(), $newGuids);
+				unset($newGuids);
 
 
+				$oldGuids = array();
 				// Add entries in database if possible.
 				// Add entries in database if possible.
-				$prepared_statement = $entryDAO->addEntryPrepare();
-				$feedDAO->beginTransaction();
 				foreach ($entries as $entry) {
 				foreach ($entries as $entry) {
 					$entry_date = $entry->date(true);
 					$entry_date = $entry->date(true);
-					if (isset($existing_guids[$entry->guid()]) ||
-							($feed_history == 0 && $entry_date < $date_min)) {
-						// This entry already exists in DB or should not be added
-						// considering configuration and date.
-						continue;
-					}
-
-					$id = uTimeString();
-					if ($use_declared_date || $entry_date < $date_min) {
-						// Use declared date at first import.
-						$id = min(time(), $entry_date) . uSecString();
+					if (isset($existingHashForGuids[$entry->guid()])) {
+						$existingHash = $existingHashForGuids[$entry->guid()];
+						if (strcasecmp($existingHash, $entry->hash()) === 0 || trim($existingHash, '0') == '') {
+							//This entry already exists and is unchanged. TODO: Remove the test with the zero'ed hash in FreshRSS v1.3
+							$oldGuids[] = $entry->guid();
+						} else {	//This entry already exists but has been updated
+							//Minz_Log::debug('Entry with GUID `' . $entry->guid() . '` updated in feed ' . $feed->id() .
+								//', old hash ' . $existingHash . ', new hash ' . $entry->hash());
+							//TODO: Make an updated/is_read policy by feed, in addition to the global one.
+							$entry->_isRead(FreshRSS_Context::$user_conf->mark_updated_article_unread ? false : null);	//Change is_read according to policy.
+							if (!$entryDAO->inTransaction()) {
+								$entryDAO->beginTransaction();
+							}
+							$entryDAO->updateEntry($entry->toArray());
+						}
+					} elseif ($feed_history == 0 && $entry_date < $date_min) {
+						// This entry should not be added considering configuration and date.
+						$oldGuids[] = $entry->guid();
+					} else {
+						if ($isNewFeed) {
+							$id = min(time(), $entry_date) . uSecString();
+						} elseif ($entry_date < $date_min) {
+							$id = min(time(), $entry_date) . uSecString();
+							$entry->_isRead(true);	//Old article that was not in database. Probably an error, so mark as read
+						} else {
+							$id = uTimeString();
+							$entry->_isRead($is_read);
+						}
+						$entry->_id($id);
+
+						$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
+						if ($entry === null) {
+							// An extension has returned a null value, there is nothing to insert.
+							continue;
+						}
+
+						if ($pubSubHubbubEnabled && !$simplePiePush) {	//We use push, but have discovered an article by pull!
+							$text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' . $url . ' GUID ' . $entry->guid();
+							file_put_contents(USERS_PATH . '/_/log_pshb.txt', date('c') . "\t" . $text . "\n", FILE_APPEND);
+							Minz_Log::warning($text);
+							$pubSubHubbubEnabled = false;
+							$feed->pubSubHubbubError(true);
+						}
+
+						if (!$entryDAO->inTransaction()) {
+							$entryDAO->beginTransaction();
+						}
+						$entryDAO->addEntry($entry->toArray());
 					}
 					}
-
-					$values = $entry->toArray();
-					$values['id'] = $id;
-					$values['is_read'] = $is_read;
-					$entryDAO->addEntry($values, $prepared_statement);
 				}
 				}
+				$entryDAO->updateLastSeen($feed->id(), $oldGuids, $mtime);
 			}
 			}
 
 
 			if ($feed_history >= 0 && rand(0, 30) === 1) {
 			if ($feed_history >= 0 && rand(0, 30) === 1) {
 				// TODO: move this function in web cron when available (see entry::purge)
 				// TODO: move this function in web cron when available (see entry::purge)
 				// Remove old entries once in 30.
 				// Remove old entries once in 30.
-				if (!$feedDAO->hasTransaction()) {
-					$feedDAO->beginTransaction();
+				if (!$entryDAO->inTransaction()) {
+					$entryDAO->beginTransaction();
 				}
 				}
 
 
 				$nb = $feedDAO->cleanOldEntries($feed->id(),
 				$nb = $feedDAO->cleanOldEntries($feed->id(),
@@ -347,18 +393,37 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 				}
 				}
 			}
 			}
 
 
-			$feedDAO->updateLastUpdate($feed->id(), 0, $feedDAO->hasTransaction());
-			if ($feedDAO->hasTransaction()) {
-				$feedDAO->commit();
+			$feedDAO->updateLastUpdate($feed->id(), false, $entryDAO->inTransaction(), $mtime);
+			if ($entryDAO->inTransaction()) {
+				$entryDAO->commit();
 			}
 			}
 
 
-			if ($feed->url() !== $url) {
-				// HTTP 301 Moved Permanently
+			if ($feed->hubUrl() && $feed->selfUrl()) {	//selfUrl has priority for PubSubHubbub
+				if ($feed->selfUrl() !== $url) {	//https://code.google.com/p/pubsubhubbub/wiki/MovingFeedsOrChangingHubs
+					$selfUrl = checkUrl($feed->selfUrl());
+					if ($selfUrl) {
+						Minz_Log::debug('PubSubHubbub unsubscribe ' . $feed->url());
+						if (!$feed->pubSubHubbubSubscribe(false)) {	//Unsubscribe
+							Minz_Log::warning('Error while PubSubHubbub unsubscribing from ' . $feed->url());
+						}
+						$feed->_url($selfUrl, false);
+						Minz_Log::notice('Feed ' . $url . ' canonical address moved to ' . $feed->url());
+						$feedDAO->updateFeed($feed->id(), array('url' => $feed->url()));
+					}
+				}
+			}
+			elseif ($feed->url() !== $url) {	// HTTP 301 Moved Permanently
 				Minz_Log::notice('Feed ' . $url . ' moved permanently to ' . $feed->url());
 				Minz_Log::notice('Feed ' . $url . ' moved permanently to ' . $feed->url());
 				$feedDAO->updateFeed($feed->id(), array('url' => $feed->url()));
 				$feedDAO->updateFeed($feed->id(), array('url' => $feed->url()));
 			}
 			}
 
 
 			$feed->faviconPrepare();
 			$feed->faviconPrepare();
+			if ($pubsubhubbubEnabledGeneral && $feed->pubSubHubbubPrepare()) {
+				Minz_Log::notice('PubSubHubbub subscribe ' . $feed->url());
+				if (!$feed->pubSubHubbubSubscribe(true)) {	//Subscribe
+					Minz_Log::warning('Error while PubSubHubbub subscribing to ' . $feed->url());
+				}
+			}
 			$feed->unlock();
 			$feed->unlock();
 			$updated_feeds++;
 			$updated_feeds++;
 			unset($feed);
 			unset($feed);
@@ -369,6 +434,26 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 				break;
 				break;
 			}
 			}
 		}
 		}
+		return array($updated_feeds, reset($feeds));
+	}
+
+	/**
+	 * This action actualizes entries from one or several feeds.
+	 *
+	 * Parameters are:
+	 *   - id (default: false): Feed ID
+	 *   - url (default: false): Feed URL
+	 *   - force (default: false)
+	 * If id and url are not specified, all the feeds are actualized. But if force is
+	 * false, process stops at 10 feeds to avoid time execution problem.
+	 */
+	public function actualizeAction() {
+		Minz_Session::_param('actualize_feeds', false);
+		$id = Minz_Request::param('id');
+		$url = Minz_Request::param('url');
+		$force = Minz_Request::param('force');
+
+		list($updated_feeds, $feed) = self::actualizeFeed($id, $url, $force);
 
 
 		if (Minz_Request::param('ajax')) {
 		if (Minz_Request::param('ajax')) {
 			// Most of the time, ajax request is for only one feed. But since
 			// Most of the time, ajax request is for only one feed. But since
@@ -376,24 +461,56 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			// are several updated feeds.
 			// are several updated feeds.
 			$notif = array(
 			$notif = array(
 				'type' => 'good',
 				'type' => 'good',
-				'content' => _t('feeds_actualized')
+				'content' => _t('feedback.sub.feed.actualizeds')
 			);
 			);
 			Minz_Session::_param('notification', $notif);
 			Minz_Session::_param('notification', $notif);
 			// No layout in ajax request.
 			// No layout in ajax request.
 			$this->view->_useLayout(false);
 			$this->view->_useLayout(false);
-			return;
+		} else {
+			// Redirect to the main page with correct notification.
+			if ($updated_feeds === 1) {
+				Minz_Request::good(_t('feedback.sub.feed.actualized', $feed->name()), array(
+					'params' => array('get' => 'f_' . $feed->id())
+				));
+			} elseif ($updated_feeds > 1) {
+				Minz_Request::good(_t('feedback.sub.feed.n_actualized', $updated_feeds), array());
+			} else {
+				Minz_Request::good(_t('feedback.sub.feed.no_refresh'), array());
+			}
 		}
 		}
+		return $updated_feeds;
+	}
 
 
-		// Redirect to the main page with correct notification.
-		if ($updated_feeds === 1) {
-			$feed = reset($feeds);
-			Minz_Request::good(_t('feed_actualized', $feed->name()),
-			                   array('get' => 'f_' . $feed->id()));
-		} elseif ($updated_feeds > 1) {
-			Minz_Request::good(_t('n_feeds_actualized', $updated_feeds), array());
-		} else {
-			Minz_Request::good(_t('no_feed_to_refresh'), array());
+	public static function renameFeed($feed_id, $feed_name) {
+		if ($feed_id <= 0 || $feed_name == '') {
+			return false;
+		}
+		FreshRSS_UserDAO::touch();
+		$feedDAO = FreshRSS_Factory::createFeedDao();
+		return $feedDAO->updateFeed($feed_id, array('name' => $feed_name));
+	}
+
+	public static function moveFeed($feed_id, $cat_id, $new_cat_name = '') {
+		if ($feed_id <= 0 || ($cat_id <= 0 && $new_cat_name == '')) {
+			return false;
+		}
+		FreshRSS_UserDAO::touch();
+
+		$catDAO = new FreshRSS_CategoryDAO();
+		if ($cat_id > 0) {
+			$cat = $catDAO->searchById($cat_id);
+			$cat_id = $cat == null ? 0 : $cat->id();
+		}
+		if ($cat_id <= 1 && $new_cat_name != '') {
+			$cat_id = $catDAO->addCategory(array('name' => $new_cat_name));
 		}
 		}
+		if ($cat_id <= 1) {
+			$catDAO->checkDefault();
+			$cat_id = FreshRSS_CategoryDAO::defaultCategoryId;
+		}
+
+		$feedDAO = FreshRSS_Factory::createFeedDao();
+		return $feedDAO->updateFeed($feed_id, array('category' => $cat_id));
 	}
 	}
 
 
 	/**
 	/**
@@ -416,31 +533,31 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		$feed_id = Minz_Request::param('f_id');
 		$feed_id = Minz_Request::param('f_id');
 		$cat_id = Minz_Request::param('c_id');
 		$cat_id = Minz_Request::param('c_id');
 
 
-		if ($cat_id === false) {
-			// If category was not given get the default one.
-			$catDAO = new FreshRSS_CategoryDAO();
-			$catDAO->checkDefault();
-			$def_cat = $catDAO->getDefault();
-			$cat_id = $def_cat->id();
-		}
-
-		$feedDAO = FreshRSS_Factory::createFeedDao();
-		$values = array('category' => $cat_id);
-
-		$feed = $feedDAO->searchById($feed_id);
-		if ($feed && ($feed->category() == $cat_id ||
-		              $feedDAO->updateFeed($feed_id, $values))) {
+		if (self::moveFeed($feed_id, $cat_id)) {
 			// TODO: return something useful
 			// TODO: return something useful
 		} else {
 		} else {
 			Minz_Log::warning('Cannot move feed `' . $feed_id . '` ' .
 			Minz_Log::warning('Cannot move feed `' . $feed_id . '` ' .
 			                  'in the category `' . $cat_id . '`');
 			                  'in the category `' . $cat_id . '`');
-			Minz_Error::error(
-				404,
-				array('error' => array(_t('error_occurred')))
-			);
+			Minz_Error::error(404);
 		}
 		}
 	}
 	}
 
 
+	public static function deleteFeed($feed_id) {
+		FreshRSS_UserDAO::touch();
+		$feedDAO = FreshRSS_Factory::createFeedDao();
+		if ($feedDAO->deleteFeed($feed_id)) {
+			// TODO: Delete old favicon
+
+			// Remove related queries
+			FreshRSS_Context::$user_conf->queries = remove_query_by_get(
+				'f_' . $feed_id, FreshRSS_Context::$user_conf->queries);
+			FreshRSS_Context::$user_conf->save();
+
+			return true;
+		}
+		return false;
+	}
+
 	/**
 	/**
 	 * This action deletes a feed.
 	 * This action deletes a feed.
 	 *
 	 *
@@ -459,23 +576,16 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		if (!$redirect_url) {
 		if (!$redirect_url) {
 			$redirect_url = array('c' => 'subscription', 'a' => 'index');
 			$redirect_url = array('c' => 'subscription', 'a' => 'index');
 		}
 		}
-
 		if (!Minz_Request::isPost()) {
 		if (!Minz_Request::isPost()) {
 			Minz_Request::forward($redirect_url, true);
 			Minz_Request::forward($redirect_url, true);
 		}
 		}
 
 
 		$id = Minz_Request::param('id');
 		$id = Minz_Request::param('id');
-		$feedDAO = FreshRSS_Factory::createFeedDao();
-		if ($feedDAO->deleteFeed($id)) {
-			// TODO: Delete old favicon
-
-			// Remove related queries
-			FreshRSS_Context::$conf->remove_query_by_get('f_' . $id);
-			FreshRSS_Context::$conf->save();
 
 
-			Minz_Request::good(_t('feed_deleted'), $redirect_url);
+		if (self::deleteFeed($id)) {
+			Minz_Request::good(_t('feedback.sub.feed.deleted'), $redirect_url);
 		} else {
 		} else {
-			Minz_Request::bad(_t('error_occurred'), $redirect_url);
+			Minz_Request::bad(_t('feedback.sub.feed.error'), $redirect_url);
 		}
 		}
 	}
 	}
 }
 }

+ 320 - 152
app/Controllers/importExportController.php

@@ -11,10 +11,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function firstAction() {
 	public function firstAction() {
 		if (!FreshRSS_Auth::hasAccess()) {
 		if (!FreshRSS_Auth::hasAccess()) {
-			Minz_Error::error(
-				403,
-				array('error' => array(_t('access_denied')))
-			);
+			Minz_Error::error(403);
 		}
 		}
 
 
 		require_once(LIB_PATH . '/lib_opml.php');
 		require_once(LIB_PATH . '/lib_opml.php');
@@ -29,35 +26,17 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function indexAction() {
 	public function indexAction() {
 		$this->view->feeds = $this->feedDAO->listFeeds();
 		$this->view->feeds = $this->feedDAO->listFeeds();
-		Minz_View::prependTitle(_t('import_export') . ' · ');
+		Minz_View::prependTitle(_t('sub.import_export.title') . ' · ');
 	}
 	}
 
 
-	/**
-	 * This action handles import action.
-	 *
-	 * It must be reached by a POST request.
-	 *
-	 * Parameter is:
-	 *   - file (default: nothing!)
-	 * Available file types are: zip, json or xml.
-	 */
-	public function importAction() {
-		if (!Minz_Request::isPost()) {
-			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
-		}
-
-		$file = $_FILES['file'];
-		$status_file = $file['error'];
-
-		if ($status_file !== 0) {
-			Minz_Log::error('File cannot be uploaded. Error code: ' . $status_file);
-			Minz_Request::bad(_t('file_cannot_be_uploaded'),
-			                  array('c' => 'importExport', 'a' => 'index'));
-		}
+	public function importFile($name, $path, $username = null) {
+		require_once(LIB_PATH . '/lib_opml.php');
 
 
-		@set_time_limit(300);
+		$this->catDAO = new FreshRSS_CategoryDAO($username);
+		$this->entryDAO = FreshRSS_Factory::createEntryDao($username);
+		$this->feedDAO = FreshRSS_Factory::createFeedDao($username);
 
 
-		$type_file = $this->guessFileType($file['name']);
+		$type_file = self::guessFileType($name);
 
 
 		$list_files = array(
 		$list_files = array(
 			'opml' => array(),
 			'opml' => array(),
@@ -68,21 +47,17 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		// We try to list all files according to their type
 		// We try to list all files according to their type
 		$list = array();
 		$list = array();
 		if ($type_file === 'zip' && extension_loaded('zip')) {
 		if ($type_file === 'zip' && extension_loaded('zip')) {
-			$zip = zip_open($file['tmp_name']);
-
+			$zip = zip_open($path);
 			if (!is_resource($zip)) {
 			if (!is_resource($zip)) {
 				// zip_open cannot open file: something is wrong
 				// zip_open cannot open file: something is wrong
-				Minz_Log::error('Zip archive cannot be imported. Error code: ' . $zip);
-				Minz_Request::bad(_t('zip_error'),
-				                  array('c' => 'importExport', 'a' => 'index'));
+				throw new FreshRSS_Zip_Exception($zip);
 			}
 			}
-
 			while (($zipfile = zip_read($zip)) !== false) {
 			while (($zipfile = zip_read($zip)) !== false) {
 				if (!is_resource($zipfile)) {
 				if (!is_resource($zipfile)) {
 					// zip_entry() can also return an error code!
 					// zip_entry() can also return an error code!
-					Minz_Log::error('Zip file cannot be imported. Error code: ' . $zipfile);
+					throw new FreshRSS_Zip_Exception($zipfile);
 				} else {
 				} else {
-					$type_zipfile = $this->guessFileType(zip_entry_name($zipfile));
+					$type_zipfile = self::guessFileType(zip_entry_name($zipfile));
 					if ($type_file !== 'unknown') {
 					if ($type_file !== 'unknown') {
 						$list_files[$type_zipfile][] = zip_entry_read(
 						$list_files[$type_zipfile][] = zip_entry_read(
 							$zipfile,
 							$zipfile,
@@ -91,35 +66,94 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 					}
 					}
 				}
 				}
 			}
 			}
-
 			zip_close($zip);
 			zip_close($zip);
 		} elseif ($type_file === 'zip') {
 		} elseif ($type_file === 'zip') {
-			// Zip extension is not loaded
-			Minz_Request::bad(_t('no_zip_extension'),
-			                  array('c' => 'importExport', 'a' => 'index'));
+			// ZIP extension is not loaded
+			throw new FreshRSS_ZipMissing_Exception();
 		} elseif ($type_file !== 'unknown') {
 		} elseif ($type_file !== 'unknown') {
-			$list_files[$type_file][] = file_get_contents($file['tmp_name']);
+			$list_files[$type_file][] = file_get_contents($path);
 		}
 		}
 
 
 		// Import file contents.
 		// Import file contents.
 		// OPML first(so categories and feeds are imported)
 		// OPML first(so categories and feeds are imported)
 		// Starred articles then so the "favourite" status is already set
 		// Starred articles then so the "favourite" status is already set
 		// And finally all other files.
 		// And finally all other files.
-		$error = false;
+		$ok = true;
 		foreach ($list_files['opml'] as $opml_file) {
 		foreach ($list_files['opml'] as $opml_file) {
-			$error = $this->importOpml($opml_file);
+			if (!$this->importOpml($opml_file)) {
+				$ok = false;
+				if (FreshRSS_Context::$isCli) {
+					fwrite(STDERR, 'FreshRSS error during OPML import' . "\n");
+				} else {
+					Minz_Log::warning('Error during OPML import');
+				}
+			}
 		}
 		}
 		foreach ($list_files['json_starred'] as $article_file) {
 		foreach ($list_files['json_starred'] as $article_file) {
-			$error = $this->importJson($article_file, true);
+			if (!$this->importJson($article_file, true)) {
+				$ok = false;
+				if (FreshRSS_Context::$isCli) {
+					fwrite(STDERR, 'FreshRSS error during JSON stars import' . "\n");
+				} else {
+					Minz_Log::warning('Error during JSON stars import');
+				}
+			}
 		}
 		}
 		foreach ($list_files['json_feed'] as $article_file) {
 		foreach ($list_files['json_feed'] as $article_file) {
-			$error = $this->importJson($article_file);
+			if (!$this->importJson($article_file)) {
+				$ok = false;
+				if (FreshRSS_Context::$isCli) {
+					fwrite(STDERR, 'FreshRSS error during JSON feeds import' . "\n");
+				} else {
+					Minz_Log::warning('Error during JSON feeds import');
+				}
+			}
+		}
+
+		return $ok;
+	}
+
+	/**
+	 * This action handles import action.
+	 *
+	 * It must be reached by a POST request.
+	 *
+	 * Parameter is:
+	 *   - file (default: nothing!)
+	 * Available file types are: zip, json or xml.
+	 */
+	public function importAction() {
+		if (!Minz_Request::isPost()) {
+			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
+		}
+
+		$file = $_FILES['file'];
+		$status_file = $file['error'];
+
+		if ($status_file !== 0) {
+			Minz_Log::warning('File cannot be uploaded. Error code: ' . $status_file);
+			Minz_Request::bad(_t('feedback.import_export.file_cannot_be_uploaded'),
+			                  array('c' => 'importExport', 'a' => 'index'));
+		}
+
+		@set_time_limit(300);
+
+		$error = false;
+		try {
+			$error = !$this->importFile($file['name'], $file['tmp_name']);
+		} catch (FreshRSS_ZipMissing_Exception $zme) {
+			Minz_Request::bad(_t('feedback.import_export.no_zip_extension'),
+				array('c' => 'importExport', 'a' => 'index'));
+		} catch (FreshRSS_Zip_Exception $ze) {
+			Minz_Log::warning('ZIP archive cannot be imported. Error code: ' . $ze->zipErrorCode());
+			Minz_Request::bad(_t('feedback.import_export.zip_error'),
+				array('c' => 'importExport', 'a' => 'index'));
 		}
 		}
 
 
 		// And finally, we get import status and redirect to the home page
 		// And finally, we get import status and redirect to the home page
 		Minz_Session::_param('actualize_feeds', true);
 		Minz_Session::_param('actualize_feeds', true);
-		$content_notif = $error === true ? _t('feeds_imported_with_errors') :
-		                                   _t('feeds_imported');
+		$content_notif = $error === true ? _t('feedback.import_export.feeds_imported_with_errors') :
+		                                   _t('feedback.import_export.feeds_imported');
 		Minz_Request::good($content_notif);
 		Minz_Request::good($content_notif);
 	}
 	}
 
 
@@ -128,10 +162,8 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 *
 	 * Itis a *very* basic guess file type function. Only based on filename.
 	 * Itis a *very* basic guess file type function. Only based on filename.
 	 * That's could be improved but should be enough for what we have to do.
 	 * That's could be improved but should be enough for what we have to do.
-	 *
-	 * @todo move into lib_rss.php
 	 */
 	 */
-	private function guessFileType($filename) {
+	private static function guessFileType($filename) {
 		if (substr_compare($filename, '.zip', -4) === 0) {
 		if (substr_compare($filename, '.zip', -4) === 0) {
 			return 'zip';
 			return 'zip';
 		} elseif (substr_compare($filename, '.opml', -5) === 0 ||
 		} elseif (substr_compare($filename, '.opml', -5) === 0 ||
@@ -151,15 +183,19 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * This method parses and imports an OPML file.
 	 * This method parses and imports an OPML file.
 	 *
 	 *
 	 * @param string $opml_file the OPML file content.
 	 * @param string $opml_file the OPML file content.
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	 */
 	private function importOpml($opml_file) {
 	private function importOpml($opml_file) {
 		$opml_array = array();
 		$opml_array = array();
 		try {
 		try {
-			$opml_array = libopml_parse_string($opml_file);
+			$opml_array = libopml_parse_string($opml_file, false);
 		} catch (LibOPML_Exception $e) {
 		} catch (LibOPML_Exception $e) {
-			Minz_Log::warning($e->getMessage());
-			return true;
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during OPML parsing: ' . $e->getMessage() . "\n");
+			} else {
+				Minz_Log::warning($e->getMessage());
+			}
+			return false;
 		}
 		}
 
 
 		$this->catDAO->checkDefault();
 		$this->catDAO->checkDefault();
@@ -172,27 +208,49 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 *
 	 * @param array $opml_elements an OPML element (body or outline).
 	 * @param array $opml_elements an OPML element (body or outline).
 	 * @param string $parent_cat the name of the parent category.
 	 * @param string $parent_cat the name of the parent category.
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	 */
 	private function addOpmlElements($opml_elements, $parent_cat = null) {
 	private function addOpmlElements($opml_elements, $parent_cat = null) {
-		$error = false;
+		$ok = true;
+
+		$nb_feeds = count($this->feedDAO->listFeeds());
+		$nb_cats = count($this->catDAO->listCategories(false));
+		$limits = FreshRSS_Context::$system_conf->limits;
+
 		foreach ($opml_elements as $elt) {
 		foreach ($opml_elements as $elt) {
-			$res = false;
 			if (isset($elt['xmlUrl'])) {
 			if (isset($elt['xmlUrl'])) {
 				// If xmlUrl exists, it means it is a feed
 				// If xmlUrl exists, it means it is a feed
-				$res = $this->addFeedOpml($elt, $parent_cat);
+				if (FreshRSS_Context::$isCli && $nb_feeds >= $limits['max_feeds']) {
+					Minz_Log::warning(_t('feedback.sub.feed.over_max',
+									  $limits['max_feeds']));
+					$ok = false;
+					continue;
+				}
+
+				if ($this->addFeedOpml($elt, $parent_cat)) {
+					$nb_feeds++;
+				} else {
+					$ok = false;
+				}
 			} else {
 			} else {
 				// No xmlUrl? It should be a category!
 				// No xmlUrl? It should be a category!
-				$res = $this->addCategoryOpml($elt, $parent_cat);
-			}
+				$limit_reached = ($nb_cats >= $limits['max_categories']);
+				if (!FreshRSS_Context::$isCli && $limit_reached) {
+					Minz_Log::warning(_t('feedback.sub.category.over_max',
+									  $limits['max_categories']));
+					$ok = false;
+					continue;
+				}
 
 
-			if (!$error && $res) {
-				// oops: there is at least one error!
-				$error = $res;
+				if ($this->addCategoryOpml($elt, $parent_cat, $limit_reached)) {
+					$nb_cats++;
+				} else {
+					$ok = false;
+				}
 			}
 			}
 		}
 		}
 
 
-		return $error;
+		return $ok;
 	}
 	}
 
 
 	/**
 	/**
@@ -200,19 +258,23 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 *
 	 * @param array $feed_elt an OPML element (must be a feed element).
 	 * @param array $feed_elt an OPML element (must be a feed element).
 	 * @param string $parent_cat the name of the parent category.
 	 * @param string $parent_cat the name of the parent category.
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	 */
 	private function addFeedOpml($feed_elt, $parent_cat) {
 	private function addFeedOpml($feed_elt, $parent_cat) {
-		if (is_null($parent_cat)) {
+		if ($parent_cat == null) {
 			// This feed has no parent category so we get the default one
 			// This feed has no parent category so we get the default one
-			$parent_cat = $this->catDAO->getDefault()->name();
+			$this->catDAO->checkDefault();
+			$default_cat = $this->catDAO->getDefault();
+			$parent_cat = $default_cat->name();
 		}
 		}
 
 
 		$cat = $this->catDAO->searchByName($parent_cat);
 		$cat = $this->catDAO->searchByName($parent_cat);
-		if (!$cat) {
+		if ($cat == null) {
 			// If there is not $cat, it means parent category does not exist in
 			// If there is not $cat, it means parent category does not exist in
-			// database. It should not happened!
-			return true;
+			// database.
+			// If it happens, take the default category.
+			$this->catDAO->checkDefault();
+			$cat = $this->catDAO->getDefault();
 		}
 		}
 
 
 		// We get different useful information
 		// We get different useful information
@@ -236,16 +298,34 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$feed->_website($website);
 			$feed->_website($website);
 			$feed->_description($description);
 			$feed->_description($description);
 
 
-			// addFeedObject checks if feed is already in DB so nothing else to
-			// check here
-			$id = $this->feedDAO->addFeedObject($feed);
-			$error = ($id === false);
+			// Call the extension hook
+			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
+			if ($feed != null) {
+				// addFeedObject checks if feed is already in DB so nothing else to
+				// check here
+				$id = $this->feedDAO->addFeedObject($feed);
+				$error = ($id === false);
+			} else {
+				$error = true;
+			}
 		} catch (FreshRSS_Feed_Exception $e) {
 		} catch (FreshRSS_Feed_Exception $e) {
-			Minz_Log::warning($e->getMessage());
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during OPML feed import: ' . $e->getMessage() . "\n");
+			} else {
+				Minz_Log::warning($e->getMessage());
+			}
 			$error = true;
 			$error = true;
 		}
 		}
 
 
-		return $error;
+		if ($error) {
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during OPML feed import from URL: ' . $url . ' in category ' . $cat->id() . "\n");
+			} else {
+				Minz_Log::warning('Error during OPML feed import from URL: ' . $url . ' in category ' . $cat->id());
+			}
+		}
+
+		return !$error;
 	}
 	}
 
 
 	/**
 	/**
@@ -253,26 +333,36 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 *
 	 * @param array $cat_elt an OPML element (must be a category element).
 	 * @param array $cat_elt an OPML element (must be a category element).
 	 * @param string $parent_cat the name of the parent category.
 	 * @param string $parent_cat the name of the parent category.
-	 * @return boolean true if an error occured, false else.
+	 * @param boolean $cat_limit_reached indicates if category limit has been reached.
+	 *                if yes, category is not added (but we try for feeds!)
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	 */
-	private function addCategoryOpml($cat_elt, $parent_cat) {
+	private function addCategoryOpml($cat_elt, $parent_cat, $cat_limit_reached) {
 		// Create a new Category object
 		// Create a new Category object
-		$cat = new FreshRSS_Category(Minz_Helper::htmlspecialchars_utf8($cat_elt['text']));
+		$catName = Minz_Helper::htmlspecialchars_utf8($cat_elt['text']);
+		$cat = new FreshRSS_Category($catName);
 
 
-		$id = $this->catDAO->addCategoryObject($cat);
-		$error = ($id === false);
+		$error = true;
+		if (FreshRSS_Context::$isCli || !$cat_limit_reached) {
+			$id = $this->catDAO->addCategoryObject($cat);
+			$error = ($id === false);
+		}
+		if ($error) {
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during OPML category import from URL: ' . $catName . "\n");
+			} else {
+				Minz_Log::warning('Error during OPML category import from URL: ' . $catName);
+			}
+		}
 
 
 		if (isset($cat_elt['@outlines'])) {
 		if (isset($cat_elt['@outlines'])) {
 			// Our cat_elt contains more categories or more feeds, so we
 			// Our cat_elt contains more categories or more feeds, so we
 			// add them recursively.
 			// add them recursively.
 			// Note: FreshRSS does not support yet category arborescence
 			// Note: FreshRSS does not support yet category arborescence
-			$res = $this->addOpmlElements($cat_elt['@outlines'], $cat->name());
-			if (!$error && $res) {
-				$error = true;
-			}
+			$error &= !$this->addOpmlElements($cat_elt['@outlines'], $catName);
 		}
 		}
 
 
-		return $error;
+		return !$error;
 	}
 	}
 
 
 	/**
 	/**
@@ -280,34 +370,66 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 *
 	 * @param string $article_file the JSON file content.
 	 * @param string $article_file the JSON file content.
 	 * @param boolean $starred true if articles from the file must be starred.
 	 * @param boolean $starred true if articles from the file must be starred.
-	 * @return boolean true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	 */
 	private function importJson($article_file, $starred = false) {
 	private function importJson($article_file, $starred = false) {
 		$article_object = json_decode($article_file, true);
 		$article_object = json_decode($article_file, true);
-		if (is_null($article_object)) {
-			Minz_Log::warning('Try to import a non-JSON file');
-			return true;
+		if ($article_object == null) {
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error trying to import a non-JSON file' . "\n");
+			} else {
+				Minz_Log::warning('Try to import a non-JSON file');
+			}
+			return false;
 		}
 		}
 
 
-		$is_read = FreshRSS_Context::$conf->mark_when['reception'] ? 1 : 0;
+		$is_read = FreshRSS_Context::$user_conf->mark_when['reception'] ? 1 : 0;
 
 
 		$google_compliant = strpos($article_object['id'], 'com.google') !== false;
 		$google_compliant = strpos($article_object['id'], 'com.google') !== false;
 
 
 		$error = false;
 		$error = false;
 		$article_to_feed = array();
 		$article_to_feed = array();
 
 
+		$nb_feeds = count($this->feedDAO->listFeeds());
+		$limits = FreshRSS_Context::$system_conf->limits;
+
 		// First, we check feeds of articles are in DB (and add them if needed).
 		// First, we check feeds of articles are in DB (and add them if needed).
 		foreach ($article_object['items'] as $item) {
 		foreach ($article_object['items'] as $item) {
-			$feed = $this->addFeedJson($item['origin'], $google_compliant);
-			if (is_null($feed)) {
-				$error = true;
-			} else {
+			$key = $google_compliant ? 'htmlUrl' : 'feedUrl';
+			$feed = new FreshRSS_Feed($item['origin'][$key]);
+			$feed = $this->feedDAO->searchByUrl($feed->url());
+
+			if ($feed == null) {
+				// Feed does not exist in DB,we should to try to add it.
+				if ((!FreshRSS_Context::$isCli) && ($nb_feeds >= $limits['max_feeds'])) {
+					// Oops, no more place!
+					Minz_Log::warning(_t('feedback.sub.feed.over_max', $limits['max_feeds']));
+				} else {
+					$feed = $this->addFeedJson($item['origin'], $google_compliant);
+				}
+
+				if ($feed == null) {
+					// Still null? It means something went wrong.
+					$error = true;
+				} else {
+					$nb_feeds++;
+				}
+			}
+
+			if ($feed != null) {
 				$article_to_feed[$item['id']] = $feed->id();
 				$article_to_feed[$item['id']] = $feed->id();
 			}
 			}
 		}
 		}
 
 
+		$newGuids = array();
+		foreach ($article_object['items'] as $item) {
+			$newGuids[] = safe_ascii($item['id']);
+		}
+		// For this feed, check existing GUIDs already in database.
+		$existingHashForGuids = $this->entryDAO->listHashForFeedGuids($feed->id(), $newGuids);
+		unset($newGuids);
+
 		// Then, articles are imported.
 		// Then, articles are imported.
-		$prepared_statement = $this->entryDAO->addEntryPrepare();
 		$this->entryDAO->beginTransaction();
 		$this->entryDAO->beginTransaction();
 		foreach ($article_object['items'] as $item) {
 		foreach ($article_object['items'] as $item) {
 			if (!isset($article_to_feed[$item['id']])) {
 			if (!isset($article_to_feed[$item['id']])) {
@@ -323,7 +445,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			if ($google_compliant) {
 			if ($google_compliant) {
 				// Remove tags containing "/state/com.google" which are useless.
 				// Remove tags containing "/state/com.google" which are useless.
 				$tags = array_filter($tags, function($var) {
 				$tags = array_filter($tags, function($var) {
-					return strpos($var, '/state/com.google') === false;
+					return strpos($var, '/state/com.google') !== false;
 				});
 				});
 			}
 			}
 
 
@@ -335,8 +457,18 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$entry->_id(min(time(), $entry->date(true)) . uSecString());
 			$entry->_id(min(time(), $entry->date(true)) . uSecString());
 			$entry->_tags($tags);
 			$entry->_tags($tags);
 
 
+			$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
+			if ($entry == null) {
+				// An extension has returned a null value, there is nothing to insert.
+				continue;
+			}
+
 			$values = $entry->toArray();
 			$values = $entry->toArray();
-			$id = $this->entryDAO->addEntry($values, $prepared_statement);
+			if (isset($existingHashForGuids[$entry->guid()])) {
+				$id = $this->entryDAO->updateEntry($values);
+			} else {
+				$id = $this->entryDAO->addEntry($values);
+			}
 
 
 			if (!$error && ($id === false)) {
 			if (!$error && ($id === false)) {
 				$error = true;
 				$error = true;
@@ -344,7 +476,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		}
 		}
 		$this->entryDAO->commit();
 		$this->entryDAO->commit();
 
 
-		return $error;
+		return !$error;
 	}
 	}
 
 
 	/**
 	/**
@@ -356,8 +488,6 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *         else null.
 	 *         else null.
 	 */
 	 */
 	private function addFeedJson($origin, $google_compliant) {
 	private function addFeedJson($origin, $google_compliant) {
-		$default_cat = $this->catDAO->getDefault();
-
 		$return = null;
 		$return = null;
 		$key = $google_compliant ? 'htmlUrl' : 'feedUrl';
 		$key = $google_compliant ? 'htmlUrl' : 'feedUrl';
 		$url = $origin[$key];
 		$url = $origin[$key];
@@ -367,80 +497,117 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		try {
 		try {
 			// Create a Feed object and add it in database.
 			// Create a Feed object and add it in database.
 			$feed = new FreshRSS_Feed($url);
 			$feed = new FreshRSS_Feed($url);
-			$feed->_category($default_cat->id());
+			$feed->_category(FreshRSS_CategoryDAO::defaultCategoryId);
 			$feed->_name($name);
 			$feed->_name($name);
 			$feed->_website($website);
 			$feed->_website($website);
 
 
-			// addFeedObject checks if feed is already in DB so nothing else to
-			// check here.
-			$id = $this->feedDAO->addFeedObject($feed);
+			// Call the extension hook
+			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
+			if ($feed != null) {
+				// addFeedObject checks if feed is already in DB so nothing else to
+				// check here.
+				$id = $this->feedDAO->addFeedObject($feed);
 
 
-			if ($id !== false) {
-				$feed->_id($id);
-				$return = $feed;
+				if ($id !== false) {
+					$feed->_id($id);
+					$return = $feed;
+				}
 			}
 			}
 		} catch (FreshRSS_Feed_Exception $e) {
 		} catch (FreshRSS_Feed_Exception $e) {
-			Minz_Log::warning($e->getMessage());
+			if (FreshRSS_Context::$isCli) {
+				fwrite(STDERR, 'FreshRSS error during JSON feed import: ' . $e->getMessage() . "\n");
+			} else {
+				Minz_Log::warning($e->getMessage());
+			}
 		}
 		}
 
 
 		return $return;
 		return $return;
 	}
 	}
 
 
-	/**
-	 * This action handles export action.
-	 *
-	 * This action must be reached by a POST request.
-	 *
-	 * Parameters are:
-	 *   - export_opml (default: false)
-	 *   - export_starred (default: false)
-	 *   - export_feeds (default: array()) a list of feed ids
-	 */
-	public function exportAction() {
-		if (!Minz_Request::isPost()) {
-			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
-		}
+	public function exportFile($export_opml = true, $export_starred = false, $export_feeds = array(), $maxFeedEntries = 50, $username = null) {
+		require_once(LIB_PATH . '/lib_opml.php');
 
 
-		$this->view->_useLayout(false);
+		$this->catDAO = new FreshRSS_CategoryDAO($username);
+		$this->entryDAO = FreshRSS_Factory::createEntryDao($username);
+		$this->feedDAO = FreshRSS_Factory::createFeedDao($username);
+
+		$this->entryDAO->disableBuffering();
+
+		if ($export_feeds === true) {
+			//All feeds
+			$export_feeds = $this->feedDAO->listFeedsIds();
+		}
+		if (!is_array($export_feeds)) {
+			$export_feeds = array();
+		}
 
 
-		$export_opml = Minz_Request::param('export_opml', false);
-		$export_starred = Minz_Request::param('export_starred', false);
-		$export_feeds = Minz_Request::param('export_feeds', array());
+		$day = date('Y-m-d');
 
 
 		$export_files = array();
 		$export_files = array();
 		if ($export_opml) {
 		if ($export_opml) {
-			$export_files['feeds.opml'] = $this->generateOpml();
+			$export_files["feeds_${day}.opml.xml"] = $this->generateOpml();
 		}
 		}
 
 
 		if ($export_starred) {
 		if ($export_starred) {
-			$export_files['starred.json'] = $this->generateEntries('starred');
+			$export_files["starred_${day}.json"] = $this->generateEntries('starred');
 		}
 		}
 
 
 		foreach ($export_feeds as $feed_id) {
 		foreach ($export_feeds as $feed_id) {
 			$feed = $this->feedDAO->searchById($feed_id);
 			$feed = $this->feedDAO->searchById($feed_id);
 			if ($feed) {
 			if ($feed) {
-				$filename = 'feed_' . $feed->category() . '_'
+				$filename = "feed_${day}_" . $feed->category() . '_'
 				          . $feed->id() . '.json';
 				          . $feed->id() . '.json';
-				$export_files[$filename] = $this->generateEntries('feed', $feed);
+				$export_files[$filename] = $this->generateEntries('feed', $feed, $maxFeedEntries);
 			}
 			}
 		}
 		}
 
 
 		$nb_files = count($export_files);
 		$nb_files = count($export_files);
 		if ($nb_files > 1) {
 		if ($nb_files > 1) {
-			// If there are more than 1 file to export, we need a zip archive.
+			// If there are more than 1 file to export, we need a ZIP archive.
 			try {
 			try {
-				$this->exportZip($export_files);
+				$this->sendZip($export_files);
 			} catch (Exception $e) {
 			} catch (Exception $e) {
-				# Oops, there is no Zip extension!
-				Minz_Request::bad(_t('export_no_zip_extension'),
-				                  array('c' => 'importExport', 'a' => 'index'));
+				throw new FreshRSS_ZipMissing_Exception($e);
 			}
 			}
 		} elseif ($nb_files === 1) {
 		} elseif ($nb_files === 1) {
 			// Only one file? Guess its type and export it.
 			// Only one file? Guess its type and export it.
 			$filename = key($export_files);
 			$filename = key($export_files);
-			$type = $this->guessFileType($filename);
-			$this->exportFile('freshrss_' . $filename, $export_files[$filename], $type);
-		} else {
+			$type = self::guessFileType($filename);
+			$this->sendFile('freshrss_' . $filename, $export_files[$filename], $type);
+		}
+		return $nb_files;
+	}
+
+	/**
+	 * This action handles export action.
+	 *
+	 * This action must be reached by a POST request.
+	 *
+	 * Parameters are:
+	 *   - export_opml (default: false)
+	 *   - export_starred (default: false)
+	 *   - export_feeds (default: array()) a list of feed ids
+	 */
+	public function exportAction() {
+		if (!Minz_Request::isPost()) {
+			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
+		}
+		$this->view->_useLayout(false);
+
+		$nb_files = 0;
+		try {
+			$nb_files = $this->exportFile(
+					Minz_Request::param('export_opml', false),
+					Minz_Request::param('export_starred', false),
+					Minz_Request::param('export_feeds', array())
+				);
+		} catch (FreshRSS_ZipMissing_Exception $zme) {
+			# Oops, there is no ZIP extension!
+			Minz_Request::bad(_t('feedback.import_export.export_no_zip_extension'),
+			                  array('c' => 'importExport', 'a' => 'index'));
+		}
+
+		if ($nb_files < 1) {
 			// Nothing to do...
 			// Nothing to do...
 			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
 			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
 		}
 		}
@@ -469,22 +636,22 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param FreshRSS_Feed $feed feed of which we want to get entries.
 	 * @param FreshRSS_Feed $feed feed of which we want to get entries.
 	 * @return string the JSON file content.
 	 * @return string the JSON file content.
 	 */
 	 */
-	private function generateEntries($type, $feed = NULL) {
+	private function generateEntries($type, $feed = NULL, $maxFeedEntries = 50) {
 		$this->view->categories = $this->catDAO->listCategories();
 		$this->view->categories = $this->catDAO->listCategories();
 
 
 		if ($type == 'starred') {
 		if ($type == 'starred') {
-			$this->view->list_title = _t('starred_list');
+			$this->view->list_title = _t('sub.import_export.starred_list');
 			$this->view->type = 'starred';
 			$this->view->type = 'starred';
 			$unread_fav = $this->entryDAO->countUnreadReadFavorites();
 			$unread_fav = $this->entryDAO->countUnreadReadFavorites();
-			$this->view->entries = $this->entryDAO->listWhere(
+			$this->view->entriesRaw = $this->entryDAO->listWhereRaw(
 				's', '', FreshRSS_Entry::STATE_ALL, 'ASC', $unread_fav['all']
 				's', '', FreshRSS_Entry::STATE_ALL, 'ASC', $unread_fav['all']
 			);
 			);
-		} elseif ($type == 'feed' && !is_null($feed)) {
-			$this->view->list_title = _t('feed_list', $feed->name());
+		} elseif ($type === 'feed' && $feed != null) {
+			$this->view->list_title = _t('sub.import_export.feed_list', $feed->name());
 			$this->view->type = 'feed/' . $feed->id();
 			$this->view->type = 'feed/' . $feed->id();
-			$this->view->entries = $this->entryDAO->listWhere(
+			$this->view->entriesRaw = $this->entryDAO->listWhereRaw(
 				'f', $feed->id(), FreshRSS_Entry::STATE_ALL, 'ASC',
 				'f', $feed->id(), FreshRSS_Entry::STATE_ALL, 'ASC',
-				FreshRSS_Context::$conf->posts_per_page
+				$maxFeedEntries
 			);
 			);
 			$this->view->feed = $feed;
 			$this->view->feed = $feed;
 		}
 		}
@@ -498,7 +665,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param array $files list of files where key is filename and value the content.
 	 * @param array $files list of files where key is filename and value the content.
 	 * @throws Exception if Zip extension is not loaded.
 	 * @throws Exception if Zip extension is not loaded.
 	 */
 	 */
-	private function exportZip($files) {
+	private function sendZip($files) {
 		if (!extension_loaded('zip')) {
 		if (!extension_loaded('zip')) {
 			throw new Exception();
 			throw new Exception();
 		}
 		}
@@ -516,7 +683,8 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		$zip->close();
 		$zip->close();
 		header('Content-Type: application/zip');
 		header('Content-Type: application/zip');
 		header('Content-Length: ' . filesize($zip_file));
 		header('Content-Length: ' . filesize($zip_file));
-		header('Content-Disposition: attachment; filename="freshrss_export.zip"');
+		$day = date('Y-m-d');
+		header('Content-Disposition: attachment; filename="freshrss_' . $day . '_export.zip"');
 		readfile($zip_file);
 		readfile($zip_file);
 		unlink($zip_file);
 		unlink($zip_file);
 	}
 	}
@@ -529,16 +697,16 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param string $type the file type (opml, json_feed or json_starred).
 	 * @param string $type the file type (opml, json_feed or json_starred).
 	 *                     If equals to unknown, nothing happens.
 	 *                     If equals to unknown, nothing happens.
 	 */
 	 */
-	private function exportFile($filename, $content, $type) {
+	private function sendFile($filename, $content, $type) {
 		if ($type === 'unknown') {
 		if ($type === 'unknown') {
 			return;
 			return;
 		}
 		}
 
 
 		$content_type = '';
 		$content_type = '';
 		if ($type === 'opml') {
 		if ($type === 'opml') {
-			$content_type = "text/opml";
+			$content_type = 'application/xml';
 		} elseif ($type === 'json_feed' || $type === 'json_starred') {
 		} elseif ($type === 'json_feed' || $type === 'json_starred') {
-			$content_type = "text/json";
+			$content_type = 'application/json';
 		}
 		}
 
 
 		header('Content-Type: ' . $content_type . '; charset=utf-8');
 		header('Content-Type: ' . $content_type . '; charset=utf-8');

+ 222 - 181
app/Controllers/indexController.php

@@ -1,220 +1,261 @@
 <?php
 <?php
 
 
+/**
+ * This class handles main actions of FreshRSS.
+ */
 class FreshRSS_index_Controller extends Minz_ActionController {
 class FreshRSS_index_Controller extends Minz_ActionController {
-	private $nb_not_read_cat = 0;
 
 
+	/**
+	 * This action only redirect on the default view mode (normal or global)
+	 */
 	public function indexAction() {
 	public function indexAction() {
-		$output = Minz_Request::param('output');
-		$token = FreshRSS_Context::$conf->token;
-
-		// check if user is logged in
-		if (!FreshRSS_Auth::hasAccess() && !Minz_Configuration::allowAnonymous()) {
-			$token_param = Minz_Request::param('token', '');
-			$token_is_ok = ($token != '' && $token === $token_param);
-			if ($output === 'rss' && !$token_is_ok) {
-				Minz_Error::error(
-					403,
-					array('error' => array(_t('access_denied')))
-				);
-				return;
-			} elseif ($output !== 'rss') {
-				// "hard" redirection is not required, just ask dispatcher to
-				// forward to the login form without 302 redirection
-				Minz_Request::forward(array('c' => 'auth', 'a' => 'login'));
-				return;
-			}
-		}
+		$prefered_output = FreshRSS_Context::$user_conf->view_mode;
+		Minz_Request::forward(array(
+			'c' => 'index',
+			'a' => $prefered_output
+		));
+	}
 
 
-		$params = Minz_Request::params();
-		if (isset($params['search'])) {
-			$params['search'] = urlencode($params['search']);
+	/**
+	 * This action displays the normal view of FreshRSS.
+	 */
+	public function normalAction() {
+		$allow_anonymous = FreshRSS_Context::$system_conf->allow_anonymous;
+		if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) {
+			Minz_Request::forward(array('c' => 'auth', 'a' => 'login'));
+			return;
 		}
 		}
 
 
-		$this->view->url = array(
-			'c' => 'index',
-			'a' => 'index',
-			'params' => $params
-		);
-
-		if ($output === 'rss') {
-			// no layout for RSS output
-			$this->view->_useLayout(false);
-			header('Content-Type: application/rss+xml; charset=utf-8');
-		} elseif ($output === 'global') {
-			Minz_View::appendScript(Minz_Url::display('/scripts/global_view.js?' . @filemtime(PUBLIC_PATH . '/scripts/global_view.js')));
+		try {
+			$this->updateContext();
+		} catch (FreshRSS_Context_Exception $e) {
+			Minz_Error::error(404);
 		}
 		}
 
 
-		$catDAO = new FreshRSS_CategoryDAO();
-		$entryDAO = FreshRSS_Factory::createEntryDao();
+		$this->view->callbackBeforeContent = function($view) {
+			try {
+				FreshRSS_Context::$number++;	//+1 for pagination
+				$entries = FreshRSS_index_Controller::listEntriesByContext();
+				FreshRSS_Context::$number--;
 
 
-		$this->view->cat_aside = $catDAO->listCategories();
-		$this->view->nb_favorites = $entryDAO->countUnreadReadFavorites();
-		$this->view->nb_not_read = FreshRSS_CategoryDAO::CountUnreads($this->view->cat_aside, 1);
-		$this->view->currentName = '';
-
-		$this->view->get_c = '';
-		$this->view->get_f = '';
-
-		$get = Minz_Request::param('get', 'a');
-		$getType = $get[0];
-		$getId = substr($get, 2);
-		if (!$this->checkAndProcessType($getType, $getId)) {
-			Minz_Log::debug('Not found [' . $getType . '][' . $getId . ']');
-			Minz_Error::error(
-				404,
-				array('error' => array(_t('page_not_found')))
-			);
-			return;
-		}
+				$nb_entries = count($entries);
+				if ($nb_entries > FreshRSS_Context::$number) {
+					// We have more elements for pagination
+					$last_entry = array_pop($entries);
+					FreshRSS_Context::$next_id = $last_entry->id();
+				}
 
 
-		// mise à jour des titres
-		$this->view->rss_title = $this->view->currentName . ' | ' . Minz_View::title();
-		Minz_View::prependTitle(
-			($this->nb_not_read_cat > 0 ? '(' . formatNumber($this->nb_not_read_cat) . ') ' : '') .
-			$this->view->currentName .
-			' · '
-		);
+				$first_entry = $nb_entries > 0 ? $entries[0] : null;
+				FreshRSS_Context::$id_max = $first_entry === null ?
+											(time() - 1) . '000000' :
+											$first_entry->id();
+				if (FreshRSS_Context::$order === 'ASC') {
+					// In this case we do not know but we guess id_max
+					$id_max = (time() - 1) . '000000';
+					if (strcmp($id_max, FreshRSS_Context::$id_max) > 0) {
+						FreshRSS_Context::$id_max = $id_max;
+					}
+				}
 
 
-		// On récupère les différents éléments de filtrage
-		$this->view->state = Minz_Request::param('state', FreshRSS_Context::$conf->default_view);
-		$state_param = Minz_Request::param('state', null);
-		$filter = Minz_Request::param('search', '');
-		$this->view->order = $order = Minz_Request::param('order', FreshRSS_Context::$conf->sort_order);
-		$nb = Minz_Request::param('nb', FreshRSS_Context::$conf->posts_per_page);
-		$first = Minz_Request::param('next', '');
-
-		$ajax_request = Minz_Request::param('ajax', false);
-		if ($output === 'reader') {
-			$nb = max(1, round($nb / 2));
-		}
-
-		if ($this->view->state === FreshRSS_Entry::STATE_NOT_READ) {	//Any unread article in this category at all?
-			switch ($getType) {
-			case 'a':
-				$hasUnread = $this->view->nb_not_read > 0;
-				break;
-			case 's':
-				// This is deprecated. The favorite button does not exist anymore
-				$hasUnread = $this->view->nb_favorites['unread'] > 0;
-				break;
-			case 'c':
-				$hasUnread = (!isset($this->view->cat_aside[$getId]) ||
-				              $this->view->cat_aside[$getId]->nbNotRead() > 0);
-				break;
-			case 'f':
-				$myFeed = FreshRSS_CategoryDAO::findFeed($this->view->cat_aside, $getId);
-				$hasUnread = ($myFeed === null) || ($myFeed->nbNotRead() > 0);
-				break;
-			default:
-				$hasUnread = true;
-				break;
+				$view->entries = $entries;
+			} catch (FreshRSS_EntriesGetter_Exception $e) {
+				Minz_Log::notice($e->getMessage());
+				Minz_Error::error(404);
 			}
 			}
-			if (!$hasUnread && ($state_param === null)) {
-				$this->view->state = FreshRSS_Entry::STATE_ALL;
+
+			$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 . ' · ');
+		};
+	}
+
+	/**
+	 * This action displays the reader view of FreshRSS.
+	 *
+	 * @todo: change this view into specific CSS rules?
+	 */
+	public function readerAction() {
+		$this->normalAction();
+	}
+
+	/**
+	 * This action displays the global view of FreshRSS.
+	 */
+	public function globalAction() {
+		$allow_anonymous = FreshRSS_Context::$system_conf->allow_anonymous;
+		if (!FreshRSS_Auth::hasAccess() && !$allow_anonymous) {
+			Minz_Request::forward(array('c' => 'auth', 'a' => 'login'));
+			return;
 		}
 		}
 
 
-		$this->view->today = @strtotime('today');
+		Minz_View::appendScript(Minz_Url::display('/scripts/global_view.js?' . @filemtime(PUBLIC_PATH . '/scripts/global_view.js')));
 
 
 		try {
 		try {
-			$entries = $entryDAO->listWhere($getType, $getId, $this->view->state, $order, $nb + 1, $first, $filter);
-
-			// Si on a récupéré aucun article "non lus"
-			// on essaye de récupérer tous les articles
-			if ($this->view->state === FreshRSS_Entry::STATE_NOT_READ && empty($entries) && ($state_param === null) && ($filter == '')) {
-				Minz_Log::debug('Conflicting information about nbNotRead!');
-				$feedDAO = FreshRSS_Factory::createFeedDao();
-				try {
-					$feedDAO->updateCachedValues();
-				} catch (Exception $ex) {
-					Minz_Log::notice('Failed to automatically correct nbNotRead! ' + $ex->getMessage());
-				}
-				$this->view->state = FreshRSS_Entry::STATE_ALL;
-				$entries = $entryDAO->listWhere($getType, $getId, $this->view->state, $order, $nb, $first, $filter);
-			}
-			Minz_Request::_param('state', $this->view->state);
+			$this->updateContext();
+		} catch (FreshRSS_Context_Exception $e) {
+			Minz_Error::error(404);
+		}
 
 
-			if (count($entries) <= $nb) {
-				$this->view->nextId  = '';
-			} else {	//We have more elements for pagination
-				$lastEntry = array_pop($entries);
-				$this->view->nextId  = $lastEntry->id();
-			}
+		$this->view->categories = FreshRSS_Context::$categories;
 
 
-			$this->view->entries = $entries;
+		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . Minz_View::title();
+		$title = _t('index.feed.title_global');
+		if (FreshRSS_Context::$get_unread > 0) {
+			$title = '(' . FreshRSS_Context::$get_unread . ') ' . $title;
+		}
+		Minz_View::prependTitle($title . ' · ');
+	}
+
+	/**
+	 * This action displays the RSS feed of FreshRSS.
+	 */
+	public function rssAction() {
+		$allow_anonymous = FreshRSS_Context::$system_conf->allow_anonymous;
+		$token = FreshRSS_Context::$user_conf->token;
+		$token_param = Minz_Request::param('token', '');
+		$token_is_ok = ($token != '' && $token === $token_param);
+
+		// Check if user has access.
+		if (!FreshRSS_Auth::hasAccess() &&
+				!$allow_anonymous &&
+				!$token_is_ok) {
+			Minz_Error::error(403);
+		}
+
+		try {
+			$this->updateContext();
+		} catch (FreshRSS_Context_Exception $e) {
+			Minz_Error::error(404);
+		}
+
+		try {
+			$this->view->entries = FreshRSS_index_Controller::listEntriesByContext();
 		} catch (FreshRSS_EntriesGetter_Exception $e) {
 		} catch (FreshRSS_EntriesGetter_Exception $e) {
 			Minz_Log::notice($e->getMessage());
 			Minz_Log::notice($e->getMessage());
-			Minz_Error::error(
-				404,
-				array('error' => array(_t('page_not_found')))
-			);
+			Minz_Error::error(404);
 		}
 		}
+
+		// No layout for RSS output.
+		$this->view->url = empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING'];
+		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . Minz_View::title();
+		$this->view->_useLayout(false);
+		header('Content-Type: application/rss+xml; charset=utf-8');
 	}
 	}
 
 
-	/*
-	 * Vérifie que la catégorie / flux sélectionné existe
-	 * + Initialise correctement les variables de vue get_c et get_f
-	 * + Met à jour la variable $this->nb_not_read_cat
+	/**
+	 * This action updates the Context object by using request parameters.
+	 *
+	 * Parameters are:
+	 *   - state (default: conf->default_view)
+	 *   - search (default: empty string)
+	 *   - order (default: conf->sort_order)
+	 *   - nb (default: conf->posts_per_page)
+	 *   - next (default: empty string)
+	 *   - hours (default: 0)
 	 */
 	 */
-	private function checkAndProcessType($getType, $getId) {
-		switch($getType) {
-		case 'a':
-			$this->view->currentName = _t('your_rss_feeds');
-			$this->nb_not_read_cat = $this->view->nb_not_read;
-			$this->view->get_c = $getType;
-			return true;
-		case 's':
-			$this->view->currentName = _t('your_favorites');
-			$this->nb_not_read_cat = $this->view->nb_favorites['unread'];
-			$this->view->get_c = $getType;
-			return true;
-		case 'c':
-			$cat = isset($this->view->cat_aside[$getId]) ? $this->view->cat_aside[$getId] : null;
-			if ($cat === null) {
-				$catDAO = new FreshRSS_CategoryDAO();
-				$cat = $catDAO->searchById($getId);
-			}
-			if ($cat) {
-				$this->view->currentName = $cat->name();
-				$this->nb_not_read_cat = $cat->nbNotRead();
-				$this->view->get_c = $getId;
-				return true;
-			} else {
-				return false;
-			}
-		case 'f':
-			$feed = FreshRSS_CategoryDAO::findFeed($this->view->cat_aside, $getId);
-			if (empty($feed)) {
-				$feedDAO = FreshRSS_Factory::createFeedDao();
-				$feed = $feedDAO->searchById($getId);
-			}
-			if ($feed) {
-				$this->view->currentName = $feed->name();
-				$this->nb_not_read_cat = $feed->nbNotRead();
-				$this->view->get_f = $getId;
-				$this->view->get_c = $feed->category();
-				return true;
-			} else {
-				return false;
-			}
-		default:
-			return false;
+	private function updateContext() {
+		if (empty(FreshRSS_Context::$categories)) {
+			$catDAO = new FreshRSS_CategoryDAO();
+			FreshRSS_Context::$categories = $catDAO->listCategories();
+		}
+
+		// Update number of read / unread variables.
+		$entryDAO = FreshRSS_Factory::createEntryDao();
+		FreshRSS_Context::$total_starred = $entryDAO->countUnreadReadFavorites();
+		FreshRSS_Context::$total_unread = FreshRSS_CategoryDAO::CountUnreads(
+			FreshRSS_Context::$categories, 1
+		);
+
+		FreshRSS_Context::_get(Minz_Request::param('get', 'a'));
+
+		FreshRSS_Context::$state = Minz_Request::param(
+			'state', FreshRSS_Context::$user_conf->default_state
+		);
+		$state_forced_by_user = Minz_Request::param('state', false) !== false;
+		if (FreshRSS_Context::$user_conf->default_view === 'adaptive' &&
+				FreshRSS_Context::$get_unread <= 0 &&
+				!FreshRSS_Context::isStateEnabled(FreshRSS_Entry::STATE_READ) &&
+				!$state_forced_by_user) {
+			FreshRSS_Context::$state |= FreshRSS_Entry::STATE_READ;
+		}
+
+		FreshRSS_Context::$search = new FreshRSS_Search(Minz_Request::param('search', ''));
+		FreshRSS_Context::$order = Minz_Request::param(
+			'order', FreshRSS_Context::$user_conf->sort_order
+		);
+		FreshRSS_Context::$number = intval(Minz_Request::param('nb', FreshRSS_Context::$user_conf->posts_per_page));
+		if (FreshRSS_Context::$number > FreshRSS_Context::$user_conf->max_posts_per_rss) {
+			FreshRSS_Context::$number = max(
+				FreshRSS_Context::$user_conf->max_posts_per_rss,
+				FreshRSS_Context::$user_conf->posts_per_page);
 		}
 		}
+		FreshRSS_Context::$first_id = Minz_Request::param('next', '');
+		FreshRSS_Context::$sinceHours = intval(Minz_Request::param('hours', 0));
 	}
 	}
-	
+
+	/**
+	 * This method returns a list of entries based on the Context object.
+	 */
+	public static function listEntriesByContext() {
+		$entryDAO = FreshRSS_Factory::createEntryDao();
+
+		$get = FreshRSS_Context::currentGet(true);
+		if (count($get) > 1) {
+			$type = $get[0];
+			$id = $get[1];
+		} else {
+			$type = $get;
+			$id = '';
+		}
+
+		$limit = FreshRSS_Context::$number;
+
+		$date_min = 0;
+		if (FreshRSS_Context::$sinceHours) {
+			$date_min = time() - (FreshRSS_Context::$sinceHours * 3600);
+			$limit = FreshRSS_Context::$user_conf->max_posts_per_rss;
+		}
+
+		$entries = $entryDAO->listWhere(
+			$type, $id, FreshRSS_Context::$state, FreshRSS_Context::$order,
+			$limit, FreshRSS_Context::$first_id,
+			FreshRSS_Context::$search, $date_min
+		);
+
+		if (FreshRSS_Context::$sinceHours && (count($entries) < FreshRSS_Context::$user_conf->min_posts_per_rss)) {
+			$date_min = 0;
+			$limit = FreshRSS_Context::$user_conf->min_posts_per_rss;
+			$entries = $entryDAO->listWhere(
+				$type, $id, FreshRSS_Context::$state, FreshRSS_Context::$order,
+				$limit, FreshRSS_Context::$first_id,
+				FreshRSS_Context::$search, $date_min
+			);
+		}
+
+		return $entries;
+	}
+
+	/**
+	 * This action displays the about page of FreshRSS.
+	 */
 	public function aboutAction() {
 	public function aboutAction() {
-		Minz_View::prependTitle(_t('about') . ' · ');
+		Minz_View::prependTitle(_t('index.about.title') . ' · ');
 	}
 	}
 
 
+	/**
+	 * This action displays logs of FreshRSS for the current user.
+	 */
 	public function logsAction() {
 	public function logsAction() {
 		if (!FreshRSS_Auth::hasAccess()) {
 		if (!FreshRSS_Auth::hasAccess()) {
-			Minz_Error::error(
-				403,
-				array('error' => array(_t('access_denied')))
-			);
+			Minz_Error::error(403);
 		}
 		}
 
 
-		Minz_View::prependTitle(_t('logs') . ' · ');
+		Minz_View::prependTitle(_t('index.log.title') . ' · ');
 
 
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
 			FreshRSS_LogDAO::truncate();
 			FreshRSS_LogDAO::truncate();

+ 14 - 6
app/Controllers/javascriptController.php

@@ -6,9 +6,9 @@ class FreshRSS_javascript_Controller extends Minz_ActionController {
 	}
 	}
 
 
 	public function actualizeAction() {
 	public function actualizeAction() {
-		header('Content-Type: text/javascript; charset=UTF-8');
+		header('Content-Type: application/json; charset=UTF-8');
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO = FreshRSS_Factory::createFeedDao();
-		$this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$conf->ttl_default);
+		$this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$user_conf->ttl_default);
 	}
 	}
 
 
 	public function nbUnreadsPerFeedAction() {
 	public function nbUnreadsPerFeedAction() {
@@ -28,19 +28,27 @@ class FreshRSS_javascript_Controller extends Minz_ActionController {
 		$user = isset($_GET['user']) ? $_GET['user'] : '';
 		$user = isset($_GET['user']) ? $_GET['user'] : '';
 		if (ctype_alnum($user)) {
 		if (ctype_alnum($user)) {
 			try {
 			try {
-				$conf = new FreshRSS_Configuration($user);
+				$salt = FreshRSS_Context::$system_conf->salt;
+				$conf = get_user_configuration($user);
 				$s = $conf->passwordHash;
 				$s = $conf->passwordHash;
 				if (strlen($s) >= 60) {
 				if (strlen($s) >= 60) {
 					$this->view->salt1 = substr($s, 0, 29);	//CRYPT_BLOWFISH Salt: "$2a$", a two digit cost parameter, "$", and 22 characters from the alphabet "./0-9A-Za-z".
 					$this->view->salt1 = substr($s, 0, 29);	//CRYPT_BLOWFISH Salt: "$2a$", a two digit cost parameter, "$", and 22 characters from the alphabet "./0-9A-Za-z".
-					$this->view->nonce = sha1(Minz_Configuration::salt() . uniqid(mt_rand(), true));
+					$this->view->nonce = sha1($salt . uniqid(mt_rand(), true));
 					Minz_Session::_param('nonce', $this->view->nonce);
 					Minz_Session::_param('nonce', $this->view->nonce);
 					return;	//Success
 					return;	//Success
 				}
 				}
 			} catch (Minz_Exception $me) {
 			} catch (Minz_Exception $me) {
 				Minz_Log::warning('Nonce failure: ' . $me->getMessage());
 				Minz_Log::warning('Nonce failure: ' . $me->getMessage());
 			}
 			}
+		} else {
+			Minz_Log::notice('Nonce failure due to invalid username!');
+		}
+		//Failure: Return random data.
+		$this->view->salt1 = sprintf('$2a$%02d$', FreshRSS_user_Controller::BCRYPT_COST);
+		$alphabet = './ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+		for ($i = 22; $i > 0; $i--) {
+			$this->view->salt1 .= $alphabet[rand(0, 63)];
 		}
 		}
-		$this->view->nonce = '';	//Failure
-		$this->view->salt1 = '';
+		$this->view->nonce = sha1(rand());
 	}
 	}
 }
 }

+ 43 - 23
app/Controllers/statsController.php

@@ -5,6 +5,40 @@
  */
  */
 class FreshRSS_stats_Controller extends Minz_ActionController {
 class FreshRSS_stats_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);
+		}
+
+		Minz_View::prependTitle(_t('admin.stats.title') . ' · ');
+	}
+
+	private function convertToSerie($data) {
+		$serie = array();
+
+		foreach ($data as $key => $value) {
+			$serie[] = array($key, $value);
+		}
+
+		return $serie;
+	}
+
+	private function convertToPieSerie($data) {
+		$serie = array();
+
+		foreach ($data as $value) {
+			$value['data'] = array(array(0, (int) $value['data']));
+			$serie[] = $value;
+		}
+
+		return $serie;
+	}
+
 	/**
 	/**
 	 * This action handles the statistic main page.
 	 * This action handles the statistic main page.
 	 *
 	 *
@@ -20,10 +54,11 @@ class FreshRSS_stats_Controller extends Minz_ActionController {
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
 		Minz_View::appendScript(Minz_Url::display('/scripts/flotr2.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/flotr2.min.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/flotr2.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/flotr2.min.js')));
 		$this->view->repartition = $statsDAO->calculateEntryRepartition();
 		$this->view->repartition = $statsDAO->calculateEntryRepartition();
-		$this->view->count = $statsDAO->calculateEntryCount();
-		$this->view->average = $statsDAO->calculateEntryAverage();
-		$this->view->feedByCategory = $statsDAO->calculateFeedByCategory();
-		$this->view->entryByCategory = $statsDAO->calculateEntryByCategory();
+		$entryCount = $statsDAO->calculateEntryCount();
+		$this->view->count = $this->convertToSerie($entryCount);
+		$this->view->average = round(array_sum(array_values($entryCount)) / count($entryCount), 2);
+		$this->view->feedByCategory = $this->convertToPieSerie($statsDAO->calculateFeedByCategory());
+		$this->view->entryByCategory = $this->convertToPieSerie($statsDAO->calculateEntryByCategory());
 		$this->view->topFeed = $statsDAO->calculateTopFeed();
 		$this->view->topFeed = $statsDAO->calculateTopFeed();
 	}
 	}
 
 
@@ -104,27 +139,12 @@ class FreshRSS_stats_Controller extends Minz_ActionController {
 		$this->view->feed = $feedDAO->searchById($id);
 		$this->view->feed = $feedDAO->searchById($id);
 		$this->view->days = $statsDAO->getDays();
 		$this->view->days = $statsDAO->getDays();
 		$this->view->months = $statsDAO->getMonths();
 		$this->view->months = $statsDAO->getMonths();
-		$this->view->repartitionHour = $statsDAO->calculateEntryRepartitionPerFeedPerHour($id);
+		$this->view->repartition = $statsDAO->calculateEntryRepartitionPerFeed($id);
+		$this->view->repartitionHour = $this->convertToSerie($statsDAO->calculateEntryRepartitionPerFeedPerHour($id));
 		$this->view->averageHour = $statsDAO->calculateEntryAveragePerFeedPerHour($id);
 		$this->view->averageHour = $statsDAO->calculateEntryAveragePerFeedPerHour($id);
-		$this->view->repartitionDayOfWeek = $statsDAO->calculateEntryRepartitionPerFeedPerDayOfWeek($id);
+		$this->view->repartitionDayOfWeek = $this->convertToSerie($statsDAO->calculateEntryRepartitionPerFeedPerDayOfWeek($id));
 		$this->view->averageDayOfWeek = $statsDAO->calculateEntryAveragePerFeedPerDayOfWeek($id);
 		$this->view->averageDayOfWeek = $statsDAO->calculateEntryAveragePerFeedPerDayOfWeek($id);
-		$this->view->repartitionMonth = $statsDAO->calculateEntryRepartitionPerFeedPerMonth($id);
+		$this->view->repartitionMonth = $this->convertToSerie($statsDAO->calculateEntryRepartitionPerFeedPerMonth($id));
 		$this->view->averageMonth = $statsDAO->calculateEntryAveragePerFeedPerMonth($id);
 		$this->view->averageMonth = $statsDAO->calculateEntryAveragePerFeedPerMonth($id);
 	}
 	}
-
-	/**
-	 * 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, array('error' => array(_t('access_denied')))
-			);
-		}
-
-		Minz_View::prependTitle(_t('stats') . ' · ');
-	}
-
 }
 }

+ 11 - 16
app/Controllers/subscriptionController.php

@@ -11,10 +11,7 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function firstAction() {
 	public function firstAction() {
 		if (!FreshRSS_Auth::hasAccess()) {
 		if (!FreshRSS_Auth::hasAccess()) {
-			Minz_Error::error(
-				403,
-				array('error' => array(_t('access_denied')))
-			);
+			Minz_Error::error(403);
 		}
 		}
 
 
 		$catDAO = new FreshRSS_CategoryDAO();
 		$catDAO = new FreshRSS_CategoryDAO();
@@ -32,7 +29,7 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 	public function indexAction() {
 	public function indexAction() {
 		Minz_View::appendScript(Minz_Url::display('/scripts/category.js?' .
 		Minz_View::appendScript(Minz_Url::display('/scripts/category.js?' .
 		                        @filemtime(PUBLIC_PATH . '/scripts/category.js')));
 		                        @filemtime(PUBLIC_PATH . '/scripts/category.js')));
-		Minz_View::prependTitle(_t('subscription_management') . ' · ');
+		Minz_View::prependTitle(_t('sub.title') . ' · ');
 
 
 		$id = Minz_Request::param('id');
 		$id = Minz_Request::param('id');
 		if ($id !== false) {
 		if ($id !== false) {
@@ -71,23 +68,20 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 
 
 		$id = Minz_Request::param('id');
 		$id = Minz_Request::param('id');
 		if ($id === false || !isset($this->view->feeds[$id])) {
 		if ($id === false || !isset($this->view->feeds[$id])) {
-			Minz_Error::error(
-				404,
-				array('error' => array(_t('page_not_found')))
-			);
+			Minz_Error::error(404);
 			return;
 			return;
 		}
 		}
 
 
 		$this->view->feed = $this->view->feeds[$id];
 		$this->view->feed = $this->view->feeds[$id];
 
 
-		Minz_View::prependTitle(_t('rss_feed_management') . ' · ' . $this->view->feed->name() . ' · ');
+		Minz_View::prependTitle(_t('sub.title.feed_management') . ' · ' . $this->view->feed->name() . ' · ');
 
 
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
-			$user = Minz_Request::param('http_user', '');
-			$pass = Minz_Request::param('http_pass', '');
+			$user = trim(Minz_Request::param('http_user_feed' . $id, ''));
+			$pass = Minz_Request::param('http_pass_feed' . $id, '');
 
 
 			$httpAuth = '';
 			$httpAuth = '';
-			if ($user != '' || $pass != '') {
+			if ($user != '' && $pass != '') {	//TODO: Sanitize
 				$httpAuth = $user . ':' . $pass;
 				$httpAuth = $user . ':' . $pass;
 			}
 			}
 
 
@@ -108,13 +102,14 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 
 
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
-			if ($feedDAO->updateFeed($id, $values)) {
+			$url_redirect = array('c' => 'subscription', 'params' => array('id' => $id));
+			if ($feedDAO->updateFeed($id, $values) !== false) {
 				$this->view->feed->_category($cat);
 				$this->view->feed->_category($cat);
 				$this->view->feed->faviconPrepare();
 				$this->view->feed->faviconPrepare();
 
 
-				Minz_Request::good(_t('feed_updated'), array('c' => 'subscription', 'params' => array('id' => $id)));
+				Minz_Request::good(_t('feedback.sub.feed.updated'), $url_redirect);
 			} else {
 			} else {
-				Minz_Request::bad(_t('error_occurred_update'), array('c' => 'subscription'));
+				Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
 			}
 			}
 		}
 		}
 	}
 	}

+ 148 - 63
app/Controllers/updateController.php

@@ -1,43 +1,81 @@
 <?php
 <?php
 
 
 class FreshRSS_update_Controller extends Minz_ActionController {
 class FreshRSS_update_Controller extends Minz_ActionController {
+
+	public static function isGit() {
+		return is_dir(FRESHRSS_PATH . '/.git/');
+	}
+
+	public static function hasGitUpdate() {
+		$cwd = getcwd();
+		chdir(FRESHRSS_PATH);
+		$output = array();
+		try {
+			exec('git fetch', $output, $return);
+			if ($return == 0) {
+				exec('git status -sb --porcelain remote', $output, $return);
+			} else {
+				$line = is_array($output) ? implode('; ', $output) : '' . $output;
+				Minz_Log::warning('git fetch warning:' . $line);
+			}
+		} catch (Exception $e) {
+			Minz_Log::warning('git fetch error:' . $e->getMessage());
+		}
+		chdir($cwd);
+		$line = is_array($output) ? implode('; ', $output) : '' . $output;
+		return strpos($line, '[behind') !== false;
+	}
+
+	public static function gitPull() {
+		$cwd = getcwd();
+		chdir(FRESHRSS_PATH);
+		$output = array();
+		$return = 1;
+		try {
+			exec('git pull --ff-only', $output, $return);
+		} catch (Exception $e) {
+			Minz_Log::warning('git pull error:' . $e->getMessage());
+		}
+		chdir($cwd);
+		$line = is_array($output) ? implode('; ', $output) : '' . $output;
+		return $return == 0 ? true : 'Git error: ' . $line;
+	}
+
 	public function firstAction() {
 	public function firstAction() {
-		$current_user = Minz_Session::param('currentUser', '');
 		if (!FreshRSS_Auth::hasAccess('admin')) {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
-			Minz_Error::error(
-				403,
-				array('error' => array(_t('access_denied')))
-			);
+			Minz_Error::error(403);
 		}
 		}
 
 
 		invalidateHttpCache();
 		invalidateHttpCache();
 
 
 		$this->view->update_to_apply = false;
 		$this->view->update_to_apply = false;
 		$this->view->last_update_time = 'unknown';
 		$this->view->last_update_time = 'unknown';
-		$this->view->check_last_hour = false;
-		$timestamp = (int)@file_get_contents(DATA_PATH . '/last_update.txt');
-		if (is_numeric($timestamp) && $timestamp > 0) {
+		$timestamp = @filemtime(join_path(DATA_PATH, 'last_update.txt'));
+		if ($timestamp !== false) {
 			$this->view->last_update_time = timestamptodate($timestamp);
 			$this->view->last_update_time = timestamptodate($timestamp);
-			$this->view->check_last_hour = (time() - 3600) <= $timestamp;
 		}
 		}
 	}
 	}
 
 
 	public function indexAction() {
 	public function indexAction() {
-		Minz_View::prependTitle(_t('update_system') . ' · ');
+		Minz_View::prependTitle(_t('admin.update.title') . ' · ');
 
 
-		if (file_exists(UPDATE_FILENAME) && !is_writable(FRESHRSS_PATH)) {
+		if (!is_writable(FRESHRSS_PATH)) {
 			$this->view->message = array(
 			$this->view->message = array(
 				'status' => 'bad',
 				'status' => 'bad',
-				'title' => _t('damn'),
-				'body' => _t('file_is_nok', FRESHRSS_PATH)
+				'title' => _t('gen.short.damn'),
+				'body' => _t('feedback.update.file_is_nok', FRESHRSS_PATH)
 			);
 			);
 		} elseif (file_exists(UPDATE_FILENAME)) {
 		} elseif (file_exists(UPDATE_FILENAME)) {
 			// There is an update file to apply!
 			// There is an update file to apply!
+			$version = @file_get_contents(join_path(DATA_PATH, 'last_update.txt'));
+			if (empty($version)) {
+				$version = 'unknown';
+			}
 			$this->view->update_to_apply = true;
 			$this->view->update_to_apply = true;
 			$this->view->message = array(
 			$this->view->message = array(
 				'status' => 'good',
 				'status' => 'good',
-				'title' => _t('ok'),
-				'body' => _t('update_can_apply')
+				'title' => _t('gen.short.ok'),
+				'body' => _t('feedback.update.can_apply', $version)
 			);
 			);
 		}
 		}
 	}
 	}
@@ -45,59 +83,80 @@ class FreshRSS_update_Controller extends Minz_ActionController {
 	public function checkAction() {
 	public function checkAction() {
 		$this->view->change_view('update', 'index');
 		$this->view->change_view('update', 'index');
 
 
-		if (file_exists(UPDATE_FILENAME) || $this->view->check_last_hour) {
+		if (file_exists(UPDATE_FILENAME)) {
 			// There is already an update file to apply: we don't need to check
 			// There is already an update file to apply: we don't need to check
 			// the webserver!
 			// the webserver!
 			// Or if already check during the last hour, do nothing.
 			// Or if already check during the last hour, do nothing.
-			Minz_Request::forward(array('c' => 'update'));
+			Minz_Request::forward(array('c' => 'update'), true);
 
 
 			return;
 			return;
 		}
 		}
 
 
-		$c = curl_init(FRESHRSS_UPDATE_WEBSITE);
-		curl_setopt($c, CURLOPT_RETURNTRANSFER, true);
-		curl_setopt($c, CURLOPT_SSL_VERIFYPEER, true);
-		curl_setopt($c, CURLOPT_SSL_VERIFYHOST, 2);
-		$result = curl_exec($c);
-		$c_status = curl_getinfo($c, CURLINFO_HTTP_CODE);
-		$c_error = curl_error($c);
-		curl_close($c);
-
-		if ($c_status !== 200) {
-			Minz_Log::error(
-				'Error during update (HTTP code ' . $c_status . '): ' . $c_error
-			);
+		$script = '';
+		$version = '';
 
 
-			$this->view->message = array(
-				'status' => 'bad',
-				'title' => _t('damn'),
-				'body' => _t('update_server_not_found', FRESHRSS_UPDATE_WEBSITE)
-			);
-			return;
-		}
-
-		$res_array = explode("\n", $result, 2);
-		$status = $res_array[0];
-		if (strpos($status, 'UPDATE') !== 0) {
-			$this->view->message = array(
-				'status' => 'bad',
-				'title' => _t('damn'),
-				'body' => _t('no_update')
-			);
+		if (self::isGit()) {
+			if (self::hasGitUpdate()) {
+				$version = 'git';
+			} else {
+				$this->view->message = array(
+					'status' => 'bad',
+					'title' => _t('gen.short.damn'),
+					'body' => _t('feedback.update.none')
+				);
+				@touch(join_path(DATA_PATH, 'last_update.txt'));
+				return;
+			}
+		} else {
+			$auto_update_url = FreshRSS_Context::$system_conf->auto_update_url . '?v=' . FRESHRSS_VERSION;
+			Minz_Log::debug('HTTP GET ' . $auto_update_url);
+			$c = curl_init($auto_update_url);
+			curl_setopt($c, CURLOPT_RETURNTRANSFER, true);
+			curl_setopt($c, CURLOPT_SSL_VERIFYPEER, true);
+			curl_setopt($c, CURLOPT_SSL_VERIFYHOST, 2);
+			$result = curl_exec($c);
+			$c_status = curl_getinfo($c, CURLINFO_HTTP_CODE);
+			$c_error = curl_error($c);
+			curl_close($c);
+
+			if ($c_status !== 200) {
+				Minz_Log::warning(
+					'Error during update (HTTP code ' . $c_status . '): ' . $c_error
+				);
+
+				$this->view->message = array(
+					'status' => 'bad',
+					'title' => _t('gen.short.damn'),
+					'body' => _t('feedback.update.server_not_found', $auto_update_url)
+				);
+				return;
+			}
 
 
-			@file_put_contents(DATA_PATH . '/last_update.txt', time());
+			$res_array = explode("\n", $result, 2);
+			$status = $res_array[0];
+			if (strpos($status, 'UPDATE') !== 0) {
+				$this->view->message = array(
+					'status' => 'bad',
+					'title' => _t('gen.short.damn'),
+					'body' => _t('feedback.update.none')
+				);
+				@touch(join_path(DATA_PATH, 'last_update.txt'));
+				return;
+			}
 
 
-			return;
+			$script = $res_array[1];
+			$version = explode(' ', $status, 2);
+			$version = $version[1];
 		}
 		}
 
 
-		$script = $res_array[1];
 		if (file_put_contents(UPDATE_FILENAME, $script) !== false) {
 		if (file_put_contents(UPDATE_FILENAME, $script) !== false) {
-			Minz_Request::forward(array('c' => 'update'));
+			@file_put_contents(join_path(DATA_PATH, 'last_update.txt'), $version);
+			Minz_Request::forward(array('c' => 'update'), true);
 		} else {
 		} else {
 			$this->view->message = array(
 			$this->view->message = array(
 				'status' => 'bad',
 				'status' => 'bad',
-				'title' => _t('damn'),
-				'body' => _t('update_problem', 'Cannot save the update script')
+				'title' => _t('gen.short.damn'),
+				'body' => _t('feedback.update.error', 'Cannot save the update script')
 			);
 			);
 		}
 		}
 	}
 	}
@@ -107,22 +166,48 @@ class FreshRSS_update_Controller extends Minz_ActionController {
 			Minz_Request::forward(array('c' => 'update'), true);
 			Minz_Request::forward(array('c' => 'update'), true);
 		}
 		}
 
 
-		require(UPDATE_FILENAME);
-
-		if (Minz_Request::isPost()) {
-			save_info_update();
-		}
+		if (Minz_Request::param('post_conf', false)) {
+			if (self::isGit()) {
+				$res = !self::hasGitUpdate();
+			} else {
+				require(UPDATE_FILENAME);
+				$res = do_post_update();
+			}
 
 
-		if (!need_info_update()) {
-			$res = apply_update();
+			Minz_ExtensionManager::callHook('post_update');
 
 
 			if ($res === true) {
 			if ($res === true) {
 				@unlink(UPDATE_FILENAME);
 				@unlink(UPDATE_FILENAME);
-				@file_put_contents(DATA_PATH . '/last_update.txt', time());
+				@file_put_contents(join_path(DATA_PATH, 'last_update.txt'), '');
+				Minz_Request::good(_t('feedback.update.finished'));
+			} else {
+				Minz_Request::bad(_t('feedback.update.error', $res),
+				                  array('c' => 'update', 'a' => 'index'));
+			}
+		} else {
+			$res = false;
 
 
-				Minz_Request::good(_t('update_finished'));
+			if (self::isGit()) {
+				$res = self::gitPull();
+			} else {
+				if (Minz_Request::isPost()) {
+					save_info_update();
+				}
+				if (!need_info_update()) {
+					$res = apply_update();
+				} else {
+					return;
+				}
+			}
+
+			if ($res === true) {
+				Minz_Request::forward(array(
+					'c' => 'update',
+					'a' => 'apply',
+					'params' => array('post_conf' => true)
+				), true);
 			} else {
 			} else {
-				Minz_Request::bad(_t('update_problem', $res),
+				Minz_Request::bad(_t('feedback.update.error', $res),
 				                  array('c' => 'update', 'a' => 'index'));
 				                  array('c' => 'update', 'a' => 'index'));
 			}
 			}
 		}
 		}
@@ -132,7 +217,7 @@ class FreshRSS_update_Controller extends Minz_ActionController {
 	 * This action displays information about installation.
 	 * This action displays information about installation.
 	 */
 	 */
 	public function checkInstallAction() {
 	public function checkInstallAction() {
-		Minz_View::prependTitle(_t('gen.title.check_install') . ' · ');
+		Minz_View::prependTitle(_t('admin.check_install.title') . ' · ');
 
 
 		$this->view->status_php = check_install_php();
 		$this->view->status_php = check_install_php();
 		$this->view->status_files = check_install_files();
 		$this->view->status_files = check_install_files();

+ 163 - 122
app/Controllers/userController.php

@@ -12,73 +12,66 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 	 * This action is called before every other action in that class. It is
 	 * 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
 	 * the common boiler plate for every action. It is triggered by the
 	 * underlying framework.
 	 * underlying framework.
+	 *
+	 * @todo clean up the access condition.
 	 */
 	 */
 	public function firstAction() {
 	public function firstAction() {
-		if (!FreshRSS_Auth::hasAccess()) {
-			Minz_Error::error(
-				403,
-				array('error' => array(_t('access_denied')))
-			);
+		if (!FreshRSS_Auth::hasAccess() && !(
+				Minz_Request::actionName() === 'create' &&
+				!max_registrations_reached()
+		)) {
+			Minz_Error::error(403);
+		}
+	}
+
+	public static function hashPassword($passwordPlain) {
+		if (!function_exists('password_hash')) {
+			include_once(LIB_PATH . '/password_compat.php');
 		}
 		}
+		$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
+		$passwordPlain = '';
+		$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
+		return $passwordHash == '' ? '' : $passwordHash;
 	}
 	}
 
 
 	/**
 	/**
-	 * This action displays the user profil page.
+	 * This action displays the user profile page.
 	 */
 	 */
-	public function profilAction() {
-		Minz_View::prependTitle(_t('users.profil') . ' · ');
+	public function profileAction() {
+		Minz_View::prependTitle(_t('conf.profile.title') . ' · ');
+
+		Minz_View::appendScript(Minz_Url::display(
+			'/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')
+		));
 
 
 		if (Minz_Request::isPost()) {
 		if (Minz_Request::isPost()) {
 			$ok = true;
 			$ok = true;
 
 
-			$passwordPlain = Minz_Request::param('passwordPlain', '', true);
+			$passwordPlain = Minz_Request::param('newPasswordPlain', '', true);
 			if ($passwordPlain != '') {
 			if ($passwordPlain != '') {
-				Minz_Request::_param('passwordPlain');	//Discard plain-text password ASAP
-				$_POST['passwordPlain'] = '';
-				if (!function_exists('password_hash')) {
-					include_once(LIB_PATH . '/password_compat.php');
-				}
-				$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-				$passwordPlain = '';
-				$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
+				Minz_Request::_param('newPasswordPlain');	//Discard plain-text password ASAP
+				$_POST['newPasswordPlain'] = '';
+				$passwordHash = self::hashPassword($passwordPlain);
 				$ok &= ($passwordHash != '');
 				$ok &= ($passwordHash != '');
-				FreshRSS_Context::$conf->_passwordHash($passwordHash);
+				FreshRSS_Context::$user_conf->passwordHash = $passwordHash;
 			}
 			}
-			Minz_Session::_param('passwordHash', FreshRSS_Context::$conf->passwordHash);
+			Minz_Session::_param('passwordHash', FreshRSS_Context::$user_conf->passwordHash);
 
 
 			$passwordPlain = Minz_Request::param('apiPasswordPlain', '', true);
 			$passwordPlain = Minz_Request::param('apiPasswordPlain', '', true);
 			if ($passwordPlain != '') {
 			if ($passwordPlain != '') {
-				if (!function_exists('password_hash')) {
-					include_once(LIB_PATH . '/password_compat.php');
-				}
-				$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-				$passwordPlain = '';
-				$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
+				$passwordHash = self::hashPassword($passwordPlain);
 				$ok &= ($passwordHash != '');
 				$ok &= ($passwordHash != '');
-				FreshRSS_Context::$conf->_apiPasswordHash($passwordHash);
-			}
-
-			// TODO: why do we need of hasAccess here?
-			if (FreshRSS_Auth::hasAccess('admin')) {
-				FreshRSS_Context::$conf->_mail_login(Minz_Request::param('mail_login', '', true));
+				FreshRSS_Context::$user_conf->apiPasswordHash = $passwordHash;
 			}
 			}
-			$email = FreshRSS_Context::$conf->mail_login;
-			Minz_Session::_param('mail', $email);
 
 
-			$ok &= FreshRSS_Context::$conf->save();
-
-			if ($email != '') {
-				$personaFile = DATA_PATH . '/persona/' . $email . '.txt';
-				@unlink($personaFile);
-				$ok &= (file_put_contents($personaFile, Minz_Session::param('currentUser', '_')) !== false);
-			}
+			$ok &= FreshRSS_Context::$user_conf->save();
 
 
 			if ($ok) {
 			if ($ok) {
-				Minz_Request::good('users.profil.updated',
-				                   array('c' => 'user', 'a' => 'profil'));
+				Minz_Request::good(_t('feedback.profile.updated'),
+				                   array('c' => 'user', 'a' => 'profile'));
 			} else {
 			} else {
-				Minz_Request::bad('error_occurred',
-				                  array('c' => 'user', 'a' => 'profil'));
+				Minz_Request::bad(_t('feedback.profile.error'),
+				                  array('c' => 'user', 'a' => 'profile'));
 			}
 			}
 		}
 		}
 	}
 	}
@@ -88,16 +81,14 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 	 */
 	 */
 	public function manageAction() {
 	public function manageAction() {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
-			Minz_Error::error(403,
-			                  array('error' => array(_t('access_denied'))));
+			Minz_Error::error(403);
 		}
 		}
 
 
-		Minz_View::prependTitle(_t('users.manage') . ' · ');
+		Minz_View::prependTitle(_t('admin.user.title') . ' · ');
 
 
 		// Get the correct current user.
 		// Get the correct current user.
-		$userDAO = new FreshRSS_UserDAO();
 		$username = Minz_Request::param('u', Minz_Session::param('currentUser'));
 		$username = Minz_Request::param('u', Minz_Session::param('currentUser'));
-		if (!$userDAO->exist($username)) {
+		if (!FreshRSS_UserDAO::exist($username)) {
 			$username = Minz_Session::param('currentUser');
 			$username = Minz_Session::param('currentUser');
 		}
 		}
 		$this->view->current_user = $username;
 		$this->view->current_user = $username;
@@ -108,109 +99,159 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		$this->view->size_user = $entryDAO->size();
 		$this->view->size_user = $entryDAO->size();
 	}
 	}
 
 
-	public function createAction() {
-		if (Minz_Request::isPost() && FreshRSS_Auth::hasAccess('admin')) {
-			$db = Minz_Configuration::dataBase();
-			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
-
-			$new_user_language = Minz_Request::param('new_user_language', FreshRSS_Context::$conf->language);
-			if (!in_array($new_user_language, FreshRSS_Context::$conf->availableLanguages())) {
-				$new_user_language = FreshRSS_Context::$conf->language;
-			}
+	public static function createUser($new_user_name, $passwordPlain, $apiPasswordPlain, $userConfig = array(), $insertDefaultFeeds = true) {
+		if (!is_array($userConfig)) {
+			$userConfig = array();
+		}
 
 
-			$new_user_name = Minz_Request::param('new_user_name');
-			$ok = ($new_user_name != '') && ctype_alnum($new_user_name);
+		$ok = ($new_user_name != '') && ctype_alnum($new_user_name);
 
 
-			if ($ok) {
-				$ok &= (strcasecmp($new_user_name, Minz_Configuration::defaultUser()) !== 0);	//It is forbidden to alter the default user
+		if ($ok) {
+			$languages = Minz_Translate::availableLanguages();
+			if (empty($userConfig['language']) || !in_array($userConfig['language'], $languages)) {
+				$userConfig['language'] = 'en';
+			}
 
 
-				$ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', listUsers()));	//Not an existing user, case-insensitive
+			$ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', listUsers()));	//Not an existing user, case-insensitive
 
 
-				$configPath = DATA_PATH . '/' . $new_user_name . '_user.php';
-				$ok &= !file_exists($configPath);
-			}
-			if ($ok) {
-			
-				$passwordPlain = Minz_Request::param('new_user_passwordPlain', '', true);
-				$passwordHash = '';
-				if ($passwordPlain != '') {
-					Minz_Request::_param('new_user_passwordPlain');	//Discard plain-text password ASAP
-					$_POST['new_user_passwordPlain'] = '';
-					if (!function_exists('password_hash')) {
-						include_once(LIB_PATH . '/password_compat.php');
-					}
-					$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-					$passwordPlain = '';
-					$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
-					$ok &= ($passwordHash != '');
-				}
-				if (empty($passwordHash)) {
-					$passwordHash = '';
-				}
-
-				$new_user_email = filter_var($_POST['new_user_email'], FILTER_VALIDATE_EMAIL);
-				if (empty($new_user_email)) {
-					$new_user_email = '';
-				} else {
-					$personaFile = DATA_PATH . '/persona/' . $new_user_email . '.txt';
-					@unlink($personaFile);
-					$ok &= (file_put_contents($personaFile, $new_user_name) !== false);
-				}
-			}
-			if ($ok) {
-				$config_array = array(
-					'language' => $new_user_language,
-					'passwordHash' => $passwordHash,
-					'mail_login' => $new_user_email,
-				);
-				$ok &= (file_put_contents($configPath, "<?php\n return " . var_export($config_array, true) . ';') !== false);
+			$configPath = join_path(DATA_PATH, 'users', $new_user_name, 'config.php');
+			$ok &= !file_exists($configPath);
+		}
+		if ($ok) {
+			$passwordHash = '';
+			if ($passwordPlain != '') {
+				$passwordHash = self::hashPassword($passwordPlain);
+				$ok &= ($passwordHash != '');
 			}
 			}
-			if ($ok) {
-				$userDAO = new FreshRSS_UserDAO();
-				$ok &= $userDAO->createUser($new_user_name);
+
+			$apiPasswordHash = '';
+			if ($apiPasswordPlain != '') {
+				$apiPasswordHash = self::hashPassword($apiPasswordPlain);
+				$ok &= ($apiPasswordHash != '');
 			}
 			}
+		}
+		if ($ok) {
+			mkdir(join_path(DATA_PATH, 'users', $new_user_name));
+			$userConfig['passwordHash'] = $passwordHash;
+			$userConfig['apiPasswordHash'] = $apiPasswordHash;
+			$ok &= (file_put_contents($configPath, "<?php\n return " . var_export($userConfig, true) . ';') !== false);
+		}
+		if ($ok) {
+			$userDAO = new FreshRSS_UserDAO();
+			$ok &= $userDAO->createUser($new_user_name, $userConfig['language'], $insertDefaultFeeds);
+		}
+		return $ok;
+	}
+
+	/**
+	 * This action creates a new user.
+	 *
+	 * Request parameters are:
+	 *   - new_user_language
+	 *   - new_user_name
+	 *   - new_user_passwordPlain
+	 *   - r (i.e. a redirection url, optional)
+	 *
+	 * @todo clean up this method. Idea: write a method to init a user with basic information.
+	 * @todo handle r redirection in Minz_Request::forward directly?
+	 */
+	public function createAction() {
+		if (Minz_Request::isPost() && (
+				FreshRSS_Auth::hasAccess('admin') ||
+				!max_registrations_reached()
+		)) {
+			$new_user_name = Minz_Request::param('new_user_name');
+			$passwordPlain = Minz_Request::param('new_user_passwordPlain', '', true);
+			$new_user_language = Minz_Request::param('new_user_language', FreshRSS_Context::$user_conf->language);
+
+			$ok = self::createUser($new_user_name, $passwordPlain, '', array('language' => $new_user_language));
+			Minz_Request::_param('new_user_passwordPlain');	//Discard plain-text password ASAP
+			$_POST['new_user_passwordPlain'] = '';
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
 			$notif = array(
 			$notif = array(
 				'type' => $ok ? 'good' : 'bad',
 				'type' => $ok ? 'good' : 'bad',
-				'content' => _t($ok ? 'user_created' : 'error_occurred', $new_user_name)
+				'content' => _t('feedback.user.created' . (!$ok ? '.error' : ''), $new_user_name)
 			);
 			);
 			Minz_Session::_param('notification', $notif);
 			Minz_Session::_param('notification', $notif);
 		}
 		}
 
 
-		Minz_Request::forward(array('c' => 'user', 'a' => 'manage'), true);
+		$redirect_url = urldecode(Minz_Request::param('r', false, true));
+		if (!$redirect_url) {
+			$redirect_url = array('c' => 'user', 'a' => 'manage');
+		}
+		Minz_Request::forward($redirect_url, true);
 	}
 	}
 
 
+	public static function deleteUser($username) {
+		$db = FreshRSS_Context::$system_conf->db;
+		require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
+
+		$ok = ctype_alnum($username);
+		if ($ok) {
+			$default_user = FreshRSS_Context::$system_conf->default_user;
+			$ok &= (strcasecmp($username, $default_user) !== 0);	//It is forbidden to delete the default user
+		}
+		$user_data = join_path(DATA_PATH, 'users', $username);
+		if ($ok) {
+			$ok &= is_dir($user_data);
+		}
+		if ($ok) {
+			$userDAO = new FreshRSS_UserDAO();
+			$ok &= $userDAO->deleteUser($username);
+			$ok &= recursive_unlink($user_data);
+		}
+		return $ok;
+	}
+
+	/**
+	 * This action delete an existing user.
+	 *
+	 * Request parameter is:
+	 *   - username
+	 *
+	 * @todo clean up this method. Idea: create a User->clean() method.
+	 */
 	public function deleteAction() {
 	public function deleteAction() {
-		if (Minz_Request::isPost() && FreshRSS_Auth::hasAccess('admin')) {
-			$db = Minz_Configuration::dataBase();
-			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
+		$username = Minz_Request::param('username');
+		$redirect_url = urldecode(Minz_Request::param('r', false, true));
+		if (!$redirect_url) {
+			$redirect_url = array('c' => 'user', 'a' => 'manage');
+		}
 
 
-			$username = Minz_Request::param('username');
-			$ok = ctype_alnum($username);
+		$self_deletion = Minz_Session::param('currentUser', '_') === $username;
 
 
-			if ($ok) {
-				$ok &= (strcasecmp($username, Minz_Configuration::defaultUser()) !== 0);	//It is forbidden to delete the default user
+		if (Minz_Request::isPost() && (
+				FreshRSS_Auth::hasAccess('admin') ||
+				$self_deletion
+		)) {
+			$ok = true;
+			if ($ok && $self_deletion) {
+				// We check the password if it's a self-destruction
+				$nonce = Minz_Session::param('nonce');
+				$challenge = Minz_Request::param('challenge', '');
+
+				$ok &= FreshRSS_FormAuth::checkCredentials(
+					$username, FreshRSS_Context::$user_conf->passwordHash,
+					$nonce, $challenge
+				);
 			}
 			}
 			if ($ok) {
 			if ($ok) {
-				$configPath = DATA_PATH . '/' . $username . '_user.php';
-				$ok &= file_exists($configPath);
+				$ok &= self::deleteUser($username);
 			}
 			}
-			if ($ok) {
-				$userDAO = new FreshRSS_UserDAO();
-				$ok &= $userDAO->deleteUser($username);
-				$ok &= unlink($configPath);
-				//TODO: delete Persona file
+			if ($ok && $self_deletion) {
+				FreshRSS_Auth::removeAccess();
+				$redirect_url = array('c' => 'index', 'a' => 'index');
 			}
 			}
 			invalidateHttpCache();
 			invalidateHttpCache();
 
 
 			$notif = array(
 			$notif = array(
 				'type' => $ok ? 'good' : 'bad',
 				'type' => $ok ? 'good' : 'bad',
-				'content' => _t($ok ? 'user_deleted' : 'error_occurred', $username)
+				'content' => _t('feedback.user.deleted' . (!$ok ? '.error' : ''), $username)
 			);
 			);
 			Minz_Session::_param('notification', $notif);
 			Minz_Session::_param('notification', $notif);
 		}
 		}
 
 
-		Minz_Request::forward(array('c' => 'user', 'a' => 'manage'), true);
+		Minz_Request::forward($redirect_url, true);
 	}
 	}
 }
 }

+ 14 - 0
app/Exceptions/AlreadySubscribedException.php

@@ -0,0 +1,14 @@
+<?php
+
+class FreshRSS_AlreadySubscribed_Exception extends Exception {
+	private $feedName = '';
+
+	public function __construct($url, $feedName) {
+		parent::__construct('Already subscribed! ' . $url, 2135);
+		$this->feedName = $feedName;
+	}
+
+	public function feedName() {
+		return $this->feedName;
+	}
+}

+ 3 - 0
app/Exceptions/BadUrlException.php

@@ -1,6 +1,9 @@
 <?php
 <?php
+
 class FreshRSS_BadUrl_Exception extends FreshRSS_Feed_Exception {
 class FreshRSS_BadUrl_Exception extends FreshRSS_Feed_Exception {
+
 	public function __construct($url) {
 	public function __construct($url) {
 		parent::__construct('`' . $url . '` is not a valid URL');
 		parent::__construct('`' . $url . '` is not a valid URL');
 	}
 	}
+
 }
 }

+ 8 - 0
app/Exceptions/ContextException.php

@@ -0,0 +1,8 @@
+<?php
+
+/**
+ * An exception raised when a context is invalid
+ */
+class FreshRSS_Context_Exception extends Exception {
+
+}

+ 5 - 0
app/Exceptions/DAOException.php

@@ -0,0 +1,5 @@
+<?php
+
+class FreshRSS_DAO_Exception extends Exception {
+
+}

+ 1 - 3
app/Exceptions/EntriesGetterException.php

@@ -1,7 +1,5 @@
 <?php
 <?php
 
 
 class FreshRSS_EntriesGetter_Exception extends Exception {
 class FreshRSS_EntriesGetter_Exception extends Exception {
-	public function __construct($message) {
-		parent::__construct($message);
-	}
+
 }
 }

+ 2 - 3
app/Exceptions/FeedException.php

@@ -1,6 +1,5 @@
 <?php
 <?php
+
 class FreshRSS_Feed_Exception extends Exception {
 class FreshRSS_Feed_Exception extends Exception {
-	public function __construct($message) {
-		parent::__construct($message);
-	}
+
 }
 }

+ 14 - 0
app/Exceptions/FeedNotAddedException.php

@@ -0,0 +1,14 @@
+<?php
+
+class FreshRSS_FeedNotAdded_Exception extends Exception {
+	private $feedName = '';
+
+	public function __construct($url, $feedName) {
+		parent::__construct('Feed not added! ' . $url, 2147);
+		$this->feedName = $feedName;
+	}
+
+	public function feedName() {
+		return $this->feedName;
+	}
+}

+ 14 - 0
app/Exceptions/ZipException.php

@@ -0,0 +1,14 @@
+<?php
+
+class FreshRSS_Zip_Exception extends Exception {
+	private $zipErrorCode = 0;
+	
+	public function __construct($zipErrorCode) {
+		parent::__construct('ZIP error! ' . $url, 2141);
+		$this->zipErrorCode = $zipErrorCode;
+	}
+
+	public function zipErrorCode() {
+		return $this->zipErrorCode;
+	}
+}

+ 4 - 0
app/Exceptions/ZipMissingException.php

@@ -0,0 +1,4 @@
+<?php
+
+class FreshRSS_ZipMissing_Exception extends Exception {
+}

+ 77 - 51
app/FreshRSS.php

@@ -1,48 +1,84 @@
 <?php
 <?php
 
 
 class FreshRSS extends Minz_FrontController {
 class FreshRSS extends Minz_FrontController {
+	/**
+	 * Initialize the different FreshRSS / Minz components.
+	 *
+	 * PLEASE DON'T CHANGE THE ORDER OF INITIALIZATIONS UNLESS YOU KNOW WHAT
+	 * YOU DO!!
+	 *
+	 * Here is the list of components:
+	 * - Create a configuration setter and register it to system conf
+	 * - Init extension manager and enable system extensions (has to be done asap)
+	 * - Init authentication system
+	 * - Init user configuration (need auth system)
+	 * - Init FreshRSS context (need user conf)
+	 * - Init i18n (need context)
+	 * - Init sharing system (need user conf and i18n)
+	 * - Init generic styles and scripts (need user conf)
+	 * - Init notifications
+	 * - Enable user extensions (need all the other initializations)
+	 */
 	public function init() {
 	public function init() {
 		if (!isset($_SESSION)) {
 		if (!isset($_SESSION)) {
 			Minz_Session::init('FreshRSS');
 			Minz_Session::init('FreshRSS');
 		}
 		}
 
 
-		// Need to be called just after session init because it initializes
-		// current user.
-		FreshRSS_Auth::init();
+		// Register the configuration setter for the system configuration
+		$configuration_setter = new FreshRSS_ConfigurationSetter();
+		$system_conf = Minz_Configuration::get('system');
+		$system_conf->_configurationSetter($configuration_setter);
+
+		// Load list of extensions and enable the "system" ones.
+		Minz_ExtensionManager::init();
+
+		// Auth has to be initialized before using currentUser session parameter
+		// because it's this part which create this parameter.
+		self::initAuth();
+
+		// Then, register the user configuration and use the configuration setter
+		// created above.
+		$current_user = Minz_Session::param('currentUser', '_');
+		Minz_Configuration::register('user',
+		                             join_path(USERS_PATH, $current_user, 'config.php'),
+		                             join_path(USERS_PATH, '_', 'config.default.php'),
+		                             $configuration_setter);
+
+		// Finish to initialize the other FreshRSS / Minz components.
+		FreshRSS_Context::init();
+		self::initI18n();
+		self::loadNotifications();
+		// Enable extensions for the current (logged) user.
+		if (FreshRSS_Auth::hasAccess() || $system_conf->allow_anonymous) {
+			$ext_list = FreshRSS_Context::$user_conf->extensions_enabled;
+			Minz_ExtensionManager::enableByList($ext_list);
+		}
+	}
 
 
-		if (Minz_Request::isPost() && !is_referer_from_same_domain()) {
+	private static function initAuth() {
+		FreshRSS_Auth::init();
+		if (Minz_Request::isPost() && !(is_referer_from_same_domain() && FreshRSS_Auth::isCsrfOk())) {
 			// Basic protection against XSRF attacks
 			// Basic protection against XSRF attacks
 			FreshRSS_Auth::removeAccess();
 			FreshRSS_Auth::removeAccess();
 			$http_referer = empty($_SERVER['HTTP_REFERER']) ? '' : $_SERVER['HTTP_REFERER'];
 			$http_referer = empty($_SERVER['HTTP_REFERER']) ? '' : $_SERVER['HTTP_REFERER'];
+			Minz_Translate::init('en');	//TODO: Better choice of fallback language
 			Minz_Error::error(
 			Minz_Error::error(
 				403,
 				403,
 				array('error' => array(
 				array('error' => array(
-					_t('access_denied'),
+					_t('feedback.access.denied'),
 					' [HTTP_REFERER=' . htmlspecialchars($http_referer) . ']'
 					' [HTTP_REFERER=' . htmlspecialchars($http_referer) . ']'
 				))
 				))
 			);
 			);
 		}
 		}
-
-		// Load context and configuration.
-		FreshRSS_Context::init();
-
-		$this->loadParamsView();
-		$this->loadStylesAndScripts();
-		$this->loadNotifications();
-		$this->loadExtensions();
 	}
 	}
 
 
-	private function loadParamsView() {
-		// TODO: outputs should be different actions.
-		$output = Minz_Request::param('output', '');
-		if (($output === '') || ($output !== 'normal' && $output !== 'rss' && $output !== 'reader' && $output !== 'global')) {
-			$output = FreshRSS_Context::$conf->view_mode;
-			Minz_Request::_param('output', $output);
-		}
+	private static function initI18n() {
+		Minz_Session::_param('language', FreshRSS_Context::$user_conf->language);
+		Minz_Translate::init(FreshRSS_Context::$user_conf->language);
 	}
 	}
 
 
-	private function loadStylesAndScripts() {
-		$theme = FreshRSS_Themes::load(FreshRSS_Context::$conf->theme);
+	public static function loadStylesAndScripts() {
+		$theme = FreshRSS_Themes::load(FreshRSS_Context::$user_conf->theme);
 		if ($theme) {
 		if ($theme) {
 			foreach($theme['files'] as $file) {
 			foreach($theme['files'] as $file) {
 				if ($file[0] === '_') {
 				if ($file[0] === '_') {
@@ -53,26 +89,18 @@ class FreshRSS extends Minz_FrontController {
 					$filename = $file;
 					$filename = $file;
 				}
 				}
 				$filetime = @filemtime(PUBLIC_PATH . '/themes/' . $theme_id . '/' . $filename);
 				$filetime = @filemtime(PUBLIC_PATH . '/themes/' . $theme_id . '/' . $filename);
-				Minz_View::appendStyle(Minz_Url::display(
-					'/themes/' . $theme_id . '/' . $filename . '?' . $filetime
-				));
+				$url = '/themes/' . $theme_id . '/' . $filename . '?' . $filetime;
+				header('Link: <' . Minz_Url::display($url, '', 'root') . '>;rel=preload', false);	//HTTP2
+				Minz_View::appendStyle(Minz_Url::display($url));
 			}
 			}
 		}
 		}
 
 
 		Minz_View::appendScript(Minz_Url::display('/scripts/jquery.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/jquery.min.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/jquery.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/jquery.min.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/shortcut.js?' . @filemtime(PUBLIC_PATH . '/scripts/shortcut.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/shortcut.js?' . @filemtime(PUBLIC_PATH . '/scripts/shortcut.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/main.js?' . @filemtime(PUBLIC_PATH . '/scripts/main.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/main.js?' . @filemtime(PUBLIC_PATH . '/scripts/main.js')));
-
-		if (Minz_Configuration::authType() === 'persona') {
-			// TODO move it in a plugin
-			// Needed for login AND logout with Persona.
-			Minz_View::appendScript('https://login.persona.org/include.js');
-			$file_mtime = @filemtime(PUBLIC_PATH . '/scripts/persona.js');
-			Minz_View::appendScript(Minz_Url::display('/scripts/persona.js?' . $file_mtime));
-		}
 	}
 	}
 
 
-	private function loadNotifications() {
+	private static function loadNotifications() {
 		$notif = Minz_Session::param('notification');
 		$notif = Minz_Session::param('notification');
 		if ($notif) {
 		if ($notif) {
 			Minz_View::_param('notification', $notif);
 			Minz_View::_param('notification', $notif);
@@ -80,23 +108,21 @@ class FreshRSS extends Minz_FrontController {
 		}
 		}
 	}
 	}
 
 
-	private function loadExtensions() {
-		$extensionPath = FRESHRSS_PATH . '/extensions/';
-		//TODO: Add a preference to load only user-selected extensions
-		foreach (scandir($extensionPath) as $key => $extension) {
-			if (ctype_alpha($extension)) {
-				$mtime = @filemtime($extensionPath . $extension . '/style.css');
-				if ($mtime !== false) {
-					Minz_View::appendStyle(Minz_Url::display('/ext.php?c&amp;e=' . $extension . '&amp;' . $mtime));
-				}
-				$mtime = @filemtime($extensionPath . $extension . '/script.js');
-				if ($mtime !== false) {
-					Minz_View::appendScript(Minz_Url::display('/ext.php?j&amp;e=' . $extension . '&amp;' . $mtime));
-				}
-				if (file_exists($extensionPath . $extension . '/module.php')) {
-					//TODO: include
-				} 
-			}
+	public static function preLayout() {
+		switch (Minz_Request::controllerName()) {
+			case 'index':
+				header("Content-Security-Policy: default-src 'self'; child-src *; frame-src *; img-src * data:; media-src *");
+				break;
+			case 'stats':
+				header("Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'");
+				break;
+			default:
+				header("Content-Security-Policy: default-src 'self'");
+				break;
 		}
 		}
+		header("X-Content-Type-Options: nosniff");
+
+		FreshRSS_Share::load(join_path(DATA_PATH, 'shares.php'));
+		self::loadStylesAndScripts();
 	}
 	}
 }
 }

+ 69 - 40
app/Models/Auth.php

@@ -16,14 +16,16 @@ class FreshRSS_Auth {
 		self::$login_ok = Minz_Session::param('loginOk', false);
 		self::$login_ok = Minz_Session::param('loginOk', false);
 		$current_user = Minz_Session::param('currentUser', '');
 		$current_user = Minz_Session::param('currentUser', '');
 		if ($current_user === '') {
 		if ($current_user === '') {
-			$current_user = Minz_Configuration::defaultUser();
+			$conf = Minz_Configuration::get('system');
+			$current_user = $conf->default_user;
 			Minz_Session::_param('currentUser', $current_user);
 			Minz_Session::_param('currentUser', $current_user);
 		}
 		}
 
 
-		$access_ok = self::accessControl();
-
-		if ($access_ok) {
+		if (self::$login_ok) {
 			self::giveAccess();
 			self::giveAccess();
+		} elseif (self::accessControl()) {
+			self::giveAccess();
+			FreshRSS_UserDAO::touch();
 		} else {
 		} else {
 			// Be sure all accesses are removed!
 			// Be sure all accesses are removed!
 			self::removeAccess();
 			self::removeAccess();
@@ -38,12 +40,10 @@ class FreshRSS_Auth {
 	 *
 	 *
 	 * @return boolean true if user can be connected, false else.
 	 * @return boolean true if user can be connected, false else.
 	 */
 	 */
-	public static function accessControl() {
-		if (self::$login_ok) {
-			return true;
-		}
-
-		switch (Minz_Configuration::authType()) {
+	private static function accessControl() {
+		$conf = Minz_Configuration::get('system');
+		$auth_type = $conf->auth_type;
+		switch ($auth_type) {
 		case 'form':
 		case 'form':
 			$credentials = FreshRSS_FormAuth::getCredentialsFromCookie();
 			$credentials = FreshRSS_FormAuth::getCredentialsFromCookie();
 			$current_user = '';
 			$current_user = '';
@@ -60,16 +60,6 @@ class FreshRSS_Auth {
 				Minz_Session::_param('currentUser', $current_user);
 				Minz_Session::_param('currentUser', $current_user);
 			}
 			}
 			return $login_ok;
 			return $login_ok;
-		case 'persona':
-			$email = filter_var(Minz_Session::param('mail'), FILTER_VALIDATE_EMAIL);
-			$persona_file = DATA_PATH . '/persona/' . $email . '.txt';
-			if (($current_user = @file_get_contents($persona_file)) !== false) {
-				$current_user = trim($current_user);
-				Minz_Session::_param('currentUser', $current_user);
-				Minz_Session::_param('mail', $email);
-				return true;
-			}
-			return false;
 		case 'none':
 		case 'none':
 			return true;
 			return true;
 		default:
 		default:
@@ -83,22 +73,16 @@ class FreshRSS_Auth {
 	 */
 	 */
 	public static function giveAccess() {
 	public static function giveAccess() {
 		$current_user = Minz_Session::param('currentUser');
 		$current_user = Minz_Session::param('currentUser');
-		try {
-			$conf = new FreshRSS_Configuration($current_user);
-		} catch(Minz_Exception $e) {
-			die($e->getMessage());
-		}
+		$user_conf = get_user_configuration($current_user);
+		$system_conf = Minz_Configuration::get('system');
 
 
-		switch (Minz_Configuration::authType()) {
+		switch ($system_conf->auth_type) {
 		case 'form':
 		case 'form':
-			self::$login_ok = Minz_Session::param('passwordHash') === $conf->passwordHash;
+			self::$login_ok = Minz_Session::param('passwordHash') === $user_conf->passwordHash;
 			break;
 			break;
 		case 'http_auth':
 		case 'http_auth':
 			self::$login_ok = strcasecmp($current_user, httpAuthUser()) === 0;
 			self::$login_ok = strcasecmp($current_user, httpAuthUser()) === 0;
 			break;
 			break;
-		case 'persona':
-			self::$login_ok = strcasecmp(Minz_Session::param('mail'), $conf->mail_login) === 0;
-			break;
 		case 'none':
 		case 'none':
 			self::$login_ok = true;
 			self::$login_ok = true;
 			break;
 			break;
@@ -117,12 +101,14 @@ class FreshRSS_Auth {
 	 * @return boolean true if user has corresponding access, false else.
 	 * @return boolean true if user has corresponding access, false else.
 	 */
 	 */
 	public static function hasAccess($scope = 'general') {
 	public static function hasAccess($scope = 'general') {
+		$conf = Minz_Configuration::get('system');
+		$default_user = $conf->default_user;
 		$ok = self::$login_ok;
 		$ok = self::$login_ok;
 		switch ($scope) {
 		switch ($scope) {
 		case 'general':
 		case 'general':
 			break;
 			break;
 		case 'admin':
 		case 'admin':
-			$ok &= Minz_Session::param('currentUser') === Minz_Configuration::defaultUser();
+			$ok &= Minz_Session::param('currentUser') === $default_user;
 			break;
 			break;
 		default:
 		default:
 			$ok = false;
 			$ok = false;
@@ -136,16 +122,15 @@ class FreshRSS_Auth {
 	public static function removeAccess() {
 	public static function removeAccess() {
 		Minz_Session::_param('loginOk');
 		Minz_Session::_param('loginOk');
 		self::$login_ok = false;
 		self::$login_ok = false;
-		Minz_Session::_param('currentUser', Minz_Configuration::defaultUser());
+		$conf = Minz_Configuration::get('system');
+		Minz_Session::_param('currentUser', $conf->default_user);
+		Minz_Session::_param('csrf');
 
 
-		switch (Minz_Configuration::authType()) {
+		switch ($conf->auth_type) {
 		case 'form':
 		case 'form':
 			Minz_Session::_param('passwordHash');
 			Minz_Session::_param('passwordHash');
 			FreshRSS_FormAuth::deleteCookie();
 			FreshRSS_FormAuth::deleteCookie();
 			break;
 			break;
-		case 'persona':
-			Minz_Session::_param('mail');
-			break;
 		case 'http_auth':
 		case 'http_auth':
 		case 'none':
 		case 'none':
 			// Nothing to do...
 			// Nothing to do...
@@ -154,6 +139,44 @@ class FreshRSS_Auth {
 			// TODO: extensions
 			// TODO: extensions
 		}
 		}
 	}
 	}
+
+	/**
+	 * Return if authentication is enabled on this instance of FRSS.
+	 */
+	public static function accessNeedsLogin() {
+		$conf = Minz_Configuration::get('system');
+		$auth_type = $conf->auth_type;
+		return $auth_type !== 'none';
+	}
+
+	/**
+	 * Return if authentication requires a PHP action.
+	 */
+	public static function accessNeedsAction() {
+		$conf = Minz_Configuration::get('system');
+		$auth_type = $conf->auth_type;
+		return $auth_type === 'form';
+	}
+
+	public static function csrfToken() {
+		$csrf = Minz_Session::param('csrf');
+		if ($csrf == '') {
+			$salt = FreshRSS_Context::$system_conf->salt;
+			$csrf = sha1($salt . uniqid(mt_rand(), true));
+			Minz_Session::_param('csrf', $csrf);
+		}
+		return $csrf;
+	}
+	public static function isCsrfOk($token = null) {
+		$csrf = Minz_Session::param('csrf');
+		if ($csrf == '') {
+			return true;	//Not logged in yet
+		}
+		if ($token === null) {
+			$token = Minz_Request::fetchPOST('_csrf');
+		}
+		return $token === $csrf;
+	}
 }
 }
 
 
 
 
@@ -196,8 +219,9 @@ class FreshRSS_FormAuth {
 	}
 	}
 
 
 	public static function makeCookie($username, $password_hash) {
 	public static function makeCookie($username, $password_hash) {
+		$conf = Minz_Configuration::get('system');
 		do {
 		do {
-			$token = sha1(Minz_Configuration::salt() . $username . uniqid(mt_rand(), true));
+			$token = sha1($conf->salt . $username . uniqid(mt_rand(), true));
 			$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
 			$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
 		} while (file_exists($token_file));
 		} while (file_exists($token_file));
 
 
@@ -205,15 +229,17 @@ class FreshRSS_FormAuth {
 			return false;
 			return false;
 		}
 		}
 
 
-		$expire = time() + 2629744;	//1 month	//TODO: Use a configuration instead
+		$limits = $conf->limits;
+		$cookie_duration = empty($limits['cookie_duration']) ? 2629744 : $limits['cookie_duration'];
+		$expire = time() + $cookie_duration;
 		Minz_Session::setLongTermCookie('FreshRSS_login', $token, $expire);
 		Minz_Session::setLongTermCookie('FreshRSS_login', $token, $expire);
 		return $token;
 		return $token;
 	}
 	}
 
 
 	public static function deleteCookie() {
 	public static function deleteCookie() {
 		$token = Minz_Session::getLongTermCookie('FreshRSS_login');
 		$token = Minz_Session::getLongTermCookie('FreshRSS_login');
-		Minz_Session::deleteLongTermCookie('FreshRSS_login');
 		if (ctype_alnum($token)) {
 		if (ctype_alnum($token)) {
+			Minz_Session::deleteLongTermCookie('FreshRSS_login');
 			@unlink(DATA_PATH . '/tokens/' . $token . '.txt');
 			@unlink(DATA_PATH . '/tokens/' . $token . '.txt');
 		}
 		}
 
 
@@ -223,7 +249,10 @@ class FreshRSS_FormAuth {
 	}
 	}
 
 
 	public static function purgeTokens() {
 	public static function purgeTokens() {
-		$oldest = time() - 2629744;	// 1 month	// TODO: Use a configuration instead
+		$conf = Minz_Configuration::get('system');
+		$limits = $conf->limits;
+		$cookie_duration = empty($limits['cookie_duration']) ? 2629744 : $limits['cookie_duration'];
+		$oldest = time() - $cookie_duration;
 		foreach (new DirectoryIterator(DATA_PATH . '/tokens/') as $file_info) {
 		foreach (new DirectoryIterator(DATA_PATH . '/tokens/') as $file_info) {
 			// $extension = $file_info->getExtension(); doesn't work in PHP < 5.3.7
 			// $extension = $file_info->getExtension(); doesn't work in PHP < 5.3.7
 			$extension = pathinfo($file_info->getFilename(), PATHINFO_EXTENSION);
 			$extension = pathinfo($file_info->getFilename(), PATHINFO_EXTENSION);

+ 7 - 0
app/Models/Category.php

@@ -6,6 +6,7 @@ class FreshRSS_Category extends Minz_Model {
 	private $nbFeed = -1;
 	private $nbFeed = -1;
 	private $nbNotRead = -1;
 	private $nbNotRead = -1;
 	private $feeds = null;
 	private $feeds = null;
+	private $hasFeedsWithError = false;
 
 
 	public function __construct($name = '', $feeds = null) {
 	public function __construct($name = '', $feeds = null) {
 		$this->_name($name);
 		$this->_name($name);
@@ -16,6 +17,7 @@ class FreshRSS_Category extends Minz_Model {
 			foreach ($feeds as $feed) {
 			foreach ($feeds as $feed) {
 				$this->nbFeed++;
 				$this->nbFeed++;
 				$this->nbNotRead += $feed->nbNotRead();
 				$this->nbNotRead += $feed->nbNotRead();
+				$this->hasFeedsWithError |= $feed->inError();
 			}
 			}
 		}
 		}
 	}
 	}
@@ -51,12 +53,17 @@ class FreshRSS_Category extends Minz_Model {
 			foreach ($this->feeds as $feed) {
 			foreach ($this->feeds as $feed) {
 				$this->nbFeed++;
 				$this->nbFeed++;
 				$this->nbNotRead += $feed->nbNotRead();
 				$this->nbNotRead += $feed->nbNotRead();
+				$this->hasFeedsWithError |= $feed->inError();
 			}
 			}
 		}
 		}
 
 
 		return $this->feeds;
 		return $this->feeds;
 	}
 	}
 
 
+	public function hasFeedsWithError() {
+		return $this->hasFeedsWithError;
+	}
+
 	public function _id($value) {
 	public function _id($value) {
 		$this->id = $value;
 		$this->id = $value;
 	}
 	}

+ 18 - 11
app/Models/CategoryDAO.php

@@ -1,6 +1,9 @@
 <?php
 <?php
 
 
-class FreshRSS_CategoryDAO extends Minz_ModelPdo {
+class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
+
+	const defaultCategoryId = 1;
+
 	public function addCategory($valuesTmp) {
 	public function addCategory($valuesTmp) {
 		$sql = 'INSERT INTO `' . $this->prefix . 'category`(name) VALUES(?)';
 		$sql = 'INSERT INTO `' . $this->prefix . 'category`(name) VALUES(?)';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
@@ -10,10 +13,10 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 		);
 		);
 
 
 		if ($stm && $stm->execute($values)) {
 		if ($stm && $stm->execute($values)) {
-			return $this->bd->lastInsertId();
+			return $this->bd->lastInsertId('"' . $this->prefix . 'category_id_seq"');
 		} else {
 		} else {
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
-			Minz_Log::error('SQL error addCategory: ' . $info[2]	);
+			Minz_Log::error('SQL error addCategory: ' . $info[2]);
 			return false;
 			return false;
 		}
 		}
 	}
 	}
@@ -50,6 +53,9 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	}
 	}
 
 
 	public function deleteCategory($id) {
 	public function deleteCategory($id) {
+		if ($id <= self::defaultCategoryId) {
+			return false;
+		}
 		$sql = 'DELETE FROM `' . $this->prefix . 'category` WHERE id=?';
 		$sql = 'DELETE FROM `' . $this->prefix . 'category` WHERE id=?';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
@@ -100,10 +106,10 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	public function listCategories($prePopulateFeeds = true, $details = false) {
 	public function listCategories($prePopulateFeeds = true, $details = false) {
 		if ($prePopulateFeeds) {
 		if ($prePopulateFeeds) {
 			$sql = 'SELECT c.id AS c_id, c.name AS c_name, '
 			$sql = 'SELECT c.id AS c_id, c.name AS c_name, '
-			     . ($details ? 'f.* ' : 'f.id, f.name, f.url, f.website, f.priority, f.error, f.cache_nbEntries, f.cache_nbUnreads ')
+			     . ($details ? 'f.* ' : 'f.id, f.name, f.url, f.website, f.priority, f.error, f.`cache_nbEntries`, f.`cache_nbUnreads` ')
 			     . 'FROM `' . $this->prefix . 'category` c '
 			     . 'FROM `' . $this->prefix . 'category` c '
 			     . 'LEFT OUTER JOIN `' . $this->prefix . 'feed` f ON f.category=c.id '
 			     . 'LEFT OUTER JOIN `' . $this->prefix . 'feed` f ON f.category=c.id '
-			     . 'GROUP BY f.id '
+			     . 'GROUP BY f.id, c_id '
 			     . 'ORDER BY c.name, f.name';
 			     . 'ORDER BY c.name, f.name';
 			$stm = $this->bd->prepare($sql);
 			$stm = $this->bd->prepare($sql);
 			$stm->execute();
 			$stm->execute();
@@ -117,7 +123,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	}
 	}
 
 
 	public function getDefault() {
 	public function getDefault() {
-		$sql = 'SELECT * FROM `' . $this->prefix . 'category` WHERE id=1';
+		$sql = 'SELECT * FROM `' . $this->prefix . 'category` WHERE id=' . self::defaultCategoryId;
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
 		$stm->execute();
 		$stm->execute();
@@ -131,11 +137,11 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 		}
 		}
 	}
 	}
 	public function checkDefault() {
 	public function checkDefault() {
-		$def_cat = $this->searchById(1);
+		$def_cat = $this->searchById(self::defaultCategoryId);
 
 
 		if ($def_cat == null) {
 		if ($def_cat == null) {
-			$cat = new FreshRSS_Category(_t('default_category'));
-			$cat->_id(1);
+			$cat = new FreshRSS_Category(_t('gen.short.default_category'));
+			$cat->_id(self::defaultCategoryId);
 
 
 			$values = array(
 			$values = array(
 				'id' => $cat->id(),
 				'id' => $cat->id(),
@@ -207,12 +213,13 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 
 
 		$previousLine = null;
 		$previousLine = null;
 		$feedsDao = array();
 		$feedsDao = array();
+		$feedDao = FreshRSS_Factory::createFeedDAO();
 		foreach ($listDAO as $line) {
 		foreach ($listDAO as $line) {
 			if ($previousLine['c_id'] != null && $line['c_id'] !== $previousLine['c_id']) {
 			if ($previousLine['c_id'] != null && $line['c_id'] !== $previousLine['c_id']) {
 				// End of the current category, we add it to the $list
 				// End of the current category, we add it to the $list
 				$cat = new FreshRSS_Category(
 				$cat = new FreshRSS_Category(
 					$previousLine['c_name'],
 					$previousLine['c_name'],
-					FreshRSS_FeedDAO::daoToFeed($feedsDao, $previousLine['c_id'])
+					$feedDao->daoToFeed($feedsDao, $previousLine['c_id'])
 				);
 				);
 				$cat->_id($previousLine['c_id']);
 				$cat->_id($previousLine['c_id']);
 				$list[$previousLine['c_id']] = $cat;
 				$list[$previousLine['c_id']] = $cat;
@@ -228,7 +235,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 		if ($previousLine != null) {
 		if ($previousLine != null) {
 			$cat = new FreshRSS_Category(
 			$cat = new FreshRSS_Category(
 				$previousLine['c_name'],
 				$previousLine['c_name'],
-				FreshRSS_FeedDAO::daoToFeed($feedsDao, $previousLine['c_id'])
+				$feedDao->daoToFeed($feedsDao, $previousLine['c_id'])
 			);
 			);
 			$cat->_id($previousLine['c_id']);
 			$cat->_id($previousLine['c_id']);
 			$list[$previousLine['c_id']] = $cat;
 			$list[$previousLine['c_id']] = $cat;

+ 0 - 336
app/Models/Configuration.php

@@ -1,336 +0,0 @@
-<?php
-
-class FreshRSS_Configuration {
-	private $filename;
-
-	private $data = array(
-		'language' => 'en',
-		'old_entries' => 3,
-		'keep_history_default' => 0,
-		'ttl_default' => 3600,
-		'mail_login' => '',
-		'token' => '',
-		'passwordHash' => '',	//CRYPT_BLOWFISH
-		'apiPasswordHash' => '',	//CRYPT_BLOWFISH
-		'posts_per_page' => 20,
-		'view_mode' => 'normal',
-		'default_view' => FreshRSS_Entry::STATE_NOT_READ,
-		'auto_load_more' => true,
-		'display_posts' => false,
-		'display_categories' => false,
-		'hide_read_feeds' => true,
-		'onread_jump_next' => true,
-		'lazyload' => true,
-		'sticky_post' => true,
-		'reading_confirm' => false,
-		'sort_order' => 'DESC',
-		'anon_access' => false,
-		'mark_when' => array(
-			'article' => true,
-			'site' => true,
-			'scroll' => false,
-			'reception' => false,
-		),
-		'theme' => 'Origine',
-		'content_width' => 'thin',
-		'shortcuts' => array(
-			'mark_read' => 'r',
-			'mark_favorite' => 'f',
-			'go_website' => 'space',
-			'next_entry' => 'j',
-			'prev_entry' => 'k',
-			'first_entry' => 'home',
-			'last_entry' => 'end',
-			'collapse_entry' => 'c',
-			'load_more' => 'm',
-			'auto_share' => 's',
-			'focus_search' => 'a',
-			'user_filter' => 'u',
-			'help' => 'f1',
-			'close_dropdown' => 'escape',
-		),
-		'topline_read' => true,
-		'topline_favorite' => true,
-		'topline_date' => true,
-		'topline_link' => true,
-		'bottomline_read' => true,
-		'bottomline_favorite' => true,
-		'bottomline_sharing' => true,
-		'bottomline_tags' => true,
-		'bottomline_date' => true,
-		'bottomline_link' => true,
-		'sharing' => array(),
-		'queries' => array(),
-		'html5_notif_timeout' => 0,
-	);
-
-	private $available_languages = array(
-		'en' => 'English',
-		'fr' => 'Français',
-	);
-
-	private $shares;
-
-	public function __construct($user) {
-		$this->filename = DATA_PATH . DIRECTORY_SEPARATOR . $user . '_user.php';
-
-		$data = @include($this->filename);
-		if (!is_array($data)) {
-			throw new Minz_PermissionDeniedException($this->filename);
-		}
-
-		foreach ($data as $key => $value) {
-			if (isset($this->data[$key])) {
-				$function = '_' . $key;
-				$this->$function($value);
-			}
-		}
-		$this->data['user'] = $user;
-
-		$this->shares = DATA_PATH . DIRECTORY_SEPARATOR . 'shares.php';
-
-		$shares = @include($this->shares);
-		if (!is_array($shares)) {
-			throw new Minz_PermissionDeniedException($this->shares);
-		}
-
-		$this->data['shares'] = $shares;
-	}
-
-	public function save() {
-		@rename($this->filename, $this->filename . '.bak.php');
-		unset($this->data['shares']); // Remove shares because it is not intended to be stored in user configuration
-		if (file_put_contents($this->filename, "<?php\n return " . var_export($this->data, true) . ';', LOCK_EX) === false) {
-			throw new Minz_PermissionDeniedException($this->filename);
-		}
-		if (function_exists('opcache_invalidate')) {
-			opcache_invalidate($this->filename);	//Clear PHP 5.5+ cache for include
-		}
-		invalidateHttpCache();
-		return true;
-	}
-
-	public function __get($name) {
-		if (array_key_exists($name, $this->data)) {
-			return $this->data[$name];
-		} else {
-			$trace = debug_backtrace();
-			trigger_error('Undefined FreshRSS_Configuration->' . $name . 'in ' . $trace[0]['file'] . ' line ' . $trace[0]['line'], E_USER_NOTICE);	//TODO: Use Minz exceptions
-			return null;
-		}
-	}
-
-	public function availableLanguages() {
-		return $this->available_languages;
-	}
-
-	public function remove_query_by_get($get) {
-		$final_queries = array();
-		foreach ($this->queries as $key => $query) {
-			if (empty($query['get']) || $query['get'] !== $get) {
-				$final_queries[$key] = $query;
-			}
-		}
-		$this->_queries($final_queries);
-	}
-
-	public function _language($value) {
-		if (!isset($this->available_languages[$value])) {
-			$value = 'en';
-		}
-		$this->data['language'] = $value;
-	}
-	public function _posts_per_page($value) {
-		$value = intval($value);
-		$this->data['posts_per_page'] = $value > 0 ? $value : 10;
-	}
-	public function _view_mode($value) {
-		if ($value === 'global' || $value === 'reader') {
-			$this->data['view_mode'] = $value;
-		} else {
-			$this->data['view_mode'] = 'normal';
-		}
-	}
-	public function _default_view($value) {
-		switch ($value) {
-		case FreshRSS_Entry::STATE_ALL:
-			// left blank on purpose
-		case FreshRSS_Entry::STATE_NOT_READ:
-			// left blank on purpose
-		case FreshRSS_Entry::STATE_STRICT + FreshRSS_Entry::STATE_NOT_READ:
-			$this->data['default_view'] = $value;
-			break;
-		default:
-			$this->data['default_view'] = FreshRSS_Entry::STATE_ALL;
-			break;
-		}
-	}
-	public function _display_posts($value) {
-		$this->data['display_posts'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _display_categories($value) {
-		$this->data['display_categories'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _hide_read_feeds($value) {
-		$this->data['hide_read_feeds'] = (bool)$value;
-	}
-	public function _onread_jump_next($value) {
-		$this->data['onread_jump_next'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _lazyload($value) {
-		$this->data['lazyload'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _sticky_post($value) {
-		$this->data['sticky_post'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _reading_confirm($value) {
-		$this->data['reading_confirm'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _sort_order($value) {
-		$this->data['sort_order'] = $value === 'ASC' ? 'ASC' : 'DESC';
-	}
-	public function _old_entries($value) {
-		$value = intval($value);
-		$this->data['old_entries'] = $value > 0 ? $value : 3;
-	}
-	public function _keep_history_default($value) {
-		$value = intval($value);
-		$this->data['keep_history_default'] = $value >= -1 ? $value : 0;
-	}
-	public function _ttl_default($value) {
-		$value = intval($value);
-		$this->data['ttl_default'] = $value >= -1 ? $value : 3600;
-	}
-	public function _shortcuts($values) {
-		foreach ($values as $key => $value) {
-			if (isset($this->data['shortcuts'][$key])) {
-				$this->data['shortcuts'][$key] = $value;
-			}
-		}
-	}
-	public function _passwordHash($value) {
-		$this->data['passwordHash'] = ctype_graph($value) && (strlen($value) >= 60) ? $value : '';
-	}
-	public function _apiPasswordHash($value) {
-		$this->data['apiPasswordHash'] = ctype_graph($value) && (strlen($value) >= 60) ? $value : '';
-	}
-	public function _mail_login($value) {
-		$value = filter_var($value, FILTER_VALIDATE_EMAIL);
-		if ($value) {
-			$this->data['mail_login'] = $value;
-		} else {
-			$this->data['mail_login'] = '';
-		}
-	}
-	public function _anon_access($value) {
-		$this->data['anon_access'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _mark_when($values) {
-		foreach ($values as $key => $value) {
-			if (isset($this->data['mark_when'][$key])) {
-				$this->data['mark_when'][$key] = ((bool)$value) && $value !== 'no';
-			}
-		}
-	}
-	public function _sharing($values) {
-		$this->data['sharing'] = array();
-		$unique = array();
-		foreach ($values as $value) {
-			if (!is_array($value)) {
-				continue;
-			}
-
-			// Verify URL and add default value when needed
-			if (isset($value['url'])) {
-				$is_url = (
-					filter_var($value['url'], FILTER_VALIDATE_URL) ||
-					(version_compare(PHP_VERSION, '5.3.3', '<') &&
-						(strpos($value, '-') > 0) &&
-						($value === filter_var($value, FILTER_SANITIZE_URL)))
-				);  //PHP bug #51192
-				if (!$is_url) {
-					continue;
-				}
-			} else {
-				$value['url'] = null;
-			}
-
-			// Add a default name
-			if (empty($value['name'])) {
-				$value['name'] = $value['type'];
-			}
-
-			$json_value = json_encode($value);
-			if (!in_array($json_value, $unique)) {
-				$unique[] = $json_value;
-				$this->data['sharing'][] = $value;
-			}
-		}
-	}
-	public function _queries($values) {
-		$this->data['queries'] = array();
-		foreach ($values as $value) {
-			$value = array_filter($value);
-			$params = $value;
-			unset($params['name']);
-			unset($params['url']);
-			$value['url'] = Minz_Url::display(array('params' => $params));
-
-			$this->data['queries'][] = $value;
-		}
-	}
-	public function _theme($value) {
-		$this->data['theme'] = $value;
-	}
-	public function _content_width($value) {
-		if ($value === 'medium' ||
-				$value === 'large' ||
-				$value === 'no_limit') {
-			$this->data['content_width'] = $value;
-		} else {
-			$this->data['content_width'] = 'thin';
-		}
-	}
-	
-	public function _html5_notif_timeout($value) {
-		$value = intval($value);
-		$this->data['html5_notif_timeout'] = $value >= 0 ? $value : 0;
-	}
-	
-	public function _token($value) {
-		$this->data['token'] = $value;
-	}
-	public function _auto_load_more($value) {
-		$this->data['auto_load_more'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _topline_read($value) {
-		$this->data['topline_read'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _topline_favorite($value) {
-		$this->data['topline_favorite'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _topline_date($value) {
-		$this->data['topline_date'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _topline_link($value) {
-		$this->data['topline_link'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _bottomline_read($value) {
-		$this->data['bottomline_read'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _bottomline_favorite($value) {
-		$this->data['bottomline_favorite'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _bottomline_sharing($value) {
-		$this->data['bottomline_sharing'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _bottomline_tags($value) {
-		$this->data['bottomline_tags'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _bottomline_date($value) {
-		$this->data['bottomline_date'] = ((bool)$value) && $value !== 'no';
-	}
-	public function _bottomline_link($value) {
-		$this->data['bottomline_link'] = ((bool)$value) && $value !== 'no';
-	}
-}

+ 380 - 0
app/Models/ConfigurationSetter.php

@@ -0,0 +1,380 @@
+<?php
+
+class FreshRSS_ConfigurationSetter {
+	/**
+	 * Return if the given key is supported by this setter.
+	 * @param $key the key to test.
+	 * @return true if the key is supported, false else.
+	 */
+	public function support($key) {
+		$name_setter = '_' . $key;
+		return is_callable(array($this, $name_setter));
+	}
+
+	/**
+	 * Set the given key in data with the current value.
+	 * @param $data an array containing the list of all configuration data.
+	 * @param $key the key to update.
+	 * @param $value the value to set.
+	 */
+	public function handle(&$data, $key, $value) {
+		$name_setter = '_' . $key;
+		call_user_func_array(array($this, $name_setter), array(&$data, $value));
+	}
+
+	/**
+	 * A helper to set boolean values.
+	 *
+	 * @param $value the tested value.
+	 * @return true if value is true and different from no, false else.
+	 */
+	private function handleBool($value) {
+		return ((bool)$value) && $value !== 'no';
+	}
+
+	/**
+	 * The (long) list of setters for user configuration.
+	 */
+	private function _apiPasswordHash(&$data, $value) {
+		$data['apiPasswordHash'] = ctype_graph($value) && (strlen($value) >= 60) ? $value : '';
+	}
+
+	private function _content_width(&$data, $value) {
+		$value = strtolower($value);
+		if (!in_array($value, array('thin', 'medium', 'large', 'no_limit'))) {
+			$value = 'thin';
+		}
+
+		$data['content_width'] = $value;
+	}
+
+	private function _default_state(&$data, $value) {
+		$data['default_state'] = (int)$value;
+	}
+
+	private function _default_view(&$data, $value) {
+		switch ($value) {
+		case 'all':
+			$data['default_view'] = $value;
+			$data['default_state'] = (FreshRSS_Entry::STATE_READ +
+			                          FreshRSS_Entry::STATE_NOT_READ);
+			break;
+		case 'adaptive':
+		case 'unread':
+		default:
+			$data['default_view'] = $value;
+			$data['default_state'] = FreshRSS_Entry::STATE_NOT_READ;
+		}
+	}
+
+	// It works for system config too!
+	private function _extensions_enabled(&$data, $value) {
+		if (!is_array($value)) {
+			$value = array($value);
+		}
+		$data['extensions_enabled'] = $value;
+	}
+
+	private function _html5_notif_timeout(&$data, $value) {
+		$value = intval($value);
+		$data['html5_notif_timeout'] = $value >= 0 ? $value : 0;
+	}
+
+	private function _keep_history_default(&$data, $value) {
+		$value = intval($value);
+		$data['keep_history_default'] = $value >= -1 ? $value : 0;
+	}
+
+	// It works for system config too!
+	private function _language(&$data, $value) {
+		$value = strtolower($value);
+		$languages = Minz_Translate::availableLanguages();
+		if (!in_array($value, $languages)) {
+			$value = 'en';
+		}
+		$data['language'] = $value;
+	}
+
+	private function _old_entries(&$data, $value) {
+		$value = intval($value);
+		$data['old_entries'] = $value > 0 ? $value : 3;
+	}
+
+	private function _passwordHash(&$data, $value) {
+		$data['passwordHash'] = ctype_graph($value) && (strlen($value) >= 60) ? $value : '';
+	}
+
+	private function _posts_per_page(&$data, $value) {
+		$value = intval($value);
+		$data['posts_per_page'] = $value > 0 ? $value : 10;
+	}
+
+	private function _queries(&$data, $values) {
+		$data['queries'] = array();
+		foreach ($values as $value) {
+			if ($value instanceof FreshRSS_UserQuery) {
+				$data['queries'][] = $value->toArray();
+			} elseif (is_array($value)) {
+				$data['queries'][] = $value;
+			}
+		}
+	}
+
+	private function _sharing(&$data, $values) {
+		$data['sharing'] = array();
+		foreach ($values as $value) {
+			if (!is_array($value)) {
+				continue;
+			}
+
+			// Verify URL and add default value when needed
+			if (isset($value['url'])) {
+				$is_url = filter_var($value['url'], FILTER_VALIDATE_URL);
+				if (!$is_url) {
+					continue;
+				}
+			} else {
+				$value['url'] = null;
+			}
+
+			$data['sharing'][] = $value;
+		}
+	}
+
+	private function _shortcuts(&$data, $values) {
+		if (!is_array($values)) {
+			return;
+		}
+
+		$data['shortcuts'] = $values;
+	}
+
+	private function _sort_order(&$data, $value) {
+		$data['sort_order'] = $value === 'ASC' ? 'ASC' : 'DESC';
+	}
+
+	private function _ttl_default(&$data, $value) {
+		$value = intval($value);
+		$data['ttl_default'] = $value >= -1 ? $value : 3600;
+	}
+
+	private function _view_mode(&$data, $value) {
+		$value = strtolower($value);
+		if (!in_array($value, array('global', 'normal', 'reader'))) {
+			$value = 'normal';
+		}
+		$data['view_mode'] =  $value;
+	}
+
+	/**
+	 * A list of boolean setters.
+	 */
+	private function _anon_access(&$data, $value) {
+		$data['anon_access'] = $this->handleBool($value);
+	}
+
+	private function _auto_load_more(&$data, $value) {
+		$data['auto_load_more'] = $this->handleBool($value);
+	}
+
+	private function _auto_remove_article(&$data, $value) {
+		$data['auto_remove_article'] = $this->handleBool($value);
+	}
+
+	private function _mark_updated_article_unread(&$data, $value) {
+		$data['mark_updated_article_unread'] = $this->handleBool($value);
+	}
+
+	private function _display_categories(&$data, $value) {
+		$data['display_categories'] = $this->handleBool($value);
+	}
+
+	private function _display_posts(&$data, $value) {
+		$data['display_posts'] = $this->handleBool($value);
+	}
+
+	private function _hide_read_feeds(&$data, $value) {
+		$data['hide_read_feeds'] = $this->handleBool($value);
+	}
+
+	private function _lazyload(&$data, $value) {
+		$data['lazyload'] = $this->handleBool($value);
+	}
+
+	private function _mark_when(&$data, $values) {
+		foreach ($values as $key => $value) {
+			$data['mark_when'][$key] = $this->handleBool($value);
+		}
+	}
+
+	private function _onread_jump_next(&$data, $value) {
+		$data['onread_jump_next'] = $this->handleBool($value);
+	}
+
+	private function _reading_confirm(&$data, $value) {
+		$data['reading_confirm'] = $this->handleBool($value);
+	}
+
+	private function _sticky_post(&$data, $value) {
+		$data['sticky_post'] = $this->handleBool($value);
+	}
+
+	private function _bottomline_date(&$data, $value) {
+		$data['bottomline_date'] = $this->handleBool($value);
+	}
+	private function _bottomline_favorite(&$data, $value) {
+		$data['bottomline_favorite'] = $this->handleBool($value);
+	}
+	private function _bottomline_link(&$data, $value) {
+		$data['bottomline_link'] = $this->handleBool($value);
+	}
+	private function _bottomline_read(&$data, $value) {
+		$data['bottomline_read'] = $this->handleBool($value);
+	}
+	private function _bottomline_sharing(&$data, $value) {
+		$data['bottomline_sharing'] = $this->handleBool($value);
+	}
+	private function _bottomline_tags(&$data, $value) {
+		$data['bottomline_tags'] = $this->handleBool($value);
+	}
+
+	private function _topline_date(&$data, $value) {
+		$data['topline_date'] = $this->handleBool($value);
+	}
+	private function _topline_favorite(&$data, $value) {
+		$data['topline_favorite'] = $this->handleBool($value);
+	}
+	private function _topline_link(&$data, $value) {
+		$data['topline_link'] = $this->handleBool($value);
+	}
+	private function _topline_read(&$data, $value) {
+		$data['topline_read'] = $this->handleBool($value);
+	}
+
+	/**
+	 * The (not so long) list of setters for system configuration.
+	 */
+	private function _allow_anonymous(&$data, $value) {
+		$data['allow_anonymous'] = $this->handleBool($value) && FreshRSS_Auth::accessNeedsAction();
+	}
+
+	private function _allow_anonymous_refresh(&$data, $value) {
+		$data['allow_anonymous_refresh'] = $this->handleBool($value) && $data['allow_anonymous'];
+	}
+
+	private function _api_enabled(&$data, $value) {
+		$data['api_enabled'] = $this->handleBool($value);
+	}
+
+	private function _auth_type(&$data, $value) {
+		$value = strtolower($value);
+		if (!in_array($value, array('form', 'http_auth', 'none'))) {
+			$value = 'none';
+		}
+		$data['auth_type'] = $value;
+		$this->_allow_anonymous($data, $data['allow_anonymous']);
+	}
+
+	private function _db(&$data, $value) {
+		if (!isset($value['type'])) {
+			return;
+		}
+
+		switch ($value['type']) {
+		case 'mysql':
+		case 'pgsql':
+			if (empty($value['host']) ||
+					empty($value['user']) ||
+					empty($value['base']) ||
+					!isset($value['password'])) {
+				return;
+			}
+
+			$data['db']['type'] = $value['type'];
+			$data['db']['host'] = $value['host'];
+			$data['db']['user'] = $value['user'];
+			$data['db']['base'] = $value['base'];
+			$data['db']['password'] = $value['password'];
+			$data['db']['prefix'] = isset($value['prefix']) ? $value['prefix'] : '';
+			break;
+		case 'sqlite':
+			$data['db']['type'] = $value['type'];
+			$data['db']['host'] = '';
+			$data['db']['user'] = '';
+			$data['db']['base'] = '';
+			$data['db']['password'] = '';
+			$data['db']['prefix'] = '';
+			break;
+		default:
+			return;
+		}
+	}
+
+	private function _default_user(&$data, $value) {
+		$user_list = listUsers();
+		if (in_array($value, $user_list)) {
+			$data['default_user'] = $value;
+		}
+	}
+
+	private function _environment(&$data, $value) {
+		$value = strtolower($value);
+		if (!in_array($value, array('silent', 'development', 'production'))) {
+			$value = 'production';
+		}
+		$data['environment'] =  $value;
+	}
+
+	private function _limits(&$data, $values) {
+		$max_small_int = 16384;
+		$limits_keys = array(
+			'cache_duration' => array(
+				'min' => 0,
+			),
+			'timeout' => array(
+				'min' => 0,
+			),
+			'max_inactivity' => array(
+				'min' => 0,
+			),
+			'max_feeds' => array(
+				'min' => 0,
+				'max' => $max_small_int,
+			),
+			'max_categories' => array(
+				'min' => 0,
+				'max' => $max_small_int,
+			),
+			'max_registrations' => array(
+				'min' => 0,
+			),
+		);
+
+		foreach ($values as $key => $value) {
+			if (!isset($limits_keys[$key])) {
+				continue;
+			}
+
+			$value = intval($value);
+			$limits = $limits_keys[$key];
+			if (
+				(!isset($limits['min']) || $value >= $limits['min']) &&
+				(!isset($limits['max']) || $value <= $limits['max'])
+			) {
+				$data['limits'][$key] = $value;
+			}
+		}
+	}
+
+	private function _unsafe_autologin_enabled(&$data, $value) {
+		$data['unsafe_autologin_enabled'] = $this->handleBool($value);
+	}
+
+	private function _auto_update_url(&$data, $value) {
+		if (!$value) {
+			return;
+		}
+
+		$data['auto_update_url'] = $value;
+	}
+}

+ 308 - 14
app/Models/Context.php

@@ -5,28 +5,322 @@
  * useful functions associated to the current view state.
  * useful functions associated to the current view state.
  */
  */
 class FreshRSS_Context {
 class FreshRSS_Context {
-	public static $conf = null;
+	public static $user_conf = null;
+	public static $system_conf = null;
+	public static $categories = array();
+
+	public static $name = '';
+	public static $description = '';
+
+	public static $total_unread = 0;
+	public static $total_starred = array(
+		'all' => 0,
+		'read' => 0,
+		'unread' => 0,
+	);
+
+	public static $get_unread = 0;
+	public static $current_get = array(
+		'all' => false,
+		'starred' => false,
+		'feed' => false,
+		'category' => false,
+	);
+	public static $next_get = 'a';
+
 	public static $state = 0;
 	public static $state = 0;
+	public static $order = 'DESC';
+	public static $number = 0;
+	public static $search;
+	public static $first_id = '';
+	public static $next_id = '';
+	public static $id_max = '';
+	public static $sinceHours = 0;
 
 
+	public static $isCli = false;
+
+	/**
+	 * Initialize the context.
+	 *
+	 * Set the correct configurations and $categories variables.
+	 */
 	public static function init() {
 	public static function init() {
 		// Init configuration.
 		// Init configuration.
-		$current_user = Minz_Session::param('currentUser');
-		try {
-			self::$conf = new FreshRSS_Configuration($current_user);
-		} catch(Minz_Exception $e) {
-			Minz_Log::error('Cannot load configuration file of user `' . $current_user . '`');
-			die($e->getMessage());
+		self::$system_conf = Minz_Configuration::get('system');
+		self::$user_conf = Minz_Configuration::get('user');
+	}
+
+	/**
+	 * Returns if the current state includes $state parameter.
+	 */
+	public static function isStateEnabled($state) {
+		return self::$state & $state;
+	}
+
+	/**
+	 * Returns the current state with or without $state parameter.
+	 */
+	public static function getRevertState($state) {
+		if (self::$state & $state) {
+			return self::$state & ~$state;
+		} else {
+			return self::$state | $state;
 		}
 		}
+	}
 
 
-		// Init i18n.
-		Minz_Session::_param('language', self::$conf->language);
-		Minz_Translate::init();
+	/**
+	 * Return the current get as a string or an array.
+	 *
+	 * If $array is true, the first item of the returned value is 'f' or 'c' and
+	 * the second is the id.
+	 */
+	public static function currentGet($array = false) {
+		if (self::$current_get['all']) {
+			return 'a';
+		} elseif (self::$current_get['starred']) {
+			return 's';
+		} elseif (self::$current_get['feed']) {
+			if ($array) {
+				return array('f', self::$current_get['feed']);
+			} else {
+				return 'f_' . self::$current_get['feed'];
+			}
+		} elseif (self::$current_get['category']) {
+			if ($array) {
+				return array('c', self::$current_get['category']);
+			} else {
+				return 'c_' . self::$current_get['category'];
+			}
+		}
+	}
 
 
-		// Get the current state.
-		self::$state = self::$conf->default_view;
+	/**
+	 * Return true if the current request targets a feed (and not a category or all articles), false otherwise.
+	 */
+	public static function isFeed() {
+		return self::$current_get['feed'] != false;
 	}
 	}
 
 
-	public static function stateEnabled($state) {
-		return self::$state & $state;
+	/**
+	 * Return true if $get parameter correspond to the $current_get attribute.
+	 */
+	public static function isCurrentGet($get) {
+		$type = $get[0];
+		$id = substr($get, 2);
+
+		switch($type) {
+		case 'a':
+			return self::$current_get['all'];
+		case 's':
+			return self::$current_get['starred'];
+		case 'f':
+			return self::$current_get['feed'] == $id;
+		case 'c':
+			return self::$current_get['category'] == $id;
+		default:
+			return false;
+		}
 	}
 	}
+
+	/**
+	 * Set the current $get attribute.
+	 *
+	 * Valid $get parameter are:
+	 *   - a
+	 *   - s
+	 *   - f_<feed id>
+	 *   - c_<category id>
+	 *
+	 * $name and $get_unread attributes are also updated as $next_get
+	 * Raise an exception if id or $get is invalid.
+	 */
+	public static function _get($get) {
+		$type = $get[0];
+		$id = substr($get, 2);
+		$nb_unread = 0;
+
+		if (empty(self::$categories)) {
+			$catDAO = new FreshRSS_CategoryDAO();
+			self::$categories = $catDAO->listCategories();
+		}
+
+		switch($type) {
+		case 'a':
+			self::$current_get['all'] = true;
+			self::$name = _t('index.feed.title');
+			self::$description = self::$system_conf->meta_description;
+			self::$get_unread = self::$total_unread;
+			break;
+		case 's':
+			self::$current_get['starred'] = true;
+			self::$name = _t('index.feed.title_fav');
+			self::$description = self::$system_conf->meta_description;
+			self::$get_unread = self::$total_starred['unread'];
+
+			// Update state if favorite is not yet enabled.
+			self::$state = self::$state | FreshRSS_Entry::STATE_FAVORITE;
+			break;
+		case 'f':
+			// We try to find the corresponding feed. When allowing robots, always retrieve the full feed including description
+			$feed = FreshRSS_Context::$system_conf->allow_robots ? null : FreshRSS_CategoryDAO::findFeed(self::$categories, $id);
+			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();
+			self::$description = $feed->description();
+			self::$get_unread = $feed->nbNotRead();
+			break;
+		case 'c':
+			// We try to find the corresponding category.
+			self::$current_get['category'] = $id;
+			if (!isset(self::$categories[$id])) {
+				$catDAO = new FreshRSS_CategoryDAO();
+				$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;
+		default:
+			throw new FreshRSS_Context_Exception('Invalid getter: ' . $get);
+		}
+
+		self::_nextGet();
+	}
+
+	/**
+	 * Set the value of $next_get attribute.
+	 */
+	private static function _nextGet() {
+		$get = self::currentGet();
+		// By default, $next_get == $get
+		self::$next_get = $get;
+
+		if (empty(self::$categories)) {
+			$catDAO = new FreshRSS_CategoryDAO();
+			self::$categories = $catDAO->listCategories();
+		}
+
+		if (self::$user_conf->onread_jump_next && strlen($get) > 2) {
+			$another_unread_id = '';
+			$found_current_get = false;
+			switch ($get[0]) {
+			case 'f':
+				// We search the next feed with at least one unread article in
+				// same category as the currend feed.
+				foreach (self::$categories as $cat) {
+					if ($cat->id() != self::$current_get['category']) {
+						// We look into the category of the current feed!
+						continue;
+					}
+
+					foreach ($cat->feeds() as $feed) {
+						if ($feed->id() == self::$current_get['feed']) {
+							// Here is our current feed! Fine, the next one will
+							// be a potential candidate.
+							$found_current_get = true;
+							continue;
+						}
+
+						if ($feed->nbNotRead() > 0) {
+							$another_unread_id = $feed->id();
+							if ($found_current_get) {
+								// We have found our current feed and now we
+								// have an feed with unread articles. Leave the
+								// loop!
+								break;
+							}
+						}
+					}
+					break;
+				}
+
+				// If no feed have been found, next_get is the current category.
+				self::$next_get = empty($another_unread_id) ?
+				                  'c_' . self::$current_get['category'] :
+				                  'f_' . $another_unread_id;
+				break;
+			case 'c':
+				// We search the next category with at least one unread article.
+				foreach (self::$categories as $cat) {
+					if ($cat->id() == self::$current_get['category']) {
+						// Here is our current category! Next one could be our
+						// champion if it has unread articles.
+						$found_current_get = true;
+						continue;
+					}
+
+					if ($cat->nbNotRead() > 0) {
+						$another_unread_id = $cat->id();
+						if ($found_current_get) {
+							// Unread articles and the current category has
+							// already been found? Leave the loop!
+							break;
+						}
+					}
+				}
+
+				// No unread category? The main stream will be our destination!
+				self::$next_get = empty($another_unread_id) ?
+				                  'a' :
+				                  'c_' . $another_unread_id;
+				break;
+			}
+		}
+	}
+
+	/**
+	 * Determine if the auto remove is available in the current context.
+	 * This feature is available if:
+	 *   - it is activated in the configuration
+	 *   - the "read" state is not enable
+	 *   - the "unread" state is enable
+	 *
+	 * @return boolean
+	 */
+	public static function isAutoRemoveAvailable() {
+		if (!self::$user_conf->auto_remove_article) {
+			return false;
+		}
+		if (self::isStateEnabled(FreshRSS_Entry::STATE_READ)) {
+			return false;
+		}
+		if (!self::isStateEnabled(FreshRSS_Entry::STATE_NOT_READ)) {
+			return false;
+		}
+		return true;
+	}
+
+	/**
+	 * Determine if the "sticky post" option is enabled. It can be enable
+	 * by the user when it is selected in the configuration page or by the
+	 * application when the context allows to auto-remove articles when they
+	 * are read.
+	 *
+	 * @return boolean
+	 */
+	public static function isStickyPostEnabled() {
+		if (self::$user_conf->sticky_post) {
+			return true;
+		}
+		if (self::isAutoRemoveAvailable()) {
+			return true;
+		}
+		return false;
+	}
+
 }
 }

+ 43 - 0
app/Models/DatabaseDAOPGSQL.php

@@ -0,0 +1,43 @@
+<?php
+
+/**
+ * This class is used to test database is well-constructed.
+ */
+class FreshRSS_DatabaseDAOPGSQL extends FreshRSS_DatabaseDAO {
+	public function tablesAreCorrect() {
+		$db = FreshRSS_Context::$system_conf->db;
+		$dbowner = $db['user'];
+		$sql = 'SELECT * FROM pg_catalog.pg_tables where tableowner=?';
+		$stm = $this->bd->prepare($sql);
+		$values = array($dbowner);
+		$stm->execute($values);
+		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+		
+		$tables = array(
+			$this->prefix . 'category' => false,
+			$this->prefix . 'feed' => false,
+			$this->prefix . 'entry' => false,
+		);
+		foreach ($res as $value) {
+			$tables[array_pop($value)] = true;
+		}
+
+		return count(array_keys($tables, true, true)) == count($tables);
+	}
+
+	public function getSchema($table) {
+		$sql = 'select column_name as field, data_type as type, column_default as default, is_nullable as null from INFORMATION_SCHEMA.COLUMNS where table_name = ?';
+		$stm = $this->bd->prepare($sql);
+		$stm->execute(array($this->prefix . $table));
+		return $this->listDaoToSchema($stm->fetchAll(PDO::FETCH_ASSOC));
+	}
+
+	public function daoToSchema($dao) {
+		return array(
+			'name' => $dao['field'],
+			'type' => strtolower($dao['type']),
+			'notnull' => (bool)$dao['null'],
+			'default' => $dao['default'],
+		);
+	}
+}

+ 19 - 4
app/Models/Entry.php

@@ -1,12 +1,11 @@
 <?php
 <?php
 
 
 class FreshRSS_Entry extends Minz_Model {
 class FreshRSS_Entry extends Minz_Model {
-	const STATE_ALL = 0;
 	const STATE_READ = 1;
 	const STATE_READ = 1;
 	const STATE_NOT_READ = 2;
 	const STATE_NOT_READ = 2;
+	const STATE_ALL = 3;
 	const STATE_FAVORITE = 4;
 	const STATE_FAVORITE = 4;
 	const STATE_NOT_FAVORITE = 8;
 	const STATE_NOT_FAVORITE = 8;
-	const STATE_STRICT = 16;
 
 
 	private $id = 0;
 	private $id = 0;
 	private $guid;
 	private $guid;
@@ -15,7 +14,8 @@ class FreshRSS_Entry extends Minz_Model {
 	private $content;
 	private $content;
 	private $link;
 	private $link;
 	private $date;
 	private $date;
-	private $is_read;
+	private $hash = null;
+	private $is_read;	//Nullable boolean
 	private $is_favorite;
 	private $is_favorite;
 	private $feed;
 	private $feed;
 	private $tags;
 	private $tags;
@@ -89,6 +89,14 @@ 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));
+		}
+		return $this->hash;
+	}
+
 	public function _id($value) {
 	public function _id($value) {
 		$this->id = $value;
 		$this->id = $value;
 	}
 	}
@@ -96,23 +104,28 @@ class FreshRSS_Entry extends Minz_Model {
 		$this->guid = $value;
 		$this->guid = $value;
 	}
 	}
 	public function _title($value) {
 	public function _title($value) {
+		$this->hash = null;
 		$this->title = $value;
 		$this->title = $value;
 	}
 	}
 	public function _author($value) {
 	public function _author($value) {
+		$this->hash = null;
 		$this->author = $value;
 		$this->author = $value;
 	}
 	}
 	public function _content($value) {
 	public function _content($value) {
+		$this->hash = null;
 		$this->content = $value;
 		$this->content = $value;
 	}
 	}
 	public function _link($value) {
 	public function _link($value) {
+		$this->hash = null;
 		$this->link = $value;
 		$this->link = $value;
 	}
 	}
 	public function _date($value) {
 	public function _date($value) {
+		$this->hash = null;
 		$value = intval($value);
 		$value = intval($value);
 		$this->date = $value > 1 ? $value : time();
 		$this->date = $value > 1 ? $value : time();
 	}
 	}
 	public function _isRead($value) {
 	public function _isRead($value) {
-		$this->is_read = $value;
+		$this->is_read = $value === null ? null : (bool)$value;
 	}
 	}
 	public function _isFavorite($value) {
 	public function _isFavorite($value) {
 		$this->is_favorite = $value;
 		$this->is_favorite = $value;
@@ -121,6 +134,7 @@ class FreshRSS_Entry extends Minz_Model {
 		$this->feed = $value;
 		$this->feed = $value;
 	}
 	}
 	public function _tags($value) {
 	public function _tags($value) {
+		$this->hash = null;
 		if (!is_array($value)) {
 		if (!is_array($value)) {
 			$value = array($value);
 			$value = array($value);
 		}
 		}
@@ -183,6 +197,7 @@ class FreshRSS_Entry extends Minz_Model {
 			'content' => $this->content(),
 			'content' => $this->content(),
 			'link' => $this->link(),
 			'link' => $this->link(),
 			'date' => $this->date(true),
 			'date' => $this->date(true),
+			'hash' => $this->hash(),
 			'is_read' => $this->isRead(),
 			'is_read' => $this->isRead(),
 			'is_favorite' => $this->isFavorite(),
 			'is_favorite' => $this->isFavorite(),
 			'id_feed' => $this->feed(),
 			'id_feed' => $this->feed(),

+ 492 - 198
app/Models/EntryDAO.php

@@ -1,89 +1,247 @@
 <?php
 <?php
 
 
-class FreshRSS_EntryDAO extends Minz_ModelPdo {
+class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 
 	public function isCompressed() {
 	public function isCompressed() {
+		return parent::$sharedDbType === 'mysql';
+	}
+
+	public function hasNativeHex() {
 		return parent::$sharedDbType !== 'sqlite';
 		return parent::$sharedDbType !== 'sqlite';
 	}
 	}
 
 
-	public function addEntryPrepare() {
-		$sql = 'INSERT INTO `' . $this->prefix . 'entry`(id, guid, title, author, '
-		     . ($this->isCompressed() ? 'content_bin' : 'content')
-		     . ', link, date, is_read, is_favorite, id_feed, tags) '
-		     . 'VALUES(?, ?, ?, ?, '
-		     . ($this->isCompressed() ? 'COMPRESS(?)' : '?')
-		     . ', ?, ?, ?, ?, ?, ?)';
-		return $this->bd->prepare($sql);
+	public function sqlHexDecode($x) {
+		return 'unhex(' . $x . ')';
 	}
 	}
 
 
-	public function addEntry($valuesTmp, $preparedStatement = null) {
-		$stm = $preparedStatement === null ?
-				FreshRSS_EntryDAO::addEntryPrepare() :
-				$preparedStatement;
+	public function sqlHexEncode($x) {
+		return 'hex(' . $x . ')';
+	}
 
 
-		$values = array(
-			$valuesTmp['id'],
-			substr($valuesTmp['guid'], 0, 760),
-			substr($valuesTmp['title'], 0, 255),
-			substr($valuesTmp['author'], 0, 255),
-			$valuesTmp['content'],
-			substr($valuesTmp['link'], 0, 1023),
-			$valuesTmp['date'],
-			$valuesTmp['is_read'] ? 1 : 0,
-			$valuesTmp['is_favorite'] ? 1 : 0,
-			$valuesTmp['id_feed'],
-			substr($valuesTmp['tags'], 0, 1023),
-		);
+	protected function addColumn($name) {
+		Minz_Log::warning('FreshRSS_EntryDAO::addColumn: ' . $name);
+		$hasTransaction = false;
+		try {
+			$stm = null;
+			if ($name === 'lastSeen') {	//v1.1.1
+				if (!$this->bd->inTransaction()) {
+					$this->bd->beginTransaction();
+					$hasTransaction = true;
+				}
+				$stm = $this->bd->prepare('ALTER TABLE `' . $this->prefix . 'entry` ADD COLUMN `lastSeen` INT(11) DEFAULT 0');
+				if ($stm && $stm->execute()) {
+					$stm = $this->bd->prepare('CREATE INDEX entry_lastSeen_index ON `' . $this->prefix . 'entry`(`lastSeen`);');	//"IF NOT EXISTS" does not exist in MySQL 5.7
+					if ($stm && $stm->execute()) {
+						if ($hasTransaction) {
+							$this->bd->commit();
+						}
+						return true;
+					}
+				}
+				if ($hasTransaction) {
+					$this->bd->rollBack();
+				}
+			} elseif ($name === 'hash') {	//v1.1.1
+				$stm = $this->bd->prepare('ALTER TABLE `' . $this->prefix . 'entry` ADD COLUMN hash BINARY(16)');
+				return $stm && $stm->execute();
+			}
+		} catch (Exception $e) {
+			Minz_Log::error('FreshRSS_EntryDAO::addColumn error: ' . $e->getMessage());
+			if ($hasTransaction) {
+				$this->bd->rollBack();
+			}
+		}
+		return false;
+	}
 
 
-		if ($stm && $stm->execute($values)) {
-			return $this->bd->lastInsertId();
-		} else {
-			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
-			if ((int)($info[0] / 1000) !== 23) {	//Filter out "SQLSTATE Class code 23: Constraint Violation" because of expected duplicate entries
-				Minz_Log::error('SQL error addEntry: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
-				. ' while adding entry in feed ' . $valuesTmp['id_feed'] . ' with title: ' . $valuesTmp['title']);
-			} /*else {
-				Minz_Log::debug('SQL error ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
-				. ' while adding entry in feed ' . $valuesTmp['id_feed'] . ' with title: ' . $valuesTmp['title']);
-			}*/
+	private $triedUpdateToUtf8mb4 = false;
+
+	protected function updateToUtf8mb4() {
+		if ($this->triedUpdateToUtf8mb4) {
 			return false;
 			return false;
 		}
 		}
+		$this->triedUpdateToUtf8mb4 = true;
+		$db = FreshRSS_Context::$system_conf->db;
+		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...');
+				$hadTransaction = $this->bd->inTransaction();
+				if ($hadTransaction) {
+					$this->bd->commit();
+				}
+				$ok = false;
+				try {
+					$sql = sprintf(SQL_UPDATE_UTF8MB4, $this->prefix, $db['base']);
+					$stm = $this->bd->prepare($sql);
+					$ok = $stm->execute();
+				} catch (Exception $e) {
+					Minz_Log::error('FreshRSS_EntryDAO::updateToUtf8mb4 error: ' . $e->getMessage());
+				}
+				if ($hadTransaction) {
+					$this->bd->beginTransaction();
+					//NB: Transaction not starting. Why? (tested on PHP 7.0.8-0ubuntu and MySQL 5.7.13-0ubuntu)
+				}
+				return $ok;
+			}
+		}
+		return false;
 	}
 	}
 
 
-	public function addEntryObject($entry, $conf, $feedHistory) {
-		$existingGuids = array_fill_keys(
-			$this->listLastGuidsByFeed($entry->feed(), 20), 1
-		);
-
-		$nb_month_old = max($conf->old_entries, 1);
-		$date_min = time() - (3600 * 24 * 30 * $nb_month_old);
+	protected function autoUpdateDb($errorInfo) {
+		if (isset($errorInfo[0])) {
+			if ($errorInfo[0] === '42S22') {	//ER_BAD_FIELD_ERROR
+				//autoAddColumn
+				foreach (array('lastSeen', 'hash') as $column) {
+					if (stripos($errorInfo[2], $column) !== false) {
+						return $this->addColumn($column);
+					}
+				}
+			}
+		}
+		if (isset($errorInfo[1])) {
+			if ($errorInfo[1] == '1366') {	//ER_TRUNCATED_WRONG_VALUE_FOR_FIELD
+				return $this->updateToUtf8mb4();
+			}
+		}
+		return false;
+	}
 
 
-		$eDate = $entry->date(true);
+	private $addEntryPrepared = null;
+
+	public function addEntry($valuesTmp) {
+		if ($this->addEntryPrepared === null) {
+			$sql = 'INSERT INTO `' . $this->prefix . 'entry` (id, guid, title, author, '
+			     . ($this->isCompressed() ? 'content_bin' : 'content')
+			     . ', link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags) '
+			     . 'VALUES(:id, :guid, :title, :author, '
+			     . ($this->isCompressed() ? 'COMPRESS(:content)' : ':content')
+			     . ', :link, :date, :last_seen, '
+			     . $this->sqlHexDecode(':hash')
+			     . ', :is_read, :is_favorite, :id_feed, :tags)';
+			$this->addEntryPrepared = $this->bd->prepare($sql);
+		}
+		$this->addEntryPrepared->bindParam(':id', $valuesTmp['id']);
+		$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 760);
+		$valuesTmp['guid'] = safe_ascii($valuesTmp['guid']);
+		$this->addEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
+		$valuesTmp['title'] = substr($valuesTmp['title'], 0, 255);
+		$this->addEntryPrepared->bindParam(':title', $valuesTmp['title']);
+		$valuesTmp['author'] = substr($valuesTmp['author'], 0, 255);
+		$this->addEntryPrepared->bindParam(':author', $valuesTmp['author']);
+		$this->addEntryPrepared->bindParam(':content', $valuesTmp['content']);
+		$valuesTmp['link'] = substr($valuesTmp['link'], 0, 1023);
+		$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
+		$this->addEntryPrepared->bindParam(':link', $valuesTmp['link']);
+		$this->addEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
+		$valuesTmp['lastSeen'] = time();
+		$this->addEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
+		$valuesTmp['is_read'] = $valuesTmp['is_read'] ? 1 : 0;
+		$this->addEntryPrepared->bindParam(':is_read', $valuesTmp['is_read'], PDO::PARAM_INT);
+		$valuesTmp['is_favorite'] = $valuesTmp['is_favorite'] ? 1 : 0;
+		$this->addEntryPrepared->bindParam(':is_favorite', $valuesTmp['is_favorite'], PDO::PARAM_INT);
+		$this->addEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
+		$valuesTmp['tags'] = substr($valuesTmp['tags'], 0, 1023);
+		$this->addEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
+
+		if ($this->hasNativeHex()) {
+			$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
+		} else {
+			$valuesTmp['hashBin'] = pack('H*', $valuesTmp['hash']);	//hex2bin() is PHP5.4+
+			$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
+		}
 
 
-		if ($feedHistory == -2) {
-			$feedHistory = $conf->keep_history_default;
+		if ($this->addEntryPrepared && $this->addEntryPrepared->execute()) {
+			return $this->bd->lastInsertId();
+		} else {
+			$info = $this->addEntryPrepared == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $this->addEntryPrepared->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->addEntry($valuesTmp);
+			} elseif ((int)($info[0] / 1000) !== 23) {	//Filter out "SQLSTATE Class code 23: Constraint Violation" because of expected duplicate entries
+				Minz_Log::error('SQL error addEntry: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
+					. ' while adding entry in feed ' . $valuesTmp['id_feed'] . ' with title: ' . $valuesTmp['title']);
+			}
+			return false;
 		}
 		}
+	}
 
 
-		if (!isset($existingGuids[$entry->guid()]) &&
-				($feedHistory != 0 || $eDate >= $date_min || $entry->isFavorite())) {
-			$values = $entry->toArray();
+	private $updateEntryPrepared = null;
 
 
-			$useDeclaredDate = empty($existingGuids);
-			$values['id'] = ($useDeclaredDate || $eDate < $date_min) ?
-				min(time(), $eDate) . uSecString() :
-				uTimeString();
+	public function updateEntry($valuesTmp) {
+		if (!isset($valuesTmp['is_read'])) {
+			$valuesTmp['is_read'] = null;
+		}
 
 
-			return $this->addEntry($values);
+		if ($this->updateEntryPrepared === null) {
+			$sql = 'UPDATE `' . $this->prefix . 'entry` '
+			     . 'SET title=:title, author=:author, '
+			     . ($this->isCompressed() ? 'content_bin=COMPRESS(:content)' : 'content=:content')
+			     . ', link=:link, date=:date, `lastSeen`=:last_seen, '
+			     . 'hash=' . $this->sqlHexDecode(':hash')
+			     . ', ' . ($valuesTmp['is_read'] === null ? '' : 'is_read=:is_read, ')
+			     . 'tags=:tags '
+			     . 'WHERE id_feed=:id_feed AND guid=:guid';
+			$this->updateEntryPrepared = $this->bd->prepare($sql);
+		}
+
+		$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 760);
+		$this->updateEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
+		$valuesTmp['title'] = substr($valuesTmp['title'], 0, 255);
+		$this->updateEntryPrepared->bindParam(':title', $valuesTmp['title']);
+		$valuesTmp['author'] = substr($valuesTmp['author'], 0, 255);
+		$this->updateEntryPrepared->bindParam(':author', $valuesTmp['author']);
+		$this->updateEntryPrepared->bindParam(':content', $valuesTmp['content']);
+		$valuesTmp['link'] = substr($valuesTmp['link'], 0, 1023);
+		$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
+		$this->updateEntryPrepared->bindParam(':link', $valuesTmp['link']);
+		$this->updateEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
+		$valuesTmp['lastSeen'] = time();
+		$this->updateEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
+		if ($valuesTmp['is_read'] !== null) {
+			$this->updateEntryPrepared->bindValue(':is_read', $valuesTmp['is_read'] ? 1 : 0, PDO::PARAM_INT);
+		}
+		$this->updateEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
+		$valuesTmp['tags'] = substr($valuesTmp['tags'], 0, 1023);
+		$this->updateEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
+
+		if ($this->hasNativeHex()) {
+			$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
+		} else {
+			$valuesTmp['hashBin'] = pack('H*', $valuesTmp['hash']);	//hex2bin() is PHP5.4+
+			$this->updateEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
 		}
 		}
 
 
-		// We don't return Entry object to avoid a research in DB
-		return -1;
+		if ($this->updateEntryPrepared && $this->updateEntryPrepared->execute()) {
+			return $this->bd->lastInsertId();
+		} else {
+			$info = $this->updateEntryPrepared == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $this->updateEntryPrepared->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->updateEntry($valuesTmp);
+			}
+			Minz_Log::error('SQL error updateEntry: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
+				. ' while updating entry with GUID ' . $valuesTmp['guid'] . ' in feed ' . $valuesTmp['id_feed']);
+			return false;
+		}
 	}
 	}
 
 
+	/**
+	 * Toggle favorite marker on one or more article
+	 *
+	 * @todo simplify the query by removing the str_repeat. I am pretty sure
+	 * there is an other way to do that.
+	 *
+	 * @param integer|array $ids
+	 * @param boolean $is_favorite
+	 * @return false|integer
+	 */
 	public function markFavorite($ids, $is_favorite = true) {
 	public function markFavorite($ids, $is_favorite = true) {
 		if (!is_array($ids)) {
 		if (!is_array($ids)) {
 			$ids = array($ids);
 			$ids = array($ids);
 		}
 		}
+		if (count($ids) < 1) {
+			return 0;
+		}
+		FreshRSS_UserDAO::touch();
 		$sql = 'UPDATE `' . $this->prefix . 'entry` '
 		$sql = 'UPDATE `' . $this->prefix . 'entry` '
 		     . 'SET is_favorite=? '
 		     . 'SET is_favorite=? '
 		     . 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?)';
 		     . 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?)';
@@ -99,6 +257,17 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		}
 		}
 	}
 	}
 
 
+	/**
+	 * Update the unread article cache held on every feed details.
+	 * Depending on the parameters, it updates the cache on one feed, on all
+	 * feeds from one category or on all feeds.
+	 *
+	 * @todo It can use the query builder refactoring to build that query
+	 *
+	 * @param false|integer $catId category ID
+	 * @param false|integer $feedId feed ID
+	 * @return boolean
+	 */
 	protected function updateCacheUnreads($catId = false, $feedId = false) {
 	protected function updateCacheUnreads($catId = false, $feedId = false) {
 		$sql = 'UPDATE `' . $this->prefix . 'feed` f '
 		$sql = 'UPDATE `' . $this->prefix . 'feed` f '
 		 . 'LEFT OUTER JOIN ('
 		 . 'LEFT OUTER JOIN ('
@@ -108,15 +277,19 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		 .	'WHERE e.is_read=0 '
 		 .	'WHERE e.is_read=0 '
 		 .	'GROUP BY e.id_feed'
 		 .	'GROUP BY e.id_feed'
 		 . ') x ON x.id_feed=f.id '
 		 . ') x ON x.id_feed=f.id '
-		 . 'SET f.cache_nbUnreads=COALESCE(x.nbUnreads, 0) '
-		 . 'WHERE 1';
+		 . 'SET f.`cache_nbUnreads`=COALESCE(x.nbUnreads, 0)';
+		$hasWhere = false;
 		$values = array();
 		$values = array();
 		if ($feedId !== false) {
 		if ($feedId !== false) {
-			$sql .= ' AND f.id=?';
+			$sql .= $hasWhere ? ' AND' : ' WHERE';
+			$hasWhere = true;
+			$sql .= ' f.id=?';
 			$values[] = $id;
 			$values[] = $id;
 		}
 		}
 		if ($catId !== false) {
 		if ($catId !== false) {
-			$sql .= ' AND f.category=?';
+			$sql .= $hasWhere ? ' AND' : ' WHERE';
+			$hasWhere = true;
+			$sql .= ' f.category=?';
 			$values[] = $catId;
 			$values[] = $catId;
 		}
 		}
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
@@ -129,7 +302,21 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		}
 		}
 	}
 	}
 
 
+	/**
+	 * Toggle the read marker on one or more article.
+	 * Then the cache is updated.
+	 *
+	 * @todo change the way the query is build because it seems there is
+	 * unnecessary code in here. For instance, the part with the str_repeat.
+	 * @todo remove code duplication. It seems the code is basically the
+	 * same if it is an array or not.
+	 *
+	 * @param integer|array $ids
+	 * @param boolean $is_read
+	 * @return integer affected rows
+	 */
 	public function markRead($ids, $is_read = true) {
 	public function markRead($ids, $is_read = true) {
+		FreshRSS_UserDAO::touch();
 		if (is_array($ids)) {	//Many IDs at once (used by API)
 		if (is_array($ids)) {	//Many IDs at once (used by API)
 			if (count($ids) < 6) {	//Speed heuristics
 			if (count($ids) < 6) {	//Speed heuristics
 				$affected = 0;
 				$affected = 0;
@@ -158,7 +345,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		} else {
 		} else {
 			$sql = 'UPDATE `' . $this->prefix . 'entry` e INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed=f.id '
 			$sql = 'UPDATE `' . $this->prefix . 'entry` e INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed=f.id '
 				 . 'SET e.is_read=?,'
 				 . 'SET e.is_read=?,'
-				 . 'f.cache_nbUnreads=f.cache_nbUnreads' . ($is_read ? '-' : '+') . '1 '
+				 . 'f.`cache_nbUnreads`=f.`cache_nbUnreads`' . ($is_read ? '-' : '+') . '1 '
 				 . 'WHERE e.id=? AND e.is_read=?';
 				 . 'WHERE e.id=? AND e.is_read=?';
 			$values = array($is_read ? 1 : 0, $ids, $is_read ? 0 : 1);
 			$values = array($is_read ? 1 : 0, $ids, $is_read ? 0 : 1);
 			$stm = $this->bd->prepare($sql);
 			$stm = $this->bd->prepare($sql);
@@ -172,7 +359,29 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		}
 		}
 	}
 	}
 
 
-	public function markReadEntries($idMax = 0, $onlyFavorites = false, $priorityMin = 0) {
+	/**
+	 * Mark all entries as read depending on parameters.
+	 * If $onlyFavorites is true, it is used when the user mark as read in
+	 * the favorite pseudo-category.
+	 * If $priorityMin is greater than 0, it is used when the user mark as
+	 * read in the main feed pseudo-category.
+	 * Then the cache is updated.
+	 *
+	 * If $idMax equals 0, a deprecated debug message is logged
+	 *
+	 * @todo refactor this method along with markReadCat and markReadFeed
+	 * since they are all doing the same thing. I think we need to build a
+	 * tool to generate the query instead of having queries all over the
+	 * place. It will be reused also for the filtering making every thing
+	 * separated.
+	 *
+	 * @param integer $idMax fail safe article ID
+	 * @param boolean $onlyFavorites
+	 * @param integer $priorityMin
+	 * @return integer affected rows
+	 */
+	public function markReadEntries($idMax = 0, $onlyFavorites = false, $priorityMin = 0, $filter = null, $state = 0) {
+		FreshRSS_UserDAO::touch();
 		if ($idMax == 0) {
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
 			Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
@@ -187,8 +396,11 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 			$sql .= ' AND f.priority > ' . intval($priorityMin);
 			$sql .= ' AND f.priority > ' . intval($priorityMin);
 		}
 		}
 		$values = array($idMax);
 		$values = array($idMax);
-		$stm = $this->bd->prepare($sql);
-		if (!($stm && $stm->execute($values))) {
+
+		list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filter, $state);
+
+		$stm = $this->bd->prepare($sql . $search);
+		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			Minz_Log::error('SQL error markReadEntries: ' . $info[2]);
 			Minz_Log::error('SQL error markReadEntries: ' . $info[2]);
 			return false;
 			return false;
@@ -200,7 +412,19 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return $affected;
 		return $affected;
 	}
 	}
 
 
-	public function markReadCat($id, $idMax = 0) {
+	/**
+	 * Mark all the articles in a category as read.
+	 * There is a fail safe to prevent to mark as read articles that are
+	 * loaded during the mark as read action. Then the cache is updated.
+	 *
+	 * If $idMax equals 0, a deprecated debug message is logged
+	 *
+	 * @param integer $id category ID
+	 * @param integer $idMax fail safe article ID
+	 * @return integer affected rows
+	 */
+	public function markReadCat($id, $idMax = 0, $filter = null, $state = 0) {
+		FreshRSS_UserDAO::touch();
 		if ($idMax == 0) {
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadCat(0) is deprecated!');
 			Minz_Log::debug('Calling markReadCat(0) is deprecated!');
@@ -210,8 +434,11 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 			 . 'SET e.is_read=1 '
 			 . 'SET e.is_read=1 '
 			 . 'WHERE f.category=? AND e.is_read=0 AND e.id <= ?';
 			 . 'WHERE f.category=? AND e.is_read=0 AND e.id <= ?';
 		$values = array($id, $idMax);
 		$values = array($id, $idMax);
-		$stm = $this->bd->prepare($sql);
-		if (!($stm && $stm->execute($values))) {
+
+		list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filter, $state);
+
+		$stm = $this->bd->prepare($sql . $search);
+		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			Minz_Log::error('SQL error markReadCat: ' . $info[2]);
 			Minz_Log::error('SQL error markReadCat: ' . $info[2]);
 			return false;
 			return false;
@@ -223,7 +450,19 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return $affected;
 		return $affected;
 	}
 	}
 
 
-	public function markReadFeed($id, $idMax = 0) {
+	/**
+	 * Mark all the articles in a feed as read.
+	 * There is a fail safe to prevent to mark as read articles that are
+	 * loaded during the mark as read action. Then the cache is updated.
+	 *
+	 * If $idMax equals 0, a deprecated debug message is logged
+	 *
+	 * @param integer $id_feed feed ID
+	 * @param integer $idMax fail safe article ID
+	 * @return integer affected rows
+	 */
+	public function markReadFeed($id_feed, $idMax = 0, $filter = null, $state = 0) {
+		FreshRSS_UserDAO::touch();
 		if ($idMax == 0) {
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadFeed(0) is deprecated!');
 			Minz_Log::debug('Calling markReadFeed(0) is deprecated!');
@@ -233,11 +472,14 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		$sql = 'UPDATE `' . $this->prefix . 'entry` '
 		$sql = 'UPDATE `' . $this->prefix . 'entry` '
 			 . 'SET is_read=1 '
 			 . 'SET is_read=1 '
 			 . 'WHERE id_feed=? AND is_read=0 AND id <= ?';
 			 . 'WHERE id_feed=? AND is_read=0 AND id <= ?';
-		$values = array($id, $idMax);
-		$stm = $this->bd->prepare($sql);
-		if (!($stm && $stm->execute($values))) {
+		$values = array($id_feed, $idMax);
+
+		list($searchValues, $search) = $this->sqlListEntriesWhere('', $filter, $state);
+
+		$stm = $this->bd->prepare($sql . $search);
+		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
-			Minz_Log::error('SQL error markReadFeed: ' . $info[2]);
+			Minz_Log::error('SQL error markReadFeed: ' . $info[2] . ' with SQL: ' . $sql . $search);
 			$this->bd->rollBack();
 			$this->bd->rollBack();
 			return false;
 			return false;
 		}
 		}
@@ -245,13 +487,13 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 
 
 		if ($affected > 0) {
 		if ($affected > 0) {
 			$sql = 'UPDATE `' . $this->prefix . 'feed` '
 			$sql = 'UPDATE `' . $this->prefix . 'feed` '
-				 . 'SET cache_nbUnreads=cache_nbUnreads-' . $affected
+				 . 'SET `cache_nbUnreads`=`cache_nbUnreads`-' . $affected
 				 . ' WHERE id=?';
 				 . ' WHERE id=?';
-			$values = array($id);
+			$values = array($id_feed);
 			$stm = $this->bd->prepare($sql);
 			$stm = $this->bd->prepare($sql);
 			if (!($stm && $stm->execute($values))) {
 			if (!($stm && $stm->execute($values))) {
 				$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 				$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
-				Minz_Log::error('SQL error markReadFeed: ' . $info[2]);
+				Minz_Log::error('SQL error markReadFeed cache: ' . $info[2]);
 				$this->bd->rollBack();
 				$this->bd->rollBack();
 				return false;
 				return false;
 			}
 			}
@@ -261,7 +503,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return $affected;
 		return $affected;
 	}
 	}
 
 
-	public function searchByGuid($feed_id, $id) {
+	public function searchByGuid($id_feed, $guid) {
 		// un guid est unique pour un flux donné
 		// un guid est unique pour un flux donné
 		$sql = 'SELECT id, guid, title, author, '
 		$sql = 'SELECT id, guid, title, author, '
 		     . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
 		     . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
@@ -270,13 +512,13 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
 		$values = array(
 		$values = array(
-			$feed_id,
-			$id
+			$id_feed,
+			$guid,
 		);
 		);
 
 
 		$stm->execute($values);
 		$stm->execute($values);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		$entries = self::daoToEntry($res);
+		$entries = self::daoToEntries($res);
 		return isset($entries[0]) ? $entries[0] : null;
 		return isset($entries[0]) ? $entries[0] : null;
 	}
 	}
 
 
@@ -291,7 +533,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 
 
 		$stm->execute($values);
 		$stm->execute($values);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		$entries = self::daoToEntry($res);
+		$entries = self::daoToEntries($res);
 		return isset($entries[0]) ? $entries[0] : null;
 		return isset($entries[0]) ? $entries[0] : null;
 	}
 	}
 
 
@@ -299,54 +541,24 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return 'CONCAT(' . $s1 . ',' . $s2 . ')';	//MySQL
 		return 'CONCAT(' . $s1 . ',' . $s2 . ')';	//MySQL
 	}
 	}
 
 
-	private function sqlListWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filter = '', $date_min = 0) {
-		if (!$state) {
-			$state = FreshRSS_Entry::STATE_ALL;
-		}
-		$where = '';
-		$joinFeed = false;
+	protected function sqlListEntriesWhere($alias = '', $filter = null, $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $firstId = '', $date_min = 0) {
+		$search = ' ';
 		$values = array();
 		$values = array();
-		switch ($type) {
-		case 'a':
-			$where .= 'f.priority > 0 ';
-			$joinFeed = true;
-			break;
-		case 's':	//Deprecated: use $state instead
-			$where .= 'e1.is_favorite=1 ';
-			break;
-		case 'c':
-			$where .= 'f.category=? ';
-			$values[] = intval($id);
-			$joinFeed = true;
-			break;
-		case 'f':
-			$where .= 'e1.id_feed=? ';
-			$values[] = intval($id);
-			break;
-		case 'A':
-			$where .= '1 ';
-			break;
-		default:
-			throw new FreshRSS_EntriesGetter_Exception('Bad type in Entry->listByType: [' . $type . ']!');
-		}
-
 		if ($state & FreshRSS_Entry::STATE_NOT_READ) {
 		if ($state & FreshRSS_Entry::STATE_NOT_READ) {
 			if (!($state & FreshRSS_Entry::STATE_READ)) {
 			if (!($state & FreshRSS_Entry::STATE_READ)) {
-				$where .= 'AND e1.is_read=0 ';
-			} elseif ($state & FreshRSS_Entry::STATE_STRICT) {
-				$where .= 'AND e1.is_read=0 ';
+				$search .= 'AND ' . $alias . 'is_read=0 ';
 			}
 			}
 		}
 		}
 		elseif ($state & FreshRSS_Entry::STATE_READ) {
 		elseif ($state & FreshRSS_Entry::STATE_READ) {
-			$where .= 'AND e1.is_read=1 ';
+			$search .= 'AND ' . $alias . 'is_read=1 ';
 		}
 		}
 		if ($state & FreshRSS_Entry::STATE_FAVORITE) {
 		if ($state & FreshRSS_Entry::STATE_FAVORITE) {
 			if (!($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
 			if (!($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
-				$where .= 'AND e1.is_favorite=1 ';
+				$search .= 'AND ' . $alias . 'is_favorite=1 ';
 			}
 			}
 		}
 		}
 		elseif ($state & FreshRSS_Entry::STATE_NOT_FAVORITE) {
 		elseif ($state & FreshRSS_Entry::STATE_NOT_FAVORITE) {
-			$where .= 'AND e1.is_favorite=0 ';
+			$search .= 'AND ' . $alias . 'is_favorite=0 ';
 		}
 		}
 
 
 		switch ($order) {
 		switch ($order) {
@@ -360,87 +572,120 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 			$firstId = $order === 'DESC' ? '9000000000'. '000000' : '0';	//MySQL optimization. TODO: check if this is needed again, after the filtering for old articles has been removed in 0.9-dev
 			$firstId = $order === 'DESC' ? '9000000000'. '000000' : '0';	//MySQL optimization. TODO: check if this is needed again, after the filtering for old articles has been removed in 0.9-dev
 		}*/
 		}*/
 		if ($firstId !== '') {
 		if ($firstId !== '') {
-			$where .= 'AND e1.id ' . ($order === 'DESC' ? '<=' : '>=') . $firstId . ' ';
+			$search .= 'AND ' . $alias . 'id ' . ($order === 'DESC' ? '<=' : '>=') . $firstId . ' ';
 		}
 		}
 		if ($date_min > 0) {
 		if ($date_min > 0) {
-			$where .= 'AND e1.id >= ' . $date_min . '000000 ';
-		}
-		$search = '';
-		if ($filter !== '') {
-			require_once(LIB_PATH . '/lib_date.php');
-			$filter = trim($filter);
-			$filter = addcslashes($filter, '\\%_');
-			$terms = array_unique(explode(' ', $filter));
-			//sort($terms);	//Put #tags first	//TODO: Put the cheapest filters first
-			foreach ($terms as $word) {
-				$word = trim($word);
-				if (stripos($word, 'intitle:') === 0) {
-					$word = substr($word, strlen('intitle:'));
-					$search .= 'AND e1.title LIKE ? ';
-					$values[] = '%' . $word .'%';
-				} elseif (stripos($word, 'inurl:') === 0) {
-					$word = substr($word, strlen('inurl:'));
-					$search .= 'AND CONCAT(e1.link, e1.guid) LIKE ? ';
-					$values[] = '%' . $word .'%';
-				} elseif (stripos($word, 'author:') === 0) {
-					$word = substr($word, strlen('author:'));
-					$search .= 'AND e1.author LIKE ? ';
-					$values[] = '%' . $word .'%';
-				} elseif (stripos($word, 'date:') === 0) {
-					$word = substr($word, strlen('date:'));
-					list($minDate, $maxDate) = parseDateInterval($word);
-					if ($minDate) {
-						$search .= 'AND e1.id >= ' . $minDate . '000000 ';
-					}
-					if ($maxDate) {
-						$search .= 'AND e1.id <= ' . $maxDate . '000000 ';
-					}
-				} elseif (stripos($word, 'pubdate:') === 0) {
-					$word = substr($word, strlen('pubdate:'));
-					list($minDate, $maxDate) = parseDateInterval($word);
-					if ($minDate) {
-						$search .= 'AND e1.date >= ' . $minDate . ' ';
-					}
-					if ($maxDate) {
-						$search .= 'AND e1.date <= ' . $maxDate . ' ';
-					}
-				} else {
-					if ($word[0] === '#' && isset($word[1])) {
-						$search .= 'AND e1.tags LIKE ? ';
-						$values[] = '%' . $word .'%';
-					} else {
-						$search .= 'AND ' . $this->sqlconcat('e1.title', $this->isCompressed() ? 'UNCOMPRESS(content_bin)' : 'content') . ' LIKE ? ';
-						$values[] = '%' . $word .'%';
-					}
+			$search .= 'AND ' . $alias . 'id >= ' . $date_min . '000000 ';
+		}
+		if ($filter) {
+			if ($filter->getIntitle()) {
+				$search .= 'AND ' . $alias . 'title LIKE ? ';
+				$values[] = "%{$filter->getIntitle()}%";
+			}
+			if ($filter->getInurl()) {
+				$search .= 'AND CONCAT(' . $alias . 'link, ' . $alias . 'guid) LIKE ? ';
+				$values[] = "%{$filter->getInurl()}%";
+			}
+			if ($filter->getAuthor()) {
+				$search .= 'AND ' . $alias . 'author LIKE ? ';
+				$values[] = "%{$filter->getAuthor()}%";
+			}
+			if ($filter->getMinDate()) {
+				$search .= 'AND ' . $alias . 'id >= ? ';
+				$values[] = "{$filter->getMinDate()}000000";
+			}
+			if ($filter->getMaxDate()) {
+				$search .= 'AND ' . $alias . 'id <= ? ';
+				$values[] = "{$filter->getMaxDate()}000000";
+			}
+			if ($filter->getMinPubdate()) {
+				$search .= 'AND ' . $alias . 'date >= ? ';
+				$values[] = $filter->getMinPubdate();
+			}
+			if ($filter->getMaxPubdate()) {
+				$search .= 'AND ' . $alias . 'date <= ? ';
+				$values[] = $filter->getMaxPubdate();
+			}
+			if ($filter->getTags()) {
+				$tags = $filter->getTags();
+				foreach ($tags as $tag) {
+					$search .= 'AND ' . $alias . 'tags LIKE ? ';
+					$values[] = "%{$tag}%";
+				}
+			}
+			if ($filter->getSearch()) {
+				$search_values = $filter->getSearch();
+				foreach ($search_values as $search_value) {
+					$search .= 'AND ' . $this->sqlconcat($alias . 'title', $this->isCompressed() ? 'UNCOMPRESS(' . $alias . 'content_bin)' : '' . $alias . 'content') . ' LIKE ? ';
+					$values[] = "%{$search_value}%";
 				}
 				}
 			}
 			}
 		}
 		}
+		return array($values, $search);
+	}
+
+	private function sqlListWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filter = '', $date_min = 0) {
+		if (!$state) {
+			$state = FreshRSS_Entry::STATE_ALL;
+		}
+		$where = '';
+		$joinFeed = false;
+		$values = array();
+		switch ($type) {
+		case 'a':
+			$where .= 'f.priority > 0 ';
+			$joinFeed = true;
+			break;
+		case 's':	//Deprecated: use $state instead
+			$where .= 'e.is_favorite=1 ';
+			break;
+		case 'c':
+			$where .= 'f.category=? ';
+			$values[] = intval($id);
+			$joinFeed = true;
+			break;
+		case 'f':
+			$where .= 'e.id_feed=? ';
+			$values[] = intval($id);
+			break;
+		case 'A':
+			$where .= '1 ';
+			break;
+		default:
+			throw new FreshRSS_EntriesGetter_Exception('Bad type in Entry->listByType: [' . $type . ']!');
+		}
+
+		list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filter, $state, $order, $firstId, $date_min);
 
 
-		return array($values,
-			'SELECT e1.id FROM `' . $this->prefix . 'entry` e1 '
-			. ($joinFeed ? 'INNER JOIN `' . $this->prefix . 'feed` f ON e1.id_feed=f.id ' : '')
+		return array(array_merge($values, $searchValues),
+			'SELECT e.id FROM `' . $this->prefix . 'entry` e '
+			. ($joinFeed ? 'INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed=f.id ' : '')
 			. 'WHERE ' . $where
 			. 'WHERE ' . $where
 			. $search
 			. $search
-			. 'ORDER BY e1.id ' . $order
+			. 'ORDER BY e.id ' . $order
 			. ($limit > 0 ? ' LIMIT ' . $limit : ''));	//TODO: See http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/
 			. ($limit > 0 ? ' LIMIT ' . $limit : ''));	//TODO: See http://explainextended.com/2009/10/23/mysql-order-by-limit-performance-late-row-lookups/
 	}
 	}
 
 
-	public function listWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filter = '', $date_min = 0) {
+	public function listWhereRaw($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filter = '', $date_min = 0) {
 		list($values, $sql) = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filter, $date_min);
 		list($values, $sql) = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filter, $date_min);
 
 
-		$sql = 'SELECT e.id, e.guid, e.title, e.author, '
+		$sql = 'SELECT e0.id, e0.guid, e0.title, e0.author, '
 		     . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
 		     . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-		     . ', e.link, e.date, e.is_read, e.is_favorite, e.id_feed, e.tags '
-		     . 'FROM `' . $this->prefix . 'entry` e '
+		     . ', e0.link, e0.date, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags '
+		     . 'FROM `' . $this->prefix . 'entry` e0 '
 		     . 'INNER JOIN ('
 		     . 'INNER JOIN ('
 		     . $sql
 		     . $sql
-		     . ') e2 ON e2.id=e.id '
-		     . 'ORDER BY e.id ' . $order;
+		     . ') e2 ON e2.id=e0.id '
+		     . 'ORDER BY e0.id ' . $order;
 
 
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 		$stm->execute($values);
 		$stm->execute($values);
+		return $stm;
+	}
 
 
-		return self::daoToEntry($stm->fetchAll(PDO::FETCH_ASSOC));
+	public function listWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filter = '', $date_min = 0) {
+		$stm = $this->listWhereRaw($type, $id, $state, $order, $limit, $firstId, $filter, $date_min);
+		return self::daoToEntries($stm->fetchAll(PDO::FETCH_ASSOC));
 	}
 	}
 
 
 	public function listIdsWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filter = '', $date_min = 0) {	//For API
 	public function listIdsWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $limit = 1, $firstId = '', $filter = '', $date_min = 0) {	//For API
@@ -452,12 +697,55 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
 		return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
 	}
 	}
 
 
-	public function listLastGuidsByFeed($id, $n) {
-		$sql = 'SELECT guid FROM `' . $this->prefix . 'entry` WHERE id_feed=? ORDER BY id DESC LIMIT ' . intval($n);
+	public function listHashForFeedGuids($id_feed, $guids) {
+		if (count($guids) < 1) {
+			return array();
+		}
+		$guids = array_unique($guids);
+		$sql = 'SELECT guid, ' . $this->sqlHexEncode('hash') . ' AS hex_hash FROM `' . $this->prefix . 'entry` WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1). '?)';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
-		$values = array($id);
-		$stm->execute($values);
-		return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+		$values = array($id_feed);
+		$values = array_merge($values, $guids);
+		if ($stm && $stm->execute($values)) {
+			$result = array();
+			$rows = $stm->fetchAll(PDO::FETCH_ASSOC);
+			foreach ($rows as $row) {
+				$result[$row['guid']] = $row['hex_hash'];
+			}
+			return $result;
+		} else {
+			$info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->listHashForFeedGuids($id_feed, $guids);
+			}
+			Minz_Log::error('SQL error listHashForFeedGuids: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
+				. ' while querying feed ' . $id_feed);
+			return false;
+		}
+	}
+
+	public function updateLastSeen($id_feed, $guids, $mtime = 0) {
+		if (count($guids) < 1) {
+			return 0;
+		}
+		$sql = 'UPDATE `' . $this->prefix . 'entry` SET `lastSeen`=? WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1). '?)';
+		$stm = $this->bd->prepare($sql);
+		if ($mtime <= 0) {
+			$mtime = time();
+		}
+		$values = array($mtime, $id_feed);
+		$values = array_merge($values, $guids);
+		if ($stm && $stm->execute($values)) {
+			return $stm->rowCount();
+		} else {
+			$info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->updateLastSeen($id_feed, $guids);
+			}
+			Minz_Log::error('SQL error updateLastSeen: ' . $info[0] . ': ' . $info[1] . ' ' . $info[2]
+				. ' while updating feed ' . $id_feed);
+			return false;
+		}
 	}
 	}
 
 
 	public function countUnreadRead() {
 	public function countUnreadRead() {
@@ -507,11 +795,13 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 	public function optimizeTable() {
 	public function optimizeTable() {
 		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'entry`';	//MySQL
 		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'entry`';	//MySQL
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
-		$stm->execute();
+		if ($stm) {
+			return $stm->execute();
+		}
 	}
 	}
 
 
 	public function size($all = false) {
 	public function size($all = false) {
-		$db = Minz_Configuration::dataBase();
+		$db = FreshRSS_Context::$system_conf->db;
 		$sql = 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema=?';	//MySQL
 		$sql = 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema=?';	//MySQL
 		$values = array($db['base']);
 		$values = array($db['base']);
 		if (!$all) {
 		if (!$all) {
@@ -524,15 +814,8 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return $res[0];
 		return $res[0];
 	}
 	}
 
 
-	public static function daoToEntry($listDAO) {
-		$list = array();
-
-		if (!is_array($listDAO)) {
-			$listDAO = array($listDAO);
-		}
-
-		foreach ($listDAO as $key => $dao) {
-			$entry = new FreshRSS_Entry(
+	public static function daoToEntry($dao) {
+		$entry = new FreshRSS_Entry(
 				$dao['id_feed'],
 				$dao['id_feed'],
 				$dao['guid'],
 				$dao['guid'],
 				$dao['title'],
 				$dao['title'],
@@ -544,10 +827,21 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 				$dao['is_favorite'],
 				$dao['is_favorite'],
 				$dao['tags']
 				$dao['tags']
 			);
 			);
-			if (isset($dao['id'])) {
-				$entry->_id($dao['id']);
-			}
-			$list[] = $entry;
+		if (isset($dao['id'])) {
+			$entry->_id($dao['id']);
+		}
+		return $entry;
+	}
+
+	private static function daoToEntries($listDAO) {
+		$list = array();
+
+		if (!is_array($listDAO)) {
+			$listDAO = array($listDAO);
+		}
+
+		foreach ($listDAO as $key => $dao) {
+			$list[] = self::daoToEntry($dao);
 		}
 		}
 
 
 		unset($listDAO);
 		unset($listDAO);

+ 31 - 0
app/Models/EntryDAOPGSQL.php

@@ -0,0 +1,31 @@
+<?php
+
+class FreshRSS_EntryDAOPGSQL extends FreshRSS_EntryDAOSQLite {
+
+	public function sqlHexDecode($x) {
+		return 'decode(' . $x . ", 'hex')";
+	}
+
+	public function sqlHexEncode($x) {
+		return 'encode(' . $x . ", 'hex')";
+	}
+
+	protected function autoUpdateDb($errorInfo) {
+		return false;
+	}
+
+	protected function addColumn($name) {
+		return false;
+	}
+
+	public function size($all = true) {
+		$db = FreshRSS_Context::$system_conf->db;
+		$sql = 'SELECT pg_size_pretty(pg_database_size(?))';
+		$values = array($db['base']);
+		$stm = $this->bd->prepare($sql);
+		$stm->execute($values);
+		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+		return $res[0];
+	}
+
+}

+ 88 - 13
app/Models/EntryDAOSQLite.php

@@ -2,23 +2,47 @@
 
 
 class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 
 
+	public function sqlHexDecode($x) {
+		return $x;
+	}
+
+	protected function autoUpdateDb($errorInfo) {
+		if (empty($errorInfo[0]) || $errorInfo[0] == '42S22') {	//ER_BAD_FIELD_ERROR
+			//autoAddColumn
+			if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entry'")) {
+				$showCreate = $tableInfo->fetchColumn();
+				Minz_Log::debug('FreshRSS_EntryDAOSQLite::autoUpdateDb: ' . $showCreate);
+				foreach (array('lastSeen', 'hash') as $column) {
+					if (stripos($showCreate, $column) === false) {
+						return $this->addColumn($column);
+					}
+				}
+			}
+		}
+		return false;
+	}
+
 	protected function sqlConcat($s1, $s2) {
 	protected function sqlConcat($s1, $s2) {
 		return $s1 . '||' . $s2;
 		return $s1 . '||' . $s2;
 	}
 	}
 
 
 	protected function updateCacheUnreads($catId = false, $feedId = false) {
 	protected function updateCacheUnreads($catId = false, $feedId = false) {
 		$sql = 'UPDATE `' . $this->prefix . 'feed` '
 		$sql = 'UPDATE `' . $this->prefix . 'feed` '
-		 . 'SET cache_nbUnreads=('
+		 . 'SET `cache_nbUnreads`=('
 		 .	'SELECT COUNT(*) AS nbUnreads FROM `' . $this->prefix . 'entry` e '
 		 .	'SELECT COUNT(*) AS nbUnreads FROM `' . $this->prefix . 'entry` e '
-		 .	'WHERE e.id_feed=`' . $this->prefix . 'feed`.id AND e.is_read=0) '
-		 . 'WHERE 1';
+		 .	'WHERE e.id_feed=`' . $this->prefix . 'feed`.id AND e.is_read=0)';
+		$hasWhere = false;
 		$values = array();
 		$values = array();
 		if ($feedId !== false) {
 		if ($feedId !== false) {
-			$sql .= ' AND id=?';
+			$sql .= $hasWhere ? ' AND' : ' WHERE';
+			$hasWhere = true;
+			$sql .= ' id=?';
 			$values[] = $feedId;
 			$values[] = $feedId;
 		}
 		}
 		if ($catId !== false) {
 		if ($catId !== false) {
-			$sql .= ' AND category=?';
+			$sql .= $hasWhere ? ' AND' : ' WHERE';
+			$hasWhere = true;
+			$sql .= ' category=?';
 			$values[] = $catId;
 			$values[] = $catId;
 		}
 		}
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
@@ -31,6 +55,19 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		}
 		}
 	}
 	}
 
 
+	/**
+	 * Toggle the read marker on one or more article.
+	 * Then the cache is updated.
+	 *
+	 * @todo change the way the query is build because it seems there is
+	 * unnecessary code in here. For instance, the part with the str_repeat.
+	 * @todo remove code duplication. It seems the code is basically the
+	 * same if it is an array or not.
+	 *
+	 * @param integer|array $ids
+	 * @param boolean $is_read
+	 * @return integer affected rows
+	 */
 	public function markRead($ids, $is_read = true) {
 	public function markRead($ids, $is_read = true) {
 		if (is_array($ids)) {	//Many IDs at once (used by API)
 		if (is_array($ids)) {	//Many IDs at once (used by API)
 			if (true) {	//Speed heuristics	//TODO: Not implemented yet for SQLite (so always call IDs one by one)
 			if (true) {	//Speed heuristics	//TODO: Not implemented yet for SQLite (so always call IDs one by one)
@@ -53,7 +90,7 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 			}
 			}
 			$affected = $stm->rowCount();
 			$affected = $stm->rowCount();
 			if ($affected > 0) {
 			if ($affected > 0) {
-				$sql = 'UPDATE `' . $this->prefix . 'feed` SET cache_nbUnreads=cache_nbUnreads' . ($is_read ? '-' : '+') . '1 '
+				$sql = 'UPDATE `' . $this->prefix . 'feed` SET `cache_nbUnreads`=`cache_nbUnreads`' . ($is_read ? '-' : '+') . '1 '
 				 . 'WHERE id=(SELECT e.id_feed FROM `' . $this->prefix . 'entry` e WHERE e.id=?)';
 				 . 'WHERE id=(SELECT e.id_feed FROM `' . $this->prefix . 'entry` e WHERE e.id=?)';
 				$values = array($ids);
 				$values = array($ids);
 				$stm = $this->bd->prepare($sql);
 				$stm = $this->bd->prepare($sql);
@@ -69,7 +106,28 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		}
 		}
 	}
 	}
 
 
-	public function markReadEntries($idMax = 0, $onlyFavorites = false, $priorityMin = 0) {
+	/**
+	 * Mark all entries as read depending on parameters.
+	 * If $onlyFavorites is true, it is used when the user mark as read in
+	 * the favorite pseudo-category.
+	 * If $priorityMin is greater than 0, it is used when the user mark as
+	 * read in the main feed pseudo-category.
+	 * Then the cache is updated.
+	 *
+	 * If $idMax equals 0, a deprecated debug message is logged
+	 *
+	 * @todo refactor this method along with markReadCat and markReadFeed
+	 * since they are all doing the same thing. I think we need to build a
+	 * tool to generate the query instead of having queries all over the
+	 * place. It will be reused also for the filtering making every thing
+	 * separated.
+	 *
+	 * @param integer $idMax fail safe article ID
+	 * @param boolean $onlyFavorites
+	 * @param integer $priorityMin
+	 * @return integer affected rows
+	 */
+	public function markReadEntries($idMax = 0, $onlyFavorites = false, $priorityMin = 0, $filter = null, $state = 0) {
 		if ($idMax == 0) {
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
 			Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
@@ -82,8 +140,11 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 			$sql .= ' AND id_feed IN (SELECT f.id FROM `' . $this->prefix . 'feed` f WHERE f.priority > ' . intval($priorityMin) . ')';
 			$sql .= ' AND id_feed IN (SELECT f.id FROM `' . $this->prefix . 'feed` f WHERE f.priority > ' . intval($priorityMin) . ')';
 		}
 		}
 		$values = array($idMax);
 		$values = array($idMax);
-		$stm = $this->bd->prepare($sql);
-		if (!($stm && $stm->execute($values))) {
+
+		list($searchValues, $search) = $this->sqlListEntriesWhere('', $filter, $state);
+
+		$stm = $this->bd->prepare($sql . $search);
+		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			Minz_Log::error('SQL error markReadEntries: ' . $info[2]);
 			Minz_Log::error('SQL error markReadEntries: ' . $info[2]);
 			return false;
 			return false;
@@ -95,7 +156,18 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		return $affected;
 		return $affected;
 	}
 	}
 
 
-	public function markReadCat($id, $idMax = 0) {
+	/**
+	 * Mark all the articles in a category as read.
+	 * There is a fail safe to prevent to mark as read articles that are
+	 * loaded during the mark as read action. Then the cache is updated.
+	 *
+	 * If $idMax equals 0, a deprecated debug message is logged
+	 *
+	 * @param integer $id category ID
+	 * @param integer $idMax fail safe article ID
+	 * @return integer affected rows
+	 */
+	public function markReadCat($id, $idMax = 0, $filter = null, $state = 0) {
 		if ($idMax == 0) {
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadCat(0) is deprecated!');
 			Minz_Log::debug('Calling markReadCat(0) is deprecated!');
@@ -106,8 +178,11 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 			 . 'WHERE is_read=0 AND id <= ? AND '
 			 . 'WHERE is_read=0 AND id <= ? AND '
 			 . 'id_feed IN (SELECT f.id FROM `' . $this->prefix . 'feed` f WHERE f.category=?)';
 			 . 'id_feed IN (SELECT f.id FROM `' . $this->prefix . 'feed` f WHERE f.category=?)';
 		$values = array($idMax, $id);
 		$values = array($idMax, $id);
-		$stm = $this->bd->prepare($sql);
-		if (!($stm && $stm->execute($values))) {
+
+		list($searchValues, $search) = $this->sqlListEntriesWhere('', $filter, $state);
+
+		$stm = $this->bd->prepare($sql . $search);
+		if (!($stm && $stm->execute(array_merge($values, $searchValues)))) {
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			Minz_Log::error('SQL error markReadCat: ' . $info[2]);
 			Minz_Log::error('SQL error markReadCat: ' . $info[2]);
 			return false;
 			return false;
@@ -124,6 +199,6 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	}
 	}
 
 
 	public function size($all = false) {
 	public function size($all = false) {
-		return @filesize(DATA_PATH . '/' . $this->current_user . '.sqlite');
+		return @filesize(join_path(DATA_PATH, 'users', $this->current_user, 'db.sqlite'));
 	}
 	}
 }
 }

+ 31 - 20
app/Models/Factory.php

@@ -3,38 +3,49 @@
 class FreshRSS_Factory {
 class FreshRSS_Factory {
 
 
 	public static function createFeedDao($username = null) {
 	public static function createFeedDao($username = null) {
-		$db = Minz_Configuration::dataBase();
-		if ($db['type'] === 'sqlite') {
-			return new FreshRSS_FeedDAOSQLite($username);
-		} else {
-			return new FreshRSS_FeedDAO($username);
+		$conf = Minz_Configuration::get('system');
+		switch ($conf->db['type']) {
+			case 'sqlite':
+			case 'pgsql':
+				return new FreshRSS_FeedDAOSQLite($username);
+			default:
+				return new FreshRSS_FeedDAO($username);
 		}
 		}
 	}
 	}
 
 
 	public static function createEntryDao($username = null) {
 	public static function createEntryDao($username = null) {
-		$db = Minz_Configuration::dataBase();
-		if ($db['type'] === 'sqlite') {
-			return new FreshRSS_EntryDAOSQLite($username);
-		} else {
-			return new FreshRSS_EntryDAO($username);
+		$conf = Minz_Configuration::get('system');
+		switch ($conf->db['type']) {
+			case 'sqlite':
+				return new FreshRSS_EntryDAOSQLite($username);
+			case 'pgsql':
+				return new FreshRSS_EntryDAOPGSQL($username);
+			default:
+				return new FreshRSS_EntryDAO($username);
 		}
 		}
 	}
 	}
 
 
 	public static function createStatsDAO($username = null) {
 	public static function createStatsDAO($username = null) {
-		$db = Minz_Configuration::dataBase();
-		if ($db['type'] === 'sqlite') {
-			return new FreshRSS_StatsDAOSQLite($username);
-		} else {
-			return new FreshRSS_StatsDAO($username);
+		$conf = Minz_Configuration::get('system');
+		switch ($conf->db['type']) {
+			case 'sqlite':
+				return new FreshRSS_StatsDAOSQLite($username);
+			case 'pgsql':
+				return new FreshRSS_StatsDAOPGSQL($username);
+			default:
+				return new FreshRSS_StatsDAO($username);
 		}
 		}
 	}
 	}
 
 
 	public static function createDatabaseDAO($username = null) {
 	public static function createDatabaseDAO($username = null) {
-		$db = Minz_Configuration::dataBase();
-		if ($db['type'] === 'sqlite') {
-			return new FreshRSS_DatabaseDAOSQLite($username);
-		} else {
-			return new FreshRSS_DatabaseDAO($username);
+		$conf = Minz_Configuration::get('system');
+		switch ($conf->db['type']) {
+			case 'sqlite':
+				return new FreshRSS_DatabaseDAOSQLite($username);
+			case 'pgsql':
+				return new FreshRSS_DatabaseDAOPGSQL($username);
+			default:
+				return new FreshRSS_DatabaseDAO($username);
 		}
 		}
 	}
 	}
 
 

+ 197 - 23
app/Models/Feed.php

@@ -19,6 +19,8 @@ class FreshRSS_Feed extends Minz_Model {
 	private $ttl = -2;
 	private $ttl = -2;
 	private $hash = null;
 	private $hash = null;
 	private $lockPath = '';
 	private $lockPath = '';
+	private $hubUrl = '';
+	private $selfUrl = '';
 
 
 	public function __construct($url, $validate=true) {
 	public function __construct($url, $validate=true) {
 		if ($validate) {
 		if ($validate) {
@@ -40,7 +42,8 @@ class FreshRSS_Feed extends Minz_Model {
 
 
 	public function hash() {
 	public function hash() {
 		if ($this->hash === null) {
 		if ($this->hash === null) {
-			$this->hash = hash('crc32b', Minz_Configuration::salt() . $this->url);
+			$salt = FreshRSS_Context::$system_conf->salt;
+			$this->hash = hash('crc32b', $salt . $this->url);
 		}
 		}
 		return $this->hash;
 		return $this->hash;
 	}
 	}
@@ -48,6 +51,12 @@ class FreshRSS_Feed extends Minz_Model {
 	public function url() {
 	public function url() {
 		return $this->url;
 		return $this->url;
 	}
 	}
+	public function selfUrl() {
+		return $this->selfUrl;
+	}
+	public function hubUrl() {
+		return $this->hubUrl;
+	}
 	public function category() {
 	public function category() {
 		return $this->category;
 		return $this->category;
 	}
 	}
@@ -95,6 +104,16 @@ class FreshRSS_Feed extends Minz_Model {
 	public function ttl() {
 	public function ttl() {
 		return $this->ttl;
 		return $this->ttl;
 	}
 	}
+	// public function ttlExpire() {
+		// $ttl = $this->ttl;
+		// if ($ttl == -2) {	//Default
+			// $ttl = FreshRSS_Context::$user_conf->ttl_default;
+		// }
+		// if ($ttl == -1) {	//Never
+			// $ttl = 64000000;	//~2 years. Good enough for PubSubHubbub logic
+		// }
+		// return $this->lastUpdate + $ttl;
+	// }
 	public function nbEntries() {
 	public function nbEntries() {
 		if ($this->nbEntries < 0) {
 		if ($this->nbEntries < 0) {
 			$feedDAO = FreshRSS_Factory::createFeedDao();
 			$feedDAO = FreshRSS_Factory::createFeedDao();
@@ -112,13 +131,26 @@ class FreshRSS_Feed extends Minz_Model {
 		return $this->nbNotRead;
 		return $this->nbNotRead;
 	}
 	}
 	public function faviconPrepare() {
 	public function faviconPrepare() {
-		$file = DATA_PATH . '/favicons/' . $this->hash() . '.txt';
-		if (!file_exists($file)) {
-			$t = $this->website;
-			if ($t == '') {
-				$t = $this->url;
+		global $favicons_dir;
+		require_once(LIB_PATH . '/favicons.php');
+		$url = $this->website;
+		if ($url == '') {
+			$url = $this->url;
+		}
+		$txt = $favicons_dir . $this->hash() . '.txt';
+		if (!file_exists($txt)) {
+			file_put_contents($txt, $url);
+		}
+		if (FreshRSS_Context::$isCli) {
+			$ico = $favicons_dir . $this->hash() . '.ico';
+			$ico_mtime = @filemtime($ico);
+			$txt_mtime = @filemtime($txt);
+			if ($txt_mtime != false &&
+				($ico_mtime == false || $ico_mtime < $txt_mtime || ($ico_mtime < time() - (14 * 86400)))) {
+				// no ico file or we should download a new one.
+				$url = file_get_contents($txt);
+				download_favicon($url, $ico) || touch($ico);
 			}
 			}
-			file_put_contents($file, $t);
 		}
 		}
 	}
 	}
 	public static function faviconDelete($hash) {
 	public static function faviconDelete($hash) {
@@ -197,7 +229,7 @@ class FreshRSS_Feed extends Minz_Model {
 		$this->nbEntries = intval($value);
 		$this->nbEntries = intval($value);
 	}
 	}
 
 
-	public function load($loadDetails = false) {
+	public function load($loadDetails = false, $noCache = false) {
 		if ($this->url !== null) {
 		if ($this->url !== null) {
 			if (CACHE_PATH === false) {
 			if (CACHE_PATH === false) {
 				throw new Minz_FileNotExistException(
 				throw new Minz_FileNotExistException(
@@ -210,6 +242,10 @@ class FreshRSS_Feed extends Minz_Model {
 					$url = preg_replace('#((.+)://)(.+)#', '${1}' . $this->httpAuth . '@${3}', $url);
 					$url = preg_replace('#((.+)://)(.+)#', '${1}' . $this->httpAuth . '@${3}', $url);
 				}
 				}
 				$feed = customSimplePie();
 				$feed = customSimplePie();
+				if (substr($url, -11) === '#force_feed') {
+					$feed->force_feed(true);
+					$url = substr($url, 0, -11);
+				}
 				$feed->set_feed_url($url);
 				$feed->set_feed_url($url);
 				if (!$loadDetails) {	//Only activates auto-discovery when adding a new feed
 				if (!$loadDetails) {	//Only activates auto-discovery when adding a new feed
 					$feed->set_autodiscovery_level(SIMPLEPIE_LOCATOR_NONE);
 					$feed->set_autodiscovery_level(SIMPLEPIE_LOCATOR_NONE);
@@ -217,15 +253,21 @@ class FreshRSS_Feed extends Minz_Model {
 				$mtime = $feed->init();
 				$mtime = $feed->init();
 
 
 				if ((!$mtime) || $feed->error()) {
 				if ((!$mtime) || $feed->error()) {
-					throw new FreshRSS_Feed_Exception($feed->error() . ' [' . $url . ']');
+					$errorMessage = $feed->error();
+					throw new FreshRSS_Feed_Exception(($errorMessage == '' ? 'Unknown error for feed' : $errorMessage) . ' [' . $url . ']');
 				}
 				}
 
 
+				$links = $feed->get_links('self');
+				$this->selfUrl = isset($links[0]) ? $links[0] : null;
+				$links = $feed->get_links('hub');
+				$this->hubUrl = isset($links[0]) ? $links[0] : null;
+
 				if ($loadDetails) {
 				if ($loadDetails) {
 					// si on a utilisé l'auto-discover, notre url va avoir changé
 					// si on a utilisé l'auto-discover, notre url va avoir changé
 					$subscribe_url = $feed->subscribe_url(false);
 					$subscribe_url = $feed->subscribe_url(false);
 
 
 					$title = strtr(html_only_entity_decode($feed->get_title()), array('<' => '&lt;', '>' => '&gt;', '"' => '&quot;'));	//HTML to HTML-PRE	//ENT_COMPAT except &
 					$title = strtr(html_only_entity_decode($feed->get_title()), array('<' => '&lt;', '>' => '&gt;', '"' => '&quot;'));	//HTML to HTML-PRE	//ENT_COMPAT except &
-					$this->_name($title == '' ? $this->url : $title);
+					$this->_name($title == '' ? $url : $title);
 
 
 					$this->_website(html_only_entity_decode($feed->get_link()));
 					$this->_website(html_only_entity_decode($feed->get_link()));
 					$this->_description(html_only_entity_decode($feed->get_description()));
 					$this->_description(html_only_entity_decode($feed->get_description()));
@@ -234,19 +276,16 @@ class FreshRSS_Feed extends Minz_Model {
 					$subscribe_url = $feed->subscribe_url(true);
 					$subscribe_url = $feed->subscribe_url(true);
 				}
 				}
 
 
-				if ($subscribe_url !== null && $subscribe_url !== $this->url) {
-					if ($this->httpAuth != '') {
-						// on enlève les id si authentification HTTP
-						$subscribe_url = preg_replace('#((.+)://)((.+)@)(.+)#', '${1}${5}', $subscribe_url);
-					}
-					$this->_url($subscribe_url);
+				$clean_url = SimplePie_Misc::url_remove_credentials($subscribe_url);
+				if ($subscribe_url !== null && $subscribe_url !== $url) {
+					$this->_url($clean_url);
 				}
 				}
 
 
-				if (($mtime === true) ||($mtime > $this->lastUpdate)) {
-					syslog(LOG_DEBUG, 'FreshRSS no cache ' . $mtime . ' > ' . $this->lastUpdate . ' for ' . $subscribe_url);
+				if (($mtime === true) || ($mtime > $this->lastUpdate) || $noCache) {
+					//Minz_Log::debug('FreshRSS no cache ' . $mtime . ' > ' . $this->lastUpdate . ' for ' . $clean_url);
 					$this->loadEntries($feed);	// et on charge les articles du flux
 					$this->loadEntries($feed);	// et on charge les articles du flux
 				} else {
 				} else {
-					syslog(LOG_DEBUG, 'FreshRSS use cache for ' . $subscribe_url);
+					//Minz_Log::debug('FreshRSS use cache for ' . $clean_url);
 					$this->entries = array();
 					$this->entries = array();
 				}
 				}
 
 
@@ -256,7 +295,7 @@ class FreshRSS_Feed extends Minz_Model {
 		}
 		}
 	}
 	}
 
 
-	private function loadEntries($feed) {
+	public function loadEntries($feed) {
 		$entries = array();
 		$entries = array();
 
 
 		foreach ($feed->get_items() as $item) {
 		foreach ($feed->get_items() as $item) {
@@ -283,11 +322,11 @@ class FreshRSS_Feed extends Minz_Model {
 					$elinks[$elink] = '1';
 					$elinks[$elink] = '1';
 					$mime = strtolower($enclosure->get_type());
 					$mime = strtolower($enclosure->get_type());
 					if (strpos($mime, 'image/') === 0) {
 					if (strpos($mime, 'image/') === 0) {
-						$content .= '<br /><img lazyload="" postpone="" src="' . $elink . '" alt="" />';
+						$content .= '<p class="enclosure"><img src="' . $elink . '" alt="" /></p>';
 					} elseif (strpos($mime, 'audio/') === 0) {
 					} elseif (strpos($mime, 'audio/') === 0) {
-						$content .= '<br /><audio lazyload="" postpone="" preload="none" src="' . $elink . '" controls="controls" />';
+						$content .= '<p class="enclosure"><audio preload="none" src="' . $elink . '" controls="controls"></audio> <a download="" href="' . $elink . '">💾</a></p>';
 					} elseif (strpos($mime, 'video/') === 0) {
 					} elseif (strpos($mime, 'video/') === 0) {
-						$content .= '<br /><video lazyload="" postpone="" preload="none" src="' . $elink . '" controls="controls" />';
+						$content .= '<p class="enclosure"><video preload="none" src="' . $elink . '" controls="controls"></video> <a download="" href="' . $elink . '">💾</a></p>';
 					} else {
 					} else {
 						unset($elinks[$elink]);
 						unset($elinks[$elink]);
 					}
 					}
@@ -314,6 +353,10 @@ class FreshRSS_Feed extends Minz_Model {
 		$this->entries = $entries;
 		$this->entries = $entries;
 	}
 	}
 
 
+	function cacheModifiedTime() {
+		return @filemtime(CACHE_PATH . '/' . md5($this->url) . '.spc');
+	}
+
 	function lock() {
 	function lock() {
 		$this->lockPath = TMP_PATH . '/' . $this->hash() . '.freshrss.lock';
 		$this->lockPath = TMP_PATH . '/' . $this->hash() . '.freshrss.lock';
 		if (file_exists($this->lockPath) && ((time() - @filemtime($this->lockPath)) > 3600)) {
 		if (file_exists($this->lockPath) && ((time() - @filemtime($this->lockPath)) > 3600)) {
@@ -330,4 +373,135 @@ class FreshRSS_Feed extends Minz_Model {
 	function unlock() {
 	function unlock() {
 		@unlink($this->lockPath);
 		@unlink($this->lockPath);
 	}
 	}
+
+	//<PubSubHubbub>
+
+	function pubSubHubbubEnabled() {
+		$url = $this->selfUrl ? $this->selfUrl : $this->url;
+		$hubFilename = PSHB_PATH . '/feeds/' . base64url_encode($url) . '/!hub.json';
+		if ($hubFile = @file_get_contents($hubFilename)) {
+			$hubJson = json_decode($hubFile, true);
+			if ($hubJson && empty($hubJson['error']) &&
+				(empty($hubJson['lease_end']) || $hubJson['lease_end'] > time())) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	function pubSubHubbubError($error = true) {
+		$url = $this->selfUrl ? $this->selfUrl : $this->url;
+		$hubFilename = PSHB_PATH . '/feeds/' . base64url_encode($url) . '/!hub.json';
+		$hubFile = @file_get_contents($hubFilename);
+		$hubJson = $hubFile ? json_decode($hubFile, true) : array();
+		if (!isset($hubJson['error']) || $hubJson['error'] !== (bool)$error) {
+			$hubJson['error'] = (bool)$error;
+			file_put_contents($hubFilename, json_encode($hubJson));
+			file_put_contents(USERS_PATH . '/_/log_pshb.txt', date('c') . "\t"
+				. 'Set error to ' . ($error ? 1 : 0) . ' for ' . $url . "\n", FILE_APPEND);
+		}
+		return false;
+	}
+
+	function pubSubHubbubPrepare() {
+		$key = '';
+		if (FreshRSS_Context::$system_conf->base_url && $this->hubUrl && $this->selfUrl && @is_dir(PSHB_PATH)) {
+			$path = PSHB_PATH . '/feeds/' . base64url_encode($this->selfUrl);
+			$hubFilename = $path . '/!hub.json';
+			if ($hubFile = @file_get_contents($hubFilename)) {
+				$hubJson = json_decode($hubFile, true);
+				if (!$hubJson || empty($hubJson['key']) || !ctype_xdigit($hubJson['key'])) {
+					$text = 'Invalid JSON for PubSubHubbub: ' . $this->url;
+					Minz_Log::warning($text);
+					file_put_contents(USERS_PATH . '/_/log_pshb.txt', date('c') . "\t" . $text . "\n", FILE_APPEND);
+					return false;
+				}
+				if ((!empty($hubJson['lease_end'])) && ($hubJson['lease_end'] < (time() + (3600 * 23)))) {	//TODO: Make a better policy
+					$text = 'PubSubHubbub lease ends at '
+						. date('c', empty($hubJson['lease_end']) ? time() : $hubJson['lease_end'])
+						. ' and needs renewal: ' . $this->url;
+					Minz_Log::warning($text);
+					file_put_contents(USERS_PATH . '/_/log_pshb.txt', date('c') . "\t" . $text . "\n", FILE_APPEND);
+					$key = $hubJson['key'];	//To renew our lease
+				} elseif (((!empty($hubJson['error'])) || empty($hubJson['lease_end'])) &&
+					(empty($hubJson['lease_start']) || $hubJson['lease_start'] < time() - (3600 * 23))) {	//Do not renew too often
+					$key = $hubJson['key'];	//To renew our lease
+				}
+			} else {
+				@mkdir($path, 0777, true);
+				$key = sha1($path . FreshRSS_Context::$system_conf->salt . uniqid(mt_rand(), true));
+				$hubJson = array(
+					'hub' => $this->hubUrl,
+					'key' => $key,
+				);
+				file_put_contents($hubFilename, json_encode($hubJson));
+				@mkdir(PSHB_PATH . '/keys/');
+				file_put_contents(PSHB_PATH . '/keys/' . $key . '.txt', base64url_encode($this->selfUrl));
+				$text = 'PubSubHubbub prepared for ' . $this->url;
+				Minz_Log::debug($text);
+				file_put_contents(USERS_PATH . '/_/log_pshb.txt', date('c') . "\t" . $text . "\n", FILE_APPEND);
+			}
+			$currentUser = Minz_Session::param('currentUser');
+			if (ctype_alnum($currentUser) && !file_exists($path . '/' . $currentUser . '.txt')) {
+				touch($path . '/' . $currentUser . '.txt');
+			}
+		}
+		return $key;
+	}
+
+	//Parameter true to subscribe, false to unsubscribe.
+	function pubSubHubbubSubscribe($state) {
+		if (FreshRSS_Context::$system_conf->base_url && $this->hubUrl && $this->selfUrl) {
+			$hubFilename = PSHB_PATH . '/feeds/' . base64url_encode($this->selfUrl) . '/!hub.json';
+			$hubFile = @file_get_contents($hubFilename);
+			if ($hubFile === false) {
+				Minz_Log::warning('JSON not found for PubSubHubbub: ' . $this->url);
+				return false;
+			}
+			$hubJson = json_decode($hubFile, true);
+			if (!$hubJson || empty($hubJson['key']) || !ctype_xdigit($hubJson['key'])) {
+				Minz_Log::warning('Invalid JSON for PubSubHubbub: ' . $this->url);
+				return false;
+			}
+			$callbackUrl = checkUrl(Minz_Request::getBaseUrl() . '/api/pshb.php?k=' . $hubJson['key']);
+			if ($callbackUrl == '') {
+				Minz_Log::warning('Invalid callback for PubSubHubbub: ' . $this->url);
+				return false;
+			}
+			if (!$state) {	//unsubscribe
+				$hubJson['lease_end'] = time() - 60;
+				file_put_contents($hubFilename, json_encode($hubJson));
+			}
+			$ch = curl_init();
+			curl_setopt_array($ch, array(
+				CURLOPT_URL => $this->hubUrl,
+				CURLOPT_FOLLOWLOCATION => true,
+				CURLOPT_RETURNTRANSFER => true,
+				CURLOPT_USERAGENT => 'FreshRSS/' . FRESHRSS_VERSION . ' (' . PHP_OS . '; ' . FRESHRSS_WEBSITE . ')',
+				CURLOPT_POSTFIELDS => 'hub.verify=sync'
+					. '&hub.mode=' . ($state ? 'subscribe' : 'unsubscribe')
+					. '&hub.topic=' . urlencode($this->selfUrl)
+					. '&hub.callback=' . urlencode($callbackUrl)
+				)
+			);
+			$response = curl_exec($ch);
+			$info = curl_getinfo($ch);
+
+			file_put_contents(USERS_PATH . '/_/log_pshb.txt', date('c') . "\t" .
+				'PubSubHubbub ' . ($state ? 'subscribe' : 'unsubscribe') . ' to ' . $this->selfUrl .
+				' with callback ' . $callbackUrl . ': ' . $info['http_code'] . ' ' . $response . "\n", FILE_APPEND);
+
+			if (substr($info['http_code'], 0, 1) == '2') {
+				return true;
+			} else {
+				$hubJson['lease_start'] = time();	//Prevent trying again too soon
+				$hubJson['error'] = true;
+				file_put_contents($hubFilename, json_encode($hubJson));
+				return false;
+			}
+		}
+		return false;
+	}
+
+	//</PubSubHubbub>
 }
 }

+ 51 - 27
app/Models/FeedDAO.php

@@ -1,10 +1,13 @@
 <?php
 <?php
 
 
-class FreshRSS_FeedDAO extends Minz_ModelPdo {
+class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	public function addFeed($valuesTmp) {
 	public function addFeed($valuesTmp) {
-		$sql = 'INSERT INTO `' . $this->prefix . 'feed` (url, category, name, website, description, lastUpdate, priority, httpAuth, error, keep_history, ttl) VALUES(?, ?, ?, ?, ?, ?, 10, ?, 0, -2, -2)';
+		$sql = 'INSERT INTO `' . $this->prefix . 'feed` (url, category, name, website, description, `lastUpdate`, priority, `httpAuth`, error, keep_history, ttl) VALUES(?, ?, ?, ?, ?, ?, 10, ?, 0, -2, -2)';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
+		$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
+		$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
+
 		$values = array(
 		$values = array(
 			substr($valuesTmp['url'], 0, 511),
 			substr($valuesTmp['url'], 0, 511),
 			$valuesTmp['category'],
 			$valuesTmp['category'],
@@ -16,7 +19,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		);
 		);
 
 
 		if ($stm && $stm->execute($values)) {
 		if ($stm && $stm->execute($values)) {
-			return $this->bd->lastInsertId();
+			return $this->bd->lastInsertId('"' . $this->prefix . 'feed_id_seq"');
 		} else {
 		} else {
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			Minz_Log::error('SQL error addFeed: ' . $info[2]);
 			Minz_Log::error('SQL error addFeed: ' . $info[2]);
@@ -55,6 +58,13 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 	}
 	}
 
 
 	public function updateFeed($id, $valuesTmp) {
 	public function updateFeed($id, $valuesTmp) {
+		if (isset($valuesTmp['url'])) {
+			$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
+		}
+		if (isset($valuesTmp['website'])) {
+			$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
+		}
+
 		$set = '';
 		$set = '';
 		foreach ($valuesTmp as $key => $v) {
 		foreach ($valuesTmp as $key => $v) {
 			$set .= $key . '=?, ';
 			$set .= $key . '=?, ';
@@ -82,22 +92,26 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		}
 		}
 	}
 	}
 
 
-	public function updateLastUpdate($id, $inError = 0, $updateCache = true) {
+	public function updateLastUpdate($id, $inError = false, $updateCache = true, $mtime = 0) {
 		if ($updateCache) {
 		if ($updateCache) {
 			$sql = 'UPDATE `' . $this->prefix . 'feed` '	//2 sub-requests with FOREIGN KEY(e.id_feed), INDEX(e.is_read) faster than 1 request with GROUP BY or CASE
 			$sql = 'UPDATE `' . $this->prefix . 'feed` '	//2 sub-requests with FOREIGN KEY(e.id_feed), INDEX(e.is_read) faster than 1 request with GROUP BY or CASE
-			     . 'SET cache_nbEntries=(SELECT COUNT(e1.id) FROM `' . $this->prefix . 'entry` e1 WHERE e1.id_feed=`' . $this->prefix . 'feed`.id),'
-			     . 'cache_nbUnreads=(SELECT COUNT(e2.id) FROM `' . $this->prefix . 'entry` e2 WHERE e2.id_feed=`' . $this->prefix . 'feed`.id AND e2.is_read=0),'
-			     . 'lastUpdate=?, error=? '
+			     . 'SET `cache_nbEntries`=(SELECT COUNT(e1.id) FROM `' . $this->prefix . 'entry` e1 WHERE e1.id_feed=`' . $this->prefix . 'feed`.id),'
+			     . '`cache_nbUnreads`=(SELECT COUNT(e2.id) FROM `' . $this->prefix . 'entry` e2 WHERE e2.id_feed=`' . $this->prefix . 'feed`.id AND e2.is_read=0),'
+			     . '`lastUpdate`=?, error=? '
 			     . 'WHERE id=?';
 			     . 'WHERE id=?';
 		} else {
 		} else {
 			$sql = 'UPDATE `' . $this->prefix . 'feed` '
 			$sql = 'UPDATE `' . $this->prefix . 'feed` '
-			     . 'SET lastUpdate=?, error=? '
+			     . 'SET `lastUpdate`=?, error=? '
 			     . 'WHERE id=?';
 			     . 'WHERE id=?';
 		}
 		}
 
 
+		if ($mtime <= 0) {
+			$mtime = time();
+		}
+
 		$values = array(
 		$values = array(
-			time(),
-			$inError,
+			$mtime,
+			$inError ? 1 : 0,
 			$id,
 			$id,
 		);
 		);
 
 
@@ -191,13 +205,20 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$feed = current(self::daoToFeed($res));
 		$feed = current(self::daoToFeed($res));
 
 
-		if (isset($feed)) {
+		if (isset($feed) && $feed !== false) {
 			return $feed;
 			return $feed;
 		} else {
 		} else {
 			return null;
 			return null;
 		}
 		}
 	}
 	}
 
 
+	public function listFeedsIds() {
+		$sql = 'SELECT id FROM `' . $this->prefix . 'feed`';
+		$stm = $this->bd->prepare($sql);
+		$stm->execute();
+		return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+	}
+
 	public function listFeeds() {
 	public function listFeeds() {
 		$sql = 'SELECT * FROM `' . $this->prefix . 'feed` ORDER BY name';
 		$sql = 'SELECT * FROM `' . $this->prefix . 'feed` ORDER BY name';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
@@ -222,14 +243,14 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		return $feedCategoryNames;
 		return $feedCategoryNames;
 	}
 	}
 
 
+	/**
+	 * Use $defaultCacheDuration == -1 to return all feeds, without filtering them by TTL.
+	 */
 	public function listFeedsOrderUpdate($defaultCacheDuration = 3600) {
 	public function listFeedsOrderUpdate($defaultCacheDuration = 3600) {
-		if ($defaultCacheDuration < 0) {
-			$defaultCacheDuration = 2147483647;
-		}
-		$sql = 'SELECT id, url, name, website, lastUpdate, pathEntries, httpAuth, keep_history, ttl '
+		$sql = 'SELECT id, url, name, website, `lastUpdate`, `pathEntries`, `httpAuth`, keep_history, ttl '
 		     . 'FROM `' . $this->prefix . 'feed` '
 		     . 'FROM `' . $this->prefix . 'feed` '
-		     . 'WHERE ttl <> -1 AND lastUpdate < (' . (time() + 60) . '-(CASE WHEN ttl=-2 THEN ' . intval($defaultCacheDuration) . ' ELSE ttl END)) '
-		     . 'ORDER BY lastUpdate';
+		     . ($defaultCacheDuration < 0 ? '' : 'WHERE ttl <> -1 AND `lastUpdate` < (' . (time() + 60) . '-(CASE WHEN ttl=-2 THEN ' . intval($defaultCacheDuration) . ' ELSE ttl END)) ')
+		     . 'ORDER BY `lastUpdate`';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 		if (!($stm && $stm->execute())) {
 		if (!($stm && $stm->execute())) {
 			$sql2 = 'ALTER TABLE `' . $this->prefix . 'feed` ADD COLUMN ttl INT NOT NULL DEFAULT -2';	//v0.7.3
 			$sql2 = 'ALTER TABLE `' . $this->prefix . 'feed` ADD COLUMN ttl INT NOT NULL DEFAULT -2';	//v0.7.3
@@ -282,7 +303,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		     .	'FROM `' . $this->prefix . 'entry` e '
 		     .	'FROM `' . $this->prefix . 'entry` e '
 		     .	'GROUP BY e.id_feed'
 		     .	'GROUP BY e.id_feed'
 		     . ') x ON x.id_feed=f.id '
 		     . ') x ON x.id_feed=f.id '
-		     . 'SET f.cache_nbEntries=x.nbEntries, f.cache_nbUnreads=x.nbUnreads';
+		     . 'SET f.`cache_nbEntries`=x.nbEntries, f.`cache_nbUnreads`=x.nbUnreads';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
 		if ($stm && $stm->execute()) {
 		if ($stm && $stm->execute()) {
@@ -308,7 +329,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		$affected = $stm->rowCount();
 		$affected = $stm->rowCount();
 
 
 		$sql = 'UPDATE `' . $this->prefix . 'feed` '
 		$sql = 'UPDATE `' . $this->prefix . 'feed` '
-			 . 'SET cache_nbEntries=0, cache_nbUnreads=0 WHERE id=?';
+			 . 'SET `cache_nbEntries`=0, `cache_nbUnreads`=0 WHERE id=?';
 		$values = array($id);
 		$values = array($id);
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 		if (!($stm && $stm->execute($values))) {
 		if (!($stm && $stm->execute($values))) {
@@ -322,17 +343,20 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		return $affected;
 		return $affected;
 	}
 	}
 
 
-	public function cleanOldEntries($id, $date_min, $keep = 15) {	//Remember to call updateLastUpdate($id) just after
+	public function cleanOldEntries($id, $date_min, $keep = 15) {	//Remember to call updateLastUpdate($id) or updateCachedValues() just after
 		$sql = 'DELETE FROM `' . $this->prefix . 'entry` '
 		$sql = 'DELETE FROM `' . $this->prefix . 'entry` '
-		     . 'WHERE id_feed = :id_feed AND id <= :id_max AND is_favorite=0 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 yet support 'LIMIT & IN/ALL/ANY/SOME subquery'
+		     . '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 FROM (SELECT e2.id FROM `' . $this->prefix . 'entry` e2 WHERE e2.id_feed=:id_feed ORDER BY id DESC LIMIT :keep) keep)';	//Double select: MySQL doesn't support 'LIMIT & IN/ALL/ANY/SOME subquery'
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 
 
-		$id_max = intval($date_min) . '000000';
-
-		$stm->bindParam(':id_feed', $id, PDO::PARAM_INT);
-		$stm->bindParam(':id_max', $id_max, PDO::PARAM_STR);
-		$stm->bindParam(':keep', $keep, PDO::PARAM_INT);
+		if ($stm) {
+			$id_max = intval($date_min) . '000000';
+			$stm->bindParam(':id_feed', $id, PDO::PARAM_INT);
+			$stm->bindParam(':id_max', $id_max, PDO::PARAM_STR);
+			$stm->bindParam(':keep', $keep, PDO::PARAM_INT);
+		}
 
 
 		if ($stm && $stm->execute()) {
 		if ($stm && $stm->execute()) {
 			return $stm->rowCount();
 			return $stm->rowCount();

+ 2 - 2
app/Models/FeedDAOSQLite.php

@@ -4,8 +4,8 @@ class FreshRSS_FeedDAOSQLite extends FreshRSS_FeedDAO {
 
 
 	public function updateCachedValues() {	//For one single feed, call updateLastUpdate($id)
 	public function updateCachedValues() {	//For one single feed, call updateLastUpdate($id)
 		$sql = 'UPDATE `' . $this->prefix . 'feed` '
 		$sql = 'UPDATE `' . $this->prefix . 'feed` '
-		     . 'SET cache_nbEntries=(SELECT COUNT(e1.id) FROM `' . $this->prefix . 'entry` e1 WHERE e1.id_feed=`' . $this->prefix . 'feed`.id),'
-		     . 'cache_nbUnreads=(SELECT COUNT(e2.id) FROM `' . $this->prefix . 'entry` e2 WHERE e2.id_feed=`' . $this->prefix . 'feed`.id AND e2.is_read=0)';
+		     . 'SET `cache_nbEntries`=(SELECT COUNT(e1.id) FROM `' . $this->prefix . 'entry` e1 WHERE e1.id_feed=`' . $this->prefix . 'feed`.id),'
+		     . '`cache_nbUnreads`=(SELECT COUNT(e2.id) FROM `' . $this->prefix . 'entry` e2 WHERE e2.id_feed=`' . $this->prefix . 'feed`.id AND e2.is_read=0)';
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 		if ($stm && $stm->execute()) {
 		if ($stm && $stm->execute()) {
 			return $stm->rowCount();
 			return $stm->rowCount();

+ 7 - 2
app/Models/LogDAO.php

@@ -3,7 +3,7 @@
 class FreshRSS_LogDAO {
 class FreshRSS_LogDAO {
 	public static function lines() {
 	public static function lines() {
 		$logs = array();
 		$logs = array();
-		$handle = @fopen(LOG_PATH . '/' . Minz_Session::param('currentUser', '_') . '.log', 'r');
+		$handle = @fopen(join_path(DATA_PATH, 'users', Minz_Session::param('currentUser', '_'), 'log.txt'), 'r');
 		if ($handle) {
 		if ($handle) {
 			while (($line = fgets($handle)) !== false) {
 			while (($line = fgets($handle)) !== false) {
 				if (preg_match('/^\[([^\[]+)\] \[([^\[]+)\] --- (.*)$/', $line, $matches)) {
 				if (preg_match('/^\[([^\[]+)\] \[([^\[]+)\] --- (.*)$/', $line, $matches)) {
@@ -20,6 +20,11 @@ class FreshRSS_LogDAO {
 	}
 	}
 
 
 	public static function truncate() {
 	public static function truncate() {
-		file_put_contents(LOG_PATH . '/' . Minz_Session::param('currentUser', '_') . '.log', '');
+		file_put_contents(join_path(DATA_PATH, 'users', Minz_Session::param('currentUser', '_'), 'log.txt'), '');
+		if (FreshRSS_Auth::hasAccess('admin')) {
+			file_put_contents(join_path(DATA_PATH, 'users', '_', 'log.txt'), '');
+			file_put_contents(join_path(DATA_PATH, 'users', '_', 'log_api.txt'), '');
+			file_put_contents(join_path(DATA_PATH, 'users', '_', 'log_pshb.txt'), '');
+		}
 	}
 	}
 }
 }

+ 229 - 0
app/Models/Search.php

@@ -0,0 +1,229 @@
+<?php
+
+require_once(LIB_PATH . '/lib_date.php');
+
+/**
+ * Contains a search from the search form.
+ *
+ * It allows to extract meaningful bits of the search and store them in a
+ * convenient object
+ */
+class FreshRSS_Search {
+
+	// This contains the user input string
+	private $raw_input = '';
+	// The following properties are extracted from the raw input
+	private $intitle;
+	private $min_date;
+	private $max_date;
+	private $min_pubdate;
+	private $max_pubdate;
+	private $inurl;
+	private $author;
+	private $tags;
+	private $search;
+
+	public function __construct($input) {
+		if (strcmp($input, '') == 0) {
+			return;
+		}
+		$this->raw_input = $input;
+		$input = $this->parseIntitleSearch($input);
+		$input = $this->parseAuthorSearch($input);
+		$input = $this->parseInurlSearch($input);
+		$input = $this->parsePubdateSearch($input);
+		$input = $this->parseDateSearch($input);
+		$input = $this->parseTagsSeach($input);
+		$this->parseSearch($input);
+	}
+
+	public function __toString() {
+		return $this->getRawInput();
+	}
+
+	public function getRawInput() {
+		return $this->raw_input;
+	}
+
+	public function getIntitle() {
+		return $this->intitle;
+	}
+
+	public function getMinDate() {
+		return $this->min_date;
+	}
+
+	public function getMaxDate() {
+		return $this->max_date;
+	}
+
+	public function getMinPubdate() {
+		return $this->min_pubdate;
+	}
+
+	public function getMaxPubdate() {
+		return $this->max_pubdate;
+	}
+
+	public function getInurl() {
+		return $this->inurl;
+	}
+
+	public function getAuthor() {
+		return $this->author;
+	}
+
+	public function getTags() {
+		return $this->tags;
+	}
+
+	public function getSearch() {
+		return $this->search;
+	}
+
+	/**
+	 * Parse the search string to find intitle keyword and the search related
+	 * to it.
+	 * The search is the first word following the keyword.
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function parseIntitleSearch($input) {
+		if (preg_match('/intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->intitle = $matches['search'];
+			return str_replace($matches[0], '', $input);
+		}
+		if (preg_match('/intitle:(?P<search>\w*)/', $input, $matches)) {
+			$this->intitle = $matches['search'];
+			return str_replace($matches[0], '', $input);
+		}
+		return $input;
+	}
+
+	/**
+	 * Parse the search string to find author keyword and the search related
+	 * to it.
+	 * The search is the first word following the keyword except when using
+	 * a delimiter. Supported delimiters are single quote (') and double
+	 * quotes (").
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function parseAuthorSearch($input) {
+		if (preg_match('/author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->author = $matches['search'];
+			return str_replace($matches[0], '', $input);
+		}
+		if (preg_match('/author:(?P<search>\w*)/', $input, $matches)) {
+			$this->author = $matches['search'];
+			return str_replace($matches[0], '', $input);
+		}
+		return $input;
+	}
+
+	/**
+	 * Parse the search string to find inurl keyword and the search related
+	 * to it.
+	 * The search is the first word following the keyword except.
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function parseInurlSearch($input) {
+		if (preg_match('/inurl:(?P<search>[^\s]*)/', $input, $matches)) {
+			$this->inurl = $matches['search'];
+			return str_replace($matches[0], '', $input);
+		}
+		return $input;
+	}
+
+	/**
+	 * Parse the search string to find date keyword and the search related
+	 * to it.
+	 * The search is the first word following the keyword.
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function parseDateSearch($input) {
+		if (preg_match('/date:(?P<search>[^\s]*)/', $input, $matches)) {
+			list($this->min_date, $this->max_date) = parseDateInterval($matches['search']);
+			return str_replace($matches[0], '', $input);
+		}
+		return $input;
+	}
+
+	/**
+	 * Parse the search string to find pubdate keyword and the search related
+	 * to it.
+	 * The search is the first word following the keyword.
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function parsePubdateSearch($input) {
+		if (preg_match('/pubdate:(?P<search>[^\s]*)/', $input, $matches)) {
+			list($this->min_pubdate, $this->max_pubdate) = parseDateInterval($matches['search']);
+			return str_replace($matches[0], '', $input);
+		}
+		return $input;
+	}
+
+	/**
+	 * Parse the search string to find tags keyword (# followed by a word)
+	 * and the search related to it.
+	 * The search is the first word following the #.
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function parseTagsSeach($input) {
+		if (preg_match_all('/#(?P<search>[^\s]+)/', $input, $matches)) {
+			$this->tags = $matches['search'];
+			return str_replace($matches[0], '', $input);
+		}
+		return $input;
+	}
+
+	/**
+	 * Parse the search string to find search values.
+	 * Every word is a distinct search value, except when using a delimiter.
+	 * Supported delimiters are single quote (') and double quotes (").
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function parseSearch($input) {
+		$input = $this->cleanSearch($input);
+		if (strcmp($input, '') == 0) {
+			return;
+		}
+		if (preg_match_all('/(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->search = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		$input = $this->cleanSearch($input);
+		if (strcmp($input, '') == 0) {
+			return;
+		}
+		if (is_array($this->search)) {
+			$this->search = array_merge($this->search, explode(' ', $input));
+		} else {
+			$this->search = explode(' ', $input);
+		}
+	}
+
+	/**
+	 * Remove all unnecessary spaces in the search
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function cleanSearch($input) {
+		$input = preg_replace('/\s+/', ' ', $input);
+		return trim($input);
+	}
+
+}

+ 6 - 0
app/Models/Searchable.php

@@ -0,0 +1,6 @@
+<?php
+
+interface FreshRSS_Searchable {
+
+	public function searchById($id);
+}

+ 212 - 18
app/Models/Share.php

@@ -1,44 +1,238 @@
 <?php
 <?php
 
 
+/**
+ * Manage the sharing options in FreshRSS.
+ */
 class FreshRSS_Share {
 class FreshRSS_Share {
+	/**
+	 * The list of available sharing options.
+	 */
+	private static $list_sharing = array();
 
 
-	static public function generateUrl($options, $selected, $link, $title) {
-		$share = $options[$selected['type']];
+	/**
+	 * Register a new sharing option.
+	 * @param $share_options is an array defining the share option.
+	 */
+	public static function register($share_options) {
+		$type = $share_options['type'];
+
+		if (isset(self::$list_sharing[$type])) {
+			return;
+		}
+
+		$help_url = isset($share_options['help']) ? $share_options['help'] : '';
+		self::$list_sharing[$type] = new FreshRSS_Share(
+			$type, $share_options['url'], $share_options['transform'],
+			$share_options['form'], $help_url
+		);
+	}
+
+	/**
+	 * Register sharing options in a file.
+	 * @param $filename the name of the file to load.
+	 */
+	public static function load($filename) {
+		$shares_from_file = @include($filename);
+		if (!is_array($shares_from_file)) {
+			$shares_from_file = array();
+		}
+
+		foreach ($shares_from_file as $share_type => $share_options) {
+			$share_options['type'] = $share_type;
+			self::register($share_options);
+		}
+	}
+
+	/**
+	 * Return the list of sharing options.
+	 * @return an array of FreshRSS_Share objects.
+	 */
+	public static function enum() {
+		return self::$list_sharing;
+	}
+
+	/**
+	 * Return FreshRSS_Share object related to the given type.
+	 * @param $type the share type, null if $type is not registered.
+	 */
+	public static function get($type) {
+		if (!isset(self::$list_sharing[$type])) {
+			return null;
+		}
+
+		return self::$list_sharing[$type];
+	}
+
+	/**
+	 *
+	 */
+	private $type = '';
+	private $name = '';
+	private $url_transform = '';
+	private $transform = array();
+	private $form_type = 'simple';
+	private $help_url = '';
+	private $custom_name = null;
+	private $base_url = null;
+	private $title = null;
+	private $link = null;
+
+	/**
+	 * Create a FreshRSS_Share object.
+	 * @param $type is a unique string defining the kind of share option.
+	 * @param $url_transform defines the url format to use in order to share.
+	 * @param $transform is an array of transformations to apply on link and title.
+	 * @param $form_type defines which form we have to use to complete. "simple"
+	 *        is typically for a centralized service while "advanced" is for
+	 *        decentralized ones.
+	 * @param $help_url is an optional url to give help on this option.
+	 */
+	private function __construct($type, $url_transform, $transform = array(),
+	                             $form_type, $help_url = '') {
+		$this->type = $type;
+		$this->name = _t('gen.share.' . $type);
+		$this->url_transform = $url_transform;
+		$this->help_url = $help_url;
+
+		if (!is_array($transform)) {
+			$transform = array();
+		}
+		$this->transform = $transform;
+
+		if (!in_array($form_type, array('simple', 'advanced'))) {
+			$form_type = 'simple';
+		}
+		$this->form_type = $form_type;
+	}
+
+	/**
+	 * Update a FreshRSS_Share object with information from an array.
+	 * @param $options is a list of informations to update where keys should be
+	 *        in this list: name, url, title, link.
+	 */
+	public function update($options) {
+		$available_options = array(
+			'name' => 'custom_name',
+			'url' => 'base_url',
+			'title' => 'title',
+			'link' => 'link',
+		);
+
+		foreach ($options as $key => $value) {
+			if (isset($available_options[$key])) {
+				$this->{$available_options[$key]} = $value;
+			}
+		}
+	}
+
+	/**
+	 * Return the current type of the share option.
+	 */
+	public function type() {
+		return $this->type;
+	}
+
+	/**
+	 * Return the current form type of the share option.
+	 */
+	public function formType() {
+		return $this->form_type;
+	}
+
+	/**
+	 * Return the current help url of the share option.
+	 */
+	public function help() {
+		return $this->help_url;
+	}
+
+	/**
+	 * Return the current name of the share option.
+	 */
+	public function name($real = false) {
+		if ($real || is_null($this->custom_name) || empty($this->custom_name)) {
+			return $this->name;
+		} else {
+			return $this->custom_name;
+		}
+	}
+
+	/**
+	 * Return the current base url of the share option.
+	 */
+	public function baseUrl() {
+		return $this->base_url;
+	}
+
+	/**
+	 * Return the current url by merging url_transform and base_url.
+	 */
+	public function url() {
 		$matches = array(
 		$matches = array(
 			'~URL~',
 			'~URL~',
 			'~TITLE~',
 			'~TITLE~',
 			'~LINK~',
 			'~LINK~',
 		);
 		);
 		$replaces = array(
 		$replaces = array(
-			$selected['url'],
-			self::transformData($title, self::getTransform($share, 'title')),
-			self::transformData($link, self::getTransform($share, 'link')),
+			$this->base_url,
+			$this->title(),
+			$this->link(),
 		);
 		);
-		$url = str_replace($matches, $replaces, $share['url']);
-		return $url;
+		return str_replace($matches, $replaces, $this->url_transform);
 	}
 	}
 
 
-	static private function transformData($data, $transform) {
-		if (!is_array($transform)) {
-			return $data;
+	/**
+	 * Return the title.
+	 * @param $raw true if we should get the title without transformations.
+	 */
+	public function title($raw = false) {
+		if ($raw) {
+			return $this->title;
 		}
 		}
-		if (count($transform) === 0) {
+
+		return $this->transform($this->title, $this->getTransform('title'));
+	}
+
+	/**
+	 * Return the link.
+	 * @param $raw true if we should get the link without transformations.
+	 */
+	public function link($raw = false) {
+		if ($raw) {
+			return $this->link;
+		}
+
+		return $this->transform($this->link, $this->getTransform('link'));
+	}
+
+	/**
+	 * Transform a data with the given functions.
+	 * @param $data the data to transform.
+	 * @param $tranform an array containing a list of functions to apply.
+	 * @return the transformed data.
+	 */
+	private static function transform($data, $transform) {
+		if (!is_array($transform) || empty($transform)) {
 			return $data;
 			return $data;
 		}
 		}
+
 		foreach ($transform as $action) {
 		foreach ($transform as $action) {
 			$data = call_user_func($action, $data);
 			$data = call_user_func($action, $data);
 		}
 		}
+
 		return $data;
 		return $data;
 	}
 	}
 
 
-	static private function getTransform($options, $type) {
-		$transform = $options['transform'];
-
-		if (array_key_exists($type, $transform)) {
-			return $transform[$type];
+	/**
+	 * Get the list of transformations for the given attribute.
+	 * @param $attr the attribute of which we want the transformations.
+	 * @return an array containing a list of transformations to apply.
+	 */
+	private function getTransform($attr) {
+		if (array_key_exists($attr, $this->transform)) {
+			return $this->transform[$attr];
 		}
 		}
 
 
-		return $transform;
+		return $this->transform;
 	}
 	}
-
 }
 }

+ 71 - 104
app/Models/StatsDAO.php

@@ -4,67 +4,77 @@ class FreshRSS_StatsDAO extends Minz_ModelPdo {
 
 
 	const ENTRY_COUNT_PERIOD = 30;
 	const ENTRY_COUNT_PERIOD = 30;
 
 
+	protected function sqlFloor($s) {
+		return "FLOOR($s)";
+	}
+
 	/**
 	/**
 	 * Calculates entry repartition for all feeds and for main stream.
 	 * Calculates entry repartition for all feeds and for main stream.
+	 *
+	 * @return array
+	 */
+	public function calculateEntryRepartition() {
+		return array(
+		    'main_stream' => $this->calculateEntryRepartitionPerFeed(null, true),
+		    'all_feeds' => $this->calculateEntryRepartitionPerFeed(null, false),
+		);
+	}
+
+	/**
+	 * Calculates entry repartition for the selection.
 	 * The repartition includes:
 	 * The repartition includes:
 	 *   - total entries
 	 *   - total entries
 	 *   - read entries
 	 *   - read entries
 	 *   - unread entries
 	 *   - unread entries
 	 *   - favorite entries
 	 *   - favorite entries
 	 *
 	 *
-	 * @return type
+	 * @param null|integer $feed feed id
+	 * @param boolean $only_main
+	 * @return array
 	 */
 	 */
-	public function calculateEntryRepartition() {
-		$repartition = array();
-
-		// Generates the repartition for the main stream of entry
+	public function calculateEntryRepartitionPerFeed($feed = null, $only_main = false) {
+		$filter = '';
+		if ($only_main) {
+			$filter .= 'AND f.priority = 10';
+		}
+		if (!is_null($feed)) {
+			$filter .= "AND e.id_feed = {$feed}";
+		}
 		$sql = <<<SQL
 		$sql = <<<SQL
-SELECT COUNT(1) AS `total`,
-COUNT(1) - SUM(e.is_read) AS `unread`,
-SUM(e.is_read) AS `read`,
-SUM(e.is_favorite) AS `favorite`
-FROM {$this->prefix}entry AS e
-, {$this->prefix}feed AS f
+SELECT COUNT(1) AS total,
+COUNT(1) - SUM(e.is_read) AS count_unreads,
+SUM(e.is_read) AS count_reads,
+SUM(e.is_favorite) AS count_favorites
+FROM `{$this->prefix}entry` AS e
+, `{$this->prefix}feed` AS f
 WHERE e.id_feed = f.id
 WHERE e.id_feed = f.id
-AND f.priority = 10
-SQL;
-		$stm = $this->bd->prepare($sql);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		$repartition['main_stream'] = $res[0];
-
-		// Generates the repartition for all entries
-		$sql = <<<SQL
-SELECT COUNT(1) AS `total`,
-COUNT(1) - SUM(e.is_read) AS `unread`,
-SUM(e.is_read) AS `read`,
-SUM(e.is_favorite) AS `favorite`
-FROM {$this->prefix}entry AS e
+{$filter}
 SQL;
 SQL;
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
 		$stm->execute();
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		$repartition['all_feeds'] = $res[0];
 
 
-		return $repartition;
+		return $res[0];
 	}
 	}
 
 
 	/**
 	/**
 	 * Calculates entry count per day on a 30 days period.
 	 * Calculates entry count per day on a 30 days period.
-	 * Returns the result as a JSON string.
+	 * Returns the result as a JSON object.
 	 *
 	 *
-	 * @return string
+	 * @return JSON object
 	 */
 	 */
 	public function calculateEntryCount() {
 	public function calculateEntryCount() {
 		$count = $this->initEntryCountArray();
 		$count = $this->initEntryCountArray();
-		$period = self::ENTRY_COUNT_PERIOD;
+		$midnight = mktime(0, 0, 0);
+		$oldest = $midnight - (self::ENTRY_COUNT_PERIOD * 86400);
 
 
 		// Get stats per day for the last 30 days
 		// Get stats per day for the last 30 days
+		$sqlDay = $this->sqlFloor("(date - $midnight) / 86400");
 		$sql = <<<SQL
 		$sql = <<<SQL
-SELECT DATEDIFF(FROM_UNIXTIME(e.date), NOW()) AS day,
-COUNT(1) AS count
-FROM {$this->prefix}entry AS e
-WHERE FROM_UNIXTIME(e.date, '%Y%m%d') BETWEEN DATE_FORMAT(DATE_ADD(NOW(), INTERVAL -{$period} DAY), '%Y%m%d') AND DATE_FORMAT(DATE_ADD(NOW(), INTERVAL -1 DAY), '%Y%m%d')
+SELECT {$sqlDay} AS day,
+COUNT(*) as count
+FROM `{$this->prefix}entry`
+WHERE date >= {$oldest} AND date < {$midnight}
 GROUP BY day
 GROUP BY day
 ORDER BY day ASC
 ORDER BY day ASC
 SQL;
 SQL;
@@ -76,28 +86,7 @@ SQL;
 			$count[$value['day']] = (int) $value['count'];
 			$count[$value['day']] = (int) $value['count'];
 		}
 		}
 
 
-		return $this->convertToSerie($count);
-	}
-
-	/**
-	 * Calculates entry average per day on a 30 days period.
-	 *
-	 * @return integer
-	 */
-	public function calculateEntryAverage() {
-		$period = self::ENTRY_COUNT_PERIOD;
-
-		// Get stats per day for the last 30 days
-		$sql = <<<SQL
-SELECT COUNT(1) / {$period} AS average
-FROM {$this->prefix}entry AS e
-WHERE FROM_UNIXTIME(e.date, '%Y%m%d') BETWEEN DATE_FORMAT(DATE_ADD(NOW(), INTERVAL -{$period} DAY), '%Y%m%d') AND DATE_FORMAT(DATE_ADD(NOW(), INTERVAL -1 DAY), '%Y%m%d')
-SQL;
-		$stm = $this->bd->prepare($sql);
-		$stm->execute();
-		$res = $stm->fetch(PDO::FETCH_NAMED);
-
-		return round($res['average'], 2);
+		return $count;
 	}
 	}
 
 
 	/**
 	/**
@@ -147,15 +136,14 @@ SQL;
 	 * @return string
 	 * @return string
 	 */
 	 */
 	protected function calculateEntryRepartitionPerFeedPerPeriod($period, $feed = null) {
 	protected function calculateEntryRepartitionPerFeedPerPeriod($period, $feed = null) {
+		$restrict = '';
 		if ($feed) {
 		if ($feed) {
 			$restrict = "WHERE e.id_feed = {$feed}";
 			$restrict = "WHERE e.id_feed = {$feed}";
-		} else {
-			$restrict = '';
 		}
 		}
 		$sql = <<<SQL
 		$sql = <<<SQL
 SELECT DATE_FORMAT(FROM_UNIXTIME(e.date), '{$period}') AS period
 SELECT DATE_FORMAT(FROM_UNIXTIME(e.date), '{$period}') AS period
 , COUNT(1) AS count
 , COUNT(1) AS count
-FROM {$this->prefix}entry AS e
+FROM `{$this->prefix}entry` AS e
 {$restrict}
 {$restrict}
 GROUP BY period
 GROUP BY period
 ORDER BY period ASC
 ORDER BY period ASC
@@ -165,11 +153,12 @@ SQL;
 		$stm->execute();
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_NAMED);
 		$res = $stm->fetchAll(PDO::FETCH_NAMED);
 
 
+		$repartition = array();
 		foreach ($res as $value) {
 		foreach ($res as $value) {
 			$repartition[(int) $value['period']] = (int) $value['count'];
 			$repartition[(int) $value['period']] = (int) $value['count'];
 		}
 		}
 
 
-		return $this->convertToSerie($repartition);
+		return $repartition;
 	}
 	}
 
 
 	/**
 	/**
@@ -179,7 +168,7 @@ SQL;
 	 * @return integer
 	 * @return integer
 	 */
 	 */
 	public function calculateEntryAveragePerFeedPerHour($feed = null) {
 	public function calculateEntryAveragePerFeedPerHour($feed = null) {
-		return $this->calculateEntryAveragePerFeedPerPeriod(1/24, $feed);
+		return $this->calculateEntryAveragePerFeedPerPeriod(1 / 24, $feed);
 	}
 	}
 
 
 	/**
 	/**
@@ -210,16 +199,15 @@ SQL;
 	 * @return integer
 	 * @return integer
 	 */
 	 */
 	protected function calculateEntryAveragePerFeedPerPeriod($period, $feed = null) {
 	protected function calculateEntryAveragePerFeedPerPeriod($period, $feed = null) {
+		$restrict = '';
 		if ($feed) {
 		if ($feed) {
 			$restrict = "WHERE e.id_feed = {$feed}";
 			$restrict = "WHERE e.id_feed = {$feed}";
-		} else {
-			$restrict = '';
 		}
 		}
 		$sql = <<<SQL
 		$sql = <<<SQL
 SELECT COUNT(1) AS count
 SELECT COUNT(1) AS count
 , MIN(date) AS date_min
 , MIN(date) AS date_min
 , MAX(date) AS date_max
 , MAX(date) AS date_max
-FROM {$this->prefix}entry AS e
+FROM `{$this->prefix}entry` AS e
 {$restrict}
 {$restrict}
 SQL;
 SQL;
 		$stm = $this->bd->prepare($sql);
 		$stm = $this->bd->prepare($sql);
@@ -255,16 +243,16 @@ SQL;
 
 
 	/**
 	/**
 	 * Calculates feed count per category.
 	 * Calculates feed count per category.
-	 * Returns the result as a JSON string.
+	 * Returns the result as a JSON object.
 	 *
 	 *
-	 * @return string
+	 * @return JSON object
 	 */
 	 */
 	public function calculateFeedByCategory() {
 	public function calculateFeedByCategory() {
 		$sql = <<<SQL
 		$sql = <<<SQL
 SELECT c.name AS label
 SELECT c.name AS label
 , COUNT(f.id) AS data
 , COUNT(f.id) AS data
-FROM {$this->prefix}category AS c,
-{$this->prefix}feed AS f
+FROM `{$this->prefix}category` AS c,
+`{$this->prefix}feed` AS f
 WHERE c.id = f.category
 WHERE c.id = f.category
 GROUP BY label
 GROUP BY label
 ORDER BY data DESC
 ORDER BY data DESC
@@ -273,22 +261,22 @@ SQL;
 		$stm->execute();
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 
 
-		return $this->convertToPieSerie($res);
+		return $res;
 	}
 	}
 
 
 	/**
 	/**
 	 * Calculates entry count per category.
 	 * Calculates entry count per category.
 	 * Returns the result as a JSON string.
 	 * Returns the result as a JSON string.
 	 *
 	 *
-	 * @return string
+	 * @return JSON object
 	 */
 	 */
 	public function calculateEntryByCategory() {
 	public function calculateEntryByCategory() {
 		$sql = <<<SQL
 		$sql = <<<SQL
 SELECT c.name AS label
 SELECT c.name AS label
 , COUNT(e.id) AS data
 , COUNT(e.id) AS data
-FROM {$this->prefix}category AS c,
-{$this->prefix}feed AS f,
-{$this->prefix}entry AS e
+FROM `{$this->prefix}category` AS c,
+`{$this->prefix}feed` AS f,
+`{$this->prefix}entry` AS e
 WHERE c.id = f.category
 WHERE c.id = f.category
 AND f.id = e.id_feed
 AND f.id = e.id_feed
 GROUP BY label
 GROUP BY label
@@ -298,7 +286,7 @@ SQL;
 		$stm->execute();
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 
 
-		return $this->convertToPieSerie($res);
+		return $res;
 	}
 	}
 
 
 	/**
 	/**
@@ -312,9 +300,9 @@ SELECT f.id AS id
 , MAX(f.name) AS name
 , MAX(f.name) AS name
 , MAX(c.name) AS category
 , MAX(c.name) AS category
 , COUNT(e.id) AS count
 , COUNT(e.id) AS count
-FROM {$this->prefix}category AS c,
-{$this->prefix}feed AS f,
-{$this->prefix}entry AS e
+FROM `{$this->prefix}category` AS c,
+`{$this->prefix}feed` AS f,
+`{$this->prefix}entry` AS e
 WHERE c.id = f.category
 WHERE c.id = f.category
 AND f.id = e.id_feed
 AND f.id = e.id_feed
 GROUP BY f.id
 GROUP BY f.id
@@ -337,8 +325,8 @@ SELECT MAX(f.id) as id
 , MAX(f.name) AS name
 , MAX(f.name) AS name
 , MAX(date) AS last_date
 , MAX(date) AS last_date
 , COUNT(*) AS nb_articles
 , COUNT(*) AS nb_articles
-FROM {$this->prefix}feed AS f,
-{$this->prefix}entry AS e
+FROM `{$this->prefix}feed` AS f,
+`{$this->prefix}entry` AS e
 WHERE f.id = e.id_feed
 WHERE f.id = e.id_feed
 GROUP BY f.id
 GROUP BY f.id
 ORDER BY name
 ORDER BY name
@@ -348,27 +336,6 @@ SQL;
 		return $stm->fetchAll(PDO::FETCH_ASSOC);
 		return $stm->fetchAll(PDO::FETCH_ASSOC);
 	}
 	}
 
 
-	protected function convertToSerie($data) {
-		$serie = array();
-
-		foreach ($data as $key => $value) {
-			$serie[] = array($key, $value);
-		}
-
-		return json_encode($serie);
-	}
-
-	protected function convertToPieSerie($data) {
-		$serie = array();
-
-		foreach ($data as $value) {
-			$value['data'] = array(array(0, (int) $value['data']));
-			$serie[] = $value;
-		}
-
-		return json_encode($serie);
-	}
-
 	/**
 	/**
 	 * Gets days ready for graphs
 	 * Gets days ready for graphs
 	 *
 	 *
@@ -409,17 +376,17 @@ SQL;
 	}
 	}
 
 
 	/**
 	/**
-	 * Translates array content and encode it as JSON
+	 * Translates array content
 	 *
 	 *
 	 * @param array $data
 	 * @param array $data
-	 * @return string
+	 * @return JSON object
 	 */
 	 */
 	private function convertToTranslatedJson($data = array()) {
 	private function convertToTranslatedJson($data = array()) {
 		$translated = array_map(function($a) {
 		$translated = array_map(function($a) {
-			return _t($a);
+			return _t('gen.date.' . $a);
 		}, $data);
 		}, $data);
 
 
-		return json_encode($translated);
+		return $translated;
 	}
 	}
 
 
 }
 }

+ 67 - 0
app/Models/StatsDAOPGSQL.php

@@ -0,0 +1,67 @@
+<?php
+
+class FreshRSS_StatsDAOPGSQL extends FreshRSS_StatsDAO {
+
+	/**
+	 * Calculates the number of article per hour of the day per feed
+	 *
+	 * @param integer $feed id
+	 * @return string
+	 */
+	public function calculateEntryRepartitionPerFeedPerHour($feed = null) {
+		return $this->calculateEntryRepartitionPerFeedPerPeriod('hour', $feed);
+	}
+
+	/**
+	 * Calculates the number of article per day of week per feed
+	 *
+	 * @param integer $feed id
+	 * @return string
+	 */
+	public function calculateEntryRepartitionPerFeedPerDayOfWeek($feed = null) {
+		return $this->calculateEntryRepartitionPerFeedPerPeriod('day', $feed);
+	}
+
+	/**
+	 * Calculates the number of article per month per feed
+	 *
+	 * @param integer $feed
+	 * @return string
+	 */
+	public function calculateEntryRepartitionPerFeedPerMonth($feed = null) {
+		return $this->calculateEntryRepartitionPerFeedPerPeriod('month', $feed);
+	}
+
+	/**
+	 * Calculates the number of article per period per feed
+	 *
+	 * @param string $period format string to use for grouping
+	 * @param integer $feed id
+	 * @return string
+	 */
+	protected function calculateEntryRepartitionPerFeedPerPeriod($period, $feed = null) {
+		$restrict = '';
+		if ($feed) {
+			$restrict = "WHERE e.id_feed = {$feed}";
+		}
+		$sql = <<<SQL
+SELECT extract( {$period} from to_timestamp(e.date)) AS period
+, COUNT(1) AS count
+FROM "{$this->prefix}entry" AS e
+{$restrict}
+GROUP BY period
+ORDER BY period ASC
+SQL;
+
+		$stm = $this->bd->prepare($sql);
+		$stm->execute();
+		$res = $stm->fetchAll(PDO::FETCH_NAMED);
+
+		foreach ($res as $value) {
+			$repartition[(int) $value['period']] = (int) $value['count'];
+		}
+
+		return $repartition;
+	}
+
+}

+ 4 - 55
app/Models/StatsDAOSQLite.php

@@ -2,59 +2,8 @@
 
 
 class FreshRSS_StatsDAOSQLite extends FreshRSS_StatsDAO {
 class FreshRSS_StatsDAOSQLite extends FreshRSS_StatsDAO {
 
 
-	/**
-	 * Calculates entry count per day on a 30 days period.
-	 * Returns the result as a JSON string.
-	 *
-	 * @return string
-	 */
-	public function calculateEntryCount() {
-		$count = $this->initEntryCountArray();
-		$period = parent::ENTRY_COUNT_PERIOD;
-
-		// Get stats per day for the last 30 days
-		$sql = <<<SQL
-SELECT round(julianday(e.date, 'unixepoch') - julianday('now')) AS day,
-COUNT(1) AS count
-FROM {$this->prefix}entry AS e
-WHERE strftime('%Y%m%d', e.date, 'unixepoch')
-	BETWEEN strftime('%Y%m%d', 'now', '-{$period} days')
-	AND strftime('%Y%m%d', 'now', '-1 day')
-GROUP BY day
-ORDER BY day ASC
-SQL;
-		$stm = $this->bd->prepare($sql);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-
-		foreach ($res as $value) {
-			$count[(int) $value['day']] = (int) $value['count'];
-		}
-
-		return $this->convertToSerie($count);
-	}
-
-	/**
-	 * Calculates entry average per day on a 30 days period.
-	 *
-	 * @return integer
-	 */
-	public function calculateEntryAverage() {
-		$period = self::ENTRY_COUNT_PERIOD;
-
-		// Get stats per day for the last 30 days
-		$sql = <<<SQL
-SELECT COUNT(1) / {$period} AS average
-FROM {$this->prefix}entry AS e
-WHERE strftime('%Y%m%d', e.date, 'unixepoch')
-	BETWEEN strftime('%Y%m%d', 'now', '-{$period} days')
-	AND strftime('%Y%m%d', 'now', '-1 day')
-SQL;
-		$stm = $this->bd->prepare($sql);
-		$stm->execute();
-		$res = $stm->fetch(PDO::FETCH_NAMED);
-
-		return round($res['average'], 2);
+	protected function sqlFloor($s) {
+		return "CAST(($s) AS INT)";
 	}
 	}
 
 
 	protected function calculateEntryRepartitionPerFeedPerPeriod($period, $feed = null) {
 	protected function calculateEntryRepartitionPerFeedPerPeriod($period, $feed = null) {
@@ -66,7 +15,7 @@ SQL;
 		$sql = <<<SQL
 		$sql = <<<SQL
 SELECT strftime('{$period}', e.date, 'unixepoch') AS period
 SELECT strftime('{$period}', e.date, 'unixepoch') AS period
 , COUNT(1) AS count
 , COUNT(1) AS count
-FROM {$this->prefix}entry AS e
+FROM `{$this->prefix}entry` AS e
 {$restrict}
 {$restrict}
 GROUP BY period
 GROUP BY period
 ORDER BY period ASC
 ORDER BY period ASC
@@ -81,7 +30,7 @@ SQL;
 			$repartition[(int) $value['period']] = (int) $value['count'];
 			$repartition[(int) $value['period']] = (int) $value['count'];
 		}
 		}
 
 
-		return $this->convertToSerie($repartition);
+		return $repartition;
 	}
 	}
 
 
 }
 }

+ 0 - 4
app/Models/Themes.php

@@ -116,7 +116,3 @@ class FreshRSS_Themes extends Minz_Model {
 			'<img class="icon" src="' . Minz_Url::display($url) . '" alt="' . $alts[$name] . '" />';
 			'<img class="icon" src="' . Minz_Url::display($url) . '" alt="' . $alts[$name] . '" />';
 	}
 	}
 }
 }
-
-function _i($icon, $url_only = false) {
-	return FreshRSS_Themes::icon($icon, $url_only);
-}

+ 56 - 19
app/Models/UserDAO.php

@@ -1,44 +1,70 @@
 <?php
 <?php
 
 
 class FreshRSS_UserDAO extends Minz_ModelPdo {
 class FreshRSS_UserDAO extends Minz_ModelPdo {
-	public function createUser($username) {
-		$db = Minz_Configuration::dataBase();
+	public function createUser($username, $new_user_language, $insertDefaultFeeds = true) {
+		$db = FreshRSS_Context::$system_conf->db;
 		require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
 		require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
 
 
 		$userPDO = new Minz_ModelPdo($username);
 		$userPDO = new Minz_ModelPdo($username);
 
 
-		$ok = false;
-		if (defined('SQL_CREATE_TABLES')) {	//E.g. MySQL
-			$sql = sprintf(SQL_CREATE_TABLES, $db['prefix'] . $username . '_', _t('default_category'));
-			$stm = $userPDO->bd->prepare($sql);
-			$ok = $stm && $stm->execute();
-		} else {	//E.g. SQLite
-			global $SQL_CREATE_TABLES;
-			if (is_array($SQL_CREATE_TABLES)) {
-				$ok = true;
-				foreach ($SQL_CREATE_TABLES as $instruction) {
-					$sql = sprintf($instruction, '', _t('default_category'));
+		$currentLanguage = Minz_Translate::language();
+
+		try {
+			Minz_Translate::reset($new_user_language);
+			$ok = false;
+			$bd_prefix_user = $db['prefix'] . $username . '_';
+			if (defined('SQL_CREATE_TABLES')) {	//E.g. MySQL
+				$sql = sprintf(SQL_CREATE_TABLES, $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;
+				if (is_array($SQL_CREATE_TABLES)) {
+					$ok = true;
+					foreach ($SQL_CREATE_TABLES as $instruction) {
+						$sql = sprintf($instruction, $bd_prefix_user, _t('gen.short.default_category'));
+						$stm = $userPDO->bd->prepare($sql);
+						$ok &= ($stm && $stm->execute());
+					}
+				}
+			}
+			if ($insertDefaultFeeds) {
+				if (defined('SQL_INSERT_FEEDS')) {	//E.g. MySQL
+					$sql = sprintf(SQL_INSERT_FEEDS, $bd_prefix_user);
 					$stm = $userPDO->bd->prepare($sql);
 					$stm = $userPDO->bd->prepare($sql);
-					$ok &= ($stm && $stm->execute());
+					$ok &= $stm && $stm->execute();
+				} else {	//E.g. SQLite
+					global $SQL_INSERT_FEEDS;
+					if (is_array($SQL_INSERT_FEEDS)) {
+						foreach ($SQL_INSERT_FEEDS as $instruction) {
+							$sql = sprintf($instruction, $bd_prefix_user);
+							$stm = $userPDO->bd->prepare($sql);
+							$ok &= ($stm && $stm->execute());
+						}
+					}
 				}
 				}
 			}
 			}
+		} catch (Exception $e) {
+			Minz_Log::error('Error while creating user: ' . $e->getMessage());
 		}
 		}
 
 
+		Minz_Translate::reset($currentLanguage);
+
 		if ($ok) {
 		if ($ok) {
 			return true;
 			return true;
 		} else {
 		} else {
 			$info = empty($stm) ? array(2 => 'syntax error') : $stm->errorInfo();
 			$info = empty($stm) ? array(2 => 'syntax error') : $stm->errorInfo();
-			Minz_Log::error('SQL error : ' . $info[2]);
+			Minz_Log::error('SQL error: ' . $info[2]);
 			return false;
 			return false;
 		}
 		}
 	}
 	}
 
 
 	public function deleteUser($username) {
 	public function deleteUser($username) {
-		$db = Minz_Configuration::dataBase();
+		$db = FreshRSS_Context::$system_conf->db;
 		require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
 		require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
 
 
 		if ($db['type'] === 'sqlite') {
 		if ($db['type'] === 'sqlite') {
-			return unlink(DATA_PATH . '/' . $username . '.sqlite');
+			return unlink(join_path(DATA_PATH, 'users', $username, 'db.sqlite'));
 		} else {
 		} else {
 			$userPDO = new Minz_ModelPdo($username);
 			$userPDO = new Minz_ModelPdo($username);
 
 
@@ -54,7 +80,18 @@ class FreshRSS_UserDAO extends Minz_ModelPdo {
 		}
 		}
 	}
 	}
 
 
-	public function exist($username) {
-		return file_exists(DATA_PATH . '/' . $username . '_user.php');
+	public static function exist($username) {
+		return is_dir(join_path(DATA_PATH , 'users', $username));
+	}
+
+	public static function touch($username = '') {
+		if (($username == '') || (!ctype_alnum($username))) {
+			$username = Minz_Session::param('currentUser', '_');
+		}
+		return touch(join_path(DATA_PATH , 'users', $username, 'config.php'));
+	}
+
+	public static function mtime($username) {
+		return @filemtime(join_path(DATA_PATH , 'users', $username, 'config.php'));
 	}
 	}
 }
 }

+ 226 - 0
app/Models/UserQuery.php

@@ -0,0 +1,226 @@
+<?php
+
+/**
+ * Contains the description of a user query
+ *
+ * It allows to extract the meaningful bits of the query to be manipulated in an
+ * easy way.
+ */
+class FreshRSS_UserQuery {
+
+	private $deprecated = false;
+	private $get;
+	private $get_name;
+	private $get_type;
+	private $name;
+	private $order;
+	private $search;
+	private $state;
+	private $url;
+	private $feed_dao;
+	private $category_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) {
+		$this->category_dao = $category_dao;
+		$this->feed_dao = $feed_dao;
+		if (isset($query['get'])) {
+			$this->parseGet($query['get']);
+		}
+		if (isset($query['name'])) {
+			$this->name = $query['name'];
+		}
+		if (isset($query['order'])) {
+			$this->order = $query['order'];
+		}
+		if (!isset($query['search'])) {
+			$query['search'] = '';
+		}
+		// linked to deeply with the search object, need to use dependency injection
+		$this->search = new FreshRSS_Search($query['search']);
+		if (isset($query['state'])) {
+			$this->state = $query['state'];
+		}
+		if (isset($query['url'])) {
+			$this->url = $query['url'];
+		}
+	}
+
+	/**
+	 * Convert the current object to an array.
+	 *
+	 * @return array
+	 */
+	public function toArray() {
+		return array_filter(array(
+		    'get' => $this->get,
+		    'name' => $this->name,
+		    'order' => $this->order,
+		    'search' => $this->search->__toString(),
+		    'state' => $this->state,
+		    'url' => $this->url,
+		));
+	}
+
+	/**
+	 * Parse the get parameter in the query string to extract its name and
+	 * type
+	 *
+	 * @param string $get
+	 */
+	private function parseGet($get) {
+		$this->get = $get;
+		if (preg_match('/(?P<type>[acfs])(_(?P<id>\d+))?/', $get, $matches)) {
+			switch ($matches['type']) {
+				case 'a':
+					$this->parseAll();
+					break;
+				case 'c':
+					$this->parseCategory($matches['id']);
+					break;
+				case 'f':
+					$this->parseFeed($matches['id']);
+					break;
+				case 's':
+					$this->parseFavorite();
+					break;
+			}
+		}
+	}
+
+	/**
+	 * Parse the query string when it is an "all" query
+	 */
+	private function parseAll() {
+		$this->get_name = 'all';
+		$this->get_type = 'all';
+	}
+
+	/**
+	 * Parse the query string when it is a "category" query
+	 *
+	 * @param integer $id
+	 * @throws FreshRSS_DAO_Exception
+	 */
+	private function parseCategory($id) {
+		if (is_null($this->category_dao)) {
+			throw new FreshRSS_DAO_Exception('Category DAO is not loaded in UserQuery');
+		}
+		$category = $this->category_dao->searchById($id);
+		if ($category) {
+			$this->get_name = $category->name();
+		} else {
+			$this->deprecated = true;
+		}
+		$this->get_type = 'category';
+	}
+
+	/**
+	 * Parse the query string when it is a "feed" query
+	 *
+	 * @param integer $id
+	 * @throws FreshRSS_DAO_Exception
+	 */
+	private function parseFeed($id) {
+		if (is_null($this->feed_dao)) {
+			throw new FreshRSS_DAO_Exception('Feed DAO is not loaded in UserQuery');
+		}
+		$feed = $this->feed_dao->searchById($id);
+		if ($feed) {
+			$this->get_name = $feed->name();
+		} else {
+			$this->deprecated = true;
+		}
+		$this->get_type = 'feed';
+	}
+
+	/**
+	 * Parse the query string when it is a "favorite" query
+	 */
+	private function parseFavorite() {
+		$this->get_name = 'favorite';
+		$this->get_type = 'favorite';
+	}
+
+	/**
+	 * Check if the current user query is deprecated.
+	 * It is deprecated if the category or the feed used in the query are
+	 * not existing.
+	 *
+	 * @return boolean
+	 */
+	public function isDeprecated() {
+		return $this->deprecated;
+	}
+
+	/**
+	 * Check if the user query has parameters.
+	 * If the type is 'all', it is considered equal to no parameters
+	 *
+	 * @return boolean
+	 */
+	public function hasParameters() {
+		if ($this->get_type === 'all') {
+			return false;
+		}
+		if ($this->hasSearch()) {
+			return true;
+		}
+		if ($this->state) {
+			return true;
+		}
+		if ($this->order) {
+			return true;
+		}
+		if ($this->get) {
+			return true;
+		}
+		return false;
+	}
+
+	/**
+	 * Check if there is a search in the search object
+	 *
+	 * @return boolean
+	 */
+	public function hasSearch() {
+		return $this->search->getRawInput() != "";
+	}
+
+	public function getGet() {
+		return $this->get;
+	}
+
+	public function getGetName() {
+		return $this->get_name;
+	}
+
+	public function getGetType() {
+		return $this->get_type;
+	}
+
+	public function getName() {
+		return $this->name;
+	}
+
+	public function getOrder() {
+		return $this->order;
+	}
+
+	public function getSearch() {
+		return $this->search;
+	}
+
+	public function getState() {
+		return $this->state;
+	}
+
+	public function getUrl() {
+		return $this->url;
+	}
+
+}

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

@@ -1,21 +1,23 @@
 <?php
 <?php
+define('SQL_CREATE_DB', 'CREATE DATABASE IF NOT EXISTS %1$s DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
+
 define('SQL_CREATE_TABLES', '
 define('SQL_CREATE_TABLES', '
 CREATE TABLE IF NOT EXISTS `%1$scategory` (
 CREATE TABLE IF NOT EXISTS `%1$scategory` (
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
-	`name` varchar(255) NOT NULL,
+	`name` varchar(191) NOT NULL,
 	PRIMARY KEY (`id`),
 	PRIMARY KEY (`id`),
 	UNIQUE KEY (`name`)	-- v0.7
 	UNIQUE KEY (`name`)	-- v0.7
-) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
 ENGINE = INNODB;
 ENGINE = INNODB;
 
 
 CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
 	`url` varchar(511) CHARACTER SET latin1 NOT NULL,
 	`url` varchar(511) CHARACTER SET latin1 NOT NULL,
 	`category` SMALLINT DEFAULT 0,	-- v0.7
 	`category` SMALLINT DEFAULT 0,	-- v0.7
-	`name` varchar(255) NOT NULL,
+	`name` varchar(191) NOT NULL,
 	`website` varchar(255) CHARACTER SET latin1,
 	`website` varchar(255) CHARACTER SET latin1,
 	`description` text,
 	`description` text,
-	`lastUpdate` int(11) DEFAULT 0,
+	`lastUpdate` int(11) DEFAULT 0,	-- Until year 2038
 	`priority` tinyint(2) NOT NULL DEFAULT 10,
 	`priority` tinyint(2) NOT NULL DEFAULT 10,
 	`pathEntries` varchar(511) DEFAULT NULL,
 	`pathEntries` varchar(511) DEFAULT NULL,
 	`httpAuth` varchar(511) DEFAULT NULL,
 	`httpAuth` varchar(511) DEFAULT NULL,
@@ -30,7 +32,7 @@ CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 	INDEX (`name`),	-- v0.7
 	INDEX (`name`),	-- v0.7
 	INDEX (`priority`),	-- v0.7
 	INDEX (`priority`),	-- v0.7
 	INDEX (`keep_history`)	-- v0.7
 	INDEX (`keep_history`)	-- v0.7
-) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
 ENGINE = INNODB;
 ENGINE = INNODB;
 
 
 CREATE TABLE IF NOT EXISTS `%1$sentry` (
 CREATE TABLE IF NOT EXISTS `%1$sentry` (
@@ -40,7 +42,9 @@ CREATE TABLE IF NOT EXISTS `%1$sentry` (
 	`author` varchar(255),
 	`author` varchar(255),
 	`content_bin` blob,	-- v0.7
 	`content_bin` blob,	-- v0.7
 	`link` varchar(1023) CHARACTER SET latin1 NOT NULL,
 	`link` varchar(1023) CHARACTER SET latin1 NOT NULL,
-	`date` int(11),
+	`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_read` boolean NOT NULL DEFAULT 0,
 	`is_favorite` boolean NOT NULL DEFAULT 0,
 	`is_favorite` boolean NOT NULL DEFAULT 0,
 	`id_feed` SMALLINT,	-- v0.7
 	`id_feed` SMALLINT,	-- v0.7
@@ -49,11 +53,38 @@ CREATE TABLE IF NOT EXISTS `%1$sentry` (
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE KEY (`id_feed`,`guid`),	-- v0.7
 	UNIQUE KEY (`id_feed`,`guid`),	-- v0.7
 	INDEX (`is_favorite`),	-- v0.7
 	INDEX (`is_favorite`),	-- v0.7
-	INDEX (`is_read`)	-- v0.7
-) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci
+	INDEX (`is_read`),	-- v0.7
+	INDEX `entry_lastSeen_index` (`lastSeen`)	-- v1.1.1
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
 ENGINE = INNODB;
 ENGINE = INNODB;
 
 
 INSERT IGNORE INTO `%1$scategory` (id, name) VALUES(1, "%2$s");
 INSERT IGNORE INTO `%1$scategory` (id, name) VALUES(1, "%2$s");
 ');
 ');
 
 
-define('SQL_DROP_TABLES', 'DROP TABLES %1$sentry, %1$sfeed, %1$scategory');
+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://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$sentry`, `%1$sfeed`, `%1$scategory`');
+
+define('SQL_UPDATE_UTF8MB4', '
+ALTER DATABASE `%2$s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+
+ALTER TABLE `%1$scategory` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+UPDATE `%1$scategory` SET name=SUBSTRING(name,1,190) WHERE LENGTH(name) > 191;
+ALTER TABLE `%1$scategory` MODIFY `name` VARCHAR(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL;
+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;
+OPTIMIZE TABLE `%1$sfeed`;
+
+ALTER TABLE `%1$sentry` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ALTER TABLE `%1$sentry` MODIFY `title` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL;
+ALTER TABLE `%1$sentry` MODIFY `author` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+ALTER TABLE `%1$sentry` MODIFY `tags` VARCHAR(1023) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+OPTIMIZE TABLE `%1$sentry`;
+');

+ 63 - 0
app/SQL/install.sql.pgsql.php

@@ -0,0 +1,63 @@
+<?php
+define('SQL_CREATE_DB', 'CREATE DATABASE %1$s ENCODING \'UTF8\';');
+
+global $SQL_CREATE_TABLES;
+$SQL_CREATE_TABLES = array(
+'CREATE TABLE IF NOT EXISTS "%1$scategory" (
+	"id" SERIAL PRIMARY KEY,
+	"name" VARCHAR(255) UNIQUE NOT NULL
+);',
+
+'CREATE TABLE IF NOT EXISTS "%1$sfeed" (
+	"id" SERIAL PRIMARY KEY,
+	"url" varchar(511) UNIQUE NOT NULL,
+	"category" SMALLINT DEFAULT 0,
+	"name" VARCHAR(255) NOT NULL,
+	"website" VARCHAR(255),
+	"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,
+	"keep_history" INT NOT NULL DEFAULT -2,
+	"ttl" INT NOT NULL DEFAULT -2,
+	"cache_nbEntries" INT DEFAULT 0,
+	"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 TABLE IF NOT EXISTS "%1$sentry" (
+	"id" BIGINT NOT NULL PRIMARY KEY,
+	"guid" VARCHAR(760) UNIQUE NOT NULL,
+	"title" VARCHAR(255) NOT NULL,
+	"author" VARCHAR(255),
+	"content" TEXT,
+	"link" VARCHAR(1023) NOT NULL,
+	"date" INT,
+	"lastSeen" INT DEFAULT 0,
+	"hash" BYTEA,
+	"is_read" SMALLINT NOT NULL DEFAULT 0,
+	"is_favorite" SMALLINT NOT NULL DEFAULT 0,
+	"id_feed" SMALLINT,
+	"tags" VARCHAR(1023),
+	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");',
+
+'INSERT INTO "%1$scategory" (name) SELECT \'%2$s\' WHERE NOT EXISTS (SELECT id FROM "%1$scategory" WHERE id = 1);',
+);
+
+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\');',
+);
+
+define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS "%1$sentry", "%1$sfeed", "%1$scategory"');

+ 24 - 15
app/SQL/install.sql.sqlite.php

@@ -1,20 +1,20 @@
 <?php
 <?php
 global $SQL_CREATE_TABLES;
 global $SQL_CREATE_TABLES;
 $SQL_CREATE_TABLES = array(
 $SQL_CREATE_TABLES = array(
-'CREATE TABLE IF NOT EXISTS `%1$scategory` (
+'CREATE TABLE IF NOT EXISTS `category` (
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
 	`name` varchar(255) NOT NULL,
 	`name` varchar(255) NOT NULL,
 	UNIQUE (`name`)
 	UNIQUE (`name`)
 );',
 );',
 
 
-'CREATE TABLE IF NOT EXISTS `%1$sfeed` (
+'CREATE TABLE IF NOT EXISTS `feed` (
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
 	`url` varchar(511) NOT NULL,
 	`url` varchar(511) NOT NULL,
-	`%1$scategory` SMALLINT DEFAULT 0,
+	`category` SMALLINT DEFAULT 0,
 	`name` varchar(255) NOT NULL,
 	`name` varchar(255) NOT NULL,
 	`website` varchar(255),
 	`website` varchar(255),
 	`description` text,
 	`description` text,
-	`lastUpdate` int(11) DEFAULT 0,
+	`lastUpdate` int(11) DEFAULT 0,	-- Until year 2038
 	`priority` tinyint(2) NOT NULL DEFAULT 10,
 	`priority` tinyint(2) NOT NULL DEFAULT 10,
 	`pathEntries` varchar(511) DEFAULT NULL,
 	`pathEntries` varchar(511) DEFAULT NULL,
 	`httpAuth` varchar(511) DEFAULT NULL,
 	`httpAuth` varchar(511) DEFAULT NULL,
@@ -23,35 +23,44 @@ $SQL_CREATE_TABLES = array(
 	`ttl` INT NOT NULL DEFAULT -2,
 	`ttl` INT NOT NULL DEFAULT -2,
 	`cache_nbEntries` int DEFAULT 0,
 	`cache_nbEntries` int DEFAULT 0,
 	`cache_nbUnreads` int DEFAULT 0,
 	`cache_nbUnreads` int DEFAULT 0,
-	FOREIGN KEY (`%1$scategory`) REFERENCES `%1$scategory`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
+	FOREIGN KEY (`category`) REFERENCES `category`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
 	UNIQUE (`url`)
 	UNIQUE (`url`)
 );',
 );',
 
 
-'CREATE INDEX IF NOT EXISTS feed_name_index ON `%1$sfeed`(`name`);',
-'CREATE INDEX IF NOT EXISTS feed_priority_index ON `%1$sfeed`(`priority`);',
-'CREATE INDEX IF NOT EXISTS feed_keep_history_index ON `%1$sfeed`(`keep_history`);',
+'CREATE INDEX IF NOT EXISTS feed_name_index ON `feed`(`name`);',
+'CREATE INDEX IF NOT EXISTS feed_priority_index ON `feed`(`priority`);',
+'CREATE INDEX IF NOT EXISTS feed_keep_history_index ON `feed`(`keep_history`);',
 
 
-'CREATE TABLE IF NOT EXISTS `%1$sentry` (
+'CREATE TABLE IF NOT EXISTS `entry` (
 	`id` bigint NOT NULL,
 	`id` bigint NOT NULL,
 	`guid` varchar(760) NOT NULL,
 	`guid` varchar(760) NOT NULL,
 	`title` varchar(255) NOT NULL,
 	`title` varchar(255) NOT NULL,
 	`author` varchar(255),
 	`author` varchar(255),
 	`content` text,
 	`content` text,
 	`link` varchar(1023) NOT NULL,
 	`link` varchar(1023) NOT NULL,
-	`date` int(11),
+	`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_read` boolean NOT NULL DEFAULT 0,
 	`is_favorite` boolean NOT NULL DEFAULT 0,
 	`is_favorite` boolean NOT NULL DEFAULT 0,
 	`id_feed` SMALLINT,
 	`id_feed` SMALLINT,
 	`tags` varchar(1023),
 	`tags` varchar(1023),
 	PRIMARY KEY (`id`),
 	PRIMARY KEY (`id`),
-	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+	FOREIGN KEY (`id_feed`) REFERENCES `feed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE (`id_feed`,`guid`)
 	UNIQUE (`id_feed`,`guid`)
 );',
 );',
 
 
-'CREATE INDEX IF NOT EXISTS entry_is_favorite_index ON `%1$sentry`(`is_favorite`);',
-'CREATE INDEX IF NOT EXISTS entry_is_read_index ON `%1$sentry`(`is_read`);',
+'CREATE INDEX IF NOT EXISTS entry_is_favorite_index ON `entry`(`is_favorite`);',
+'CREATE INDEX IF NOT EXISTS entry_is_read_index ON `entry`(`is_read`);',
+'CREATE INDEX IF NOT EXISTS entry_lastSeen_index ON `entry`(`lastSeen`);',	//v1.1.1
 
 
-'INSERT OR IGNORE INTO `%1$scategory` (id, name) VALUES(1, "%2$s");',
+'INSERT OR IGNORE INTO `category` (id, name) VALUES(1, "%2$s");',
 );
 );
 
 
-define('SQL_DROP_TABLES', 'DROP TABLES %1$sentry, %1$sfeed, %1$scategory');
+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);',
+);
+
+define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS entry, feed, category');

+ 57 - 27
app/actualize_script.php

@@ -7,51 +7,81 @@ ob_implicit_flush(false);
 ob_start();
 ob_start();
 echo 'Results: ', "\n";	//Buffered
 echo 'Results: ', "\n";	//Buffered
 
 
-Minz_Configuration::init();
+if (defined('STDOUT')) {
+	$begin_date = date_create('now');
+	fwrite(STDOUT, 'Starting feed actualization at ' . $begin_date->format('c') . "\n");	//Unbuffered
+}
 
 
-$users = listUsers();
-shuffle($users);	//Process users in random order
 
 
-if (Minz_Configuration::defaultUser() !== ''){
-	array_unshift($users, Minz_Configuration::defaultUser());	//But always start with admin
+// Set the header params ($_GET) to call the FRSS application.
+$_GET['c'] = 'feed';
+$_GET['a'] = 'actualize';
+$_GET['ajax'] = 1;
+$_GET['force'] = true;
+$_SERVER['HTTP_HOST'] = '';
+
+
+$log_file = join_path(USERS_PATH, '_', 'log.txt');
+
+
+$app = new FreshRSS();
+
+$system_conf = Minz_Configuration::get('system');
+$system_conf->auth_type = 'none';  // avoid necessity to be logged in (not saved!)
+FreshRSS_Context::$isCli = true;
+
+// Create the list of users to actualize.
+// Users are processed in a random order but always start with admin
+$users = listUsers();
+shuffle($users);
+if ($system_conf->default_user !== '') {
+	array_unshift($users, $system_conf->default_user);
 	$users = array_unique($users);
 	$users = array_unique($users);
 }
 }
 
 
-foreach ($users as $myUser) {
-	syslog(LOG_INFO, 'FreshRSS actualize ' . $myUser);
+
+$limits = $system_conf->limits;
+$min_last_activity = time() - $limits['max_inactivity'];
+foreach ($users as $user) {
+	if (($user !== $system_conf->default_user) &&
+			(FreshRSS_UserDAO::mtime($user) < $min_last_activity)) {
+		Minz_Log::notice('FreshRSS skip inactive user ' . $user, $log_file);
+		if (defined('STDOUT')) {
+			fwrite(STDOUT, 'FreshRSS skip inactive user ' . $user . "\n");	//Unbuffered
+		}
+		continue;
+	}
+	Minz_Log::notice('FreshRSS actualize ' . $user, $log_file);
 	if (defined('STDOUT')) {
 	if (defined('STDOUT')) {
-		fwrite(STDOUT, 'Actualize ' . $myUser . "...\n");	//Unbuffered
+		fwrite(STDOUT, 'Actualize ' . $user . "...\n");	//Unbuffered
 	}
 	}
-	echo $myUser, ' ';	//Buffered
+	echo $user, ' ';	//Buffered
 
 
-	$_GET['c'] = 'feed';
-	$_GET['a'] = 'actualize';
-	$_GET['ajax'] = 1;
-	$_GET['force'] = true;
-	$_SERVER['HTTP_HOST'] = '';
 
 
-	$freshRSS = new FreshRSS();
+	Minz_Session::_param('currentUser', $user);
+	new Minz_ModelPdo($user);	//TODO: FIXME: Quick-fix while waiting for a better FreshRSS() constructor/init
+	FreshRSS_Auth::giveAccess();
+	$app->init();
+	$app->run();
 
 
-	Minz_Configuration::_authType('none');
-
-	Minz_Session::init('FreshRSS');
-	Minz_Session::_param('currentUser', $myUser);
-
-	$freshRSS->init();
-	$freshRSS->run();
 
 
 	if (!invalidateHttpCache()) {
 	if (!invalidateHttpCache()) {
-		syslog(LOG_NOTICE, 'FreshRSS write access problem in ' . LOG_PATH . '/*.log!');
+		Minz_Log::notice('FreshRSS write access problem in ' . join_path(USERS_PATH, $user, 'log.txt'),
+		                 $log_file);
 		if (defined('STDERR')) {
 		if (defined('STDERR')) {
-			fwrite(STDERR, 'Write access problem in ' . LOG_PATH . '/*.log!' . "\n");
+			fwrite(STDERR, 'Write access problem in ' . join_path(USERS_PATH, $user, 'log.txt') . "\n");
 		}
 		}
 	}
 	}
-	Minz_Session::unset_session(true);
-	Minz_ModelPdo::clean();
 }
 }
-syslog(LOG_INFO, 'FreshRSS actualize done.');
+
+
+Minz_Log::notice('FreshRSS actualize done.', $log_file);
 if (defined('STDOUT')) {
 if (defined('STDOUT')) {
 	fwrite(STDOUT, 'Done.' . "\n");
 	fwrite(STDOUT, 'Done.' . "\n");
+	$end_date = date_create('now');
+	$duration = date_diff($end_date, $begin_date);
+	fwrite(STDOUT, 'Ending feed actualization at ' . $end_date->format('c') . "\n");	//Unbuffered
+	fwrite(STDOUT, 'Feed actualizations took ' . $duration->format('%a day(s), %h hour(s),  %i minute(s) and %s seconds') . ' for ' . count($users) . " users\n");	//Unbuffered
 }
 }
 echo 'End.', "\n";
 echo 'End.', "\n";
 ob_end_flush();
 ob_end_flush();

+ 181 - 0
app/i18n/cz/admin.php

@@ -0,0 +1,181 @@
+<?php
+
+return array(
+	'auth' => array(
+		'allow_anonymous' => 'Umožnit anonymně číst články výchozího uživatele (%s)',
+		'allow_anonymous_refresh' => 'Umožnit anonymní obnovení článků',
+		'api_enabled' => 'Povolit přístup k <abbr>API</abbr> <small>(vyžadováno mobilními aplikacemi)</small>',
+		'form' => 'Webový formulář (tradiční, vyžaduje JavaScript)',
+		'http' => 'HTTP (pro pokročilé uživatele s HTTPS)',
+		'none' => 'Žádný (nebezpečné)',
+		'title' => 'Přihlášení',
+		'title_reset' => 'Reset přihlášení',
+		'token' => 'Authentizační token',
+		'token_help' => 'Umožňuje přístup k RSS kanálu článků výchozího uživatele bez přihlášení:',
+		'type' => 'Způsob přihlášení',
+		'unsafe_autologin' => 'Povolit nebezpečné automatické přihlášení přes: ',
+	),
+	'check_install' => array(
+		'cache' => array(
+			'nok' => 'Zkontrolujte oprávnění adresáře <em>./data/cache</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře cache jsou v pořádku.',
+		),
+		'categories' => array(
+			'nok' => 'Tabulka kategorií je nastavena špatně.',
+			'ok' => 'Tabulka kategorií je v pořádku.',
+		),
+		'connection' => array(
+			'nok' => 'Nelze navázat spojení s databází.',
+			'ok' => 'Připojení k databázi je v pořádku.',
+		),
+		'ctype' => array(
+			'nok' => 'Nemáte požadovanou knihovnu pro ověřování znaků (php-ctype).',
+			'ok' => 'Máte požadovanou knihovnu pro ověřování znaků (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Nemáte cURL (balíček php-curl).',
+			'ok' => 'Máte rozšíření cURL.',
+		),
+		'data' => array(
+			'nok' => 'Zkontrolujte oprávnění adresáře <em>./data</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře data jsou v pořádku.',
+		),
+		'database' => 'Instalace databáze',
+		'dom' => array(
+			'nok' => 'Nemáte požadovanou knihovnu pro procházení DOM (balíček php-xml).',
+			'ok' => 'Máte požadovanou knihovnu pro procházení DOM.',
+		),
+		'entries' => array(
+			'nok' => 'Tabulka článků je nastavena špatně.',
+			'ok' => 'Tabulka kategorií je v pořádku.',
+		),
+		'favicons' => array(
+			'nok' => 'Zkontrolujte oprávnění adresáře <em>./data/favicons</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře favicons jsou v pořádku.',
+		),
+		'feeds' => array(
+			'nok' => 'Tabulka kanálů je nastavena špatně.',
+			'ok' => 'Tabulka kanálů je v pořádku.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Nemáte PHP fileinfo (balíček fileinfo).',
+			'ok' => 'Máte rozšíření fileinfo.',
+		),
+		'files' => 'Instalace souborů',
+		'json' => array(
+			'nok' => 'Nemáte JSON (balíček php5-json).',
+			'ok' => 'Máte rozšíření JSON.',
+		),
+		'minz' => array(
+			'nok' => 'Nemáte framework Minz.',
+			'ok' => 'Máte framework Minz.',
+		),
+		'pcre' => array(
+			'nok' => 'Nemáte požadovanou knihovnu pro regulární výrazy (php-pcre).',
+			'ok' => 'Máte požadovanou knihovnu pro regulární výrazy (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Nemáte PDO nebo některý z podporovaných ovladačů (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'Máte PDO a alespoň jeden z podporovaných ovladačů (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'_' => 'PHP instalace',
+			'nok' => 'Vaše verze PHP je %s, ale FreshRSS vyžaduje alespoň verzi %s.',
+			'ok' => 'Vaše verze PHP je %s a je kompatibilní s FreshRSS.',
+		),
+		'tables' => array(
+			'nok' => 'V databázi chybí jedna nevo více tabulek.',
+			'ok' => 'V databázi jsou všechny tabulky.',
+		),
+		'title' => 'Kontrola instalace',
+		'tokens' => array(
+			'nok' => 'Zkontrolujte oprávnění adresáře <em>./data/tokens</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře tokens jsou v pořádku.',
+		),
+		'users' => array(
+			'nok' => 'Zkontrolujte oprávnění adresáře <em>./data/users</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře users jsou v pořádku.',
+		),
+		'zip' => array(
+			'nok' => 'Nemáte rozšíření ZIP (balíček php-zip).',
+			'ok' => 'Máte rozšíření ZIP.',
+		),
+	),
+	'extensions' => array(
+		'disabled' => 'Vypnuto',
+		'empty_list' => 'Není naistalováno žádné rozšíření',
+		'enabled' => 'Zapnuto',
+		'no_configure_view' => 'Toto rozšíření nemá žádné možnosti nastavení.',
+		'system' => array(
+			'_' => 'Systémová rozšíření',
+			'no_rights' => 'Systémová rozšíření (na ně nemáte oprávnění)',
+		),
+		'title' => 'Rozšíření',
+		'user' => 'Uživatelská rozšíření',
+	),
+	'stats' => array(
+		'_' => 'Statistika',
+		'all_feeds' => 'Všechny kanály',
+		'category' => 'Kategorie',
+		'entry_count' => 'Počet článků',
+		'entry_per_category' => 'Článků na kategorii',
+		'entry_per_day' => 'Článků za den (posledních 30 dní)',
+		'entry_per_day_of_week' => 'Za den v týdnu (průměr: %.2f zprávy)',
+		'entry_per_hour' => 'Za hodinu (průměr: %.2f zprávy)',
+		'entry_per_month' => 'Za měsíc (průměr: %.2f zprávy)',
+		'entry_repartition' => 'Rozdělení článků',
+		'feed' => 'Kanál',
+		'feed_per_category' => 'Článků na kategorii',
+		'idle' => 'Neaktivní kanály',
+		'main' => 'Přehled',
+		'main_stream' => 'Všechny kanály',
+		'menu' => array(
+			'idle' => 'Neaktivní kanály',
+			'main' => 'Přehled',
+			'repartition' => 'Rozdělení článků',
+		),
+		'no_idle' => 'Žádné neaktivní kanály!',
+		'number_entries' => '%d článků',
+		'percent_of_total' => '%% ze všech',
+		'repartition' => 'Rozdělení článků',
+		'status_favorites' => 'Oblíbené',
+		'status_read' => 'Přečtené',
+		'status_total' => 'Celkem',
+		'status_unread' => 'Nepřečtené',
+		'title' => 'Statistika',
+		'top_feed' => 'Top ten kanálů',
+	),
+	'system' => array(
+		'_' => 'System configuration', // @todo translate
+		'auto-update-url' => 'Auto-update server URL', // @todo translate
+		'instance-name' => 'Instance name', // @todo translate
+		'max-categories' => 'Categories per user limit', // @todo translate
+		'max-feeds' => 'Feeds per user limit', // @todo translate
+		'registration' => array(
+			'help' => '0 znamená žádná omezení účtu',
+			'number' => 'Maximální počet účtů',
+		),
+	),
+	'update' => array(
+		'_' => 'Aktualizace systému',
+		'apply' => 'Použít',
+		'check' => 'Zkontrolovat aktualizace',
+		'current_version' => 'Vaše instalace FreshRSS je verze %s.',
+		'last' => 'Poslední kontrola: %s',
+		'none' => 'Žádné nové aktualizace',
+		'title' => 'Aktualizovat systém',
+	),
+	'user' => array(
+		'articles_and_size' => '%s článků (%s)',
+		'create' => 'Vytvořit nového uživatele',
+		'language' => 'Jazyk',
+		'number' => 'Zatím je vytvořen %d účet',
+		'numbers' => 'Zatím je vytvořeno %d účtů',
+		'password_form' => 'Heslo<br /><small>(pro přihlášení webovým formulářem)</small>',
+		'password_format' => 'Alespoň 7 znaků',
+		'title' => 'Správa uživatelů',
+		'user_list' => 'Seznam uživatelů',
+		'username' => 'Přihlašovací jméno',
+		'users' => 'Uživatelé',
+	),
+);

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

@@ -0,0 +1,173 @@
+<?php
+
+return array(
+	'archiving' => array(
+		'_' => 'Archivace',
+		'advanced' => 'Pokročilé',
+		'delete_after' => 'Smazat články starší než',
+		'help' => 'Více možností je dostupných v nastavení jednotlivých kanálů',
+		'keep_history_by_feed' => 'Zachovat tento minimální počet článků v každém kanálu',
+		'optimize' => 'Optimalizovat databázi',
+		'optimize_help' => 'Občasná údržba zmenší velikost databáze',
+		'purge_now' => 'Vyčistit nyní',
+		'title' => 'Archivace',
+		'ttl' => 'Neaktualizovat častěji než',
+	),
+	'display' => array(
+		'_' => 'Zobrazení',
+		'icon' => array(
+			'bottom_line' => 'Spodní řádek',
+			'entry' => 'Ikony článků',
+			'publication_date' => 'Datum vydání',
+			'related_tags' => 'Související tagy',
+			'sharing' => 'Sdílení',
+			'top_line' => 'Horní řádek',
+		),
+		'language' => 'Jazyk',
+		'notif_html5' => array(
+			'seconds' => 'sekund (0 znamená žádný timeout)',
+			'timeout' => 'Timeout HTML5 notifikací',
+		),
+		'theme' => 'Vzhled',
+		'title' => 'Zobrazení',
+		'width' => array(
+			'content' => 'Šířka obsahu',
+			'large' => 'Velká',
+			'medium' => 'Střední',
+			'no_limit' => 'Bez limitu',
+			'thin' => 'Tenká',
+		),
+	),
+	'query' => array(
+		'_' => 'Uživatelské dotazy',
+		'deprecated' => 'Tento dotaz již není platný. Odkazovaná kategorie nebo kanál byly smazány.',
+		'filter' => 'Filtr aplikován:',
+		'get_all' => 'Zobrazit všechny články',
+		'get_category' => 'Zobrazit "%s" kategorii',
+		'get_favorite' => 'Zobrazit oblíbené články',
+		'get_feed' => 'Zobrazit "%s" článkek',
+		'no_filter' => 'Zrušit filtr',
+		'none' => 'Ještě jste nevytvořil žádný uživatelský dotaz.',
+		'number' => 'Dotaz n°%d',
+		'order_asc' => 'Zobrazit nejdříve nejstarší články',
+		'order_desc' => 'Zobrazit nejdříve nejnovější články',
+		'search' => 'Hledat "%s"',
+		'state_0' => 'Zobrazit všechny články',
+		'state_1' => 'Zobrazit přečtené články',
+		'state_2' => 'Zobrazit nepřečtené články',
+		'state_3' => 'Zobrazit všechny články',
+		'state_4' => 'Zobrazit oblíbené články',
+		'state_5' => 'Zobrazit oblíbené přečtené články',
+		'state_6' => 'Zobrazit oblíbené nepřečtené články',
+		'state_7' => 'Zobrazit oblíbené články',
+		'state_8' => 'Zobrazit všechny články vyjma oblíbených',
+		'state_9' => 'Zobrazit všechny přečtené články vyjma  oblíbených',
+		'state_10' => 'Zobrazit všechny nepřečtené články vyjma  oblíbených',
+		'state_11' => 'Zobrazit všechny články vyjma oblíbených',
+		'state_12' => 'Zobrazit všechny články',
+		'state_13' => 'Zobrazit přečtené články',
+		'state_14' => 'Zobrazit nepřečtené články',
+		'state_15' => 'Zobrazit všechny články',
+		'title' => 'Uživatelské dotazy',
+	),
+	'profile' => array(
+		'_' => 'Správa profilu',
+		'delete' => array(
+			'_' => 'Smazání účtu',
+			'warn' => 'Váš účet bude smazán spolu se všemi souvisejícími daty',
+		),
+		'password_api' => 'Password API<br /><small>(tzn. pro mobilní aplikace)</small>',
+		'password_form' => 'Heslo<br /><small>(pro přihlášení webovým formulářem)</small>',
+		'password_format' => 'Alespoň 7 znaků',
+		'title' => 'Profil',
+	),
+	'reading' => array(
+		'_' => 'Čtení',
+		'after_onread' => 'Po “označit vše jako přečtené”,',
+		'articles_per_page' => 'Počet článků na stranu',
+		'auto_load_more' => 'Načítat další články dole na stránce',
+		'auto_remove_article' => 'Po přečtení články schovat',
+		'mark_updated_article_unread' => 'Označte aktualizované položky jako nepřečtené',
+		'confirm_enabled' => 'Vyžadovat potvrzení pro akci “označit vše jako přečtené”',
+		'display_articles_unfolded' => 'Ve výchozím stavu zobrazovat články otevřené',
+		'display_categories_unfolded' => 'Ve výchozím stavu zobrazovat kategorie zavřené',
+		'hide_read_feeds' => 'Schovat kategorie a kanály s nulovým počtem nepřečtených článků (nefunguje s nastavením “Zobrazit všechny články”)',
+		'img_with_lazyload' => 'Použít "lazy load" mód pro načítaní obrázků',
+		'jump_next' => 'skočit na další nepřečtený (kanál nebo kategorii)',
+		'number_divided_when_reader' => 'V režimu “Čtení” děleno dvěma.',
+		'read' => array(
+			'article_open_on_website' => 'když je otevřen původní web s článkem',
+			'article_viewed' => 'během čtení článku',
+			'scroll' => 'během skrolování',
+			'upon_reception' => 'po načtení článku',
+			'when' => 'Označit článek jako přečtený…',
+		),
+		'show' => array(
+			'_' => 	'Počet zobrazených článků',
+			'adaptive' => 'Vyberte zobrazení',
+			'all_articles' => 'Zobrazit všechny články',
+			'unread' => 'Zobrazit jen nepřečtené',
+		),
+		'sort' => array(
+			'_' => 'Řazení',
+			'newer_first' => 'Nejdříve nejnovější',
+			'older_first' => 'Nejdříve nejstarší',
+		),
+		'sticky_post' => 'Při otevření posunout článek nahoru',
+		'title' => 'Čtení',
+		'view' => array(
+			'default' => 'Výchozí',
+			'global' => 'Přehled',
+			'normal' => 'Normální',
+			'reader' => 'Čtení',
+		),
+	),
+	'sharing' => array(
+		'_' => 'Sdílení',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'more_information' => 'Více informací',
+		'print' => 'Tisk',
+		'shaarli' => 'Shaarli',
+		'share_name' => 'Jméno pro zobrazení',
+		'share_url' => 'Jakou URL použít pro sdílení',
+		'title' => 'Sdílení',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag',
+	),
+	'shortcut' => array(
+		'_' => 'Zkratky',
+		'article_action' => 'Články - akce',
+		'auto_share' => 'Sdílet',
+		'auto_share_help' => 'Je-li nastavena pouze jedna možnost sdílení, bude použita. Další možnosti jsou dostupné pomocí jejich čísla.',
+		'close_dropdown' => 'Zavřít menu',
+		'collapse_article' => 'Srolovat',
+		'first_article' => 'Skočit na první článek',
+		'focus_search' => 'Hledání',
+		'help' => 'Zobrazit documentaci',
+		'javascript' => 'Pro použití zkratek musí být povolen JavaScript',
+		'last_article' => 'Skočit na poslední článek',
+		'load_more' => 'Načíst více článků',
+		'mark_read' => 'Označit jako přečtené',
+		'mark_favorite' => 'Označit jako oblíbené',
+		'navigation' => 'Navigace',
+		'navigation_help' => 'Pomocí přepínače "Shift" fungují navigační zkratky v rámci kanálů.<br/>Pomocí přepínače "Alt" fungují v rámci kategorií.',
+		'next_article' => 'Skočit na další článek',
+		'other_action' => 'Ostatní akce',
+		'previous_article' => 'Skočit na předchozí článek',
+		'see_on_website' => 'Navštívit původní webovou stránku',
+		'shift_for_all_read' => '+ <code>shift</code> označí vše jako přečtené',
+		'title' => 'Zkratky',
+		'user_filter' => 'Aplikovat uživatelské filtry',
+		'user_filter_help' => 'Je-li nastaven pouze jeden filtr, bude použit. Další filtry jsou dostupné pomocí jejich čísla.',
+	),
+	'user' => array(
+		'articles_and_size' => '%s článků (%s)',
+		'current' => 'Aktuální uživatel',
+		'is_admin' => 'je administrátor',
+		'users' => 'Uživatelé',
+	),
+);

+ 109 - 0
app/i18n/cz/feedback.php

@@ -0,0 +1,109 @@
+<?php
+
+return array(
+	'admin' => array(
+		'optimization_complete' => 'Optimalizace dokončena',
+	),
+	'access' => array(
+		'denied' => 'Nemáte oprávnění přistupovat na tuto stránku',
+		'not_found' => 'Tato stránka neexistuje',
+	),
+	'auth' => array(
+		'form' => array(
+			'not_set' => 'Nastal problém s konfigurací přihlašovacího systému. Zkuste to prosím později.',
+			'set' => 'Webový formulář je nyní výchozí přihlašovací systém.',
+		),
+		'login' => array(
+			'invalid' => 'Login není platný',
+			'success' => 'Jste přihlášen',
+		),
+		'logout' => array(
+			'success' => 'Jste odhlášen',
+		),
+		'no_password_set' => 'Heslo administrátora nebylo nastaveno. Tato funkce není k dispozici.',
+	),
+	'conf' => array(
+		'error' => 'Během ukládání nastavení došlo k chybě',
+		'query_created' => 'Dotaz "%s" byl vytvořen.',
+		'shortcuts_updated' => 'Zkratky byly aktualizovány',
+		'updated' => 'Nastavení bylo aktualizováno',
+	),
+	'extensions' => array(
+		'already_enabled' => '%s je již zapnut',
+		'disable' => array(
+			'ko' => '%s nelze vypnout. Pro více detailů <a href="%s">zkontrolujte logy FressRSS</a>.',
+			'ok' => '%s je nyní vypnut',
+		),
+		'enable' => array(
+			'ko' => '%s nelze zapnout. Pro více detailů <a href="%s">zkontrolujte logy FressRSS</a>.',
+			'ok' => '%s je nyní zapnut',
+		),
+		'no_access' => 'Nemáte přístup k %s',
+		'not_enabled' => '%s není ještě zapnut',
+		'not_found' => '%s neexistuje',
+	),
+	'import_export' => array(
+		'export_no_zip_extension' => 'Na serveru není naistalována podpora ZIP. Zkuste prosím exportovat soubory jeden po druhém.',
+		'feeds_imported' => 'Vaše kanály byly naimportovány a nyní budou aktualizovány',
+		'feeds_imported_with_errors' => 'Vaše kanály byly naimportovány, došlo ale k nějakým chybám',
+		'file_cannot_be_uploaded' => 'Soubor nelze nahrát!',
+		'no_zip_extension' => 'Na serveru není naistalována podpora ZIP.',
+		'zip_error' => 'Během importu ZIP souboru došlo k chybě.',
+	),
+	'sub' => array(
+		'actualize' => 'Aktualizovat',
+		'category' => array(
+			'created' => 'Kategorie %s byla vytvořena.',
+			'deleted' => 'Kategorie byla smazána.',
+			'emptied' => 'Kategorie byla vyprázdněna',
+			'error' => 'Kategorii nelze aktualizovat',
+			'name_exists' => 'Název kategorie již existuje.',
+			'no_id' => 'Musíte upřesnit id kategorie.',
+			'no_name' => 'Název kategorie nemůže být prázdný.',
+			'not_delete_default' => 'Nelze smazat výchozí kategorii!',
+			'not_exist' => 'Tato kategorie neexistuje!',
+			'over_max' => 'Dosáhl jste maximálního počtu kategorií (%d)',
+			'updated' => 'Kategorie byla aktualizována.',
+		),
+		'feed' => array(
+			'actualized' => '<em>%s</em> bylo aktualizováno',
+			'actualizeds' => 'RSS kanály byly aktualizovány',
+			'added' => 'RSS kanál <em>%s</em> byl přidán',
+			'already_subscribed' => 'Již jste přihlášen k odběru <em>%s</em>',
+			'deleted' => 'Kanál byl smazán',
+			'error' => 'Kanál nelze aktualizovat',
+			'internal_problem' => 'RSS kanál nelze přidat. Pro detaily <a href="%s">zkontrolujte logy FressRSS</a>.',
+			'invalid_url' => 'URL <em>%s</em> není platné',
+			'marked_read' => 'Kanály byly označeny jako přečtené',
+			'n_actualized' => '%d kanálů bylo aktualizováno',
+			'n_entries_deleted' => '%d článků bylo smazáno',
+			'no_refresh' => 'Nelze obnovit žádné kanály…',
+			'not_added' => '<em>%s</em> nemůže být přidán',
+			'over_max' => 'Dosáhl jste maximálního počtu kanálů (%d)',
+			'updated' => 'Kanál byl aktualizován',
+		),
+		'purge_completed' => 'Vyprázdněno (smazáno %d článků)',
+	),
+	'update' => array(
+		'can_apply' => 'FreshRSS bude nyní upgradováno na <strong>verzi %s</strong>.',
+		'error' => 'Během upgrade došlo k chybě: %s',
+		'file_is_nok' => 'Zkontrolujte oprávnění adresáře <em>%s</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+		'finished' => 'Upgrade hotov!',
+		'none' => 'Novější verze není k dispozici',
+		'server_not_found' => 'Nelze nalézt server s instalačním souborem. [%s]',
+	),
+	'user' => array(
+		'created' => array(
+			'_' => 'Uživatel %s byl vytvořen',
+			'error' => 'Uživatele %s nelze vytvořit',
+		),
+		'deleted' => array(
+			'_' => 'Uživatel %s byl smazán',
+			'error' => 'Uživatele %s nelze smazat',
+		),
+	),
+	'profile' => array(
+		'error' => 'Váš profil nelze změnit',
+		'updated' => 'Váš profil byl změněn',
+	),
+);

+ 181 - 0
app/i18n/cz/gen.php

@@ -0,0 +1,181 @@
+<?php
+
+return array(
+	'action' => array(
+		'actualize' => 'Aktualizovat',
+		'back_to_rss_feeds' => '← Zpět na seznam RSS kanálů',
+		'cancel' => 'Zrušit',
+		'create' => 'Vytvořit',
+		'disable' => 'Zakázat',
+		'empty' => 'Vyprázdnit',
+		'enable' => 'Povolit',
+		'export' => 'Export',
+		'filter' => 'Filtrovat',
+		'import' => 'Import',
+		'manage' => 'Spravovat',
+		'mark_read' => 'Označit jako přečtené',
+		'mark_favorite' => 'Označit jako oblíbené',
+		'remove' => 'Odstranit',
+		'see_website' => 'Navštívit WWW stránku',
+		'submit' => 'Odeslat',
+		'truncate' => 'Smazat všechny články',
+	),
+	'auth' => array(
+		'email' => 'Email',
+		'keep_logged_in' => 'Zapamatovat přihlášení <small>(%s dny)</small>',
+		'login' => 'Login',
+		'logout' => 'Odhlášení',
+		'password' =>  array(
+			'_' => 'Heslo',
+			'format' => '<small>Alespoň 7 znaků</small>',
+		),
+		'registration' => array(
+			'_' => 'Nový účet',
+			'ask' => 'Vytvořit účet?',
+			'title' => 'Vytvoření účtu',
+		),
+		'reset' => 'Reset přihlášení',
+		'username' => array(
+			'_' => 'Uživatel',
+			'admin' => 'Název administrátorského účtu',
+			'format' => '<small>maximálně 16 alfanumerických znaků</small>',
+		),
+	),
+	'date' => array(
+		'Apr' => '\\D\\u\\b\\e\\n',
+		'Aug' => '\\S\\r\\p\\e\\n',
+		'Dec' => '\\P\\r\\o\\s\\i\\n\\e\\c',
+		'Feb' => '\\Ú\\n\\o\\r',
+		'Jan' => '\\L\\e\\d\\e\\n',
+		'Jul' => '\\Č\\e\\r\\v\\e\\n\\e\\c',
+		'Jun' => '\\Č\\e\\r\\v\\e\\n',
+		'Mar' => '\\B\\ř\\e\\z\\e\\n',
+		'May' => '\\K\\v\\ě\\t\\e\\n',
+		'Nov' => '\\L\\i\\s\\t\\o\\p\\a\\d',
+		'Oct' => '\\Ř\\í\\j\\e\\n',
+		'Sep' => '\\Z\\á\\ř\\í',
+		'apr' => 'dub',
+		'april' => 'Dub',
+		'aug' => 'srp',
+		'august' => 'Srp',
+		'before_yesterday' => 'Předevčírem',
+		'dec' => 'pro',
+		'december' => 'Pro',
+		'feb' => 'úno',
+		'february' => 'Úno',
+		'format_date' => 'j\\. %s Y',
+		'format_date_hour' => 'j\\. %s Y \\v H\\:i',
+		'fri' => 'Pá',
+		'jan' => 'led',
+		'january' => 'Led',
+		'jul' => 'čvn',
+		'july' => 'Čvn',
+		'jun' => 'čer',
+		'june' => 'Čer',
+		'last_3_month' => 'Minulé tři měsíce',
+		'last_6_month' => 'Minulých šest měsíců',
+		'last_month' => 'Minulý měsíc',
+		'last_week' => 'Minulý týden',
+		'last_year' => 'Minulý rok',
+		'mar' => 'bře',
+		'march' => 'Bře',
+		'may' => 'Kvě',
+		'mon' => 'Po',
+		'month' => 'měsíce',
+		'nov' => 'lis',
+		'november' => 'Lis',
+		'oct' => 'říj',
+		'october' => 'Říj',
+		'sat' => 'So',
+		'sep' => 'zář',
+		'september' => 'Zář',
+		'sun' => 'Ne',
+		'thu' => 'Čt',
+		'today' => 'Dnes',
+		'tue' => 'Út',
+		'wed' => 'St',
+		'yesterday' => 'Včera',
+	),
+	'freshrss' => array(
+		'_' => 'FreshRSS',
+		'about' => 'O FreshRSS',
+	),
+	'js' => array(
+		'category_empty' => 'Prázdná kategorie',
+		'confirm_action' => 'Jste si jist, že chcete provést tuto akci? Změny nelze vrátit zpět!',
+		'confirm_action_feed_cat' => 'Jste si jist, že chcete provést tuto akci? Přijdete o související oblíbené položky a uživatelské dotazy. Změny nelze vrátit zpět!',
+		'feedback' => array(
+			'body_new_articles' => 'Je %%d nových článků k přečtení v FreshRSS.',
+			'request_failed' => 'Požadavek selhal, což může být způsobeno problémy s připojení k internetu.',
+			'title_new_articles' => 'FreshRSS: nové články!',
+		),
+		'new_article' => 'Jsou k dispozici nové články, stránku obnovíte kliknutím zde.',
+		'should_be_activated' => 'JavaScript musí být povolen',
+	),
+	'lang' => array(
+		'cz' => 'Čeština',
+		'de' => 'Deutsch',
+		'en' => 'English',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'nl' => 'Nederlands',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+	),
+	'menu' => array(
+		'about' => 'O aplikaci',
+		'admin' => 'Administrace',
+		'archiving' => 'Archivace',
+		'authentication' => 'Přihlášení',
+		'check_install' => 'Ověření instalace',
+		'configuration' => 'Nastavení',
+		'display' => 'Zobrazení',
+		'extensions' => 'Rozšíření',
+		'logs' => 'Logy',
+		'queries' => 'Uživatelské dotazy',
+		'reading' => 'Čtení',
+		'search' => 'Hledat výraz nebo #tagy',
+		'sharing' => 'Sdílení',
+		'shortcuts' => 'Zkratky',
+		'stats' => 'Statistika',
+		'system' => 'System configuration',// @todo translate
+		'update' => 'Aktualizace',
+		'user_management' => 'Správa uživatelů',
+		'user_profile' => 'Profil',
+	),
+	'pagination' => array(
+		'first' => 'První',
+		'last' => 'Poslední',
+		'load_more' => 'Načíst více článků',
+		'mark_all_read' => 'Označit vše jako přečtené',
+		'next' => 'Další',
+		'nothing_to_load' => 'Žádné nové články',
+		'previous' => 'Předchozí',
+	),
+	'share' => array(
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'movim' => 'Movim',
+		'print' => 'Tisk',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
+		'jdh' => 'Journal du hacker',
+	),
+	'short' => array(
+		'attention' => 'Upozornění!',
+		'blank_to_disable' => 'Zakázat - ponechte prázdné',
+		'by_author' => 'Od <em>%s</em>',
+		'by_default' => 'Výchozí',
+		'damn' => 'Sakra!',
+		'default_category' => 'Nezařazeno',
+		'no' => 'Ne',
+		'ok' => 'Ok!',
+		'or' => 'nebo',
+		'yes' => 'Ano',
+	),
+);

+ 61 - 0
app/i18n/cz/index.php

@@ -0,0 +1,61 @@
+<?php
+
+return array(
+	'about' => array(
+		'_' => 'O FreshRSS',
+		'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
+		'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.',
+		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">na Github</a>',
+		'license' => 'Licence',
+		'project_website' => 'Stránka projektu',
+		'title' => 'O FreshRSS',
+		'version' => 'Verze',
+		'website' => 'Webové stránka',
+	),
+	'feed' => array(
+		'add' => 'Můžete přidat kanály.',
+		'empty' => 'Žádné články k zobrazení.',
+		'rss_of' => 'RSS kanál %s',
+		'title' => 'RSS kanály',
+		'title_global' => 'Přehled',
+		'title_fav' => 'Oblíbené',
+	),
+	'log' => array(
+		'_' => 'Logy',
+		'clear' => 'Vymazat logy',
+		'empty' => 'Log je prázdný',
+		'title' => 'Logy',
+	),
+	'menu' => array(
+		'about' => 'O FreshRSS',
+		'add_query' => 'Vytvořit dotaz',
+		'before_one_day' => 'Den nazpět',
+		'before_one_week' => 'Před týdnem',
+		'favorites' => 'Oblíbené (%s)',
+		'global_view' => 'Přehled',
+		'main_stream' => 'Všechny kanály',
+		'mark_all_read' => 'Označit vše jako přečtené',
+		'mark_cat_read' => 'Označit kategorii jako přečtenou',
+		'mark_feed_read' => 'Označit kanál jako přečtený',
+		'newer_first' => 'Nové nejdříve',
+		'non-starred' => 'Zobrazit vše vyjma oblíbených',
+		'normal_view' => 'Normální',
+		'older_first' => 'Nejstarší nejdříve',
+		'queries' => 'Uživatelské dotazy',
+		'read' => 'Zobrazovat přečtené',
+		'reader_view' => 'Čtení',
+		'rss_view' => 'RSS kanál',
+		'search_short' => 'Hledat',
+		'starred' => 'Zobrazit oblíbené',
+		'stats' => 'Statistika',
+		'subscription' => 'Správa subskripcí',
+		'unread' => 'Zobrazovat nepřečtené',
+	),
+	'share' => 'Sdílet',
+	'tag' => array(
+		'related' => 'Související tagy',
+	),
+);

+ 119 - 0
app/i18n/cz/install.php

@@ -0,0 +1,119 @@
+<?php
+
+return array(
+	'action' => array(
+		'finish' => 'Dokončit instalaci',
+		'fix_errors_before' => 'Chyby prosím před přechodem na další krok opravte.',
+		'keep_install' => 'Zachovat předchozí instalaci',
+		'next_step' => 'Přejít na další krok',
+		'reinstall' => 'Reinstalovat FreshRSS',
+	),
+	'auth' => array(
+		'form' => 'Webový formulář (tradiční, vyžaduje JavaScript)',
+		'http' => 'HTTP (pro pokročilé uživatele s HTTPS)',
+		'none' => 'Žádný (nebezpečné)',
+		'password_form' => 'Heslo<br /><small>(pro přihlášení webovým formulářem)</small>',
+		'password_format' => 'Alespoň 7 znaků',
+		'type' => 'Způsob přihlášení',
+	),
+	'bdd' => array(
+		'_' => 'Databáze',
+		'conf' => array(
+			'_' => 'Nastavení databáze',
+			'ko' => 'Ověřte informace o databázi.',
+			'ok' => 'Nastavení databáze bylo uloženo.',
+		),
+		'host' => 'Hostitel',
+		'prefix' => 'Prefix tabulky',
+		'password' => 'Heslo',
+		'type' => 'Typ databáze',
+		'username' => 'Uživatel',
+	),
+	'check' => array(
+		'_' => 'Kontrola',
+		'already_installed' => 'Zjistili jsme, že FreshRSS je již nainstalován!',
+		'cache' => array(
+			'nok' => 'Zkontrolujte oprávnění adresáře <em>./data/cache</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře cache jsou v pořádku.',
+		),
+		'ctype' => array(
+			'nok' => 'Není nainstalována požadovaná knihovna pro ověřování znaků (php-ctype).',
+			'ok' => 'Je nainstalována požadovaná knihovna pro ověřování znaků (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Nemáte cURL (balíček php-curl).',
+			'ok' => 'Máte rozšíření cURL.',
+		),
+		'data' => array(
+		'nok' => 'Zkontrolujte oprávnění adresáře <em>./data</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře data jsou v pořádku.',
+		),
+		'dom' => array(
+			'nok' => 'Nemáte požadovanou knihovnu pro procházení DOM.',
+			'ok' => 'Máte požadovanou knihovnu pro procházení DOM.',
+		),
+		'favicons' => array(
+			'nok' => 'Zkontrolujte oprávnění adresáře <em>./data/favicons</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře favicons jsou v pořádku.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Nemáte PHP fileinfo (balíček fileinfo).',
+			'ok' => 'Máte rozšíření fileinfo.',
+		),
+		'http_referer' => array(
+			'nok' => 'Zkontrolujte prosím že neměníte HTTP REFERER.',
+			'ok' => 'Váš HTTP REFERER je znám a odpovídá Vašemu serveru.',
+		),
+		'json' => array(
+			'nok' => 'Pro parsování JSON chybí doporučená knihovna.',
+			'ok' => 'Máte doporučenou knihovnu pro parsování JSON.',
+		),
+		'minz' => array(
+			'nok' => 'Nemáte framework Minz.',
+			'ok' => 'Máte framework Minz.',
+		),
+		'pcre' => array(
+			'nok' => 'Nemáte požadovanou knihovnu pro regulární výrazy (php-pcre).',
+			'ok' => 'Máte požadovanou knihovnu pro regulární výrazy (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Nemáte PDO nebo některý z podporovaných ovladačů (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'Máte PDO a alespoň jeden z podporovaných ovladačů (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'nok' => 'Vaše verze PHP je %s, ale FreshRSS vyžaduje alespoň verzi %s.',
+			'ok' => 'Vaše verze PHP je %s a je kompatibilní s FreshRSS.',
+		),
+		'users' => array(
+			'nok' => 'Zkontrolujte oprávnění adresáře <em>./data/users</em>. HTTP server musí mít do tohoto adresáře práva zápisu',
+			'ok' => 'Oprávnění adresáře users jsou v pořádku.',
+		),
+		'xml' => array(
+			'nok' => 'Pro parsování XML chybí požadovaná knihovna.',
+			'ok' => 'Máte požadovanou knihovnu pro parsování XML.',
+		),
+	),
+	'conf' => array(
+		'_' => 'Obecná nastavení',
+		'ok' => 'Nastavení bylo uloženo.',
+	),
+	'congratulations' => 'Gratulujeme!',
+	'default_user' => 'Jméno výchozího uživatele <small>(maximálně 16 alfanumerických znaků)</small>',
+	'delete_articles_after' => 'Smazat články starší než',
+	'fix_errors_before' => 'Chyby prosím před přechodem na další krok opravte.',
+	'javascript_is_better' => 'Práce s FreshRSS je příjemnější se zapnutým JavaScriptem',
+	'js' => array(
+		'confirm_reinstall' => 'Reinstalací FreshRSS ztratíte předchozí konfiguraci. Opravdu chcete pokračovat?',
+	),
+	'language' => array(
+		'_' => 'Jazyk',
+		'choose' => 'Vyberte jazyk FreshRSS',
+		'defined' => 'Jazyk byl nastaven.',
+	),
+	'not_deleted' => 'Nastala chyba, soubor <em>%s</em> musíte smazat ručně.',
+	'ok' => 'Instalace byla úspěšná.',
+	'step' => 'krok %d',
+	'steps' => 'Kroky',
+	'title' => 'Instalace · FreshRSS',
+	'this_is_the_end' => 'Konec',
+);

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

@@ -0,0 +1,62 @@
+<?php
+
+return array(
+	'category' => array(
+		'_' => 'Kategorie',
+		'add' => 'Přidat kategorii',
+		'empty' => 'Vyprázdit kategorii',
+		'new' => 'Nová kategorie',
+	),
+	'feed' => array(
+		'add' => 'Přidat RSS kanál',
+		'advanced' => 'Pokročilé',
+		'archiving' => 'Archivace',
+		'auth' => array(
+			'configuration' => 'Přihlášení',
+			'help' => 'Umožní přístup k RSS kanálům chráneným HTTP autentizací',
+			'http' => 'HTTP přihlášení',
+			'password' => 'Heslo',
+			'username' => 'Přihlašovací jméno',
+		),
+		'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',
+		'empty' => 'Kanál je prázdný. Ověřte prosím zda je ještě autorem udržován.',
+		'error' => 'Vyskytl se problém s kanálem. Ověřte že je vždy dostupný, prosím, a poté jej aktualizujte.',
+		'in_main_stream' => 'Zobrazit ve “Všechny kanály”',
+		'informations' => 'Informace',
+		'keep_history' => 'Zachovat tento minimální počet článků',
+		'moved_category_deleted' => 'Po smazání kategorie budou v ní obsažené kanály automaticky přesunuty do <em>%s</em>.',
+		'no_selected' => 'Nejsou označeny žádné kanály.',
+		'number_entries' => '%d článků',
+		'stats' => 'Statistika',
+		'think_to_add' => 'Můžete přidat kanály.',
+		'title' => 'Název',
+		'title_add' => 'Přidat RSS kanál',
+		'ttl' => 'Neobnovovat častěji než',
+		'url' => 'URL kanálu',
+		'validator' => 'Zkontrolovat platnost kanálu',
+		'website' => 'URL webové stránky',
+		'pubsubhubbub' => 'Okamžité oznámení s PubSubHubbub',
+	),
+	'import_export' => array(
+		'export' => 'Export',
+		'export_opml' => 'Exportovat seznam kanálů (OPML)',
+		'export_starred' => 'Exportovat oblíbené',
+		'feed_list' => 'Seznam %s článků',
+		'file_to_import' => 'Soubor k importu<br />(OPML, JSON nebo ZIP)',
+		'file_to_import_no_zip' => 'Soubor k importu<br />(OPML nebo JSON)',
+		'import' => 'Import',
+		'starred_list' => 'Seznam oblíbených článků',
+		'title' => 'Import / export',
+	),
+	'menu' => array(
+		'bookmark' => 'Přihlásit (FreshRSS bookmark)',
+		'import_export' => 'Import / export',
+		'subscription_management' => 'Správa subskripcí',
+	),
+	'title' => array(
+		'_' => 'Správa subskripcí',
+		'feed_management' => 'Správa RSS kanálů',
+	),
+);

+ 181 - 0
app/i18n/de/admin.php

@@ -0,0 +1,181 @@
+<?php
+
+return array(
+	'auth' => array(
+		'allow_anonymous' => 'Anonymes Lesen der Artikel des Standardbenutzers (%s) erlauben',
+		'allow_anonymous_refresh' => 'Anonymes Aktualisieren der Artikel erlauben',
+		'api_enabled' => '<abbr>API</abbr>-Zugriff erlauben <small>(für mobile Anwendungen benötigt)</small>',
+		'form' => 'Webformular (traditionell, benötigt JavaScript)',
+		'http' => 'HTTP (HTTPS für erfahrene Benutzer)',
+		'none' => 'Keine (gefährlich)',
+		'title' => 'Authentifizierung',
+		'title_reset' => 'Zurücksetzen der Authentifizierung',
+		'token' => 'Authentifizierungs-Token',
+		'token_help' => 'Erlaubt den Zugriff auf die RSS-Ausgabe des Standardbenutzers ohne Authentifizierung.',
+		'type' => 'Authentifizierungsmethode',
+		'unsafe_autologin' => 'Erlaube unsicheres automatisches Anmelden mit folgendem Format: ',
+	),
+	'check_install' => array(
+		'cache' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/cache</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data/cache</em> sind in Ordnung.',
+		),
+		'categories' => array(
+			'nok' => 'Die Tabelle <em>category</em> ist schlecht konfiguriert.',
+			'ok' => 'Die Tabelle <em>category</em> ist korrekt konfiguriert.',
+		),
+		'connection' => array(
+			'nok' => 'Verbindung zur Datenbank kann nicht aufgebaut werden.',
+			'ok' => 'Verbindung zur Datenbank konnte aufgebaut werden.',
+		),
+		'ctype' => array(
+			'nok' => 'Ihnen fehlt eine benötigte Bibliothek für die Überprüfung von Zeichentypen (php-ctype).',
+			'ok' => 'Sie haben die benötigte Bibliothek für die Überprüfung von Zeichentypen (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Ihnen fehlt cURL (Paket php-curl).',
+			'ok' => 'Sie haben die cURL-Erweiterung.',
+		),
+		'data' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data</em> sind in Ordnung.',
+		),
+		'database' => 'Datenbank-Installation',
+		'dom' => array(
+			'nok' => 'Ihnen fehlt eine benötigte Bibliothek um DOM zu durchstöbern (Paket php-xml).',
+			'ok' => 'Sie haben die benötigte Bibliothek um DOM zu durchstöbern.',
+		),
+		'entries' => array(
+			'nok' => 'Die Tabelle <em>entry</em> ist schlecht konfiguriert.',
+			'ok' => 'Die Tabelle <em>entry</em> ist korrekt konfiguriert.',
+		),
+		'favicons' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/favicons</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data/favicons</em> sind in Ordnung.',
+		),
+		'feeds' => array(
+			'nok' => 'Die Tabelle <em>feed</em> ist schlecht konfiguriert.',
+			'ok' => 'Die Tabelle <em>feed</em> ist korrekt konfiguriert.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Ihnen fehlt PHP fileinfo (Paket fileinfo).',
+			'ok' => 'Sie haben die fileinfo-Erweiterung.',
+		),
+		'files' => 'Datei-Installation',
+		'json' => array(
+			'nok' => 'Ihnen fehlt die JSON-Erweiterung (Paket php5-json).',
+			'ok' => 'Sie haben die JSON-Erweiterung.',
+		),
+		'minz' => array(
+			'nok' => 'Ihnen fehlt das Minz-Framework.',
+			'ok' => 'Sie haben das Minz-Framework.',
+		),
+		'pcre' => array(
+			'nok' => 'Ihnen fehlt eine benötigte Bibliothek für reguläre Ausdrücke (php-pcre).',
+			'ok' => 'Sie haben die benötigte Bibliothek für reguläre Ausdrücke (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Ihnen fehlt PDO oder einer der unterstützten Treiber (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'Sie haben PDO und mindestens einen der unterstützten Treiber (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'_' => 'PHP-Installation',
+			'nok' => 'Ihre PHP-Version ist %s aber FreshRSS benötigt mindestens Version %s.',
+			'ok' => 'Ihre PHP-Version ist %s, welche kompatibel mit FreshRSS ist.',
+		),
+		'tables' => array(
+			'nok' => 'Es fehlen eine oder mehrere Tabellen in der Datenbank.',
+			'ok' => 'Tabellen existieren in der Datenbank.',
+		),
+		'title' => 'Installationsüberprüfung',
+		'tokens' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/tokens</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data/tokens</em> sind in Ordnung.',
+		),
+		'users' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/users</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data/users</em> sind in Ordnung.',
+		),
+		'zip' => array(
+			'nok' => 'Ihnen fehlt die ZIP-Erweiterung (Paket php-zip).',
+			'ok' => 'Sie haben die ZIP-Erweiterung.',
+		),
+	),
+	'extensions' => array(
+		'disabled' => 'Deaktiviert',
+		'empty_list' => 'Es gibt keine installierte Erweiterung.',
+		'enabled' => 'Aktiviert',
+		'no_configure_view' => 'Diese Erweiterung kann nicht konfiguriert werden.',
+		'system' => array(
+			'_' => 'System-Erweiterungen',
+			'no_rights' => 'System-Erweiterung (Sie haben keine Berechtigung dafür)',
+		),
+		'title' => 'Erweiterungen',
+		'user' => 'Benutzer-Erweiterungen',
+	),
+	'stats' => array(
+		'_' => 'Statistiken',
+		'all_feeds' => 'Alle Feeds',
+		'category' => 'Kategorie',
+		'entry_count' => 'Anzahl der Einträge',
+		'entry_per_category' => 'Einträge pro Kategorie',
+		'entry_per_day' => 'Einträge pro Tag (letzten 30 Tage)',
+		'entry_per_day_of_week' => 'Pro Wochentag (Durchschnitt: %.2f Nachrichten)',
+		'entry_per_hour' => 'Pro Stunde (Durchschnitt: %.2f Nachrichten)',
+		'entry_per_month' => 'Pro Monat (Durchschnitt: %.2f Nachrichten)',
+		'entry_repartition' => 'Einträge-Verteilung',
+		'feed' => 'Feed',
+		'feed_per_category' => 'Feeds pro Kategorie',
+		'idle' => 'Inaktive Feeds',
+		'main' => 'Haupt-Statistiken',
+		'main_stream' => 'Haupt-Feeds',
+		'menu' => array(
+			'idle' => 'Inaktive Feeds',
+			'main' => 'Haupt-Statistiken',
+			'repartition' => 'Artikel-Verteilung',
+		),
+		'no_idle' => 'Es gibt keinen inaktiven Feed!',
+		'number_entries' => '%d Artikel',
+		'percent_of_total' => '%% Gesamt',
+		'repartition' => 'Artikel-Verteilung',
+		'status_favorites' => 'Favoriten',
+		'status_read' => 'Gelesen',
+		'status_total' => 'Gesamt',
+		'status_unread' => 'Ungelesen',
+		'title' => 'Statistiken',
+		'top_feed' => 'Top 10-Feeds',
+	),
+	'system' => array(
+		'_' => 'System configuration', // @todo translate
+		'auto-update-url' => 'Auto-update server URL', // @todo translate
+		'instance-name' => 'Instance name', // @todo translate
+		'max-categories' => 'Categories per user limit', // @todo translate
+		'max-feeds' => 'Feeds per user limit', // @todo translate
+		'registration' => array(
+			'help' => '0 meint, dass es kein Account Limit gibt',
+			'number' => 'Maximale Anzahl von Accounts',
+		),
+	),
+	'update' => array(
+		'_' => 'System aktualisieren',
+		'apply' => 'Anwenden',
+		'check' => 'Auf neue Aktualisierungen prüfen',
+		'current_version' => 'Ihre aktuelle Version von FreshRSS ist %s.',
+		'last' => 'Letzte Überprüfung: %s',
+		'none' => 'Keine ausstehende Aktualisierung',
+		'title' => 'System aktualisieren',
+	),
+	'user' => array(
+		'articles_and_size' => '%s Artikel (%s)',
+		'create' => 'Neuen Benutzer erstellen',
+		'language' => 'Sprache',
+		'number' => 'Es wurde bis jetzt %d Account erstellt',
+		'numbers' => 'Es wurden bis jetzt %d Accounts erstellt',
+		'password_form' => 'Passwort<br /><small>(für die Anmeldemethode per Webformular)</small>',
+		'password_format' => 'mindestens 7 Zeichen',
+		'title' => 'Benutzer verwalten',
+		'user_list' => 'Liste der Benutzer',
+		'username' => 'Nutzername',
+		'users' => 'Benutzer',
+	),
+);

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

@@ -0,0 +1,173 @@
+<?php
+
+return array(
+	'archiving' => array(
+		'_' => 'Archivierung',
+		'advanced' => 'Erweitert',
+		'delete_after' => 'Entferne Artikel nach',
+		'help' => 'Weitere Optionen sind in den Einstellungen der individuellen Feeds verfügbar.',
+		'keep_history_by_feed' => 'Minimale Anzahl an Artikeln, die pro Feed behalten werden',
+		'optimize' => 'Datenbank optimieren',
+		'optimize_help' => 'Sollte gelegentlich durchgeführt werden, um die Größe der Datenbank zu reduzieren.',
+		'purge_now' => 'Jetzt bereinigen',
+		'title' => 'Archivierung',
+		'ttl' => 'Aktualisiere automatisch nicht öfter als',
+	),
+	'display' => array(
+		'_' => 'Anzeige',
+		'icon' => array(
+			'bottom_line' => 'Fußzeile',
+			'entry' => 'Artikel-Symbole',
+			'publication_date' => 'Datum der Veröffentlichung',
+			'related_tags' => 'Verwandte Tags',
+			'sharing' => 'Teilen',
+			'top_line' => 'Kopfzeile',
+		),
+		'language' => 'Sprache',
+		'notif_html5' => array(
+			'seconds' => 'Sekunden (0 bedeutet keine Zeitüberschreitung)',
+			'timeout' => 'Zeitüberschreitung für HTML5-Benachrichtigung',
+		),
+		'theme' => 'Erscheinungsbild',
+		'title' => 'Anzeige',
+		'width' => array(
+			'content' => 'Inhaltsbreite',
+			'large' => 'Gross',
+			'medium' => 'Mittel',
+			'no_limit' => 'Keine Begrenzung',
+			'thin' => 'Klein',
+		),
+	),
+	'query' => array(
+		'_' => 'Benutzerabfragen',
+		'deprecated' => 'Diese Abfrage ist nicht länger gültig. Die referenzierte Kategorie oder der Feed ist gelöscht worden.',
+		'filter' => 'Angewendeter Filter:',
+		'get_all' => 'Alle Artikel anzeigen',
+		'get_category' => 'Kategorie "%s" anzeigen',
+		'get_favorite' => 'Lieblingsartikel anzeigen',
+		'get_feed' => 'Feed "%s" anzeigen',
+		'no_filter' => 'Kein Filter',
+		'none' => 'Sie haben bisher keine Benutzerabfrage erstellt.',
+		'number' => 'Abfrage Nr. %d',
+		'order_asc' => 'Älteste Artikel zuerst anzeigen',
+		'order_desc' => 'Neueste Artikel zuerst anzeigen',
+		'search' => 'Suche nach "%s"',
+		'state_0' => 'Alle Artikel anzeigen',
+		'state_1' => 'Gelesene Artikel anzeigen',
+		'state_2' => 'Ungelesene Artikel anzeigen',
+		'state_3' => 'Alle Artikel anzeigen',
+		'state_4' => 'Lieblingsartikel anzeigen',
+		'state_5' => 'Gelesene Lieblingsartikel anzeigen',
+		'state_6' => 'Ungelesene Lieblingsartikel anzeigen',
+		'state_7' => 'Lieblingsartikel anzeigen',
+		'state_8' => 'Keine Lieblingsartikel anzeigen',
+		'state_9' => 'Gelesene ohne Lieblingsartikel anzeigen',
+		'state_10' => 'Ungelesene ohne Lieblingsartikel anzeigen',
+		'state_11' => 'Keine Lieblingsartikel anzeigen',
+		'state_12' => 'Alle Artikel anzeigen',
+		'state_13' => 'Gelesene Artikel anzeigen',
+		'state_14' => 'Ungelesene Artikel anzeigen',
+		'state_15' => 'Alle Artikel anzeigen',
+		'title' => 'Benutzerabfragen',
+	),
+	'profile' => array(
+		'_' => 'Profil-Verwaltung',
+		'delete' => array(
+			'_' => 'Accountlöschung',
+			'warn' => 'Dein Account und alle damit bezogenen Daten werden gelöscht.',
+		),
+		'password_api' => 'Passwort-API<br /><small>(z. B. für mobile Anwendungen)</small>',
+		'password_form' => 'Passwort<br /><small>(für die Anmeldemethode per Webformular)</small>',
+		'password_format' => 'mindestens 7 Zeichen',
+		'title' => 'Profil',
+	),
+	'reading' => array(
+		'_' => 'Lesen',
+		'after_onread' => 'Nach „Alle als gelesen markieren“,',
+		'articles_per_page' => 'Anzahl der Artikel pro Seite',
+		'auto_load_more' => 'Die nächsten Artikel am Seitenende laden',
+		'auto_remove_article' => 'Artikel nach dem Lesen verstecken',
+		'mark_updated_article_unread' => 'Markieren Sie aktualisierte Artikel als ungelesen',
+		'confirm_enabled' => 'Bei der Aktion „Alle als gelesen markieren“ einen Bestätigungsdialog anzeigen',
+		'display_articles_unfolded' => 'Artikel standardmäßig ausgeklappt zeigen',
+		'display_categories_unfolded' => 'Kategorien standardmäßig eingeklappt zeigen',
+		'hide_read_feeds' => 'Kategorien & Feeds ohne ungelesene Artikel verstecken (funktioniert nicht mit der Einstellung „Alle Artikel zeigen“)',
+		'img_with_lazyload' => 'Verwende die "träges Laden"-Methode zum Laden von Bildern',
+		'jump_next' => 'springe zum nächsten ungelesenen Geschwisterelement (Feed oder Kategorie)',
+		'number_divided_when_reader' => 'Geteilt durch 2 in der Lese-Ansicht.',
+		'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',
+			'upon_reception' => 'beim Empfang des Artikels',
+			'when' => 'Artikel als gelesen markieren…',
+		),
+		'show' => array(
+			'_' => 	'Artikel zum Anzeigen',
+			'adaptive' => 'Anzeige anpassen',
+			'all_articles' => 'Alle Artikel zeigen',
+			'unread' => 'Nur ungelesene zeigen',
+		),
+		'sort' => array(
+			'_' => 'Sortierreihenfolge',
+			'newer_first' => 'Neuere zuerst',
+			'older_first' => 'Ältere zuerst',
+		),
+		'sticky_post' => 'Wenn geöffnet, den Artikel ganz oben anheften',
+		'title' => 'Lesen',
+		'view' => array(
+			'default' => 'Standard-Ansicht',
+			'global' => 'Globale Ansicht',
+			'normal' => 'Normale Ansicht',
+			'reader' => 'Lese-Ansicht',
+		),
+	),
+	'sharing' => array(
+		'_' => 'Teilen',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'E-Mail',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'more_information' => 'Weitere Informationen',
+		'print' => 'Drucken',
+		'shaarli' => 'Shaarli',
+		'share_name' => 'Anzuzeigender Teilen-Name',
+		'share_url' => 'Zu verwendende Teilen-URL',
+		'title' => 'Teilen',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag',
+	),
+	'shortcut' => array(
+		'_' => 'Tastenkombination',
+		'article_action' => 'Artikelaktionen',
+		'auto_share' => 'Teilen',
+		'auto_share_help' => 'Wenn es nur eine Option zum Teilen gibt, wird diese verwendet. Ansonsten sind die Optionen über ihre Nummer erreichbar.',
+		'close_dropdown' => 'Menüs schließen',
+		'collapse_article' => 'Einklappen',
+		'first_article' => 'Zum ersten Artikel springen',
+		'focus_search' => 'Auf das Suchfeld zugreifen',
+		'help' => 'Dokumentation anzeigen',
+		'javascript' => 'JavaScript muss aktiviert sein, um Tastaturkürzel benutzen zu können',
+		'last_article' => 'Zum letzten Artikel springen',
+		'load_more' => 'Weitere Artikel laden',
+		'mark_read' => 'Als gelesen markieren',
+		'mark_favorite' => 'Als Favorit markieren',
+		'navigation' => 'Navigation',
+		'navigation_help' => 'Mit der "Umschalttaste" finden die Tastenkombination auf Feeds Anwendung.<br/>Mit der "Alt-Taste" finden die Tastenkombination auf Kategorien Anwendung.',
+		'next_article' => 'Zum nächsten Artikel springen',
+		'other_action' => 'Andere Aktionen',
+		'previous_article' => 'Zum vorherigen Artikel springen',
+		'see_on_website' => 'Auf der Original-Webseite ansehen',
+		'shift_for_all_read' => '+ <code>Umschalttaste</code>, um alle Artikel als gelesen zu markieren.',
+		'title' => 'Tastenkombination',
+		'user_filter' => 'Auf Benutzerfilter zugreifen',
+		'user_filter_help' => 'Wenn es nur einen Benutzerfilter gibt, wird dieser verwendet. Ansonsten sind die Filter über ihre Nummer erreichbar.',
+	),
+	'user' => array(
+		'articles_and_size' => '%s Artikel (%s)',
+		'current' => 'Aktueller Benutzer',
+		'is_admin' => 'ist Administrator',
+		'users' => 'Benutzer',
+	),
+);

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

@@ -0,0 +1,109 @@
+<?php
+
+return array(
+	'admin' => array(
+		'optimization_complete' => 'Optimierung abgeschlossen',
+	),
+	'access' => array(
+		'denied' => 'Sie haben nicht die Berechtigung, diese Seite aufzurufen',
+		'not_found' => 'Sie suchen nach einer Seite, die nicht existiert',
+	),
+	'auth' => array(
+		'form' => array(
+			'not_set' => 'Während der Konfiguration des Authentifikationssystems trat ein Fehler auf. Bitte versuchen Sie es später erneut.',
+			'set' => 'Formular ist ab sofort ihr Standard-Authentifikationssystem.',
+		),
+		'login' => array(
+			'invalid' => 'Anmeldung ist ungültig',
+			'success' => 'Sie sind angemeldet',
+		),
+		'logout' => array(
+			'success' => 'Sie sind abgemeldet',
+		),
+		'no_password_set' => 'Administrator-Passwort ist nicht gesetzt worden. Dieses Feature ist nicht verfügbar.',
+	),
+	'conf' => array(
+		'error' => 'Während der Speicherung der Konfiguration trat ein Fehler auf',
+		'query_created' => 'Abfrage "%s" ist erstellt worden.',
+		'shortcuts_updated' => 'Die Tastenkombinationen sind aktualisiert worden',
+		'updated' => 'Die Konfiguration ist aktualisiert worden',
+	),
+	'extensions' => array(
+		'already_enabled' => '%s ist bereits aktiviert',
+		'disable' => array(
+			'ko' => '%s kann nicht deaktiviert werden. Für Details <a href="%s">prüfen Sie die FressRSS-Protokolle</a>.',
+			'ok' => '%s ist jetzt deaktiviert',
+		),
+		'enable' => array(
+			'ko' => '%s kann nicht aktiviert werden. Für Details <a href="%s">prüfen Sie die FressRSS-Protokolle</a>.',
+			'ok' => '%s ist jetzt aktiviert',
+		),
+		'no_access' => 'Sie haben keinen Zugang zu %s',
+		'not_enabled' => '%s ist noch nicht aktiviert',
+		'not_found' => '%s existiert nicht',
+	),
+	'import_export' => array(
+		'export_no_zip_extension' => 'Die ZIP-Erweiterung fehlt auf Ihrem Server. Bitte versuchen Sie die Dateien eine nach der anderen zu exportieren.',
+		'feeds_imported' => 'Ihre Feeds sind importiert worden und werden jetzt aktualisiert',
+		'feeds_imported_with_errors' => 'Ihre Feeds sind importiert worden, aber es traten einige Fehler auf',
+		'file_cannot_be_uploaded' => 'Die Datei kann nicht hochgeladen werden!',
+		'no_zip_extension' => 'Die ZIP-Erweiterung ist auf Ihrem Server nicht vorhanden.',
+		'zip_error' => 'Ein Fehler trat während des ZIP-Imports auf.',
+	),
+	'sub' => array(
+		'actualize' => 'Aktualisieren',
+		'category' => array(
+			'created' => 'Die Kategorie %s ist erstellt worden.',
+			'deleted' => 'Die Kategorie ist gelöscht worden.',
+			'emptied' => 'Die Kategorie ist geleert worden.',
+			'error' => 'Die Kategorie kann nicht aktualisiert werden',
+			'name_exists' => 'Der Kategorie-Name existiert bereits.',
+			'no_id' => 'Sie müssen die ID der Kategorie präzisieren.',
+			'no_name' => 'Der Kategorie-Name kann nicht leer sein.',
+			'not_delete_default' => 'Sie können die Vorgabe-Kategorie nicht löschen!',
+			'not_exist' => 'Die Kategorie existiert nicht!',
+			'over_max' => 'Sie haben Ihre Kategorien-Limite erreicht (%d)',
+			'updated' => 'Die Kategorie ist aktualisiert worden.',
+		),
+		'feed' => array(
+			'actualized' => '<em>%s</em> ist aktualisiert worden',
+			'actualizeds' => 'Die RSS-Feeds sind aktualisiert worden',
+			'added' => 'Der RSS-Feed <em>%s</em> ist hinzugefügt worden',
+			'already_subscribed' => 'Sie haben <em>%s</em> bereits abonniert',
+			'deleted' => 'Der Feed ist gelöscht worden',
+			'error' => 'Der Feed kann nicht aktualisiert werden',
+			'internal_problem' => 'Der RSS-Feed konnte nicht hinzugefügt werden. Für Details <a href="%s">prüfen Sie die FressRSS-Protokolle</a>.',
+			'invalid_url' => 'Die URL <em>%s</em> ist ungültig',
+			'marked_read' => 'Die Feeds sind als gelesen markiert worden',
+			'n_actualized' => 'Die %d Feeds sind aktualisiert worden',
+			'n_entries_deleted' => 'Die %d Artikel sind gelöscht worden',
+			'no_refresh' => 'Es gibt keinen Feed zum Aktualisieren…',
+			'not_added' => '<em>%s</em> konnte nicht hinzugefügt werden',
+			'over_max' => 'Sie haben Ihre Feeds-Limite erreicht (%d)',
+			'updated' => 'Der Feed ist aktualisiert worden',
+		),
+		'purge_completed' => 'Bereinigung abgeschlossen (%d Artikel gelöscht)',
+	),
+	'update' => array(
+		'can_apply' => 'FreshRSS wird nun auf die <strong>Version %s</strong> aktualisiert.',
+		'error' => 'Der Aktualisierungsvorgang stieß auf einen Fehler: %s',
+		'file_is_nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>%s</em>. Der HTTP-Server muss Schreibrechte besitzen',
+		'finished' => 'Aktualisierung abgeschlossen!',
+		'none' => 'Keine Aktualisierung zum Anwenden',
+		'server_not_found' => 'Der Aktualisierungs-Server kann nicht gefunden werden. [%s]',
+	),
+	'user' => array(
+		'created' => array(
+			'_' => 'Der Benutzer %s ist erstellt worden',
+			'error' => 'Der Benutzer %s kann nicht erstellt werden',
+		),
+		'deleted' => array(
+			'_' => 'Der Benutzer %s ist gelöscht worden',
+			'error' => 'Der Benutzer %s kann nicht gelöscht werden',
+		),
+	),
+	'profile' => array(
+		'error' => 'Ihr Profil kann nicht geändert werden',
+		'updated' => 'Ihr Profil ist geändert worden',
+	),
+);

+ 182 - 0
app/i18n/de/gen.php

@@ -0,0 +1,182 @@
+<?php
+
+return array(
+	'action' => array(
+		'actualize' => 'Aktualisieren',
+		'back_to_rss_feeds' => '← Zurück zu Ihren RSS-Feeds gehen',
+		'cancel' => 'Abbrechen',
+		'create' => 'Erstellen',
+		'disable' => 'Deaktivieren',
+		'empty' => 'Leeren',
+		'enable' => 'Aktivieren',
+		'export' => 'Exportieren',
+		'filter' => 'Filtern',
+		'import' => 'Importieren',
+		'manage' => 'Verwalten',
+		'mark_read' => 'Als gelesen markieren',
+		'mark_favorite' => 'Als Favorit markieren',
+		'remove' => 'Entfernen',
+		'see_website' => 'Webseite ansehen',
+		'submit' => 'Abschicken',
+		'truncate' => 'Alle Artikel löschen',
+	),
+	'auth' => array(
+		'email' => 'E-Mail-Adresse',
+		'keep_logged_in' => 'Eingeloggt bleiben <small>(%s Tage)</small>',
+		'login' => 'Anmelden',
+		'logout' => 'Abmelden',
+		'password' => array(
+			'_' => 'Passwort',
+			'format' => '<small>mindestens 7 Zeichen</small>',
+		),
+		'registration' => array(
+			'_' => 'Neuer Account',
+			'ask' => 'Erstelle einen Account?',
+			'title' => 'Accounterstellung',
+		),
+		'reset' => 'Zurücksetzen der Authentifizierung',
+		'username' => array(
+			'_' => 'Nutzername',
+			'admin' => 'Administrator-Nutzername',
+			'format' => '<small>maximal 16 alphanumerische Zeichen</small>',
+		),
+	),
+	'date' => array(
+		'Apr' => '\\A\\p\\r\\i\\l',
+		'Aug' => '\\A\\u\\g\\u\\s\\t',
+		'Dec' => '\\D\\e\\z\\e\\m\\b\\e\\r',
+		'Feb' => '\\F\\e\\b\\r\\u\\a\\r',
+		'Jan' => '\\J\\a\\n\\u\\a\\r',
+		'Jul' => '\\J\\u\\l\\i',
+		'Jun' => '\\J\\u\\n\\i',
+		'Mar' => '\\M\\ä\\r\\z',
+		'May' => '\\M\\a\\i',
+		'Nov' => '\\N\\o\\v\\e\\m\\b\\e\\r',
+		'Oct' => '\\O\\k\\t\\o\\b\\e\\r',
+		'Sep' => '\\S\\e\\p\\t\\e\\m\\b\\e\\r',
+		'apr' => 'Apr',
+		'april' => 'April',
+		'aug' => 'Aug',
+		'august' => 'August',
+		'before_yesterday' => 'Vor vorgestern',
+		'dec' => 'Dez',
+		'december' => 'Dezember',
+		'feb' => 'Feb',
+		'february' => 'Februar',
+		'format_date' => 'd\\. %s Y',
+		'format_date_hour' => 'd\\. %s Y \\u\\m H\\:i',
+		'fri' => 'Fr',
+		'jan' => 'Jan',
+		'january' => 'Januar',
+		'jul' => 'Jul',
+		'july' => 'Juli',
+		'jun' => 'Jun',
+		'june' => 'Juni',
+		'last_3_month' => 'Letzte drei Monate',
+		'last_6_month' => 'Letzte sechs Monate',
+		'last_month' => 'Letzter Monat',
+		'last_week' => 'Letzte Woche',
+		'last_year' => 'Letztes Jahr',
+		'mar' => 'Mär',
+		'march' => 'März',
+		'may' => 'Mai',
+		'mon' => 'Mo',
+		'month' => 'Monat(en)',
+		'nov' => 'Nov',
+		'november' => 'November',
+		'oct' => 'Okt',
+		'october' => 'Oktober',
+		'sat' => 'Sa',
+		'sep' => 'Sep',
+		'september' => 'September',
+		'sun' => 'So',
+		'thu' => 'Do',
+		'today' => 'Heute',
+		'tue' => 'Di',
+		'wed' => 'Mi',
+		'yesterday' => 'Gestern',
+	),
+	'freshrss' => array(
+		'_' => 'FreshRSS',
+		'about' => 'Über FreshRSS',
+	),
+	'js' => array(
+		'category_empty' => 'Kategorie leeren',
+		'confirm_action' => 'Sind Sie sicher, dass Sie diese Aktion durchführen wollen? Diese Aktion kann nicht abgebrochen werden!',
+		'confirm_action_feed_cat' => 'Sind Sie sicher, dass Sie diese Aktion durchführen wollen? Sie werden zugehörige Favoriten und Benutzerabfragen verlieren. Dies kann nicht abgebrochen werden!',
+		'feedback' => array(
+			'body_new_articles' => 'Es gibt %%d neue Artikel zum Lesen auf FreshRSS.',
+			'request_failed' => 'Eine Anfrage ist fehlgeschlagen, dies könnte durch Probleme mit der Internetverbindung verursacht worden sein.',
+			'title_new_articles' => 'FreshRSS: neue Artikel!',
+		),
+		'new_article' => 'Es gibt neue verfügbare Artikel. Klicken Sie, um die Seite zu aktualisieren.',
+		'should_be_activated' => 'JavaScript muss aktiviert sein',
+	),
+	'lang' => array(
+		'cz' => 'Čeština',
+		'de' => 'Deutsch',
+		'en' => 'English',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'nl' => 'Nederlands',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+	),
+	'menu' => array(
+		'about' => 'Über',
+		'admin' => 'Administration',
+		'archiving' => 'Archivierung',
+		'authentication' => 'Authentifizierung',
+		'check_install' => 'Installationsüberprüfung',
+		'configuration' => 'Konfiguration',
+		'display' => 'Anzeige',
+		'extensions' => 'Erweiterungen',
+		'logs' => 'Protokolle',
+		'queries' => 'Benutzerabfragen',
+		'reading' => 'Lesen',
+		'search' => 'Suche Worte oder #Tags',
+		'sharing' => 'Teilen',
+		'shortcuts' => 'Tastaturkürzel',
+		'stats' => 'Statistiken',
+		'system' => 'System configuration',// @todo translate
+		'update' => 'Aktualisieren',
+		'user_management' => 'Benutzer verwalten',
+		'user_profile' => 'Profil',
+	),
+	'pagination' => array(
+		'first' => 'Erste',
+		'last' => 'Letzte',
+		'load_more' => 'Weitere Artikel laden',
+		'mark_all_read' => 'Alle als gelesen markieren',
+		'next' => 'Nächste',
+		'nothing_to_load' => 'Es gibt keine weiteren Artikel',
+		'previous' => 'Vorherige',
+	),
+	'share' => array(
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'E-Mail',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'movim' => 'Movim',
+		'print' => 'Drucken',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
+		'jdh' => 'Journal du hacker',
+	),
+	'short' => array(
+		'attention' => 'Achtung!',
+		'blank_to_disable' => 'Zum Deaktivieren frei lassen',
+		'by_author' => 'Von <em>%s</em>',
+		'by_default' => 'standardmäßig',
+		'damn' => 'Verdammt!',
+		'default_category' => 'Unkategorisiert',
+		'no' => 'Nein',
+		'not_applicable' => 'Nicht verfügbar',
+		'ok' => 'OK!',
+		'or' => 'oder',
+		'yes' => 'Ja',
+	),
+);

+ 61 - 0
app/i18n/de/index.php

@@ -0,0 +1,61 @@
+<?php
+
+return array(
+	'about' => array(
+		'_' => 'Über',
+		'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
+		'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.',
+		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
+		'license' => 'Lizenz',
+		'project_website' => 'Projekt-Webseite',
+		'title' => 'Über',
+		'version' => 'Version',
+		'website' => 'Webseite',
+	),
+	'feed' => array(
+		'add' => 'Sie können Feeds hinzufügen.',
+		'empty' => 'Es gibt keinen Artikel zum Anzeigen.',
+		'rss_of' => 'RSS-Feed von %s',
+		'title' => 'Ihre RSS-Feeds',
+		'title_global' => 'Globale Ansicht',
+		'title_fav' => 'Ihre Favoriten',
+	),
+	'log' => array(
+		'_' => 'Protokolle',
+		'clear' => 'Protokolle leeren',
+		'empty' => 'Protokolldatei ist leer.',
+		'title' => 'Protokolle',
+	),
+	'menu' => array(
+		'about' => 'Über FreshRSS',
+		'add_query' => 'Eine Abfrage hinzufügen',
+		'before_one_day' => 'Vor einem Tag',
+		'before_one_week' => 'Vor einer Woche',
+		'favorites' => 'Favoriten (%s)',
+		'global_view' => 'Globale Ansicht',
+		'main_stream' => 'Haupt-Feeds',
+		'mark_all_read' => 'Alle als gelesen markieren',
+		'mark_cat_read' => 'Kategorie als gelesen markieren',
+		'mark_feed_read' => 'Feed als gelesen markieren',
+		'newer_first' => 'Neuere zuerst',
+		'non-starred' => 'Alle außer Favoriten zeigen',
+		'normal_view' => 'Normale Ansicht',
+		'older_first' => 'Ältere zuerst',
+		'queries' => 'Benutzerabfragen',
+		'read' => 'Nur gelesene zeigen',
+		'reader_view' => 'Lese-Ansicht',
+		'rss_view' => 'RSS-Feed',
+		'search_short' => 'Suchen',
+		'starred' => 'Nur Favoriten zeigen',
+		'stats' => 'Statistiken',
+		'subscription' => 'Abonnementverwaltung',
+		'unread' => 'Nur ungelesene zeigen',
+	),
+	'share' => 'Teilen',
+	'tag' => array(
+		'related' => 'Verwandte Tags',
+	),
+);

+ 119 - 0
app/i18n/de/install.php

@@ -0,0 +1,119 @@
+<?php
+
+return array(
+	'action' => array(
+		'finish' => 'Installation fertigstellen',
+		'fix_errors_before' => 'Bitte Fehler korrigieren, bevor zum nächsten Schritt gesprungen wird.',
+		'keep_install' => 'Vorherige Konfiguration beibehalten',
+		'next_step' => 'Zum nächsten Schritt springen',
+		'reinstall' => 'Neuinstallation von FreshRSS',
+	),
+	'auth' => array(
+		'form' => 'Webformular (traditionell, benötigt JavaScript)',
+		'http' => 'HTTP (HTTPS für erfahrene Benutzer)',
+		'none' => 'Keine (gefährlich)',
+		'password_form' => 'Passwort<br /><small>(für die Anmeldemethode per Webformular)</small>',
+		'password_format' => 'mindestens 7 Zeichen',
+		'type' => 'Authentifizierungsmethode',
+	),
+	'bdd' => array(
+		'_' => 'Datenbank',
+		'conf' => array(
+			'_' => 'Datenbank-Konfiguration',
+			'ko' => 'Überprüfen Sie Ihre Datenbank-Information.',
+			'ok' => 'Datenbank-Konfiguration ist gespeichert worden.',
+		),
+		'host' => 'Host',
+		'prefix' => 'Tabellen-Präfix',
+		'password' => 'SQL-Password',
+		'type' => 'Datenbank-Typ',
+		'username' => 'SQL-Nutzername',
+	),
+	'check' => array(
+		'_' => 'Überprüfungen',
+		'already_installed' => 'Wir haben festgestellt, dass FreshRSS bereits installiert wurde!',
+		'cache' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/cache</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data/cache</em> sind in Ordnung.',
+		),
+		'ctype' => array(
+			'nok' => 'Ihnen fehlt eine benötigte Bibliothek für die Überprüfung von Zeichentypen (php-ctype).',
+			'ok' => 'Sie haben die benötigte Bibliothek für die Überprüfung von Zeichentypen (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Ihnen fehlt cURL (Paket php-curl).',
+			'ok' => 'Sie haben die cURL-Erweiterung.',
+		),
+		'data' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data</em> sind in Ordnung.',
+		),
+		'dom' => array(
+			'nok' => 'Ihnen fehlt eine benötigte Bibliothek um DOM zu durchstöbern.',
+			'ok' => 'Sie haben die benötigte Bibliothek um DOM zu durchstöbern.',
+		),
+		'favicons' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/favicons</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data/favicons</em> sind in Ordnung.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Ihnen fehlt PHP fileinfo (Paket fileinfo).',
+			'ok' => 'Sie haben die fileinfo-Erweiterung.',
+		),
+		'http_referer' => array(
+			'nok' => 'Bitte stellen Sie sicher, dass Sie Ihren HTTP REFERER nicht abändern.',
+			'ok' => 'Ihr HTTP REFERER ist bekannt und entspricht Ihrem Server.',
+		),
+		'json' => array(
+			'nok' => 'Ihnen fehlt eine empfohlene Bibliothek um JSON zu parsen.',
+			'ok' => 'Sie haben eine empfohlene Bibliothek um JSON zu parsen.',
+		),
+		'minz' => array(
+			'nok' => 'Ihnen fehlt das Minz-Framework.',
+			'ok' => 'Sie haben das Minz-Framework.',
+		),
+		'pcre' => array(
+			'nok' => 'Ihnen fehlt eine benötigte Bibliothek für reguläre Ausdrücke (php-pcre).',
+			'ok' => 'Sie haben die benötigte Bibliothek für reguläre Ausdrücke (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Ihnen fehlt PDO oder einer der unterstützten Treiber (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'Sie haben PDO und mindestens einen der unterstützten Treiber (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'nok' => 'Ihre PHP-Version ist %s aber FreshRSS benötigt mindestens Version %s.',
+			'ok' => 'Ihre PHP-Version ist %s, welche kompatibel mit FreshRSS ist.',
+		),
+		'users' => array(
+			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/users</em>. Der HTTP-Server muss Schreibrechte besitzen.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data/users</em> sind in Ordnung.',
+		),
+		'xml' => array(
+			'nok' => 'Ihnen fehlt die benötigte Bibliothek um XML zu parsen.',
+			'ok' => 'Sie haben die benötigte Bibliothek um XML zu parsen.',
+		),
+	),
+	'conf' => array(
+		'_' => 'Allgemeine Konfiguration',
+		'ok' => 'Die allgemeine Konfiguration ist gespeichert worden.',
+	),
+	'congratulations' => 'Glückwunsch!',
+	'default_user' => 'Nutzername des Standardbenutzers <small>(maximal 16 alphanumerische Zeichen)</small>',
+	'delete_articles_after' => 'Entferne Artikel nach',
+	'fix_errors_before' => 'Bitte den Fehler korrigieren, bevor zum nächsten Schritt gesprungen wird.',
+	'javascript_is_better' => 'FreshRSS ist ansprechender mit aktiviertem JavaScript',
+	'js' => array(
+		'confirm_reinstall' => 'Du wirst deine vorherige Konfiguration (Daten) verlieren FreshRSS. Bist du sicher, dass du fortfahren willst?',
+	),
+	'language' => array(
+		'_' => 'Sprache',
+		'choose' => 'Wählen Sie eine Sprache für FreshRSS',
+		'defined' => 'Die Sprache ist festgelegt worden.',
+	),
+	'not_deleted' => 'Etwas ist schiefgelaufen; Sie müssen die Datei <em>%s</em> manuell löschen.',
+	'ok' => 'Der Installationsvorgang war erfolgreich.',
+	'step' => 'Schritt %d',
+	'steps' => 'Schritte',
+	'title' => 'Installation · FreshRSS',
+	'this_is_the_end' => 'Das ist das Ende',
+);

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

@@ -0,0 +1,62 @@
+<?php
+
+return array(
+	'category' => array(
+		'_' => 'Kategorie',
+		'add' => 'Eine Kategorie hinzufügen',
+		'empty' => 'Leere Kategorie',
+		'new' => 'Neue Kategorie',
+	),
+	'feed' => array(
+		'add' => 'Einen RSS-Feed hinzufügen',
+		'advanced' => 'Erweitert',
+		'archiving' => 'Archivierung',
+		'auth' => array(
+			'configuration' => 'Anmelden',
+			'help' => 'Die Verbindung erlaubt Zugriff auf HTTP-geschützte RSS-Feeds',
+			'http' => 'HTTP-Authentifizierung',
+			'password' => 'HTTP-Passwort',
+			'username' => 'HTTP-Nutzername',
+		),
+		'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',
+		'empty' => 'Dieser Feed ist leer. Bitte stellen Sie sicher, dass er noch gepflegt wird.',
+		'error' => 'Dieser Feed ist auf ein Problem gestoßen. Bitte stellen Sie sicher, dass er immer lesbar ist und aktualisieren Sie ihn dann.',
+		'in_main_stream' => 'In Haupt-Feeds zeigen',
+		'informations' => 'Information',
+		'keep_history' => 'Minimale Anzahl an Artikeln, die behalten wird',
+		'moved_category_deleted' => 'Wenn Sie eine Kategorie entfernen, werden deren Feeds automatisch in die Kategorie <em>%s</em> eingefügt.',
+		'no_selected' => 'Kein Feed ausgewählt.',
+		'number_entries' => '%d Artikel',
+		'stats' => 'Statistiken',
+		'think_to_add' => 'Sie können Feeds hinzufügen.',
+		'title' => 'Titel',
+		'title_add' => 'Einen RSS-Feed hinzufügen',
+		'ttl' => 'Aktualisiere automatisch nicht öfter als',
+		'url' => 'Feed-URL',
+		'validator' => 'Überprüfen Sie die Gültigkeit des Feeds',
+		'website' => 'Webseiten-URL',
+		'pubsubhubbub' => 'Sofortbenachrichtigung mit PubSubHubbub',
+	),
+	'import_export' => array(
+		'export' => 'Exportieren',
+		'export_opml' => 'Liste der Feeds exportieren (OPML)',
+		'export_starred' => 'Ihre Favoriten exportieren',
+		'feed_list' => 'Liste von %s Artikeln',
+		'file_to_import' => 'Zu importierende Datei<br />(OPML, JSON oder ZIP)',
+		'file_to_import_no_zip' => 'Zu importierende Datei<br />(OPML oder JSON)',
+		'import' => 'Importieren',
+		'starred_list' => 'Liste der Lieblingsartikel',
+		'title' => 'Importieren / Exportieren',
+	),
+	'menu' => array(
+		'bookmark' => 'Abonnieren (FreshRSS-Lesezeichen)',
+		'import_export' => 'Importieren / Exportieren',
+		'subscription_management' => 'Abonnementverwaltung',
+	),
+	'title' => array(
+		'_' => 'Abonnementverwaltung',
+		'feed_management' => 'Verwaltung der RSS-Feeds',
+	),
+);

+ 0 - 528
app/i18n/en.php

@@ -1,528 +0,0 @@
-<?php
- return array (
-  'about' => 'About',
-  'about_freshrss' => 'About FreshRSS',
-  'access_denied' => 'You don’t have permission to access this page',
-  'access_protected_feeds' => 'Connection allows to access HTTP protected RSS feeds',
-  'activate_sharing' => 'Activate sharing',
-  'actualize' => 'Actualize',
-  'add_category' => 'Add a category',
-  'add_query' => 'Add a query',
-  'add_rss_feed' => 'Add a RSS feed',
-  'admin.check_install.cache.nok' => 'Check permissions on <em>./data/cache</em> directory. HTTP server must have rights to write into',
-  'admin.check_install.cache.ok' => 'Permissions on cache directory are good.',
-  'admin.check_install.categories.nok' => 'Category table is bad configured.',
-  'admin.check_install.categories.ok' => 'Category table is ok.',
-  'admin.check_install.connection.nok' => 'Connection to the database cannot being established.',
-  'admin.check_install.connection.ok' => 'Connection to the database is ok.',
-  'admin.check_install.ctype.nok' => 'You lack a required library for character type checking (php-ctype).',
-  'admin.check_install.ctype.ok' => 'You have the required library for character type checking (ctype).',
-  'admin.check_install.curl.nok' => 'You lack cURL (php5-curl package).',
-  'admin.check_install.curl.ok' => 'You have cURL extension.',
-  'admin.check_install.data.nok' => 'Check permissions on <em>./data</em> directory. HTTP server must have rights to write into',
-  'admin.check_install.data.ok' => 'Permissions on data directory are good.',
-  'admin.check_install.database' => 'Database installation',
-  'admin.check_install.dom.nok' => 'You lack a required library to browse the DOM (php-xml package).',
-  'admin.check_install.dom.ok' => 'You have the required library to browse the DOM.',
-  'admin.check_install.entries.nok' => 'Entry table is bad configured.',
-  'admin.check_install.entries.ok' => 'Entry table is ok.',
-  'admin.check_install.favicons.nok' => 'Check permissions on <em>./data/favicons</em> directory. HTTP server must have rights to write into',
-  'admin.check_install.favicons.ok' => 'Permissions on favicons directory are good.',
-  'admin.check_install.feeds.nok' => 'Feed table is bad configured.',
-  'admin.check_install.feeds.ok' => 'Feed table is ok.',
-  'admin.check_install.files' => 'File installation',
-  'admin.check_install.json.nok' => 'You lack JSON (php5-json package).',
-  'admin.check_install.json.ok' => 'You have JSON extension.',
-  'admin.check_install.logs.nok' => 'Check permissions on <em>./data/logs</em> directory. HTTP server must have rights to write into',
-  'admin.check_install.logs.ok' => 'Permissions on logs directory are good.',
-  'admin.check_install.minz.nok' => 'You lack the Minz framework.',
-  'admin.check_install.minz.ok' => 'You have the Minz framework.',
-  'admin.check_install.pcre.nok' => 'You lack a required library for regular expressions (php-pcre).',
-  'admin.check_install.pcre.ok' => 'You have the required library for regular expressions (PCRE).',
-  'admin.check_install.pdo.nok' => 'You lack PDO or one of the supported drivers (pdo_mysql, pdo_sqlite).',
-  'admin.check_install.pdo.ok' => 'You have PDO and at least one of the supported drivers (pdo_mysql, pdo_sqlite).',
-  'admin.check_install.pdo_mysql.nok' => 'You lack PDO for MySQL (it\'s ok if PDO is good).',
-  'admin.check_install.pdo_mysql.ok' => 'You have PDO for MySQL.',
-  'admin.check_install.pdo_sqlite.nok' => 'You lack PDO for SQLite (it\'s ok if PDO is good).',
-  'admin.check_install.pdo_sqlite.ok' => 'You have PDO for SQLite.',
-  'admin.check_install.persona.nok' => 'Check permissions on <em>./data/persona</em> directory. HTTP server must have rights to write into',
-  'admin.check_install.persona.ok' => 'Permissions on Mozilla Persona directory are good.',
-  'admin.check_install.php' => 'PHP installation',
-  'admin.check_install.php.nok' => 'Your PHP version is %s but FreshRSS requires at least version %s.',
-  'admin.check_install.php.ok' => 'Your PHP version is %s, which is compatible with FreshRSS.',
-  'admin.check_install.tables.nok' => 'There is one or more lacking tables in the database.',
-  'admin.check_install.tables.ok' => 'Tables are existing in the database.',
-  'admin.check_install.tokens.nok' => 'Check permissions on <em>./data/tokens</em> directory. HTTP server must have rights to write into',
-  'admin.check_install.tokens.ok' => 'Permissions on tokens directory are good.',
-  'admin.check_install.zip.nok' => 'You lack ZIP extension (php5-zip package).',
-  'admin.check_install.zip.ok' => 'You have ZIP extension.',
-  'admin.users.articles_and_size' => '%s articles (%s)',
-  'administration' => 'Manage',
-  'advanced' => 'Advanced',
-  'after_onread' => 'After “mark all as read”,',
-  'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
-  'allow_anonymous' => 'Allow anonymous reading of the articles of the default user (%s)',
-  'allow_anonymous_refresh' => 'Allow anonymous refresh of the articles',
-  'all_feeds' => 'All feeds',
-  'already_subscribed' => 'You have already subscribed to <em>%s</em>',
-  'api_enabled' => 'Allow <abbr>API</abbr> access <small>(required for mobile apps)</small>',
-  'Apr' => '\\A\\p\\r\\i\\l',
-  'apr' => 'apr',
-  'april' => 'Apr',
-  'archiving_configuration' => 'Archiving',
-  'archiving_configuration_help' => 'More options are available in the individual stream settings',
-  'article' => 'Article',
-  'articles' => 'articles',
-  'articles_per_page' => 'Number of articles per page',
-  'articles_to_display' => 'Articles to display',
-  'article_icons' => 'Article icons',
-  'article_open_on_website' => 'when article is opened on its original website',
-  'article_published_on' => 'This article originally appeared on <a href="%s">%s</a>',
-  'article_published_on_author' => 'This article originally appeared on <a href="%s">%s</a> by %s',
-  'article_viewed' => 'when article is viewed',
-  'ask_empty' => 'Clear?',
-  'attention' => 'Attention!',
-  'Aug' => '\\A\\u\\g\\u\\s\\t',
-  'aug' => 'aug',
-  'august' => 'Aug',
-  'author' => 'Author',
-  'auth_form' => 'Web form (traditional, requires JavaScript)',
-  'auth_form_not_set' => 'A problem occured during authentication system configuration. Please retry later.',
-  'auth_form_set' => 'Form is now your default authentication system.',
-  'auth_none' => 'None (dangerous)',
-  'auth_not_persona' => 'Only Persona system can be reset.',
-  'auth_no_password_set' => 'Administrator password hasn’t been set. This feature isn’t available.',
-  'auth_persona' => 'Mozilla Persona (modern, requires JavaScript)',
-  'auth_reset' => 'Authentication reset',
-  'auth_token' => 'Authentication token',
-  'auth_type' => 'Authentication method',
-  'auth_will_reset' => 'Authentication system will be reset: a form will be used instead of Persona.',
-  'auto_load_more' => 'Load next articles at the page bottom',
-  'auto_read_when' => 'Mark article as read…',
-  'auto_share' => 'Share',
-  'auto_share_help' => 'If there is only one sharing mode, it is used. Else modes are accessible by their number.',
-  'back_to_rss_feeds' => '← Go back to your RSS feeds',
-  'bad_opml_file' => 'Your OPML file is invalid',
-  'base_url' => 'Base URL',
-  'bdd' => 'Database',
-  'bdd_configuration' => 'Database configuration',
-  'bdd_conf_is_ko' => 'Verify your database information.',
-  'bdd_conf_is_ok' => 'Database configuration has been saved.',
-  'bdd_type' => 'Type of database',
-  'before_one_day' => 'Before one day',
-  'before_one_week' => 'Before one week',
-  'before_yesterday' => 'Before yesterday',
-  'blank_to_disable' => 'Leave blank to disable',
-  'blogotext' => 'Blogotext',
-  'bookmark' => 'Subscribe (FreshRSS bookmark)',
-  'bottom_line' => 'Bottom line',
-  'bugs_reports' => 'Bugs reports',
-  'by' => 'by',
-  'by_author' => 'By <em>%s</em>',
-  'by_default' => 'By default',
-  'by_email' => 'By email',
-  'by_feed' => 'by feed',
-  'cache_is_ok' => 'Permissions on cache directory are good',
-  'cancel' => 'Cancel',
-  'can_not_be_deleted' => 'Cannot be deleted',
-  'categories' => 'Categories',
-  'categories_management' => 'Categories management',
-  'categories_updated' => 'Categories have been updated',
-  'categorize' => 'Store in a category',
-  'category' => 'Category',
-  'category_created' => 'Category %s has been created.',
-  'category_deleted' => 'Category has been deleted.',
-  'category_emptied' => 'Category has been emptied',
-  'category_empty' => 'Empty category',
-  'category_name_exists' => 'Category name already exists.',
-  'category_not_delete_default' => 'You cannot delete the default category!',
-  'category_not_exist' => 'The category does not exist!',
-  'category_no_id' => 'You must precise the id of the category.',
-  'category_no_name' => 'Category name cannot be empty.',
-  'category_number' => 'Category n°%d',
-  'category_updated' => 'Category has been updated.',
-  'change_value' => 'You should change this value by any other',
-  'checks' => 'Checks',
-  'choose_language' => 'Choose a language for FreshRSS',
-  'clear_logs' => 'Clear the logs',
-  'collapse_article' => 'Collapse',
-  'conf.users.articles_and_size' => '%s articles (%s)',
-  'configuration' => 'Configuration',
-  'configuration_updated' => 'Configuration has been updated',
-  'confirm_action' => 'Are you sure you want to perform this action? It cannot be cancelled!',
-  'confirm_action_feed_cat' => 'Are you sure you want to perform this action? You will lose related favorites and user queries. It cannot be cancelled!',
-  'congratulations' => 'Congratulations!',
-  'content_width' => 'Content width',
-  'create' => 'Create',
-  'create_user' => 'Create new user',
-  '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://www.google.com/webfonts/specimen/Open+Sans">Steve Matteson</a>. Favicons are collected with <a href="https://getfavicon.appspot.com/">getFavicon API</a>. FreshRSS is based on <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, a PHP framework.',
-  'css_path_on_website' => 'Articles CSS path on original website',
-  'ctype_is_nok' => 'You lack a required library for character type checking (php-ctype)',
-  'ctype_is_ok' => 'You have the required library for character type checking (ctype)',
-  'curl_is_nok' => 'You lack cURL (php5-curl package)',
-  'curl_is_ok' => 'You have version %s of cURL',
-  'current_user' => 'Current user',
-  'damn' => 'Damn!',
-  'data_is_ok' => 'Permissions on data directory are good',
-  'Dec' => '\\D\\e\\c\\e\\m\\b\\e\\r',
-  'dec' => 'dec',
-  'december' => 'Dec',
-  'default_category' => 'Uncategorized',
-  'default_user' => 'Username of the default user <small>(maximum 16 alphanumeric characters)</small>',
-  'default_view' => 'Default view',
-  'delete' => 'Delete',
-  'delete_articles_every' => 'Remove articles after',
-  'diaspora' => 'Diaspora*',
-  'display' => 'Display',
-  'display_articles_unfolded' => 'Show articles unfolded by default',
-  'display_categories_unfolded' => 'Show categories folded by default',
-  'display_configuration' => 'Display',
-  'dom_is_nok' => 'You lack a required library to browse the DOM (php-xml package)',
-  'dom_is_ok' => 'You have the required library to browse the DOM',
-  'do_not_change_if_doubt' => 'Don’t change if you doubt about it',
-  'email' => 'Email',
-  'error_occurred' => 'An error occurred',
-  'error_occurred_update' => 'Nothing was changed',
-  'explain_token' => 'Allows to access RSS output of the default user without authentication.<br /><kbd>%s?output=rss&token=%s</kbd>',
-  'export' => 'Export',
-  'export_no_zip_extension' => 'Zip extension is not present on your server. Please try to export files one by one.',
-  'export_opml' => 'Export list of feeds (OPML)',
-  'export_starred' => 'Export your favourites',
-  'facebook' => 'Facebook',
-  'favicons_is_ok' => 'Permissions on favicons directory are good',
-  'favorite_feeds' => 'Favourites (%s)',
-  'feb' => 'feb',
-  'Feb' => '\\F\\e\\b\\r\\u\\a\\r\\y',
-  'february' => 'Feb',
-  'feed' => 'Feed',
-  'feeds' => 'Feeds',
-  'feeds_actualized' => 'RSS feeds have been updated',
-  'feeds_imported' => 'Your feeds have been imported and will now be updated',
-  'feeds_imported_with_errors' => 'Your feeds have been imported but some errors occurred',
-  'feeds_marked_read' => 'Feeds have been marked as read',
-  'feeds_moved_category_deleted' => 'When you delete a category, their feeds are automatically classified under <em>%s</em>.',
-  'feed_actualized' => '<em>%s</em> has been updated',
-  'feed_added' => 'RSS feed <em>%s</em> has been added',
-  'feed_deleted' => 'Feed has been deleted',
-  'feed_description' => 'Description',
-  'feed_empty' => 'This feed is empty. Please verify that it is still maintained.',
-  'feed_in_error' => 'This feed has encountered a problem. Please verify that it is always reachable then actualize it.',
-  'feed_list' => 'List of %s articles',
-  'feed_not_added' => '<em>%s</em> could not be added',
-  'feed_updated' => 'Feed has been updated',
-  'feed_url' => 'Feed URL',
-  'feed_validator' => 'Check the validity of the feed',
-  'file_cannot_be_uploaded' => 'File cannot be uploaded!',
-  'file_is_nok' => 'Check permissions on <em>%s</em> directory. HTTP server must have rights to write into',
-  'file_to_import' => 'File to import<br />(OPML, Json or Zip)',
-  'file_to_import_no_zip' => 'File to import<br />(OPML or Json)',
-  'filter' => 'Filter',
-  'finish_installation' => 'Complete installation',
-  'first' => 'First',
-  'first_article' => 'Skip to the first article',
-  'fix_errors_before' => 'Fix errors before skip to the next step.',
-  'focus_search' => 'Access search box',
-  'forbidden_access' => 'Access is forbidden!',
-  'format_date' => '%s j\\<\\s\\u\\p\\>S\\<\\/\\s\\u\\p\\> Y',
-  'format_date_hour' => '%s j\\<\\s\\u\\p\\>S\\<\\/\\s\\u\\p\\> Y \\a\\t H\\:i',
-  'freshrss' => 'FreshRSS',
-  '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_installation' => 'Installation · FreshRSS',
-  'fri' => 'Fri',
-  'g+' => 'Google+',
-  'gen.menu.admin' => 'Administration',
-  'gen.menu.authentication' => 'Authentication',
-  'gen.menu.check_install' => 'Installation checking',
-  'gen.menu.manage_users' => 'Manage users',
-  'gen.menu.profil' => 'Profil',
-  'gen.title.check_install' => 'Installation checking',
-  'general_configuration' => 'General configuration',
-  'general_conf_is_ok' => 'General configuration has been saved.',
-  'github_or_email' => '<a href="https://github.com/marienfressinaud/FreshRSS/issues">on Github</a> or <a href="mailto:dev@marienfressinaud.fr">by mail</a>',
-  'global_view' => 'Global view',
-  'help' => 'Display documentation',
-  'hide_read_feeds' => 'Hide categories & feeds with no unread article (does not work with “Show all articles” configuration)',
-  'host' => 'Host',
-  'html5_notif_timeout' => 'HTML5 notification timeout',
-  'http_auth' => 'HTTP (for advanced users with HTTPS)',
-  'http_authentication' => 'HTTP Authentication',
-  'http_password' => 'HTTP password',
-  'http_referer_is_nok' => 'Please check that you are not altering your HTTP REFERER.',
-  'http_referer_is_ok' => 'Your HTTP REFERER is known and corresponds to your server.',
-  'http_username' => 'HTTP username',
-  'img_with_lazyload' => 'Use "lazy load" mode to load pictures',
-  'import' => 'Import',
-  'import_export' => 'Import / export',
-  'informations' => 'Information',
-  'installation_is_ok' => 'The installation process was successful.<br />The final step will now attempt to delete any file and database backup created during the update process.<br />You may choose to skip this step by deleting <kbd>./data/do-install.txt</kbd> manually.',
-  'installation_step' => 'Installation — step %d · FreshRSS',
-  'install_not_deleted' => 'Something went wrong; you must delete the file <em>%s</em> manually.',
-  'internal_problem_feed' => 'The RSS feed could not be added. <a href="%s">Check FressRSS logs</a> for details.',
-  'invalid_login' => 'Login is invalid',
-  'invalid_url' => 'URL <em>%s</em> is invalid',
-  'is_admin' => 'is administrator',
-  'Jan' => '\\J\\a\\n\\u\\a\\r\\y',
-  'jan' => 'jan',
-  'january' => 'Jan',
-  'javascript_for_shortcuts' => 'JavaScript must be enabled in order to use shortcuts',
-  'javascript_is_better' => 'FreshRSS is more pleasant with JavaScript enabled',
-  'javascript_should_be_activated' => 'JavaScript must be enabled',
-  'Jul' => '\\J\\u\\l\\y',
-  'jul' => 'jul',
-  'july' => 'Jul',
-  'jump_next' => 'jump to next unread sibling (feed or category)',
-  'jun' => 'jun',
-  'Jun' => '\\J\\u\\n\\e',
-  'june' => 'Jun',
-  'keep_history' => 'Minimum number of articles to keep',
-  'keep_logged_in' => 'Keep me logged in <small>(1 month)</small>',
-  'language' => 'Language',
-  'language_defined' => 'Language has been defined.',
-  'last' => 'Last',
-  'last_3_month' => 'Last three months',
-  'last_6_month' => 'Last six months',
-  'last_article' => 'Skip to the last article',
-  'last_month' => 'Last month',
-  'last_week' => 'Last week',
-  'last_year' => 'Last year',
-  'lead_developer' => 'Lead developer',
-  'license' => 'License',
-  'load_more' => 'Load more articles',
-  'login' => 'Login',
-  'login_configuration' => 'Login',
-  'login_persona_problem' => 'Connection problem with Persona?',
-  'login_required' => 'Login required:',
-  'login_with_persona' => 'Login with Persona',
-  'logout' => 'Logout',
-  'logs' => 'Logs',
-  'logs_empty' => 'Log file is empty',
-  'log_is_ok' => 'Permissions on logs directory are good',
-  'main_stream' => 'Main stream',
-  'mar' => 'mar',
-  'Mar' => '\\M\\a\\r\\c\\h',
-  'march' => 'Mar',
-  'mark_all_read' => 'Mark all as read',
-  'mark_cat_read' => 'Mark category as read',
-  'mark_favorite' => 'Mark as favourite',
-  'mark_feed_read' => 'Mark feed as read',
-  'mark_read' => 'Mark as read',
-  'may' => 'May',
-  'May' => '\\M\\a\\y',
-  'minz_is_nok' => 'You lack the Minz framework. You should execute <em>build.sh</em> script or <a href="https://github.com/marienfressinaud/MINZ">download it on Github</a> and install in <em>%s</em> directory the content of its <em>/lib</em> directory.',
-  'minz_is_ok' => 'You have the Minz framework',
-  'mon' => 'Mon',
-  'month' => 'months',
-  'more_information' => 'More information',
-  'newer_first' => 'Newer first',
-  'new_article' => 'There are new available articles, click to refresh the page.',
-  'new_category' => 'New category',
-  'next' => 'Next',
-  'next_article' => 'Skip to the next article',
-  'next_page' => 'Skip to the next page',
-  'next_step' => 'Go to the next step',
-  'no' => 'No',
-  'normal_view' => 'Normal view',
-  'nothing_to_load' => 'There are no more articles',
-  'notif_body_new_articles' => 'There are \\d new articles to read on FreshRSS.',
-  'notif_title_new_articles' => 'FreshRSS: new articles!',
-  'not_read' => '%d unread',
-  'not_reads' => '%d unread',
-  'not_yet_implemented' => 'Not yet implemented',
-  'Nov' => '\\N\\o\\v\\e\\m\\b\\e\\r',
-  'nov' => 'nov',
-  'november' => 'Nov',
-  'no_feed_actualized' => 'No RSS feed has been updated',
-  'no_feed_to_display' => 'There is no article to show.',
-  'no_feed_to_refresh' => 'There is no feed to refresh…',
-  'no_query' => 'You haven’t created any user query yet.',
-  'no_query_filter' => 'No filter',
-  'no_rss_feed' => 'No RSS feed',
-  'no_selected_feed' => 'No feed selected.',
-  'no_update' => 'No update to apply',
-  'no_zip_extension' => 'Zip extension is not present on your server.',
-  'number_articles' => '%d articles',
-  'number_divided_when_reader' => 'Divided by 2 in the reading view.',
-  'number_feeds' => '%d feeds',
-  'n_entries_deleted' => '%d articles have been deleted',
-  'n_feeds_actualized' => '%d feeds have been updated',
-  'oct' => 'oct',
-  'Oct' => '\\O\\c\\t\\o\\b\\e\\r',
-  'october' => 'Oct',
-  'ok' => 'Ok!',
-  'older_first' => 'Oldest first',
-  'oops' => 'Oops!',
-  'optimization_complete' => 'Optimization complete',
-  'optimize_bdd' => 'Optimize database',
-  'optimize_todo_sometimes' => 'To do occasionally to reduce the size of the database',
-  'or' => 'or',
-  'page_not_found' => 'You are looking for a page which doesn’t exist',
-  'password' => 'Password',
-  'password_api' => 'Password API<br /><small>(e.g., for mobile apps)</small>',
-  'password_form' => 'Password<br /><small>(for the Web-form login method)</small>',
-  'pcre_is_nok' => 'You lack a required library for regular expressions (php-pcre)',
-  'pcre_is_ok' => 'You have the required library for regular expressions (PCRE)',
-  'pdo_is_nok' => 'You lack PDO or one of the supported drivers (pdo_mysql, pdo_sqlite)',
-  'pdo_is_ok' => 'You have PDO and at least one of the supported drivers (pdo_mysql, pdo_sqlite)',
-  'persona_connection_email' => 'Login mail address<br /><small>(for <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
-  'persona_is_ok' => 'Permissions on Mozilla Persona directory are good',
-  'php_is_nok' => 'Your PHP version is %s but FreshRSS requires at least version %s',
-  'php_is_ok' => 'Your PHP version is %s, which is compatible with FreshRSS',
-  'prefix' => 'Table prefix',
-  'previous' => 'Previous',
-  'previous_article' => 'Skip to the previous article',
-  'previous_page' => 'Skip to the previous page',
-  'print' => 'Print',
-  'project_website' => 'Project website',
-  'public' => 'Public',
-  'publication_date' => 'Date of publication',
-  'purge_completed' => 'Purge completed (%d articles deleted)',
-  'purge_now' => 'Purge now',
-  'queries' => 'User queries',
-  'query_created' => 'Query "%s" has been created.',
-  'query_deprecated' => 'This query is no longer valid. The referenced category or feed has been deleted.',
-  'query_filter' => 'Filter applied:',
-  'query_get_all' => 'Display all articles',
-  'query_get_category' => 'Display "%s" category',
-  'query_get_favorite' => 'Display favorite articles',
-  'query_get_feed' => 'Display "%s" feed',
-  'query_number' => 'Query n°%d',
-  'query_order_asc' => 'Display oldest articles first',
-  'query_order_desc' => 'Display newest articles first',
-  'query_search' => 'Search for "%s"',
-  'query_state_0' => 'Display all articles',
-  'query_state_1' => 'Display read articles',
-  'query_state_2' => 'Display unread articles',
-  'query_state_3' => 'Display all articles',
-  'query_state_4' => 'Display favorite articles',
-  'query_state_5' => 'Display read favorite articles',
-  'query_state_6' => 'Display unread favorite articles',
-  'query_state_7' => 'Display favorite articles',
-  'query_state_8' => 'Display not favorite articles',
-  'query_state_9' => 'Display read not favorite articles',
-  'query_state_10' => 'Display unread not favorite articles',
-  'query_state_11' => 'Display not favorite articles',
-  'query_state_12' => 'Display all articles',
-  'query_state_13' => 'Display read articles',
-  'query_state_14' => 'Display unread articles',
-  'query_state_15' => 'Display all articles',
-  'random_string' => 'Random string',
-  'reader_view' => 'Reading view',
-  'reading_configuration' => 'Reading',
-  'reading_confirm' => 'Display a confirmation dialog on “mark all as read” actions',
-  'refresh' => 'Refresh',
-  'related_tags' => 'Related tags',
-  'retrieve_truncated_feeds' => 'Retrieves truncated RSS feeds (attention, requires more time!)',
-  'rss_feeds_of' => 'RSS feed of %s',
-  'rss_feed_management' => 'RSS feeds management',
-  'rss_view' => 'RSS feed',
-  'sat' => 'Sat',
-  'save' => 'Save',
-  'scroll' => 'while scrolling',
-  'search' => 'Search words or #tags',
-  'search_short' => 'Search',
-  'seconds_(0_means_no_timeout)' => 'seconds (0 means no timeout)',
-  'see_on_website' => 'See on original website',
-  'see_website' => 'See website',
-  'Sep' => '\\S\\e\\p\\t\\e\\m\\b\\e\\r',
-  'sep' => 'sep',
-  'september' => 'Sep',
-  'shaarli' => 'Shaarli',
-  'share' => 'Share',
-  'share_name' => 'Share name to display',
-  'share_url' => 'Share URL to use',
-  'sharing' => 'Sharing',
-  'sharing_management' => 'Sharing options management',
-  'shift_for_all_read' => '+ <code>shift</code> to mark all articles as read',
-  'shortcuts' => 'Shortcuts',
-  'shortcuts_article_action' => 'Article actions',
-  'shortcuts_navigation' => 'Navigation',
-  'shortcuts_navigation_help' => 'With the "Shift" modifier, navigation shortcuts apply on feeds.<br/>With the "Alt" modifier, navigation shortcuts apply on categories.',
-  'shortcuts_other_action' => 'Other actions',
-  'shortcuts_updated' => 'Shortcuts have been updated',
-  'show_adaptive' => 'Adjust showing',
-  'show_all_articles' => 'Show all articles',
-  'show_favorite' => 'Show only favorites',
-  'show_in_all_flux' => 'Show in main stream',
-  'show_not_favorite' => 'Show all but favorites',
-  'show_not_reads' => 'Show only unread',
-  'show_read' => 'Show only read',
-  'sort_order' => 'Sort order',
-  'starred_list' => 'List of favourite articles',
-  'stats' => 'Statistics',
-  'stats_entry_count' => 'Entry count',
-  'stats_entry_per_category' => 'Entries per category',
-  'stats_entry_per_day' => 'Entries per day (last 30 days)',
-  'stats_entry_per_day_of_week' => 'Per day of week (average: %.2f messages)',
-  'stats_entry_per_hour' => 'Per hour (average: %.2f messages)',
-  'stats_entry_per_month' => 'Per month (average: %.2f messages)',
-  'stats_entry_repartition' => 'Entries repartition',
-  'stats_feed_per_category' => 'Feeds per category',
-  'stats_idle' => 'Idle feeds',
-  'stats_main' => 'Main statistics',
-  'stats_no_idle' => 'There is no idle feed!',
-  'stats_percent_of_total' => '%% of total',
-  'stats_repartition' => 'Articles repartition',
-  'stats_top_feed' => 'Top ten feeds',
-  'status_favorites' => 'Favourites',
-  'status_read' => 'Read',
-  'status_total' => 'Total',
-  'status_unread' => 'Unread',
-  'steps' => 'Steps',
-  'sticky_post' => 'Stick the article to the top when opened',
-  'submit' => 'Submit',
-  'subscription_management' => 'Subscriptions management',
-  'sun' => 'Sun',
-  'theme' => 'Theme',
-  'think_to_add' => 'You may add some feeds.',
-  'this_is_the_end' => 'This is the end',
-  'thu' => 'Thu',
-  'title' => 'Title',
-  'today' => 'Today',
-  'top_line' => 'Top line',
-  'truncate' => 'Delete all articles',
-  'ttl' => 'Do not automatically refresh more often than',
-  'tue' => 'Tue',
-  'twitter' => 'Twitter',
-  'unsafe_autologin' => 'Allow unsafe automatic login using the format: ',
-  'update' => 'Update',
-  'updated' => 'Modifications have been updated',
-  'update_apply' => 'Apply',
-  'update_can_apply' => 'An update is available.',
-  'update_check' => 'Check for new updates',
-  'update_end' => 'Update process is completed, now you can go to the final step.',
-  'update_finished' => 'Update completed!',
-  'update_last' => 'Last verification: %s',
-  'update_long' => 'This can take a long time, depending on the size of your database. You may have to wait for this page to time out (~5 minutes) and then refresh this page.',
-  'update_problem' => 'The update process has encountered an error: %s',
-  'update_server_not_found' => 'Update server cannot be found. [%s]',
-  'update_start' => 'Start update process',
-  'update_system' => 'Update system',
-  'upon_reception' => 'upon reception of the article',
-  'username' => 'Username',
-  'username_admin' => 'Administrator username',
-  'users' => 'Users',
-  'users_list' => 'List of users',
-  'user_created' => 'User %s has been created',
-  'user_deleted' => 'User %s has been deleted',
-  'user_filter' => 'Access user filters',
-  'user_filter_help' => 'If there is only one user filter, it is used. Else filters are accessible by their number.',
-  'version' => 'Version',
-  'version_update' => 'Update',
-  'wallabag' => 'wallabag',
-  'website' => 'Website',
-  'website_url' => 'Website URL',
-  'wed' => 'Wed',
-  'width_large' => 'Large',
-  'width_medium' => 'Medium',
-  'width_no_limit' => 'No limit',
-  'width_thin' => 'Thin',
-  'yes' => 'Yes',
-  'yesterday' => 'Yesterday',
-  'your_diaspora_pod' => 'Your Diaspora* pod',
-  'your_favorites' => 'Your favourites',
-  'your_rss_feeds' => 'Your RSS feeds',
-  'your_shaarli' => 'Your Shaarli',
-  'your_wallabag' => 'Your wallabag',
-  'zip_error' => 'An error occured during Zip import.',
-);

+ 181 - 0
app/i18n/en/admin.php

@@ -0,0 +1,181 @@
+<?php
+
+return array(
+	'auth' => array(
+		'allow_anonymous' => 'Allow anonymous reading of the articles of the default user (%s)',
+		'allow_anonymous_refresh' => 'Allow anonymous refresh of the articles',
+		'api_enabled' => 'Allow <abbr>API</abbr> access <small>(required for mobile apps)</small>',
+		'form' => 'Web form (traditional, requires JavaScript)',
+		'http' => 'HTTP (for advanced users with HTTPS)',
+		'none' => 'None (dangerous)',
+		'title' => 'Authentication',
+		'title_reset' => 'Authentication reset',
+		'token' => 'Authentication token',
+		'token_help' => 'Allows access to RSS output of the default user without authentication:',
+		'type' => 'Authentication method',
+		'unsafe_autologin' => 'Allow unsafe automatic login using the format: ',
+	),
+	'check_install' => array(
+		'cache' => array(
+			'nok' => 'Check permissions on <em>./data/cache</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on cache directory are good.',
+		),
+		'categories' => array(
+			'nok' => 'Category table is bad configured.',
+			'ok' => 'Category table is ok.',
+		),
+		'connection' => array(
+			'nok' => 'Connection to the database cannot being established.',
+			'ok' => 'Connection to the database is ok.',
+		),
+		'ctype' => array(
+			'nok' => 'Cannot find a required library for character type checking (php-ctype).',
+			'ok' => 'You have the required library for character type checking (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Cannot find the cURL library (php-curl package).',
+			'ok' => 'You have the cURL library.',
+		),
+		'data' => array(
+			'nok' => 'Check permissions on <em>./data</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on data directory are good.',
+		),
+		'database' => 'Database installation',
+		'dom' => array(
+			'nok' => 'Cannot find a required library to browse the DOM (php-xml package).',
+			'ok' => 'You have the required library to browse the DOM.',
+		),
+		'entries' => array(
+			'nok' => 'Entry table is bad configured.',
+			'ok' => 'Entry table is ok.',
+		),
+		'favicons' => array(
+			'nok' => 'Check permissions on <em>./data/favicons</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on favicons directory are good.',
+		),
+		'feeds' => array(
+			'nok' => 'Feed table is bad configured.',
+			'ok' => 'Feed table is ok.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Cannot find the PHP fileinfo library (fileinfo package).',
+			'ok' => 'You have the fileinfo library.',
+		),
+		'files' => 'File installation',
+		'json' => array(
+			'nok' => 'Cannot find JSON (php5-json package).',
+			'ok' => 'You have JSON extension.',
+		),
+		'minz' => array(
+			'nok' => 'Cannot find the Minz framework.',
+			'ok' => 'You have the Minz framework.',
+		),
+		'pcre' => array(
+			'nok' => 'Cannot find a required library for regular expressions (php-pcre).',
+			'ok' => 'You have the required library for regular expressions (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Cannot find PDO or one of the supported drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'You have PDO and at least one of the supported drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'_' => 'PHP installation',
+			'nok' => 'Your PHP version is %s but FreshRSS requires at least version %s.',
+			'ok' => 'Your PHP version is %s, which is compatible with FreshRSS.',
+		),
+		'tables' => array(
+			'nok' => 'There is one or more lacking tables in the database.',
+			'ok' => 'Tables are existing in the database.',
+		),
+		'title' => 'Installation checking',
+		'tokens' => array(
+			'nok' => 'Check permissions on <em>./data/tokens</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on tokens directory are good.',
+		),
+		'users' => array(
+			'nok' => 'Check permissions on <em>./data/users</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on users directory are good.',
+		),
+		'zip' => array(
+			'nok' => 'Cannot find ZIP extension (php-zip package).',
+			'ok' => 'You have ZIP extension.',
+		),
+	),
+	'extensions' => array(
+		'disabled' => 'Disabled',
+		'empty_list' => 'There is no installed extension',
+		'enabled' => 'Enabled',
+		'no_configure_view' => 'This extension cannot be configured.',
+		'system' => array(
+			'_' => 'System extensions',
+			'no_rights' => 'System extension (you have no rights on it)',
+		),
+		'title' => 'Extensions',
+		'user' => 'User extensions',
+	),
+	'stats' => array(
+		'_' => 'Statistics',
+		'all_feeds' => 'All feeds',
+		'category' => 'Category',
+		'entry_count' => 'Entry count',
+		'entry_per_category' => 'Entries per category',
+		'entry_per_day' => 'Entries per day (last 30 days)',
+		'entry_per_day_of_week' => 'Per day of week (average: %.2f messages)',
+		'entry_per_hour' => 'Per hour (average: %.2f messages)',
+		'entry_per_month' => 'Per month (average: %.2f messages)',
+		'entry_repartition' => 'Entries repartition',
+		'feed' => 'Feed',
+		'feed_per_category' => 'Feeds per category',
+		'idle' => 'Idle feeds',
+		'main' => 'Main statistics',
+		'main_stream' => 'Main stream',
+		'menu' => array(
+			'idle' => 'Idle feeds',
+			'main' => 'Main statistics',
+			'repartition' => 'Articles repartition',
+		),
+		'no_idle' => 'There is no idle feed!',
+		'number_entries' => '%d articles',
+		'percent_of_total' => '%% of total',
+		'repartition' => 'Articles repartition',
+		'status_favorites' => 'Favourites',
+		'status_read' => 'Read',
+		'status_total' => 'Total',
+		'status_unread' => 'Unread',
+		'title' => 'Statistics',
+		'top_feed' => 'Top ten feeds',
+	),
+	'system' => array(
+		'_' => 'System configuration',
+		'auto-update-url' => 'Auto-update server URL',
+		'instance-name' => 'Instance name',
+		'max-categories' => 'Categories per user limit',
+		'max-feeds' => 'Feeds per user limit',
+		'registration' => array(
+			'help' => '0 means that there is no account limit',
+			'number' => 'Max number of accounts',
+		),
+	),
+	'update' => array(
+		'_' => 'Update system',
+		'apply' => 'Apply',
+		'check' => 'Check for new updates',
+		'current_version' => 'Your current version of FreshRSS is the %s.',
+		'last' => 'Last verification: %s',
+		'none' => 'No update to apply',
+		'title' => 'Update system',
+	),
+	'user' => array(
+		'articles_and_size' => '%s articles (%s)',
+		'create' => 'Create new user',
+		'language' => 'Language',
+		'number' => 'There is %d account created yet',
+		'numbers' => 'There are %d accounts created yet',
+		'password_form' => 'Password<br /><small>(for the Web-form login method)</small>',
+		'password_format' => 'At least 7 characters',
+		'title' => 'Manage users',
+		'user_list' => 'List of users',
+		'username' => 'Username',
+		'users' => 'Users',
+	),
+);

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

@@ -0,0 +1,173 @@
+<?php
+
+return array(
+	'archiving' => array(
+		'_' => 'Archiving',
+		'advanced' => 'Advanced',
+		'delete_after' => 'Remove articles after',
+		'help' => 'More options are available in the individual feed settings',
+		'keep_history_by_feed' => 'Minimum number of articles to keep by feed',
+		'optimize' => 'Optimise database',
+		'optimize_help' => 'To do occasionally to reduce the size of the database',
+		'purge_now' => 'Purge now',
+		'title' => 'Archiving',
+		'ttl' => 'Do not automatically refresh more often than',
+	),
+	'display' => array(
+		'_' => 'Display',
+		'icon' => array(
+			'bottom_line' => 'Bottom line',
+			'entry' => 'Article icons',
+			'publication_date' => 'Date of publication',
+			'related_tags' => 'Related tags',
+			'sharing' => 'Sharing',
+			'top_line' => 'Top line',
+		),
+		'language' => 'Language',
+		'notif_html5' => array(
+			'seconds' => 'seconds (0 means no timeout)',
+			'timeout' => 'HTML5 notification timeout',
+		),
+		'theme' => 'Theme',
+		'title' => 'Display',
+		'width' => array(
+			'content' => 'Content width',
+			'large' => 'Large',
+			'medium' => 'Medium',
+			'no_limit' => 'No limit',
+			'thin' => 'Thin',
+		),
+	),
+	'query' => array(
+		'_' => 'User queries',
+		'deprecated' => 'This query is no longer valid. The referenced category or feed has been deleted.',
+		'filter' => 'Filter applied:',
+		'get_all' => 'Display all articles',
+		'get_category' => 'Display "%s" category',
+		'get_favorite' => 'Display favorite articles',
+		'get_feed' => 'Display "%s" feed',
+		'no_filter' => 'No filter',
+		'none' => 'You haven’t created any user query yet.',
+		'number' => 'Query n°%d',
+		'order_asc' => 'Display oldest articles first',
+		'order_desc' => 'Display newest articles first',
+		'search' => 'Search for "%s"',
+		'state_0' => 'Display all articles',
+		'state_1' => 'Display read articles',
+		'state_2' => 'Display unread articles',
+		'state_3' => 'Display all articles',
+		'state_4' => 'Display favorite articles',
+		'state_5' => 'Display read favorite articles',
+		'state_6' => 'Display unread favorite articles',
+		'state_7' => 'Display favorite articles',
+		'state_8' => 'Display not favorite articles',
+		'state_9' => 'Display read not favorite articles',
+		'state_10' => 'Display unread not favorite articles',
+		'state_11' => 'Display not favorite articles',
+		'state_12' => 'Display all articles',
+		'state_13' => 'Display read articles',
+		'state_14' => 'Display unread articles',
+		'state_15' => 'Display all articles',
+		'title' => 'User queries',
+	),
+	'profile' => array(
+		'_' => 'Profile management',
+		'delete' => array(
+			'_' => 'Account deletion',
+			'warn' => 'Your account and all the related data will be deleted.',
+		),
+		'password_api' => 'API password<br /><small>(e.g., for mobile apps)</small>',
+		'password_form' => 'Password<br /><small>(for the Web-form login method)</small>',
+		'password_format' => 'At least 7 characters',
+		'title' => 'Profile',
+	),
+	'reading' => array(
+		'_' => 'Reading',
+		'after_onread' => 'After “mark all as read”,',
+		'articles_per_page' => 'Number of articles per page',
+		'auto_load_more' => 'Load next articles at the page bottom',
+		'auto_remove_article' => 'Hide articles after reading',
+		'mark_updated_article_unread' => 'Mark updated articles as unread',
+		'confirm_enabled' => 'Display a confirmation dialog on “mark all as read” actions',
+		'display_articles_unfolded' => 'Show articles unfolded by default',
+		'display_categories_unfolded' => 'Show categories folded by default',
+		'hide_read_feeds' => 'Hide categories & feeds with no unread article (does not work with “Show all articles” configuration)',
+		'img_with_lazyload' => 'Use "lazy load" mode to load pictures',
+		'jump_next' => 'jump to next unread sibling (feed or category)',
+		'number_divided_when_reader' => 'Divided by 2 in the reading view.',
+		'read' => array(
+			'article_open_on_website' => 'when article is opened on its original website',
+			'article_viewed' => 'when article is viewed',
+			'scroll' => 'while scrolling',
+			'upon_reception' => 'upon reception of the article',
+			'when' => 'Mark article as read…',
+		),
+		'show' => array(
+			'_' => 	'Articles to display',
+			'adaptive' => 'Adjust showing',
+			'all_articles' => 'Show all articles',
+			'unread' => 'Show only unread',
+		),
+		'sort' => array(
+			'_' => 'Sort order',
+			'newer_first' => 'Newer first',
+			'older_first' => 'Oldest first',
+		),
+		'sticky_post' => 'Stick the article to the top when opened',
+		'title' => 'Reading',
+		'view' => array(
+			'default' => 'Default view',
+			'global' => 'Global view',
+			'normal' => 'Normal view',
+			'reader' => 'Reading view',
+		),
+	),
+	'sharing' => array(
+		'_' => 'Sharing',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'more_information' => 'More information',
+		'print' => 'Print',
+		'shaarli' => 'Shaarli',
+		'share_name' => 'Share name to display',
+		'share_url' => 'Share URL to use',
+		'title' => 'Sharing',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag',
+	),
+	'shortcut' => array(
+		'_' => 'Shortcuts',
+		'article_action' => 'Article actions',
+		'auto_share' => 'Share',
+		'auto_share_help' => 'If there is only one sharing mode, it is used. Else modes are accessible by their number.',
+		'close_dropdown' => 'Close menus',
+		'collapse_article' => 'Collapse',
+		'first_article' => 'Skip to the first article',
+		'focus_search' => 'Access search box',
+		'help' => 'Display documentation',
+		'javascript' => 'JavaScript must be enabled in order to use shortcuts',
+		'last_article' => 'Skip to the last article',
+		'load_more' => 'Load more articles',
+		'mark_read' => 'Mark as read',
+		'mark_favorite' => 'Mark as favourite',
+		'navigation' => 'Navigation',
+		'navigation_help' => 'With the "Shift" modifier, navigation shortcuts apply on feeds.<br/>With the "Alt" modifier, navigation shortcuts apply on categories.',
+		'next_article' => 'Skip to the next article',
+		'other_action' => 'Other actions',
+		'previous_article' => 'Skip to the previous article',
+		'see_on_website' => 'See on original website',
+		'shift_for_all_read' => '+ <code>shift</code> to mark all articles as read',
+		'title' => 'Shortcuts',
+		'user_filter' => 'Access user filters',
+		'user_filter_help' => 'If there is only one user filter, it is used. Else filters are accessible by their number.',
+	),
+	'user' => array(
+		'articles_and_size' => '%s articles (%s)',
+		'current' => 'Current user',
+		'is_admin' => 'is administrator',
+		'users' => 'Users',
+	),
+);

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

@@ -0,0 +1,109 @@
+<?php
+
+return array(
+	'admin' => array(
+		'optimization_complete' => 'Optimisation complete',
+	),
+	'access' => array(
+		'denied' => 'You don’t have permission to access this page',
+		'not_found' => 'You are looking for a page which doesn’t exist',
+	),
+	'auth' => array(
+		'form' => array(
+			'not_set' => 'A problem occured during authentication system configuration. Please retry later.',
+			'set' => 'Form is now your default authentication system.',
+		),
+		'login' => array(
+			'invalid' => 'Login is invalid',
+			'success' => 'You are connected',
+		),
+		'logout' => array(
+			'success' => 'You are disconnected',
+		),
+		'no_password_set' => 'Administrator password hasn’t been set. This feature isn’t available.',
+	),
+	'conf' => array(
+		'error' => 'An error occurred during configuration saving',
+		'query_created' => 'Query "%s" has been created.',
+		'shortcuts_updated' => 'Shortcuts have been updated',
+		'updated' => 'Configuration has been updated',
+	),
+	'extensions' => array(
+		'already_enabled' => '%s is already enabled',
+		'disable' => array(
+			'ko' => '%s cannot be disabled. <a href="%s">Check FressRSS logs</a> for details.',
+			'ok' => '%s is now disabled',
+		),
+		'enable' => array(
+			'ko' => '%s cannot be enabled. <a href="%s">Check FressRSS logs</a> for details.',
+			'ok' => '%s is now enabled',
+		),
+		'no_access' => 'You have no access on %s',
+		'not_enabled' => '%s is not enabled yet',
+		'not_found' => '%s does not exist',
+	),
+	'import_export' => array(
+		'export_no_zip_extension' => 'ZIP extension is not present on your server. Please try to export files one by one.',
+		'feeds_imported' => 'Your feeds have been imported and will now be updated',
+		'feeds_imported_with_errors' => 'Your feeds have been imported but some errors occurred',
+		'file_cannot_be_uploaded' => 'File cannot be uploaded!',
+		'no_zip_extension' => 'ZIP extension is not present on your server.',
+		'zip_error' => 'An error occured during ZIP import.',
+	),
+	'sub' => array(
+		'actualize' => 'Actualise',
+		'category' => array(
+			'created' => 'Category %s has been created.',
+			'deleted' => 'Category has been deleted.',
+			'emptied' => 'Category has been emptied',
+			'error' => 'Category cannot be updated',
+			'name_exists' => 'Category name already exists.',
+			'no_id' => 'You must precise the id of the category.',
+			'no_name' => 'Category name cannot be empty.',
+			'not_delete_default' => 'You cannot delete the default category!',
+			'not_exist' => 'The category does not exist!',
+			'over_max' => 'You have reached your limit of categories (%d)',
+			'updated' => 'Category has been updated.',
+		),
+		'feed' => array(
+			'actualized' => '<em>%s</em> has been updated',
+			'actualizeds' => 'RSS feeds have been updated',
+			'added' => 'RSS feed <em>%s</em> has been added',
+			'already_subscribed' => 'You have already subscribed to <em>%s</em>',
+			'deleted' => 'Feed has been deleted',
+			'error' => 'Feed cannot be updated',
+			'internal_problem' => 'The RSS feed could not be added. <a href="%s">Check FressRSS logs</a> for details.',
+			'invalid_url' => 'URL <em>%s</em> is invalid',
+			'marked_read' => 'Feeds have been marked as read',
+			'n_actualized' => '%d feeds have been updated',
+			'n_entries_deleted' => '%d articles have been deleted',
+			'no_refresh' => 'There is no feed to refresh…',
+			'not_added' => '<em>%s</em> could not be added',
+			'over_max' => 'You have reached your limit of feeds (%d)',
+			'updated' => 'Feed has been updated',
+		),
+		'purge_completed' => 'Purge completed (%d articles deleted)',
+	),
+	'update' => array(
+		'can_apply' => 'FreshRSS will now be updated to the <strong>version %s</strong>.',
+		'error' => 'The update process has encountered an error: %s',
+		'file_is_nok' => 'Check permissions on <em>%s</em> directory. HTTP server must have rights to write into',
+		'finished' => 'Update completed!',
+		'none' => 'No update to apply',
+		'server_not_found' => 'Update server cannot be found. [%s]',
+	),
+	'user' => array(
+		'created' => array(
+			'_' => 'User %s has been created',
+			'error' => 'User %s cannot be created',
+		),
+		'deleted' => array(
+			'_' => 'User %s has been deleted',
+			'error' => 'User %s cannot be deleted',
+		),
+	),
+	'profile' => array(
+		'error' => 'Your profile cannot be modified',
+		'updated' => 'Your profile has been modified',
+	),
+);

+ 182 - 0
app/i18n/en/gen.php

@@ -0,0 +1,182 @@
+<?php
+
+return array(
+	'action' => array(
+		'actualize' => 'Actualize',
+		'back_to_rss_feeds' => '← Go back to your RSS feeds',
+		'cancel' => 'Cancel',
+		'create' => 'Create',
+		'disable' => 'Disable',
+		'empty' => 'Empty',
+		'enable' => 'Enable',
+		'export' => 'Export',
+		'filter' => 'Filter',
+		'import' => 'Import',
+		'manage' => 'Manage',
+		'mark_read' => 'Mark as read',
+		'mark_favorite' => 'Mark as favourite',
+		'remove' => 'Remove',
+		'see_website' => 'See website',
+		'submit' => 'Submit',
+		'truncate' => 'Delete all articles',
+	),
+	'auth' => array(
+		'email' => 'Email address',
+		'keep_logged_in' => 'Keep me logged in <small>(%s days)</small>',
+		'login' => 'Login',
+		'logout' => 'Logout',
+		'password' => array(
+			'_' => 'Password',
+			'format' => '<small>At least 7 characters</small>',
+		),
+		'registration' => array(
+			'_' => 'New account',
+			'ask' => 'Create an account?',
+			'title' => 'Account creation',
+		),
+		'reset' => 'Authentication reset',
+		'username' => array(
+			'_' => 'Username',
+			'admin' => 'Administrator username',
+			'format' => '<small>maximum 16 alphanumeric characters</small>',
+		),
+	),
+	'date' => array(
+		'Apr' => '\\A\\p\\r\\i\\l',
+		'Aug' => '\\A\\u\\g\\u\\s\\t',
+		'Dec' => '\\D\\e\\c\\e\\m\\b\\e\\r',
+		'Feb' => '\\F\\e\\b\\r\\u\\a\\r\\y',
+		'Jan' => '\\J\\a\\n\\u\\a\\r\\y',
+		'Jul' => '\\J\\u\\l\\y',
+		'Jun' => '\\J\\u\\n\\e',
+		'Mar' => '\\M\\a\\r\\c\\h',
+		'May' => '\\M\\a\\y',
+		'Nov' => '\\N\\o\\v\\e\\m\\b\\e\\r',
+		'Oct' => '\\O\\c\\t\\o\\b\\e\\r',
+		'Sep' => '\\S\\e\\p\\t\\e\\m\\b\\e\\r',
+		'apr' => 'apr',
+		'april' => 'Apr',
+		'aug' => 'aug',
+		'august' => 'Aug',
+		'before_yesterday' => 'Before yesterday',
+		'dec' => 'dec',
+		'december' => 'Dec',
+		'feb' => 'feb',
+		'february' => 'Feb',
+		'format_date' => '%s j\\<\\s\\u\\p\\>S\\<\\/\\s\\u\\p\\> Y',
+		'format_date_hour' => '%s j\\<\\s\\u\\p\\>S\\<\\/\\s\\u\\p\\> Y \\a\\t H\\:i',
+		'fri' => 'Fri',
+		'jan' => 'jan',
+		'january' => 'Jan',
+		'jul' => 'jul',
+		'july' => 'Jul',
+		'jun' => 'jun',
+		'june' => 'Jun',
+		'last_3_month' => 'Last three months',
+		'last_6_month' => 'Last six months',
+		'last_month' => 'Last month',
+		'last_week' => 'Last week',
+		'last_year' => 'Last year',
+		'mar' => 'mar',
+		'march' => 'Mar',
+		'may' => 'May',
+		'mon' => 'Mon',
+		'month' => 'months',
+		'nov' => 'nov',
+		'november' => 'Nov',
+		'oct' => 'oct',
+		'october' => 'Oct',
+		'sat' => 'Sat',
+		'sep' => 'sep',
+		'september' => 'Sep',
+		'sun' => 'Sun',
+		'thu' => 'Thu',
+		'today' => 'Today',
+		'tue' => 'Tue',
+		'wed' => 'Wed',
+		'yesterday' => 'Yesterday',
+	),
+	'freshrss' => array(
+		'_' => 'FreshRSS',
+		'about' => 'About FreshRSS',
+	),
+	'js' => array(
+		'category_empty' => 'Empty category',
+		'confirm_action' => 'Are you sure you want to perform this action? It cannot be cancelled!',
+		'confirm_action_feed_cat' => 'Are you sure you want to perform this action? You will lose related favorites and user queries. It cannot be cancelled!',
+		'feedback' => array(
+			'body_new_articles' => 'There are %%d new articles to read on FreshRSS.',
+			'request_failed' => 'A request has failed, it may have been caused by Internet connection problems.',
+			'title_new_articles' => 'FreshRSS: new articles!',
+		),
+		'new_article' => 'There are new available articles, click to refresh the page.',
+		'should_be_activated' => 'JavaScript must be enabled',
+	),
+	'lang' => array(
+		'cz' => 'Čeština',
+		'de' => 'Deutsch',
+		'en' => 'English',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'nl' => 'Nederlands',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+	),
+	'menu' => array(
+		'about' => 'About',
+		'admin' => 'Administration',
+		'archiving' => 'Archiving',
+		'authentication' => 'Authentication',
+		'check_install' => 'Installation checking',
+		'configuration' => 'Configuration',
+		'display' => 'Display',
+		'extensions' => 'Extensions',
+		'logs' => 'Logs',
+		'queries' => 'User queries',
+		'reading' => 'Reading',
+		'search' => 'Search words or #tags',
+		'sharing' => 'Sharing',
+		'shortcuts' => 'Shortcuts',
+		'stats' => 'Statistics',
+		'system' => 'System configuration',
+		'update' => 'Update',
+		'user_management' => 'Manage users',
+		'user_profile' => 'Profile',
+	),
+	'pagination' => array(
+		'first' => 'First',
+		'last' => 'Last',
+		'load_more' => 'Load more articles',
+		'mark_all_read' => 'Mark all as read',
+		'next' => 'Next',
+		'nothing_to_load' => 'There are no more articles',
+		'previous' => 'Previous',
+	),
+	'share' => array(
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'movim' => 'Movim',
+		'print' => 'Print',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
+		'jdh' => 'Journal du hacker',
+	),
+	'short' => array(
+		'attention' => 'Warning!',
+		'blank_to_disable' => 'Leave blank to disable',
+		'by_author' => 'By <em>%s</em>',
+		'by_default' => 'By default',
+		'damn' => 'Damn!',
+		'default_category' => 'Uncategorized',
+		'no' => 'No',
+		'not_applicable' => 'Not available',
+		'ok' => 'Ok!',
+		'or' => 'or',
+		'yes' => 'Yes',
+	),
+);

+ 61 - 0
app/i18n/en/index.php

@@ -0,0 +1,61 @@
+<?php
+
+return array(
+	'about' => array(
+		'_' => 'About',
+		'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
+		'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.',
+		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
+		'license' => 'License',
+		'project_website' => 'Project website',
+		'title' => 'About',
+		'version' => 'Version',
+		'website' => 'Website',
+	),
+	'feed' => array(
+		'add' => 'You may add some feeds.',
+		'empty' => 'There is no article to show.',
+		'rss_of' => 'RSS feed of %s',
+		'title' => 'Your RSS feeds',
+		'title_global' => 'Global view',
+		'title_fav' => 'Your favourites',
+	),
+	'log' => array(
+		'_' => 'Logs',
+		'clear' => 'Clear the logs',
+		'empty' => 'Log file is empty',
+		'title' => 'Logs',
+	),
+	'menu' => array(
+		'about' => 'About FreshRSS',
+		'add_query' => 'Add a query',
+		'before_one_day' => 'Before one day',
+		'before_one_week' => 'Before one week',
+		'favorites' => 'Favourites (%s)',
+		'global_view' => 'Global view',
+		'main_stream' => 'Main stream',
+		'mark_all_read' => 'Mark all as read',
+		'mark_cat_read' => 'Mark category as read',
+		'mark_feed_read' => 'Mark feed as read',
+		'newer_first' => 'Newer first',
+		'non-starred' => 'Show all but favorites',
+		'normal_view' => 'Normal view',
+		'older_first' => 'Oldest first',
+		'queries' => 'User queries',
+		'read' => 'Show only read',
+		'reader_view' => 'Reading view',
+		'rss_view' => 'RSS feed',
+		'search_short' => 'Search',
+		'starred' => 'Show only favorites',
+		'stats' => 'Statistics',
+		'subscription' => 'Subscriptions management',
+		'unread' => 'Show only unread',
+	),
+	'share' => 'Share',
+	'tag' => array(
+		'related' => 'Related tags',
+	),
+);

+ 119 - 0
app/i18n/en/install.php

@@ -0,0 +1,119 @@
+<?php
+
+return array(
+	'action' => array(
+		'finish' => 'Complete installation',
+		'fix_errors_before' => 'Please fix errors before skipping to the next step.',
+		'keep_install' => 'Keep previous configuration',
+		'next_step' => 'Go to the next step',
+		'reinstall' => 'Reinstall FreshRSS',
+	),
+	'auth' => array(
+		'form' => 'Web form (traditional, requires JavaScript)',
+		'http' => 'HTTP (for advanced users with HTTPS)',
+		'none' => 'None (dangerous)',
+		'password_form' => 'Password<br /><small>(for the Web-form login method)</small>',
+		'password_format' => 'At least 7 characters',
+		'type' => 'Authentication method',
+	),
+	'bdd' => array(
+		'_' => 'Database',
+		'conf' => array(
+			'_' => 'Database configuration',
+			'ko' => 'Verify your database information.',
+			'ok' => 'Database configuration has been saved.',
+		),
+		'host' => 'Host',
+		'prefix' => 'Table prefix',
+		'password' => 'Database password',
+		'type' => 'Type of database',
+		'username' => 'Database username',
+	),
+	'check' => array(
+		'_' => 'Checks',
+		'already_installed' => 'We have detected that FreshRSS is already installed!',
+		'cache' => array(
+			'nok' => 'Check permissions on <em>./data/cache</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on cache directory are good.',
+		),
+		'ctype' => array(
+			'nok' => 'Cannot find a required library for character type checking (php-ctype).',
+			'ok' => 'You have the required library for character type checking (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Cannot find the cURL library (php-curl package).',
+			'ok' => 'You have the cURL library.',
+		),
+		'data' => array(
+			'nok' => 'Check permissions on <em>./data</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on data directory are good.',
+		),
+		'dom' => array(
+			'nok' => 'Cannot find a required library to browse the DOM.',
+			'ok' => 'You have the required library to browse the DOM.',
+		),
+		'favicons' => array(
+			'nok' => 'Check permissions on <em>./data/favicons</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on favicons directory are good.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Cannot find the PHP fileinfo library (fileinfo package).',
+			'ok' => 'You have the fileinfo library.',
+		),
+		'http_referer' => array(
+			'nok' => 'Please check that you are not altering your HTTP REFERER.',
+			'ok' => 'Your HTTP REFERER is known and corresponds to your server.',
+		),
+		'json' => array(
+			'nok' => 'Cannot find a recommended library to parse JSON.',
+			'ok' => 'You have a recommended library to parse JSON.',
+		),
+		'minz' => array(
+			'nok' => 'Cannot find the Minz framework.',
+			'ok' => 'You have the Minz framework.',
+		),
+		'pcre' => array(
+			'nok' => 'Cannot find a required library for regular expressions (php-pcre).',
+			'ok' => 'You have the required library for regular expressions (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Cannot find PDO or one of the supported drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'You have PDO and at least one of the supported drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'nok' => 'Your PHP version is %s but FreshRSS requires at least version %s.',
+			'ok' => 'Your PHP version is %s, which is compatible with FreshRSS.',
+		),
+		'users' => array(
+			'nok' => 'Check permissions on <em>./data/users</em> directory. HTTP server must have rights to write into',
+			'ok' => 'Permissions on users directory are good.',
+		),
+		'xml' => array(
+			'nok' => 'Cannot find the required library to parse XML.',
+			'ok' => 'You have the required library to parse XML.',
+		),
+	),
+	'conf' => array(
+		'_' => 'General configuration',
+		'ok' => 'General configuration has been saved.',
+	),
+	'congratulations' => 'Congratulations!',
+	'default_user' => 'Username of the default user <small>(maximum 16 alphanumeric characters)</small>',
+	'delete_articles_after' => 'Remove articles after',
+	'fix_errors_before' => 'Please fix errors before skipping to the next step.',
+	'javascript_is_better' => 'FreshRSS is more pleasant with JavaScript enabled',
+	'js' => array(
+		'confirm_reinstall' => 'You will lose your previous configuration by reinstalling FreshRSS. Are you sure you want to continue?',
+	),
+	'language' => array(
+		'_' => 'Language',
+		'choose' => 'Choose a language for FreshRSS',
+		'defined' => 'Language has been defined.',
+	),
+	'not_deleted' => 'Something went wrong; you must delete the file <em>%s</em> manually.',
+	'ok' => 'The installation process was successful.',
+	'step' => 'step %d',
+	'steps' => 'Steps',
+	'title' => 'Installation · FreshRSS',
+	'this_is_the_end' => 'This is the end',
+);

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

@@ -0,0 +1,62 @@
+<?php
+
+return array(
+	'category' => array(
+		'_' => 'Category',
+		'add' => 'Add a category',
+		'empty' => 'Empty category',
+		'new' => 'New category',
+	),
+	'feed' => array(
+		'add' => 'Add a RSS feed',
+		'advanced' => 'Advanced',
+		'archiving' => 'Archivage',
+		'auth' => array(
+			'configuration' => 'Login',
+			'help' => 'Connection allows to access HTTP protected RSS feeds',
+			'http' => 'HTTP Authentication',
+			'password' => 'HTTP password',
+			'username' => 'HTTP username',
+		),
+		'css_help' => 'Retrieves truncated RSS feeds (caution, requires more time!)',
+		'css_path' => 'Articles CSS path on original website',
+		'description' => 'Description',
+		'empty' => 'This feed is empty. Please verify that it is still maintained.',
+		'error' => 'This feed has encountered a problem. Please verify that it is always reachable then actualize it.',
+		'in_main_stream' => 'Show in main stream',
+		'informations' => 'Information',
+		'keep_history' => 'Minimum number of articles to keep',
+		'moved_category_deleted' => 'When you delete a category, its feeds are automatically classified under <em>%s</em>.',
+		'no_selected' => 'No feed selected.',
+		'number_entries' => '%d articles',
+		'stats' => 'Statistics',
+		'think_to_add' => 'You may add some feeds.',
+		'title' => 'Title',
+		'title_add' => 'Add a RSS feed',
+		'ttl' => 'Do not automatically refresh more often than',
+		'url' => 'Feed URL',
+		'validator' => 'Check the validity of the feed',
+		'website' => 'Website URL',
+		'pubsubhubbub' => 'Instant notification with PubSubHubbub',
+	),
+	'import_export' => array(
+		'export' => 'Export',
+		'export_opml' => 'Export list of feeds (OPML)',
+		'export_starred' => 'Export your favourites',
+		'feed_list' => 'List of %s articles',
+		'file_to_import' => 'File to import<br />(OPML, JSON or ZIP)',
+		'file_to_import_no_zip' => 'File to import<br />(OPML or JSON)',
+		'import' => 'Import',
+		'starred_list' => 'List of favourite articles',
+		'title' => 'Import / export',
+	),
+	'menu' => array(
+		'bookmark' => 'Subscribe (FreshRSS bookmark)',
+		'import_export' => 'Import / export',
+		'subscription_management' => 'Subscriptions management',
+	),
+	'title' => array(
+		'_' => 'Subscriptions management',
+		'feed_management' => 'RSS feeds management',
+	),
+);

+ 0 - 528
app/i18n/fr.php

@@ -1,528 +0,0 @@
-<?php
- return array (
-  'about' => 'À propos',
-  'about_freshrss' => 'À propos de FreshRSS',
-  'access_denied' => 'Vous n’avez pas le droit d’accéder à cette page !',
-  'access_protected_feeds' => 'La connexion permet d’accéder aux flux protégés par une authentification HTTP.',
-  'activate_sharing' => 'Activer le partage',
-  'actualize' => 'Actualiser',
-  'add_category' => 'Ajouter une catégorie',
-  'add_query' => 'Créer un filtre',
-  'add_rss_feed' => 'Ajouter un flux RSS',
-  'admin.check_install.cache.nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/cache</em>. Le serveur HTTP doit être capable d’écrire dedans',
-  'admin.check_install.cache.ok' => 'Les droits sur le répertoire de cache sont bons.',
-  'admin.check_install.categories.nok' => 'La table category est mal configurée.',
-  'admin.check_install.categories.ok' => 'La table category est bien configurée.',
-  'admin.check_install.connection.nok' => 'La connexion à la base de données est impossible.',
-  'admin.check_install.connection.ok' => 'La connexion à la base de données est bonne.',
-  'admin.check_install.ctype.nok' => 'Il manque une librairie pour la vérification des types de caractères (php-ctype).',
-  'admin.check_install.ctype.ok' => 'Vous disposez du nécessaire pour la vérification des types de caractères (ctype).',
-  'admin.check_install.curl.nok' => 'Vous ne disposez pas de cURL (paquet php5-curl).',
-  'admin.check_install.curl.ok' => 'Vous disposez de cURL.',
-  'admin.check_install.data.nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data</em>. Le serveur HTTP doit être capable d’écrire dedans',
-  'admin.check_install.data.ok' => 'Les droits sur le répertoire de data sont bons.',
-  'admin.check_install.database' => 'Installation de la base de données',
-  'admin.check_install.dom.nok' => 'Il manque une librairie pour parcourir le DOM (paquet php-xml).',
-  'admin.check_install.dom.ok' => 'Vous disposez du nécessaire pour parcourir le DOM.',
-  'admin.check_install.entries.nok' => 'La table entry est mal configurée.',
-  'admin.check_install.entries.ok' => 'La table entry est bien configurée.',
-  'admin.check_install.favicons.nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/favicons</em>. Le serveur HTTP doit être capable d’écrire dedans',
-  'admin.check_install.favicons.ok' => 'Les droits sur le répertoire des favicons sont bons.',
-  'admin.check_install.feeds.nok' => 'La table feed est mal configurée.',
-  'admin.check_install.feeds.ok' => 'La table feed est bien configurée.',
-  'admin.check_install.files' => 'Installation des fichiers',
-  'admin.check_install.json.nok' => 'Vous ne disposez pas de JSON (paquet php5-json).',
-  'admin.check_install.json.ok' => 'Vous disposez de l\'extension JSON.',
-  'admin.check_install.logs.nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/logs</em>. Le serveur HTTP doit être capable d’écrire dedans',
-  'admin.check_install.logs.ok' => 'Les droits sur le répertoire des logs sont bons.',
-  'admin.check_install.minz.nok' => 'Vous ne disposez pas de la librairie Minz.',
-  'admin.check_install.minz.ok' => 'Vous disposez du framework Minz',
-  'admin.check_install.pcre.nok' => 'Il manque une librairie pour les expressions régulières (php-pcre).',
-  'admin.check_install.pcre.ok' => 'Vous disposez du nécessaire pour les expressions régulières (PCRE).',
-  'admin.check_install.pdo.nok' => 'Vous ne disposez pas de PDO ou d’un des drivers supportés (pdo_mysql, pdo_sqlite).',
-  'admin.check_install.pdo.ok' => 'Vous disposez de PDO et d’au moins un des drivers supportés (pdo_mysql, pdo_sqlite).',
-  'admin.check_install.pdo_mysql.nok' => 'Vous ne possédez pas PDO pour MySQL (ok si PDO est bon).',
-  'admin.check_install.pdo_mysql.ok' => 'Vous possédez PDO pour MySQL.',
-  'admin.check_install.pdo_sqlite.nok' => 'Vous ne possédez pas PDO pour SQLite (ok si PDO est bon).',
-  'admin.check_install.pdo_sqlite.ok' => 'Vous possédez PDO pour SQLite.',
-  'admin.check_install.persona.nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/persona</em>. Le serveur HTTP doit être capable d’écrire dedans',
-  'admin.check_install.persona.ok' => 'Les droits sur le répertoire de Mozilla Persona sont bons.',
-  'admin.check_install.php' => 'Installation de PHP',
-  'admin.check_install.php.nok' => 'Votre version de PHP est la %s mais FreshRSS requiert au moins la version %s.',
-  'admin.check_install.php.ok' => 'Votre version de PHP est la %s, qui est compatible avec FreshRSS.',
-  'admin.check_install.tables.nok' => 'Il manque une ou plusieurs tables en base de données.',
-  'admin.check_install.tables.ok' => 'Les tables sont bien présentes en base de données.',
-  'admin.check_install.tokens.nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/tokens</em>. Le serveur HTTP doit être capable d’écrire dedans',
-  'admin.check_install.tokens.ok' => 'Les droits sur le répertoire des tokens sont bons.',
-  'admin.check_install.zip.nok' => 'Vous ne disposez pas de l\'extension ZIP (paquet php5-zip).',
-  'admin.check_install.zip.ok' => 'Vous disposez de l\'extension ZIP.',
-  'admin.users.articles_and_size' => '%s articles (%s)',
-  'administration' => 'Gérer',
-  'advanced' => 'Avancé',
-  'after_onread' => 'Après “marquer tout comme lu”,',
-  'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
-  'allow_anonymous' => 'Autoriser la lecture anonyme des articles de l’utilisateur par défaut (%s)',
-  'allow_anonymous_refresh' => 'Autoriser le rafraîchissement anonyme des flux',
-  'all_feeds' => 'Tous les flux',
-  'already_subscribed' => 'Vous êtes déjà abonné à <em>%s</em>',
-  'api_enabled' => 'Autoriser l’accès par <abbr>API</abbr> <small>(nécessaire pour les applis mobiles)</small>',
-  'Apr' => '\\a\\v\\r\\i\\l',
-  'apr' => 'avr.',
-  'april' => 'avril',
-  'archiving_configuration' => 'Archivage',
-  'archiving_configuration_help' => 'D’autres options sont disponibles dans la configuration individuelle des flux.',
-  'article' => 'Article',
-  'articles' => 'articles',
-  'articles_per_page' => 'Nombre d’articles par page',
-  'articles_to_display' => 'Articles à afficher',
-  'article_icons' => 'Icônes d’article',
-  'article_open_on_website' => 'lorsque l’article est ouvert sur le site d’origine',
-  'article_published_on' => 'Article publié initialement sur <a href="%s">%s</a>',
-  'article_published_on_author' => 'Article publié initialement sur <a href="%s">%s</a> par %s',
-  'article_viewed' => 'lorsque l’article est affiché',
-  'ask_empty' => 'Vider ?',
-  'attention' => 'Attention !',
-  'Aug' => '\\a\\o\\û\\t',
-  'aug' => 'août',
-  'august' => 'août',
-  'author' => 'Auteur',
-  'auth_form' => 'Formulaire (traditionnel, requiert JavaScript)',
-  'auth_form_not_set' => 'Un problème est survenu lors de la configuration de votre système d’authentification. Veuillez réessayer plus tard.',
-  'auth_form_set' => 'Le formulaire est désormais votre système d’authentification.',
-  'auth_none' => 'Aucune (dangereux)',
-  'auth_not_persona' => 'Seul le système d’authentification Persona peut être réinitialisé.',
-  'auth_no_password_set' => 'Aucun mot de passe administrateur n’a été précisé. Cette fonctionnalité n’est pas disponible.',
-  'auth_persona' => 'Mozilla Persona (moderne, requiert JavaScript)',
-  'auth_reset' => 'Réinitialisation de l’authentification',
-  'auth_token' => 'Jeton d’identification',
-  'auth_type' => 'Méthode d’authentification',
-  'auth_will_reset' => 'Le système d’authentification va être réinitialisé : un formulaire sera utilisé à la place de Persona.',
-  'auto_load_more' => 'Charger les articles suivants en bas de page',
-  'auto_read_when' => 'Marquer un article comme lu…',
-  'auto_share' => 'Partager',
-  'auto_share_help' => 'S’il n’y a qu’un mode de partage, celui ci est utilisé automatiquement. Sinon ils sont accessibles par leur numéro.',
-  'back_to_rss_feeds' => '← Retour à vos flux RSS',
-  'bad_opml_file' => 'Votre fichier OPML n’est pas valide.',
-  'base_url' => 'Base de l’URL',
-  'bdd' => 'Base de données',
-  'bdd_configuration' => 'Base de données',
-  'bdd_conf_is_ko' => 'Vérifiez les informations d’accès à la base de données.',
-  'bdd_conf_is_ok' => 'La configuration de la base de données a été enregistrée.',
-  'bdd_type' => 'Type de base de données',
-  'before_one_day' => 'Antérieurs à 1 jour',
-  'before_one_week' => 'Antérieurs à 1 semaine',
-  'before_yesterday' => 'À partir d’avant-hier',
-  'blank_to_disable' => 'Laissez vide pour désactiver',
-  'blogotext' => 'Blogotext',
-  'bookmark' => 'S’abonner (bookmark FreshRSS)',
-  'bottom_line' => 'Ligne du bas',
-  'bugs_reports' => 'Rapports de bugs',
-  'by' => 'par',
-  'by_author' => 'Par <em>%s</em>',
-  'by_default' => 'Par défaut',
-  'by_email' => 'Par courriel',
-  'by_feed' => 'par flux',
-  'cache_is_ok' => 'Les droits sur le répertoire de cache sont bons',
-  'cancel' => 'Annuler',
-  'can_not_be_deleted' => 'Ne peut pas être supprimée.',
-  'categories' => 'Catégories',
-  'categories_management' => 'Gestion des catégories',
-  'categories_updated' => 'Les catégories ont été mises à jour.',
-  'categorize' => 'Ranger dans une catégorie',
-  'category' => 'Catégorie',
-  'category_created' => 'La catégorie %s a été créée.',
-  'category_deleted' => 'La catégorie a été supprimée.',
-  'category_emptied' => 'La catégorie a été vidée.',
-  'category_empty' => 'Catégorie vide',
-  'category_name_exists' => 'Une catégorie possède déjà ce nom.',
-  'category_not_delete_default' => 'Vous ne pouvez pas supprimer la catégorie par défaut !',
-  'category_not_exist' => 'Cette catégorie n’existe pas !',
-  'category_no_id' => 'Vous devez préciser l’id de la catégorie.',
-  'category_no_name' => 'Vous devez préciser un nom pour la catégorie.',
-  'category_number' => 'Catégorie n°%d',
-  'category_updated' => 'La catégorie a été mise à jour.',
-  'change_value' => 'Vous devriez changer cette valeur par n’importe quelle autre',
-  'checks' => 'Vérifications',
-  'choose_language' => 'Choisissez la langue pour FreshRSS',
-  'clear_logs' => 'Effacer les logs',
-  'collapse_article' => 'Refermer',
-  'conf.users.articles_and_size' => '%s articles (%s)',
-  'configuration' => 'Configuration',
-  'configuration_updated' => 'La configuration a été mise à jour.',
-  'confirm_action' => 'Êtes-vous sûr(e) de vouloir continuer ? Cette action ne peut être annulée !',
-  'confirm_action_feed_cat' => 'Êtes-vous sûr(e) de vouloir continuer ? Vous perdrez les favoris et les filtres associés. Cette action ne peut être annulée !',
-  'congratulations' => 'Félicitations !',
-  'content_width' => 'Largeur du contenu',
-  'create' => 'Créer',
-  'create_user' => 'Créer un nouvel utilisateur',
-  '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://www.google.com/webfonts/specimen/Open+Sans">Steve Matteson</a>. Les favicons sont récupérés grâce au site <a href="https://getfavicon.appspot.com/">getFavicon</a>. FreshRSS repose sur <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, un framework PHP.',
-  'css_path_on_website' => 'Sélecteur CSS des articles sur le site d’origine',
-  'ctype_is_nok' => 'Il manque une librairie pour la vérification des types de caractères (php-ctype)',
-  'ctype_is_ok' => 'Vous disposez du nécessaire pour la vérification des types de caractères (ctype)',
-  'curl_is_nok' => 'Vous ne disposez pas de cURL (paquet php5-curl)',
-  'curl_is_ok' => 'Vous disposez de cURL dans sa version %s',
-  'current_user' => 'Utilisateur actuel',
-  'damn' => 'Arf !',
-  'data_is_ok' => 'Les droits sur le répertoire de data sont bons',
-  'Dec' => '\\d\\é\\c\\e\\m\\b\\r\\e',
-  'dec' => 'déc.',
-  'december' => 'décembre',
-  'default_category' => 'Sans catégorie',
-  'default_user' => 'Nom de l’utilisateur par défaut <small>(16 caractères alphanumériques maximum)</small>',
-  'default_view' => 'Vue par défaut',
-  'delete' => 'Supprimer',
-  'delete_articles_every' => 'Supprimer les articles après',
-  'diaspora' => 'Diaspora*',
-  'display' => 'Affichage',
-  'display_articles_unfolded' => 'Afficher les articles dépliés par défaut',
-  'display_categories_unfolded' => 'Afficher les catégories pliées par défaut',
-  'display_configuration' => 'Affichage',
-  'dom_is_nok' => 'Il manque une librairie pour parcourir le DOM (paquet php-xml)',
-  'dom_is_ok' => 'Vous disposez du nécessaire pour parcourir le DOM',
-  'do_not_change_if_doubt' => 'Laissez tel quel dans le doute',
-  'email' => 'Courriel',
-  'error_occurred' => 'Une erreur est survenue !',
-  'error_occurred_update' => 'Rien n’a été modifié !',
-  'explain_token' => 'Permet d’accéder à la sortie RSS de l’utilisateur par défaut sans besoin de s’authentifier.<br /><kbd>%s?output=rss&token=%s</kbd>',
-  'export' => 'Exporter',
-  'export_no_zip_extension' => 'L’extension Zip n’est pas présente sur votre serveur. Veuillez essayer d’exporter les fichiers un par un.',
-  'export_opml' => 'Exporter la liste des flux (OPML)',
-  'export_starred' => 'Exporter les favoris',
-  'facebook' => 'Facebook',
-  'favicons_is_ok' => 'Les droits sur le répertoire des favicons sont bons',
-  'favorite_feeds' => 'Favoris (%s)',
-  'feb' => 'fév.',
-  'Feb' => '\\f\\é\\v\\r\\i\\e\\r',
-  'february' => 'février',
-  'feed' => 'Flux',
-  'feeds' => 'Flux',
-  'feeds_actualized' => 'Les flux ont été mis à jour.',
-  'feeds_imported' => 'Vos flux ont été importés et vont maintenant être actualisés.',
-  'feeds_imported_with_errors' => 'Vos flux ont été importés mais des erreurs sont survenues.',
-  'feeds_marked_read' => 'Les flux ont été marqués comme lus.',
-  'feeds_moved_category_deleted' => 'Lors de la suppression d’une catégorie, ses flux seront automatiquement classés dans <em>%s</em>.',
-  'feed_actualized' => '<em>%s</em> a été mis à jour.',
-  'feed_added' => 'Le flux <em>%s</em> a bien été ajouté.',
-  'feed_deleted' => 'Le flux a été supprimé.',
-  'feed_description' => 'Description',
-  'feed_empty' => 'Ce flux est vide. Veuillez vérifier qu’il est toujours maintenu.',
-  'feed_in_error' => 'Ce flux a rencontré un problème. Veuillez vérifier qu’il est toujours accessible puis actualisez-le.',
-  'feed_list' => 'Liste des articles de %s',
-  'feed_not_added' => '<em>%s</em> n’a pas pu être ajouté.',
-  'feed_updated' => 'Le flux a été mis à jour.',
-  'feed_url' => 'URL du flux',
-  'feed_validator' => 'Vérifier la valididé du flux',
-  'file_cannot_be_uploaded' => 'Le fichier ne peut pas être téléchargé !',
-  'file_is_nok' => 'Veuillez vérifier les droits sur le répertoire <em>%s</em>. Le serveur HTTP doit être capable d’écrire dedans',
-  'file_to_import' => 'Fichier à importer<br />(OPML, Json ou Zip)',
-  'file_to_import_no_zip' => 'Fichier à importer<br />(OPML ou Json)',
-  'filter' => 'Filtrer',
-  'finish_installation' => 'Terminer l’installation',
-  'first' => 'Début',
-  'first_article' => 'Passer au premier article',
-  'fix_errors_before' => 'Veuillez corriger les erreurs avant de passer à l’étape suivante.',
-  'focus_search' => 'Accéder à la recherche',
-  'forbidden_access' => 'L’accès vous est interdit !',
-  'format_date' => 'j %s Y',
-  'format_date_hour' => 'j %s Y \\à H\\:i',
-  'freshrss' => 'FreshRSS',
-  '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_installation' => 'Installation · FreshRSS',
-  'fri' => 'ven.',
-  'g+' => 'Google+',
-  'gen.menu.admin' => 'Administration',
-  'gen.menu.authentication' => 'Authentification',
-  'gen.menu.check_install' => 'Vérification de l\'installation',
-  'gen.menu.manage_users' => 'Gestion des utilisateurs',
-  'gen.menu.profil' => 'Profil',
-  'gen.title.check_install' => 'Vérification de l\'installation',
-  'general_configuration' => 'Configuration générale',
-  'general_conf_is_ok' => 'La configuration générale a été enregistrée.',
-  'github_or_email' => '<a href="https://github.com/marienfressinaud/FreshRSS/issues">sur Github</a> ou <a href="mailto:dev@marienfressinaud.fr">par courriel</a>',
-  'global_view' => 'Vue globale',
-  'help' => 'Afficher la documentation',
-  'hide_read_feeds' => 'Cacher les catégories & flux sans article non-lu (ne fonctionne pas avec la configuration “Afficher tous les articles”)',
-  'host' => 'Hôte',
-  'html5_notif_timeout' => 'Temps d’affichage de la notification HTML5',
-  'http_auth' => 'HTTP (pour utilisateurs avancés avec HTTPS)',
-  'http_authentication' => 'Authentification HTTP',
-  'http_password' => 'Mot de passe HTTP',
-  'http_referer_is_nok' => 'Veuillez vérifier que vous ne modifiez pas votre HTTP REFERER.',
-  'http_referer_is_ok' => 'Le HTTP REFERER est connu et semble correspondre à votre serveur.',
-  'http_username' => 'Identifiant HTTP',
-  'img_with_lazyload' => 'Utiliser le mode “chargement différé” pour les images',
-  'import' => 'Importer',
-  'import_export' => 'Importer / exporter',
-  'informations' => 'Informations',
-  'installation_is_ok' => 'L’installation s’est bien passée.<br />La dernière étape va maintenant tenter de supprimer les fichiers ainsi que d’éventuelles copies de base de données créés durant le processus de mise à jour.<br />Vous pouvez choisir de sauter cette étape en supprimant <kbd>./data/do-install.txt</kbd> manuellement.',
-  'installation_step' => 'Installation — étape %d · FreshRSS',
-  'install_not_deleted' => 'Quelque chose s’est mal passé, vous devez supprimer le fichier <em>%s</em> à la main.',
-  'internal_problem_feed' => 'Le flux ne peut pas être ajouté. <a href="%s">Consulter les logs de FreshRSS</a> pour plus de détails.',
-  'invalid_login' => 'L’identifiant est invalide !',
-  'invalid_url' => 'L’url <em>%s</em> est invalide.',
-  'is_admin' => 'est administrateur',
-  'Jan' => '\\j\\a\\n\\v\\i\\e\\r',
-  'jan' => 'jan.',
-  'january' => 'janvier',
-  'javascript_for_shortcuts' => 'Le JavaScript doit être activé pour pouvoir profiter des raccourcis.',
-  'javascript_is_better' => 'FreshRSS est plus agréable à utiliser avec JavaScript activé',
-  'javascript_should_be_activated' => 'Le JavaScript doit être activé.',
-  'Jul' => '\\j\\u\\i\\l\\l\\e\\t',
-  'jul' => 'jui.',
-  'july' => 'juillet',
-  'jump_next' => 'sauter au prochain voisin non lu (flux ou catégorie)',
-  'jun' => 'juin',
-  'Jun' => '\\j\\u\\i\\n',
-  'june' => 'juin',
-  'keep_history' => 'Nombre minimum d’articles à conserver',
-  'keep_logged_in' => 'Rester connecté <small>(1 mois)</small>',
-  'language' => 'Langue',
-  'language_defined' => 'La langue a bien été définie.',
-  'last' => 'Fin',
-  'last_3_month' => 'Depuis les trois derniers mois',
-  'last_6_month' => 'Depuis les six derniers mois',
-  'last_article' => 'Passer au dernier article',
-  'last_month' => 'Depuis le mois dernier',
-  'last_week' => 'Depuis la semaine dernière',
-  'last_year' => 'Depuis l’année dernière',
-  'lead_developer' => 'Développeur principal',
-  'license' => 'Licence',
-  'load_more' => 'Charger plus d’articles',
-  'login' => 'Connexion',
-  'login_configuration' => 'Identification',
-  'login_persona_problem' => 'Problème de connexion à Persona ?',
-  'login_required' => 'Accès protégé par mot de passe :',
-  'login_with_persona' => 'Connexion avec Persona',
-  'logout' => 'Déconnexion',
-  'logs' => 'Logs',
-  'logs_empty' => 'Les logs sont vides.',
-  'log_is_ok' => 'Les droits sur le répertoire des logs sont bons',
-  'main_stream' => 'Flux principal',
-  'mar' => 'mar.',
-  'Mar' => '\\m\\a\\r\\s',
-  'march' => 'mars',
-  'mark_all_read' => 'Tout marquer comme lu',
-  'mark_cat_read' => 'Marquer la catégorie comme lue',
-  'mark_favorite' => 'Mettre en favori',
-  'mark_feed_read' => 'Marquer le flux comme lu',
-  'mark_read' => 'Marquer comme lu',
-  'may' => 'mai.',
-  'May' => '\\m\\a\\i',
-  'minz_is_nok' => 'Vous ne disposez pas de la librairie Minz. Vous devriez exécuter le script <em>build.sh</em> ou bien <a href="https://github.com/marienfressinaud/MINZ">la télécharger sur Github</a> et installer dans le répertoire <em>%s</em> le contenu de son répertoire <em>/lib</em>.',
-  'minz_is_ok' => 'Vous disposez du framework Minz',
-  'mon' => 'lun.',
-  'month' => 'mois',
-  'more_information' => 'Plus d’informations',
-  'newer_first' => 'Plus récents en premier',
-  'new_article' => 'Il y a de nouveaux articles disponibles, cliquez pour rafraîchir la page.',
-  'new_category' => 'Nouvelle catégorie',
-  'next' => 'Suivant',
-  'next_article' => 'Passer à l’article suivant',
-  'next_page' => 'Passer à la page suivante',
-  'next_step' => 'Passer à l’étape suivante',
-  'no' => 'Non',
-  'normal_view' => 'Vue normale',
-  'nothing_to_load' => 'Fin des articles',
-  'notif_body_new_articles' => 'Il y a \\d nouveaux articles à lire sur FreshRSS.',
-  'notif_title_new_articles' => 'FreshRSS : nouveaux articles !',
-  'not_read' => '%d non lu',
-  'not_reads' => '%d non lus',
-  'not_yet_implemented' => 'Pas encore implémenté',
-  'Nov' => '\\n\\o\\v\\e\\m\\b\\r\\e',
-  'nov' => 'nov.',
-  'november' => 'novembre',
-  'no_feed_actualized' => 'Aucun flux n’a pu être mis à jour.',
-  'no_feed_to_display' => 'Il n’y a aucun article à afficher.',
-  'no_feed_to_refresh' => 'Il n’y a aucun flux à actualiser…',
-  'no_query' => 'Vous n’avez pas encore créé de filtre.',
-  'no_query_filter' => 'Aucun filtre appliqué',
-  'no_rss_feed' => 'Aucun flux RSS',
-  'no_selected_feed' => 'Aucun flux sélectionné.',
-  'no_update' => 'Aucune mise à jour à appliquer',
-  'no_zip_extension' => 'L’extension Zip n’est pas présente sur votre serveur.',
-  'number_articles' => '%d articles',
-  'number_divided_when_reader' => 'Divisé par 2 dans la vue de lecture.',
-  'number_feeds' => '%d flux',
-  'n_entries_deleted' => '%d articles ont été supprimés.',
-  'n_feeds_actualized' => '%d flux ont été mis à jour.',
-  'oct' => 'oct.',
-  'Oct' => '\\o\\c\\t\\o\\b\\r\\e',
-  'october' => 'octobre',
-  'ok' => 'Ok !',
-  'older_first' => 'Plus anciens en premier',
-  'oops' => 'Oups !',
-  'optimization_complete' => 'Optimisation terminée.',
-  'optimize_bdd' => 'Optimiser la base de données',
-  'optimize_todo_sometimes' => 'À faire de temps en temps pour réduire la taille de la BDD',
-  'or' => 'ou',
-  'page_not_found' => 'La page que vous cherchez n’existe pas !',
-  'password' => 'Mot de passe',
-  'password_api' => 'Mot de passe API<br /><small>(ex. : pour applis mobiles)</small>',
-  'password_form' => 'Mot de passe<br /><small>(pour connexion par formulaire)</small>',
-  'pcre_is_nok' => 'Il manque une librairie pour les expressions régulières (php-pcre)',
-  'pcre_is_ok' => 'Vous disposez du nécessaire pour les expressions régulières (PCRE)',
-  'pdo_is_nok' => 'Vous ne disposez pas de PDO ou d’un des drivers supportés (pdo_mysql, pdo_sqlite)',
-  'pdo_is_ok' => 'Vous disposez de PDO et d’au moins un des drivers supportés (pdo_mysql, pdo_sqlite)',
-  'persona_connection_email' => 'Adresse courriel de connexion<br /><small>(pour <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
-  'persona_is_ok' => 'Les droits sur le répertoire de Mozilla Persona sont bons',
-  'php_is_nok' => 'Votre version de PHP est la %s mais FreshRSS requiert au moins la version %s',
-  'php_is_ok' => 'Votre version de PHP est la %s, qui est compatible avec FreshRSS',
-  'prefix' => 'Préfixe des tables',
-  'previous' => 'Précédent',
-  'previous_article' => 'Passer à l’article précédent',
-  'previous_page' => 'Passer à la page précédente',
-  'print' => 'Imprimer',
-  'project_website' => 'Site du projet',
-  'public' => 'Public',
-  'publication_date' => 'Date de publication',
-  'purge_completed' => 'Purge effectuée (%d articles supprimés).',
-  'purge_now' => 'Purger maintenant',
-  'queries' => 'Filtres utilisateurs',
-  'query_created' => 'Le filtre "%s" a bien été créé.',
-  'query_deprecated' => 'Ce filtre n’est plus valide. La catégorie ou le flux concerné a été supprimé.',
-  'query_filter' => 'Filtres appliqués :',
-  'query_get_all' => 'Afficher tous les articles',
-  'query_get_category' => 'Afficher la catégorie "%s"',
-  'query_get_favorite' => 'Afficher les articles favoris',
-  'query_get_feed' => 'Afficher le flux "%s"',
-  'query_number' => 'Filtre n°%d',
-  'query_order_asc' => 'Afficher les articles les plus anciens en premier',
-  'query_order_desc' => 'Afficher les articles les plus récents en premier',
-  'query_search' => 'Recherche de "%s"',
-  'query_state_0' => 'Afficher tous les articles',
-  'query_state_1' => 'Afficher les articles lus',
-  'query_state_2' => 'Afficher les articles non lus',
-  'query_state_3' => 'Afficher tous les articles',
-  'query_state_4' => 'Afficher les articles favoris',
-  'query_state_5' => 'Afficher les articles lus et favoris',
-  'query_state_6' => 'Afficher les articles non lus et favoris',
-  'query_state_7' => 'Afficher les articles favoris',
-  'query_state_8' => 'Afficher les articles non favoris',
-  'query_state_9' => 'Afficher les articles lus et non favoris',
-  'query_state_10' => 'Afficher les articles non lus et non favoris',
-  'query_state_11' => 'Afficher les articles non favoris',
-  'query_state_12' => 'Afficher tous les articles',
-  'query_state_13' => 'Afficher les articles lus',
-  'query_state_14' => 'Afficher les articles non lus',
-  'query_state_15' => 'Afficher tous les articles',
-  'random_string' => 'Chaîne aléatoire',
-  'reader_view' => 'Vue lecture',
-  'reading_configuration' => 'Lecture',
-  'reading_confirm' => 'Afficher une confirmation lors des actions “marquer tout comme lu”',
-  'refresh' => 'Actualisation',
-  'related_tags' => 'Tags associés',
-  'retrieve_truncated_feeds' => 'Permet de récupérer les flux tronqués (attention, demande plus de temps !)',
-  'rss_feeds_of' => 'Flux RSS de %s',
-  'rss_feed_management' => 'Gestion des flux RSS',
-  'rss_view' => 'Flux RSS',
-  'sat' => 'sam.',
-  'save' => 'Enregistrer',
-  'scroll' => 'au défilement de la page',
-  'search' => 'Rechercher des mots ou des #tags',
-  'search_short' => 'Rechercher',
-  'seconds_(0_means_no_timeout)' => 'secondes (0 signifie aucun timeout ) ',
-  'see_on_website' => 'Voir sur le site d’origine',
-  'see_website' => 'Voir le site',
-  'Sep' => '\\s\\e\\p\\t\\e\\m\\b\\r\\e',
-  'sep' => 'sep.',
-  'september' => 'septembre',
-  'shaarli' => 'Shaarli',
-  'share' => 'Partager',
-  'share_name' => 'Nom du partage à afficher',
-  'share_url' => 'URL du partage à utiliser',
-  'sharing' => 'Partage',
-  'sharing_management' => 'Gestion des options de partage',
-  'shift_for_all_read' => '+ <code>shift</code> pour marquer tous les articles comme lus',
-  'shortcuts' => 'Raccourcis',
-  'shortcuts_article_action' => 'Actions associées à l’article courant',
-  'shortcuts_navigation' => 'Navigation',
-  'shortcuts_navigation_help' => 'Avec le modificateur "Shift", les raccourcis de navigation s’appliquent aux flux.<br/>Avec le modificateur "Alt", les raccourcis de navigation s’appliquent aux catégories.',
-  'shortcuts_other_action' => 'Autres actions',
-  'shortcuts_updated' => 'Les raccourcis ont été mis à jour.',
-  'show_adaptive' => 'Adapter l’affichage',
-  'show_all_articles' => 'Afficher tous les articles',
-  'show_favorite' => 'Afficher les favoris',
-  'show_in_all_flux' => 'Afficher dans le flux principal',
-  'show_not_favorite' => 'Afficher tout sauf les favoris',
-  'show_not_reads' => 'Afficher les non lus',
-  'show_read' => 'Afficher les lus',
-  'sort_order' => 'Ordre de tri',
-  'starred_list' => 'Liste des articles favoris',
-  'stats' => 'Statistiques',
-  'stats_entry_count' => 'Nombre d’articles',
-  'stats_entry_per_category' => 'Articles par catégorie',
-  'stats_entry_per_day' => 'Nombre d’articles par jour (30 derniers jours)',
-  'stats_entry_per_day_of_week' => 'Par jour de la semaine (moyenne : %.2f messages)',
-  'stats_entry_per_hour' => 'Par heure (moyenne : %.2f messages)',
-  'stats_entry_per_month' => 'Par mois (moyenne : %.2f messages)',
-  'stats_entry_repartition' => 'Répartition des articles',
-  'stats_feed_per_category' => 'Flux par catégorie',
-  'stats_idle' => 'Flux inactifs',
-  'stats_main' => 'Statistiques principales',
-  'stats_no_idle' => 'Il n’y a aucun flux inactif !',
-  'stats_percent_of_total' => '%% du total',
-  'stats_repartition' => 'Répartition des articles',
-  'stats_top_feed' => 'Les dix plus gros flux',
-  'status_favorites' => 'favoris',
-  'status_read' => 'lus',
-  'status_total' => 'total',
-  'status_unread' => 'non lus',
-  'steps' => 'Étapes',
-  'sticky_post' => 'Aligner l’article en haut quand il est ouvert',
-  'submit' => 'Valider',
-  'subscription_management' => 'Gestion des abonnements',
-  'sun' => 'dim.',
-  'theme' => 'Thème',
-  'think_to_add' => 'Vous pouvez ajouter des flux.',
-  'this_is_the_end' => 'This is the end',
-  'thu' => 'jeu.',
-  'title' => 'Titre',
-  'today' => 'Aujourd’hui',
-  'top_line' => 'Ligne du haut',
-  'truncate' => 'Supprimer tous les articles',
-  'ttl' => 'Ne pas automatiquement rafraîchir plus souvent que',
-  'tue' => 'mar.',
-  'twitter' => 'Twitter',
-  'unsafe_autologin' => 'Autoriser les connexions automatiques non-sûres au format : ',
-  'update' => 'Mise à jour',
-  'updated' => 'Modifications enregistrées.',
-  'update_apply' => 'Appliquer la mise à jour',
-  'update_can_apply' => 'Une mise à jour est disponible.',
-  'update_check' => 'Vérifier les mises à jour',
-  'update_end' => 'La mise à jour est terminée, vous pouvez maintenant passer à l’étape finale.',
-  'update_finished' => 'La mise à jour est terminée !',
-  'update_last' => 'Dernière vérification : %s',
-  'update_long' => 'Ce processus peut prendre longtemps, selon la taille de votre base de données. Vous aurez peut-être à attendre que cette page dépasse son temps maximum d’exécution (~5 minutes) puis à la recharger.',
-  'update_problem' => 'La mise à jour a rencontré un problème : %s',
-  'update_server_not_found' => 'Le serveur de mise à jour n’a pas été trouvé. [%s]',
-  'update_start' => 'Lancer la mise à jour',
-  'update_system' => 'Système de mise à jour',
-  'upon_reception' => 'dès la réception du nouvel article',
-  'username' => 'Nom d’utilisateur',
-  'username_admin' => 'Nom d’utilisateur administrateur',
-  'users' => 'Utilisateurs',
-  'users_list' => 'Liste des utilisateurs',
-  'user_created' => 'L’utilisateur %s a été créé.',
-  'user_deleted' => 'L’utilisateur %s a été supprimé.',
-  'user_filter' => 'Accéder aux filtres utilisateur',
-  'user_filter_help' => 'S’il n’y a qu’un filtre utilisateur, celui ci est utilisé automatiquement. Sinon ils sont accessibles par leur numéro.',
-  'version' => 'Version',
-  'version_update' => 'Mise à jour',
-  'wallabag' => 'wallabag',
-  'website' => 'Site Internet',
-  'website_url' => 'URL du site',
-  'wed' => 'mer.',
-  'width_large' => 'Large',
-  'width_medium' => 'Moyenne',
-  'width_no_limit' => 'Pas de limite',
-  'width_thin' => 'Fine',
-  'yes' => 'Oui',
-  'yesterday' => 'Hier',
-  'your_diaspora_pod' => 'Votre pod Diaspora*',
-  'your_favorites' => 'Vos favoris',
-  'your_rss_feeds' => 'Vos flux RSS',
-  'your_shaarli' => 'Votre Shaarli',
-  'your_wallabag' => 'Votre wallabag',
-  'zip_error' => 'Une erreur est survenue durant l’import du fichier Zip.',
-);

+ 181 - 0
app/i18n/fr/admin.php

@@ -0,0 +1,181 @@
+<?php
+
+return array(
+	'auth' => array(
+		'allow_anonymous' => 'Autoriser la lecture anonyme des articles de l’utilisateur par défaut (%s)',
+		'allow_anonymous_refresh' => 'Autoriser le rafraîchissement anonyme des flux',
+		'api_enabled' => 'Autoriser l’accès par <abbr>API</abbr> <small>(nécessaire pour les applis mobiles)</small>',
+		'form' => 'Formulaire (traditionnel, requiert JavaScript)',
+		'http' => 'HTTP (pour utilisateurs avancés avec HTTPS)',
+		'none' => 'Aucune (dangereux)',
+		'title' => 'Authentification',
+		'title_reset' => 'Réinitialisation de l’authentification',
+		'token' => 'Jeton d’identification',
+		'token_help' => 'Permet d’accéder à la sortie RSS de l’utilisateur par défaut sans besoin de s’authentifier :',
+		'type' => 'Méthode d’authentification',
+		'unsafe_autologin' => 'Autoriser les connexions automatiques non-sûres au format : ',
+	),
+	'check_install' => array(
+		'cache' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/cache</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire de cache sont bons.',
+		),
+		'categories' => array(
+			'nok' => 'La table category est mal configurée.',
+			'ok' => 'La table category est bien configurée.',
+		),
+		'connection' => array(
+			'nok' => 'La connexion à la base de données est impossible.',
+			'ok' => 'La connexion à la base de données est bonne.',
+		),
+		'ctype' => array(
+			'nok' => 'Impossible de trouver une librairie pour la vérification des types de caractères (php-ctype).',
+			'ok' => 'Vous disposez de la librairie pour la vérification des types de caractères (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Impossible de trouver la librairie cURL (paquet php-curl).',
+			'ok' => 'Vous disposez de la librairie cURL.',
+		),
+		'data' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire de data sont bons.',
+		),
+		'database' => 'Installation de la base de données',
+		'dom' => array(
+			'nok' => 'Impossible de trouver une librairie pour parcourir le DOM (paquet php-xml).',
+			'ok' => 'Vous disposez de la librairie pour parcourir le DOM.',
+		),
+		'entries' => array(
+			'nok' => 'La table entry est mal configurée.',
+			'ok' => 'La table entry est bien configurée.',
+		),
+		'favicons' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/favicons</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire des favicons sont bons.',
+		),
+		'feeds' => array(
+			'nok' => 'La table feed est mal configurée.',
+			'ok' => 'La table feed est bien configurée.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Impossible de trouver la librairie PHP fileinfo (paquet fileinfo).',
+			'ok' => 'Vous disposez de la librairie fileinfo.',
+		),
+		'files' => 'Installation des fichiers',
+		'json' => array(
+			'nok' => 'Vous ne disposez pas de JSON (paquet php5-json).',
+			'ok' => 'Vous disposez de l’extension JSON.',
+		),
+		'minz' => array(
+			'nok' => 'Vous ne disposez pas de la librairie Minz.',
+			'ok' => 'Vous disposez du framework Minz',
+		),
+		'pcre' => array(
+			'nok' => 'Impossible de trouver une librairie pour les expressions régulières (php-pcre).',
+			'ok' => 'Vous disposez de la librairie pour les expressions régulières (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Vous ne disposez pas de PDO ou d’un des drivers supportés (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'Vous disposez de PDO et d’au moins un des drivers supportés (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'_' => 'Installation de PHP',
+			'nok' => 'Votre version de PHP est la %s mais FreshRSS requiert au moins la version %s.',
+			'ok' => 'Votre version de PHP est la %s, qui est compatible avec FreshRSS.',
+		),
+		'tables' => array(
+			'nok' => 'Impossible de trouver une ou plusieurs tables en base de données.',
+			'ok' => 'Les tables sont bien présentes en base de données.',
+		),
+		'title' => 'Vérification de l’installation',
+		'tokens' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/tokens</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire des tokens sont bons.',
+		),
+		'users' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/users</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire des utilisateurs sont bons.',
+		),
+		'zip' => array(
+			'nok' => 'Vous ne disposez pas de l’extension ZIP (paquet php-zip).',
+			'ok' => 'Vous disposez de l’extension ZIP.',
+		),
+	),
+	'extensions' => array(
+		'disabled' => 'Désactivée',
+		'empty_list' => 'Il n’y a aucune extension installée.',
+		'enabled' => 'Activée',
+		'no_configure_view' => 'Cette extension ne peut pas être configurée.',
+		'system' => array(
+			'_' => 'Extensions système',
+			'no_rights' => 'Extension système (vous n’avez aucun droit dessus)',
+		),
+		'title' => 'Extensions',
+		'user' => 'Extensions utilisateur',
+	),
+	'stats' => array(
+		'_' => 'Statistiques',
+		'all_feeds' => 'Tous les flux',
+		'category' => 'Catégorie',
+		'entry_count' => 'Nombre d’articles',
+		'entry_per_category' => 'Articles par catégorie',
+		'entry_per_day' => 'Nombre d’articles par jour (30 derniers jours)',
+		'entry_per_day_of_week' => 'Par jour de la semaine (moyenne : %.2f messages)',
+		'entry_per_hour' => 'Par heure (moyenne : %.2f messages)',
+		'entry_per_month' => 'Par mois (moyenne : %.2f messages)',
+		'entry_repartition' => 'Répartition des articles',
+		'feed' => 'Flux',
+		'feed_per_category' => 'Flux par catégorie',
+		'idle' => 'Flux inactifs',
+		'main' => 'Statistiques principales',
+		'main_stream' => 'Flux principal',
+		'menu' => array(
+			'idle' => 'Flux inactifs',
+			'main' => 'Statistiques principales',
+			'repartition' => 'Répartition des articles',
+		),
+		'no_idle' => 'Il n’y a aucun flux inactif !',
+		'number_entries' => '%d articles',
+		'percent_of_total' => '%% du total',
+		'repartition' => 'Répartition des articles',
+		'status_favorites' => 'favoris',
+		'status_read' => 'lus',
+		'status_total' => 'total',
+		'status_unread' => 'non lus',
+		'title' => 'Statistiques',
+		'top_feed' => 'Les dix plus gros flux',
+	),
+	'system' => array(
+		'_' => 'Configuration du système',
+		'auto-update-url' => 'URL du service de mise à jour',
+		'instance-name' => 'Nom de l’instance',
+		'max-categories' => 'Limite de catégories par utilisateur',
+		'max-feeds' => 'Limite de flux par utilisateur',
+		'registration' => array(
+			'help' => 'Un chiffre de 0 signifie que l’on peut créer un nombre infini de comptes',
+			'number' => 'Nombre max de comptes',
+		),
+	),
+	'update' => array(
+		'_' => 'Système de mise à jour',
+		'apply' => 'Appliquer la mise à jour',
+		'check' => 'Vérifier les mises à jour',
+		'current_version' => 'Votre version actuelle de FreshRSS est la %s.',
+		'last' => 'Dernière vérification : %s',
+		'none' => 'Aucune mise à jour à appliquer',
+		'title' => 'Système de mise à jour',
+	),
+	'user' => array(
+		'articles_and_size' => '%s articles (%s)',
+		'create' => 'Créer un nouvel utilisateur',
+		'language' => 'Langue',
+		'number' => '%d compte a déjà été créé',
+		'numbers' => '%d comptes ont déjà été créés',
+		'password_form' => 'Mot de passe<br /><small>(pour connexion par formulaire)</small>',
+		'password_format' => '7 caractères minimum',
+		'title' => 'Gestion des utilisateurs',
+		'user_list' => 'Liste des utilisateurs',
+		'username' => 'Nom d’utilisateur',
+		'users' => 'Utilisateurs',
+	),
+);

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

@@ -0,0 +1,173 @@
+<?php
+
+return array(
+	'archiving' => array(
+		'_' => 'Archivage',
+		'advanced' => 'Avancé',
+		'delete_after' => 'Supprimer les articles après',
+		'help' => 'D’autres options sont disponibles dans la configuration individuelle des flux.',
+		'keep_history_by_feed' => 'Nombre minimum d’articles à conserver par flux',
+		'optimize' => 'Optimiser la base de données',
+		'optimize_help' => 'À faire de temps en temps pour réduire la taille de la BDD',
+		'purge_now' => 'Purger maintenant',
+		'title' => 'Archivage',
+		'ttl' => 'Ne pas automatiquement rafraîchir plus souvent que',
+	),
+	'display' => array(
+		'_' => 'Affichage',
+		'icon' => array(
+			'bottom_line' => 'Ligne du bas',
+			'entry' => 'Icônes d’article',
+			'publication_date' => 'Date de publication',
+			'related_tags' => 'Tags associés',
+			'sharing' => 'Partage',
+			'top_line' => 'Ligne du haut',
+		),
+		'language' => 'Langue',
+		'notif_html5' => array(
+			'seconds' => 'secondes (0 signifie aucun timeout)',
+			'timeout' => 'Temps d’affichage de la notification HTML5',
+		),
+		'theme' => 'Thème',
+		'title' => 'Affichage',
+		'width' => array(
+			'content' => 'Largeur du contenu',
+			'large' => 'Large',
+			'medium' => 'Moyenne',
+			'no_limit' => 'Pas de limite',
+			'thin' => 'Fine',
+		),
+	),
+	'query' => array(
+		'_' => 'Filtres utilisateurs',
+		'deprecated' => 'Ce filtre n’est plus valide. La catégorie ou le flux concerné a été supprimé.',
+		'filter' => 'Filtres appliqués :',
+		'get_all' => 'Afficher tous les articles',
+		'get_category' => 'Afficher la catégorie "%s"',
+		'get_favorite' => 'Afficher les articles favoris',
+		'get_feed' => 'Afficher le flux "%s"',
+		'no_filter' => 'Aucun filtre appliqué',
+		'none' => 'Vous n’avez pas encore créé de filtre.',
+		'number' => 'Filtre n°%d',
+		'order_asc' => 'Afficher les articles les plus anciens en premier',
+		'order_desc' => 'Afficher les articles les plus récents en premier',
+		'search' => 'Recherche de "%s"',
+		'state_0' => 'Afficher tous les articles',
+		'state_1' => 'Afficher les articles lus',
+		'state_2' => 'Afficher les articles non lus',
+		'state_3' => 'Afficher tous les articles',
+		'state_4' => 'Afficher les articles favoris',
+		'state_5' => 'Afficher les articles lus et favoris',
+		'state_6' => 'Afficher les articles non lus et favoris',
+		'state_7' => 'Afficher les articles favoris',
+		'state_8' => 'Afficher les articles non favoris',
+		'state_9' => 'Afficher les articles lus et non favoris',
+		'state_10' => 'Afficher les articles non lus et non favoris',
+		'state_11' => 'Afficher les articles non favoris',
+		'state_12' => 'Afficher tous les articles',
+		'state_13' => 'Afficher les articles lus',
+		'state_14' => 'Afficher les articles non lus',
+		'state_15' => 'Afficher tous les articles',
+		'title' => 'Filtres utilisateurs',
+	),
+	'profile' => array(
+		'_' => 'Gestion du profil',
+		'delete' => array(
+			'_' => 'Suppression du compte',
+			'warn' => 'Le compte et toutes les données associées vont être supprimées.',
+		),
+		'password_api' => 'Mot de passe API<br /><small>(ex. : pour applis mobiles)</small>',
+		'password_form' => 'Mot de passe<br /><small>(pour connexion par formulaire)</small>',
+		'password_format' => '7 caractères minimum',
+		'title' => 'Profil',
+	),
+	'reading' => array(
+		'_' => 'Lecture',
+		'after_onread' => 'Après “marquer tout comme lu”,',
+		'articles_per_page' => 'Nombre d’articles par page',
+		'auto_load_more' => 'Charger les articles suivants en bas de page',
+		'auto_remove_article' => 'Cacher les articles après lecture',
+		'mark_updated_article_unread' => 'Marquer les articles mis à jour comme non-lus',
+		'confirm_enabled' => 'Afficher une confirmation lors des actions “marquer tout comme lu”',
+		'display_articles_unfolded' => 'Afficher les articles dépliés par défaut',
+		'display_categories_unfolded' => 'Afficher les catégories pliées par défaut',
+		'hide_read_feeds' => 'Cacher les catégories & flux sans article non-lu (ne fonctionne pas avec la configuration “Afficher tous les articles”)',
+		'img_with_lazyload' => 'Utiliser le mode “chargement différé” pour les images',
+		'jump_next' => 'sauter au prochain voisin non lu (flux ou catégorie)',
+		'number_divided_when_reader' => 'Divisé par 2 dans la vue de lecture.',
+		'read' => array(
+			'article_open_on_website' => 'lorsque l’article est ouvert sur le site d’origine',
+			'article_viewed' => 'lorsque l’article est affiché',
+			'scroll' => 'au défilement de la page',
+			'upon_reception' => 'dès la réception du nouvel article',
+			'when' => 'Marquer un article comme lu…',
+		),
+		'show' => array(
+			'_' => 'Articles à afficher',
+			'adaptive' => 'Adapter l’affichage',
+			'all_articles' => 'Afficher tous les articles',
+			'unread' => 'Afficher les non lus',
+		),
+		'sort' => array(
+			'_' => 'Ordre de tri',
+			'newer_first' => 'Plus récents en premier',
+			'older_first' => 'Plus anciens en premier',
+		),
+		'sticky_post' => 'Aligner l’article en haut quand il est ouvert',
+		'title' => 'Lecture',
+		'view' => array(
+			'default' => 'Vue par défaut',
+			'global' => 'Vue globale',
+			'normal' => 'Vue normale',
+			'reader' => 'Vue lecture',
+		),
+	),
+	'sharing' => array(
+		'_' => 'Partage',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Courriel',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'more_information' => 'Plus d’informations',
+		'print' => 'Print',
+		'shaarli' => 'Shaarli',
+		'share_name' => 'Nom du partage à afficher',
+		'share_url' => 'URL du partage à utiliser',
+		'title' => 'Partage',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag',
+	),
+	'shortcut' => array(
+		'_' => 'Raccourcis',
+		'article_action' => 'Actions associées à l’article courant',
+		'auto_share' => 'Partager',
+		'auto_share_help' => 'S’il n’y a qu’un mode de partage, celui-ci est utilisé automatiquement. Sinon ils sont accessibles par leur numéro.',
+		'close_dropdown' => 'Fermer les menus',
+		'collapse_article' => 'Refermer',
+		'first_article' => 'Passer au premier article',
+		'focus_search' => 'Accéder à la recherche',
+		'help' => 'Afficher la documentation',
+		'javascript' => 'Le JavaScript doit être activé pour pouvoir profiter des raccourcis.',
+		'last_article' => 'Passer au dernier article',
+		'load_more' => 'Charger plus d’articles',
+		'mark_read' => 'Marquer comme lu',
+		'mark_favorite' => 'Mettre en favori',
+		'navigation' => 'Navigation',
+		'navigation_help' => 'Avec le modificateur "Shift", les raccourcis de navigation s’appliquent aux flux.<br/>Avec le modificateur "Alt", les raccourcis de navigation s’appliquent aux catégories.',
+		'next_article' => 'Passer à l’article suivant',
+		'other_action' => 'Autres actions',
+		'previous_article' => 'Passer à l’article précédent',
+		'see_on_website' => 'Voir sur le site d’origine',
+		'shift_for_all_read' => '+ <code>shift</code> pour marquer tous les articles comme lus',
+		'title' => 'Raccourcis',
+		'user_filter' => 'Accéder aux filtres utilisateur',
+		'user_filter_help' => 'S’il n’y a qu’un filtre utilisateur, celui-ci est utilisé automatiquement. Sinon ils sont accessibles par leur numéro.',
+	),
+	'user' => array(
+		'articles_and_size' => '%s articles (%s)',
+		'current' => 'Utilisateur actuel',
+		'is_admin' => 'est administrateur',
+		'users' => 'Utilisateurs',
+	),
+);

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

@@ -0,0 +1,109 @@
+<?php
+
+return array(
+	'admin' => array(
+		'optimization_complete' => 'Optimisation terminée.',
+	),
+	'access' => array(
+		'denied' => 'Vous n’avez pas le droit d’accéder à cette page !',
+		'not_found' => 'La page que vous cherchez n’existe pas !',
+	),
+	'auth' => array(
+		'form' => array(
+			'not_set' => 'Un problème est survenu lors de la configuration de votre système d’authentification. Veuillez réessayer plus tard.',
+			'set' => 'Le formulaire est désormais votre système d’authentification.',
+		),
+		'login' => array(
+			'invalid' => 'L’identifiant est invalide',
+			'success' => 'Vous êtes désormais connecté',
+		),
+		'logout' => array(
+			'success' => 'Vous avez été déconnecté',
+		),
+		'no_password_set' => 'Aucun mot de passe administrateur n’a été précisé. Cette fonctionnalité n’est pas disponible.',
+	),
+	'conf' => array(
+		'error' => 'Une erreur est survenue durant la sauvegarde de la configuration',
+		'query_created' => 'Le filtre "%s" a bien été créé.',
+		'shortcuts_updated' => 'Les raccourcis ont été mis à jour.',
+		'updated' => 'La configuration a été mise à jour',
+	),
+	'extensions' => array(
+		'already_enabled' => '%s est déjà activée',
+		'disable' => array(
+			'ko' => '%s ne peut pas être désactivée. <a href="%s">Consulter les logs de FreshRSS</a> pour plus de détails.',
+			'ok' => '%s est désormais désactivée',
+		),
+		'enable' => array(
+			'ko' => '%s ne peut pas être activée. <a href="%s">Consulter les logs de FreshRSS</a> pour plus de détails.',
+			'ok' => '%s est désormais activée',
+		),
+		'no_access' => 'Vous n’avez aucun accès sur %s',
+		'not_enabled' => '%s n’est pas encore activée',
+		'not_found' => '%s n’existe pas',
+	),
+	'import_export' => array(
+		'export_no_zip_extension' => 'L’extension ZIP n’est pas présente sur votre serveur. Veuillez essayer d’exporter les fichiers un par un.',
+		'feeds_imported' => 'Vos flux ont été importés et vont maintenant être actualisés.',
+		'feeds_imported_with_errors' => 'Vos flux ont été importés mais des erreurs sont survenues.',
+		'file_cannot_be_uploaded' => 'Le fichier ne peut pas être téléchargé !',
+		'no_zip_extension' => 'L’extension ZIP n’est pas présente sur votre serveur.',
+		'zip_error' => 'Une erreur est survenue durant l’import du fichier ZIP.',
+	),
+	'sub' => array(
+		'actualize' => 'Actualiser',
+		'category' => array(
+			'created' => 'La catégorie %s a été créée.',
+			'deleted' => 'La catégorie a été supprimée.',
+			'emptied' => 'La catégorie a été vidée.',
+			'error' => 'La catégorie n’a pas pu être modifiée',
+			'name_exists' => 'Une catégorie possède déjà ce nom.',
+			'no_id' => 'Vous devez préciser l’id de la catégorie.',
+			'no_name' => 'Vous devez préciser un nom pour la catégorie.',
+			'not_delete_default' => 'Vous ne pouvez pas supprimer la catégorie par défaut !',
+			'not_exist' => 'Cette catégorie n’existe pas !',
+			'over_max' => 'Vous avez atteint votre limite de catégories (%d)',
+			'updated' => 'La catégorie a été mise à jour.',
+		),
+		'feed' => array(
+			'actualized' => '<em>%s</em> a été mis à jour.',
+			'actualizeds' => 'Les flux ont été mis à jour.',
+			'added' => 'Le flux <em>%s</em> a bien été ajouté.',
+			'already_subscribed' => 'Vous êtes déjà abonné à <em>%s</em>',
+			'deleted' => 'Le flux a été supprimé.',
+			'error' => 'Une erreur est survenue',
+			'internal_problem' => 'Le flux ne peut pas être ajouté. <a href="%s">Consulter les logs de FreshRSS</a> pour plus de détails.',
+			'invalid_url' => 'L’url <em>%s</em> est invalide.',
+			'marked_read' => 'Les flux ont été marqués comme lus.',
+			'n_actualized' => '%d flux ont été mis à jour.',
+			'n_entries_deleted' => '%d articles ont été supprimés.',
+			'no_refresh' => 'Il n’y a aucun flux à actualiser…',
+			'not_added' => '<em>%s</em> n’a pas pu être ajouté.',
+			'over_max' => 'Vous avez atteint votre limite de flux (%d)',
+			'updated' => 'Le flux a été mis à jour',
+		),
+		'purge_completed' => 'Purge effectuée (%d articles supprimés).',
+	),
+	'update' => array(
+		'can_apply' => 'FreshRSS va maintenant être mis à jour vers la <strong>version %s</strong>.',
+		'error' => 'La mise à jour a rencontré un problème : %s',
+		'file_is_nok' => 'Veuillez vérifier les droits sur le répertoire <em>%s</em>. Le serveur HTTP doit être capable d’écrire dedans',
+		'finished' => 'La mise à jour est terminée !',
+		'none' => 'Aucune mise à jour à appliquer',
+		'server_not_found' => 'Le serveur de mise à jour n’a pas été trouvé. [%s]',
+	),
+	'user' => array(
+		'created' => array(
+			'_' => 'L’utilisateur %s a été créé.',
+			'error' => 'L’utilisateur %s ne peut pas être créé.',
+		),
+		'deleted' => array(
+			'_' => 'L’utilisateur %s a été supprimé.',
+			'error' => 'L’utilisateur %s ne peut pas être supprimé.',
+		),
+	),
+	'profile' => array(
+		'error' => 'Votre profil n’a pas pu être mis à jour',
+		'updated' => 'Votre profil a été mis à jour',
+	),
+);

+ 182 - 0
app/i18n/fr/gen.php

@@ -0,0 +1,182 @@
+<?php
+
+return array(
+	'action' => array(
+		'actualize' => 'Actualiser',
+		'back_to_rss_feeds' => '← Retour à vos flux RSS',
+		'cancel' => 'Annuler',
+		'create' => 'Créer',
+		'disable' => 'Désactiver',
+		'empty' => 'Vider',
+		'enable' => 'Activer',
+		'export' => 'Exporter',
+		'filter' => 'Filtrer',
+		'import' => 'Importer',
+		'manage' => 'Gérer',
+		'mark_read' => 'Marquer comme lu',
+		'mark_favorite' => 'Mettre en favori',
+		'remove' => 'Supprimer',
+		'see_website' => 'Voir le site',
+		'submit' => 'Valider',
+		'truncate' => 'Supprimer tous les articles',
+	),
+	'auth' => array(
+		'email' => 'Adresse courriel',
+		'keep_logged_in' => 'Rester connecté <small>(%s jours)</small>',
+		'login' => 'Connexion',
+		'logout' => 'Déconnexion',
+		'password' => array(
+			'_' => 'Mot de passe',
+			'format' => '<small>7 caractères minimum</small>',
+		),
+		'registration' => array(
+			'_' => 'Nouveau compte',
+			'ask' => 'Créer un compte ?',
+			'title' => 'Création de compte',
+		),
+		'reset' => 'Réinitialisation de l’authentification',
+		'username' => array(
+			'_' => 'Nom d’utilisateur',
+			'admin' => 'Nom d’utilisateur administrateur',
+			'format' => '<small>16 caractères alphanumériques maximum</small>',
+		),
+	),
+	'date' => array(
+		'Apr' => '\\a\\v\\r\\i\\l',
+		'Aug' => '\\a\\o\\û\\t',
+		'Dec' => '\\d\\é\\c\\e\\m\\b\\r\\e',
+		'Feb' => '\\f\\é\\v\\r\\i\\e\\r',
+		'Jan' => '\\j\\a\\n\\v\\i\\e\\r',
+		'Jul' => '\\j\\u\\i\\l\\l\\e\\t',
+		'Jun' => '\\j\\u\\i\\n',
+		'Mar' => '\\m\\a\\r\\s',
+		'May' => '\\m\\a\\i',
+		'Nov' => '\\n\\o\\v\\e\\m\\b\\r\\e',
+		'Oct' => '\\o\\c\\t\\o\\b\\r\\e',
+		'Sep' => '\\s\\e\\p\\t\\e\\m\\b\\r\\e',
+		'apr' => 'avr.',
+		'april' => 'avril',
+		'aug' => 'août',
+		'august' => 'août',
+		'before_yesterday' => 'À partir d’avant-hier',
+		'dec' => 'déc.',
+		'december' => 'décembre',
+		'feb' => 'fév.',
+		'february' => 'février',
+		'format_date' => 'j %s Y',
+		'format_date_hour' => 'j %s Y \\à H\\:i',
+		'fri' => 'ven.',
+		'jan' => 'jan.',
+		'january' => 'janvier',
+		'jul' => 'jui.',
+		'july' => 'juillet',
+		'jun' => 'juin',
+		'june' => 'juin',
+		'last_3_month' => 'Depuis les trois derniers mois',
+		'last_6_month' => 'Depuis les six derniers mois',
+		'last_month' => 'Depuis le mois dernier',
+		'last_week' => 'Depuis la semaine dernière',
+		'last_year' => 'Depuis l’année dernière',
+		'mar' => 'mar.',
+		'march' => 'mars',
+		'may' => 'mai.',
+		'mon' => 'lun.',
+		'month' => 'mois',
+		'nov' => 'nov.',
+		'november' => 'novembre',
+		'oct' => 'oct.',
+		'october' => 'octobre',
+		'sat' => 'sam.',
+		'sep' => 'sep.',
+		'september' => 'septembre',
+		'sun' => 'dim.',
+		'thu' => 'jeu.',
+		'today' => 'Aujourd’hui',
+		'tue' => 'mar.',
+		'wed' => 'mer.',
+		'yesterday' => 'Hier',
+	),
+	'freshrss' => array(
+		'_' => 'FreshRSS',
+		'about' => 'À propos de FreshRSS',
+	),
+	'js' => array(
+		'category_empty' => 'Catégorie vide',
+		'confirm_action' => 'Êtes-vous sûr(e) de vouloir continuer ? Cette action ne peut être annulée !',
+		'confirm_action_feed_cat' => 'Êtes-vous sûr(e) de vouloir continuer ? Vous perdrez les favoris et les filtres associés. Cette action ne peut être annulée !',
+		'feedback' => array(
+			'body_new_articles' => 'Il y a %%d nouveaux articles à lire sur FreshRSS.',
+			'request_failed' => 'Une requête a échoué, cela peut être dû à des problèmes de connexion à Internet.',
+			'title_new_articles' => 'FreshRSS : nouveaux articles !',
+		),
+		'new_article' => 'Il y a de nouveaux articles disponibles, cliquez pour rafraîchir la page.',
+		'should_be_activated' => 'Le JavaScript doit être activé.',
+	),
+	'lang' => array(
+		'cz' => 'Čeština',
+		'de' => 'Deutsch',
+		'en' => 'English',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'nl' => 'Nederlands',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+	),
+	'menu' => array(
+		'about' => 'À propos',
+		'admin' => 'Administration',
+		'archiving' => 'Archivage',
+		'authentication' => 'Authentification',
+		'check_install' => 'Vérification de l’installation',
+		'configuration' => 'Configuration',
+		'display' => 'Affichage',
+		'extensions' => 'Extensions',
+		'logs' => 'Logs',
+		'queries' => 'Filtres utilisateurs',
+		'reading' => 'Lecture',
+		'search' => 'Rechercher des mots ou des #tags',
+		'sharing' => 'Partage',
+		'shortcuts' => 'Raccourcis',
+		'stats' => 'Statistiques',
+		'system' => 'Configuration du système',
+		'update' => 'Mise à jour',
+		'user_management' => 'Gestion des utilisateurs',
+		'user_profile' => 'Profil',
+	),
+	'pagination' => array(
+		'first' => 'Début',
+		'last' => 'Fin',
+		'load_more' => 'Charger plus d’articles',
+		'mark_all_read' => 'Tout marquer comme lu',
+		'next' => 'Suivant',
+		'nothing_to_load' => 'Fin des articles',
+		'previous' => 'Précédent',
+	),
+	'share' => array(
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Courriel',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'movim' => 'Movim',
+		'print' => 'Imprimer',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
+		'jdh' => 'Journal du hacker',
+	),
+	'short' => array(
+		'attention' => 'Attention !',
+		'blank_to_disable' => 'Laissez vide pour désactiver',
+		'by_author' => 'Par <em>%s</em>',
+		'by_default' => 'Par défaut',
+		'damn' => 'Arf !',
+		'default_category' => 'Sans catégorie',
+		'no' => 'Non',
+		'not_applicable' => 'Non disponible',
+		'ok' => 'Ok !',
+		'or' => 'ou',
+		'yes' => 'Oui',
+	),
+);

+ 61 - 0
app/i18n/fr/index.php

@@ -0,0 +1,61 @@
+<?php
+
+return array(
+	'about' => array(
+		'_' => 'À propos',
+		'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
+		'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.',
+		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">sur Github</a>',
+		'license' => 'Licence',
+		'project_website' => 'Site du projet',
+		'title' => 'À propos',
+		'version' => 'Version',
+		'website' => 'Site Internet',
+	),
+	'feed' => array(
+		'add' => 'Vous pouvez ajouter des flux.',
+		'empty' => 'Il n’y a aucun article à afficher.',
+		'rss_of' => 'Flux RSS de %s',
+		'title' => 'Vos flux RSS',
+		'title_global' => 'Vue globale',
+		'title_fav' => 'Vos favoris',
+	),
+	'log' => array(
+		'_' => 'Logs',
+		'clear' => 'Effacer les logs',
+		'empty' => 'Les logs sont vides.',
+		'title' => 'Logs',
+	),
+	'menu' => array(
+		'about' => 'À propos de FreshRSS',
+		'add_query' => 'Créer un filtre',
+		'before_one_day' => 'Antérieurs à 1 jour',
+		'before_one_week' => 'Antérieurs à 1 semaine',
+		'favorites' => 'Favoris (%s)',
+		'global_view' => 'Vue globale',
+		'main_stream' => 'Flux principal',
+		'mark_all_read' => 'Tout marquer comme lu',
+		'mark_cat_read' => 'Marquer la catégorie comme lue',
+		'mark_feed_read' => 'Marquer le flux comme lu',
+		'newer_first' => 'Plus récents en premier',
+		'non-starred' => 'Afficher tout sauf les favoris',
+		'normal_view' => 'Vue normale',
+		'older_first' => 'Plus anciens en premier',
+		'queries' => 'Filtres utilisateurs',
+		'read' => 'Afficher les lus',
+		'reader_view' => 'Vue lecture',
+		'rss_view' => 'Flux RSS',
+		'search_short' => 'Rechercher',
+		'starred' => 'Afficher les favoris',
+		'stats' => 'Statistiques',
+		'subscription' => 'Gestion des abonnements',
+		'unread' => 'Afficher les non lus',
+	),
+	'share' => 'Partager',
+	'tag' => array(
+		'related' => 'Tags associés',
+	),
+);

+ 119 - 0
app/i18n/fr/install.php

@@ -0,0 +1,119 @@
+<?php
+
+return array(
+	'action' => array(
+		'finish' => 'Terminer l’installation',
+		'fix_errors_before' => 'Veuillez corriger les erreurs avant de passer à l’étape suivante.',
+		'keep_install' => 'Garder l’ancienne configuration',
+		'next_step' => 'Passer à l’étape suivante',
+		'reinstall' => 'Réinstaller FreshRSS',
+	),
+	'auth' => array(
+		'form' => 'Formulaire (traditionnel, requiert JavaScript)',
+		'http' => 'HTTP (pour utilisateurs avancés avec HTTPS)',
+		'none' => 'Aucune (dangereux)',
+		'password_form' => 'Mot de passe<br /><small>(pour connexion par formulaire)</small>',
+		'password_format' => '7 caractères minimum',
+		'type' => 'Méthode d’authentification',
+	),
+	'bdd' => array(
+		'_' => 'Base de données',
+		'conf' => array(
+			'_' => 'Configuration de la base de données',
+			'ko' => 'Vérifiez les informations d’accès à la base de données.',
+			'ok' => 'La configuration de la base de données a été enregistrée.',
+		),
+		'host' => 'Hôte',
+		'password' => 'Mot de passe pour base de données',
+		'prefix' => 'Préfixe des tables',
+		'type' => 'Type de base de données',
+		'username' => 'Nom d’utilisateur pour base de données',
+	),
+	'check' => array(
+		'_' => 'Vérifications',
+		'already_installed' => 'FreshRSS semble avoir déjà été installé !',
+		'cache' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/cache</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire de cache sont bons.',
+		),
+		'ctype' => array(
+			'nok' => 'Impossible de trouver une librairie pour la vérification des types de caractères (php-ctype).',
+			'ok' => 'Vous disposez de la librairie pour la vérification des types de caractères (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Vous ne disposez pas de cURL (paquet php-curl).',
+			'ok' => 'Vous disposez de cURL.',
+		),
+		'data' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire de data sont bons.',
+		),
+		'dom' => array(
+			'nok' => 'Impossible de trouver une librairie pour parcourir le DOM.',
+			'ok' => 'Vous disposez de la librairie pour parcourir le DOM.',
+		),
+		'favicons' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/favicons</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire des favicons sont bons.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Vous ne disposez pas de PHP fileinfo (paquet fileinfo).',
+			'ok' => 'Vous disposez de fileinfo.',
+		),
+		'http_referer' => array(
+			'nok' => 'Veuillez vérifier que vous ne modifiez pas votre HTTP REFERER.',
+			'ok' => 'Le HTTP REFERER est connu et semble correspondre à votre serveur.',
+		),
+		'json' => array(
+			'nok' => 'Impossible de trouver une librairie recommandée pour JSON.',
+			'ok' => 'Vouz disposez de la librairie recommandée pour JSON.',
+		),
+		'minz' => array(
+			'nok' => 'Vous ne disposez pas de la librairie Minz.',
+			'ok' => 'Vous disposez du framework Minz',
+		),
+		'pcre' => array(
+			'nok' => 'Impossible de trouver une librairie pour les expressions régulières (php-pcre).',
+			'ok' => 'Vous disposez de la librairie pour les expressions régulières (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Vous ne disposez pas de PDO ou d’un des drivers supportés (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'Vous disposez de PDO et d’au moins un des drivers supportés (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'nok' => 'Votre version de PHP est la %s mais FreshRSS requiert au moins la version %s.',
+			'ok' => 'Votre version de PHP est la %s, qui est compatible avec FreshRSS.',
+		),
+		'users' => array(
+			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/users</em>. Le serveur HTTP doit être capable d’écrire dedans',
+			'ok' => 'Les droits sur le répertoire des utilisateurs sont bons.',
+		),
+		'xml' => array(
+			'nok' => 'Impossible de trouver une librairie requise pour XML.',
+			'ok' => 'Vouz disposez de la librairie requise pour XML.',
+		),
+	),
+	'conf' => array(
+		'_' => 'Configuration générale',
+		'ok' => 'La configuration générale a été enregistrée.',
+	),
+	'congratulations' => 'Félicitations !',
+	'default_user' => 'Nom de l’utilisateur par défaut <small>(16 caractères alphanumériques maximum)</small>',
+	'delete_articles_after' => 'Supprimer les articles après',
+	'fix_errors_before' => 'Veuillez corriger les erreurs avant de passer à l’étape suivante.',
+	'javascript_is_better' => 'FreshRSS est plus agréable à utiliser avec JavaScript activé',
+	'js' => array(
+		'confirm_reinstall' => 'Réinstaller FreshRSS vous fera perdre la configuration précédente. Êtes-vous sûr de vouloir continuer ?',
+	),
+	'language' => array(
+		'_' => 'Langue',
+		'choose' => 'Choisissez la langue pour FreshRSS',
+		'defined' => 'La langue a bien été définie.',
+	),
+	'not_deleted' => 'Quelque chose s’est mal passé, vous devez supprimer le fichier <em>%s</em> à la main.',
+	'ok' => 'L’installation s’est bien passée.',
+	'step' => 'étape %d',
+	'steps' => 'Étapes',
+	'title' => 'Installation · FreshRSS',
+	'this_is_the_end' => 'This is the end',
+);

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

@@ -0,0 +1,62 @@
+<?php
+
+return array(
+	'category' => array(
+		'_' => 'Catégorie',
+		'add' => 'Ajouter une catégorie',
+		'empty' => 'Catégorie vide',
+		'new' => 'Nouvelle catégorie',
+	),
+	'feed' => array(
+		'add' => 'Ajouter un flux RSS',
+		'advanced' => 'Avancé',
+		'archiving' => 'Archivage',
+		'auth' => array(
+			'configuration' => 'Identification',
+			'help' => 'La connexion permet d’accéder aux flux protégés par une authentification HTTP.',
+			'http' => 'Authentification HTTP',
+			'password' => 'Mot de passe HTTP',
+			'username' => 'Identifiant HTTP',
+		),
+		'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',
+		'empty' => 'Ce flux est vide. Veuillez vérifier qu’il est toujours maintenu.',
+		'error' => 'Ce flux a rencontré un problème. Veuillez vérifier qu’il est toujours accessible puis actualisez-le.',
+		'in_main_stream' => 'Afficher dans le flux principal',
+		'informations' => 'Informations',
+		'keep_history' => 'Nombre minimum d’articles à conserver',
+		'moved_category_deleted' => 'Lors de la suppression d’une catégorie, ses flux seront automatiquement classés dans <em>%s</em>.',
+		'no_selected' => 'Aucun flux sélectionné.',
+		'number_entries' => '%d articles',
+		'stats' => 'Statistiques',
+		'think_to_add' => 'Vous pouvez ajouter des flux.',
+		'title' => 'Titre',
+		'title_add' => 'Ajouter un flux RSS',
+		'ttl' => 'Ne pas automatiquement rafraîchir plus souvent que',
+		'url' => 'URL du flux',
+		'validator' => 'Vérifier la validité du flux',
+		'website' => 'URL du site',
+		'pubsubhubbub' => 'Notification instantanée par PubSubHubbub',
+	),
+	'import_export' => array(
+		'export' => 'Exporter',
+		'export_opml' => 'Exporter la liste des flux (OPML)',
+		'export_starred' => 'Exporter les favoris',
+		'feed_list' => 'Liste des articles de %s',
+		'file_to_import' => 'Fichier à importer<br />(OPML, JSON ou ZIP)',
+		'file_to_import_no_zip' => 'Fichier à importer<br />(OPML ou JSON)',
+		'import' => 'Importer',
+		'starred_list' => 'Liste des articles favoris',
+		'title' => 'Importer / exporter',
+	),
+	'menu' => array(
+		'bookmark' => 'S’abonner (bookmark FreshRSS)',
+		'import_export' => 'Importer / exporter',
+		'subscription_management' => 'Gestion des abonnements',
+	),
+	'title' => array(
+		'_' => 'Gestion des abonnements',
+		'feed_management' => 'Gestion des flux RSS',
+	),
+);

+ 181 - 0
app/i18n/it/admin.php

@@ -0,0 +1,181 @@
+<?php
+
+return array(
+	'auth' => array(
+		'allow_anonymous' => 'Consenti la lettura agli utenti anonimi degli articoli dell utente predefinito (%s)',
+		'allow_anonymous_refresh' => 'Consenti agli utenti anonimi di aggiornare gli articoli',
+		'api_enabled' => 'Consenti le <abbr>API</abbr> di accesso <small>(richiesto per le app mobili)</small>',
+		'form' => 'Web form (tradizionale, richiede JavaScript)',
+		'http' => 'HTTP (per gli utenti avanzati con HTTPS)',
+		'none' => 'Nessuno (pericoloso)',
+		'title' => 'Autenticazione',
+		'title_reset' => 'Reset autenticazione',
+		'token' => 'Token di autenticazione',
+		'token_help' => 'Consenti accesso agli RSS dell utente predefinito senza autenticazione:',
+		'type' => 'Metodo di autenticazione',
+		'unsafe_autologin' => 'Consenti accesso automatico non sicuro usando il formato: ',
+	),
+	'check_install' => array(
+		'cache' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data/cache</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella della cache sono corretti.',
+		),
+		'categories' => array(
+			'nok' => 'La tabella delle categorie ha una configurazione errata.',
+			'ok' => 'Tabella delle categorie OK.',
+		),
+		'connection' => array(
+			'nok' => 'La connessione al database non può essere stabilita.',
+			'ok' => 'Connessione al database OK',
+		),
+		'ctype' => array(
+			'nok' => 'Manca una libreria richiesta per il controllo dei caratteri (php-ctype).',
+			'ok' => 'Libreria richiesta per il controllo dei caratteri presente (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Manca il supporto per cURL (pacchetto php-curl).',
+			'ok' => 'Estensione cURL presente.',
+		),
+		'data' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella data sono corretti.',
+		),
+		'database' => 'Installazione database',
+		'dom' => array(
+			'nok' => 'Manca una libreria richiesta per leggere DOM (pacchetto php-xml).',
+			'ok' => 'Libreria richiesta per leggere DOM presente.',
+		),
+		'entries' => array(
+			'nok' => 'La tabella Entry ha una configurazione errata.',
+			'ok' => 'Tabella Entry OK.',
+		),
+		'favicons' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data/favicons</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella favicons sono corretti.',
+		),
+		'feeds' => array(
+			'nok' => 'La tabella Feed ha una configurazione errata.',
+			'ok' => 'Tabella Feed OK.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Manca il supporto per PHP fileinfo (pacchetto fileinfo).',
+			'ok' => 'Estensione fileinfo presente.',
+		),
+		'files' => 'Installazione files',
+		'json' => array(
+			'nok' => 'Manca il supoorto a JSON (pacchetto php5-json).',
+			'ok' => 'Estensione JSON presente.',
+		),
+		'minz' => array(
+			'nok' => 'Manca il framework Minz.',
+			'ok' => 'Framework Minz presente.',
+		),
+		'pcre' => array(
+			'nok' => 'Manca una libreria richiesta per le regular expressions (php-pcre).',
+			'ok' => 'Libreria richiesta per le regular expressions presente (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Manca PDO o uno degli altri driver supportati (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'PDO e altri driver supportati (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'_' => 'Installazione PHP',
+			'nok' => 'Versione PHP %s FreshRSS richiede almeno la versione %s.',
+			'ok' => 'Versione PHP %s, compatibile con FreshRSS.',
+		),
+		'tables' => array(
+			'nok' => 'Rilevate tabelle mancanti nel database.',
+			'ok' => 'Tutte le tabelle sono presenti nel database.',
+		),
+		'title' => 'Verifica installazione',
+		'tokens' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data/tokens</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella tokens sono corretti.',
+		),
+		'users' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data/users</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella users sono corretti.',
+		),
+		'zip' => array(
+			'nok' => 'Manca estensione ZIP (pacchetto php-zip).',
+			'ok' => 'Estensione ZIP presente.',
+		),
+	),
+	'extensions' => array(
+		'disabled' => 'Disabilitata',
+		'empty_list' => 'Non ci sono estensioni installate',
+		'enabled' => 'Abilitata',
+		'no_configure_view' => 'Questa estensioni non può essere configurata.',
+		'system' => array(
+			'_' => 'Estensioni di sistema',
+			'no_rights' => 'Estensione di sistema (non hai i permessi su questo tipo)',
+		),
+		'title' => 'Estensioni',
+		'user' => 'Estensioni utente',
+	),
+	'stats' => array(
+		'_' => 'Statistiche',
+		'all_feeds' => 'Tutti i feeds',
+		'category' => 'Categoria',
+		'entry_count' => 'Articoli',
+		'entry_per_category' => 'Articoli per categoria',
+		'entry_per_day' => 'Articoli per giorno (ultimi 30 giorni)',
+		'entry_per_day_of_week' => 'Per giorno della settimana (media: %.2f articoli)',
+		'entry_per_hour' => 'Per ora (media: %.2f articoli)',
+		'entry_per_month' => 'Per mese (media: %.2f articoli)',
+		'entry_repartition' => 'Ripartizione contenuti',
+		'feed' => 'Feed',
+		'feed_per_category' => 'Feeds per categoria',
+		'idle' => 'Feeds non aggiornati',
+		'main' => 'Statistiche principali',
+		'main_stream' => 'Flusso principale',
+		'menu' => array(
+			'idle' => 'Feeds non aggiornati',
+			'main' => 'Statistiche principali',
+			'repartition' => 'Ripartizione articoli',
+		),
+		'no_idle' => 'Non ci sono feed non aggiornati',
+		'number_entries' => '%d articoli',
+		'percent_of_total' => '%% del totale',
+		'repartition' => 'Ripartizione articoli',
+		'status_favorites' => 'Preferiti',
+		'status_read' => 'Letti',
+		'status_total' => 'Totale',
+		'status_unread' => 'Non letti',
+		'title' => 'Statistiche',
+		'top_feed' => 'I migliori 10 feeds',
+	),
+	'system' => array(
+		'_' => 'Configurazione di sistema',
+		'auto-update-url' => 'Auto-update server URL', // @todo translate
+		'instance-name' => 'Nome istanza',
+		'max-categories' => 'Limite categorie per utente',
+		'max-feeds' => 'Limite feeds per utente',
+		'registration' => array(
+			'help' => '0 significa che non esiste limite sui profili',
+			'number' => 'Numero massimo di profili',
+		),
+	),
+	'update' => array(
+		'_' => 'Aggiornamento sistema',
+		'apply' => 'Applica',
+		'check' => 'Controlla la presenza di nuovi aggiornamenti',
+		'current_version' => 'FreshRSS versione %s.',
+		'last' => 'Ultima verifica: %s',
+		'none' => 'Nessun aggiornamento da applicare',
+		'title' => 'Aggiorna sistema',
+	),
+	'user' => array(
+		'articles_and_size' => '%s articoli (%s)',
+		'create' => 'Crea nuovo utente',
+		'language' => 'Lingua',
+		'number' => ' %d profilo utente creato',
+		'numbers' => 'Sono presenti %d profili utente',
+		'password_form' => 'Password<br /><small>(per il login classico)</small>',
+		'password_format' => 'Almeno 7 caratteri',
+		'title' => 'Gestione utenti',
+		'user_list' => 'Lista utenti',
+		'username' => 'Nome utente',
+		'users' => 'Utenti',
+	),
+);

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

@@ -0,0 +1,173 @@
+<?php
+
+return array(
+	'archiving' => array(
+		'_' => 'Archiviazione',
+		'advanced' => 'Avanzate',
+		'delete_after' => 'Rimuovi articoli dopo',
+		'help' => 'Altre opzioni sono disponibili nelle impostazioni dei singoli feed',
+		'keep_history_by_feed' => 'Numero minimo di articoli da mantenere per feed',
+		'optimize' => 'Ottimizza database',
+		'optimize_help' => 'Da fare occasionalmente per ridurre le dimensioni del database',
+		'purge_now' => 'Cancella ora',
+		'title' => 'Archiviazione',
+		'ttl' => 'Non effettuare aggiornamenti per più di',
+	),
+	'display' => array(
+		'_' => 'Visualizzazione',
+		'icon' => array(
+			'bottom_line' => 'Barra in fondo',
+			'entry' => 'Icone degli articoli',
+			'publication_date' => 'Data di pubblicazione',
+			'related_tags' => 'Tags correlati',
+			'sharing' => 'Condivisione',
+			'top_line' => 'Barra in alto',
+		),
+		'language' => 'Lingua',
+		'notif_html5' => array(
+			'seconds' => 'secondi (0 significa nessun timeout)',
+			'timeout' => 'Notifica timeout HTML5',
+		),
+		'theme' => 'Tema',
+		'title' => 'Visualizzazione',
+		'width' => array(
+			'content' => 'Larghezza contenuto',
+			'large' => 'Largo',
+			'medium' => 'Medio',
+			'no_limit' => 'Nessun limite',
+			'thin' => 'Stretto',
+		),
+	),
+	'query' => array(
+		'_' => 'Ricerche personali',
+		'deprecated' => 'Questa query non è più valida. La categoria o il feed di riferimento non stati cancellati.',
+		'filter' => 'Filtro applicato:',
+		'get_all' => 'Mostra tutti gli articoli',
+		'get_category' => 'Mostra la categoria "%s" ',
+		'get_favorite' => 'Mostra articoli preferiti',
+		'get_feed' => 'Mostra feed "%s" ',
+		'no_filter' => 'Nessun filtro',
+		'none' => 'Non hai creato nessuna ricerca personale.',
+		'number' => 'Ricerca n°%d',
+		'order_asc' => 'Mostra prima gli articoli più vecchi',
+		'order_desc' => 'Mostra prima gli articoli più nuovi',
+		'search' => 'Cerca per "%s"',
+		'state_0' => 'Mostra tutti gli articoli',
+		'state_1' => 'Mostra gli articoli letti',
+		'state_2' => 'Mostra gli articoli non letti',
+		'state_3' => 'Mostra tutti gli articoli',
+		'state_4' => 'Mostra gli articoli preferiti',
+		'state_5' => 'Mostra gli articoli preferiti letti',
+		'state_6' => 'Mostra gli articoli preferiti non letti',
+		'state_7' => 'Mostra gli articoli preferiti',
+		'state_8' => 'Non mostrare gli articoli preferiti',
+		'state_9' => 'Mostra gli articoli letti non preferiti',
+		'state_10' => 'Mostra gli articoli non letti e non preferiti',
+		'state_11' => 'Non mostrare gli articoli preferiti',
+		'state_12' => 'Mostra tutti gli articoli',
+		'state_13' => 'Mostra gli articoli letti',
+		'state_14' => 'Mostra gli articoli non letti',
+		'state_15' => 'Mostra tutti gli articoli',
+		'title' => 'Ricerche personali',
+	),
+	'profile' => array(
+		'_' => 'Gestione profili',
+		'delete' => array(
+			'_' => 'Cancellazione account',
+			'warn' => 'Il tuo account e tutti i dati associati saranno cancellati.',
+		),
+		'password_api' => 'Password API<br /><small>(e.g., per applicazioni mobili)</small>',
+		'password_form' => 'Password<br /><small>(per il login classico)</small>',
+		'password_format' => 'Almeno 7 caratteri',
+		'title' => 'Profilo',
+	),
+	'reading' => array(
+		'_' => 'Lettura',
+		'after_onread' => 'Dopo “segna tutto come letto”,',
+		'articles_per_page' => 'Numero di articoli per pagina',
+		'auto_load_more' => 'Carica articoli successivi a fondo pagina',
+		'auto_remove_article' => 'Nascondi articoli dopo la lettura',
+		'mark_updated_article_unread' => 'Segna articoli aggiornati come non letti',
+		'confirm_enabled' => 'Mostra una conferma per “segna tutto come letto”',
+		'display_articles_unfolded' => 'Mostra articoli aperti di predefinito',
+		'display_categories_unfolded' => 'Mostra categorie aperte di predefinito',
+		'hide_read_feeds' => 'Nascondi categorie e feeds con articoli già letti (non funziona se “Mostra tutti gli articoli” è selezionato)',
+		'img_with_lazyload' => 'Usa la modalità "caricamento ritardato" per le immagini',
+		'jump_next' => 'Salta al successivo feed o categoria non letto',
+		'number_divided_when_reader' => 'Diviso 2 nella modalità di lettura.',
+		'read' => array(
+			'article_open_on_website' => 'Quando un articolo è aperto nel suo sito di origine',
+			'article_viewed' => 'Quando un articolo viene letto',
+			'scroll' => 'Scorrendo la pagina',
+			'upon_reception' => 'Alla ricezione del contenuto',
+			'when' => 'Segna articoli come letti…',
+		),
+		'show' => array(
+			'_' => 	'Articoli da visualizzare',
+			'adaptive' => 'Adatta visualizzazione',
+			'all_articles' => 'Mostra tutti gli articoli',
+			'unread' => 'Mostra solo non letti',
+		),
+		'sort' => array(
+			'_' => 'Ordinamento',
+			'newer_first' => 'Prima i più recenti',
+			'older_first' => 'Prima i più vecchi',
+		),
+		'sticky_post' => 'Blocca il contenuto a inizio pagina quando aperto',
+		'title' => 'Lettura',
+		'view' => array(
+			'default' => 'Visualizzazione predefinita',
+			'global' => 'Vista globale per categorie',
+			'normal' => 'Vista elenco',
+			'reader' => 'Modalità di lettura',
+		),
+	),
+	'sharing' => array(
+		'_' => 'Condivisione',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'more_information' => 'Ulteriori informazioni',
+		'print' => 'Stampa',
+		'shaarli' => 'Shaarli',
+		'share_name' => 'Nome condivisione',
+		'share_url' => 'URL condivisione',
+		'title' => 'Condividi',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag',
+	),
+	'shortcut' => array(
+		'_' => 'Comandi tastiera',
+		'article_action' => 'Azioni sugli articoli',
+		'auto_share' => 'Condividi',
+		'auto_share_help' => 'Se è presente un solo servizio di condivisione verrà usato quello, altrimenti usare anche il numero associato.',
+		'close_dropdown' => 'Chiudi menù',
+		'collapse_article' => 'Collassa articoli',
+		'first_article' => 'Salta al primo articolo',
+		'focus_search' => 'Modulo di ricerca',
+		'help' => 'Mostra documentazione',
+		'javascript' => 'JavaScript deve essere abilitato per poter usare i comandi da tastiera',
+		'last_article' => 'Salta all ultimo articolo',
+		'load_more' => 'Carica altri articoli',
+		'mark_read' => 'Segna come letto',
+		'mark_favorite' => 'Segna come preferito',
+		'navigation' => 'Navigazione',
+		'navigation_help' => 'Con il tasto "Shift" i comandi di navigazione verranno applicati ai feeds.<br/>Con il tasto "Alt" i comandi di navigazione verranno applicati alle categorie.',
+		'next_article' => 'Salta al contenuto successivo',
+		'other_action' => 'Altre azioni',
+		'previous_article' => 'Salta al contenuto precedente',
+		'see_on_website' => 'Vai al sito fonte',
+		'shift_for_all_read' => '+ <code>shift</code> per segnare tutti gli articoli come letti',
+		'title' => 'Comandi da tastiera',
+		'user_filter' => 'Accedi alle ricerche personali',
+		'user_filter_help' => 'Se è presente una sola ricerca personale verrà usata quella, altrimenti usare anche il numero associato.',
+	),
+	'user' => array(
+		'articles_and_size' => '%s articoli (%s)',
+		'current' => 'Utente connesso',
+		'is_admin' => 'è amministratore',
+		'users' => 'Utenti',
+	),
+);

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

@@ -0,0 +1,109 @@
+<?php
+
+return array(
+	'admin' => array(
+		'optimization_complete' => 'Ottimizzazione completata',
+	),
+	'access' => array(
+		'denied' => 'Non hai i permessi per accedere a questa pagina',
+		'not_found' => 'Pagina non disponibile',
+	),
+	'auth' => array(
+		'form' => array(
+			'not_set' => 'Si è verificato un problema alla configurazione del sistema di autenticazione. Per favore riprova più tardi.',
+			'set' => 'Sistema di autenticazione tramite Form impostato come predefinito.',
+		),
+		'login' => array(
+			'invalid' => 'Autenticazione non valida',
+			'success' => 'Autenticazione effettuata',
+		),
+		'logout' => array(
+			'success' => 'Disconnessione effettuata',
+		),
+		'no_password_set' => 'Password di amministrazione non impostata. Opzione non disponibile.',
+	),
+	'conf' => array(
+		'error' => 'Si è verificato un errore durante il salvataggio della configurazione',
+		'query_created' => 'Ricerca "%s" creata.',
+		'shortcuts_updated' => 'Collegamenti tastiera aggiornati',
+		'updated' => 'Configurazione aggiornata',
+	),
+	'extensions' => array(
+		'already_enabled' => '%s è già abilitata',
+		'disable' => array(
+			'ko' => '%s non può essere disabilitata. <a href="%s">Verifica i logs</a> per dettagli.',
+			'ok' => '%s è disabilitata',
+		),
+		'enable' => array(
+			'ko' => '%s non può essere abilitata. <a href="%s">Verifica i logs</a> per dettagli.',
+			'ok' => '%s è ora abilitata',
+		),
+		'no_access' => 'Accesso negato a %s',
+		'not_enabled' => '%s non abilitato',
+		'not_found' => '%s non disponibile',
+	),
+	'import_export' => array(
+		'export_no_zip_extension' => 'Estensione ZIP non presente sul server. Per favore esporta i files singolarmente.',
+		'feeds_imported' => 'I tuoi feed sono stati importati e saranno aggiornati',
+		'feeds_imported_with_errors' => 'I tuoi feeds sono stati importati ma si sono verificati alcuni errori',
+		'file_cannot_be_uploaded' => 'Il file non può essere caricato!',
+		'no_zip_extension' => 'Estensione ZIP non presente sul server.',
+		'zip_error' => 'Si è verificato un errore importando il file ZIP',
+	),
+	'sub' => array(
+		'actualize' => 'Aggiorna',
+		'category' => array(
+			'created' => 'Categoria %s creata.',
+			'deleted' => 'Categoria cancellata',
+			'emptied' => 'Categoria svuotata',
+			'error' => 'Categoria non aggiornata',
+			'name_exists' => 'Categoria già esistente.',
+			'no_id' => 'Categoria senza ID.',
+			'no_name' => 'Il nome della categoria non può essere lasciato vuoto.',
+			'not_delete_default' => 'Non puoi cancellare la categoria predefinita!',
+			'not_exist' => 'La categoria non esite!',
+			'over_max' => 'Hai raggiunto il numero limite di categorie (%d)',
+			'updated' => 'Categoria aggiornata.',
+		),
+		'feed' => array(
+			'actualized' => '<em>%s</em> aggiornato',
+			'actualizeds' => 'RSS feeds aggiornati',
+			'added' => 'RSS feed <em>%s</em> aggiunti',
+			'already_subscribed' => 'Hai già sottoscritto <em>%s</em>',
+			'deleted' => 'Feed cancellato',
+			'error' => 'Feed non aggiornato',
+			'internal_problem' => 'RSS feed non aggiunto. <a href="%s">Verifica i logs</a> per dettagli.',
+			'invalid_url' => 'URL <em>%s</em> non valido',
+			'marked_read' => 'Feeds segnati come letti',
+			'n_actualized' => '%d feeds aggiornati',
+			'n_entries_deleted' => '%d articoli cancellati',
+			'no_refresh' => 'Nessun aggiornamento disponibile…',
+			'not_added' => '<em>%s</em> non può essere aggiunto',
+			'over_max' => 'Hai raggiunto il numero limite di feed (%d)',
+			'updated' => 'Feed aggiornato',
+		),
+		'purge_completed' => 'Svecchiamento completato (%d articoli cancellati)',
+	),
+	'update' => array(
+		'can_apply' => 'FreshRSS verrà aggiornato alla <strong>versione %s</strong>.',
+		'error' => 'Il processo di aggiornamento ha riscontrato il seguente errore: %s',
+		'file_is_nok' => 'Verifica i permessi della cartella <em>%s</em>. Il server HTTP deve avere i permessi per la scrittura ',
+		'finished' => 'Aggiornamento completato con successo!',
+		'none' => 'Nessun aggiornamento disponibile',
+		'server_not_found' => 'Server per aggiornamento non disponibile. [%s]',
+	),
+	'user' => array(
+		'created' => array(
+			'_' => 'Utente %s creato',
+			'error' => 'Errore nella creazione utente %s ',
+		),
+		'deleted' => array(
+			'_' => 'Utente %s cancellato',
+			'error' => 'Utente %s non cancellato',
+		),
+	),
+	'profile' => array(
+		'error' => 'Il tuo profilo non può essere modificato',
+		'updated' => 'Il tuo profilo è stato modificato',
+	),
+);

+ 182 - 0
app/i18n/it/gen.php

@@ -0,0 +1,182 @@
+<?php
+
+return array(
+	'action' => array(
+		'actualize' => 'Aggiorna',
+		'back_to_rss_feeds' => '← Indietro',
+		'cancel' => 'Annulla',
+		'create' => 'Crea',
+		'disable' => 'Disabilita',
+		'empty' => 'Vuoto',
+		'enable' => 'Abilita',
+		'export' => 'Esporta',
+		'filter' => 'Filtra',
+		'import' => 'Importa',
+		'manage' => 'Gestisci',
+		'mark_read' => 'Segna come letto',
+		'mark_favorite' => 'Segna come preferito',
+		'remove' => 'Rimuovi',
+		'see_website' => 'Vai al sito',
+		'submit' => 'Conferma',
+		'truncate' => 'Cancella tutti gli articoli',
+	),
+	'auth' => array(
+		'email' => 'Indirizzo email',
+		'keep_logged_in' => 'Ricorda i dati <small>(%s giorni)</small>',
+		'login' => 'Accedi',
+		'logout' => 'Esci',
+		'password' => array(
+			'_' => 'Password',
+			'format' => '<small>almeno 7 caratteri</small>',
+		),
+		'registration' => array(
+			'_' => 'Nuovo profilo',
+			'ask' => 'Vuoi creare un nuovo profilo?',
+			'title' => 'Creazione profilo',
+		),
+		'reset' => 'Reset autenticazione',
+		'username' => array(
+			'_' => 'Username',
+			'admin' => 'Username amministratore',
+			'format' => '<small>massimo 16 caratteri alfanumerici</small>',
+		),
+	),
+	'date' => array(
+		'Apr' => '\\A\\p\\r\\i\\l\\e',
+		'Aug' => '\\A\\g\\o\\s\\t\\o',
+		'Dec' => '\\D\\i\\c\\e\\m\\b\\r\\e',
+		'Feb' => '\\F\\e\\b\\b\\r\\a\\i\\o',
+		'Jan' => '\\G\\e\\n\\u\\a\\i\\o',
+		'Jul' => '\\L\\u\\g\\l\\i\\o',
+		'Jun' => '\\G\\i\\u\\g\\n\\o',
+		'Mar' => '\\M\\a\\r\\z\\o',
+		'May' => '\\M\\a\\g\\g\\i\\o',
+		'Nov' => '\\N\\o\\v\\e\\m\\b\\r\\e',
+		'Oct' => '\\O\\t\\t\\o\\b\\r\\e',
+		'Sep' => '\\S\\e\\t\\t\\e\\m\\b\\r\\e',
+		'apr' => 'apr',
+		'april' => 'Apr',
+		'aug' => 'aug',
+		'august' => 'Aug',
+		'before_yesterday' => 'Meno recenti',
+		'dec' => 'dec',
+		'december' => 'Dec',
+		'feb' => 'feb',
+		'february' => 'Feb',
+		'format_date' => 'j\\ %s Y',
+		'format_date_hour' => 'j\\ %s Y \\o\\r\\e H\\:i',
+		'fri' => 'Fri',
+		'jan' => 'jan',
+		'january' => 'Jan',
+		'jul' => 'jul',
+		'july' => 'Jul',
+		'jun' => 'jun',
+		'june' => 'Jun',
+		'last_3_month' => 'Ultimi 3 mesi',
+		'last_6_month' => 'Ultimi 6 mesi',
+		'last_month' => 'Ultimo mese',
+		'last_week' => 'Ultima settimana',
+		'last_year' => 'Ultimo anno',
+		'mar' => 'mar',
+		'march' => 'Mar',
+		'may' => 'May',
+		'mon' => 'Mon',
+		'month' => 'mesi',
+		'nov' => 'nov',
+		'november' => 'Nov',
+		'oct' => 'oct',
+		'october' => 'Oct',
+		'sat' => 'Sat',
+		'sep' => 'sep',
+		'september' => 'Sep',
+		'sun' => 'Sun',
+		'thu' => 'Thu',
+		'today' => 'Oggi',
+		'tue' => 'Tue',
+		'wed' => 'Wed',
+		'yesterday' => 'Ieri',
+	),
+	'freshrss' => array(
+		'_' => 'Feed RSS Reader',
+		'about' => 'Informazioni',
+	),
+	'js' => array(
+		'category_empty' => 'Categoria vuota',
+		'confirm_action' => 'Sei sicuro di voler continuare?',
+		'confirm_action_feed_cat' => 'Sei sicuro di voler continuare? Verranno persi i preferiti e le ricerche utente correlate!',
+		'feedback' => array(
+			'body_new_articles' => 'Ci sono %%d nuovi articoli da leggere.',
+			'request_failed' => 'Richiesta fallita, probabilmente a causa di problemi di connessione',
+			'title_new_articles' => 'Feed RSS Reader: nuovi articoli!',
+		),
+		'new_article' => 'Sono disponibili nuovi articoli, clicca qui per caricarli.',
+		'should_be_activated' => 'JavaScript deve essere abilitato',
+	),
+	'lang' => array(
+		'cz' => 'Čeština',
+		'de' => 'Deutsch',
+		'en' => 'English',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'nl' => 'Nederlands',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+	),
+	'menu' => array(
+		'about' => 'Informazioni',
+		'admin' => 'Amministrazione',
+		'archiving' => 'Archiviazione',
+		'authentication' => 'Autenticazione',
+		'check_install' => 'Installazione',
+		'configuration' => 'Configurazione',
+		'display' => 'Visualizzazione',
+		'extensions' => 'Estensioni',
+		'logs' => 'Logs',
+		'queries' => 'Ricerche personali',
+		'reading' => 'Lettura',
+		'search' => 'Ricerca parole o #tags',
+		'sharing' => 'Condivisione',
+		'shortcuts' => 'Comandi tastiera',
+		'stats' => 'Statistiche',
+		'system' => 'Configurazione sistema',
+		'update' => 'Aggiornamento',
+		'user_management' => 'Gestione utenti',
+		'user_profile' => 'Profilo',
+	),
+	'pagination' => array(
+		'first' => 'Prima',
+		'last' => 'Ultima',
+		'load_more' => 'Carica altri articoli',
+		'mark_all_read' => 'Segna tutto come letto',
+		'next' => 'Successiva',
+		'nothing_to_load' => 'Non ci sono altri articoli',
+		'previous' => 'Precedente',
+	),
+	'share' => array(
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'movim' => 'Movim',
+		'print' => 'Stampa',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
+		'jdh' => 'Journal du hacker',
+	),
+	'short' => array(
+		'attention' => 'Attenzione!',
+		'blank_to_disable' => 'Lascia vuoto per disabilitare',
+		'by_author' => 'di <em>%s</em>',
+		'by_default' => 'predefinito',
+		'damn' => 'Ops!',
+		'default_category' => 'Senza categoria',
+		'no' => 'No',
+		'not_applicable' => 'Non disponibile',
+		'ok' => 'OK!',
+		'or' => 'o',
+		'yes' => 'Si',
+	),
+);

+ 61 - 0
app/i18n/it/index.php

@@ -0,0 +1,61 @@
+<?php
+
+return array(
+	'about' => array(
+		'_' => 'Informazioni',
+		'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
+		'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.',
+		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">su Github</a>',
+		'license' => 'Licenza',
+		'project_website' => 'Sito del progetto',
+		'title' => 'Informazioni',
+		'version' => 'Versione',
+		'website' => 'Sito',
+	),
+	'feed' => array(
+		'add' => 'Aggiungi un Feed RSS',
+		'empty' => 'Non ci sono articoli da mostrare.',
+		'rss_of' => 'RSS feed di %s',
+		'title' => 'RSS feeds',
+		'title_global' => 'Vista globale per categorie',
+		'title_fav' => 'Preferiti',
+	),
+	'log' => array(
+		'_' => 'Logs',
+		'clear' => 'Svuota logs',
+		'empty' => 'File di log vuoto',
+		'title' => 'Logs',
+	),
+	'menu' => array(
+		'about' => 'Informazioni',
+		'add_query' => 'Aggiungi ricerca',
+		'before_one_day' => 'Giorno precedente',
+		'before_one_week' => 'Settimana precedente',
+		'favorites' => 'Preferiti (%s)',
+		'global_view' => 'Vista globale per categorie',
+		'main_stream' => 'Flusso principale',
+		'mark_all_read' => 'Segna tutto come letto',
+		'mark_cat_read' => 'Segna la categoria come letta',
+		'mark_feed_read' => 'Segna il feed come letto',
+		'newer_first' => 'Mostra prima i recenti',
+		'non-starred' => 'Escludi preferiti',
+		'normal_view' => 'Vista elenco',
+		'older_first' => 'Ordina per meno recenti',
+		'queries' => 'Chiavi di ricerca',
+		'read' => 'Mostra solo letti',
+		'reader_view' => 'Modalità di lettura',
+		'rss_view' => 'RSS feed',
+		'search_short' => 'Cerca',
+		'starred' => 'Mostra solo preferiti',
+		'stats' => 'Statistiche',
+		'subscription' => 'Gestione sottoscrizioni',
+		'unread' => 'Mostra solo non letti',
+	),
+	'share' => 'Condividi',
+	'tag' => array(
+		'related' => 'Tags correlati',
+	),
+);

+ 120 - 0
app/i18n/it/install.php

@@ -0,0 +1,120 @@
+<?php
+
+return array(
+	'action' => array(
+		'finish' => 'Installazione completata',
+		'fix_errors_before' => 'Per favore correggi gli errori prima di passare al passaggio successivo.',
+		'keep_install' => 'Mantieni configurazione precedente',
+		'next_step' => 'Vai al prossimo passaggio',
+		'reinstall' => 'Reinstalla FreshRSS',
+	),
+	'auth' => array(
+		'form' => 'Web form (tradizionale, richiede JavaScript)',
+		'http' => 'HTTP (per gli utenti avanzati con HTTPS)',
+		'none' => 'Nessuno (pericoloso)',
+		'password_form' => 'Password<br /><small>(per il login tramite Web-form tradizionale)</small>',
+		'password_format' => 'Almeno 7 caratteri',
+		'type' => 'Metodo di autenticazione',
+	),
+	'bdd' => array(
+		'_' => 'Database',
+		'conf' => array(
+			'_' => 'Configurazione database',
+			'ko' => 'Verifica le informazioni del database.',
+			'ok' => 'Le configurazioni del database sono state salvate.',
+		),
+		'host' => 'Host',
+		'prefix' => 'Prefisso tabella',
+		'password' => 'Password del database',
+		'type' => 'Tipo di database',
+		'username' => 'Nome utente del database',
+	),
+	'check' => array(
+		'_' => 'Controlli',
+		'already_installed' => 'FreshRSS risulta già installato!',
+		'cache' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data/cache</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella della cache sono corretti.',
+		),
+		'ctype' => array(
+			'nok' => 'Manca una libreria richiesta per il controllo dei caratteri (php-ctype).',
+			'ok' => 'Libreria richiesta per il controllo dei caratteri presente (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'Manca il supporto per cURL (pacchetto php-curl).',
+			'ok' => 'Estensione cURL presente.',
+		),
+		'data' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella data sono corretti.',
+		),
+		'dom' => array(
+			'nok' => 'Manca una libreria richiesta per leggere DOM.',
+			'ok' => 'Libreria richiesta per leggere DOM presente.',
+		),
+		'favicons' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data/favicons</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella favicons sono corretti.',
+		),
+		'fileinfo' => array(
+			'nok' => 'Manca il supporto per PHP fileinfo (pacchetto fileinfo).',
+			'ok' => 'Estensione fileinfo presente.',
+		),
+		'http_referer' => array(
+			'nok' => 'Per favore verifica che non stai alterando il tuo HTTP REFERER.',
+			'ok' => 'Il tuo HTTP REFERER riconosciuto corrisponde al tuo server.',
+		),
+		'json' => array(
+			'nok' => 'You lack a recommended library to parse JSON.',
+			'ok' => 'You have a recommended library to parse JSON.',
+		),
+		'minz' => array(
+			'nok' => 'Manca il framework Minz.',
+			'ok' => 'Framework Minz presente.',
+		),
+		'pcre' => array(
+			'nok' => 'Manca una libreria richiesta per le regular expressions (php-pcre).',
+			'ok' => 'Libreria richiesta per le regular expressions presente (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'Manca PDO o uno degli altri driver supportati (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'PDO e altri driver supportati (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'_' => 'Installazione PHP',
+			'nok' => 'Versione di PHP %s FreshRSS richiede almeno la versione %s.',
+			'ok' => 'Versione di PHP %s, compatibile con FreshRSS.',
+		),
+		'users' => array(
+			'nok' => 'Verifica i permessi sulla cartella <em>./data/users</em>. Il server HTTP deve avere i permessi per scriverci dentro',
+			'ok' => 'I permessi sulla cartella users sono corretti.',
+		),
+		'xml' => array(
+			'nok' => 'You lack the required library to parse XML.',
+			'ok' => 'You have the required library to parse XML.',
+		),
+	),
+	'conf' => array(
+		'_' => 'Configurazioni generali',
+		'ok' => 'Configurazioni generali salvate.',
+	),
+	'congratulations' => 'Congratulazione!',
+	'default_user' => 'Username utente predefinito <small>(massimo 16 caratteri alfanumerici)</small>',
+	'delete_articles_after' => 'Rimuovi articoli dopo',
+	'fix_errors_before' => 'Per favore correggi gli errori prima di passare al passaggio successivo.',
+	'javascript_is_better' => 'FreshRSS funziona meglio con JavaScript abilitato',
+	'js' => array(
+		'confirm_reinstall' => 'Reinstallando FreshRSS perderai la configurazione precedente. Sei sicuro di voler procedere?',
+	),
+	'language' => array(
+		'_' => 'Lingua',
+		'choose' => 'Seleziona la lingua per FreshRSS',
+		'defined' => 'Lingua impostata.',
+	),
+	'not_deleted' => 'Qualcosa non ha funzionato; devi cancellare il file <em>%s</em> manualmente.',
+	'ok' => 'Processo di installazione terminato con successo.',
+	'step' => 'Passaggio %d',
+	'steps' => 'Passaggi',
+	'title' => 'Installazione · FreshRSS',
+	'this_is_the_end' => 'Fine',
+);

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

@@ -0,0 +1,62 @@
+<?php
+
+return array(
+	'category' => array(
+		'_' => 'Categoria',
+		'add' => 'Aggiungi una categoria',
+		'empty' => 'Categoria vuota',
+		'new' => 'Nuova categoria',
+	),
+	'feed' => array(
+		'add' => 'Aggiungi un Feed RSS',
+		'advanced' => 'Avanzate',
+		'archiving' => 'Archiviazione',
+		'auth' => array(
+			'configuration' => 'Autenticazione',
+			'help' => 'Accesso per feeds protetti',
+			'http' => 'Autenticazione HTTP',
+			'password' => 'HTTP password',
+			'username' => 'HTTP username',
+		),
+		'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',
+		'empty' => 'Questo feed non contiene articoli. Per favore verifica il sito direttamente.',
+		'error' => 'Questo feed ha generato un errore. Per favore verifica se ancora disponibile.',
+		'in_main_stream' => 'Mostra in homepage',
+		'informations' => 'Informazioni',
+		'keep_history' => 'Numero minimo di articoli da mantenere',
+		'moved_category_deleted' => 'Cancellando una categoria i feed al suo interno verranno classificati automaticamente come <em>%s</em>.',
+		'no_selected' => 'Nessun feed selezionato.',
+		'number_entries' => '%d articoli',
+		'stats' => 'Statistiche',
+		'think_to_add' => 'Aggiungi feed.',
+		'title' => 'Titolo',
+		'title_add' => 'Aggiungi RSS feed',
+		'ttl' => 'Non aggiornare automaticamente piu di',
+		'url' => 'Feed URL',
+		'validator' => 'Controlla la validita del feed ',
+		'website' => 'URL del sito',
+		'pubsubhubbub' => 'Notifica istantanea con PubSubHubbub',
+	),
+	'import_export' => array(
+		'export' => 'Esporta',
+		'export_opml' => 'Esporta tutta la lista dei feed (OPML)',
+		'export_starred' => 'Esporta i tuoi preferiti',
+		'feed_list' => 'Elenco di %s articoli',
+		'file_to_import' => 'File da importare<br />(OPML, JSON o ZIP)',
+		'file_to_import_no_zip' => 'File da importare<br />(OPML o JSON)',
+		'import' => 'Importa',
+		'starred_list' => 'Elenco articoli preferiti',
+		'title' => 'Importa / esporta',
+	),
+	'menu' => array(
+		'bookmark' => 'Bookmark (trascina nei preferiti)',
+		'import_export' => 'Importa / esporta',
+		'subscription_management' => 'Gestione sottoscrizioni',
+	),
+	'title' => array(
+		'_' => 'Gestione sottoscrizioni',
+		'feed_management' => 'Gestione RSS feeds',
+	),
+);

+ 186 - 0
app/i18n/nl/admin.php

@@ -0,0 +1,186 @@
+<?php
+/* Dutch translation by Wanabo. http://www.nieuwskop.be */
+return array(
+	'auth' => array(
+		'allow_anonymous' => 'Sta bezoekers toe om artikelen te lezen van de standaard gebruiker (%s)',
+		'allow_anonymous_refresh' => 'Sta bezoekers toe om de artikelen te vernieuwen',
+		'api_enabled' => 'Sta <abbr>API</abbr> toegang toe <small>(nodig voor mobiele apps)</small>',
+		'form' => 'Web formulier (traditioneel, benodigd JavaScript)',
+		'http' => 'HTTP (voor geavanceerde gebruikers met HTTPS)',
+		'none' => 'Geen (gevaarlijk)',
+		'title' => 'Authenticatie',
+		'title_reset' => 'Authenticatie terugzetten',
+		'token' => 'Authenticatie teken',
+		'token_help' => 'Sta toegang toe tot de RSS uitvoer van de standaard gebruiker zonder authenticatie:',
+		'type' => 'Authenticatie methode',
+		'unsafe_autologin' => 'Sta onveilige automatische log in toe met het volgende formaat: ',
+	),
+	'check_install' => array(
+		'cache' => array(
+			'nok' => 'Controleer de permissies van de <em>./data/cache</em> map. HTTP server moet rechten hebben om hierin te schrijven',
+			'ok' => 'Permissies van de cache map zijn goed.',
+		),
+		'categories' => array(
+			'nok' => 'Categorie tabel is slecht geconfigureerd.',
+			'ok' => 'Categorie tabel is ok.',
+		),
+		'connection' => array(
+			'nok' => 'Verbinding met de database kan niet worden gemaakt.',
+			'ok' => 'Verbinding met de database is ok.',
+		),
+		'ctype' => array(
+			'nok' => 'U mist de benodigde bibliotheek voor character type checking (php-ctype).',
+			'ok' => 'U hebt de benodigde bibliotheek voor character type checking (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'U mist de cURL (php-curl package).',
+			'ok' => 'U hebt de cURL uitbreiding.',
+		),
+		'data' => array(
+			'nok' => 'Controleer de permissies op de <em>./data</em> map. HTTP server moet rechten hebben om hierin te schrijven',
+			'ok' => 'Permissies op de data map zijn goed.',
+		),
+		'database' => 'Database installatie',
+		'dom' => array(
+			'nok' => 'U mist de benodigde bibliotheek voor het bladeren van DOM (php-xml package).',
+			'ok' => 'U hebt de benodigde bibliotheek voor het bladeren van DOM.',
+		),
+		'entries' => array(
+			'nok' => 'Invoer tabel is slecht geconfigureerd.',
+			'ok' => 'Invoer tabel is ok.',
+		),
+		'favicons' => array(
+			'nok' => 'Controleer de permissies op de <em>./data/favicons</em> map. HTTP server moet rechten hebben om hierin te schrijven',
+			'ok' => 'Permissies op de favicons map zijn goed.',
+		),
+		'feeds' => array(
+			'nok' => 'Feed tabel is slecht geconfigureerd.',
+			'ok' => 'Feed tabel is ok.',
+		),
+		'fileinfo' => array(
+			'nok' => 'U mist de PHP fileinfo (fileinfo package).',
+			'ok' => 'U hebt de fileinfo uitbreiding.',
+		),
+		'files' => 'Bestanden installatie',
+		'json' => array(
+			'nok' => 'U mist JSON (php5-json package).',
+			'ok' => 'U hebt JSON uitbreiding.',
+		),
+		'minz' => array(
+			'nok' => 'U mist Minz framework.',
+			'ok' => 'U hebt Minz framework.',
+		),
+		'pcre' => array(
+			'nok' => 'U mist de benodigde bibliotheek voor regular expressions (php-pcre).',
+			'ok' => 'U hebt de benodigde bibliotheek voor regular expressions (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'U mist PDO of een van de ondersteunde drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'U hebt PDO en ten minste één van de ondersteunde drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'_' => 'PHP installatie',
+			'nok' => 'Uw PHP versie is %s maar FreshRSS benodigd tenminste versie %s.',
+			'ok' => 'Uw PHP versie is %s, welke compatibel is met FreshRSS.',
+		),
+		'tables' => array(
+			'nok' => 'Er zijn één of meer ontbrekende tabellen in de database.',
+			'ok' => 'Alle tabellen zijn aanwezig in de database.',
+		),
+		'title' => 'Installatie controle',
+		'tokens' => array(
+			'nok' => 'Controleer de permissies op de <em>./data/tokens</em> map. HTTP server moet rechten hebben om hierin te schrijven',
+			'ok' => 'Permissies op de tokens map zijn goed.',
+		),
+		'users' => array(
+			'nok' => 'Controleer de permissies op de <em>./data/users</em> map. HTTP server moet rechten hebben om hierin te schrijven',
+			'ok' => 'Permissies op de users map zijn goed.',
+		),
+		'zip' => array(
+			'nok' => 'U mist ZIP uitbreiding (php-zip package).',
+			'ok' => 'U hebt ZIP uitbreiding.',
+		),
+	),
+	'extensions' => array(
+		'disabled' => 'Uitgeschakeld',
+		'empty_list' => 'Er zijn geïnstalleerde uitbreidingen',
+		'enabled' => 'Ingeschakeld',
+		'no_configure_view' => 'Deze uitbreiding kan niet worden geconfigureerd.',
+		'system' => array(
+			'_' => 'Systeem uitbreidingen',
+			'no_rights' => 'Systeem uitbreidingen (U hebt hier geen rechten op)',
+		),
+		'title' => 'Uitbreidingen',
+		'user' => 'Gebruikers uitbreidingen',
+	),
+	'stats' => array(
+		'_' => 'Statistieken',
+		'all_feeds' => 'Alle feeds',
+		'category' => 'Categorie',
+		'entry_count' => 'Invoer aantallen',
+		'entry_per_category' => 'Aantallen per categorie',
+		'entry_per_day' => 'Aantallen per dag (laatste 30 dagen)',
+		'entry_per_day_of_week' => 'Per dag of week (gemiddeld: %.2f berichten)',
+		'entry_per_hour' => 'Per uur (gemiddeld: %.2f berichten)',
+		'entry_per_month' => 'Per maand (gemiddeld: %.2f berichten)',
+		'entry_repartition' => 'Invoer verdeling',
+		'feed' => 'Feed',
+		'feed_per_category' => 'Feeds per categorie',
+		'idle' => 'Gepauzeerde feeds',
+		'main' => 'Hoofd statistieken',
+		'main_stream' => 'Overzicht',
+		'menu' => array(
+			'idle' => 'Gepauzeerde feeds',
+			'main' => 'Hoofd statistieken',
+			'repartition' => 'Artikelen verdeling',
+		),
+		'no_idle' => 'Er is geen gepauzeerde feed!',
+		'number_entries' => '%d artikelen',
+		'percent_of_total' => '%% van totaal',
+		'repartition' => 'Artikelen verdeling',
+		'status_favorites' => 'Favorieten',
+		'status_read' => 'Gelezen',
+		'status_total' => 'Totaal',
+		'status_unread' => 'Ongelezen',
+		'title' => 'Statistieken',
+		'top_feed' => 'Top tien feeds',
+	),
+	'system' => array(
+		'_' => 'Systeem configuratie',
+		'auto-update-url' => 'Automatische update server URL',
+		'instance-name' => 'Voorbeeld naam',
+		'max-categories' => 'Categoriën limiet per gebruiker',
+		'max-feeds' => 'Feed limiet per gebruiker',
+		'registration' => array(
+			'help' => '0 betekent geen account limiet',
+			'number' => 'Maximum aantal accounts',
+		),
+	),
+	'update' => array(
+		'_' => 'Versie controle',
+		'apply' => 'Toepassen',
+		'check' => 'Controleer op nieuwe versies',
+		'current_version' => 'Uw huidige versie van FreshRSS is %s.',
+		'last' => 'Laatste controle: %s',
+		'none' => 'Geen nieuwe versie om toe te passen',
+		'title' => 'Vernieuw systeem',
+	),
+	'user' => array(
+		'articles_and_size' => '%s artikelen (%s)',
+		'create' => 'Creëer  nieuwe gebruiker',
+		'language' => 'Taal',
+		'number' => 'Er is %d accounts gemaakt',
+		'numbers' => 'Er zijn %d accounts gemaakt',
+		'password_form' => 'Wachtwoord<br /><small>(voor de Web-formulier log in methode)</small>',
+		'password_format' => 'Ten minste 7 tekens',
+		'registration' => array(
+			'allow' => 'Sta het maken van nieuwe accounts toe',
+			'help' => '0 betekent dat er geen account limiet is',
+			'number' => 'Max aantal accounts',
+		),
+		'title' => 'Beheer gebruikers',
+		'user_list' => 'Lijst van gebruikers ',
+		'username' => 'Gebruikers naam',
+		'users' => 'Gebruikers',
+	),
+);

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

@@ -0,0 +1,173 @@
+<?php
+/* Dutch translation by Wanabo. http://www.nieuwskop.be */
+return array(
+	'archiving' => array(
+		'_' => 'Archivering',
+		'advanced' => 'Geavanceerd',
+		'delete_after' => 'Verwijder artikelen na',
+		'help' => 'Meer opties zijn beschikbaar in de persoonlijke stroom instellingen',
+		'keep_history_by_feed' => 'Minimum aantal te behouden artikelen in de feed',
+		'optimize' => 'Optimaliseer database',
+		'optimize_help' => 'Doe dit zo af en toe om de omvang van de database te verkleinen',
+		'purge_now' => 'Schoon nu op',
+		'title' => 'Archivering',
+		'ttl' => 'Vernieuw niet automatisch meer dan',
+	),
+	'display' => array(
+		'_' => 'Opmaak',
+		'icon' => array(
+			'bottom_line' => 'Onderaan',
+			'entry' => 'Artikel pictogrammen',
+			'publication_date' => 'Publicatie datum',
+			'related_tags' => 'Gerelateerde labels',
+			'sharing' => 'Delen',
+			'top_line' => 'Bovenaan',
+		),
+		'language' => 'Taal',
+		'notif_html5' => array(
+			'seconds' => 'seconden (0 betekent geen stop)',
+			'timeout' => 'HTML5 notificatie stop',
+		),
+		'theme' => 'Thema',
+		'title' => 'Opmaak',
+		'width' => array(
+			'content' => 'Inhoud breedte',
+			'large' => 'Breed',
+			'medium' => 'Normaal',
+			'no_limit' => 'Geen limiet',
+			'thin' => 'Smal',
+		),
+	),
+	'query' => array(
+		'_' => 'Gebruikers queries (informatie aanvragen)',
+		'deprecated' => 'Deze query (informatie aanvraag) is niet langer geldig. De bedoelde categorie of feed is al verwijderd.',
+		'filter' => 'Filter toegepast:',
+		'get_all' => 'Toon alle artikelen',
+		'get_category' => 'Toon "%s" categorie',
+		'get_favorite' => 'Toon favoriete artikelen',
+		'get_feed' => 'Toon "%s" feed',
+		'no_filter' => 'Geen filter',
+		'none' => 'U hebt nog geen gebruikers query aangemaakt..',
+		'number' => 'Query n°%d',
+		'order_asc' => 'Toon oudste artikelen eerst',
+		'order_desc' => 'Toon nieuwste artikelen eerst',
+		'search' => 'Zoek naar "%s"',
+		'state_0' => 'Toon alle artikelen',
+		'state_1' => 'Toon gelezen artikelen',
+		'state_2' => 'Toon ongelezen artikelen',
+		'state_3' => 'Toon alle artikelen',
+		'state_4' => 'Toon favoriete artikelen',
+		'state_5' => 'Toon gelezen favoriete artikelen',
+		'state_6' => 'Toon ongelezen favoriete artikelen',
+		'state_7' => 'Toon favoriete artikelen',
+		'state_8' => 'Toon niet favoriete artikelen',
+		'state_9' => 'Toon gelezen niet favoriete artikelen',
+		'state_10' => 'Toon ongelezen niet favoriete artikelen',
+		'state_11' => 'Toon niet favoriete artikelen',
+		'state_12' => 'Toon alle artikelen',
+		'state_13' => 'Toon gelezen artikelen',
+		'state_14' => 'Toon ongelezen artikelen',
+		'state_15' => 'Toon alle artikelen',
+		'title' => 'Gebruikers queries',
+	),
+	'profile' => array(
+		'_' => 'Profiel beheer',
+		'delete' => array(
+			'_' => 'Account verwijderen',
+			'warn' => 'Uw account en alle gerelateerde gegvens worden verwijderd.',
+		),
+		'password_api' => 'Wachtwoord API<br /><small>(e.g., voor mobiele apps)</small>',
+		'password_form' => 'Wachtwoord<br /><small>(voor de Web-formulier log in methode)</small>',
+		'password_format' => 'Ten minste 7 tekens',
+		'title' => 'Profiel',
+	),
+	'reading' => array(
+		'_' => 'Lezen',
+		'after_onread' => 'Na “markeer alles als gelezen”,',
+		'articles_per_page' => 'Aantal artikelen per pagina',
+		'auto_load_more' => 'Laad volgende artikel onderaan de pagina',
+		'auto_remove_article' => 'Verberg artikel na lezen',
+		'confirm_enabled' => 'Toon een bevestigings dialoog op “markeer alles als gelezen” acties',
+		'display_articles_unfolded' => 'Toon artikelen uitgeklapt als standaard',
+		'display_categories_unfolded' => 'Toon categoriën ingeklapt als standaard',
+		'hide_read_feeds' => 'Verberg categoriën en feeds zonder ongelezen artikelen (werkt niet met “Toon alle artikelen” configuratie)',
+		'img_with_lazyload' => 'Gebruik "lazy load" methode om afbeeldingen te laden',
+		'jump_next' => 'Ga naar volgende ongelezen (feed of categorie)',
+		'mark_updated_article_unread' => 'Markeer vernieuwd artikel als ongelezen',
+		'number_divided_when_reader' => 'Gedeeld door 2 in de lees modus.',
+		'read' => array(
+			'article_open_on_website' => 'Als het artikel is geopend op de originele website',
+			'article_viewed' => 'Als het artikel is bekeken',
+			'scroll' => 'Tijdens scrollen',
+			'upon_reception' => 'Tijdens ontvangst van het artikel',
+			'when' => 'Markeer artikel als gelezen…',
+		),
+		'show' => array(
+			'_' => 	'Artikelen om te tonen',
+			'adaptive' => 'Pas weergave aan',
+			'all_articles' => 'Bekijk alle artikelen',
+			'unread' => 'Bekijk alleen ongelezen',
+		),
+		'sort' => array(
+			'_' => 'Sorteer volgorde',
+			'newer_first' => 'Nieuwste eerst',
+			'older_first' => 'Oudste eerst',
+		),
+		'sticky_post' => 'Koppel artikel aan de bovenkant als het geopend wordt',
+		'title' => 'Lees modus',
+		'view' => array(
+			'default' => 'Standaard weergave',
+			'global' => 'Globale weergave',
+			'normal' => 'Normale weergave',
+			'reader' => 'Lees weergave',
+		),
+	),
+	'sharing' => array(
+		'_' => 'Delen',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'more_information' => 'Meer informatie',
+		'print' => 'Afdrukken',
+		'shaarli' => 'Shaarli',
+		'share_name' => 'Gedeelde naam om weer te geven',
+		'share_url' => 'Deel URL voor gebruik',
+		'title' => 'Delen',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag',
+	),
+	'shortcut' => array(
+		'_' => 'Shortcuts',
+		'article_action' => 'Artikel acties',
+		'auto_share' => 'Delen',
+		'auto_share_help' => 'Als er slechts één deel methode i, dan wordt deze gebruikt. Anders zijn ze toegankelijk met hun nummer.',
+		'close_dropdown' => 'Sluit menu',
+		'collapse_article' => 'Inklappen',
+		'first_article' => 'Spring naar eerste artikel',
+		'focus_search' => 'Toegang zoek venster',
+		'help' => 'Toon documentatie',
+		'javascript' => 'JavaScript moet geactiveerd zijn om verwijzingen te gebruiken',
+		'last_article' => 'Spring naar laatste artikel',
+		'load_more' => 'Laad meer artikelen',
+		'mark_read' => 'Markeer als gelezen',
+		'mark_favorite' => 'Markeer als favoriet',
+		'navigation' => 'Navigatie',
+		'navigation_help' => 'Met de "Shift" toets, kunt u navigatie verwijzingen voor feeds gebruiken.<br/>Met de "Alt" toets, kunt u navigatie verwijzingen voor categoriën gebruiken.',
+		'next_article' => 'Spring naar volgende artikel',
+		'other_action' => 'Andere acties',
+		'previous_article' => 'Spring naar vorige artikel',
+		'see_on_website' => 'Bekijk op originale website',
+		'shift_for_all_read' => '+ <code>shift</code> om alle artikelen als gelezen te markeren',
+		'title' => 'Verwijzingen',
+		'user_filter' => 'Toegang gebruikers filters',
+		'user_filter_help' => 'Als er slechts één gebruikers filter s, dan wordt deze gebruikt. Anders zijn ze toegankelijk met hun nummer.',
+	),
+	'user' => array(
+		'articles_and_size' => '%s artikelen (%s)',
+		'current' => 'Huidige gebruiker',
+		'is_admin' => 'is administrateur',
+		'users' => 'Gebruikers',
+	),
+);

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

@@ -0,0 +1,110 @@
+<?php
+/* Dutch translation by Wanabo. http://www.nieuwskop.be */
+return array(
+	'admin' => array(
+		'optimization_complete' => 'Optimalisatie compleet',
+	),
+	'access' => array(
+		'denied' => 'U hebt geen rechten om deze pagina te bekijken.',
+		'not_found' => 'Deze pagina bestaat niet',
+	),
+	'auth' => array(
+		'form' => array(
+			'not_set' => 'Een probleem is opgetreden tijdens de controle van de systeem configuratie. Probeer het later nog eens.',
+			'set' => 'Formulier is nu uw standaard authenticatie systeem.',
+		),
+		'login' => array(
+			'invalid' => 'Log in is ongeldig',
+			'success' => 'U bent ingelogd',
+		),
+		'logout' => array(
+			'success' => 'U bent uitgelogd',
+		),
+		'no_password_set' => 'Administrateur wachtwoord is niet ingesteld. Deze mogelijkheid is niet beschikbaar.',
+	),
+	'conf' => array(
+		'error' => 'Er is een fout opgetreden tijdens het opslaan van de configuratie',
+		'query_created' => 'Query "%s" is gemaakt.',
+		'shortcuts_updated' => 'Verwijzingen zijn vernieuwd',
+		'updated' => 'Configuratie is vernieuwd',
+	),
+	'extensions' => array(
+		'already_enabled' => '%s is al ingeschakeld',
+		'disable' => array(
+			'ko' => '%s kan niet worden uitgeschakeld. <a href="%s">Controleer FressRSS log bestanden</a> voor details.',
+			'ok' => '%s is nu uitgeschakeld',
+		),
+		'enable' => array(
+			'ko' => '%s kan niet worden ingeschakeld. <a href="%s">Controleer FressRSS log bestanden</a> voor details.',
+			'ok' => '%s is nn ingeschakeld',
+		),
+		'no_access' => 'U hebt geen toegang voor %s',
+		'not_enabled' => '%s is nog niet ingeschakeld',
+		'not_found' => '%s bestaat niet',
+	),
+	'import_export' => array(
+		'export_no_zip_extension' => 'ZIP uitbreiding is niet aanwezig op uw server. Exporteer a.u.b. uw bestanden één voor één.',
+		'feeds_imported' => 'Uw feeds zijn geimporteerd en worden nu vernieuwd',
+		'feeds_imported_with_errors' => 'Uw feeds zijn geimporteerd maar er zijn enige fouten opgetreden',
+		'file_cannot_be_uploaded' => 'Bestand kan niet worden verzonden!',
+		'no_zip_extension' => 'ZIP uitbreiding is niet aanwezig op uw server.',
+		'zip_error' => 'Er is een fout opgetreden tijdens het imporeren van het ZIP bestand.',
+	),
+	'sub' => array(
+		'actualize' => 'Actualiseren',
+		'category' => array(
+			'created' => 'Categorie %s is gemaakt.',
+			'deleted' => 'Categorie is verwijderd.',
+			'emptied' => 'Categorie is leeg gemaakt',
+			'error' => 'Categorie kan niet worden vernieuwd',
+			'name_exists' => 'Categorie naam bestaat al.',
+			'no_id' => 'U moet de id specificeren of de categorie.',
+			'no_name' => 'Categorie naam mag niet leeg zijn.',
+			'not_delete_default' => 'U kunt de standaard categorie niet verwijderen!',
+			'not_exist' => 'De categorie bestaat niet!',
+			'over_max' => 'U hebt het maximale aantal categoriën bereikt (%d)',
+			'updated' => 'Categorie is vernieuwd.',
+		),
+		'feed' => array(
+			'actualized' => '<em>%s</em> is vernieuwd',
+			'actualizeds' => 'RSS feeds zijn vernieuwd',
+			'added' => 'RSS feed <em>%s</em> is toegevoegd',
+			'already_subscribed' => 'U bent al geabonneerd op <em>%s</em>',
+			'deleted' => 'Feed is verwijderd',
+			'error' => 'Feed kan niet worden vernieuwd',
+			'internal_problem' => 'De RSS feed kon niet worden toegevoegd. <a href="%s">Controleer FressRSS log bestanden</a> voor details.',
+			'invalid_url' => 'URL <em>%s</em> is ongeldig',
+			'marked_read' => 'Feeds zijn gemarkeerd als gelezen',
+			'n_actualized' => '%d feeds zijn vernieuwd',
+			'n_entries_deleted' => '%d artikelen zijn verwijderd',
+			'no_refresh' => 'Er is geen feed om te vernieuwen…',
+			'not_added' => '<em>%s</em> kon niet worden toegevoegd',
+			'over_max' => 'U hebt het maximale aantal feeds bereikt(%d)',
+			'updated' => 'Feed is vernieuwd',
+		),
+		'purge_completed' => 'Opschonen klaar (%d artikelen verwijderd)',
+	),
+	'update' => array(
+		'can_apply' => 'FreshRSS word nu vernieud naar <strong>versie %s</strong>.',
+		'error' => 'Het vernieuwingsproces kwam een fout tegen: %s',
+		'file_is_nok' => 'Controleer permissies op <em>%s</em> map. HTTP server moet rechten hebben om er in te schrijven',
+		'finished' => 'Vernieuwing compleet!',
+		'none' => 'Geen vernieuwing om toe te passen',
+		'server_not_found' => 'Vernieuwings server kan niet worden gevonden. [%s]',
+	),
+	'user' => array(
+		'created' => array(
+			'_' => 'Gebruiker %s is aangemaakt',
+			'error' => 'Gebruiker %s kan niet worden aangemaakt',
+		),
+		'deleted' => array(
+			'_' => 'Gebruiker %s is verwijderd',
+			'error' => 'Gebruiker %s kan niet worden verwijderd',
+		),
+		'set_registration' => 'Het maximale aantal accounts is vernieuwd.',
+	),
+	'profile' => array(
+		'error' => 'Uw profiel kan niet worden aangepast',
+		'updated' => 'Uw profiel is aangepast',
+	),
+);

+ 182 - 0
app/i18n/nl/gen.php

@@ -0,0 +1,182 @@
+<?php
+/* Dutch translation by Wanabo. http://www.nieuwskop.be */
+return array(
+	'action' => array(
+		'actualize' => 'Actualiseren',
+		'back_to_rss_feeds' => '← Ga terug naar je RSS feeds',
+		'cancel' => 'Annuleren',
+		'create' => 'Opslaan',
+		'disable' => 'Uitzetten',
+		'empty' => 'Leeg',
+		'enable' => 'Aanzetten',
+		'export' => 'Exporteren',
+		'filter' => 'Filteren',
+		'import' => 'Importeren',
+		'manage' => 'Beheren',
+		'mark_read' => 'Markeer als gelezen',
+		'mark_favorite' => 'Markeer als favoriet',
+		'remove' => 'Verwijder',
+		'see_website' => 'Bekijk website',
+		'submit' => 'Opslaan',
+		'truncate' => 'Verwijder alle artikelen',
+	),
+	'auth' => array(
+		'email' => 'Email adres',
+		'keep_logged_in' => 'Ingelogd blijven voor <small>(%s dagen)</small>',
+		'login' => 'Log in',
+		'logout' => 'Log uit',
+		'password' => array(
+			'_' => 'Wachtwoord',
+			'format' => '<small>Ten minste 7 tekens</small>',
+		),
+		'registration' => array(
+			'_' => 'Nieuw account',
+			'ask' => 'Maak een account?',
+			'title' => 'Account maken',
+		),
+		'reset' => 'Authenticatie reset',
+		'username' => array(
+			'_' => 'Gebruikersnaam',
+			'admin' => 'Administrator gebruikersnaam',
+			'format' => '<small>maximaal 16 alphanumerieke tekens</small>',
+		),
+	),
+	'date' => array(
+		'Apr' => '\\A\\p\\r\\i\\l',
+		'Aug' => '\\A\\u\\g\\u\\s\\t\\u\\s',
+		'Dec' => '\\D\\e\\c\\e\\m\\b\\e\\r',
+		'Feb' => '\\F\\e\\b\\r\\u\\a\\r\\i',
+		'Jan' => '\\J\\a\\n\\u\\a\\r\\i',
+		'Jul' => '\\J\\u\\l\\i',
+		'Jun' => '\\J\\u\\n\\i',
+		'Mar' => '\\M\\a\\a\\r\\t',
+		'May' => '\\M\\e\\i',
+		'Nov' => '\\N\\o\\v\\e\\m\\b\\e\\r',
+		'Oct' => '\\O\\k\\t\\o\\b\\e\\r',
+		'Sep' => '\\S\\e\\p\\t\\e\\m\\b\\e\\r',
+		'apr' => 'apr',
+		'april' => 'Apr',
+		'aug' => 'aug',
+		'august' => 'Aug',
+		'before_yesterday' => 'Ouder',
+		'dec' => 'dec',
+		'december' => 'Dec',
+		'feb' => 'feb',
+		'february' => 'Feb',
+		'format_date' => 'j %s Y', //<-- European date format // 'format_date' => '%s j\\<\\s\\u\\p\\>S\\<\\/\\s\\u\\p\\> Y',
+		'format_date_hour' => 'j %s Y \\o\\m H\\:i', //<-- European date format // 'format_date_hour' => '%s j\\<\\s\\u\\p\\>S\\<\\/\\s\\u\\p\\> Y \\a\\t H\\:i',
+		'fri' => 'Vr',
+		'jan' => 'jan',
+		'january' => 'Jan',
+		'jul' => 'jul',
+		'july' => 'Jul',
+		'jun' => 'jun',
+		'june' => 'Jun',
+		'last_3_month' => 'Laatste drie maanden',
+		'last_6_month' => 'Laatste zes maanden',
+		'last_month' => 'Vorige maand',
+		'last_week' => 'Vorige week',
+		'last_year' => 'Vorig jaar',
+		'mar' => 'mar',
+		'march' => 'Mar',
+		'may' => 'Mei',
+		'mon' => 'Ma',
+		'month' => 'maanden',
+		'nov' => 'nov',
+		'november' => 'Nov',
+		'oct' => 'okt',
+		'october' => 'Okt',
+		'sat' => 'Za',
+		'sep' => 'sep',
+		'september' => 'Sep',
+		'sun' => 'Zo',
+		'thu' => 'Do',
+		'today' => 'Vandaag',
+		'tue' => 'Di',
+		'wed' => 'Wo',
+		'yesterday' => 'Gisteren',
+	),
+	'freshrss' => array(
+		'_' => 'FreshRSS',
+		'about' => 'Over FreshRSS',
+	),
+	'js' => array(
+		'category_empty' => 'Lege categorie',
+		'confirm_action' => 'Weet u zeker dat u dit wilt doen? Het kan niet ongedaan worden gemaakt!',
+		'confirm_action_feed_cat' => 'Weet u zeker dat u dit wilt doen? U verliest alle gereleteerde favorieten en gebruikers informatie. Het kan niet ongedaan worden gemaakt!',
+		'feedback' => array(
+			'body_new_articles' => 'Er zijn %%d nieuwe artikelen om te lezen op FreshRSS.',
+			'request_failed' => 'Een opdracht is mislukt, mogelijk door Internet verbindings problemen.',
+			'title_new_articles' => 'FreshRSS: nieuwe artikelen!',
+		),
+		'new_article' => 'Er zijn nieuwe artikelen beschikbaar, klik om de pagina te vernieuwen.',
+		'should_be_activated' => 'JavaScript moet aan staan',
+	),
+	'lang' => array(
+		'cz' => 'Čeština',
+		'de' => 'Deutsch',
+		'en' => 'English',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'nl' => 'Nederlands',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+	),
+	'menu' => array(
+		'about' => 'Over',
+		'admin' => 'Administratie',
+		'archiving' => 'Archiveren',
+		'authentication' => 'Authenticatie',
+		'check_install' => 'Installatie controle',
+		'configuration' => 'Configuratie',
+		'display' => 'Opmaak',
+		'extensions' => 'Uitbreidingen',
+		'logs' => 'Log boeken',
+		'queries' => 'Gebruikers informatie',
+		'reading' => 'Lezen',
+		'search' => 'Zoek woorden of #labels',
+		'sharing' => 'Delen',
+		'shortcuts' => 'Snelle toegang',
+		'stats' => 'Statistieken',
+		'system' => 'Systeem configuratie',
+		'update' => 'Versie controle',
+		'user_management' => 'Beheer gebruikers',
+		'user_profile' => 'Profiel',
+	),
+	'pagination' => array(
+		'first' => 'Eerste',
+		'last' => 'Laatste',
+		'load_more' => 'Laad meer artikelen',
+		'mark_all_read' => 'Markeer alle als gelezen',
+		'next' => 'Volgende',
+		'nothing_to_load' => 'Er zijn geen artikelen meer',
+		'previous' => 'Vorige',
+	),
+	'share' => array(
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'movim' => 'Movim',
+		'print' => 'Print',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',             
+		'jdh' => 'Journal du hacker',
+	),
+	'short' => array(
+		'attention' => 'Attentie!',
+		'blank_to_disable' => 'Laat leeg om uit te zetten',
+		'by_author' => 'Door <em>%s</em>',
+		'by_default' => 'Door standaard',
+		'damn' => 'Potverdorie!',
+		'default_category' => 'Niet ingedeeld',
+		'no' => 'Nee',
+		'not_applicable' => 'Niet aanwezig',
+		'ok' => 'Ok!',
+		'or' => 'of',
+		'yes' => 'Ja',
+	),
+);

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно