Procházet zdrojové kódy

Merge branch 'dev' into hebrew-i18n

Alexandre Alapetite před 8 roky
rodič
revize
fdc9e0d75a
100 změnil soubory, kde provedl 7776 přidání a 2470 odebrání
  1. 46 0
      .travis.yml
  2. 0 425
      CHANGELOG
  3. 800 0
      CHANGELOG.md
  4. 57 0
      CONTRIBUTING.md
  5. 0 42
      CREDITS
  6. 53 0
      CREDITS.md
  7. 117 33
      README.fr.md
  8. 128 35
      README.md
  9. 16 158
      app/Controllers/authController.php
  10. 2 3
      app/Controllers/categoryController.php
  11. 59 69
      app/Controllers/configureController.php
  12. 18 6
      app/Controllers/entryController.php
  13. 37 0
      app/Controllers/extensionController.php
  14. 315 196
      app/Controllers/feedController.php
  15. 259 148
      app/Controllers/importExportController.php
  16. 73 40
      app/Controllers/indexController.php
  17. 9 4
      app/Controllers/javascriptController.php
  18. 29 7
      app/Controllers/statsController.php
  19. 12 5
      app/Controllers/subscriptionController.php
  20. 129 60
      app/Controllers/updateController.php
  21. 186 113
      app/Controllers/userController.php
  22. 14 0
      app/Exceptions/AlreadySubscribedException.php
  23. 3 0
      app/Exceptions/BadUrlException.php
  24. 1 3
      app/Exceptions/ContextException.php
  25. 5 0
      app/Exceptions/DAOException.php
  26. 1 3
      app/Exceptions/EntriesGetterException.php
  27. 2 3
      app/Exceptions/FeedException.php
  28. 14 0
      app/Exceptions/FeedNotAddedException.php
  29. 14 0
      app/Exceptions/ZipException.php
  30. 4 0
      app/Exceptions/ZipMissingException.php
  31. 44 29
      app/FreshRSS.php
  32. 57 28
      app/Models/Auth.php
  33. 7 0
      app/Models/Category.php
  34. 17 10
      app/Models/CategoryDAO.php
  35. 33 25
      app/Models/ConfigurationSetter.php
  36. 30 161
      app/Models/Context.php
  37. 42 1
      app/Models/DatabaseDAO.php
  38. 80 0
      app/Models/DatabaseDAOPGSQL.php
  39. 14 1
      app/Models/DatabaseDAOSQLite.php
  40. 34 3
      app/Models/Entry.php
  41. 529 239
      app/Models/EntryDAO.php
  42. 49 0
      app/Models/EntryDAOPGSQL.php
  43. 110 20
      app/Models/EntryDAOSQLite.php
  44. 22 18
      app/Models/Factory.php
  45. 203 22
      app/Models/Feed.php
  46. 82 46
      app/Models/FeedDAO.php
  47. 0 19
      app/Models/FeedDAOSQLite.php
  48. 5 0
      app/Models/LogDAO.php
  49. 339 0
      app/Models/Search.php
  50. 6 0
      app/Models/Searchable.php
  51. 33 8
      app/Models/Share.php
  52. 43 78
      app/Models/StatsDAO.php
  53. 67 0
      app/Models/StatsDAOPGSQL.php
  54. 4 55
      app/Models/StatsDAOSQLite.php
  55. 3 9
      app/Models/Themes.php
  56. 49 18
      app/Models/UserDAO.php
  57. 226 0
      app/Models/UserQuery.php
  58. 66 9
      app/SQL/install.sql.mysql.php
  59. 87 0
      app/SQL/install.sql.pgsql.php
  60. 80 14
      app/SQL/install.sql.sqlite.php
  61. 9 15
      app/actualize_script.php
  62. 188 0
      app/i18n/cz/admin.php
  63. 174 0
      app/i18n/cz/conf.php
  64. 109 0
      app/i18n/cz/feedback.php
  65. 189 0
      app/i18n/cz/gen.php
  66. 61 0
      app/i18n/cz/index.php
  67. 119 0
      app/i18n/cz/install.php
  68. 77 0
      app/i18n/cz/sub.php
  69. 43 25
      app/i18n/de/admin.php
  70. 16 11
      app/i18n/de/conf.php
  71. 33 34
      app/i18n/de/feedback.php
  72. 38 12
      app/i18n/de/gen.php
  73. 2 2
      app/i18n/de/index.php
  74. 33 21
      app/i18n/de/install.php
  75. 17 1
      app/i18n/de/sub.php
  76. 43 25
      app/i18n/en/admin.php
  77. 27 22
      app/i18n/en/conf.php
  78. 9 10
      app/i18n/en/feedback.php
  79. 62 36
      app/i18n/en/gen.php
  80. 3 3
      app/i18n/en/index.php
  81. 30 18
      app/i18n/en/install.php
  82. 23 7
      app/i18n/en/sub.php
  83. 188 0
      app/i18n/es/admin.php
  84. 174 0
      app/i18n/es/conf.php
  85. 109 0
      app/i18n/es/feedback.php
  86. 190 0
      app/i18n/es/gen.php
  87. 61 0
      app/i18n/es/index.php
  88. 119 0
      app/i18n/es/install.php
  89. 66 0
      app/i18n/es/sub.php
  90. 41 23
      app/i18n/fr/admin.php
  91. 6 1
      app/i18n/fr/conf.php
  92. 4 5
      app/i18n/fr/feedback.php
  93. 39 12
      app/i18n/fr/gen.php
  94. 1 1
      app/i18n/fr/index.php
  95. 29 17
      app/i18n/fr/install.php
  96. 19 3
      app/i18n/fr/sub.php
  97. 188 0
      app/i18n/it/admin.php
  98. 174 0
      app/i18n/it/conf.php
  99. 109 0
      app/i18n/it/feedback.php
  100. 190 0
      app/i18n/it/gen.php

+ 46 - 0
.travis.yml

@@ -0,0 +1,46 @@
+language: php
+php:
+  - '5.4'
+  - '5.5'
+  - '5.6'
+  - '7.0'
+  - '7.1'
+  - hhvm
+  - nightly
+
+install:
+  # newest version without https://github.com/squizlabs/PHP_CodeSniffer/pull/1404
+  - pear install PHP_CodeSniffer-3.0.0RC4
+
+script:
+  - phpenv rehash
+  - |
+    if [[ $VALIDATE_STANDARD == yes ]]; then
+      phpcs . --standard=phpcs.xml --warning-severity=0 --extensions=php -p
+    fi
+  - |
+    if [[ $CHECK_TRANSLATION == yes ]]; then
+      php cli/check.translation.php -r
+    fi
+
+env:
+  - CHECK_TRANSLATION=no VALIDATE_STANDARD=yes
+
+matrix:
+  fast_finish: true
+  include:
+    - php: "5.3"
+      dist: precise
+    - php: "7.1"
+      env: CHECK_TRANSLATION=yes VALIDATE_STANDARD=no
+  allow_failures:
+    # PHP 5.3 only runs on Ubuntu 12.04 (precise), not 14.04 (trusty)
+    - php: "5.3"
+      dist: precise
+    - php: "5.4"
+    - php: "5.5"
+    - php: "5.6"
+    - php: "7.0"
+    - php: hhvm
+    - php: nightly
+    - env: CHECK_TRANSLATION=yes VALIDATE_STANDARD=no

+ 0 - 425
CHANGELOG

@@ -1,425 +0,0 @@
-# Changelog
-
-## 2015-xx-xx FreshRSS 1.1.1 (beta)
-
-## 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 not load 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 (didn't 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
-* 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

+ 800 - 0
CHANGELOG.md

@@ -0,0 +1,800 @@
+# Changelog
+
+## 2017-1X-XX FreshRSS 1.8.1-dev
+
+* API
+	* Breaking change / compatibility fix (EasyRSS): Provide `link` to articles without HTML-encoding [#1683](https://github.com/FreshRSS/FreshRSS/issues/1683)
+* Features
+	* Share with Mastodon [#1521](https://github.com/FreshRSS/FreshRSS/issues/1521)
+* UI
+	* Add more Unicode glyphs in the Open Sans font [#1032](https://github.com/FreshRSS/FreshRSS/pull/1032)
+	* Show URL to add subscriptions from third-party tools [#1247](https://github.com/FreshRSS/FreshRSS/issues/1247)
+	* Improved message when checking for new versions [#1586](https://github.com/FreshRSS/FreshRSS/issues/1586)
+* SimplePie
+	* Remove "SimplePie" name from HTTP User-Agent string [#1656](https://github.com/FreshRSS/FreshRSS/pull/1656)
+* Bug fixing
+	* Work-around for `CURLOPT_FOLLOWLOCATION` `open_basedir` bug in favicons and PubSubHubbub [#1655](https://github.com/FreshRSS/FreshRSS/issues/1655)
+	* Fix PDO PostgreSQL detection [#1690](https://github.com/FreshRSS/FreshRSS/issues/1690)
+	* Fix punycode warning in PHP 7.2 [#1699](https://github.com/FreshRSS/FreshRSS/issues/1699)
+* CLI
+	* New command `./cli/db-optimize.php` for database optimisation [#1583](https://github.com/FreshRSS/FreshRSS/issues/1583)
+	* Check PHP requirements before running `actualize_script.php` (cron for refreshing feeds) [#1711](https://github.com/FreshRSS/FreshRSS/pull/1711)
+* SQL
+	* Perform `VACUUM` on SQLite and PostgreSQL databases when optimisation is requested [#918](https://github.com/FreshRSS/FreshRSS/issues/918)
+* I18n
+	* Improved German [#1698](https://github.com/FreshRSS/FreshRSS/pull/1698)
+* Extensions
+	* Show existing extensions in admin panel [#1708](https://github.com/FreshRSS/FreshRSS/pull/1708)
+	* New function `$entry->_hash($hex)` for extensions that change the content of entries [#1707](https://github.com/FreshRSS/FreshRSS/pull/1707)
+* Misc.
+	* Basic mechanism to limit the size of the logs [#1712](https://github.com/FreshRSS/FreshRSS/pull/1712)
+	* Translation validation tool [#1653](https://github.com/FreshRSS/FreshRSS/pull/1653)
+	* Translation manipulation tool [#1658](https://github.com/FreshRSS/FreshRSS/pull/1658)
+
+
+## 2017-10-01 FreshRSS 1.8.0
+
+* Compatibility
+	* Minimal PHP version increased to PHP 5.3.8+ to fix sanitize bug [#1604](https://github.com/FreshRSS/FreshRSS/issues/1604)
+	* Add support for PHP 7.1 in the API [#1584](https://github.com/FreshRSS/FreshRSS/issues/1584), [#1594](https://github.com/FreshRSS/FreshRSS/pull/1594)
+* UI
+	* New page for subscription tools [#1534](https://github.com/FreshRSS/FreshRSS/issues/1354)
+	* Adjustments to the padding of the tree of categories and feeds [1589](https://github.com/FreshRSS/FreshRSS/pull/1589)
+	* Fix feed column position after lazy-loading images [#1616](https://github.com/FreshRSS/FreshRSS/pull/1616)
+	* Force UI controls for HTML5 video and audio [#1642](https://github.com/FreshRSS/FreshRSS/pull/1642)
+	* Fix share menu on small screens [#1645](https://github.com/FreshRSS/FreshRSS/pull/1645)
+	* Go back to previous view when collapsing article [#1177](https://github.com/FreshRSS/FreshRSS/issues/1177)
+* CLI
+	* New command `./cli/update-user.php` to update user settings [#1600](https://github.com/FreshRSS/FreshRSS/issues/1600)
+* I18n
+	* Korean [#1578](https://github.com/FreshRSS/FreshRSS/pull/1578)
+	* Portuguese (Brazilian) [#1648](https://github.com/FreshRSS/FreshRSS/pull/1648)
+	* Fix month abbreviations [#1560](https://github.com/FreshRSS/FreshRSS/issues/1560)
+* Bug fixing
+	* Fix API compatibility bug between PostgreSQL and EasyRSS [#1603](https://github.com/FreshRSS/FreshRSS/pull/1603)
+	* Fix PostgreSQL error when adding entries with duplicated GUID [#1610](https://github.com/FreshRSS/FreshRSS/issues/1610), [#1614](https://github.com/FreshRSS/FreshRSS/issues/1614)
+	* Fix for RSS feeds containing HTML in author field [#1590](https://github.com/FreshRSS/FreshRSS/issues/1590)
+	* Fix logout issue in global view due to CSRF [#1591](https://github.com/FreshRSS/FreshRSS/issues/1591)
+* Misc.
+	* Travis continuous integration [#1619](https://github.com/FreshRSS/FreshRSS/pull/1619)
+	* Allow longer database usernames [#1597](https://github.com/FreshRSS/FreshRSS/issues/1597)
+
+
+## 2017-06-03 FreshRSS 1.7.0
+
+* Features
+	* Deferred insertion of new articles, for better chronological order [#530](https://github.com/FreshRSS/FreshRSS/issues/530)
+	* Better search:
+		* Possibility to use multiple `intitle:`, `inurl:`, `author:` [#1478](https://github.com/FreshRSS/FreshRSS/pull/1478)
+		* Negative searches with `!` or `-` [#1381](https://github.com/FreshRSS/FreshRSS/issues/1381)
+			* Examples: `!intitle:unwanted`, `-intitle:unwanted`, `-inurl:unwanted`, `-author:unwanted`, `-#unwanted`, `-unwanted`
+		* Allow double-quotes, such as `author:"some name"`, in addition to single-quotes such as `author:'some name'` [#1478](https://github.com/FreshRSS/FreshRSS/pull/1478)
+	* Multi-user tokens (to access RSS outputs of any user) [#1390](https://github.com/FreshRSS/FreshRSS/issues/1390)
+* Compatibility
+	* Add support for PHP 7.1 [#1471](https://github.com/FreshRSS/FreshRSS/issues/1471)
+	* PostgreSQL is not experimental anymore [#1476](https://github.com/FreshRSS/FreshRSS/pull/1476)
+* Bug fixing
+	* Fix PubSubHubbub bugs when deleting users, and improved behaviour when removing feeds [#1495](https://github.com/FreshRSS/FreshRSS/pull/1495)
+	* Fix SQL uniqueness bug with PostgreSQL [#1476](https://github.com/FreshRSS/FreshRSS/pull/1476)
+		* (Require manual update for existing installations)
+	* Do not require PHP extension `fileinfo` for favicons [#1461](https://github.com/FreshRSS/FreshRSS/issues/1461)
+	* Fix UI lowest subscription popup hidden [#1479](https://github.com/FreshRSS/FreshRSS/issues/1479)
+	* Fix update system via ZIP archive [#1498](https://github.com/FreshRSS/FreshRSS/pull/1498)
+	* Work around for IE / Edge bug in username pattern in version 1.6.3 [#1511](https://github.com/FreshRSS/FreshRSS/issues/1511)
+	* Fix *mark as read* articles when adding a new feed [#1535](https://github.com/FreshRSS/FreshRSS/issues/1535)
+	* Change load order of CSS and JS to help CustomCSS and CustomJS extensions [Extensions#13](https://github.com/FreshRSS/Extensions/issues/13), [#1547](https://github.com/FreshRSS/FreshRSS/pull/1547)
+* UI
+	* New option for not closing the article when clicking outside its area [#1539](https://github.com/FreshRSS/FreshRSS/pull/1539)
+	* Add shortcut in reader view to open the original page [#1564](https://github.com/FreshRSS/FreshRSS/pull/1564)
+	* Download icon 💾 for other MIME types (e.g. `application/*`) [#1522](https://github.com/FreshRSS/FreshRSS/pull/1522)
+* I18n
+	* Simplified Chinese [#1541](https://github.com/FreshRSS/FreshRSS/pull/1541)
+	* Improve English [#1465](https://github.com/FreshRSS/FreshRSS/pull/1465)
+	* Improve Dutch [#1559](https://github.com/FreshRSS/FreshRSS/pull/1559)
+	* Added Spanish language [#1631] (https://github.com/FreshRSS/FreshRSS/pull/1631/) 
+* Security
+	* Do not require write access to check availability of new versions [#1450](https://github.com/FreshRSS/FreshRSS/issues/1450)
+* Misc.
+	* Move [documentation](./docs/) into FreshRSS code [#1510](https://github.com/FreshRSS/FreshRSS/pull/1510)
+	* Moved `./data/force-https.default.txt` to `./force-https.default.txt`,
+		`./data/config.default.php` to `./config.default.php`,
+		and `./data/users/_/config.default.php` to `./config-user.default.php` [#1531](https://github.com/FreshRSS/FreshRSS/issues/1531)
+	* Fall back to article URL when the article GUID is empty [#1482](https://github.com/FreshRSS/FreshRSS/issues/1482)
+	* Rewritten Favicon library using cURL [#1504](https://github.com/FreshRSS/FreshRSS/pull/1504)
+	* Fix SimplePie option to disable syslog [#1528](https://github.com/FreshRSS/FreshRSS/pull/1528)
+
+
+## 2017-03-11 FreshRSS 1.6.3
+
+* Features
+	* New option `disable_update` (also from CLI) to hide the system to update to new FreshRSS versions [#1436](https://github.com/FreshRSS/FreshRSS/pull/1436)
+	* Share with Ⓚnown [#1420](https://github.com/FreshRSS/FreshRSS/pull/1420)
+	* Share with GNU social [#1422](https://github.com/FreshRSS/FreshRSS/issues/1422)
+* UI
+	* New theme *Origine-compact* [#1388](https://github.com/FreshRSS/FreshRSS/pull/1388)
+	* Chrome parity with Firefox: auto-focus tab when clicking on notification [#1409](https://github.com/FreshRSS/FreshRSS/pull/1409)
+* CLI
+	* New command `./cli/reconfigure.php` to update an existing installation [#1439](https://github.com/FreshRSS/FreshRSS/pull/1439)
+	* Many CLI improvements [#1447](https://github.com/FreshRSS/FreshRSS/pull/1447)
+		* More information (number of feeds, articles, etc.) in `./cli/user-info.php`
+		* Better idempotency of `./cli/do-install.php` and language parameter [#1449](https://github.com/FreshRSS/FreshRSS/issues/1449) 
+* Bug fixing
+	* Fix several CLI issues [#1445](https://github.com/FreshRSS/FreshRSS/issues/1445)
+		* Fix CLI install bugs with SQLite [#1443](https://github.com/FreshRSS/FreshRSS/issues/1443), [#1448](https://github.com/FreshRSS/FreshRSS/issues/1448)
+		* Allow empty strings in CLI do-install [#1435](https://github.com/FreshRSS/FreshRSS/pull/1435)
+	* Fix PostgreSQL bugs with API and feed modifications [#1417](https://github.com/FreshRSS/FreshRSS/pull/1417)
+	* Do not mark as read in anonymous mode [#1431](https://github.com/FreshRSS/FreshRSS/issues/1431)
+	* Fix Favicons warnings [#59dfc64](https://github.com/FreshRSS/FreshRSS/commit/59dfc64512372eaba7609d84500d943bb7274399), [#1452](https://github.com/FreshRSS/FreshRSS/pull/1452)
+* Security
+	* Sanitize feed Web site URL [#1434](https://github.com/FreshRSS/FreshRSS/issues/1434)
+	* No version number for anonymous users [#1404](https://github.com/FreshRSS/FreshRSS/issues/1404)
+* Misc.
+	* Relaxed requirements for username to `/^[0-9a-zA-Z]|[0-9a-zA-Z_]{2,38}$/` [#1423](https://github.com/FreshRSS/FreshRSS/pull/1423)
+
+
+## 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)!

+ 0 - 42
CREDITS

@@ -1,42 +0,0 @@
-This is a credit file of people who have contributed to FreshRSS with, at least,
-one commit on the FreshRSS repository (at https://github.com/FreshRSS/FreshRSS).
-Please note a commit on THIS specific file is not considered as a contribution
-(too easy!). It's purpose is to show even the smallest contribution is important.
-People are sorted by name so please keep this order.
-
----
-
-Alexandre Alapetite
-https://github.com/Alkarex
-
-Alexis Degrugillier
-https://github.com/aledeg
-
-Alwaysin
-https://github.com/Alwaysin
-
-Amaury Carrade
-https://github.com/AmauryCarrade
-
-ealdraed
-https://github.com/ealdraed
-
-Luc Didry
-https://github.com/ldidry
-
-Marien Fressinaud
-dev@marienfressinaud.fr
-http://marienfressinaud.fr
-https://github.com/marienfressinaud
-
-Melvyn Laïly
-https://github.com/yaurthek
-
-Nicolas Elie
-https://github.com/nicolaselie
-
-plopoyop
-https://github.com/plopoyop
-
-tomgue
-https://github.com/tomgue

+ 53 - 0
CREDITS.md

@@ -0,0 +1,53 @@
+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.
+
+---
+
+* [Adrien Dorsaz](https://github.com/Trim): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Trim), [Web](https://adorsaz.ch/)
+* [Alexandre Alapetite](https://github.com/Alkarex): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Alkarex), [Web](https://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/)
+* [Anton Smirnov](https://github.com/sandfoxme): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:sandfoxme), [Web](http://sandfox.me/)
+* [ASMfreaK](https://github.com/ASMfreaK): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=ASMfreaK)
+* [Craig Andrews](https://github.com/candrews): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:candrews), [Web](http://candrews.integralblue.com/)
+* [Crupuk](https://github.com/Crupuk): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Crupuk)
+* [Damstre](https://github.com/Damstre): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Damstre)
+* [danc](https://github.com/danc): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=danc), [Web](http://tintouli.free.fr/)
+* [David Souza](https://github.com/araujo0205): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:araujo0205), [Web](http://davidsouza.tech/)
+* [dswd](https://github.com/dswd): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:dswd)
+* [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/)
+* [gsongsong](https://github.com/gsongsong): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:gsongsong)
+* [Guillaume Fillon](https://github.com/kokaz): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:kokaz), [Web](http://www.guillaume-fillon.com/)
+* [Guillaume Hayot](https://github.com/postblue): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:postblue), [Web](https://postblue.info/)
+* [hckweb](https://github.com/hckweb): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=hckweb)
+* [hoilc](https://github.com/hoilc): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:hoilc)
+* [Jaussoin Timothée](https://github.com/edhelas): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=edhelas), [Web](http://edhelas.movim.eu/)
+* [jlefler](https://github.com/jlefler): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:jlefler)
+* [Jonas Östanbäck](https://github.com/cez81): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=cez81)
+* [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](https://marienfressinaud.fr/)
+* [Melvyn Laïly](https://github.com/yaurthek): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=yaurthek), [Web](http://x2a.yt/)
+* [MSZ](https://github.com/mszkb): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=mszkb)
+* [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/)
+* [Olivier Dossmann](https://github.com/blankoworld): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=blankoworld), [Web](https://olivier.dossmann.net)
+* [plopoyop](https://github.com/plopoyop): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=plopoyop)
+* [Paulius Šukys](https://github.com/psukys): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:psukys), [Web](http://sukys.eu)
+* [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/)
+* [Ramón Cutanda](https://github.com/rcutanda): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:rcutanda)
+* [romibi](https://github.com/romibi): [contributions](https://github.com/FreshRSS/FreshRSS/commits/dev?author=romibi)
+* [subic](https://github.com/subic): [contributions](https://github.com/FreshRSS/documentation/commits?author=subic)
+* [Tets42](https://github.com/Tets42): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Tets42)
+* [Thomas Citharel](https://github.com/tcitworld): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:tomgue), [Web](https://www.tcit.fr/)
+* [tomgue](https://github.com/tomgue): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=tomgue)
+* [Wanabo](https://github.com/Wanabo): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Wanabo)

+ 117 - 33
README.fr.md

@@ -6,51 +6,108 @@ 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 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 : https://freshrss.org
 * Démo : http://demo.freshrss.org/
 * 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](./docs/img/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).
 
+## À propos des branches
 * Utilisez [la branche master](https://github.com/FreshRSS/FreshRSS/tree/master/) si vous visez la stabilité.
-* [La branche beta](https://github.com/FreshRSS/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/FreshRSS/FreshRSS/tree/dev) vous ouvre les bras !
+* 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/FreshRSS/FreshRSS/issues).
+# 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
-	* 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)
-* 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.8+ (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), [session](http://php.net/session),  [ctype](http://php.net/ctype), 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 9.2+
+* Un navigateur Web récent tel Firefox, Internet Explorer 11 / Edge, Chrome, Opera, Safari.
 	* Fonctionne aussi sur mobile
 
-![Capture d’écran de FreshRSS](http://marienfressinaud.fr/data/images/freshrss/freshrss_default-design.png)
+![Capture d’écran de FreshRSS](./docs/img/FreshRSS-screenshot.png)
 
-# Installation
-1. Récupérez l’application FreshRSS via la commande git ou [en téléchargeant l’archive](https://github.com/FreshRSS/FreshRSS/archive/master.zip)
+# Documentation
+* https://freshrss.github.io/FreshRSS/fr/
+
+# [Installation](https://freshrss.github.io/FreshRSS/fr/users/01_Installation.html)
+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/`)
 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
-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 vues dans [config.default.php](./config.default.php) et modifiées dans `data/config.php`.
+7. Avec Apache, activer [`AllowEncodedSlashes`](http://httpd.apache.org/docs/trunk/mod/core.html#allowencodedslashes) pour une meilleure compatibilité avec les clients mobiles.
+
+## 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 :
-* 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 l’identification par formulaire (requiert JavaScript, et PHP 5.5+ recommandé)
 * 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)
 		* Créer dans ce cas un fichier `./p/i/.htaccess` avec un fichier `.htpasswd` correspondant.
@@ -62,30 +119,47 @@ 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 :
 
 ```
-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
 * 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/`.
 * 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/users/*/log*.txt`.
+	* Le répertoire spécial `./data/users/_/` contient la partie des logs partagés par tous les utilisateurs.
+
 
 # Sauvegarde
-* Il faut conserver vos fichiers `./data/config.php` ainsi que `./data/*_user.php` et éventuellement `./data/persona/`
-* 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 :
+* Il faut conserver vos fichiers `./data/config.php` ainsi que `./data/users/*/config.php`
+* Vous pouvez exporter votre liste de flux au format OPML 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
-mysqldump -u utilisateur -p --databases freshrss > freshrss.sql
+mysqldump --skip-comments --disable-keys --user=<db_user> --password --host <db_host> --result-file=freshrss.dump.sql --databases <freshrss_db>
 ```
 
 
+# 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
 * [SimplePie](http://simplepie.org/)
 * [MINZ](https://github.com/marienfressinaud/MINZ)
-* [php-http-304](http://alexandre.alapetite.fr/doc-alex/php-http-304/)
+* [php-http-304](https://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [jQuery](http://jquery.com/)
+* [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/)
 * [flotr2](http://www.humblesoftware.com/flotr2)
 
@@ -96,3 +170,13 @@ mysqldump -u utilisateur -p --databases freshrss > freshrss.sql
 ## Si les fonctions natives ne sont pas disponibles
 * [Services_JSON](http://pear.php.net/pepr/pepr-proposal-show.php?id=198)
 * [password_compat](https://github.com/ircmaxell/password_compat)
+
+
+# [Clients compatibles](https://freshrss.github.io/FreshRSS/en/users/06_Mobile_access.html)
+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)

+ 128 - 35
README.md

@@ -1,91 +1,171 @@
+[![Build Status][travis-badge]][travis-link]
+
 * [Version française](README.fr.md)
 
 # 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 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
 * Demo: http://demo.freshrss.org/
 * 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](./docs/img/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).
 
+## About branches
 * Use [the master branch](https://github.com/FreshRSS/FreshRSS/tree/master/) if you need a stable version.
-* [The beta branch](https://github.com/FreshRSS/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/FreshRSS/FreshRSS/tree/dev) is waiting for you!
+* 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
-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/FreshRSS/FreshRSS/issues).
+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
 * 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) or 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.8+ (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), [session](http://php.net/session), [ctype](http://php.net/ctype), 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 9.2+
+* A recent browser like Firefox, Internet Explorer 11 / Edge, Chrome, Opera, Safari.
 	* Works on mobile
 
-![FreshRSS screenshot](http://marienfressinaud.fr/data/images/freshrss/freshrss_default-design.png)
+![FreshRSS screenshot](./docs/img/FreshRSS-screenshot.png)
+
+# Documentation
+* https://freshrss.github.io/FreshRSS/en/
 
-# Installation
+# [Installation](https://freshrss.github.io/FreshRSS/en/users/01_Installation.html)
 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)
 3. Add write access on `./data/` folder to the webserver user
 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.default.php](./config.default.php) and modified in `data/config.php`.
+7. When using Apache, enable [`AllowEncodedSlashes`](http://httpd.apache.org/docs/trunk/mod/core.html#allowencodedslashes) for better compatibility with mobile clients.
+
+More information about installation and server configuration can be found in [our documentation](https://freshrss.github.io/FreshRSS/en/admins/02_Installation.html). 
+
+## Automated install
+* [![Install on Cloudron](https://cloudron.io/img/button.svg)](https://cloudron.io/button.html?app=org.freshrss.cloudronapp)
+* [![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/
+```
+See more commands and git commands in the [Command-Line Interface documentation](./cli/README.md).
 
-# Access control
+## Access control
 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 [Mozilla Persona](https://login.persona.org/about) authentication included in FreshRSS
+* use form authentication (needs JavaScript, and PHP 5.5+ recommended)
 * use HTTP authentication supported by your web server
 	* 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.
 
-# Automatic feed update
+## Automatic feed update
 * 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)…).
-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
-* 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 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 accessibles from the interface or manually in `./data/log/*.log` files.
+* If you encounter any problem, logs are accessible from the interface or manually in `./data/users/*/log*.txt` files.
+	* The special folder `./data/users/_/` contains the part of the logs that are shared by all users.
+
 
 # Backup
-* You need to keep `./data/config.php`, `./data/*_user.php` and `./data/persona/` files
-* You can export your feed list in OPML format from FreshRSS
+* You need to keep `./data/config.php`, and `./data/users/*/config.php` files
+* You can export your feed list in OPML format 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:
 
 ```bash
-mysqldump -u user -p --databases freshrss > freshrss.sql
+mysqldump --skip-comments --disable-keys --user=<db_user> --password --host <db_host> --result-file=freshrss.dump.sql --databases <freshrss_db>
 ```
 
 
+# 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
 * [SimplePie](http://simplepie.org/)
 * [MINZ](https://github.com/marienfressinaud/MINZ)
-* [php-http-304](http://alexandre.alapetite.fr/doc-alex/php-http-304/)
+* [php-http-304](https://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [jQuery](http://jquery.com/)
+* [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/)
 * [flotr2](http://www.humblesoftware.com/flotr2)
 
@@ -96,3 +176,16 @@ mysqldump -u user -p --databases freshrss > freshrss.sql
 ## If native functions are not available
 * [Services_JSON](http://pear.php.net/pepr/pepr-proposal-show.php?id=198)
 * [password_compat](https://github.com/ircmaxell/password_compat)
+
+
+# [Compatible clients](https://freshrss.github.io/FreshRSS/en/users/06_Mobile_access.html)
+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)
+
+[travis-badge]:https://travis-ci.org/FreshRSS/FreshRSS.svg?branch=master
+[travis-link]:https://travis-ci.org/FreshRSS/FreshRSS

+ 16 - 158
app/Controllers/authController.php

@@ -27,11 +27,6 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 		if (Minz_Request::isPost()) {
 			$ok = true;
 
-			$current_token = FreshRSS_Context::$user_conf->token;
-			$token = Minz_Request::param('token', $current_token);
-			FreshRSS_Context::$user_conf->token = $token;
-			$ok &= FreshRSS_Context::$user_conf->save();
-
 			$anon = Minz_Request::param('anon_access', false);
 			$anon = ((bool)$anon) && ($anon !== 'no');
 			$anon_refresh = Minz_Request::param('anon_refresh', false);
@@ -70,7 +65,7 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 	/**
 	 * 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.
 	 */
 	public function loginAction() {
@@ -83,9 +78,6 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 		case 'form':
 			Minz_Request::forward(array('c' => 'auth', 'a' => 'formLogin'));
 			break;
-		case 'persona':
-			Minz_Request::forward(array('c' => 'auth', 'a' => 'personaLogin'));
-			break;
 		case 'http_auth':
 		case 'none':
 			// It should not happened!
@@ -116,15 +108,19 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 		$file_mtime = @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js');
 		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()) {
 			$nonce = Minz_Session::param('nonce');
 			$username = Minz_Request::param('username', '');
 			$challenge = Minz_Request::param('challenge', '');
 
 			$conf = get_user_configuration($username);
-			if (is_null($conf)) {
-				Minz_Request::bad(_t('feedback.auth.login.invalid'),
-				                  array('c' => 'auth', 'a' => 'login'));
+			if ($conf == null) {
+				Minz_Error::error(403, array(_t('feedback.auth.login.invalid')), false);
+				return;
 			}
 
 			$ok = FreshRSS_FormAuth::checkCredentials(
@@ -151,8 +147,7 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 				                  ' user=' . $username .
 				                  ', nonce=' . $nonce .
 				                  ', c=' . $challenge);
-				Minz_Request::bad(_t('feedback.auth.login.invalid'),
-				                  array('c' => 'auth', 'a' => 'login'));
+				Minz_Error::error(403, array(_t('feedback.auth.login.invalid')), false);
 			}
 		} elseif (FreshRSS_Context::$system_conf->unsafe_autologin_enabled) {
 			$username = Minz_Request::param('u', '');
@@ -164,7 +159,7 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 			}
 
 			$conf = get_user_configuration($username);
-			if (is_null($conf)) {
+			if ($conf == null) {
 				return;
 			}
 
@@ -184,84 +179,8 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 				                   array('c' => 'index', 'a' => 'index'));
 			} else {
 				Minz_Log::warning('Unsafe password mismatch for user ' . $username);
-				Minz_Request::bad(_t('feedback.auth.login.invalid'),
-				                  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);
-						$conf = get_user_configuration($current_user);
-						if (!is_null($conf)) {
-							$login_ok = strcasecmp($email, $conf->mail_login) === 0;
-						} else {
-							$reason = 'Invalid configuration for user ' .
-							          '[' . $current_user . ']';
-						}
-					}
-				} 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('feedback.auth.login.invalid');
-			}
-
-			header('Content-Type: application/json; charset=UTF-8');
-			$this->view->res = $res;
 		}
 	}
 
@@ -276,74 +195,13 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 	}
 
 	/**
-	 * 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('admin.auth.title_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 (FreshRSS_Context::$system_conf->auth_type != 'persona') {
-			$this->view->message = array(
-				'status' => 'bad',
-				'title' => _t('gen.short.damn'),
-				'body' => _t('feedback.auth.not_persona')
-			);
-			$this->view->no_form = true;
-			return;
-		}
-
-		$conf = get_user_configuration(FreshRSS_Context::$system_conf->default_user);
-		if (is_null($conf)) {
-			return;
-		}
-
-		// Admin user must have set its master password.
-		if (!$conf->passwordHash) {
-			$this->view->message = array(
-				'status' => 'bad',
-				'title' => _t('gen.short.damn'),
-				'body' => _t('feedback.auth.no_password_set')
-			);
-			$this->view->no_form = true;
-			return;
+	public function registerAction() {
+		if (max_registrations_reached()) {
+			Minz_Error::error(403);
 		}
 
-		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) {
-				FreshRSS_Context::$system_conf->auth_type = 'form';
-				$ok = FreshRSS_Context::$system_conf->save();
-
-				if ($ok) {
-					Minz_Request::good(_t('feedback.auth.form.set'));
-				} else {
-					Minz_Request::bad(_t('feedback.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('feedback.auth.login.invalid'),
-				                  array('c' => 'auth', 'a' => 'reset'));
-			}
-		}
+		Minz_View::prependTitle(_t('gen.auth.registration.title') . ' · ');
 	}
 }

+ 2 - 3
app/Controllers/categoryController.php

@@ -117,7 +117,6 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 	public function deleteAction() {
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$catDAO = new FreshRSS_CategoryDAO();
-		$default_category = $catDAO->getDefault();
 		$url_redirect = array('c' => 'subscription', 'a' => 'index');
 
 		if (Minz_Request::isPost()) {
@@ -128,11 +127,11 @@ class FreshRSS_category_Controller extends Minz_ActionController {
 				Minz_Request::bad(_t('feedback.sub.category.no_id'), $url_redirect);
 			}
 
-			if ($id === $default_category->id()) {
+			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) {
+			if ($feedDAO->changeCategory($id, FreshRSS_CategoryDAO::DEFAULTCATEGORYID) === false) {
 				Minz_Request::bad(_t('feedback.sub.category.error'), $url_redirect);
 			}
 

+ 59 - 69
app/Controllers/configureController.php

@@ -109,9 +109,11 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 			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->sides_close_article = Minz_Request::param('sides_close_article', 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),
@@ -138,7 +140,7 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 */
 	public function sharingAction() {
 		if (Minz_Request::isPost()) {
-			$params = Minz_Request::params();
+			$params = Minz_Request::fetchPOST();
 			FreshRSS_Context::$user_conf->sharing = $params['share'];
 			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();
@@ -223,10 +225,12 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		$this->view->nb_total = $entryDAO->count();
-		$this->view->size_user = $entryDAO->size();
+
+		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
+		$this->view->size_user = $databaseDAO->size();
 
 		if (FreshRSS_Auth::hasAccess('admin')) {
-			$this->view->size_total = $entryDAO->size(true);
+			$this->view->size_total = $databaseDAO->size(true);
 		}
 	}
 
@@ -241,13 +245,16 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 * checking if categories and feeds are still in use.
 	 */
 	public function queriesAction() {
+		$category_dao = new FreshRSS_CategoryDAO();
+		$feed_dao = FreshRSS_Factory::createFeedDao();
 		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']) {
 					$query['name'] = _t('conf.query.number', $key + 1);
 				}
+				$queries[] = new FreshRSS_UserQuery($query, $feed_dao, $category_dao);
 			}
 			FreshRSS_Context::$user_conf->queries = $queries;
 			FreshRSS_Context::$user_conf->save();
@@ -255,62 +262,9 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 			Minz_Request::good(_t('feedback.conf.updated'),
 			                   array('c' => 'configure', 'a' => 'queries'));
 		} else {
-			$this->view->query_get = array();
-			$cat_dao = new FreshRSS_CategoryDAO();
-			$feed_dao = FreshRSS_Factory::createFeedDao();
+			$this->view->queries = array();
 			foreach (FreshRSS_Context::$user_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[$key] = new FreshRSS_UserQuery($query, $feed_dao, $category_dao);
 			}
 		}
 
@@ -325,20 +279,56 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 * lean data.
 	 */
 	public function addQueryAction() {
-		$whitelist = array('get', 'order', 'name', 'search', 'state');
-		$queries = FreshRSS_Context::$user_conf->queries;
-		$query = Minz_Request::params();
-		$query['name'] = _t('conf.query.number', count($queries) + 1);
-		foreach ($query as $key => $value) {
-			if (!in_array($key, $whitelist)) {
-				unset($query[$key]);
-			}
+		$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;
+		$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('feedback.conf.query_created', $query['name']),
 		                   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')
+			));
+		}
+	}
 }

+ 18 - 6
app/Controllers/entryController.php

@@ -40,12 +40,24 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 		$get = Minz_Request::param('get');
 		$next_get = Minz_Request::param('nextGet', $get);
 		$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();
 		if ($id === false) {
 			// id is false? It MUST be a POST request!
 			if (!Minz_Request::isPost()) {
+				Minz_Request::bad(_t('feedback.access.not_found'), array('c' => 'index', 'a' => 'index'));
 				return;
 			}
 
@@ -57,16 +69,16 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 				$get = substr($get, 2);
 				switch($type_get) {
 				case 'c':
-					$entryDAO->markReadCat($get, $id_max);
+					$entryDAO->markReadCat($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state);
 					break;
 				case 'f':
-					$entryDAO->markReadFeed($get, $id_max);
+					$entryDAO->markReadFeed($get, $id_max, FreshRSS_Context::$search, FreshRSS_Context::$state);
 					break;
 				case 's':
-					$entryDAO->markReadEntries($id_max, true);
+					$entryDAO->markReadEntries($id_max, true, 0, FreshRSS_Context::$search);
 					break;
 				case 'a':
-					$entryDAO->markReadEntries($id_max);
+					$entryDAO->markReadEntries($id_max, false, 0, FreshRSS_Context::$search, FreshRSS_Context::$state);
 					break;
 				}
 
@@ -135,8 +147,8 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 
 		@set_time_limit(300);
 
-		$entryDAO = FreshRSS_Factory::createEntryDao();
-		$entryDAO->optimizeTable();
+		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
+		$databaseDAO->optimize();
 
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$feedDAO->updateCachedValues();

+ 37 - 0
app/Controllers/extensionController.php

@@ -25,10 +25,47 @@ class FreshRSS_extension_Controller extends Minz_ActionController {
 			'user' => array(),
 		);
 
+		$this->view->extensions_installed = array();
+
 		$extensions = Minz_ExtensionManager::listExtensions();
 		foreach ($extensions as $ext) {
 			$this->view->extension_list[$ext->getType()][] = $ext;
+			$this->view->extensions_installed[$ext->getEntrypoint()] = $ext->getVersion();
+		}
+
+		$availableExtensions = $this->getAvailableExtensionList();
+		$this->view->available_extensions = $availableExtensions;
+	}
+
+	/**
+	 * fetch extension list from GitHub
+	 */
+	protected function getAvailableExtensionList() {
+		$extensionListUrl = 'https://raw.githubusercontent.com/FreshRSS/Extensions/master/extensions.json';
+		$json = file_get_contents($extensionListUrl);
+
+		// we ran into problems, simply ignore them
+		if ($json === false) {
+			Minz_Log::error('Could not fetch available extension from GitHub');
+			return array();
+		}
+
+		// fetch the list as an array
+		$list = json_decode($json, true);
+		if (empty($list)) {
+			Minz_Log::warning('Failed to convert extension file list');
+			return array();
 		}
+
+		// we could use that for comparing and caching later
+		$version = $list['version'];
+
+		// By now, all the needed data is kept in the main extension file.
+		// In the future we could fetch detail information from the extensions metadata.json, but I tend to stick with
+		// the current implementation for now, unless it becomes too much effort maintain the extension list manually
+		$extensions = $list['extensions'];
+
+		return $extensions;
 	}
 
 	/**

+ 315 - 196
app/Controllers/feedController.php

@@ -26,6 +26,63 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 	}
 
+	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.
 	 *
@@ -59,7 +116,6 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 
 		$feedDAO = FreshRSS_Factory::createFeedDao();
-		$this->catDAO = new FreshRSS_CategoryDAO();
 		$url_redirect = array(
 			'c' => 'subscription',
 			'a' => 'index',
@@ -74,133 +130,44 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 
 		if (Minz_Request::isPost()) {
-			@set_time_limit(300);
-
 			$cat = Minz_Request::param('category');
+			$new_cat_name = '';
 			if ($cat === 'nc') {
 				// User want to create a new category, new_category parameter
 				// must exist
 				$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 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 = '';
-			if ($user != '' || $pass != '') {
+			if ($user != '' && $pass != '') {	//TODO: Sanitize
 				$http_auth = $user . ':' . $pass;
 			}
 
-			$transaction_started = false;
 			try {
-				$feed = new FreshRSS_Feed($url);
+				$feed = self::addFeed($url, '', $cat, $new_cat_name, $http_auth);
 			} catch (FreshRSS_BadUrl_Exception $e) {
 				// Given url was not a valid url!
 				Minz_Log::warning($e->getMessage());
 				Minz_Request::bad(_t('feedback.sub.feed.invalid_url', $url), $url_redirect);
-			}
-
-			try {
-				$feed->load(true);
 			} catch (FreshRSS_Feed_Exception $e) {
 				// Something went bad (timeout, server not found, etc.)
 				Minz_Log::warning($e->getMessage());
-				Minz_Request::bad(
-					_t('feedback.sub.feed.internal_problem', _url('index', 'logs')),
-					$url_redirect
-				);
+				Minz_Request::bad(_t('feedback.sub.feed.internal_problem', _url('index', 'logs')), $url_redirect);
 			} catch (Minz_FileNotExistException $e) {
 				// Cache directory doesn't exist!
 				Minz_Log::error($e->getMessage());
-				Minz_Request::bad(
-					_t('feedback.sub.feed.internal_problem', _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('feedback.sub.feed.already_subscribed', $feed->name()),
-					$url_redirect
-				);
-			}
-
-			$feed->_category($cat);
-			$feed->_httpAuth($http_auth);
-
-			// Call the extension hook
-			$name = $feed->name();
-			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
-			if (is_null($feed)) {
-				Minz_Request::bad(_t('feed_not_added', $name), $url_redirect);
-			}
-
-			$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('feedback.sub.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::$user_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::$user_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.
-				$entry->_feed($feed->id());
-				$entry->_id(min(time(), $entry->date(true)) . uSecString());
-				$entry->_isRead($is_read);
-
-				$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
-				if (is_null($entry)) {
-					// An extension has returned a null value, there is nothing to insert.
-					continue;
-				}
-
-				$values = $entry->toArray();
-				$entryDAO->addEntry($values, $prepared_statement);
-			}
-			$feedDAO->updateLastUpdate($feed->id());
-			$feedDAO->commit();
-
 			// Entries are in DB, we redirect to feed configuration page.
 			$url_redirect['params']['id'] = $feed->id();
 			Minz_Request::good(_t('feedback.sub.feed.added', $feed->name()), $url_redirect);
@@ -208,6 +175,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			// GET request: we must ask confirmation to user before adding feed.
 			Minz_View::prependTitle(_t('sub.feed.title_add') . ' · ');
 
+			$this->catDAO = new FreshRSS_CategoryDAO();
 			$this->view->categories = $this->catDAO->listCategories(false);
 			$this->view->feed = new FreshRSS_Feed($url);
 			try {
@@ -258,137 +226,217 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 	}
 
-	/**
-	 * 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, $noCommit = false) {
 		@set_time_limit(300);
 
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$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.
-		// 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.
 		$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) {
 				$feeds[] = $feed;
 			}
 		} else {
-			$feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$user_conf->ttl_default);
+			$feeds = $feedDAO->listFeedsOrderUpdate(-1);
 		}
 
 		// Calculate date of oldest entries we accept in DB.
 		$nb_month_old = max(FreshRSS_Context::$user_conf->old_entries, 1);
 		$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;
+		$nb_new_articles = 0;
 		$is_read = FreshRSS_Context::$user_conf->mark_when['reception'] ? 1 : 0;
 		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);
+				//Minz_Log::debug($text, PSHB_LOG);
+				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()) {
 				Minz_Log::notice('Feed already being actualized: ' . $feed->url());
 				continue;
 			}
 
 			try {
-				// Load entries
-				$feed->load(false);
+				if ($simplePiePush) {
+					$feed->loadEntries($simplePiePush);	//Used by PubSubHubbub
+				} else {
+					$feed->load(false, $isNewFeed);
+				}
 			} 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;
 			}
 
-			$url = $feed->url();
 			$feed_history = $feed->keepHistory();
-			if ($feed_history == -2) {
+			if ($isNewFeed) {
+				$feed_history = -1; //∞
+			} elseif ($feed_history == -2) {
 				// TODO: -2 must be a constant!
 				// -2 means we take the default value from configuration
 				$feed_history = FreshRSS_Context::$user_conf->keep_history_default;
 			}
+			$needFeedCacheRefresh = false;
 
 			// We want chronological order and SimplePie uses reverse order.
 			$entries = array_reverse($feed->entries());
 			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);
+				$newGuids = array();
 
+				$oldGuids = array();
 				// Add entries in database if possible.
-				$prepared_statement = $entryDAO->addEntryPrepare();
-				$feedDAO->beginTransaction();
 				foreach ($entries as $entry) {
-					$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;
+					if (isset($newGuids[$entry->guid()])) {
+						continue;	//Skip subsequent articles with same GUID
 					}
+					$newGuids[$entry->guid()] = true;
 
-					$id = uTimeString();
-					if ($use_declared_date || $entry_date < $date_min) {
-						// Use declared date at first import.
-						$id = min(time(), $entry_date) . uSecString();
-					}
-
-					$entry->_id($id);
-					$entry->_isRead($is_read);
-
-					$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
-					if (is_null($entry)) {
-						// An extension has returned a null value, there is nothing to insert.
-						continue;
+					$entry_date = $entry->date(true);
+					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.
+							$needFeedCacheRefresh = FreshRSS_Context::$user_conf->mark_updated_article_unread;
+							$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();
+							$entry->_isRead($is_read);
+						} 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();
+							Minz_Log::warning($text, PSHB_LOG);
+							Minz_Log::warning($text);
+							$pubSubHubbubEnabled = false;
+							$feed->pubSubHubbubError(true);
+						}
+
+						if (!$entryDAO->inTransaction()) {
+							$entryDAO->beginTransaction();
+						}
+						$entryDAO->addEntry($entry->toArray());
+						$nb_new_articles++;
 					}
-
-					$values = $entry->toArray();
-					$entryDAO->addEntry($values, $prepared_statement);
 				}
+				$entryDAO->updateLastSeen($feed->id(), $oldGuids, $mtime);
 			}
 
 			if ($feed_history >= 0 && rand(0, 30) === 1) {
 				// TODO: move this function in web cron when available (see entry::purge)
 				// Remove old entries once in 30.
-				if (!$feedDAO->hasTransaction()) {
-					$feedDAO->beginTransaction();
+				if (!$entryDAO->inTransaction()) {
+					$entryDAO->beginTransaction();
 				}
 
 				$nb = $feedDAO->cleanOldEntries($feed->id(),
 				                                $date_min,
 				                                max($feed_history, count($entries) + 10));
 				if ($nb > 0) {
+					$needFeedCacheRefresh = true;
 					Minz_Log::debug($nb . ' old entries cleaned in feed [' .
 					                $feed->url() . ']');
 				}
 			}
 
-			$feedDAO->updateLastUpdate($feed->id(), 0, $feedDAO->hasTransaction());
-			if ($feedDAO->hasTransaction()) {
-				$feedDAO->commit();
+			$feedDAO->updateLastUpdate($feed->id(), false, $mtime);
+			if ($needFeedCacheRefresh) {
+				$feedDAO->updateCachedValue($feed->id());
+			}
+			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());
 				$feedDAO->updateFeed($feed->id(), array('url' => $feed->url()));
 			}
 
 			$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();
 			$updated_feeds++;
 			unset($feed);
@@ -399,6 +447,48 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 				break;
 			}
 		}
+		if (!$noCommit) {
+			if (!$entryDAO->inTransaction()) {
+				$entryDAO->beginTransaction();
+			}
+			$entryDAO->commitNewEntries();
+			$feedDAO->updateCachedValues();
+			if ($entryDAO->inTransaction()) {
+				$entryDAO->commit();
+			}
+		}
+		return array($updated_feeds, reset($feeds), $nb_new_articles);
+	}
+
+	/**
+	 * This action actualizes entries from one or several feeds.
+	 *
+	 * Parameters are:
+	 *   - id (default: false): Feed ID
+	 *   - url (default: false): Feed URL
+	 *   - force (default: false)
+	 *   - noCommit (default: 0): Set to 1 to prevent committing the new articles to the main database
+	 * 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');
+		$noCommit = Minz_Request::fetchPOST('noCommit', 0) == 1;
+
+		if ($id == -1 && !$noCommit) {	//Special request only to commit & refresh DB cache
+			$updated_feeds = 0;
+			$entryDAO = FreshRSS_Factory::createEntryDao();
+			$feedDAO = FreshRSS_Factory::createFeedDao();
+			$entryDAO->beginTransaction();
+			$entryDAO->commitNewEntries();
+			$feedDAO->updateCachedValues();
+			$entryDAO->commit();
+		} else {
+			list($updated_feeds, $feed, $nb_new_articles) = self::actualizeFeed($id, $url, $force, null, false, $noCommit);
+		}
 
 		if (Minz_Request::param('ajax')) {
 			// Most of the time, ajax request is for only one feed. But since
@@ -411,20 +501,51 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			Minz_Session::_param('notification', $notif);
 			// No layout in ajax request.
 			$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('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());
+	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));
 	}
 
 	/**
@@ -447,21 +568,11 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		$feed_id = Minz_Request::param('f_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
+			// Log a notice to prevent "Empty IF statement" warning in PHP_CodeSniffer
+			Minz_Log::notice('Moved feed `' . $feed_id . '` ' .
+			                 'in the category `' . $cat_id . '`');;
 		} else {
 			Minz_Log::warning('Cannot move feed `' . $feed_id . '` ' .
 			                  'in the category `' . $cat_id . '`');
@@ -469,6 +580,22 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 	}
 
+	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.
 	 *
@@ -487,21 +614,13 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		if (!$redirect_url) {
 			$redirect_url = array('c' => 'subscription', 'a' => 'index');
 		}
-
 		if (!Minz_Request::isPost()) {
 			Minz_Request::forward($redirect_url, true);
 		}
 
 		$id = Minz_Request::param('id');
-		$feedDAO = FreshRSS_Factory::createFeedDao();
-		if ($feedDAO->deleteFeed($id)) {
-			// TODO: Delete old favicon
-
-			// Remove related queries
-			FreshRSS_Context::$user_conf->queries = remove_query_by_get(
-				'f_' . $id, FreshRSS_Context::$user_conf->queries);
-			FreshRSS_Context::$user_conf->save();
 
+		if (self::deleteFeed($id)) {
 			Minz_Request::good(_t('feedback.sub.feed.deleted'), $redirect_url);
 		} else {
 			Minz_Request::bad(_t('feedback.sub.feed.error'), $redirect_url);

+ 259 - 148
app/Controllers/importExportController.php

@@ -29,32 +29,14 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		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('feedback.import_export.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(
 			'opml' => array(),
@@ -65,21 +47,17 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		// We try to list all files according to their type
 		$list = array();
 		if ($type_file === 'zip' && extension_loaded('zip')) {
-			$zip = zip_open($file['tmp_name']);
-
+			$zip = zip_open($path);
 			if (!is_resource($zip)) {
 				// zip_open cannot open file: something is wrong
-				Minz_Log::error('Zip archive cannot be imported. Error code: ' . $zip);
-				Minz_Request::bad(_t('feedback.import_export.zip_error'),
-				                  array('c' => 'importExport', 'a' => 'index'));
+				throw new FreshRSS_Zip_Exception($zip);
 			}
-
 			while (($zipfile = zip_read($zip)) !== false) {
 				if (!is_resource($zipfile)) {
 					// 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 {
-					$type_zipfile = $this->guessFileType(zip_entry_name($zipfile));
+					$type_zipfile = self::guessFileType(zip_entry_name($zipfile));
 					if ($type_file !== 'unknown') {
 						$list_files[$type_zipfile][] = zip_entry_read(
 							$zipfile,
@@ -88,35 +66,93 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 					}
 				}
 			}
-
 			zip_close($zip);
 		} elseif ($type_file === 'zip') {
-			// Zip extension is not loaded
-			Minz_Request::bad(_t('feedback.import_export.no_zip_extension'),
-			                  array('c' => 'importExport', 'a' => 'index'));
+			// ZIP extension is not loaded
+			throw new FreshRSS_ZipMissing_Exception();
 		} 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.
 		// OPML first(so categories and feeds are imported)
 		// Starred articles then so the "favourite" status is already set
 		// And finally all other files.
-		$error = false;
+		$ok = true;
 		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) {
-			$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) {
-			$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
 		Minz_Session::_param('actualize_feeds', true);
-		$content_notif = $error === true ? _t('feedback.import_export.feeds_imported_with_errors') :
-		                                   _t('feedback.import_export.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);
 	}
 
@@ -126,7 +162,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * 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.
 	 */
-	private function guessFileType($filename) {
+	private static function guessFileType($filename) {
 		if (substr_compare($filename, '.zip', -4) === 0) {
 			return 'zip';
 		} elseif (substr_compare($filename, '.opml', -5) === 0 ||
@@ -146,15 +182,19 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * This method parses and imports an OPML file.
 	 *
 	 * @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) {
 		$opml_array = array();
 		try {
 			$opml_array = libopml_parse_string($opml_file, false);
 		} 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();
@@ -167,51 +207,49 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 * @param array $opml_elements an OPML element (body or outline).
 	 * @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) {
-		$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) {
-			$is_error = false;
 			if (isset($elt['xmlUrl'])) {
 				// If xmlUrl exists, it means it is a feed
-				if ($nb_feeds >= $limits['max_feeds']) {
+				if (FreshRSS_Context::$isCli && $nb_feeds >= $limits['max_feeds']) {
 					Minz_Log::warning(_t('feedback.sub.feed.over_max',
-					                  $limits['max_feeds']));
-					$is_error = true;
+									  $limits['max_feeds']));
+					$ok = false;
 					continue;
 				}
 
-				$is_error = $this->addFeedOpml($elt, $parent_cat);
-				if (!$is_error) {
-					$nb_feeds += 1;
+				if ($this->addFeedOpml($elt, $parent_cat)) {
+					$nb_feeds++;
+				} else {
+					$ok = false;
 				}
 			} else {
 				// No xmlUrl? It should be a category!
 				$limit_reached = ($nb_cats >= $limits['max_categories']);
-				if ($limit_reached) {
+				if (!FreshRSS_Context::$isCli && $limit_reached) {
 					Minz_Log::warning(_t('feedback.sub.category.over_max',
-					                  $limits['max_categories']));
+									  $limits['max_categories']));
+					$ok = false;
+					continue;
 				}
 
-				$is_error = $this->addCategoryOpml($elt, $parent_cat, $limit_reached);
-				if (!$is_error) {
-					$nb_cats += 1;
+				if ($this->addCategoryOpml($elt, $parent_cat, $limit_reached)) {
+					$nb_cats++;
+				} else {
+					$ok = false;
 				}
 			}
-
-			if (!$error && $is_error) {
-				// oops: there is at least one error!
-				$error = $is_error;
-			}
 		}
 
-		return $error;
+		return $ok;
 	}
 
 	/**
@@ -219,21 +257,23 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 * @param array $feed_elt an OPML element (must be a feed element).
 	 * @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) {
-		$default_cat = $this->catDAO->getDefault();
-		if (is_null($parent_cat)) {
+		if ($parent_cat == null) {
 			// This feed has no parent category so we get the default one
+			$this->catDAO->checkDefault();
+			$default_cat = $this->catDAO->getDefault();
 			$parent_cat = $default_cat->name();
 		}
 
 		$cat = $this->catDAO->searchByName($parent_cat);
-		if (is_null($cat)) {
+		if ($cat == null) {
 			// If there is not $cat, it means parent category does not exist in
 			// database.
 			// If it happens, take the default category.
-			$cat = $default_cat;
+			$this->catDAO->checkDefault();
+			$cat = $this->catDAO->getDefault();
 		}
 
 		// We get different useful information
@@ -259,7 +299,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 
 			// Call the extension hook
 			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
-			if (!is_null($feed)) {
+			if ($feed != null) {
 				// addFeedObject checks if feed is already in DB so nothing else to
 				// check here
 				$id = $this->feedDAO->addFeedObject($feed);
@@ -268,11 +308,23 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 				$error = true;
 			}
 		} 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;
 		}
 
-		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;
 	}
 
 	/**
@@ -282,29 +334,34 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param string $parent_cat the name of the parent category.
 	 * @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 true if an error occured, false else.
+	 * @return boolean false if an error occured, true otherwise.
 	 */
 	private function addCategoryOpml($cat_elt, $parent_cat, $cat_limit_reached) {
 		// 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);
 
 		$error = true;
-		if (!$cat_limit_reached) {
+		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'])) {
 			// Our cat_elt contains more categories or more feeds, so we
 			// add them recursively.
 			// 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;
 	}
 
 	/**
@@ -312,13 +369,17 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *
 	 * @param string $article_file the JSON file content.
 	 * @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) {
 		$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::$user_conf->mark_when['reception'] ? 1 : 0;
@@ -337,31 +398,37 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$feed = new FreshRSS_Feed($item['origin'][$key]);
 			$feed = $this->feedDAO->searchByUrl($feed->url());
 
-			if (is_null($feed)) {
+			if ($feed == null) {
 				// Feed does not exist in DB,we should to try to add it.
-				if ($nb_feeds >= $limits['max_feeds']) {
+				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 (is_null($feed)) {
+				if ($feed == null) {
 					// Still null? It means something went wrong.
 					$error = true;
 				} else {
-					// Nice! Increase the counter.
-					$nb_feeds += 1;
+					$nb_feeds++;
 				}
 			}
 
-			if (!is_null($feed)) {
+			if ($feed != null) {
 				$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);
+		$newGuids = array();
+
 		// Then, articles are imported.
-		$prepared_statement = $this->entryDAO->addEntryPrepare();
 		$this->entryDAO->beginTransaction();
 		foreach ($article_object['items'] as $item) {
 			if (!isset($article_to_feed[$item['id']])) {
@@ -371,13 +438,12 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 
 			$feed_id = $article_to_feed[$item['id']];
 			$author = isset($item['author']) ? $item['author'] : '';
-			$key_content = ($google_compliant && !isset($item['content'])) ?
-			               'summary' : 'content';
+			$key_content = ($google_compliant && !isset($item['content'])) ? 'summary' : 'content';
 			$tags = $item['categories'];
 			if ($google_compliant) {
 				// Remove tags containing "/state/com.google" which are useless.
 				$tags = array_filter($tags, function($var) {
-					return strpos($var, '/state/com.google') === false;
+					return strpos($var, '/state/com.google') !== false;
 				});
 			}
 
@@ -389,22 +455,35 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$entry->_id(min(time(), $entry->date(true)) . uSecString());
 			$entry->_tags($tags);
 
+			if (isset($newGuids[$entry->guid()])) {
+				continue;	//Skip subsequent articles with same GUID
+			}
+			$newGuids[$entry->guid()] = true;
+
 			$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
-			if (is_null($entry)) {
+			if ($entry == null) {
 				// An extension has returned a null value, there is nothing to insert.
 				continue;
 			}
 
 			$values = $entry->toArray();
-			$id = $this->entryDAO->addEntry($values, $prepared_statement);
-
-			if (!$error && ($id === false)) {
-				$error = true;
+			$ok = false;
+			if (isset($existingHashForGuids[$entry->guid()])) {
+				$ok = $this->entryDAO->updateEntry($values);
+			} else {
+				$ok = $this->entryDAO->addEntry($values);
 			}
+			$error |= ($ok === false);
+
 		}
 		$this->entryDAO->commit();
 
-		return $error;
+		$this->entryDAO->beginTransaction();
+		$this->entryDAO->commitNewEntries();
+		$this->feedDAO->updateCachedValues();
+		$this->entryDAO->commit();
+
+		return !$error;
 	}
 
 	/**
@@ -416,8 +495,6 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 *         else null.
 	 */
 	private function addFeedJson($origin, $google_compliant) {
-		$default_cat = $this->catDAO->getDefault();
-
 		$return = null;
 		$key = $google_compliant ? 'htmlUrl' : 'feedUrl';
 		$url = $origin[$key];
@@ -427,13 +504,13 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		try {
 			// Create a Feed object and add it in database.
 			$feed = new FreshRSS_Feed($url);
-			$feed->_category($default_cat->id());
+			$feed->_category(FreshRSS_CategoryDAO::DEFAULTCATEGORYID);
 			$feed->_name($name);
 			$feed->_website($website);
 
 			// Call the extension hook
 			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
-			if (!is_null($feed)) {
+			if ($feed != null) {
 				// addFeedObject checks if feed is already in DB so nothing else to
 				// check here.
 				$id = $this->feedDAO->addFeedObject($feed);
@@ -444,67 +521,100 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 				}
 			}
 		} 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;
 	}
 
-	/**
-	 * 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);
 
-		$export_opml = Minz_Request::param('export_opml', false);
-		$export_starred = Minz_Request::param('export_starred', false);
-		$export_feeds = Minz_Request::param('export_feeds', array());
+		$this->entryDAO->disableBuffering();
+
+		if ($export_feeds === true) {
+			//All feeds
+			$export_feeds = $this->feedDAO->listFeedsIds();
+		}
+		if (!is_array($export_feeds)) {
+			$export_feeds = array();
+		}
+
+		$day = date('Y-m-d');
 
 		$export_files = array();
 		if ($export_opml) {
-			$export_files['feeds.opml'] = $this->generateOpml();
+			$export_files["feeds_${day}.opml.xml"] = $this->generateOpml();
 		}
 
 		if ($export_starred) {
-			$export_files['starred.json'] = $this->generateEntries('starred');
+			$export_files["starred_${day}.json"] = $this->generateEntries('starred');
 		}
 
 		foreach ($export_feeds as $feed_id) {
 			$feed = $this->feedDAO->searchById($feed_id);
 			if ($feed) {
-				$filename = 'feed_' . $feed->category() . '_'
+				$filename = "feed_${day}_" . $feed->category() . '_'
 				          . $feed->id() . '.json';
-				$export_files[$filename] = $this->generateEntries('feed', $feed);
+				$export_files[$filename] = $this->generateEntries('feed', $feed, $maxFeedEntries);
 			}
 		}
 
 		$nb_files = count($export_files);
 		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 {
-				$this->exportZip($export_files);
+				$this->sendZip($export_files);
 			} catch (Exception $e) {
-				# Oops, there is no Zip extension!
-				Minz_Request::bad(_t('feedback.import_export.export_no_zip_extension'),
-				                  array('c' => 'importExport', 'a' => 'index'));
+				throw new FreshRSS_ZipMissing_Exception($e);
 			}
 		} elseif ($nb_files === 1) {
 			// Only one file? Guess its type and export it.
 			$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...
 			Minz_Request::forward(array('c' => 'importExport', 'a' => 'index'), true);
 		}
@@ -533,22 +643,22 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param FreshRSS_Feed $feed feed of which we want to get entries.
 	 * @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();
 
 		if ($type == 'starred') {
 			$this->view->list_title = _t('sub.import_export.starred_list');
 			$this->view->type = 'starred';
 			$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']
 			);
-		} elseif ($type == 'feed' && !is_null($feed)) {
+		} 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->entries = $this->entryDAO->listWhere(
+			$this->view->entriesRaw = $this->entryDAO->listWhereRaw(
 				'f', $feed->id(), FreshRSS_Entry::STATE_ALL, 'ASC',
-				FreshRSS_Context::$user_conf->posts_per_page
+				$maxFeedEntries
 			);
 			$this->view->feed = $feed;
 		}
@@ -562,7 +672,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param array $files list of files where key is filename and value the content.
 	 * @throws Exception if Zip extension is not loaded.
 	 */
-	private function exportZip($files) {
+	private function sendZip($files) {
 		if (!extension_loaded('zip')) {
 			throw new Exception();
 		}
@@ -580,7 +690,8 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		$zip->close();
 		header('Content-Type: application/zip');
 		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);
 		unlink($zip_file);
 	}
@@ -593,16 +704,16 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 	 * @param string $type the file type (opml, json_feed or json_starred).
 	 *                     If equals to unknown, nothing happens.
 	 */
-	private function exportFile($filename, $content, $type) {
+	private function sendFile($filename, $content, $type) {
 		if ($type === 'unknown') {
 			return;
 		}
 
 		$content_type = '';
 		if ($type === 'opml') {
-			$content_type = "text/opml";
+			$content_type = 'application/xml';
 		} elseif ($type === 'json_feed' || $type === 'json_starred') {
-			$content_type = "text/json";
+			$content_type = 'application/json';
 		}
 
 		header('Content-Type: ' . $content_type . '; charset=utf-8');

+ 73 - 40
app/Controllers/indexController.php

@@ -32,42 +32,44 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 			Minz_Error::error(404);
 		}
 
-		try {
-			$entries = $this->listEntriesByContext();
-
-			$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();
-			}
+		$this->view->callbackBeforeContent = function($view) {
+			try {
+				FreshRSS_Context::$number++;	//+1 for pagination
+				$entries = FreshRSS_index_Controller::listEntriesByContext();
+				FreshRSS_Context::$number--;
+
+				$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();
+				}
 
-			$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;
+				$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;
+					}
 				}
-			}
 
-			$this->view->entries = $entries;
-		} catch (FreshRSS_EntriesGetter_Exception $e) {
-			Minz_Log::notice($e->getMessage());
-			Minz_Error::error(404);
-		}
+				$view->entries = $entries;
+			} catch (FreshRSS_EntriesGetter_Exception $e) {
+				Minz_Log::notice($e->getMessage());
+				Minz_Error::error(404);
+			}
 
-		$this->view->categories = FreshRSS_Context::$categories;
+			$view->categories = FreshRSS_Context::$categories;
 
-		$this->view->rss_title = FreshRSS_Context::$name . ' | ' . Minz_View::title();
-		$title = FreshRSS_Context::$name;
-		if (FreshRSS_Context::$get_unread > 0) {
-			$title = '(' . FreshRSS_Context::$get_unread . ') ' . $title;
-		}
-		Minz_View::prependTitle($title . ' · ');
+			$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 . ' · ');
+		};
 	}
 
 	/**
@@ -130,13 +132,14 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 		}
 
 		try {
-			$this->view->entries = $this->listEntriesByContext();
+			$this->view->entries = FreshRSS_index_Controller::listEntriesByContext();
 		} catch (FreshRSS_EntriesGetter_Exception $e) {
 			Minz_Log::notice($e->getMessage());
 			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');
@@ -151,8 +154,14 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 	 *   - order (default: conf->sort_order)
 	 *   - nb (default: conf->posts_per_page)
 	 *   - next (default: empty string)
+	 *   - hours (default: 0)
 	 */
 	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();
@@ -173,20 +182,24 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 			FreshRSS_Context::$state |= FreshRSS_Entry::STATE_READ;
 		}
 
-		FreshRSS_Context::$search = Minz_Request::param('search', '');
+		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 = Minz_Request::param(
-			'nb', FreshRSS_Context::$user_conf->posts_per_page
-		);
+		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.
 	 */
-	private function listEntriesByContext() {
+	public static function listEntriesByContext() {
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 
 		$get = FreshRSS_Context::currentGet(true);
@@ -198,11 +211,31 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 			$id = '';
 		}
 
-		return $entryDAO->listWhere(
+		$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,
-			FreshRSS_Context::$number + 1, FreshRSS_Context::$first_id,
-			FreshRSS_Context::$search
+			$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;
 	}
 
 	/**

+ 9 - 4
app/Controllers/javascriptController.php

@@ -6,7 +6,7 @@ class FreshRSS_javascript_Controller extends Minz_ActionController {
 	}
 
 	public function actualizeAction() {
-		header('Content-Type: text/javascript; charset=UTF-8');
+		header('Content-Type: application/json; charset=UTF-8');
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$user_conf->ttl_default);
 	}
@@ -26,7 +26,7 @@ class FreshRSS_javascript_Controller extends Minz_ActionController {
 		header('Pragma: no-cache');
 
 		$user = isset($_GET['user']) ? $_GET['user'] : '';
-		if (ctype_alnum($user)) {
+		if (FreshRSS_user_Controller::checkUsername($user)) {
 			try {
 				$salt = FreshRSS_Context::$system_conf->salt;
 				$conf = get_user_configuration($user);
@@ -43,7 +43,12 @@ class FreshRSS_javascript_Controller extends Minz_ActionController {
 		} else {
 			Minz_Log::notice('Nonce failure due to invalid username!');
 		}
-		$this->view->nonce = '';	//Failure
-		$this->view->salt1 = '';
+		//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 = sha1(rand());
 	}
 }

+ 29 - 7
app/Controllers/statsController.php

@@ -18,6 +18,27 @@ class FreshRSS_stats_Controller extends Minz_ActionController {
 		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.
 	 *
@@ -33,10 +54,11 @@ class FreshRSS_stats_Controller extends Minz_ActionController {
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
 		Minz_View::appendScript(Minz_Url::display('/scripts/flotr2.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/flotr2.min.js')));
 		$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();
 	}
 
@@ -118,11 +140,11 @@ class FreshRSS_stats_Controller extends Minz_ActionController {
 		$this->view->days = $statsDAO->getDays();
 		$this->view->months = $statsDAO->getMonths();
 		$this->view->repartition = $statsDAO->calculateEntryRepartitionPerFeed($id);
-		$this->view->repartitionHour = $statsDAO->calculateEntryRepartitionPerFeedPerHour($id);
+		$this->view->repartitionHour = $this->convertToSerie($statsDAO->calculateEntryRepartitionPerFeedPerHour($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->repartitionMonth = $statsDAO->calculateEntryRepartitionPerFeedPerMonth($id);
+		$this->view->repartitionMonth = $this->convertToSerie($statsDAO->calculateEntryRepartitionPerFeedPerMonth($id));
 		$this->view->averageMonth = $statsDAO->calculateEntryAveragePerFeedPerMonth($id);
 	}
 }

+ 12 - 5
app/Controllers/subscriptionController.php

@@ -77,11 +77,11 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 		Minz_View::prependTitle(_t('sub.title.feed_management') . ' · ' . $this->view->feed->name() . ' · ');
 
 		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 = '';
-			if ($user != '' || $pass != '') {
+			if ($user != '' && $pass != '') {	//TODO: Sanitize
 				$httpAuth = $user . ':' . $pass;
 			}
 
@@ -90,8 +90,8 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 			$values = array(
 				'name' => Minz_Request::param('name', ''),
 				'description' => sanitizeHTML(Minz_Request::param('description', '', true)),
-				'website' => Minz_Request::param('website', ''),
-				'url' => Minz_Request::param('url', ''),
+				'website' => checkUrl(Minz_Request::param('website', '')),
+				'url' => checkUrl(Minz_Request::param('url', '')),
 				'category' => $cat,
 				'pathEntries' => Minz_Request::param('path_entries', ''),
 				'priority' => intval(Minz_Request::param('priority', 0)),
@@ -113,4 +113,11 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 			}
 		}
 	}
+
+	/**
+	 * This action displays the bookmarklet page.
+	 */
+	public function bookmarkletAction() {
+		Minz_View::prependTitle(_t('sub.title.subscription_tools') . ' . ');
+	}
 }

+ 129 - 60
app/Controllers/updateController.php

@@ -2,6 +2,45 @@
 
 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() {
 		if (!FreshRSS_Auth::hasAccess('admin')) {
 			Minz_Error::error(403);
@@ -20,24 +59,26 @@ class FreshRSS_update_Controller extends Minz_ActionController {
 	public function indexAction() {
 		Minz_View::prependTitle(_t('admin.update.title') . ' · ');
 
-		if (file_exists(UPDATE_FILENAME) && !is_writable(FRESHRSS_PATH)) {
-			$this->view->message = array(
-				'status' => 'bad',
-				'title' => _t('gen.short.damn'),
-				'body' => _t('feedback.update.file_is_nok', FRESHRSS_PATH)
-			);
-		} elseif (file_exists(UPDATE_FILENAME)) {
+		if (file_exists(UPDATE_FILENAME)) {
 			// There is an update file to apply!
 			$version = @file_get_contents(join_path(DATA_PATH, 'last_update.txt'));
-			if (empty($version)) {
+			if ($version == '') {
 				$version = 'unknown';
 			}
-			$this->view->update_to_apply = true;
-			$this->view->message = array(
-				'status' => 'good',
-				'title' => _t('gen.short.ok'),
-				'body' => _t('feedback.update.can_apply', $version)
-			);
+			if (is_writable(FRESHRSS_PATH)) {
+				$this->view->update_to_apply = true;
+				$this->view->message = array(
+					'status' => 'good',
+					'title' => _t('gen.short.ok'),
+					'body' => _t('feedback.update.can_apply', $version),
+				);
+			} else {
+				$this->view->message = array(
+					'status' => 'bad',
+					'title' => _t('gen.short.damn'),
+					'body' => _t('feedback.update.file_is_nok', $version, FRESHRSS_PATH),
+				);
+			}
 		}
 	}
 
@@ -53,48 +94,65 @@ class FreshRSS_update_Controller extends Minz_ActionController {
 			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('gen.short.damn'),
-				'body' => _t('feedback.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('gen.short.damn'),
-				'body' => _t('feedback.update.none')
-			);
+		if (self::isGit()) {
+			if (self::hasGitUpdate()) {
+				$version = 'git';
+			} else {
+				$this->view->message = array(
+					'status' => 'latest',
+					'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;
+			}
 
-			@touch(join_path(DATA_PATH, 'last_update.txt'));
+			$res_array = explode("\n", $result, 2);
+			$status = $res_array[0];
+			if (strpos($status, 'UPDATE') !== 0) {
+				$this->view->message = array(
+					'status' => 'latest',
+					'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) {
-			$version = explode(' ', $status, 2);
-			$version = $version[1];
 			@file_put_contents(join_path(DATA_PATH, 'last_update.txt'), $version);
-
 			Minz_Request::forward(array('c' => 'update'), true);
 		} else {
 			$this->view->message = array(
@@ -106,14 +164,17 @@ class FreshRSS_update_Controller extends Minz_ActionController {
 	}
 
 	public function applyAction() {
-		if (!file_exists(UPDATE_FILENAME) || !is_writable(FRESHRSS_PATH)) {
+		if (!file_exists(UPDATE_FILENAME) || !is_writable(FRESHRSS_PATH) || Minz_Configuration::get('system')->disable_update) {
 			Minz_Request::forward(array('c' => 'update'), true);
 		}
 
-		require(UPDATE_FILENAME);
-
 		if (Minz_Request::param('post_conf', false)) {
-			$res = do_post_update();
+			if (self::isGit()) {
+				$res = !self::hasGitUpdate();
+			} else {
+				require(UPDATE_FILENAME);
+				$res = do_post_update();
+			}
 
 			Minz_ExtensionManager::callHook('post_update');
 
@@ -125,14 +186,22 @@ class FreshRSS_update_Controller extends Minz_ActionController {
 				Minz_Request::bad(_t('feedback.update.error', $res),
 				                  array('c' => 'update', 'a' => 'index'));
 			}
-		}
-
-		if (Minz_Request::isPost()) {
-			save_info_update();
-		}
+		} else {
+			$res = false;
 
-		if (!need_info_update()) {
-			$res = apply_update();
+			if (self::isGit()) {
+				$res = self::gitPull();
+			} else {
+				require(UPDATE_FILENAME);
+				if (Minz_Request::isPost()) {
+					save_info_update();
+				}
+				if (!need_info_update()) {
+					$res = apply_update();
+				} else {
+					return;
+				}
+			}
 
 			if ($res === true) {
 				Minz_Request::forward(array(

+ 186 - 113
app/Controllers/userController.php

@@ -12,63 +12,83 @@ class FreshRSS_user_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.
+	 *
+	 * @todo clean up the access condition.
 	 */
 	public function firstAction() {
-		if (!FreshRSS_Auth::hasAccess()) {
+		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 profile page.
+	 * The username is also used as folder name, file name, and part of SQL table name.
+	 * '_' is a reserved internal username.
 	 */
-	public function profileAction() {
-		Minz_View::prependTitle(_t('conf.profile.title') . ' · ');
+	const USERNAME_PATTERN = '[0-9a-zA-Z_]{2,38}|[0-9a-zA-Z]';
 
-		if (Minz_Request::isPost()) {
-			$ok = true;
+	public static function checkUsername($username) {
+		return preg_match('/^' . self::USERNAME_PATTERN . '$/', $username) === 1;
+	}
 
-			$passwordPlain = Minz_Request::param('passwordPlain', '', true);
-			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
-				$ok &= ($passwordHash != '');
-				FreshRSS_Context::$user_conf->passwordHash = $passwordHash;
-			}
-			Minz_Session::_param('passwordHash', FreshRSS_Context::$user_conf->passwordHash);
+	public static function updateContextUser($passwordPlain, $apiPasswordPlain, $userConfigUpdated = array()) {
+		if ($passwordPlain != '') {
+			$passwordHash = self::hashPassword($passwordPlain);
+			FreshRSS_Context::$user_conf->passwordHash = $passwordHash;
+		}
 
-			$passwordPlain = Minz_Request::param('apiPasswordPlain', '', true);
-			if ($passwordPlain != '') {
-				if (!function_exists('password_hash')) {
-					include_once(LIB_PATH . '/password_compat.php');
+		if ($apiPasswordPlain != '') {
+			$apiPasswordHash = self::hashPassword($apiPasswordPlain);
+			FreshRSS_Context::$user_conf->apiPasswordHash = $apiPasswordHash;
+		}
+
+		if (is_array($userConfigUpdated)) {
+			foreach ($userConfigUpdated as $configName => $configValue) {
+				if ($configValue !== null) {
+					FreshRSS_Context::$user_conf->_param($configName, $configValue);
 				}
-				$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 != '');
-				FreshRSS_Context::$user_conf->apiPasswordHash = $passwordHash;
 			}
+		}
 
-			// TODO: why do we need of hasAccess here?
-			if (FreshRSS_Auth::hasAccess('admin')) {
-				FreshRSS_Context::$user_conf->mail_login = Minz_Request::param('mail_login', '', true);
-			}
-			$email = FreshRSS_Context::$user_conf->mail_login;
-			Minz_Session::_param('mail', $email);
+		$ok = FreshRSS_Context::$user_conf->save();
+		return $ok;
+	}
+
+	/**
+	 * This action displays the user profile page.
+	 */
+	public function profileAction() {
+		Minz_View::prependTitle(_t('conf.profile.title') . ' · ');
 
-			$ok &= FreshRSS_Context::$user_conf->save();
+		Minz_View::appendScript(Minz_Url::display(
+			'/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')
+		));
 
-			if ($email != '') {
-				$personaFile = DATA_PATH . '/persona/' . $email . '.txt';
-				@unlink($personaFile);
-				$ok &= (file_put_contents($personaFile, Minz_Session::param('currentUser', '_')) !== false);
-			}
+		if (Minz_Request::isPost()) {
+			$passwordPlain = Minz_Request::param('newPasswordPlain', '', true);
+			Minz_Request::_param('newPasswordPlain');	//Discard plain-text password ASAP
+			$_POST['newPasswordPlain'] = '';
+
+			$apiPasswordPlain = Minz_Request::param('apiPasswordPlain', '', true);
+
+			$ok = self::updateContextUser($passwordPlain, $apiPasswordPlain, array(
+					'token' => Minz_Request::param('token', null),
+				));
+
+			Minz_Session::_param('passwordHash', FreshRSS_Context::$user_conf->passwordHash);
 
 			if ($ok) {
 				Minz_Request::good(_t('feedback.profile.updated'),
@@ -100,72 +120,82 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		// Get information about the current user.
 		$entryDAO = FreshRSS_Factory::createEntryDao($this->view->current_user);
 		$this->view->nb_articles = $entryDAO->count();
-		$this->view->size_user = $entryDAO->size();
+
+		$databaseDAO = FreshRSS_Factory::createDatabaseDAO();
+		$this->view->size_user = $databaseDAO->size();
 	}
 
-	public function createAction() {
-		if (Minz_Request::isPost() && FreshRSS_Auth::hasAccess('admin')) {
-			$db = FreshRSS_Context::$system_conf->db;
-			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
+	public static function createUser($new_user_name, $passwordPlain, $apiPasswordPlain, $userConfig = array(), $insertDefaultFeeds = true) {
+		if (!is_array($userConfig)) {
+			$userConfig = array();
+		}
 
-			$new_user_language = Minz_Request::param('new_user_language', FreshRSS_Context::$user_conf->language);
+		$ok = self::checkUsername($new_user_name);
+		$homeDir = join_path(DATA_PATH, 'users', $new_user_name);
+
+		if ($ok) {
 			$languages = Minz_Translate::availableLanguages();
-			if (!isset($languages[$new_user_language])) {
-				$new_user_language = FreshRSS_Context::$user_conf->language;
+			if (empty($userConfig['language']) || !in_array($userConfig['language'], $languages)) {
+				$userConfig['language'] = 'en';
 			}
 
-			$new_user_name = Minz_Request::param('new_user_name');
-			$ok = ($new_user_name != '') && ctype_alnum($new_user_name);
-
-			if ($ok) {
-				$default_user = FreshRSS_Context::$system_conf->default_user;
-				$ok &= (strcasecmp($new_user_name, $default_user) !== 0);	//It is forbidden to alter the default user
-
-				$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 = join_path(DATA_PATH, 'users', $new_user_name, 'config.php');
-				$ok &= !file_exists($configPath);
+			$configPath = join_path($homeDir, 'config.php');
+			$ok &= !file_exists($configPath);
+		}
+		if ($ok) {
+			$passwordHash = '';
+			if ($passwordPlain != '') {
+				$passwordHash = self::hashPassword($passwordPlain);
+				$ok &= ($passwordHash != '');
 			}
-			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 = join_path(DATA_PATH, 'persona', $new_user_email . '.txt');
-					@unlink($personaFile);
-					$ok &= (file_put_contents($personaFile, $new_user_name) !== false);
-				}
+			$apiPasswordHash = '';
+			if ($apiPasswordPlain != '') {
+				$apiPasswordHash = self::hashPassword($apiPasswordPlain);
+				$ok &= ($apiPasswordHash != '');
 			}
-			if ($ok) {
-				mkdir(join_path(DATA_PATH, 'users', $new_user_name));
-				$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);
-			}
-			if ($ok) {
-				$userDAO = new FreshRSS_UserDAO();
-				$ok &= $userDAO->createUser($new_user_name);
+		}
+		if ($ok) {
+			if (!is_dir($homeDir)) {
+				mkdir($homeDir);
 			}
+			$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();
 
 			$notif = array(
@@ -175,30 +205,73 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 			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 = self::checkUsername($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);
+			array_map('unlink', glob(PSHB_PATH . '/feeds/*/' . $username . '.txt'));
+		}
+		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() {
-		if (Minz_Request::isPost() && FreshRSS_Auth::hasAccess('admin')) {
-			$db = FreshRSS_Context::$system_conf->db;
-			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);
-			$user_data = join_path(DATA_PATH, 'users', $username);
+		$self_deletion = Minz_Session::param('currentUser', '_') === $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
+		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) {
-				$ok &= is_dir($user_data);
+				$ok &= self::deleteUser($username);
 			}
-			if ($ok) {
-				$userDAO = new FreshRSS_UserDAO();
-				$ok &= $userDAO->deleteUser($username);
-				$ok &= recursive_unlink($user_data);
-				//TODO: delete Persona file
+			if ($ok && $self_deletion) {
+				FreshRSS_Auth::removeAccess();
+				$redirect_url = array('c' => 'index', 'a' => 'index');
 			}
 			invalidateHttpCache();
 
@@ -209,6 +282,6 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 			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
+
 class FreshRSS_BadUrl_Exception extends FreshRSS_Feed_Exception {
+
 	public function __construct($url) {
 		parent::__construct('`' . $url . '` is not a valid URL');
 	}
+
 }

+ 1 - 3
app/Exceptions/ContextException.php

@@ -4,7 +4,5 @@
  * An exception raised when a context is invalid
  */
 class FreshRSS_Context_Exception extends Exception {
-	public function __construct($message) {
-		parent::__construct($message);
-	}
+
 }

+ 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
 
 class FreshRSS_EntriesGetter_Exception extends Exception {
-	public function __construct($message) {
-		parent::__construct($message);
-	}
+
 }

+ 2 - 3
app/Exceptions/FeedException.php

@@ -1,6 +1,5 @@
 <?php
+
 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 {
+}

+ 44 - 29
app/FreshRSS.php

@@ -34,54 +34,53 @@ class FreshRSS extends Minz_FrontController {
 
 		// Auth has to be initialized before using currentUser session parameter
 		// because it's this part which create this parameter.
-		$this->initAuth();
+		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'),
+		                             join_path(FRESHRSS_PATH, 'config-user.default.php'),
 		                             $configuration_setter);
 
 		// Finish to initialize the other FreshRSS / Minz components.
 		FreshRSS_Context::init();
-		$this->initI18n();
-		FreshRSS_Share::load(join_path(DATA_PATH, 'shares.php'));
-		$this->loadStylesAndScripts();
-		$this->loadNotifications();
+		self::initI18n();
+		self::loadNotifications();
 		// Enable extensions for the current (logged) user.
-		if (FreshRSS_Auth::hasAccess()) {
+		if (FreshRSS_Auth::hasAccess() || $system_conf->allow_anonymous) {
 			$ext_list = FreshRSS_Context::$user_conf->extensions_enabled;
 			Minz_ExtensionManager::enableByList($ext_list);
 		}
 	}
 
-	private function initAuth() {
+	private static function initAuth() {
 		FreshRSS_Auth::init();
-		if (Minz_Request::isPost() && !is_referer_from_same_domain()) {
+		if (Minz_Request::isPost() && !(is_referer_from_same_domain() && FreshRSS_Auth::isCsrfOk())) {
 			// Basic protection against XSRF attacks
 			FreshRSS_Auth::removeAccess();
 			$http_referer = empty($_SERVER['HTTP_REFERER']) ? '' : $_SERVER['HTTP_REFERER'];
+			Minz_Translate::init('en');	//TODO: Better choice of fallback language
 			Minz_Error::error(
 				403,
 				array('error' => array(
-					_t('access_denied'),
+					_t('feedback.access.denied'),
 					' [HTTP_REFERER=' . htmlspecialchars($http_referer) . ']'
 				))
 			);
 		}
 	}
 
-	private function initI18n() {
+	private static function initI18n() {
 		Minz_Session::_param('language', FreshRSS_Context::$user_conf->language);
 		Minz_Translate::init(FreshRSS_Context::$user_conf->language);
 	}
 
-	private function loadStylesAndScripts() {
+	public static function loadStylesAndScripts() {
 		$theme = FreshRSS_Themes::load(FreshRSS_Context::$user_conf->theme);
 		if ($theme) {
-			foreach($theme['files'] as $file) {
+			foreach(array_reverse($theme['files']) as $file) {
 				if ($file[0] === '_') {
 					$theme_id = 'base-theme';
 					$filename = substr($file, 1);
@@ -90,30 +89,46 @@ class FreshRSS extends Minz_FrontController {
 					$filename = $file;
 				}
 				$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::prependStyle(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/shortcut.js?' . @filemtime(PUBLIC_PATH . '/scripts/shortcut.js')));
-		Minz_View::appendScript(Minz_Url::display('/scripts/main.js?' . @filemtime(PUBLIC_PATH . '/scripts/main.js')));
-
-		if (FreshRSS_Context::$system_conf->auth_type === '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));
-		}
+		//Use prepend to insert before extensions. Added in reverse order.
+		Minz_View::prependScript(Minz_Url::display('/scripts/main.js?' . @filemtime(PUBLIC_PATH . '/scripts/main.js')));
+		Minz_View::prependScript(Minz_Url::display('/scripts/shortcut.js?' . @filemtime(PUBLIC_PATH . '/scripts/shortcut.js')));
+		Minz_View::prependScript(Minz_Url::display('/scripts/jquery.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/jquery.min.js')));
 	}
 
-	private function loadNotifications() {
+	private static function loadNotifications() {
 		$notif = Minz_Session::param('notification');
 		if ($notif) {
 			Minz_View::_param('notification', $notif);
 			Minz_Session::_param('notification');
 		}
 	}
+
+	public static function preLayout() {
+		switch (Minz_Request::controllerName()) {
+			case 'index':
+				$urlToAuthorize = array_filter(array_map(function ($a) {
+					if (isset($a['method']) && $a['method'] === 'POST') {
+						return $a['url'];
+					}
+				}, FreshRSS_Context::$user_conf->sharing));
+				$connectSrc = count($urlToAuthorize) ? sprintf("; connect-src 'self' %s", implode(' ', $urlToAuthorize)) : '';
+				header(sprintf("Content-Security-Policy: default-src 'self'; child-src *; frame-src *; img-src * data:; media-src *%s", $connectSrc));
+				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();
+	}
 }

+ 57 - 28
app/Models/Auth.php

@@ -25,7 +25,7 @@ class FreshRSS_Auth {
 			self::giveAccess();
 		} elseif (self::accessControl()) {
 			self::giveAccess();
-			FreshRSS_UserDAO::touch($current_user);
+			FreshRSS_UserDAO::touch();
 		} else {
 			// Be sure all accesses are removed!
 			self::removeAccess();
@@ -60,16 +60,6 @@ class FreshRSS_Auth {
 				Minz_Session::_param('currentUser', $current_user);
 			}
 			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':
 			return true;
 		default:
@@ -84,6 +74,10 @@ class FreshRSS_Auth {
 	public static function giveAccess() {
 		$current_user = Minz_Session::param('currentUser');
 		$user_conf = get_user_configuration($current_user);
+		if ($user_conf == null) {
+			self::$login_ok = false;
+			return;
+		}
 		$system_conf = Minz_Configuration::get('system');
 
 		switch ($system_conf->auth_type) {
@@ -93,9 +87,6 @@ class FreshRSS_Auth {
 		case 'http_auth':
 			self::$login_ok = strcasecmp($current_user, httpAuthUser()) === 0;
 			break;
-		case 'persona':
-			self::$login_ok = strcasecmp(Minz_Session::param('mail'), $user_conf->mail_login) === 0;
-			break;
 		case 'none':
 			self::$login_ok = true;
 			break;
@@ -133,19 +124,32 @@ class FreshRSS_Auth {
 	 * Removes all accesses for the current user.
 	 */
 	public static function removeAccess() {
-		Minz_Session::_param('loginOk');
 		self::$login_ok = false;
-		$conf = Minz_Configuration::get('system');
-		Minz_Session::_param('currentUser', $conf->default_user);
+		Minz_Session::_param('loginOk');
+		Minz_Session::_param('csrf');
+		$system_conf = Minz_Configuration::get('system');
+
+		$username = '';
+		$token_param = Minz_Request::param('token', '');
+		if ($token_param != '') {
+			$username = trim(Minz_Request::param('user', ''));
+			if ($username != '') {
+				$conf = get_user_configuration($username);
+				if ($conf == null) {
+					$username = '';
+				}
+			}
+		}
+		if ($username == '') {
+			$username = $system_conf->default_user;
+		}
+		Minz_Session::_param('currentUser', $username);
 
-		switch ($conf->auth_type) {
+		switch ($system_conf->auth_type) {
 		case 'form':
 			Minz_Session::_param('passwordHash');
 			FreshRSS_FormAuth::deleteCookie();
 			break;
-		case 'persona':
-			Minz_Session::_param('mail');
-			break;
 		case 'http_auth':
 		case 'none':
 			// Nothing to do...
@@ -170,14 +174,34 @@ class FreshRSS_Auth {
 	public static function accessNeedsAction() {
 		$conf = Minz_Configuration::get('system');
 		$auth_type = $conf->auth_type;
-		return $auth_type === 'form' || $auth_type === 'persona';
+		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;
 	}
 }
 
 
 class FreshRSS_FormAuth {
 	public static function checkCredentials($username, $hash, $nonce, $challenge) {
-		if (!ctype_alnum($username) ||
+		if (!FreshRSS_user_Controller::checkUsername($username) ||
 				!ctype_graph($challenge) ||
 				!ctype_alnum($nonce)) {
 			Minz_Log::debug('Invalid credential parameters:' .
@@ -206,7 +230,7 @@ class FreshRSS_FormAuth {
 			// Token has expired (> 1 month) or does not exist.
 			// TODO: 1 month -> use a configuration instead
 			@unlink($token_file);
-			return array(); 	
+			return array();
 		}
 
 		$credentials = @file_get_contents($token_file);
@@ -214,8 +238,8 @@ class FreshRSS_FormAuth {
 	}
 
 	public static function makeCookie($username, $password_hash) {
+		$conf = Minz_Configuration::get('system');
 		do {
-			$conf = Minz_Configuration::get('system');
 			$token = sha1($conf->salt . $username . uniqid(mt_rand(), true));
 			$token_file = DATA_PATH . '/tokens/' . $token . '.txt';
 		} while (file_exists($token_file));
@@ -224,15 +248,17 @@ class FreshRSS_FormAuth {
 			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);
 		return $token;
 	}
 
 	public static function deleteCookie() {
 		$token = Minz_Session::getLongTermCookie('FreshRSS_login');
-		Minz_Session::deleteLongTermCookie('FreshRSS_login');
 		if (ctype_alnum($token)) {
+			Minz_Session::deleteLongTermCookie('FreshRSS_login');
 			@unlink(DATA_PATH . '/tokens/' . $token . '.txt');
 		}
 
@@ -242,7 +268,10 @@ class FreshRSS_FormAuth {
 	}
 
 	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) {
 			// $extension = $file_info->getExtension(); doesn't work in PHP < 5.3.7
 			$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 $nbNotRead = -1;
 	private $feeds = null;
+	private $hasFeedsWithError = false;
 
 	public function __construct($name = '', $feeds = null) {
 		$this->_name($name);
@@ -16,6 +17,7 @@ class FreshRSS_Category extends Minz_Model {
 			foreach ($feeds as $feed) {
 				$this->nbFeed++;
 				$this->nbNotRead += $feed->nbNotRead();
+				$this->hasFeedsWithError |= $feed->inError();
 			}
 		}
 	}
@@ -51,12 +53,17 @@ class FreshRSS_Category extends Minz_Model {
 			foreach ($this->feeds as $feed) {
 				$this->nbFeed++;
 				$this->nbNotRead += $feed->nbNotRead();
+				$this->hasFeedsWithError |= $feed->inError();
 			}
 		}
 
 		return $this->feeds;
 	}
 
+	public function hasFeedsWithError() {
+		return $this->hasFeedsWithError;
+	}
+
 	public function _id($value) {
 		$this->id = $value;
 	}

+ 17 - 10
app/Models/CategoryDAO.php

@@ -1,6 +1,9 @@
 <?php
 
-class FreshRSS_CategoryDAO extends Minz_ModelPdo {
+class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
+
+	const DEFAULTCATEGORYID = 1;
+
 	public function addCategory($valuesTmp) {
 		$sql = 'INSERT INTO `' . $this->prefix . 'category`(name) VALUES(?)';
 		$stm = $this->bd->prepare($sql);
@@ -10,10 +13,10 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 		);
 
 		if ($stm && $stm->execute($values)) {
-			return $this->bd->lastInsertId();
+			return $this->bd->lastInsertId('"' . $this->prefix . 'category_id_seq"');
 		} else {
 			$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;
 		}
 	}
@@ -50,6 +53,9 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	}
 
 	public function deleteCategory($id) {
+		if ($id <= self::DEFAULTCATEGORYID) {
+			return false;
+		}
 		$sql = 'DELETE FROM `' . $this->prefix . 'category` WHERE id=?';
 		$stm = $this->bd->prepare($sql);
 
@@ -100,10 +106,10 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	public function listCategories($prePopulateFeeds = true, $details = false) {
 		if ($prePopulateFeeds) {
 			$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 '
 			     . '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';
 			$stm = $this->bd->prepare($sql);
 			$stm->execute();
@@ -117,7 +123,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	}
 
 	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->execute();
@@ -131,11 +137,11 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 		}
 	}
 	public function checkDefault() {
-		$def_cat = $this->searchById(1);
+		$def_cat = $this->searchById(self::DEFAULTCATEGORYID);
 
 		if ($def_cat == null) {
 			$cat = new FreshRSS_Category(_t('gen.short.default_category'));
-			$cat->_id(1);
+			$cat->_id(self::DEFAULTCATEGORYID);
 
 			$values = array(
 				'id' => $cat->id(),
@@ -207,12 +213,13 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 
 		$previousLine = null;
 		$feedsDao = array();
+		$feedDao = FreshRSS_Factory::createFeedDAO();
 		foreach ($listDAO as $line) {
 			if ($previousLine['c_id'] != null && $line['c_id'] !== $previousLine['c_id']) {
 				// End of the current category, we add it to the $list
 				$cat = new FreshRSS_Category(
 					$previousLine['c_name'],
-					FreshRSS_FeedDAO::daoToFeed($feedsDao, $previousLine['c_id'])
+					$feedDao->daoToFeed($feedsDao, $previousLine['c_id'])
 				);
 				$cat->_id($previousLine['c_id']);
 				$list[$previousLine['c_id']] = $cat;
@@ -228,7 +235,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 		if ($previousLine != null) {
 			$cat = new FreshRSS_Category(
 				$previousLine['c_name'],
-				FreshRSS_FeedDAO::daoToFeed($feedsDao, $previousLine['c_id'])
+				$feedDao->daoToFeed($feedsDao, $previousLine['c_id'])
 			);
 			$cat->_id($previousLine['c_id']);
 			$list[$previousLine['c_id']] = $cat;

+ 33 - 25
app/Models/ConfigurationSetter.php

@@ -56,8 +56,7 @@ class FreshRSS_ConfigurationSetter {
 		switch ($value) {
 		case 'all':
 			$data['default_view'] = $value;
-			$data['default_state'] = (FreshRSS_Entry::STATE_READ +
-			                          FreshRSS_Entry::STATE_NOT_READ);
+			$data['default_state'] = (FreshRSS_Entry::STATE_READ + FreshRSS_Entry::STATE_NOT_READ);
 			break;
 		case 'adaptive':
 		case 'unread':
@@ -95,11 +94,6 @@ class FreshRSS_ConfigurationSetter {
 		$data['language'] = $value;
 	}
 
-	private function _mail_login(&$data, $value) {
-		$value = filter_var($value, FILTER_VALIDATE_EMAIL);
-		$data['mail_login'] = $value ? $value : '';
-	}
-
 	private function _old_entries(&$data, $value) {
 		$value = intval($value);
 		$data['old_entries'] = $value > 0 ? $value : 3;
@@ -117,12 +111,11 @@ class FreshRSS_ConfigurationSetter {
 	private function _queries(&$data, $values) {
 		$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));
-			$data['queries'][] = $value;
+			if ($value instanceof FreshRSS_UserQuery) {
+				$data['queries'][] = $value->toArray();
+			} elseif (is_array($value)) {
+				$data['queries'][] = $value;
+			}
 		}
 	}
 
@@ -135,12 +128,7 @@ class FreshRSS_ConfigurationSetter {
 
 			// 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
+				$is_url = filter_var($value['url'], FILTER_VALIDATE_URL);
 				if (!$is_url) {
 					continue;
 				}
@@ -174,7 +162,7 @@ class FreshRSS_ConfigurationSetter {
 		if (!in_array($value, array('global', 'normal', 'reader'))) {
 			$value = 'normal';
 		}
-		$data['view_mode'] =  $value;
+		$data['view_mode'] = $value;
 	}
 
 	/**
@@ -192,6 +180,10 @@ class FreshRSS_ConfigurationSetter {
 		$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);
 	}
@@ -204,6 +196,10 @@ class FreshRSS_ConfigurationSetter {
 		$data['hide_read_feeds'] = $this->handleBool($value);
 	}
 
+	private function _sides_close_article(&$data, $value) {
+		$data['sides_close_article'] = $this->handleBool($value);
+	}
+
 	private function _lazyload(&$data, $value) {
 		$data['lazyload'] = $this->handleBool($value);
 	}
@@ -275,7 +271,7 @@ class FreshRSS_ConfigurationSetter {
 
 	private function _auth_type(&$data, $value) {
 		$value = strtolower($value);
-		if (!in_array($value, array('form', 'http_auth', 'persona', 'none'))) {
+		if (!in_array($value, array('form', 'http_auth', 'none'))) {
 			$value = 'none';
 		}
 		$data['auth_type'] = $value;
@@ -289,6 +285,7 @@ class FreshRSS_ConfigurationSetter {
 
 		switch ($value['type']) {
 		case 'mysql':
+		case 'pgsql':
 			if (empty($value['host']) ||
 					empty($value['user']) ||
 					empty($value['base']) ||
@@ -328,7 +325,7 @@ class FreshRSS_ConfigurationSetter {
 		if (!in_array($value, array('silent', 'development', 'production'))) {
 			$value = 'production';
 		}
-		$data['environment'] =  $value;
+		$data['environment'] = $value;
 	}
 
 	private function _limits(&$data, $values) {
@@ -351,6 +348,9 @@ class FreshRSS_ConfigurationSetter {
 				'min' => 0,
 				'max' => $max_small_int,
 			),
+			'max_registrations' => array(
+				'min' => 0,
+			),
 		);
 
 		foreach ($values as $key => $value) {
@@ -358,10 +358,10 @@ class FreshRSS_ConfigurationSetter {
 				continue;
 			}
 
+			$value = intval($value);
 			$limits = $limits_keys[$key];
-			if (
-				(!isset($limits['min']) || $value > $limits['min']) &&
-				(!isset($limits['max']) || $value < $limits['max'])
+			if ((!isset($limits['min']) || $value >= $limits['min']) &&
+				(!isset($limits['max']) || $value <= $limits['max'])
 			) {
 				$data['limits'][$key] = $value;
 			}
@@ -371,4 +371,12 @@ class FreshRSS_ConfigurationSetter {
 	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;
+	}
 }

+ 30 - 161
app/Models/Context.php

@@ -10,6 +10,7 @@ class FreshRSS_Context {
 	public static $categories = array();
 
 	public static $name = '';
+	public static $description = '';
 
 	public static $total_unread = 0;
 	public static $total_starred = array(
@@ -30,10 +31,13 @@ class FreshRSS_Context {
 	public static $state = 0;
 	public static $order = 'DESC';
 	public static $number = 0;
-	public static $search = '';
+	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.
@@ -44,9 +48,6 @@ class FreshRSS_Context {
 		// Init configuration.
 		self::$system_conf = Minz_Configuration::get('system');
 		self::$user_conf = Minz_Configuration::get('user');
-
-		$catDAO = new FreshRSS_CategoryDAO();
-		self::$categories = $catDAO->listCategories();
 	}
 
 	/**
@@ -93,6 +94,13 @@ class FreshRSS_Context {
 		}
 	}
 
+	/**
+	 * 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;
+	}
+
 	/**
 	 * Return true if $get parameter correspond to the $current_get attribute.
 	 */
@@ -131,23 +139,30 @@ class FreshRSS_Context {
 		$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.
-			$feed = FreshRSS_CategoryDAO::findFeed(self::$categories, $id);
+			// 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);
@@ -160,6 +175,7 @@ class FreshRSS_Context {
 			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':
@@ -189,11 +205,16 @@ class FreshRSS_Context {
 	/**
 	 * Set the value of $next_get attribute.
 	 */
-	public static function _nextGet() {
+	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;
@@ -229,9 +250,7 @@ class FreshRSS_Context {
 				}
 
 				// 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;
+				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.
@@ -254,9 +273,7 @@ class FreshRSS_Context {
 				}
 
 				// No unread category? The main stream will be our destination!
-				self::$next_get = empty($another_unread_id) ?
-				                  'a' :
-				                  'c_' . $another_unread_id;
+				self::$next_get = empty($another_unread_id) ? 'a' : 'c_' . $another_unread_id;
 				break;
 			}
 		}
@@ -302,152 +319,4 @@ class FreshRSS_Context {
 		return false;
 	}
 
-	/**
-	 * Parse search string to extract the different keywords.
-	 *
-	 * @return array
-	 */
-	public function parseSearch() {
-		$search = self::$search;
-		$intitle = $this->parseIntitleSearch($search);
-		$author = $this->parseAuthorSearch($intitle['string']);
-		$inurl = $this->parseInurlSearch($author['string']);
-		$pubdate = $this->parsePubdateSearch($inurl['string']);
-		$date = $this->parseDateSearch($pubdate['string']);
-
-		$remaining = array();
-		$remaining_search = trim($date['string']);
-		if (strcmp($remaining_search, '') != 0) {
-			$remaining['search'] = $remaining_search;
-		}
-
-		return array_merge($intitle['search'], $author['search'], $inurl['search'], $date['search'], $pubdate['search'], $remaining);
-	}
-
-	/**
-	 * Parse the search string to find intitle keyword and the search related
-	 * to it.
-	 * The search is the first word following the keyword.
-	 * It returns an array containing the matched string and the search.
-	 *
-	 * @param string $search
-	 * @return array
-	 */
-	private function parseIntitleSearch($search) {
-		if (preg_match('/intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $search, $matches)) {
-			return array(
-			    'string' => str_replace($matches[0], '', $search),
-			    'search' => array('intitle' => $matches['search']),
-			);
-		}
-		if (preg_match('/intitle:(?P<search>\w*)/', $search, $matches)) {
-			return array(
-			    'string' => str_replace($matches[0], '', $search),
-			    'search' => array('intitle' => $matches['search']),
-			);
-		}
-		return array(
-		    'string' => $search,
-		    'search' => array(),
-		);
-	}
-
-	/**
-	 * 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 (").
-	 * It returns an array containing the matched string and the search.
-	 *
-	 * @param string $search
-	 * @return array
-	 */
-	private function parseAuthorSearch($search) {
-		if (preg_match('/author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $search, $matches)) {
-			return array(
-			    'string' => str_replace($matches[0], '', $search),
-			    'search' => array('author' => $matches['search']),
-			);
-		}
-		if (preg_match('/author:(?P<search>\w*)/', $search, $matches)) {
-			return array(
-			    'string' => str_replace($matches[0], '', $search),
-			    'search' => array('author' => $matches['search']),
-			);
-		}
-		return array(
-		    'string' => $search,
-		    'search' => array(),
-		);
-	}
-
-	/**
-	 * Parse the search string to find inurl keyword and the search related
-	 * to it.
-	 * The search is the first word following the keyword except.
-	 * It returns an array containing the matched string and the search.
-	 *
-	 * @param string $search
-	 * @return array
-	 */
-	private function parseInurlSearch($search) {
-		if (preg_match('/inurl:(?P<search>[^\s]*)/', $search, $matches)) {
-			return array(
-			    'string' => str_replace($matches[0], '', $search),
-			    'search' => array('inurl' => $matches['search']),
-			);
-		}
-		return array(
-		    'string' => $search,
-		    'search' => array(),
-		);
-	}
-
-	/**
-	 * Parse the search string to find date keyword and the search related
-	 * to it.
-	 * The search is the first word following the keyword.
-	 * It returns an array containing the matched string and the search.
-	 *
-	 * @param string $search
-	 * @return array
-	 */
-	private function parseDateSearch($search) {
-		if (preg_match('/date:(?P<search>[^\s]*)/', $search, $matches)) {
-			list($min_date, $max_date) = parseDateInterval($matches['search']);
-			return array(
-			    'string' => str_replace($matches[0], '', $search),
-			    'search' => array('min_date' => $min_date, 'max_date' => $max_date),
-			);
-		}
-		return array(
-		    'string' => $search,
-		    'search' => array(),
-		);
-	}
-
-	/**
-	 * Parse the search string to find pubdate keyword and the search related
-	 * to it.
-	 * The search is the first word following the keyword.
-	 * It returns an array containing the matched string and the search.
-	 *
-	 * @param string $search
-	 * @return array
-	 */
-	private function parsePubdateSearch($search) {
-		if (preg_match('/pubdate:(?P<search>[^\s]*)/', $search, $matches)) {
-			list($min_date, $max_date) = parseDateInterval($matches['search']);
-			return array(
-			    'string' => str_replace($matches[0], '', $search),
-			    'search' => array('min_pubdate' => $min_date, 'max_pubdate' => $max_date),
-			);
-		}
-		return array(
-		    'string' => $search,
-		    'search' => array(),
-		);
-	}
-
 }

+ 42 - 1
app/Models/DatabaseDAO.php

@@ -9,7 +9,7 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 		$stm = $this->bd->prepare($sql);
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		
+
 		$tables = array(
 			$this->prefix . 'category' => false,
 			$this->prefix . 'feed' => false,
@@ -80,4 +80,45 @@ class FreshRSS_DatabaseDAO extends Minz_ModelPdo {
 
 		return $list;
 	}
+
+	public function size($all = false) {
+		$db = FreshRSS_Context::$system_conf->db;
+		$sql = 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema=?';	//MySQL
+		$values = array($db['base']);
+		if (!$all) {
+			$sql .= ' AND table_name LIKE ?';
+			$values[] = $this->prefix . '%';
+		}
+		$stm = $this->bd->prepare($sql);
+		$stm->execute($values);
+		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+		return $res[0];
+	}
+
+	public function optimize() {
+		$ok = true;
+
+		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'entry`';	//MySQL
+		$stm = $this->bd->prepare($sql);
+		$ok &= $stm != false;
+		if ($stm) {
+			$ok &= $stm->execute();
+		}
+
+		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'feed`';	//MySQL
+		$stm = $this->bd->prepare($sql);
+		$ok &= $stm != false;
+		if ($stm) {
+			$ok &= $stm->execute();
+		}
+
+		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'category`';	//MySQL
+		$stm = $this->bd->prepare($sql);
+		$ok &= $stm != false;
+		if ($stm) {
+			$ok &= $stm->execute();
+		}
+
+		return $ok;
+	}
 }

+ 80 - 0
app/Models/DatabaseDAOPGSQL.php

@@ -0,0 +1,80 @@
+<?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'],
+		);
+	}
+
+	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];
+	}
+
+	public function optimize() {
+		$ok = true;
+
+		$sql = 'VACUUM `' . $this->prefix . 'entry`';
+		$stm = $this->bd->prepare($sql);
+		$ok &= $stm != false;
+		if ($stm) {
+			$ok &= $stm->execute();
+		}
+
+		$sql = 'VACUUM `' . $this->prefix . 'feed`';
+		$stm = $this->bd->prepare($sql);
+		$ok &= $stm != false;
+		if ($stm) {
+			$ok &= $stm->execute();
+		}
+
+		$sql = 'VACUUM `' . $this->prefix . 'category`';
+		$stm = $this->bd->prepare($sql);
+		$ok &= $stm != false;
+		if ($stm) {
+			$ok &= $stm->execute();
+		}
+
+		return $ok;
+	}
+}

+ 14 - 1
app/Models/DatabaseDAOSQLite.php

@@ -9,7 +9,7 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 		$stm = $this->bd->prepare($sql);
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		
+
 		$tables = array(
 			'category' => false,
 			'feed' => false,
@@ -45,4 +45,17 @@ class FreshRSS_DatabaseDAOSQLite extends FreshRSS_DatabaseDAO {
 			'default' => $dao['dflt_value'],
 		);
 	}
+
+	public function size($all = false) {
+		return @filesize(join_path(DATA_PATH, 'users', $this->current_user, 'db.sqlite'));
+	}
+
+	public function optimize() {
+		$sql = 'VACUUM';
+		$stm = $this->bd->prepare($sql);
+		if ($stm) {
+			return $stm->execute();
+		}
+		return false;
+	}
 }

+ 34 - 3
app/Models/Entry.php

@@ -14,14 +14,14 @@ class FreshRSS_Entry extends Minz_Model {
 	private $content;
 	private $link;
 	private $date;
-	private $is_read;
+	private $hash = null;
+	private $is_read;	//Nullable boolean
 	private $is_favorite;
 	private $feed;
 	private $tags;
 
 	public function __construct($feed = '', $guid = '', $title = '', $author = '', $content = '',
 	                            $link = '', $pubdate = 0, $is_read = false, $is_favorite = false, $tags = '') {
-		$this->_guid($guid);
 		$this->_title($title);
 		$this->_author($author);
 		$this->_content($content);
@@ -31,6 +31,7 @@ class FreshRSS_Entry extends Minz_Model {
 		$this->_isFavorite($is_favorite);
 		$this->_feed($feed);
 		$this->_tags(preg_split('/[\s#]/', $tags));
+		$this->_guid($guid);
 	}
 
 	public function id() {
@@ -88,30 +89,57 @@ 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 _hash($value) {
+		$value = trim($value);
+		if (ctype_xdigit($value)) {
+			$this->hash = substr($value, 0, 32);
+		}
+		return $this->hash;
+	}
+
 	public function _id($value) {
 		$this->id = $value;
 	}
 	public function _guid($value) {
+		if ($value == '') {
+			$value = $this->link;
+			if ($value == '') {
+				$value = $this->hash();
+			}
+		}
 		$this->guid = $value;
 	}
 	public function _title($value) {
+		$this->hash = null;
 		$this->title = $value;
 	}
 	public function _author($value) {
+		$this->hash = null;
 		$this->author = $value;
 	}
 	public function _content($value) {
+		$this->hash = null;
 		$this->content = $value;
 	}
 	public function _link($value) {
+		$this->hash = null;
 		$this->link = $value;
 	}
 	public function _date($value) {
+		$this->hash = null;
 		$value = intval($value);
 		$this->date = $value > 1 ? $value : time();
 	}
 	public function _isRead($value) {
-		$this->is_read = $value;
+		$this->is_read = $value === null ? null : (bool)$value;
 	}
 	public function _isFavorite($value) {
 		$this->is_favorite = $value;
@@ -120,6 +148,7 @@ class FreshRSS_Entry extends Minz_Model {
 		$this->feed = $value;
 	}
 	public function _tags($value) {
+		$this->hash = null;
 		if (!is_array($value)) {
 			$value = array($value);
 		}
@@ -168,6 +197,7 @@ class FreshRSS_Entry extends Minz_Model {
 					);
 				} catch (Exception $e) {
 					// rien à faire, on garde l'ancien contenu(requête a échoué)
+					Minz_Log::warning($e->getMessage());
 				}
 			}
 		}
@@ -182,6 +212,7 @@ class FreshRSS_Entry extends Minz_Model {
 			'content' => $this->content(),
 			'link' => $this->link(),
 			'date' => $this->date(true),
+			'hash' => $this->hash(),
 			'is_read' => $this->isRead(),
 			'is_favorite' => $this->isFavorite(),
 			'id_feed' => $this->feed(),

+ 529 - 239
app/Models/EntryDAO.php

@@ -1,83 +1,284 @@
 <?php
 
-class FreshRSS_EntryDAO extends Minz_ModelPdo {
+class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 	public function isCompressed() {
+		return parent::$sharedDbType === 'mysql';
+	}
+
+	public function hasNativeHex() {
 		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;
 		}
+		$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 createEntryTempTable() {
+		$ok = false;
+		$hadTransaction = $this->bd->inTransaction();
+		if ($hadTransaction) {
+			$this->bd->commit();
+		}
+		try {
+			$db = FreshRSS_Context::$system_conf->db;
+			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
+			Minz_Log::warning('SQL CREATE TABLE entrytmp...');
+			if (defined('SQL_CREATE_TABLE_ENTRYTMP')) {
+				$sql = sprintf(SQL_CREATE_TABLE_ENTRYTMP, $this->prefix);
+				$stm = $this->bd->prepare($sql);
+				$ok = $stm && $stm->execute();
+			} else {
+				global $SQL_CREATE_TABLE_ENTRYTMP;
+				$ok = !empty($SQL_CREATE_TABLE_ENTRYTMP);
+				foreach ($SQL_CREATE_TABLE_ENTRYTMP as $instruction) {
+					$sql = sprintf($instruction, $this->prefix);
+					$stm = $this->bd->prepare($sql);
+					$ok &= $stm && $stm->execute();
+				}
+			}
+		} catch (Exception $e) {
+			Minz_Log::error('FreshRSS_EntryDAO::createEntryTempTable error: ' . $e->getMessage());
+		}
+		if ($hadTransaction) {
+			$this->bd->beginTransaction();
+		}
+		return $ok;
+	}
 
-		$eDate = $entry->date(true);
+	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);
+					}
+				}
+			} elseif ($errorInfo[0] === '42S02' && stripos($errorInfo[2], 'entrytmp') !== false) {	//ER_BAD_TABLE_ERROR
+				return $this->createEntryTempTable();	//v1.7
+			}
+		}
+		if (isset($errorInfo[1])) {
+			if ($errorInfo[1] == '1366') {	//ER_TRUNCATED_WRONG_VALUE_FOR_FIELD
+				return $this->updateToUtf8mb4();
+			}
+		}
+		return false;
+	}
 
-		if ($feedHistory == -2) {
-			$feedHistory = $conf->keep_history_default;
+	private $addEntryPrepared = null;
+
+	public function addEntry($valuesTmp) {
+		if ($this->addEntryPrepared == null) {
+			$sql = 'INSERT INTO `' . $this->prefix . 'entrytmp` (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);
+		}
+		if ($this->addEntryPrepared) {
+			$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 ($this->addEntryPrepared && $this->addEntryPrepared->execute()) {
+			return true;
+		} else {
+			$info = $this->addEntryPrepared == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $this->addEntryPrepared->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				$this->addEntryPrepared = null;
+				return $this->addEntry($valuesTmp);
+			} elseif ((int)((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();
+	public function commitNewEntries() {
+		$sql = 'SET @rank=(SELECT MAX(id) - COUNT(*) FROM `' . $this->prefix . 'entrytmp`); ' .	//MySQL-specific
+			'INSERT IGNORE INTO `' . $this->prefix . 'entry`
+				(
+					id, guid, title, author, content_bin, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags
+				) ' .
+				'SELECT @rank:=@rank+1 AS id, guid, title, author, content_bin, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags
+					FROM `' . $this->prefix . 'entrytmp`
+					ORDER BY date; ' .
+			'DELETE FROM `' . $this->prefix . 'entrytmp` WHERE id <= @rank;';
+		$hadTransaction = $this->bd->inTransaction();
+		if (!$hadTransaction) {
+			$this->bd->beginTransaction();
+		}
+		$result = $this->bd->exec($sql) !== false;
+		if (!$hadTransaction) {
+			$this->bd->commit();
+		}
+		return $result;
+	}
+
+	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 true;
+		} 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;
+		}
 	}
 
 	/**
@@ -94,9 +295,13 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		if (!is_array($ids)) {
 			$ids = array($ids);
 		}
+		if (count($ids) < 1) {
+			return 0;
+		}
+		FreshRSS_UserDAO::touch();
 		$sql = 'UPDATE `' . $this->prefix . 'entry` '
-		     . 'SET is_favorite=? '
-		     . 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?)';
+			. 'SET is_favorite=? '
+			. 'WHERE id IN (' . str_repeat('?,', count($ids) - 1). '?)';
 		$values = array($is_favorite ? 1 : 0);
 		$values = array_merge($values, $ids);
 		$stm = $this->bd->prepare($sql);
@@ -122,22 +327,26 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 	 */
 	protected function updateCacheUnreads($catId = false, $feedId = false) {
 		$sql = 'UPDATE `' . $this->prefix . 'feed` f '
-		 . 'LEFT OUTER JOIN ('
-		 .	'SELECT e.id_feed, '
-		 .	'COUNT(*) AS nbUnreads '
-		 .	'FROM `' . $this->prefix . 'entry` e '
-		 .	'WHERE e.is_read=0 '
-		 .	'GROUP BY e.id_feed'
-		 . ') x ON x.id_feed=f.id '
-		 . 'SET f.cache_nbUnreads=COALESCE(x.nbUnreads, 0) '
-		 . 'WHERE 1';
+			. 'LEFT OUTER JOIN ('
+			.	'SELECT e.id_feed, '
+			.	'COUNT(*) AS nbUnreads '
+			.	'FROM `' . $this->prefix . 'entry` e '
+			.	'WHERE e.is_read=0 '
+			.	'GROUP BY e.id_feed'
+			. ') x ON x.id_feed=f.id '
+			. 'SET f.`cache_nbUnreads`=COALESCE(x.nbUnreads, 0)';
+		$hasWhere = false;
 		$values = array();
 		if ($feedId !== false) {
-			$sql .= ' AND f.id=?';
+			$sql .= $hasWhere ? ' AND' : ' WHERE';
+			$hasWhere = true;
+			$sql .= ' f.id=?';
 			$values[] = $id;
 		}
 		if ($catId !== false) {
-			$sql .= ' AND f.category=?';
+			$sql .= $hasWhere ? ' AND' : ' WHERE';
+			$hasWhere = true;
+			$sql .= ' f.category=?';
 			$values[] = $catId;
 		}
 		$stm = $this->bd->prepare($sql);
@@ -164,6 +373,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 	 * @return integer affected rows
 	 */
 	public function markRead($ids, $is_read = true) {
+		FreshRSS_UserDAO::touch();
 		if (is_array($ids)) {	//Many IDs at once (used by API)
 			if (count($ids) < 6) {	//Speed heuristics
 				$affected = 0;
@@ -192,7 +402,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		} else {
 			$sql = 'UPDATE `' . $this->prefix . 'entry` e INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed=f.id '
 				 . '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=?';
 			$values = array($is_read ? 1 : 0, $ids, $is_read ? 0 : 1);
 			$stm = $this->bd->prepare($sql);
@@ -227,7 +437,8 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 	 * @param integer $priorityMin
 	 * @return integer affected rows
 	 */
-	public function markReadEntries($idMax = 0, $onlyFavorites = false, $priorityMin = 0) {
+	public function markReadEntries($idMax = 0, $onlyFavorites = false, $priorityMin = 0, $filter = null, $state = 0) {
+		FreshRSS_UserDAO::touch();
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
@@ -242,8 +453,11 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 			$sql .= ' AND f.priority > ' . intval($priorityMin);
 		}
 		$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();
 			Minz_Log::error('SQL error markReadEntries: ' . $info[2]);
 			return false;
@@ -266,7 +480,8 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 	 * @param integer $idMax fail safe article ID
 	 * @return integer affected rows
 	 */
-	public function markReadCat($id, $idMax = 0) {
+	public function markReadCat($id, $idMax = 0, $filter = null, $state = 0) {
+		FreshRSS_UserDAO::touch();
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadCat(0) is deprecated!');
@@ -276,8 +491,11 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 			 . 'SET e.is_read=1 '
 			 . 'WHERE f.category=? AND e.is_read=0 AND e.id <= ?';
 		$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();
 			Minz_Log::error('SQL error markReadCat: ' . $info[2]);
 			return false;
@@ -296,11 +514,12 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 	 *
 	 * If $idMax equals 0, a deprecated debug message is logged
 	 *
-	 * @param integer $id feed ID
+	 * @param integer $id_feed feed ID
 	 * @param integer $idMax fail safe article ID
 	 * @return integer affected rows
 	 */
-	public function markReadFeed($id, $idMax = 0) {
+	public function markReadFeed($id_feed, $idMax = 0, $filter = null, $state = 0) {
+		FreshRSS_UserDAO::touch();
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadFeed(0) is deprecated!');
@@ -310,11 +529,14 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		$sql = 'UPDATE `' . $this->prefix . 'entry` '
 			 . 'SET is_read=1 '
 			 . '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();
-			Minz_Log::error('SQL error markReadFeed: ' . $info[2]);
+			Minz_Log::error('SQL error markReadFeed: ' . $info[2] . ' with SQL: ' . $sql . $search);
 			$this->bd->rollBack();
 			return false;
 		}
@@ -322,13 +544,13 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 
 		if ($affected > 0) {
 			$sql = 'UPDATE `' . $this->prefix . 'feed` '
-				 . 'SET cache_nbUnreads=cache_nbUnreads-' . $affected
+				 . 'SET `cache_nbUnreads`=`cache_nbUnreads`-' . $affected
 				 . ' WHERE id=?';
-			$values = array($id);
+			$values = array($id_feed);
 			$stm = $this->bd->prepare($sql);
 			if (!($stm && $stm->execute($values))) {
 				$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();
 				return false;
 			}
@@ -338,37 +560,37 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return $affected;
 	}
 
-	public function searchByGuid($feed_id, $id) {
+	public function searchByGuid($id_feed, $guid) {
 		// un guid est unique pour un flux donné
 		$sql = 'SELECT id, guid, title, author, '
-		     . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-		     . ', link, date, is_read, is_favorite, id_feed, tags '
-		     . 'FROM `' . $this->prefix . 'entry` WHERE id_feed=? AND guid=?';
+			. ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
+			. ', link, date, is_read, is_favorite, id_feed, tags '
+			. 'FROM `' . $this->prefix . 'entry` WHERE id_feed=? AND guid=?';
 		$stm = $this->bd->prepare($sql);
 
 		$values = array(
-			$feed_id,
-			$id
+			$id_feed,
+			$guid,
 		);
 
 		$stm->execute($values);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		$entries = self::daoToEntry($res);
+		$entries = self::daoToEntries($res);
 		return isset($entries[0]) ? $entries[0] : null;
 	}
 
 	public function searchById($id) {
 		$sql = 'SELECT id, guid, title, author, '
-		     . ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
-		     . ', link, date, is_read, is_favorite, id_feed, tags '
-		     . 'FROM `' . $this->prefix . 'entry` WHERE id=?';
+			. ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
+			. ', link, date, is_read, is_favorite, id_feed, tags '
+			. 'FROM `' . $this->prefix . 'entry` WHERE id=?';
 		$stm = $this->bd->prepare($sql);
 
 		$values = array($id);
 
 		$stm->execute($values);
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		$entries = self::daoToEntry($res);
+		$entries = self::daoToEntries($res);
 		return isset($entries[0]) ? $entries[0] : null;
 	}
 
@@ -376,6 +598,125 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return 'CONCAT(' . $s1 . ',' . $s2 . ')';	//MySQL
 	}
 
+	protected function sqlListEntriesWhere($alias = '', $filter = null, $state = FreshRSS_Entry::STATE_ALL, $order = 'DESC', $firstId = '', $date_min = 0) {
+		$search = ' ';
+		$values = array();
+		if ($state & FreshRSS_Entry::STATE_NOT_READ) {
+			if (!($state & FreshRSS_Entry::STATE_READ)) {
+				$search .= 'AND ' . $alias . 'is_read=0 ';
+			}
+		} elseif ($state & FreshRSS_Entry::STATE_READ) {
+			$search .= 'AND ' . $alias . 'is_read=1 ';
+		}
+		if ($state & FreshRSS_Entry::STATE_FAVORITE) {
+			if (!($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
+				$search .= 'AND ' . $alias . 'is_favorite=1 ';
+			}
+		} elseif ($state & FreshRSS_Entry::STATE_NOT_FAVORITE) {
+			$search .= 'AND ' . $alias . 'is_favorite=0 ';
+		}
+
+		switch ($order) {
+			case 'DESC':
+			case 'ASC':
+				break;
+			default:
+				throw new FreshRSS_EntriesGetter_Exception('Bad order in Entry->listByType: [' . $order . ']!');
+		}
+		/*if ($firstId === '' && parent::$sharedDbType === 'mysql') {
+			//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';
+		}*/
+		if ($firstId !== '') {
+			$search .= 'AND ' . $alias . 'id ' . ($order === 'DESC' ? '<=' : '>=') . $firstId . ' ';
+		}
+		if ($date_min > 0) {
+			$search .= 'AND ' . $alias . 'id >= ' . $date_min . '000000 ';
+		}
+		if ($filter) {
+			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->getAuthor()) {
+				foreach ($filter->getAuthor() as $author) {
+					$search .= 'AND ' . $alias . 'author LIKE ? ';
+					$values[] = "%{$author}%";
+				}
+			}
+			if ($filter->getIntitle()) {
+				foreach ($filter->getIntitle() as $title) {
+					$search .= 'AND ' . $alias . 'title LIKE ? ';
+					$values[] = "%{$title}%";
+				}
+			}
+			if ($filter->getTags()) {
+				foreach ($filter->getTags() as $tag) {
+					$search .= 'AND ' . $alias . 'tags LIKE ? ';
+					$values[] = "%{$tag}%";
+				}
+			}
+			if ($filter->getInurl()) {
+				foreach ($filter->getInurl() as $url) {
+					$search .= 'AND CONCAT(' . $alias . 'link, ' . $alias . 'guid) LIKE ? ';
+					$values[] = "%{$url}%";
+				}
+			}
+
+			if ($filter->getNotAuthor()) {
+				foreach ($filter->getNotAuthor() as $author) {
+					$search .= 'AND (NOT ' . $alias . 'author LIKE ?) ';
+					$values[] = "%{$author}%";
+				}
+			}
+			if ($filter->getNotIntitle()) {
+				foreach ($filter->getNotIntitle() as $title) {
+					$search .= 'AND (NOT ' . $alias . 'title LIKE ?) ';
+					$values[] = "%{$title}%";
+				}
+			}
+			if ($filter->getNotTags()) {
+				foreach ($filter->getNotTags() as $tag) {
+					$search .= 'AND (NOT ' . $alias . 'tags LIKE ?) ';
+					$values[] = "%{$tag}%";
+				}
+			}
+			if ($filter->getNotInurl()) {
+				foreach ($filter->getNotInurl() as $url) {
+					$search .= 'AND (NOT CONCAT(' . $alias . 'link, ' . $alias . 'guid) LIKE ?) ';
+					$values[] = "%{$url}%";
+				}
+			}
+
+			if ($filter->getSearch()) {
+				foreach ($filter->getSearch() as $search_value) {
+					$search .= 'AND ' . $this->sqlconcat($alias . 'title', $this->isCompressed() ? 'UNCOMPRESS(' . $alias . 'content_bin)' : '' . $alias . 'content') . ' LIKE ? ';
+					$values[] = "%{$search_value}%";
+				}
+			}
+			if ($filter->getNotSearch()) {
+				foreach ($filter->getNotSearch() as $search_value) {
+					$search .= 'AND (NOT ' . $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;
@@ -389,7 +730,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 			$joinFeed = true;
 			break;
 		case 's':	//Deprecated: use $state instead
-			$where .= 'e1.is_favorite=1 ';
+			$where .= 'e.is_favorite=1 ';
 			break;
 		case 'c':
 			$where .= 'f.category=? ';
@@ -397,125 +738,47 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 			$joinFeed = true;
 			break;
 		case 'f':
-			$where .= 'e1.id_feed=? ';
+			$where .= 'e.id_feed=? ';
 			$values[] = intval($id);
 			break;
 		case 'A':
-			$where .= '1 ';
+			$where .= '1=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_READ)) {
-				$where .= 'AND e1.is_read=0 ';
-			}
-		}
-		elseif ($state & FreshRSS_Entry::STATE_READ) {
-			$where .= 'AND e1.is_read=1 ';
-		}
-		if ($state & FreshRSS_Entry::STATE_FAVORITE) {
-			if (!($state & FreshRSS_Entry::STATE_NOT_FAVORITE)) {
-				$where .= 'AND e1.is_favorite=1 ';
-			}
-		}
-		elseif ($state & FreshRSS_Entry::STATE_NOT_FAVORITE) {
-			$where .= 'AND e1.is_favorite=0 ';
-		}
-
-		switch ($order) {
-			case 'DESC':
-			case 'ASC':
-				break;
-			default:
-				throw new FreshRSS_EntriesGetter_Exception('Bad order in Entry->listByType: [' . $order . ']!');
-		}
-		/*if ($firstId === '' && parent::$sharedDbType === 'mysql') {
-			$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 !== '') {
-			$where .= 'AND e1.id ' . ($order === 'DESC' ? '<=' : '>=') . $firstId . ' ';
-		}
-		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 .'%';
-					}
-				}
-			}
-		}
+		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
 			. $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/
 	}
 
-	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);
 
-		$sql = 'SELECT e.id, e.guid, e.title, e.author, '
-		     . ($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 '
-		     . 'INNER JOIN ('
-		     . $sql
-		     . ') e2 ON e2.id=e.id '
-		     . 'ORDER BY e.id ' . $order;
+		$sql = 'SELECT e0.id, e0.guid, e0.title, e0.author, '
+			. ($this->isCompressed() ? 'UNCOMPRESS(content_bin) AS content' : 'content')
+			. ', e0.link, e0.date, e0.is_read, e0.is_favorite, e0.id_feed, e0.tags '
+			. 'FROM `' . $this->prefix . 'entry` e0 '
+			. 'INNER JOIN ('
+			. $sql
+			. ') e2 ON e2.id=e0.id '
+			. 'ORDER BY e0.id ' . $order;
 
 		$stm = $this->bd->prepare($sql);
 		$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
@@ -527,17 +790,60 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		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);
-		$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() {
 		$sql = 'SELECT COUNT(e.id) AS count FROM `' . $this->prefix . 'entry` e INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed=f.id WHERE priority > 0'
-		     . ' UNION SELECT COUNT(e.id) AS count FROM `' . $this->prefix . 'entry` e INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed=f.id WHERE priority > 0 AND is_read=0';
+			. ' UNION SELECT COUNT(e.id) AS count FROM `' . $this->prefix . 'entry` e INNER JOIN `' . $this->prefix . 'feed` f ON e.id_feed=f.id WHERE priority > 0 AND is_read=0';
 		$stm = $this->bd->prepare($sql);
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
@@ -568,9 +874,9 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 
 	public function countUnreadReadFavorites() {
 		$sql = 'SELECT c FROM ('
-		     .	'SELECT COUNT(id) AS c, 1 as o FROM `' . $this->prefix . 'entry` WHERE is_favorite=1 '
-		     .	'UNION SELECT COUNT(id) AS c, 2 AS o FROM `' . $this->prefix . 'entry` WHERE is_favorite=1 AND is_read=0'
-		     .	') u ORDER BY o';
+			.	'SELECT COUNT(id) AS c, 1 as o FROM `' . $this->prefix . 'entry` WHERE is_favorite=1 '
+			.	'UNION SELECT COUNT(id) AS c, 2 AS o FROM `' . $this->prefix . 'entry` WHERE is_favorite=1 AND is_read=0'
+			.	') u ORDER BY o';
 		$stm = $this->bd->prepare($sql);
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 0);
@@ -579,35 +885,8 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 		return array('all' => $all, 'unread' => $unread, 'read' => $all - $unread);
 	}
 
-	public function optimizeTable() {
-		$sql = 'OPTIMIZE TABLE `' . $this->prefix . 'entry`';	//MySQL
-		$stm = $this->bd->prepare($sql);
-		$stm->execute();
-	}
-
-	public function size($all = false) {
-		$db = FreshRSS_Context::$system_conf->db;
-		$sql = 'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema=?';	//MySQL
-		$values = array($db['base']);
-		if (!$all) {
-			$sql .= ' AND table_name LIKE ?';
-			$values[] = $this->prefix . '%';
-		}
-		$stm = $this->bd->prepare($sql);
-		$stm->execute($values);
-		$res = $stm->fetchAll(PDO::FETCH_COLUMN, 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['guid'],
 				$dao['title'],
@@ -619,10 +898,21 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo {
 				$dao['is_favorite'],
 				$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);

+ 49 - 0
app/Models/EntryDAOPGSQL.php

@@ -0,0 +1,49 @@
+<?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) {
+		if (isset($errorInfo[0])) {
+			if ($errorInfo[0] === '42P01' && stripos($errorInfo[2], 'entrytmp') !== false) {	//undefined_table
+				return $this->createEntryTempTable();
+			}
+		}
+		return false;
+	}
+
+	protected function addColumn($name) {
+		return false;
+	}
+
+	public function commitNewEntries() {
+		$sql = 'DO $$
+DECLARE
+maxrank bigint := (SELECT MAX(id) FROM `' . $this->prefix . 'entrytmp`);
+rank bigint := (SELECT maxrank - COUNT(*) FROM `' . $this->prefix . 'entrytmp`);
+BEGIN
+	INSERT INTO `' . $this->prefix . 'entry` (id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags)
+		(SELECT rank + row_number() OVER(ORDER BY date) AS id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags
+			FROM `' . $this->prefix . 'entrytmp` AS etmp
+			WHERE NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'entry` AS ereal WHERE etmp.id_feed = ereal.id_feed AND etmp.guid = ereal.guid)
+			ORDER BY date);
+	DELETE FROM `' . $this->prefix . 'entrytmp` WHERE id <= maxrank;
+END $$;';
+		$hadTransaction = $this->bd->inTransaction();
+		if (!$hadTransaction) {
+			$this->bd->beginTransaction();
+		}
+		$result = $this->bd->exec($sql) !== false;
+		if (!$hadTransaction) {
+			$this->bd->commit();
+		}
+		return $result;
+	}
+}

+ 110 - 20
app/Models/EntryDAOSQLite.php

@@ -2,23 +2,115 @@
 
 class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 
+	public function sqlHexDecode($x) {
+		return $x;
+	}
+
+	protected function autoUpdateDb($errorInfo) {
+		Minz_Log::error('FreshRSS_EntryDAO::autoUpdateDb error: ' . print_r($errorInfo, true));
+		if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entrytmp'")) {
+			$showCreate = $tableInfo->fetchColumn();
+			if (stripos($showCreate, 'entrytmp') === false) {
+				return $this->createEntryTempTable();
+			}
+		}
+		if ($tableInfo = $this->bd->query("SELECT sql FROM sqlite_master where name='entry'")) {
+			$showCreate = $tableInfo->fetchColumn();
+			foreach (array('lastSeen', 'hash') as $column) {
+				if (stripos($showCreate, $column) === false) {
+					return $this->addColumn($column);
+				}
+			}
+		}
+		return false;
+	}
+
+	public function commitNewEntries() {
+		$sql = '
+			CREATE TEMP TABLE `tmp` AS
+				SELECT
+					id,
+					guid,
+					title,
+					author,
+					content,
+					link,
+					date,
+					`lastSeen`,
+					hash, is_read,
+					is_favorite,
+					id_feed,
+					tags
+				FROM `' . $this->prefix . 'entrytmp`
+				ORDER BY date;
+				INSERT OR IGNORE INTO `' . $this->prefix . 'entry`
+					(
+						id,
+						guid,
+						title,
+						author,
+						content,
+						link,
+						date,
+						`lastSeen`,
+						hash,
+						is_read,
+						is_favorite,
+						id_feed,
+						tags
+					)
+				SELECT rowid + (SELECT MAX(id) - COUNT(*) FROM `tmp`) AS
+					id,
+					guid,
+					title,
+					author,
+					content,
+					link,
+					date,
+					`lastSeen`,
+					hash,
+					is_read,
+					is_favorite,
+					id_feed,
+					tags
+				FROM `tmp`
+				ORDER BY date;
+			DELETE FROM `' . $this->prefix . 'entrytmp`
+			WHERE id <= (SELECT MAX(id)
+			FROM `tmp`);
+			DROP TABLE `tmp`;';
+		$hadTransaction = $this->bd->inTransaction();
+		if (!$hadTransaction) {
+			$this->bd->beginTransaction();
+		}
+		$result = $this->bd->exec($sql) !== false;
+		if (!$hadTransaction) {
+			$this->bd->commit();
+		}
+		return $result;
+	}
+
 	protected function sqlConcat($s1, $s2) {
 		return $s1 . '||' . $s2;
 	}
 
 	protected function updateCacheUnreads($catId = false, $feedId = false) {
 		$sql = 'UPDATE `' . $this->prefix . 'feed` '
-		 . 'SET cache_nbUnreads=('
+		 . 'SET `cache_nbUnreads`=('
 		 .	'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();
 		if ($feedId !== false) {
-			$sql .= ' AND id=?';
+			$sql .= $hasWhere ? ' AND' : ' WHERE';
+			$hasWhere = true;
+			$sql .= ' id=?';
 			$values[] = $feedId;
 		}
 		if ($catId !== false) {
-			$sql .= ' AND category=?';
+			$sql .= $hasWhere ? ' AND' : ' WHERE';
+			$hasWhere = true;
+			$sql .= ' category=?';
 			$values[] = $catId;
 		}
 		$stm = $this->bd->prepare($sql);
@@ -66,7 +158,7 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 			}
 			$affected = $stm->rowCount();
 			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=?)';
 				$values = array($ids);
 				$stm = $this->bd->prepare($sql);
@@ -103,7 +195,7 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	 * @param integer $priorityMin
 	 * @return integer affected rows
 	 */
-	public function markReadEntries($idMax = 0, $onlyFavorites = false, $priorityMin = 0) {
+	public function markReadEntries($idMax = 0, $onlyFavorites = false, $priorityMin = 0, $filter = null, $state = 0) {
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadEntries(0) is deprecated!');
@@ -116,8 +208,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) . ')';
 		}
 		$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();
 			Minz_Log::error('SQL error markReadEntries: ' . $info[2]);
 			return false;
@@ -140,7 +235,7 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 	 * @param integer $idMax fail safe article ID
 	 * @return integer affected rows
 	 */
-	public function markReadCat($id, $idMax = 0) {
+	public function markReadCat($id, $idMax = 0, $filter = null, $state = 0) {
 		if ($idMax == 0) {
 			$idMax = time() . '000000';
 			Minz_Log::debug('Calling markReadCat(0) is deprecated!');
@@ -151,8 +246,11 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 			 . 'WHERE is_read=0 AND id <= ? AND '
 			 . 'id_feed IN (SELECT f.id FROM `' . $this->prefix . 'feed` f WHERE f.category=?)';
 		$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();
 			Minz_Log::error('SQL error markReadCat: ' . $info[2]);
 			return false;
@@ -163,12 +261,4 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		}
 		return $affected;
 	}
-
-	public function optimizeTable() {
-		//TODO: Search for an equivalent in SQLite
-	}
-
-	public function size($all = false) {
-		return @filesize(join_path(DATA_PATH, 'users', $this->current_user, 'db.sqlite'));
-	}
 }

+ 22 - 18
app/Models/Factory.php

@@ -3,38 +3,42 @@
 class FreshRSS_Factory {
 
 	public static function createFeedDao($username = null) {
-		$conf = Minz_Configuration::get('system');
-		if ($conf->db['type'] === 'sqlite') {
-			return new FreshRSS_FeedDAOSQLite($username);
-		} else {
-			return new FreshRSS_FeedDAO($username);
-		}
+		return new FreshRSS_FeedDAO($username);
 	}
 
 	public static function createEntryDao($username = null) {
 		$conf = Minz_Configuration::get('system');
-		if ($conf->db['type'] === 'sqlite') {
-			return new FreshRSS_EntryDAOSQLite($username);
-		} else {
-			return new FreshRSS_EntryDAO($username);
+		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) {
 		$conf = Minz_Configuration::get('system');
-		if ($conf->db['type'] === 'sqlite') {
-			return new FreshRSS_StatsDAOSQLite($username);
-		} else {
-			return new FreshRSS_StatsDAO($username);
+		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) {
 		$conf = Minz_Configuration::get('system');
-		if ($conf->db['type'] === 'sqlite') {
-			return new FreshRSS_DatabaseDAOSQLite($username);
-		} else {
-			return new FreshRSS_DatabaseDAO($username);
+		switch ($conf->db['type']) {
+			case 'sqlite':
+				return new FreshRSS_DatabaseDAOSQLite($username);
+			case 'pgsql':
+				return new FreshRSS_DatabaseDAOPGSQL($username);
+			default:
+				return new FreshRSS_DatabaseDAO($username);
 		}
 	}
 

+ 203 - 22
app/Models/Feed.php

@@ -19,8 +19,10 @@ class FreshRSS_Feed extends Minz_Model {
 	private $ttl = -2;
 	private $hash = null;
 	private $lockPath = '';
+	private $hubUrl = '';
+	private $selfUrl = '';
 
-	public function __construct($url, $validate=true) {
+	public function __construct($url, $validate = true) {
 		if ($validate) {
 			$this->_url($url);
 		} else {
@@ -49,6 +51,12 @@ class FreshRSS_Feed extends Minz_Model {
 	public function url() {
 		return $this->url;
 	}
+	public function selfUrl() {
+		return $this->selfUrl;
+	}
+	public function hubUrl() {
+		return $this->hubUrl;
+	}
 	public function category() {
 		return $this->category;
 	}
@@ -96,6 +104,16 @@ class FreshRSS_Feed extends Minz_Model {
 	public function 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() {
 		if ($this->nbEntries < 0) {
 			$feedDAO = FreshRSS_Factory::createFeedDao();
@@ -113,13 +131,26 @@ class FreshRSS_Feed extends Minz_Model {
 		return $this->nbNotRead;
 	}
 	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) {
@@ -134,7 +165,7 @@ class FreshRSS_Feed extends Minz_Model {
 	public function _id($value) {
 		$this->id = $value;
 	}
-	public function _url($value, $validate=true) {
+	public function _url($value, $validate = true) {
 		$this->hash = null;
 		if ($validate) {
 			$value = checkUrl($value);
@@ -151,7 +182,7 @@ class FreshRSS_Feed extends Minz_Model {
 	public function _name($value) {
 		$this->name = $value === null ? '' : $value;
 	}
-	public function _website($value, $validate=true) {
+	public function _website($value, $validate = true) {
 		if ($validate) {
 			$value = checkUrl($value);
 		}
@@ -198,7 +229,7 @@ class FreshRSS_Feed extends Minz_Model {
 		$this->nbEntries = intval($value);
 	}
 
-	public function load($loadDetails = false) {
+	public function load($loadDetails = false, $noCache = false) {
 		if ($this->url !== null) {
 			if (CACHE_PATH === false) {
 				throw new Minz_FileNotExistException(
@@ -223,9 +254,16 @@ class FreshRSS_Feed extends Minz_Model {
 
 				if ((!$mtime) || $feed->error()) {
 					$errorMessage = $feed->error();
-					throw new FreshRSS_Feed_Exception(($errorMessage == '' ? 'Feed error' : $errorMessage) . ' [' . $url . ']');
+					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) {
 					// si on a utilisé l'auto-discover, notre url va avoir changé
 					$subscribe_url = $feed->subscribe_url(false);
@@ -240,16 +278,16 @@ class FreshRSS_Feed extends Minz_Model {
 					$subscribe_url = $feed->subscribe_url(true);
 				}
 
-				$clean_url = url_remove_credentials($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)) {
-					Minz_Log::notice('FreshRSS no cache ' . $mtime . ' > ' . $this->lastUpdate . ' for ' . $clean_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
 				} else {
-					Minz_Log::notice('FreshRSS use cache for ' . $clean_url);
+					//Minz_Log::debug('FreshRSS use cache for ' . $clean_url);
 					$this->entries = array();
 				}
 
@@ -259,7 +297,7 @@ class FreshRSS_Feed extends Minz_Model {
 		}
 	}
 
-	private function loadEntries($feed) {
+	public function loadEntries($feed) {
 		$entries = array();
 
 		foreach ($feed->get_items() as $item) {
@@ -282,15 +320,19 @@ class FreshRSS_Feed extends Minz_Model {
 			$elinks = array();
 			foreach ($item->get_enclosures() as $enclosure) {
 				$elink = $enclosure->get_link();
-				if (empty($elinks[$elink])) {
+				if ($elink != '' && empty($elinks[$elink])) {
 					$elinks[$elink] = '1';
 					$mime = strtolower($enclosure->get_type());
 					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) {
-						$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) {
-						$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>';
+					} elseif (strpos($mime, 'application/') === 0 || strpos($mime, 'text/') === 0) {
+						$content .= '<p class="enclosure"><a download="" href="' . $elink . '">💾</a></p>';
 					} else {
 						unset($elinks[$elink]);
 					}
@@ -299,9 +341,9 @@ class FreshRSS_Feed extends Minz_Model {
 
 			$entry = new FreshRSS_Entry(
 				$this->id(),
-				$item->get_id(),
+				$item->get_id(false, false),
 				$title === null ? '' : $title,
-				$author === null ? '' : html_only_entity_decode($author->name),
+				$author === null ? '' : html_only_entity_decode(strip_tags($author->name)),
 				$content === null ? '' : $content,
 				$link === null ? '' : $link,
 				$date ? $date : time()
@@ -317,6 +359,10 @@ class FreshRSS_Feed extends Minz_Model {
 		$this->entries = $entries;
 	}
 
+	function cacheModifiedTime() {
+		return @filemtime(CACHE_PATH . '/' . md5($this->url) . '.spc');
+	}
+
 	function lock() {
 		$this->lockPath = TMP_PATH . '/' . $this->hash() . '.freshrss.lock';
 		if (file_exists($this->lockPath) && ((time() - @filemtime($this->lockPath)) > 3600)) {
@@ -333,4 +379,139 @@ class FreshRSS_Feed extends Minz_Model {
 	function unlock() {
 		@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));
+			Minz_Log::warning('Set error to ' . ($error ? 1 : 0) . ' for ' . $url, PSHB_LOG);
+		}
+		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);
+					Minz_Log::warning($text, PSHB_LOG);
+					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);
+					Minz_Log::warning($text, PSHB_LOG);
+					$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);
+				$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);
+				Minz_Log::debug($text, PSHB_LOG);
+			}
+			$currentUser = Minz_Session::param('currentUser');
+			if (FreshRSS_user_Controller::checkUsername($currentUser) && !file_exists($path . '/' . $currentUser . '.txt')) {
+				touch($path . '/' . $currentUser . '.txt');
+			}
+		}
+		return $key;
+	}
+
+	//Parameter true to subscribe, false to unsubscribe.
+	function pubSubHubbubSubscribe($state) {
+		$url = $this->selfUrl ? $this->selfUrl : $this->url;
+		if (FreshRSS_Context::$system_conf->base_url && $url) {
+			$hubFilename = PSHB_PATH . '/feeds/' . base64url_encode($url) . '/!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']) || empty($hubJson['hub'])) {
+				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 => $hubJson['hub'],
+					CURLOPT_RETURNTRANSFER => true,
+					CURLOPT_POSTFIELDS => http_build_query(array(
+						'hub.verify' => 'sync',
+						'hub.mode' => $state ? 'subscribe' : 'unsubscribe',
+						'hub.topic' => $url,
+						'hub.callback' => $callbackUrl,
+						)),
+					CURLOPT_USERAGENT => FRESHRSS_USERAGENT,
+					CURLOPT_MAXREDIRS => 10,
+				));
+			curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);	//Keep option separated for open_basedir bug
+			if (defined('CURLOPT_ENCODING')) {
+				curl_setopt($ch, CURLOPT_ENCODING, '');	//Enable all encodings
+			}
+			$response = curl_exec($ch);
+			$info = curl_getinfo($ch);
+
+			Minz_Log::warning('PubSubHubbub ' . ($state ? 'subscribe' : 'unsubscribe') . ' to ' . $url .
+				' with callback ' . $callbackUrl . ': ' . $info['http_code'] . ' ' . $response, PSHB_LOG);
+
+			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>
 }

+ 82 - 46
app/Models/FeedDAO.php

@@ -1,10 +1,29 @@
 <?php
 
-class FreshRSS_FeedDAO extends Minz_ModelPdo {
+class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	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);
 
+		$valuesTmp['url'] = safe_ascii($valuesTmp['url']);
+		$valuesTmp['website'] = safe_ascii($valuesTmp['website']);
+
 		$values = array(
 			substr($valuesTmp['url'], 0, 511),
 			$valuesTmp['category'],
@@ -16,7 +35,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		);
 
 		if ($stm && $stm->execute($values)) {
-			return $this->bd->lastInsertId();
+			return $this->bd->lastInsertId('"' . $this->prefix . 'feed_id_seq"');
 		} else {
 			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
 			Minz_Log::error('SQL error addFeed: ' . $info[2]);
@@ -55,9 +74,16 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 	}
 
 	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 = '';
 		foreach ($valuesTmp as $key => $v) {
-			$set .= $key . '=?, ';
+			$set .= '`' . $key . '`=?, ';
 
 			if ($key == 'httpAuth') {
 				$valuesTmp[$key] = base64_encode($v);
@@ -82,25 +108,15 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		}
 	}
 
-	public function updateLastUpdate($id, $inError = 0, $updateCache = true) {
-		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
-			     . '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=?';
-		} else {
-			$sql = 'UPDATE `' . $this->prefix . 'feed` '
-			     . 'SET lastUpdate=?, error=? '
-			     . 'WHERE id=?';
-		}
-
+	public function updateLastUpdate($id, $inError = false, $mtime = 0) {	//See also updateCachedValue()
+		$sql = 'UPDATE `' . $this->prefix . 'feed` '
+		     . 'SET `lastUpdate`=?, error=? '
+		     . 'WHERE id=?';
 		$values = array(
-			time(),
-			$inError,
+			$mtime <= 0 ? time() : $mtime,
+			$inError ? 1 : 0,
 			$id,
 		);
-
 		$stm = $this->bd->prepare($sql);
 
 		if ($stm && $stm->execute($values)) {
@@ -198,6 +214,13 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		}
 	}
 
+	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() {
 		$sql = 'SELECT * FROM `' . $this->prefix . 'feed` ORDER BY name';
 		$stm = $this->bd->prepare($sql);
@@ -222,14 +245,14 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		return $feedCategoryNames;
 	}
 
+	/**
+	 * Use $defaultCacheDuration == -1 to return all feeds, without filtering them by TTL.
+	 */
 	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` '
-		     . '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);
 		if (!($stm && $stm->execute())) {
 			$sql2 = 'ALTER TABLE `' . $this->prefix . 'feed` ADD COLUMN ttl INT NOT NULL DEFAULT -2';	//v0.7.3
@@ -273,18 +296,28 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		return $res[0]['count'];
 	}
 
-	public function updateCachedValues() {	//For one single feed, call updateLastUpdate($id)
-		$sql = 'UPDATE `' . $this->prefix . 'feed` f '
-		     . 'INNER JOIN ('
-		     .	'SELECT e.id_feed, '
-		     .	'COUNT(CASE WHEN e.is_read = 0 THEN 1 END) AS nbUnreads, '
-		     .	'COUNT(e.id) AS nbEntries '
-		     .	'FROM `' . $this->prefix . 'entry` e '
-		     .	'GROUP BY e.id_feed'
-		     . ') x ON x.id_feed=f.id '
-		     . 'SET f.cache_nbEntries=x.nbEntries, f.cache_nbUnreads=x.nbUnreads';
+	public function updateCachedValue($id) {	//For multiple feeds, call updateCachedValues()
+		$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) '
+		     . 'WHERE id=?';
+		$values = array($id);
 		$stm = $this->bd->prepare($sql);
 
+		if ($stm && $stm->execute($values)) {
+			return $stm->rowCount();
+		} else {
+			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
+			Minz_Log::error('SQL error updateCachedValue: ' . $info[2]);
+			return false;
+		}
+	}
+
+	public function updateCachedValues() {	//For one single feed, call updateCachedValue($id)
+		$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)';
+		$stm = $this->bd->prepare($sql);
 		if ($stm && $stm->execute()) {
 			return $stm->rowCount();
 		} else {
@@ -308,7 +341,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		$affected = $stm->rowCount();
 
 		$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);
 		$stm = $this->bd->prepare($sql);
 		if (!($stm && $stm->execute($values))) {
@@ -322,17 +355,20 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 		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 updateCachedValue($id) or updateCachedValues() just after
 		$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);
 
-		$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()) {
 			return $stm->rowCount();
@@ -360,7 +396,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 			if ($catID === null) {
 				$category = isset($dao['category']) ? $dao['category'] : 0;
 			} else {
-				$category = $catID ;
+				$category = $catID;
 			}
 
 			$myFeed = new FreshRSS_Feed(isset($dao['url']) ? $dao['url'] : '', false);

+ 0 - 19
app/Models/FeedDAOSQLite.php

@@ -1,19 +0,0 @@
-<?php
-
-class FreshRSS_FeedDAOSQLite extends FreshRSS_FeedDAO {
-
-	public function updateCachedValues() {	//For one single feed, call updateLastUpdate($id)
-		$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)';
-		$stm = $this->bd->prepare($sql);
-		if ($stm && $stm->execute()) {
-			return $stm->rowCount();
-		} else {
-			$info = $stm == null ? array(2 => 'syntax error') : $stm->errorInfo();
-			Minz_Log::error('SQL error updateCachedValues: ' . $info[2]);
-			return false;
-		}
-	}
-
-}

+ 5 - 0
app/Models/LogDAO.php

@@ -21,5 +21,10 @@ class FreshRSS_LogDAO {
 
 	public static function truncate() {
 		file_put_contents(join_path(DATA_PATH, 'users', Minz_Session::param('currentUser', '_'), 'log.txt'), '');
+		if (FreshRSS_Auth::hasAccess('admin')) {
+			file_put_contents(ADMIN_LOG, '');
+			file_put_contents(API_LOG, '');
+			file_put_contents(PSHB_LOG, '');
+		}
 	}
 }

+ 339 - 0
app/Models/Search.php

@@ -0,0 +1,339 @@
+<?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;
+
+	private $not_intitle;
+	private $not_inurl;
+	private $not_author;
+	private $not_tags;
+	private $not_search;
+
+	public function __construct($input) {
+		if ($input == '') {
+			return;
+		}
+		$this->raw_input = $input;
+
+		$input = preg_replace('/:&quot;(.*?)&quot;/', ':"\1"', $input);
+
+		$input = $this->parseNotIntitleSearch($input);
+		$input = $this->parseNotAuthorSearch($input);
+		$input = $this->parseNotInurlSearch($input);
+		$input = $this->parseNotTagsSeach($input);
+
+		$input = $this->parsePubdateSearch($input);
+		$input = $this->parseDateSearch($input);
+
+		$input = $this->parseIntitleSearch($input);
+		$input = $this->parseAuthorSearch($input);
+		$input = $this->parseInurlSearch($input);
+		$input = $this->parseTagsSeach($input);
+
+		$input = $this->parseNotSearch($input);
+		$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 getNotIntitle() {
+		return $this->not_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 getNotInurl() {
+		return $this->not_inurl;
+	}
+
+	public function getAuthor() {
+		return $this->author;
+	}
+	public function getNotAuthor() {
+		return $this->not_author;
+	}
+
+	public function getTags() {
+		return $this->tags;
+	}
+	public function getNotTags() {
+		return $this->not_tags;
+	}
+
+	public function getSearch() {
+		return $this->search;
+	}
+	public function getNotSearch() {
+		return $this->not_search;
+	}
+
+	private static function removeEmptyValues($anArray) {
+		return is_array($anArray) ? array_filter($anArray, function($value) { return $value !== ''; }) : array();
+	}
+
+	/**
+	 * 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_all('/\bintitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->intitle = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/\bintitle:(?P<search>\w*)/', $input, $matches)) {
+			$this->intitle = array_merge($this->intitle ? $this->intitle : array(), $matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->intitle = self::removeEmptyValues($this->intitle);
+		return $input;
+	}
+
+	private function parseNotIntitleSearch($input) {
+		if (preg_match_all('/[!-]intitle:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->not_intitle = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/[!-]intitle:(?P<search>\w*)/', $input, $matches)) {
+			$this->not_intitle = array_merge($this->not_intitle ? $this->not_intitle : array(), $matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->not_intitle = self::removeEmptyValues($this->not_intitle);
+		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_all('/\bauthor:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->author = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/\bauthor:(?P<search>\w*)/', $input, $matches)) {
+			$this->author = array_merge($this->author ? $this->author : array(), $matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->author = self::removeEmptyValues($this->author);
+		return $input;
+	}
+
+	private function parseNotAuthorSearch($input) {
+		if (preg_match_all('/[!-]author:(?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->not_author = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		if (preg_match_all('/[!-]author:(?P<search>\w*)/', $input, $matches)) {
+			$this->not_author = array_merge($this->not_author ? $this->not_author : array(), $matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->not_author = self::removeEmptyValues($this->not_author);
+		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.
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private function parseInurlSearch($input) {
+		if (preg_match_all('/\binurl:(?P<search>[^\s]*)/', $input, $matches)) {
+			$this->inurl = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->inurl = self::removeEmptyValues($this->inurl);
+		return $input;
+	}
+
+	private function parseNotInurlSearch($input) {
+		if (preg_match_all('/[!-]inurl:(?P<search>[^\s]*)/', $input, $matches)) {
+			$this->not_inurl = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->not_inurl = self::removeEmptyValues($this->not_inurl);
+		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_all('/\bdate:(?P<search>[^\s]*)/', $input, $matches)) {
+			$input = str_replace($matches[0], '', $input);
+			$dates = self::removeEmptyValues($matches['search']);
+			if (!empty($dates[0])) {
+				list($this->min_date, $this->max_date) = parseDateInterval($dates[0]);
+			}
+		}
+		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_all('/\bpubdate:(?P<search>[^\s]*)/', $input, $matches)) {
+			$input = str_replace($matches[0], '', $input);
+			$dates = self::removeEmptyValues($matches['search']);
+			if (!empty($dates[0])) {
+				list($this->min_pubdate, $this->max_pubdate) = parseDateInterval($dates[0]);
+			}
+		}
+		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'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->tags = self::removeEmptyValues($this->tags);
+		return $input;
+	}
+
+	private function parseNotTagsSeach($input) {
+		if (preg_match_all('/[!-]#(?P<search>[^\s]+)/', $input, $matches)) {
+			$this->not_tags = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->not_tags = self::removeEmptyValues($this->not_tags);
+		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 = self::cleanSearch($input);
+		if ($input == '') {
+			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 = self::cleanSearch($input);
+		if ($input == '') {
+			return;
+		}
+		if (is_array($this->search)) {
+			$this->search = array_merge($this->search, explode(' ', $input));
+		} else {
+			$this->search = explode(' ', $input);
+		}
+	}
+
+	private function parseNotSearch($input) {
+		$input = self::cleanSearch($input);
+		if ($input == '') {
+			return;
+		}
+		if (preg_match_all('/[!-](?P<delim>[\'"])(?P<search>.*)(?P=delim)/U', $input, $matches)) {
+			$this->not_search = $matches['search'];
+			$input = str_replace($matches[0], '', $input);
+		}
+		if ($input == '') {
+			return;
+		}
+		if (preg_match_all('/[!-](?P<search>[^\s]+)/', $input, $matches)) {
+			$this->not_search = array_merge(is_array($this->not_search) ? $this->not_search : array(), $matches['search']);
+			$input = str_replace($matches[0], '', $input);
+		}
+		$this->not_search = self::removeEmptyValues($this->not_search);
+		return $input;
+	}
+
+	/**
+	 * Remove all unnecessary spaces in the search
+	 *
+	 * @param string $input
+	 * @return string
+	 */
+	private static 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);
+}

+ 33 - 8
app/Models/Share.php

@@ -21,9 +21,11 @@ class FreshRSS_Share {
 		}
 
 		$help_url = isset($share_options['help']) ? $share_options['help'] : '';
+		$field = isset($share_options['field']) ? $share_options['field'] : null;
 		self::$list_sharing[$type] = new FreshRSS_Share(
 			$type, $share_options['url'], $share_options['transform'],
-			$share_options['form'], $help_url
+			$share_options['form'], $help_url, $share_options['method'],
+			$field
 		);
 	}
 
@@ -76,6 +78,8 @@ class FreshRSS_Share {
 	private $base_url = null;
 	private $title = null;
 	private $link = null;
+	private $method = 'GET';
+	private $field;
 
 	/**
 	 * Create a FreshRSS_Share object.
@@ -86,9 +90,10 @@ class FreshRSS_Share {
 	 *        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.
+	 * @param $method defines the sharing method (GET or POST)
 	 */
-	private function __construct($type, $url_transform, $transform = array(),
-	                             $form_type, $help_url = '') {
+	private function __construct($type, $url_transform, $transform,
+	                             $form_type, $help_url, $method, $field) {
 		$this->type = $type;
 		$this->name = _t('gen.share.' . $type);
 		$this->url_transform = $url_transform;
@@ -103,6 +108,11 @@ class FreshRSS_Share {
 			$form_type = 'simple';
 		}
 		$this->form_type = $form_type;
+		if (!in_array($method, array('GET', 'POST'))) {
+			$method = 'GET';
+		}
+		$this->method = $method;
+		$this->field = $field;
 	}
 
 	/**
@@ -116,14 +126,14 @@ class FreshRSS_Share {
 			'url' => 'base_url',
 			'title' => 'title',
 			'link' => 'link',
+			'method' => 'method',
+			'field' => 'field',
 		);
 
 		foreach ($options as $key => $value) {
-			if (!isset($available_options[$key])) {
-				continue;
+			if (isset($available_options[$key])) {
+				$this->{$available_options[$key]} = $value;
 			}
-
-			$this->$available_options[$key] = $value;
 		}
 	}
 
@@ -134,6 +144,21 @@ class FreshRSS_Share {
 		return $this->type;
 	}
 
+	/**
+	 * Return the current method of the share option.
+	 */
+	public function method() {
+		return $this->method;
+	}
+
+	/**
+	 * Return the current field of the share option. It's null for shares
+	 * using the GET method.
+	 */
+	public function field() {
+		return $this->field;
+	}
+
 	/**
 	 * Return the current form type of the share option.
 	 */
@@ -152,7 +177,7 @@ class FreshRSS_Share {
 	 * Return the current name of the share option.
 	 */
 	public function name($real = false) {
-		if ($real || is_null($this->custom_name)) {
+		if ($real || is_null($this->custom_name) || empty($this->custom_name)) {
 			return $this->name;
 		} else {
 			return $this->custom_name;

+ 43 - 78
app/Models/StatsDAO.php

@@ -4,6 +4,10 @@ class FreshRSS_StatsDAO extends Minz_ModelPdo {
 
 	const ENTRY_COUNT_PERIOD = 30;
 
+	protected function sqlFloor($s) {
+		return "FLOOR($s)";
+	}
+
 	/**
 	 * Calculates entry repartition for all feeds and for main stream.
 	 *
@@ -37,12 +41,12 @@ class FreshRSS_StatsDAO extends Minz_ModelPdo {
 			$filter .= "AND e.id_feed = {$feed}";
 		}
 		$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
 {$filter}
 SQL;
@@ -55,20 +59,22 @@ SQL;
 
 	/**
 	 * 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() {
 		$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
+		$sqlDay = $this->sqlFloor("(date - $midnight) / 86400");
 		$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
 ORDER BY day ASC
 SQL;
@@ -80,28 +86,7 @@ SQL;
 			$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;
 	}
 
 	/**
@@ -158,7 +143,7 @@ SQL;
 		$sql = <<<SQL
 SELECT DATE_FORMAT(FROM_UNIXTIME(e.date), '{$period}') AS period
 , COUNT(1) AS count
-FROM {$this->prefix}entry AS e
+FROM `{$this->prefix}entry` AS e
 {$restrict}
 GROUP BY period
 ORDER BY period ASC
@@ -168,11 +153,12 @@ SQL;
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_NAMED);
 
+		$repartition = array();
 		foreach ($res as $value) {
 			$repartition[(int) $value['period']] = (int) $value['count'];
 		}
 
-		return $this->convertToSerie($repartition);
+		return $repartition;
 	}
 
 	/**
@@ -221,7 +207,7 @@ SQL;
 SELECT COUNT(1) AS count
 , MIN(date) AS date_min
 , MAX(date) AS date_max
-FROM {$this->prefix}entry AS e
+FROM `{$this->prefix}entry` AS e
 {$restrict}
 SQL;
 		$stm = $this->bd->prepare($sql);
@@ -257,16 +243,16 @@ SQL;
 
 	/**
 	 * 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() {
 		$sql = <<<SQL
 SELECT c.name AS label
 , 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
 GROUP BY label
 ORDER BY data DESC
@@ -275,22 +261,22 @@ SQL;
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 
-		return $this->convertToPieSerie($res);
+		return $res;
 	}
 
 	/**
 	 * Calculates entry count per category.
 	 * Returns the result as a JSON string.
 	 *
-	 * @return string
+	 * @return JSON object
 	 */
 	public function calculateEntryByCategory() {
 		$sql = <<<SQL
 SELECT c.name AS label
 , 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
 AND f.id = e.id_feed
 GROUP BY label
@@ -300,7 +286,7 @@ SQL;
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 
-		return $this->convertToPieSerie($res);
+		return $res;
 	}
 
 	/**
@@ -314,9 +300,9 @@ SELECT f.id AS id
 , MAX(f.name) AS name
 , MAX(c.name) AS category
 , 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
 AND f.id = e.id_feed
 GROUP BY f.id
@@ -339,8 +325,8 @@ SELECT MAX(f.id) as id
 , MAX(f.name) AS name
 , MAX(date) AS last_date
 , 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
 GROUP BY f.id
 ORDER BY name
@@ -350,27 +336,6 @@ SQL;
 		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
 	 *
@@ -399,7 +364,7 @@ SQL;
 			'feb',
 			'mar',
 			'apr',
-			'may',
+			'may_',
 			'jun',
 			'jul',
 			'aug',
@@ -411,17 +376,17 @@ SQL;
 	}
 
 	/**
-	 * Translates array content and encode it as JSON
+	 * Translates array content
 	 *
 	 * @param array $data
-	 * @return string
+	 * @return JSON object
 	 */
 	private function convertToTranslatedJson($data = array()) {
 		$translated = array_map(function($a) {
 			return _t('gen.date.' . $a);
 		}, $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 {
 
-	/**
-	 * 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) {
@@ -66,7 +15,7 @@ SQL;
 		$sql = <<<SQL
 SELECT strftime('{$period}', e.date, 'unixepoch') AS period
 , COUNT(1) AS count
-FROM {$this->prefix}entry AS e
+FROM `{$this->prefix}entry` AS e
 {$restrict}
 GROUP BY period
 ORDER BY period ASC
@@ -81,7 +30,7 @@ SQL;
 			$repartition[(int) $value['period']] = (int) $value['count'];
 		}
 
-		return $this->convertToSerie($repartition);
+		return $repartition;
 	}
 
 }

+ 3 - 9
app/Models/Themes.php

@@ -25,7 +25,7 @@ class FreshRSS_Themes extends Minz_Model {
 	}
 
 	public static function get_infos($theme_id) {
-		$theme_dir = PUBLIC_PATH . self::$themesUrl . $theme_id ;
+		$theme_dir = PUBLIC_PATH . self::$themesUrl . $theme_id;
 		if (is_dir($theme_dir)) {
 			$json_filename = $theme_dir . '/metadata.json';
 			if (file_exists($json_filename)) {
@@ -109,14 +109,8 @@ class FreshRSS_Themes extends Minz_Model {
 		}
 
 		$url = $name . '.svg';
-		$url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) :
-			(self::$defaultIconsUrl . $url);
+		$url = isset(self::$themeIcons[$url]) ? (self::$themeIconsUrl . $url) : (self::$defaultIconsUrl . $url);
 
-		return $urlOnly ? Minz_Url::display($url) :
-			'<img class="icon" src="' . Minz_Url::display($url) . '" alt="' . $alts[$name] . '" />';
+		return $urlOnly ? Minz_Url::display($url) : '<img class="icon" src="' . Minz_Url::display($url) . '" alt="' . $alts[$name] . '" />';
 	}
 }
-
-function _i($icon, $url_only = false) {
-	return FreshRSS_Themes::icon($icon, $url_only);
-}

+ 49 - 18
app/Models/UserDAO.php

@@ -1,34 +1,62 @@
 <?php
 
 class FreshRSS_UserDAO extends Minz_ModelPdo {
-	public function createUser($username) {
+	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');
 
 		$userPDO = new Minz_ModelPdo($username);
 
-		$ok = false;
-		if (defined('SQL_CREATE_TABLES')) {	//E.g. MySQL
-			$sql = sprintf(SQL_CREATE_TABLES, $db['prefix'] . $username . '_', _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, '', _t('gen.short.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 . SQL_CREATE_TABLE_ENTRYTMP, $bd_prefix_user, _t('gen.short.default_category'));
+				$stm = $userPDO->bd->prepare($sql);
+				$ok = $stm && $stm->execute();
+			} else {	//E.g. SQLite
+				global $SQL_CREATE_TABLES;
+				global $SQL_CREATE_TABLE_ENTRYTMP;
+				if (is_array($SQL_CREATE_TABLES)) {
+					$instructions = array_merge($SQL_CREATE_TABLES, $SQL_CREATE_TABLE_ENTRYTMP);
+					$ok = !empty($instructions);
+					foreach ($instructions as $instruction) {
+						$sql = sprintf($instruction, $bd_prefix_user, _t('gen.short.default_category'));
+						$stm = $userPDO->bd->prepare($sql);
+						$ok &= ($stm && $stm->execute());
+					}
+				}
+			}
+			if ($ok && $insertDefaultFeeds) {
+				if (defined('SQL_INSERT_FEEDS')) {	//E.g. MySQL
+					$sql = sprintf(SQL_INSERT_FEEDS, $bd_prefix_user);
 					$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) {
 			return true;
 		} else {
 			$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;
 		}
 	}
@@ -55,14 +83,17 @@ class FreshRSS_UserDAO extends Minz_ModelPdo {
 	}
 
 	public static function exist($username) {
-		return is_dir(join_path(DATA_PATH , 'users', $username));
+		return is_dir(join_path(DATA_PATH, 'users', $username));
 	}
 
-	public static function touch($username) {
-		return touch(join_path(DATA_PATH , 'users', $username, 'config.php'));
+	public static function touch($username = '') {
+		if (!FreshRSS_user_Controller::checkUsername($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'));
+		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;
+	}
+
+}

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

@@ -1,21 +1,23 @@
 <?php
+define('SQL_CREATE_DB', 'CREATE DATABASE IF NOT EXISTS %1$s DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
+
 define('SQL_CREATE_TABLES', '
 CREATE TABLE IF NOT EXISTS `%1$scategory` (
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
-	`name` varchar(255) NOT NULL,
+	`name` varchar(191) NOT NULL,
 	PRIMARY KEY (`id`),
 	UNIQUE KEY (`name`)	-- v0.7
-) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
 ENGINE = INNODB;
 
 CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 	`id` SMALLINT NOT NULL AUTO_INCREMENT,	-- v0.7
 	`url` varchar(511) CHARACTER SET latin1 NOT NULL,
 	`category` SMALLINT DEFAULT 0,	-- v0.7
-	`name` varchar(255) NOT NULL,
+	`name` varchar(191) NOT NULL,
 	`website` varchar(255) CHARACTER SET latin1,
 	`description` text,
-	`lastUpdate` int(11) DEFAULT 0,
+	`lastUpdate` int(11) DEFAULT 0,	-- Until year 2038
 	`priority` tinyint(2) NOT NULL DEFAULT 10,
 	`pathEntries` varchar(511) DEFAULT NULL,
 	`httpAuth` varchar(511) DEFAULT NULL,
@@ -30,7 +32,7 @@ CREATE TABLE IF NOT EXISTS `%1$sfeed` (
 	INDEX (`name`),	-- v0.7
 	INDEX (`priority`),	-- 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;
 
 CREATE TABLE IF NOT EXISTS `%1$sentry` (
@@ -40,7 +42,9 @@ CREATE TABLE IF NOT EXISTS `%1$sentry` (
 	`author` varchar(255),
 	`content_bin` blob,	-- v0.7
 	`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_favorite` boolean NOT NULL DEFAULT 0,
 	`id_feed` SMALLINT,	-- v0.7
@@ -49,11 +53,64 @@ CREATE TABLE IF NOT EXISTS `%1$sentry` (
 	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
 	UNIQUE KEY (`id_feed`,`guid`),	-- 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
+	-- INDEX `entry_feed_read_index` (`id_feed`,`is_read`)	-- v1.7 Located futher down
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
 ENGINE = INNODB;
 
 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_CREATE_TABLE_ENTRYTMP', '
+CREATE TABLE IF NOT EXISTS `%1$sentrytmp` (	-- v1.7
+	`id` bigint NOT NULL,
+	`guid` varchar(760) CHARACTER SET latin1 NOT NULL,
+	`title` varchar(255) NOT NULL,
+	`author` varchar(255),
+	`content_bin` blob,
+	`link` varchar(1023) CHARACTER SET latin1 NOT NULL,
+	`date` int(11),
+	`lastSeen` INT(11) DEFAULT 0,
+	`hash` BINARY(16),
+	`is_read` boolean NOT NULL DEFAULT 0,
+	`is_favorite` boolean NOT NULL DEFAULT 0,
+	`id_feed` SMALLINT,
+	`tags` varchar(1023),
+	PRIMARY KEY (`id`),
+	FOREIGN KEY (`id_feed`) REFERENCES `%1$sfeed`(`id`) ON DELETE CASCADE ON UPDATE CASCADE,
+	UNIQUE KEY (`id_feed`,`guid`),
+	INDEX (`date`)
+) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
+ENGINE = INNODB;
+
+CREATE INDEX `entry_feed_read_index` ON `%1$sentry`(`id_feed`,`is_read`);	-- v1.7 Located here to be auto-added
+');
+
+define('SQL_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$sentrytmp`, `%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`;
+');

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

@@ -0,0 +1,87 @@
+<?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) 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_CREATE_TABLE_ENTRYTMP;
+$SQL_CREATE_TABLE_ENTRYTMP = array(
+'CREATE TABLE IF NOT EXISTS "%1$sentrytmp" (	-- v1.7
+	"id" BIGINT NOT NULL PRIMARY KEY,
+	"guid" VARCHAR(760) 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$sentrytmp_date_index ON "%1$sentrytmp" ("date");',
+
+'CREATE INDEX %1$sentry_feed_read_index ON "%1$sentry" ("id_feed","is_read");',	//v1.7
+);
+
+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$sentrytmp", "%1$sentry", "%1$sfeed", "%1$scategory"');

+ 80 - 14
app/SQL/install.sql.sqlite.php

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

+ 9 - 15
app/actualize_script.php

@@ -1,6 +1,5 @@
 <?php
-require(dirname(__FILE__) . '/../constants.php');
-require(LIB_PATH . '/lib_rss.php');	//Includes class autoloader
+require(__DIR__ . '/../cli/_cli.php');
 
 session_cache_limiter('');
 ob_implicit_flush(false);
@@ -12,7 +11,6 @@ if (defined('STDOUT')) {
 	fwrite(STDOUT, 'Starting feed actualization at ' . $begin_date->format('c') . "\n");	//Unbuffered
 }
 
-
 // Set the header params ($_GET) to call the FRSS application.
 $_GET['c'] = 'feed';
 $_GET['a'] = 'actualize';
@@ -20,37 +18,35 @@ $_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!)
 
+// make sure the PHP setup of the CLI environment is compatible with FreshRSS as well
+performRequirementCheck($system_conf->db['type']);
+
 // 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 !== ''){
+if ($system_conf->default_user !== '') {
 	array_unshift($users, $system_conf->default_user);
 	$users = array_unique($users);
 }
 
-
 $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);
+		Minz_Log::notice('FreshRSS skip inactive user ' . $user, ADMIN_LOG);
 		if (defined('STDOUT')) {
 			fwrite(STDOUT, 'FreshRSS skip inactive user ' . $user . "\n");	//Unbuffered
 		}
 		continue;
 	}
-	Minz_Log::notice('FreshRSS actualize ' . $user, $log_file);
+	Minz_Log::notice('FreshRSS actualize ' . $user, ADMIN_LOG);
 	if (defined('STDOUT')) {
 		fwrite(STDOUT, 'Actualize ' . $user . "...\n");	//Unbuffered
 	}
@@ -65,16 +61,14 @@ foreach ($users as $user) {
 
 
 	if (!invalidateHttpCache()) {
-		Minz_Log::notice('FreshRSS write access problem in ' . join_path(USERS_PATH, $user, 'log.txt'),
-		                 $log_file);
+		Minz_Log::warning('FreshRSS write access problem in ' . join_path(USERS_PATH, $user, 'log.txt'), ADMIN_LOG);
 		if (defined('STDERR')) {
 			fwrite(STDERR, 'Write access problem in ' . join_path(USERS_PATH, $user, 'log.txt') . "\n");
 		}
 	}
 }
 
-
-Minz_Log::notice('FreshRSS actualize done.', $log_file);
+Minz_Log::notice('FreshRSS actualize done.', ADMIN_LOG);
 if (defined('STDOUT')) {
 	fwrite(STDOUT, 'Done.' . "\n");
 	$end_date = date_create('now');

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

@@ -0,0 +1,188 @@
+<?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ů',
+		),
+		'community' => 'Available community extensions', // @todo translate
+		'name' => 'Name', // @todo translate
+		'version' => 'Version', // @todo translate
+		'description' => 'Description', // @todo translate
+		'author' => 'Author', // @todo translate
+		'latest' => 'Installed', // @todo translate
+		'update' => 'Update available', // @todo translate
+	),
+	'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é',
+	),
+);

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

@@ -0,0 +1,174 @@
+<?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ů',
+		'sides_close_article' => 'Clicking outside of article text area closes the article',	//TODO
+		'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' => '<strong>Verzi %s</strong>. 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',
+	),
+);

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

@@ -0,0 +1,189 @@
+<?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_favorite' => 'Označit jako oblíbené',
+		'mark_read' => 'Označit jako přečtené',
+		'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ěten',
+		'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',
+		'es' => 'Español',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'kr' => '한국어',
+		'nl' => 'Nederlands',
+		'pt-br' => 'Português (Brasil)',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+		'zh-cn' => '简体中文',
+	),
+	'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(
+		'Known' => 'Known based sites',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'gnusocial' => 'GNU social',
+		'jdh' => 'Journal du hacker',
+		'mastodon' => 'Mastodon',
+		'movim' => 'Movim',
+		'print' => 'Tisk',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
+	),
+	'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',
+);

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

@@ -0,0 +1,77 @@
+<?php
+
+return array(
+	'api' => array(
+		'documentation' => 'Copy the following URL to use it within an external tool.',// TODO
+		'title' => 'API',// TODO
+	),
+	'bookmarklet' => array(
+		'documentation' => 'Drag this button to your bookmarks toolbar or right-click it and choose "Bookmark This Link". Then click "Subscribe" button in any page you want to subscribe to.',// TODO
+		'label' => 'Subscribe',// TODO
+		'title' => 'Bookmarklet',// TODO
+	),
+	'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',
+	),
+	'firefox' => array(
+		'documentation' => 'Follow the steps described <a href="https://developer.mozilla.org/en-US/Firefox/Releases/2/Adding_feed_readers_to_Firefox#Adding_a_new_feed_reader_manually">here</a> to add FreshRSS to Firefox feed reader list.',// TODO
+		'title' => 'Firefox feed reader',// TODO
+	),
+	'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í',
+		'subscription_tools' => 'Subscription tools',// TODO
+	),
+	'title' => array(
+		'_' => 'Správa subskripcí',
+		'feed_management' => 'Správa RSS kanálů',
+		'subscription_tools' => 'Subscription tools',// TODO
+	),
+);

+ 43 - 25
app/i18n/de/admin.php

@@ -8,7 +8,6 @@ return array(
 		'form' => 'Webformular (traditionell, benötigt JavaScript)',
 		'http' => 'HTTP (HTTPS für erfahrene Benutzer)',
 		'none' => 'Keine (gefährlich)',
-		'persona' => 'Mozilla Persona (modern, benötigt JavaScript)',
 		'title' => 'Authentifizierung',
 		'title_reset' => 'Zurücksetzen der Authentifizierung',
 		'token' => 'Authentifizierungs-Token',
@@ -19,27 +18,27 @@ return array(
 	'check_install' => array(
 		'cache' => array(
 			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/cache</em>. Der HTTP-Server muss Schreibrechte besitzen.',
-			'ok' => 'Berechtigungen des Verzeichnisses <em>./data/cache</em> sind in Ordnung.',
+			'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 in Ordnung.',
+			'ok' => 'Die Tabelle <em>category</em> ist korrekt konfiguriert.',
 		),
 		'connection' => array(
 			'nok' => 'Verbindung zur Datenbank kann nicht aufgebaut werden.',
-			'ok' => 'Verbindung zur Datenbank ist in Ordnung.',
+			'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 php5-curl).',
+			'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' => 'Berechtigungen des Verzeichnisses <em>./data</em> sind in Ordnung.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data</em> sind in Ordnung.',
 		),
 		'database' => 'Datenbank-Installation',
 		'dom' => array(
@@ -48,19 +47,23 @@ return array(
 		),
 		'entries' => array(
 			'nok' => 'Die Tabelle <em>entry</em> ist schlecht konfiguriert.',
-			'ok' => 'Die Tabelle <em>entry</em> ist in Ordnung.',
+			'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' => 'Berechtigungen des Verzeichnisses <em>./data/favicons</em> sind in Ordnung.',
+			'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 in Ordnung.',
+			'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 JSON (Paket php5-json).',
+			'nok' => 'Ihnen fehlt die JSON-Erweiterung (Paket php5-json).',
 			'ok' => 'Sie haben die JSON-Erweiterung.',
 		),
 		'minz' => array(
@@ -72,12 +75,8 @@ return array(
 			'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).',
-			'ok' => 'Sie haben PDO und mindestens einen der unterstützten Treiber (pdo_mysql, pdo_sqlite).',
-		),
-		'persona' => array(
-			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/persona</em>. Der HTTP-Server muss Schreibrechte besitzen.',
-			'ok' => 'Berechtigungen des Verzeichnisses <em>./data/persona</em> sind in Ordnung.',
+			'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',
@@ -91,14 +90,14 @@ return array(
 		'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' => 'Berechtigungen des Verzeichnisses <em>./data/tokens</em> sind in Ordnung.',
+			'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' => 'Berechtigungen des Verzeichnisses <em>./data/users</em> sind in Ordnung.',
+			'ok' => 'Die Berechtigungen des Verzeichnisses <em>./data/users</em> sind in Ordnung.',
 		),
 		'zip' => array(
-			'nok' => 'Ihnen fehlt die ZIP-Erweiterung (Paket php5-zip).',
+			'nok' => 'Ihnen fehlt die ZIP-Erweiterung (Paket php-zip).',
 			'ok' => 'Sie haben die ZIP-Erweiterung.',
 		),
 	),
@@ -113,6 +112,13 @@ return array(
 		),
 		'title' => 'Erweiterungen',
 		'user' => 'Benutzer-Erweiterungen',
+		'community' => 'Verfügbare Community Erweiterungen',
+		'name' => 'Name',
+		'version' => 'Version',
+		'description' => 'Beschreibungen',
+		'author' => 'Autor',
+		'latest' => 'Installiert',
+		'update' => 'Update verfügbar',
 	),
 	'stats' => array(
 		'_' => 'Statistiken',
@@ -120,22 +126,22 @@ return array(
 		'category' => 'Kategorie',
 		'entry_count' => 'Anzahl der Einträge',
 		'entry_per_category' => 'Einträge pro Kategorie',
-		'entry_per_day' => 'Einträge pro Tag (letzte 30 Tage)',
+		'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' => 'Untätige Feeds',
+		'idle' => 'Inaktive Feeds',
 		'main' => 'Haupt-Statistiken',
 		'main_stream' => 'Haupt-Feeds',
 		'menu' => array(
-			'idle' => 'Untätige Feeds',
+			'idle' => 'Inaktive Feeds',
 			'main' => 'Haupt-Statistiken',
 			'repartition' => 'Artikel-Verteilung',
 		),
-		'no_idle' => 'Es gibt keinen untätigen Feed!',
+		'no_idle' => 'Es gibt keinen inaktiven Feed!',
 		'number_entries' => '%d Artikel',
 		'percent_of_total' => '%% Gesamt',
 		'repartition' => 'Artikel-Verteilung',
@@ -146,20 +152,32 @@ return array(
 		'title' => 'Statistiken',
 		'top_feed' => 'Top 10-Feeds',
 	),
+	'system' => array(
+		'_' => 'Systemeinstellungen',
+		'auto-update-url' => 'Auto-update URL',
+		'instance-name' => 'Dein Reader Name',
+		'max-categories' => 'Anzahl erlaubter Kategorien pro Benutzer',
+		'max-feeds' => 'Anzahl erlaubter Feeds pro Benutzer',
+		'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 Aktualisierung zum Anwenden',
+		'none' => 'Keine ausstehende Aktualisierung',
 		'title' => 'System aktualisieren',
 	),
 	'user' => array(
 		'articles_and_size' => '%s Artikel (%s)',
 		'create' => 'Neuen Benutzer erstellen',
-		'email_persona' => 'Anmelde-E-Mail-Adresse<br /><small>(für <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
 		'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',

+ 16 - 11
app/i18n/de/conf.php

@@ -5,8 +5,8 @@ return array(
 		'_' => 'Archivierung',
 		'advanced' => 'Erweitert',
 		'delete_after' => 'Entferne Artikel nach',
-		'help' => 'Weitere Optionen sind in den Einstellungen der individuellen Nachrichten-Feeds vorhanden.',
-		'keep_history_by_feed' => 'Minimale Anzahl an Artikeln, die pro Feed behalten wird',
+		'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',
@@ -32,10 +32,10 @@ return array(
 		'title' => 'Anzeige',
 		'width' => array(
 			'content' => 'Inhaltsbreite',
-			'large' => 'Weit',
+			'large' => 'Gross',
 			'medium' => 'Mittel',
 			'no_limit' => 'Keine Begrenzung',
-			'thin' => 'Schmal',
+			'thin' => 'Klein',
 		),
 	),
 	'query' => array(
@@ -72,7 +72,10 @@ return array(
 	),
 	'profile' => array(
 		'_' => 'Profil-Verwaltung',
-		'email_persona' => 'Anmelde-E-Mail-Adresse<br /><small>(für <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
+		'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',
@@ -84,11 +87,13 @@ return array(
 		'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',
+		'sides_close_article' => 'Clicking outside of article text area closes the article',	//TODO
 		'jump_next' => 'springe zum nächsten ungelesenen Geschwisterelement (Feed oder Kategorie)',
 		'number_divided_when_reader' => 'Geteilt durch 2 in der Lese-Ansicht.',
 		'read' => array(
@@ -99,7 +104,7 @@ return array(
 			'when' => 'Artikel als gelesen markieren…',
 		),
 		'show' => array(
-			'_' => 	'Artikel zum Anzeigen',
+			'_' => 'Artikel zum Anzeigen',
 			'adaptive' => 'Anzeige anpassen',
 			'all_articles' => 'Alle Artikel zeigen',
 			'unread' => 'Nur ungelesene zeigen',
@@ -135,14 +140,14 @@ return array(
 		'wallabag' => 'wallabag',
 	),
 	'shortcut' => array(
-		'_' => 'Tastaturkürzel',
+		'_' => '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' => 'Zusammenfalten',
+		'collapse_article' => 'Einklappen',
 		'first_article' => 'Zum ersten Artikel springen',
-		'focus_search' => 'Auf Suchfeld zugreifen',
+		'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',
@@ -150,13 +155,13 @@ return array(
 		'mark_read' => 'Als gelesen markieren',
 		'mark_favorite' => 'Als Favorit markieren',
 		'navigation' => 'Navigation',
-		'navigation_help' => 'Mit der "Umschalttaste" finden die Tastaturkürzel auf Feeds Anwendung.<br/>Mit der "Alt-Taste" finden die Tastaturkürzel auf Kategorien Anwendung.',
+		'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' => 'Tastaturkürzel',
+		'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.',
 	),

+ 33 - 34
app/i18n/de/feedback.php

@@ -15,19 +15,18 @@ return array(
 		),
 		'login' => array(
 			'invalid' => 'Anmeldung ist ungültig',
-			'success' => 'Sie sind verbunden',
+			'success' => 'Sie sind angemeldet',
 		),
 		'logout' => array(
-			'success' => 'Sie sind getrennt',
+			'success' => 'Sie sind abgemeldet',
 		),
 		'no_password_set' => 'Administrator-Passwort ist nicht gesetzt worden. Dieses Feature ist nicht verfügbar.',
-		'not_persona' => 'Nur das Persona-System kann zurückgesetzt werden.',
 	),
 	'conf' => array(
-		'error' => 'Während des Speicherung der Konfiguration trat ein Fehler auf',
+		'error' => 'Während der Speicherung der Konfiguration trat ein Fehler auf',
 		'query_created' => 'Abfrage "%s" ist erstellt worden.',
-		'shortcuts_updated' => 'Tastaturkürzel sind aktualisiert worden',
-		'updated' => 'Konfiguration ist aktualisiert worden',
+		'shortcuts_updated' => 'Die Tastenkombinationen sind aktualisiert worden',
+		'updated' => 'Die Konfiguration ist aktualisiert worden',
 	),
 	'extensions' => array(
 		'already_enabled' => '%s ist bereits aktiviert',
@@ -44,63 +43,63 @@ return array(
 		'not_found' => '%s existiert nicht',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Die Zip-Erweiterung fehlt auf Ihrem Server. Bitte versuchen Sie, Dateien eine nach der anderen zu exportieren.',
+		'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' => '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.',
+		'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' => 'Kategorie %s ist erstellt worden.',
-			'deleted' => 'Kategorie ist gelöscht worden.',
-			'emptied' => 'Kategorie ist geleert worden.',
-			'error' => 'Kategorie kann nicht aktualisiert werden',
-			'name_exists' => 'Kategorie-Name existiert bereits.',
+			'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' => 'Kategorie-Name kann nicht leer sein.',
+			'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 Ihr Kategorien-Limit erreicht (%d)',
-			'updated' => 'Kategorie ist aktualisiert worden.',
+			'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' => 'RSS-Feeds sind aktualisiert worden',
-			'added' => 'RSS-Feed <em>%s</em> ist hinzugefügt 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' => 'Feed ist gelöscht worden',
-			'error' => 'Feed kann nicht aktualisiert werden',
+			'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' => 'URL <em>%s</em> ist ungültig',
-			'marked_read' => 'Feeds sind als gelesen markiert worden',
-			'n_actualized' => '%d Feeds sind aktualisiert worden',
-			'n_entries_deleted' => '%d Artikel sind gelöscht worden',
+			'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 Ihr Feeds-Limit erreicht (%d)',
-			'updated' => 'Feed ist aktualisiert worden',
+			'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',
+		'file_is_nok' => '<strong>Version %s</strong>. Ü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' => 'Aktualisierungs-Server kann nicht gefunden werden. [%s]',
+		'server_not_found' => 'Der Aktualisierungs-Server kann nicht gefunden werden. [%s]',
 	),
 	'user' => array(
 		'created' => array(
-			'_' => 'Benutzer %s ist erstellt worden',
-			'error' => 'Benutzer %s kann nicht erstellt werden',
+			'_' => 'Der Benutzer %s ist erstellt worden',
+			'error' => 'Der Benutzer %s kann nicht erstellt werden',
 		),
 		'deleted' => array(
-			'_' => 'Benutzer %s ist gelöscht worden',
-			'error' => 'Benutzer %s kann nicht gelöscht werden',
+			'_' => 'Der Benutzer %s ist gelöscht worden',
+			'error' => 'Der Benutzer %s kann nicht gelöscht werden',
 		),
 	),
 	'profile' => array(

+ 38 - 12
app/i18n/de/gen.php

@@ -13,24 +13,33 @@ return array(
 		'filter' => 'Filtern',
 		'import' => 'Importieren',
 		'manage' => 'Verwalten',
-		'mark_read' => 'Als gelesen markieren',
 		'mark_favorite' => 'Als Favorit markieren',
+		'mark_read' => 'Als gelesen markieren',
 		'remove' => 'Entfernen',
 		'see_website' => 'Webseite ansehen',
 		'submit' => 'Abschicken',
 		'truncate' => 'Alle Artikel löschen',
 	),
 	'auth' => array(
-		'keep_logged_in' => 'Eingeloggt bleiben <small>(1 Monat)</small>',
+		'email' => 'E-Mail-Adresse',
+		'keep_logged_in' => 'Eingeloggt bleiben <small>(%s Tage)</small>',
 		'login' => 'Anmelden',
-		'login_persona' => 'Anmelden mit Persona',
-		'login_persona_problem' => 'Verbindungsproblem mit Persona?',
 		'logout' => 'Abmelden',
-		'password' => 'Passwort',
+		'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' => 'Nutzername',
-		'username_admin' => 'Administrator-Nutzername',
-		'will_reset' => 'Authentifikationssystem wird zurückgesetzt: ein Formular wird anstelle von Persona benutzt.',
+		'username' => array(
+			'_' => 'Nutzername',
+			'admin' => 'Administrator-Nutzername',
+			'format' => '<small>maximal 16 alphanumerische Zeichen</small>',
+		),
 	),
 	'date' => array(
 		'Apr' => '\\A\\p\\r\\i\\l',
@@ -49,7 +58,7 @@ return array(
 		'april' => 'April',
 		'aug' => 'Aug',
 		'august' => 'August',
-		'before_yesterday' => 'Vor gestern',
+		'before_yesterday' => 'Vor vorgestern',
 		'dec' => 'Dez',
 		'december' => 'Dezember',
 		'feb' => 'Feb',
@@ -71,6 +80,7 @@ return array(
 		'mar' => 'Mär',
 		'march' => 'März',
 		'may' => 'Mai',
+		'may_' => 'Mai',
 		'mon' => 'Mo',
 		'month' => 'Monat(en)',
 		'nov' => 'Nov',
@@ -93,10 +103,10 @@ return array(
 	),
 	'js' => array(
 		'category_empty' => 'Kategorie leeren',
-		'confirm_action' => 'Sind Sie sicher, dass Sie diese Aktion durchführen wollen? Dies kann nicht abgebrochen werden!',
+		'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.',
+			'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!',
 		),
@@ -104,10 +114,19 @@ return array(
 		'should_be_activated' => 'JavaScript muss aktiviert sein',
 	),
 	'lang' => array(
+		'cz' => 'Čeština',
 		'de' => 'Deutsch',
 		'en' => 'English',
+		'es' => 'Español',
 		'fr' => 'Français',
 		'he' => 'עברית',
+		'it' => 'Italiano',
+		'kr' => '한국어',
+		'nl' => 'Nederlands',
+		'pt-br' => 'Português (Brasil)',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+		'zh-cn' => '简体中文',
 	),
 	'menu' => array(
 		'about' => 'Über',
@@ -125,6 +144,7 @@ return array(
 		'sharing' => 'Teilen',
 		'shortcuts' => 'Tastaturkürzel',
 		'stats' => 'Statistiken',
+		'system' => 'Systemeinstellungen',
 		'update' => 'Aktualisieren',
 		'user_management' => 'Benutzer verwalten',
 		'user_profile' => 'Profil',
@@ -144,10 +164,15 @@ return array(
 		'email' => 'E-Mail',
 		'facebook' => 'Facebook',
 		'g+' => 'Google+',
+		'gnusocial' => 'GNU social',
+		'jdh' => 'Journal du hacker',
+		'mastodon' => 'Mastodon',
+		'movim' => 'Movim',
 		'print' => 'Drucken',
 		'shaarli' => 'Shaarli',
 		'twitter' => 'Twitter',
-		'wallabag' => 'wallabag',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
 	),
 	'short' => array(
 		'attention' => 'Achtung!',
@@ -157,6 +182,7 @@ return array(
 		'damn' => 'Verdammt!',
 		'default_category' => 'Unkategorisiert',
 		'no' => 'Nein',
+		'not_applicable' => 'Nicht verfügbar',
 		'ok' => 'OK!',
 		'or' => 'oder',
 		'yes' => 'Ja',

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

@@ -6,7 +6,7 @@ return array(
 		'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://www.google.com/webfonts/specimen/Open+Sans">Steve Matteson</a> erstellt. Favicons werden mit <a href="https://getfavicon.appspot.com/">getFavicon API</a> gesammelt. FreshRSS basiert auf <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, einem PHP-Framework.',
+		'credits_content' => 'Einige Designelemente stammen von <a href="http://twitter.github.io/bootstrap/">Bootstrap</a>, obwohl FreshRSS dieses Framework nicht nutzt. <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Icons</a> stammen vom <a href="https://www.gnome.org/">GNOME project</a>. <em>Open Sans</em> Font wurde von <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a> erstellt. FreshRSS basiert auf <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, einem PHP-Framework.',
 		'freshrss_description' => 'FreshRSS ist ein RSS-Feedsaggregator zum selbst hosten wie zum Beispiel <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> oder <a href="http://projet.idleman.fr/leed/">Leed</a>. Er ist leicht und einfach zu handhaben und gleichzeitig ein leistungsstarkes und konfigurierbares Werkzeug.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
 		'license' => 'Lizenz',
@@ -17,7 +17,7 @@ return array(
 	),
 	'feed' => array(
 		'add' => 'Sie können Feeds hinzufügen.',
-		'empty' => 'Es gibt keinen Artikel zum Zeigen.',
+		'empty' => 'Es gibt keinen Artikel zum Anzeigen.',
 		'rss_of' => 'RSS-Feed von %s',
 		'title' => 'Ihre RSS-Feeds',
 		'title_global' => 'Globale Ansicht',

+ 33 - 21
app/i18n/de/install.php

@@ -3,17 +3,17 @@
 return array(
 	'action' => array(
 		'finish' => 'Installation fertigstellen',
-		'fix_errors_before' => 'Fehler korrigieren, bevor zum nächsten Schritt gesprungen wird.',
-		'next_step' => 'Zum nächsten Schritt gehen',
+		'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(
-		'email_persona' => 'Anmelde-E-Mail-Adresse<br /><small>(für <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
 		'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',
-		'persona' => 'Mozilla Persona (modern, benötigt JavaScript)',
 		'type' => 'Authentifizierungsmethode',
 	),
 	'bdd' => array(
@@ -25,40 +25,49 @@ return array(
 		),
 		'host' => 'Host',
 		'prefix' => 'Tabellen-Präfix',
-		'password' => 'HTTP-Password',
+		'password' => 'SQL-Password',
 		'type' => 'Datenbank-Typ',
-		'username' => 'HTTP-Nutzername',
+		'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' => 'Berechtigungen des Verzeichnisses <em>./data/cache</em> sind in Ordnung.',
+			'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 php5-curl).',
+			'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' => 'Berechtigungen des Verzeichnisses <em>./data</em> sind in Ordnung.',
+			'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 (Paket php-xml).',
+			'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' => 'Berechtigungen des Verzeichnisses <em>./data/favicons</em> sind in Ordnung.',
+			'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.',
@@ -68,12 +77,8 @@ return array(
 			'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).',
-			'ok' => 'Sie haben PDO und mindestens einen der unterstützten Treiber (pdo_mysql, pdo_sqlite).',
-		),
-		'persona' => array(
-			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/persona</em>. Der HTTP-Server muss Schreibrechte besitzen.',
-			'ok' => 'Berechtigungen des Verzeichnisses <em>./data/persona</em> sind in Ordnung.',
+			'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.',
@@ -81,22 +86,29 @@ return array(
 		),
 		'users' => array(
 			'nok' => 'Überprüfen Sie die Berechtigungen des Verzeichnisses <em>./data/users</em>. Der HTTP-Server muss Schreibrechte besitzen.',
-			'ok' => 'Berechtigungen des Verzeichnisses <em>./data/users</em> sind in Ordnung.',
+			'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' => 'Allgemeine Konfiguration ist gespeichert worden.',
+		'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' => 'Fehler korrigieren, bevor zum nächsten Schritt gesprungen wird.',
+	'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' => 'Sprache ist festgelegt worden.',
+		'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.',

+ 17 - 1
app/i18n/de/sub.php

@@ -1,6 +1,15 @@
 <?php
 
 return array(
+	'api' => array(
+		'documentation' => 'Copy the following URL to use it within an external tool.',// TODO
+		'title' => 'API',// TODO
+	),
+	'bookmarklet' => array(
+		'documentation' => 'Drag this button to your bookmarks toolbar or right-click it and choose "Bookmark This Link". Then click "Subscribe" button in any page you want to subscribe to.',// TODO
+		'label' => 'Subscribe',// TODO
+		'title' => 'Bookmarklet',// TODO
+	),
 	'category' => array(
 		'_' => 'Kategorie',
 		'add' => 'Eine Kategorie hinzufügen',
@@ -37,13 +46,18 @@ return array(
 		'url' => 'Feed-URL',
 		'validator' => 'Überprüfen Sie die Gültigkeit des Feeds',
 		'website' => 'Webseiten-URL',
+		'pubsubhubbub' => 'Sofortbenachrichtigung mit PubSubHubbub',
+	),
+	'firefox' => array(
+		'documentation' => 'Follow the steps described <a href="https://developer.mozilla.org/en-US/Firefox/Releases/2/Adding_feed_readers_to_Firefox#Adding_a_new_feed_reader_manually">here</a> to add FreshRSS to Firefox feed reader list.',// TODO
+		'title' => 'Firefox feed reader',// TODO
 	),
 	'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' => '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',
@@ -53,9 +67,11 @@ return array(
 		'bookmark' => 'Abonnieren (FreshRSS-Lesezeichen)',
 		'import_export' => 'Importieren / Exportieren',
 		'subscription_management' => 'Abonnementverwaltung',
+		'subscription_tools' => 'Subscription tools',// TODO
 	),
 	'title' => array(
 		'_' => 'Abonnementverwaltung',
 		'feed_management' => 'Verwaltung der RSS-Feeds',
+		'subscription_tools' => 'Subscription tools',// TODO
 	),
 );

+ 43 - 25
app/i18n/en/admin.php

@@ -8,11 +8,10 @@ return array(
 		'form' => 'Web form (traditional, requires JavaScript)',
 		'http' => 'HTTP (for advanced users with HTTPS)',
 		'none' => 'None (dangerous)',
-		'persona' => 'Mozilla Persona (modern, requires JavaScript)',
 		'title' => 'Authentication',
 		'title_reset' => 'Authentication reset',
 		'token' => 'Authentication token',
-		'token_help' => 'Allows to access RSS output of the default user without authentication:',
+		'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: ',
 	),
@@ -22,20 +21,20 @@ return array(
 			'ok' => 'Permissions on cache directory are good.',
 		),
 		'categories' => array(
-			'nok' => 'Category table is bad configured.',
+			'nok' => 'Category table is improperly configured.',
 			'ok' => 'Category table is ok.',
 		),
 		'connection' => array(
-			'nok' => 'Connection to the database cannot being established.',
+			'nok' => 'Connection to the database cannot be established.',
 			'ok' => 'Connection to the database is ok.',
 		),
 		'ctype' => array(
-			'nok' => 'You lack a required library for character type checking (php-ctype).',
+			'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' => 'You lack cURL (php5-curl package).',
-			'ok' => 'You have cURL extension.',
+			'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',
@@ -43,11 +42,11 @@ return array(
 		),
 		'database' => 'Database installation',
 		'dom' => array(
-			'nok' => 'You lack a required library to browse the DOM (php-xml package).',
+			'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.',
+			'nok' => 'Entry table is improperly configured.',
 			'ok' => 'Entry table is ok.',
 		),
 		'favicons' => array(
@@ -55,29 +54,29 @@ return array(
 			'ok' => 'Permissions on favicons directory are good.',
 		),
 		'feeds' => array(
-			'nok' => 'Feed table is bad configured.',
+			'nok' => 'Feed table is improperly 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' => 'You lack JSON (php5-json package).',
+			'nok' => 'Cannot find JSON (php5-json package).',
 			'ok' => 'You have JSON extension.',
 		),
 		'minz' => array(
-			'nok' => 'You lack the Minz framework.',
+			'nok' => 'Cannot find the Minz framework.',
 			'ok' => 'You have the Minz framework.',
 		),
 		'pcre' => array(
-			'nok' => 'You lack a required library for regular expressions (php-pcre).',
+			'nok' => 'Cannot find a required library for regular expressions (php-pcre).',
 			'ok' => 'You have the required library for regular expressions (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'You lack PDO or one of the supported drivers (pdo_mysql, pdo_sqlite).',
-			'ok' => 'You have PDO and at least one of the supported drivers (pdo_mysql, pdo_sqlite).',
-		),
-		'persona' => array(
-			'nok' => 'Check permissions on <em>./data/persona</em> directory. HTTP server must have rights to write into',
-			'ok' => 'Permissions on Mozilla Persona directory are good.',
+			'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',
@@ -85,8 +84,8 @@ return array(
 			'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.',
+			'nok' => 'There are one or more missing tables in the database.',
+			'ok' => 'The appropriate tables exist in the database.',
 		),
 		'title' => 'Installation checking',
 		'tokens' => array(
@@ -98,13 +97,13 @@ return array(
 			'ok' => 'Permissions on users directory are good.',
 		),
 		'zip' => array(
-			'nok' => 'You lack ZIP extension (php5-zip package).',
+			'nok' => 'Cannot find ZIP extension (php-zip package).',
 			'ok' => 'You have ZIP extension.',
 		),
 	),
 	'extensions' => array(
 		'disabled' => 'Disabled',
-		'empty_list' => 'There is no installed extension',
+		'empty_list' => 'There are no installed extensions',
 		'enabled' => 'Enabled',
 		'no_configure_view' => 'This extension cannot be configured.',
 		'system' => array(
@@ -113,6 +112,13 @@ return array(
 		),
 		'title' => 'Extensions',
 		'user' => 'User extensions',
+		'community' => 'Available community extensions',
+		'name' => 'Name',
+		'version' => 'Version',
+		'description' => 'Description',
+		'author' => 'Author',
+		'latest' => 'Installed',
+		'update' => 'Update available'
 	),
 	'stats' => array(
 		'_' => 'Statistics',
@@ -146,11 +152,22 @@ return array(
 		'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.',
+		'current_version' => 'Your current version of FreshRSS is %s.',
 		'last' => 'Last verification: %s',
 		'none' => 'No update to apply',
 		'title' => 'Update system',
@@ -158,8 +175,9 @@ return array(
 	'user' => array(
 		'articles_and_size' => '%s articles (%s)',
 		'create' => 'Create new user',
-		'email_persona' => 'Login mail address<br /><small>(for <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
 		'language' => 'Language',
+		'number' => 'There is %d account created',
+		'numbers' => 'There are %d accounts created',
 		'password_form' => 'Password<br /><small>(for the Web-form login method)</small>',
 		'password_format' => 'At least 7 characters',
 		'title' => 'Manage users',

+ 27 - 22
app/i18n/en/conf.php

@@ -5,10 +5,10 @@ return array(
 		'_' => 'Archiving',
 		'advanced' => 'Advanced',
 		'delete_after' => 'Remove articles after',
-		'help' => 'More options are available in the individual stream settings',
+		'help' => 'More options are available in the individual feed settings',
 		'keep_history_by_feed' => 'Minimum number of articles to keep by feed',
-		'optimize' => 'Optimize database',
-		'optimize_help' => 'To do occasionally to reduce the size of the database',
+		'optimize' => 'Optimise database',
+		'optimize_help' => 'Do occasionally to reduce the size of the database',
 		'purge_now' => 'Purge now',
 		'title' => 'Archiving',
 		'ttl' => 'Do not automatically refresh more often than',
@@ -44,10 +44,10 @@ return array(
 		'filter' => 'Filter applied:',
 		'get_all' => 'Display all articles',
 		'get_category' => 'Display "%s" category',
-		'get_favorite' => 'Display favorite articles',
+		'get_favorite' => 'Display favourite articles',
 		'get_feed' => 'Display "%s" feed',
 		'no_filter' => 'No filter',
-		'none' => 'You haven’t created any user query yet.',
+		'none' => 'You haven’t created any user queries yet.',
 		'number' => 'Query n°%d',
 		'order_asc' => 'Display oldest articles first',
 		'order_desc' => 'Display newest articles first',
@@ -56,14 +56,14 @@ return array(
 		'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_4' => 'Display favourite articles',
+		'state_5' => 'Display read favourite articles',
+		'state_6' => 'Display unread favourite articles',
+		'state_7' => 'Display favourite articles',
+		'state_8' => 'Display not favourite articles',
+		'state_9' => 'Display read not favourite articles',
+		'state_10' => 'Display unread not favourite articles',
+		'state_11' => 'Display not favourite articles',
 		'state_12' => 'Display all articles',
 		'state_13' => 'Display read articles',
 		'state_14' => 'Display unread articles',
@@ -72,8 +72,11 @@ return array(
 	),
 	'profile' => array(
 		'_' => 'Profile management',
-		'email_persona' => 'Login mail address<br /><small>(for <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
-		'password_api' => 'Password API<br /><small>(e.g., for mobile apps)</small>',
+		'delete' => array(
+			'_' => 'Account deletion',
+			'warn' => 'Your account and all 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',
@@ -82,31 +85,33 @@ return 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_load_more' => 'Load more 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)',
+		'hide_read_feeds' => 'Hide categories & feeds with no unread articles (does not work with “Show all articles” configuration)',
 		'img_with_lazyload' => 'Use "lazy load" mode to load pictures',
+		'sides_close_article' => 'Clicking outside of article text area closes the article',
 		'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',
+			'upon_reception' => 'upon receiving the article',
 			'when' => 'Mark article as read…',
 		),
 		'show' => array(
-			'_' => 	'Articles to display',
+			'_' => 'Articles to display',
 			'adaptive' => 'Adjust showing',
 			'all_articles' => 'Show all articles',
 			'unread' => 'Show only unread',
 		),
 		'sort' => array(
 			'_' => 'Sort order',
-			'newer_first' => 'Newer first',
+			'newer_first' => 'Newest first',
 			'older_first' => 'Oldest first',
 		),
 		'sticky_post' => 'Stick the article to the top when opened',
@@ -138,7 +143,7 @@ return 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.',
+		'auto_share_help' => 'If there is only one sharing mode, it is used. Otherwise, modes are accessible by their number.',
 		'close_dropdown' => 'Close menus',
 		'collapse_article' => 'Collapse',
 		'first_article' => 'Skip to the first article',
@@ -158,7 +163,7 @@ return array(
 		'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_filter_help' => 'If there is only one user filter, it is used. Otherwise, filters are accessible by their number.',
 	),
 	'user' => array(
 		'articles_and_size' => '%s articles (%s)',

+ 9 - 10
app/i18n/en/feedback.php

@@ -21,7 +21,6 @@ return array(
 			'success' => 'You are disconnected',
 		),
 		'no_password_set' => 'Administrator password hasn’t been set. This feature isn’t available.',
-		'not_persona' => 'Only Persona system can be reset.',
 	),
 	'conf' => array(
 		'error' => 'An error occurred during configuration saving',
@@ -40,26 +39,26 @@ return array(
 			'ok' => '%s is now enabled',
 		),
 		'no_access' => 'You have no access on %s',
-		'not_enabled' => '%s is not enabled yet',
+		'not_enabled' => '%s is not enabled',
 		'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.',
+		'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',
+		'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.',
+		'no_zip_extension' => 'ZIP extension is not present on your server.',
+		'zip_error' => 'An error occured during ZIP import.',
 	),
 	'sub' => array(
-		'actualize' => 'Actualize',
+		'actualize' => 'Updating',
 		'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_id' => 'You must specify 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!',
@@ -86,9 +85,9 @@ return array(
 		'purge_completed' => 'Purge completed (%d articles deleted)',
 	),
 	'update' => array(
-		'can_apply' => 'FreshRSS will be now updated to the <strong>version %s</strong>.',
+		'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',
+		'file_is_nok' => 'New <strong>version %s</strong> available, but 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]',

+ 62 - 36
app/i18n/en/gen.php

@@ -10,27 +10,36 @@ return array(
 		'empty' => 'Empty',
 		'enable' => 'Enable',
 		'export' => 'Export',
-		'filter' => 'Filtrer',
+		'filter' => 'Filter',
 		'import' => 'Import',
 		'manage' => 'Manage',
-		'mark_read' => 'Mark as read',
 		'mark_favorite' => 'Mark as favourite',
+		'mark_read' => 'Mark as read',
 		'remove' => 'Remove',
 		'see_website' => 'See website',
 		'submit' => 'Submit',
 		'truncate' => 'Delete all articles',
 	),
 	'auth' => array(
-		'keep_logged_in' => 'Keep me logged in <small>(1 month)</small>',
+		'email' => 'Email address',
+		'keep_logged_in' => 'Keep me logged in <small>(%s days)</small>',
 		'login' => 'Login',
-		'login_persona' => 'Login with Persona',
-		'login_persona_problem' => 'Connection problem with Persona?',
 		'logout' => 'Logout',
-		'password' => 'Password',
+		'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' => 'Username',
-		'username_admin' => 'Administrator username',
-		'will_reset' => 'Authentication system will be reset: a form will be used instead of Persona.',
+		'username' => array(
+			'_' => 'Username',
+			'admin' => 'Administrator username',
+			'format' => '<small>maximum 16 alphanumeric characters</small>',
+		),
 	),
 	'date' => array(
 		'Apr' => '\\A\\p\\r\\i\\l',
@@ -45,41 +54,42 @@ return array(
 		'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',
+		'apr' => 'Apr.',
+		'april' => 'April',
+		'aug' => 'Aug.',
+		'august' => 'August',
 		'before_yesterday' => 'Before yesterday',
-		'dec' => 'dec',
-		'december' => 'Dec',
-		'feb' => 'feb',
-		'february' => 'Feb',
+		'dec' => 'Dec.',
+		'december' => 'December',
+		'feb' => 'Feb.',
+		'february' => 'February',
 		'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',
+		'jan' => 'Jan.',
+		'january' => 'January',
+		'jul' => 'July',
+		'july' => 'July',
+		'jun' => 'June',
+		'june' => 'June',
 		'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',
+		'mar' => 'Mar.',
+		'march' => 'March',
 		'may' => 'May',
+		'may_' => 'May',
 		'mon' => 'Mon',
 		'month' => 'months',
-		'nov' => 'nov',
-		'november' => 'Nov',
-		'oct' => 'oct',
-		'october' => 'Oct',
+		'nov' => 'Nov.',
+		'november' => 'November',
+		'oct' => 'Oct.',
+		'october' => 'October',
 		'sat' => 'Sat',
-		'sep' => 'sep',
-		'september' => 'Sep',
+		'sep' => 'Sept.',
+		'september' => 'September',
 		'sun' => 'Sun',
 		'thu' => 'Thu',
 		'today' => 'Today',
@@ -94,9 +104,9 @@ return array(
 	'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!',
+		'confirm_action_feed_cat' => 'Are you sure you want to perform this action? You will lose related favourites and user queries. It cannot be cancelled!',
 		'feedback' => array(
-			'body_new_articles' => 'There are \\d new articles to read on FreshRSS.',
+			'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!',
 		),
@@ -104,10 +114,19 @@ return array(
 		'should_be_activated' => 'JavaScript must be enabled',
 	),
 	'lang' => array(
+		'cz' => 'Čeština',
 		'de' => 'Deutsch',
 		'en' => 'English',
+		'es' => 'Español',
 		'fr' => 'Français',
 		'he' => 'עברית',
+		'it' => 'Italiano',
+		'kr' => '한국어',
+		'nl' => 'Nederlands',
+		'pt-br' => 'Português (Brasil)',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+		'zh-cn' => '简体中文',
 	),
 	'menu' => array(
 		'about' => 'About',
@@ -125,6 +144,7 @@ return array(
 		'sharing' => 'Sharing',
 		'shortcuts' => 'Shortcuts',
 		'stats' => 'Statistics',
+		'system' => 'System configuration',
 		'update' => 'Update',
 		'user_management' => 'Manage users',
 		'user_profile' => 'Profile',
@@ -144,19 +164,25 @@ return array(
 		'email' => 'Email',
 		'facebook' => 'Facebook',
 		'g+' => 'Google+',
+		'gnusocial' => 'GNU social',
+		'jdh' => 'Journal du hacker',
+		'mastodon' => 'Mastodon',
+		'movim' => 'Movim',
 		'print' => 'Print',
 		'shaarli' => 'Shaarli',
 		'twitter' => 'Twitter',
-		'wallabag' => 'wallabag',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
 	),
 	'short' => array(
-		'attention' => 'Attention!',
+		'attention' => 'Warning!',
 		'blank_to_disable' => 'Leave blank to disable',
 		'by_author' => 'By <em>%s</em>',
 		'by_default' => 'By default',
-		'damn' => 'Damn!',
+		'damn' => 'Blast!',
 		'default_category' => 'Uncategorized',
 		'no' => 'No',
+		'not_applicable' => 'Not available',
 		'ok' => 'Ok!',
 		'or' => 'or',
 		'yes' => 'Yes',

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

@@ -6,7 +6,7 @@ return array(
 		'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://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.',
+		'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',
@@ -41,7 +41,7 @@ return array(
 		'mark_cat_read' => 'Mark category as read',
 		'mark_feed_read' => 'Mark feed as read',
 		'newer_first' => 'Newer first',
-		'non-starred' => 'Show all but favorites',
+		'non-starred' => 'Show all but favourites',
 		'normal_view' => 'Normal view',
 		'older_first' => 'Oldest first',
 		'queries' => 'User queries',
@@ -49,7 +49,7 @@ return array(
 		'reader_view' => 'Reading view',
 		'rss_view' => 'RSS feed',
 		'search_short' => 'Search',
-		'starred' => 'Show only favorites',
+		'starred' => 'Show only favourites',
 		'stats' => 'Statistics',
 		'subscription' => 'Subscriptions management',
 		'unread' => 'Show only unread',

+ 30 - 18
app/i18n/en/install.php

@@ -3,17 +3,17 @@
 return array(
 	'action' => array(
 		'finish' => 'Complete installation',
-		'fix_errors_before' => 'Fix errors before skip to the next step.',
+		'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(
-		'email_persona' => 'Login mail address<br /><small>(for <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
 		'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',
-		'persona' => 'Mozilla Persona (modern, requires JavaScript)',
 		'type' => 'Authentication method',
 	),
 	'bdd' => array(
@@ -25,55 +25,60 @@ return array(
 		),
 		'host' => 'Host',
 		'prefix' => 'Table prefix',
-		'password' => 'HTTP password',
+		'password' => 'Database password',
 		'type' => 'Type of database',
-		'username' => 'HTTP username',
+		'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' => 'You lack a required library for character type checking (php-ctype).',
+			'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' => 'You lack cURL (php5-curl package).',
-			'ok' => 'You have cURL extension.',
+			'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' => 'You lack a required library to browse the DOM (php-xml package).',
+			'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' => 'You lack the Minz framework.',
+			'nok' => 'Cannot find the Minz framework.',
 			'ok' => 'You have the Minz framework.',
 		),
 		'pcre' => array(
-			'nok' => 'You lack a required library for regular expressions (php-pcre).',
+			'nok' => 'Cannot find a required library for regular expressions (php-pcre).',
 			'ok' => 'You have the required library for regular expressions (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'You lack PDO or one of the supported drivers (pdo_mysql, pdo_sqlite).',
-			'ok' => 'You have PDO and at least one of the supported drivers (pdo_mysql, pdo_sqlite).',
-		),
-		'persona' => array(
-			'nok' => 'Check permissions on <em>./data/persona</em> directory. HTTP server must have rights to write into',
-			'ok' => 'Permissions on Mozilla Persona directory are good.',
+			'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.',
@@ -83,6 +88,10 @@ return 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',
@@ -91,8 +100,11 @@ return array(
 	'congratulations' => 'Congratulations!',
 	'default_user' => 'Username of the default user <small>(maximum 16 alphanumeric characters)</small>',
 	'delete_articles_after' => 'Remove articles after',
-	'fix_errors_before' => 'Fix errors before skip to the next step.',
+	'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',

+ 23 - 7
app/i18n/en/sub.php

@@ -1,6 +1,15 @@
 <?php
 
 return array(
+	'api' => array(
+		'documentation' => 'Copy the following URL to use it within an external tool.',
+		'title' => 'API',
+	),
+	'bookmarklet' => array(
+		'documentation' => 'Drag this button to your bookmarks toolbar or right-click it and choose "Bookmark This Link". Then click "Subscribe" button in any page you want to subscribe to.',
+		'label' => 'Subscribe',
+		'title' => 'Bookmarklet',
+	),
 	'category' => array(
 		'_' => 'Category',
 		'add' => 'Add a category',
@@ -10,23 +19,23 @@ return array(
 	'feed' => array(
 		'add' => 'Add a RSS feed',
 		'advanced' => 'Advanced',
-		'archiving' => 'Archivage',
+		'archiving' => 'Archiving',
 		'auth' => array(
 			'configuration' => 'Login',
-			'help' => 'Connection allows to access HTTP protected RSS feeds',
+			'help' => 'Allows access to HTTP protected RSS feeds',
 			'http' => 'HTTP Authentication',
 			'password' => 'HTTP password',
 			'username' => 'HTTP username',
 		),
-		'css_help' => 'Retrieves truncated RSS feeds (attention, requires more time!)',
+		'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.',
+		'error' => 'This feed has encountered a problem. Please verify that it is always reachable then update 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, their feeds are automatically classified under <em>%s</em>.',
+		'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',
@@ -37,14 +46,19 @@ return array(
 		'url' => 'Feed URL',
 		'validator' => 'Check the validity of the feed',
 		'website' => 'Website URL',
+		'pubsubhubbub' => 'Instant notification with PubSubHubbub',
+	),
+	'firefox' => array(
+		'documentation' => 'Follow the steps described <a href="https://developer.mozilla.org/en-US/Firefox/Releases/2/Adding_feed_readers_to_Firefox#Adding_a_new_feed_reader_manually">here</a> to add FreshRSS to Firefox feed reader list.',
+		'title' => 'Firefox feed reader',
 	),
 	'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)',
+		'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',
@@ -53,9 +67,11 @@ return array(
 		'bookmark' => 'Subscribe (FreshRSS bookmark)',
 		'import_export' => 'Import / export',
 		'subscription_management' => 'Subscriptions management',
+		'subscription_tools' => 'Subscription tools',
 	),
 	'title' => array(
 		'_' => 'Subscriptions management',
 		'feed_management' => 'RSS feeds management',
+		'subscription_tools' => 'Subscription tools',
 	),
 );

+ 188 - 0
app/i18n/es/admin.php

@@ -0,0 +1,188 @@
+<?php
+
+return array(
+	'auth' => array(
+		'allow_anonymous' => 'Permitir la lectura anónima de los artículos del usuario por defecto (%s)',
+		'allow_anonymous_refresh' => 'Permitir la actualización anónima de los artículos',
+		'api_enabled' => 'Concederle acceso a la <abbr>API</abbr> <small>(necesario para apps de móvil)</small>',
+		'form' => 'Formulario Web (el más habitual, requiere JavaScript)',
+		'http' => 'HTTP (para usuarios avanzados con HTTPS)',
+		'none' => 'Ninguno (peligroso)',
+		'title' => 'Identificación',
+		'title_reset' => 'Reinicio de la identificación',
+		'token' => 'Clave de identificación',
+		'token_help' => 'Permite el acceso a la salida RSS del usuario por defecto sin necesidad de identificación:',
+		'type' => 'Método de identificación',
+		'unsafe_autologin' => 'Permite la identificación automática insegura usando el formato: ',
+	),
+	'check_install' => array(
+		'cache' => array(
+			'nok' => 'Comprueba los permisos en el directorio <em>./data/cache</em> . El servidor HTTP debe contar con permiso de escritura',
+			'ok' => 'Los permisos en el cache son correctos.',
+		),
+		'categories' => array(
+			'nok' => 'La tabla Categorías está configurada de forma incorrecta.',
+			'ok' => 'La tabla Categorías está correcta.',
+		),
+		'connection' => array(
+			'nok' => 'No se pudo establecer una conexión con la base de datos.',
+			'ok' => 'La conexión con la base de datos es correcta.',
+		),
+		'ctype' => array(
+			'nok' => 'No se puedo encontrar la librería necesaria para compropbar el tipo de caracteres (php-ctype).',
+			'ok' => 'Dispones de la librería necesaria para la verificación del tipo de caracteres (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'No se pudo encontrar la librería cURL (paquete php-curl).',
+			'ok' => 'Dispones de la librería cURL.',
+		),
+		'data' => array(
+			'nok' => 'Comprueba los permisos en el directorio <em>./data</em>. El servidor HTTP debe contar con permisos de escritura.',
+			'ok' => 'Los permisos en el directorio data son correctos.',
+		),
+		'database' => 'Instalación de la base de datos',
+		'dom' => array(
+			'nok' => 'No se ha podido localizar la librería necesaria para explorar el DOM (paquete php-xml).',
+			'ok' => 'Dispones de la librería necesaria para explorar el DOM.',
+		),
+		'entries' => array(
+			'nok' => 'La tabla de entrada no está configurada correctamente.',
+			'ok' => 'La tabla de entrada está correcta.',
+		),
+		'favicons' => array(
+			'nok' => 'Comprueba los permisos en el directorio <em>./data/favicons</em>. El servidor HTTP debe contar con permisos de escritura.',
+			'ok' => 'Los permisos en el directorio favicons son correctos.',
+		),
+		'feeds' => array(
+			'nok' => 'La tabla Feed está configurada de forma incorrecta.',
+			'ok' => 'La tabla Feed está correcta.',
+		),
+		'fileinfo' => array(
+			'nok' => 'No se ha podido localizar la librería PHP fileinfo (paquete fileinfo).',
+			'ok' => 'Dispones de la librería fileinfo.',
+		),
+		'files' => 'Instalación de Archivos',
+		'json' => array(
+			'nok' => 'No se ha podido localizar JSON (paquete php5-json).',
+			'ok' => 'Dispones de la extensión JSON.',
+		),
+		'minz' => array(
+			'nok' => 'No se ha podido localizar el entorno Minz.',
+			'ok' => 'Dispones del entorno Minz.',
+		),
+		'pcre' => array(
+			'nok' => 'No se ha podido localizar la librería para las expresiones regulares (php-pcre).',
+			'ok' => 'Dispones de la librería necesaria para expresiones regulares (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'No se ha podido localiar PDO o uno de los controladores compatibles (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'Dispones de PDO y, al menos, de uno de los controladores compatibles (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'_' => 'Instalación PHP',
+			'nok' => 'Dispones de la versión PHP %s pero FreshRSS requiere de, al menos, la versión %s.',
+			'ok' => 'Dispones de la versión PHP %s, que es compatible con FreshRSS.',
+		),
+		'tables' => array(
+			'nok' => 'Falta al menos una tabla en la base de datos.',
+			'ok' => 'Todas las tablas necesarias están disponibles en la base de datos.',
+		),
+		'title' => 'Verificación de instalación',
+		'tokens' => array(
+			'nok' => 'Comprueba los permisos en el directorio <em>./data/tokens</em>. El servidor HTTP debe contar con permisos de escritura.',
+			'ok' => 'Los permisos en el directorio de tokens de identificación son correctos.',
+		),
+		'users' => array(
+			'nok' => 'Comprueba los permisos en el directorio <em>./data/users</em>. El servidor HTTP debe contar con permisos de escritura.',
+			'ok' => 'Los permisos en el directorio users son correctos.',
+		),
+		'zip' => array(
+			'nok' => 'No se ha podido localizar la extensión ZIP (paquete php-zip).',
+			'ok' => 'Dispones de la extensión ZIP.',
+		),
+	),
+	'extensions' => array(
+		'disabled' => 'Desactivado',
+		'empty_list' => 'No hay extensiones instaladas',
+		'enabled' => 'Activado',
+		'no_configure_view' => 'Esta extensión no puede ser configurada.',
+		'system' => array(
+			'_' => 'Sistema de extensiones',
+			'no_rights' => 'Sistema de extensiones (careces de los permisos necesarios)',
+		),
+		'title' => 'Extensiones',
+		'user' => 'Extensiones de usuario',
+		'community' => 'Available community extensions', // @todo translate
+		'name' => 'Name', // @todo translate
+		'version' => 'Version', // @todo translate
+		'description' => 'Description', // @todo translate
+		'author' => 'Author', // @todo translate
+		'latest' => 'Installed', // @todo translate
+		'update' => 'Update available', // @todo translate
+	),
+	'stats' => array(
+		'_' => 'Estadísticas',
+		'all_feeds' => 'Todas las fuentes',
+		'category' => 'Categoría',
+		'entry_count' => 'Cómputo total',
+		'entry_per_category' => 'Entradas por categoría',
+		'entry_per_day' => 'Entradas por día (últimos 30 días)',
+		'entry_per_day_of_week' => 'Por día de la semana (mnedia: %.2f mensajes)',
+		'entry_per_hour' => 'Por hora (media: %.2f mensajes)',
+		'entry_per_month' => 'Por mes (media: %.2f mensajes)',
+		'entry_repartition' => 'Reparto de entradas',
+		'feed' => 'Fuente',
+		'feed_per_category' => 'Fuentes por categoría',
+		'idle' => 'Fuentes inactivas',
+		'main' => 'Estadísticas principales',
+		'main_stream' => 'Salida principal',
+		'menu' => array(
+			'idle' => 'Fuentes inactivas',
+			'main' => 'Estadísticas principañes',
+			'repartition' => 'Reparto de artículos',
+		),
+		'no_idle' => 'No hay fuentes inactivas',
+		'number_entries' => '%d artículos',
+		'percent_of_total' => '%% del total',
+		'repartition' => 'Reprto de artículos',
+		'status_favorites' => 'Favoritos',
+		'status_read' => 'Leídos',
+		'status_total' => 'Total',
+		'status_unread' => 'Pendientes',
+		'title' => 'Estadísticas',
+		'top_feed' => 'Las 10 fuentes más activas',
+	),
+	'system' => array(
+		'_' => 'Configuración del sistema',
+		'auto-update-url' => 'URL de auto-actualización',
+		'instance-name' => 'Nombre de la fuente',
+		'max-categories' => 'Límite de categorías por usuario',
+		'max-feeds' => 'Límite de fuentes por usuario',
+		'registration' => array(
+			'help' => '0 significa que no hay límite en la cuenta',
+			'number' => 'Número máximo de cuentas',
+		),
+	),
+	'update' => array(
+		'_' => 'Actualizar sistema',
+		'apply' => 'Aplicar',
+		'check' => 'Buscar actualizaciones',
+		'current_version' => 'Dispones de la versión %s de FreshRSS.',
+		'last' => 'Última comprobación: %s',
+		'none' => 'No hay actualizaciones disponibles',
+		'title' => 'Actualizar sistema',
+	),
+	'user' => array(
+		'articles_and_size' => '%s articles (%s)',
+		'create' => 'Crear nuevo usuario',
+		'language' => 'Idioma',
+		'number' => 'Hay %d cuenta creada',
+		'numbers' => 'Hay %d cuentas creadas',
+		'password_form' => 'Contraseña<br /><small>(para el método de identificación por formulario web)</small>',
+		'password_format' => 'Mínimo de 7 caracteres',
+		'title' => 'Administrar usuarios',
+		'user_list' => 'Lista de usuarios',
+		'username' => 'Nombre de usuario',
+		'users' => 'Usuarios',
+	),
+);

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

@@ -0,0 +1,174 @@
+<?php
+
+return array(
+	'archiving' => array(
+		'_' => 'Archivo',
+		'advanced' => 'Avanzado',
+		'delete_after' => 'Eliminar artículos tras',
+		'help' => 'Hay más opciones disponibles en los ajustes de la fuente',
+		'keep_history_by_feed' => 'Número mínimo de artículos a conservar por fuente',
+		'optimize' => 'Optimizar la base de datos',
+		'optimize_help' => 'Ejecuta la optimización de vez en cuando para reducir el tamaño de la base de datos',
+		'purge_now' => 'Limpiar ahora',
+		'title' => 'Archivo',
+		'ttl' => 'No actualizar automáticamente más de',
+	),
+	'display' => array(
+		'_' => 'Visualización',
+		'icon' => array(
+			'bottom_line' => 'Línea inferior',
+			'entry' => 'Iconos de artículos',
+			'publication_date' => 'Fecha de publicación',
+			'related_tags' => 'Etiquetas relacionadas',
+			'sharing' => 'Compartir',
+			'top_line' => 'Línea superior',
+		),
+		'language' => 'Idioma',
+		'notif_html5' => array(
+			'seconds' => 'segundos (0 significa sin límite de espera)',
+			'timeout' => 'Notificación de fin de espera HTML5',
+		),
+		'theme' => 'Tema',
+		'title' => 'Visualización',
+		'width' => array(
+			'content' => 'Ancho de contenido',
+			'large' => 'Grande',
+			'medium' => 'Mediano',
+			'no_limit' => 'Sin límite',
+			'thin' => 'Estrecho',
+		),
+	),
+	'query' => array(
+		'_' => 'Consultas de usuario',
+		'deprecated' => 'Esta consulta ya no es válida. La categoría referenciada o fuente ha sido eliminada.',
+		'filter' => 'Filtro aplicado:',
+		'get_all' => 'Mostrar todos los artículos',
+		'get_category' => 'Mostrar la categoría "%s"',
+		'get_favorite' => 'Mostrar artículos favoritos',
+		'get_feed' => 'Mostrar fuente "%s"',
+		'no_filter' => 'Sin filtro',
+		'none' => 'Todavía no has creado ninguna consulta de usuario.',
+		'number' => 'Consulta n° %d',
+		'order_asc' => 'Mostrar primero los artículos más antiguos',
+		'order_desc' => 'Mostrar primero los artículos más recientes',
+		'search' => 'Buscar "%s"',
+		'state_0' => 'Mostrar todos los artículos',
+		'state_1' => 'Mostrar artículos leídos',
+		'state_2' => 'Mostrar artículos pendientes',
+		'state_3' => 'Mostrar todos los artículos',
+		'state_4' => 'Mostrar artículos favoritos',
+		'state_5' => 'Mostrar artículos favoritos leídos',
+		'state_6' => 'Mostrar artículos favoritos pendientes',
+		'state_7' => 'Mostrar artículos favoritos',
+		'state_8' => 'Mostrar artículos no favoritos',
+		'state_9' => 'Mostrar artículos no favoritos leídos',
+		'state_10' => 'Mostrar artículos no favoritos pendientes',
+		'state_11' => 'Mostrar artículos no favoritos',
+		'state_12' => 'Mostrar todos los artículos',
+		'state_13' => 'Mostrar artículos leídos',
+		'state_14' => 'Mostrar artículos sin leer',
+		'state_15' => 'Mostrar todos los artículos',
+		'title' => 'Consultas de usuario',
+	),
+	'profile' => array(
+		'_' => 'Administración de perfiles',
+		'delete' => array(
+			'_' => 'Borrar cuenta',
+			'warn' => 'Tu cuenta y todos los datos asociados serán eliminados.',
+		),
+		'password_api' => 'Contraseña API <br /><small>(para apps móviles, por ej.)</small>',
+		'password_form' => 'Contraseña<br /><small>(para el método de identificación por formulario web)</small>',
+		'password_format' => 'Mínimo de 7 caracteres',
+		'title' => 'Perfil',
+	),
+	'reading' => array(
+		'_' => 'Lectura',
+		'after_onread' => 'Tras “marcar todo como leído”,',
+		'articles_per_page' => 'Número de artículos por página',
+		'auto_load_more' => 'Cargar más artículos al final de la página',
+		'auto_remove_article' => 'Ocultar artículos tras la lectura',
+		'mark_updated_article_unread' => 'Marcar artículos actualizados como no leídos',
+		'confirm_enabled' => 'Mostrar ventana de confirmación al usar la función “marcar todos como leídos”',
+		'display_articles_unfolded' => 'Mostrar los artículos sin expandir por defecto',
+		'display_categories_unfolded' => 'Mostrar categorías expandidas por defecto',
+		'hide_read_feeds' => 'Ocultar categorías & fuentes sin artículos no leídos (no funciona con la configuración "Mostrar todos los artículos")',
+		'img_with_lazyload' => 'Usar el modo de "carga perezosa" para las imágenes',
+		'sides_close_article' => 'Pinchar fuera del área de texto del artículo lo cerrará',
+		'jump_next' => 'saltar al siguiente archivo sin leer emparentado (fuente o categoría)',
+		'number_divided_when_reader' => 'Dividido en 2 en la vista de lectura.',
+		'read' => array(
+			'article_open_on_website' => 'cuando el artículo se abra en su web original',
+			'article_viewed' => 'cuando se muestre el artículo',
+			'scroll' => 'durante el desplazamiento',
+			'upon_reception' => 'al recibir el artículo',
+			'when' => 'Marcar el artículo como leído…',
+		),
+		'show' => array(
+			'_' => 'Artículos a mostrar',
+			'adaptive' => 'Ajustar la visualización',
+			'all_articles' => 'Mostrar todos los artículos',
+			'unread' => 'Mostrar solo pendientes',
+		),
+		'sort' => array(
+			'_' => 'Orden',
+			'newer_first' => 'Nuevos primero',
+			'older_first' => 'Antiguos primero',
+		),
+		'sticky_post' => 'Pegar el artículo a la parte superior al abrirlo',
+		'title' => 'Lectura',
+		'view' => array(
+			'default' => 'Vista por defecto',
+			'global' => 'Vista Global',
+			'normal' => 'Vista Normal',
+			'reader' => 'Vista de Lectura',
+		),
+	),
+	'sharing' => array(
+		'_' => 'Compartir',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'more_information' => 'Más información',
+		'print' => 'Print',
+		'shaarli' => 'Shaarli',
+		'share_name' => 'Compartir nombre a mostrar',
+		'share_url' => 'Compatir URL a usar',
+		'title' => 'Compartir',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag',
+	),
+	'shortcut' => array(
+		'_' => 'Atajos de teclado',
+		'article_action' => 'Acciones de artículo',
+		'auto_share' => 'Compartir',
+		'auto_share_help' => 'Si solo hay un modo para compartir, ese será el que se use. En caso contrario los modos quedarán accesibles por su numeración.',
+		'close_dropdown' => 'Cerrar menús',
+		'collapse_article' => 'Contraer',
+		'first_article' => 'Saltar al primer artículo',
+		'focus_search' => 'Acceso a la casilla de búsqueda',
+		'help' => 'Mostrar documentación',
+		'javascript' => 'JavaScript debe estar activado para poder usar atajos de teclado',
+		'last_article' => 'Saltar al último artículo',
+		'load_more' => 'Cargar más artículos',
+		'mark_read' => 'Marcar como leído',
+		'mark_favorite' => 'Marcar como favorito',
+		'navigation' => 'Navegación',
+		'navigation_help' => 'Con el modificador "Mayúsculas" es posible usar los atajos de teclado en las fuentes.<br/>Con el modificador "Alt" es posible aplicar los atajos de teclado en las categorías.',
+		'next_article' => 'Saltar al siguiente artículo',
+		'other_action' => 'Otras acciones',
+		'previous_article' => 'Saltar al artículo anterior',
+		'see_on_website' => 'Ver en la web original',
+		'shift_for_all_read' => '+ <code>mayúsculas</code> para marcar todos los artículos como leídos',
+		'title' => 'Atajos de teclado',
+		'user_filter' => 'Acceso a filtros de usuario',
+		'user_filter_help' => 'Si solo hay un filtro de usuario, ese será el que se use. En caso contrario, los filtros están accesibles por su númeración.',
+	),
+	'user' => array(
+		'articles_and_size' => '%s artículos (%s)',
+		'current' => 'Usuario actual',
+		'is_admin' => 'es administrador',
+		'users' => 'Usuarios',
+	),
+);

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

@@ -0,0 +1,109 @@
+<?php
+
+return array(
+	'admin' => array(
+		'optimization_complete' => 'Optimimización completada',
+	),
+	'access' => array(
+		'denied' => 'No dispones de permiso para acceder a esta página',
+		'not_found' => 'La página que buscas no existe',
+	),
+	'auth' => array(
+		'form' => array(
+			'not_set' => 'Hubo un problema durante la configuración del sistema de idenfificación. Por favor, inténtalo más tarde.',
+			'set' => 'El formulario será desde ahora tu sistema de identificación por defecto.',
+		),
+		'login' => array(
+			'invalid' => 'Identificación incorrecta',
+			'success' => 'Conexión',
+		),
+		'logout' => array(
+			'success' => 'Desconexión',
+		),
+		'no_password_set' => 'Esta opción no está disponible porque no se ha definido una contraseña de administrador.',
+	),
+	'conf' => array(
+		'error' => 'Hubo un error durante el guardado de la configuración.',
+		'query_created' => 'Se ha creado la petición "%s".',
+		'shortcuts_updated' => 'Se han actualizado los atajos de teclado',
+		'updated' => 'Se ha actualizado la configuración',
+	),
+	'extensions' => array(
+		'already_enabled' => '%s ya está activado',
+		'disable' => array(
+			'ko' => '%s no se puede desactivar. <a href="%s">Revisa el registro de FressRSS</a> para más información.',
+			'ok' => '%s ha quedado desactivado',
+		),
+		'enable' => array(
+			'ko' => '%s no se puede activar. <a href="%s">Revisa el registro de FressRSS</a> para más información.',
+			'ok' => '%s ha quedado activado',
+		),
+		'no_access' => 'No tienes acceso a %s',
+		'not_enabled' => '%s no está activado',
+		'not_found' => '%s no existe',
+	),
+	'import_export' => array(
+		'export_no_zip_extension' => 'La extensión ZIP no está disponible en tu servidor. Por favor, exporta estos archivos uno a uno.',
+		'feeds_imported' => 'Se han importado tus fuentes y quedarán actualizadas',
+		'feeds_imported_with_errors' => 'Se importaron tus fuentes; pero hubo algunos errores',
+		'file_cannot_be_uploaded' => 'No es posible enviar el archivo',
+		'no_zip_extension' => 'La extensión ZIP no está disponible en tu servidor.',
+		'zip_error' => 'Hubo un error durante la importación ZIP.',
+	),
+	'sub' => array(
+		'actualize' => 'Actualización',
+		'category' => array(
+			'created' => 'Se ha creado la categoría %s.',
+			'deleted' => 'Se ha eliminado la categoría.',
+			'emptied' => 'Se ha vaciado la categoría',
+			'error' => 'No es posible actualizar la categoría',
+			'name_exists' => 'Ya existe una categoría con ese nombre.',
+			'no_id' => 'Debes especificar la id de la categoría.',
+			'no_name' => '¡El nombre de la categoría no puede dejarse en blanco!.',
+			'not_delete_default' => '¡No puedes borrar la categoría por defecto!',
+			'not_exist' => 'La categoría no existe',
+			'over_max' => 'Has alcanzado el límite de categorías (%d)',
+			'updated' => 'La categoría se ha actualizado.',
+		),
+		'feed' => array(
+			'actualized' => '<em>%s</em> ha sido actualizada',
+			'actualizeds' => 'Las fuentes RSS se han actualizado',
+			'added' => 'Fuente RSS agregada <em>%s</em>',
+			'already_subscribed' => 'Ya estás suscrito a <em>%s</em>',
+			'deleted' => 'Fuente eliminada',
+			'error' => 'No es posible actualizar la fuente',
+			'internal_problem' => 'No ha sido posible agregar la fuente RSS. <a href="%s">Revisa el registro de FressRSS </a> para más información.',
+			'invalid_url' => 'La URL <em>%s</em> es inválida',
+			'marked_read' => 'Fuentes marcadas como leídas',
+			'n_actualized' => 'Se han actualiado %d fuentes',
+			'n_entries_deleted' => 'Se han eliminado %d artículos',
+			'no_refresh' => 'No hay fuente a actualizar…',
+			'not_added' => '<em>%s</em> no ha podido se añadida',
+			'over_max' => 'Has alcanzado tu límite de fuentes (%d)',
+			'updated' => 'Fuente actualizada',
+		),
+		'purge_completed' => 'Limpieza completada (se han eliminado %d artículos)',
+	),
+	'update' => array(
+		'can_apply' => 'FreshRSS se va a actualizar a la <strong>versión %s</strong>.',
+		'error' => 'Hubo un error durante el proceso de actualización: %s',
+		'file_is_nok' => 'Disponible la nueva <strong>versión %s</strong>. Sin embargo, debes revisar los permisos en el directorio <em>%s</em>. El servidor HTTP debe contar con permisos de escritura',
+		'finished' => '¡Actualización completada!',
+		'none' => 'No hay actualizaciones para procesar',
+		'server_not_found' => 'No se ha podido conectar con el servidor de actualizaciones. [%s]',
+	),
+	'user' => array(
+		'created' => array(
+			'_' => 'Se ha creado el usuario %s',
+			'error' => 'No se ha podido crear al usuario %s',
+		),
+		'deleted' => array(
+			'_' => 'El usuario %s ha sido eliminado',
+			'error' => 'El usuario %s no ha podido ser eliminado',
+		),
+	),
+	'profile' => array(
+		'error' => 'Tu perfil no puede ser modificado',
+		'updated' => 'Tu perfil ha sido modificado',
+	),
+);

+ 190 - 0
app/i18n/es/gen.php

@@ -0,0 +1,190 @@
+<?php
+
+return array(
+	'action' => array(
+		'actualize' => 'Actualizar',
+		'back_to_rss_feeds' => '← regresar a tus fuentes RSS',
+		'cancel' => 'Cancelar',
+		'create' => 'Crear',
+		'disable' => 'Desactivar',
+		'empty' => 'Vaciar',
+		'enable' => 'Activar',
+		'export' => 'Exportar',
+		'filter' => 'Filtrar',
+		'import' => 'Importar',
+		'manage' => 'Administrar',
+		'mark_favorite' => 'Marcar como favorita',
+		'mark_read' => 'Marcar como leído',
+		'remove' => 'Borrar',
+		'see_website' => 'Ver web',
+		'submit' => 'Enviar',
+		'truncate' => 'Borrar todos los artículos',
+	),
+	'auth' => array(
+		'email' => 'Correo electrónico',
+		'keep_logged_in' => 'Mantenerme identificado <small>(%s días)</small>',
+		'login' => 'Conectar',
+		'logout' => 'Desconectar',
+		'password' => array(
+			'_' => 'Contraseña',
+			'format' => '<small>Mínimo de 7 caracteres</small>',
+		),
+		'registration' => array(
+			'_' => 'Nueva cuenta',
+			'ask' => '¿Crear una cuenta?',
+			'title' => 'Creación de cuenta',
+		),
+		'reset' => 'Reinicar identificación',
+		'username' => array(
+			'_' => 'Nombre de usuario',
+			'admin' => 'Nombre de usuario del Administrador',
+			'format' => '<small>máximo 16 caracteres alfanuméricos</small>',
+		),
+	),
+	'date' => array(
+		'Apr' => '\\A\\b\\r\\i\\l',
+		'Aug' => '\\A\\g\\o\\s\\t\\o',
+		'Dec' => '\\D\\i\\c\\i\\e\\m\\b\\r\\e',
+		'Feb' => '\\F\\e\\b\\r\\e\\r\\o',
+		'Jan' => '\\E\\n\\e\\r\\o',
+		'Jul' => '\\J\\u\\l\\i\\o',
+		'Jun' => '\\J\\u\\n\\i\\o',
+		'Mar' => '\\M\\a\\r\\z\\o',
+		'May' => '\\M\\a\\y\\o',
+		'Nov' => '\\N\\o\\v\\i\\e\\m\\b\\r\\e',
+		'Oct' => '\\O\\c\\t\\u\\b\\r\\e',
+		'Sep' => '\\S\\e\\p\\t\\i\\e\\m\\b\\r\\e',
+		'apr' => 'abr',
+		'april' => 'abril',
+		'aug' => 'ago',
+		'august' => 'agosto',
+		'before_yesterday' => 'Anteayer',
+		'dec' => 'dic',
+		'december' => 'diciembre',
+		'feb' => 'feb',
+		'february' => 'febrero',
+		'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' => 'Vie',
+		'jan' => 'ene',
+		'january' => 'ene',
+		'jul' => 'jul',
+		'july' => 'julio',
+		'jun' => 'jun',
+		'june' => 'junio',
+		'last_3_month' => 'Últimos tres meses',
+		'last_6_month' => 'Últimos seis meses',
+		'last_month' => 'Mes pasado',
+		'last_week' => 'Semana pasada',
+		'last_year' => 'Año pasado',
+		'mar' => 'mar',
+		'march' => 'marzo',
+		'may' => 'mayo',
+		'may_' => 'may',
+		'mon' => 'Lun',
+		'month' => 'meses',
+		'nov' => 'nov',
+		'november' => 'noviembre',
+		'oct' => 'oct',
+		'october' => 'octubre',
+		'sat' => 'Sab',
+		'sep' => 'sep',
+		'september' => 'septiembre',
+		'sun' => 'Dom',
+		'thu' => 'Jue',
+		'today' => 'Hoy',
+		'tue' => 'Mar',
+		'wed' => 'Mie',
+		'yesterday' => 'Ayer',
+	),
+	'freshrss' => array(
+		'_' => 'FreshRSS',
+		'about' => 'Acerca de FreshRSS',
+	),
+	'js' => array(
+		'category_empty' => 'Vaciar categoría',
+		'confirm_action' => '¿Seguyro que quieres hacerlo? No hay marcha atrás...',
+		'confirm_action_feed_cat' => '¿Seguro que quieres hacerlo? Perderás todos los favoritos relacionados y las peticiones de usuario. ¡Y no hay marcha atrás!',
+		'feedback' => array(
+			'body_new_articles' => 'Hay %%d nuevos artículos para leer en FreshRSS.',
+			'request_failed' => 'La petición ha fallado. Puede ser debido a problemas de conexión a internet.',
+			'title_new_articles' => 'FreshRSS: ¡Nuevos artículos!',
+		),
+		'new_article' => 'Hay nuevos artículos disponibles. Pincha para refrescar la página.',
+		'should_be_activated' => 'JavaScript debe estar activado',
+	),
+	'lang' => array(
+		'cz' => 'Čeština',
+		'de' => 'Deutsch',
+		'en' => 'English',
+		'es' => 'Español',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'kr' => '한국어',
+		'nl' => 'Nederlands',
+		'pt-br' => 'Português (Brasil)',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+		'zh-cn' => '简体中文',
+	),
+	'menu' => array(
+		'about' => 'Acerca de',
+		'admin' => 'Administración',
+		'archiving' => 'Archivo',
+		'authentication' => 'Identificación',
+		'check_install' => 'Verificación de instalación',
+		'configuration' => 'Configuración',
+		'display' => 'Visualización',
+		'extensions' => 'Extensiones',
+		'logs' => 'Registros',
+		'queries' => 'Peticiones de usuario',
+		'reading' => 'Lectura',
+		'search' => 'Buscar palabras o #etiquetas',
+		'sharing' => 'Compartir',
+		'shortcuts' => 'Atajos',
+		'stats' => 'Estadísticas',
+		'system' => 'Configuración del sistema',
+		'update' => 'Actualización',
+		'user_management' => 'Administrar usuarios',
+		'user_profile' => 'Perfil',
+	),
+	'pagination' => array(
+		'first' => 'Primero',
+		'last' => 'Último',
+		'load_more' => 'Cargar más artículos',
+		'mark_all_read' => 'Marcar todo como leído',
+		'next' => 'Siguiente',
+		'nothing_to_load' => 'No hay más artículos',
+		'previous' => 'Anterior',
+	),
+	'share' => array(
+		'Known' => 'Known based sites',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'gnusocial' => 'GNU social',
+		'jdh' => 'Journal du hacker',
+		'mastodon' => 'Mastodon',
+		'movim' => 'Movim',
+		'print' => 'Print',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
+	),
+	'short' => array(
+		'attention' => '¡Aviso!',
+		'blank_to_disable' => 'Deja en blanco para desactivar',
+		'by_author' => 'Por <em>%s</em>',
+		'by_default' => 'Por defecto',
+		'damn' => '¡Córcholis!',
+		'default_category' => 'Sin categorizar',
+		'no' => 'No',
+		'not_applicable' => 'No disponible',
+		'ok' => '¡Vale!',
+		'or' => 'o',
+		'yes' => 'Sí',
+	),
+);

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

@@ -0,0 +1,61 @@
+<?php
+
+return array(
+	'about' => array(
+		'_' => 'Acerca de',
+		'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
+		'bugs_reports' => 'Informe de fallos',
+		'credits' => 'Créditos',
+		'credits_content' => 'Aunque FreshRSS no usa ese entorno, algunos elementos del diseño están obtenidos de <a href="http://twitter.github.io/bootstrap/">Bootstrap</a>. Los <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">Iconos</a> han sido obtenidos del <a href="https://www.gnome.org/">proyecto GNOME</a>. La fuente <em>Open Sans</em> es una creación de <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS usa el entorno PHP <a href="https://github.com/marienfressinaud/MINZ">Minz</a>.',
+		'freshrss_description' => 'FreshRSS es un agregador de fuentes RSS de alojamiento privado al estilo de <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> o <a href="http://projet.idleman.fr/leed/">Leed</a>. Es una herramienta potente, pero ligera y fácil de usar y configurar.',
+		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">en Github</a>',
+		'license' => 'Licencia',
+		'project_website' => 'Web del proyecto',
+		'title' => 'Acerca de',
+		'version' => 'Versión',
+		'website' => 'Web',
+	),
+	'feed' => array(
+		'add' => 'Puedes añadir fuentes.',
+		'empty' => 'No hay artículos a mostrar.',
+		'rss_of' => 'Fuente RSS de %s',
+		'title' => 'Tus fuentes RSS',
+		'title_global' => 'Vista global',
+		'title_fav' => 'Tus favoritos',
+	),
+	'log' => array(
+		'_' => 'Registros',
+		'clear' => 'Limpiar registros',
+		'empty' => 'El archivo de registro está vacío',
+		'title' => 'Registros',
+	),
+	'menu' => array(
+		'about' => 'Acerca de FreshRSS',
+		'add_query' => 'Añadir petición',
+		'before_one_day' => 'Con más de 1 día',
+		'before_one_week' => 'Con más de una semana',
+		'favorites' => 'Favoritos (%s)',
+		'global_view' => 'Vista Global',
+		'main_stream' => 'Salida Principal',
+		'mark_all_read' => 'Marcar todo como leído',
+		'mark_cat_read' => 'Marcar categoría como leída',
+		'mark_feed_read' => 'Marcar fuente como leída',
+		'newer_first' => 'Nuevos primero',
+		'non-starred' => 'Mostrar todos menos los favoritos',
+		'normal_view' => 'Vista normal',
+		'older_first' => 'Más antiguos primero',
+		'queries' => 'Peticiones de usuario',
+		'read' => 'Mostrar solo los leídos',
+		'reader_view' => 'Vista de lectura',
+		'rss_view' => 'Fuente RSS',
+		'search_short' => 'Buscar',
+		'starred' => 'Mostrar solo los favoritos',
+		'stats' => 'Estadísticas',
+		'subscription' => 'Administración de suscripciones',
+		'unread' => 'Mostar solo no leídos',
+	),
+	'share' => 'Compartir',
+	'tag' => array(
+		'related' => 'Etiquetas relacionadas',
+	),
+);

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

@@ -0,0 +1,119 @@
+<?php
+
+return array(
+	'action' => array(
+		'finish' => 'Completar instalación',
+		'fix_errors_before' => 'Por favor, soluciona los errores detectados antes de continuar con el siguiente paso.',
+		'keep_install' => 'Conservar la configuración anterior',
+		'next_step' => 'Ir al siguiente paso',
+		'reinstall' => 'Reinstalar FreshRSS',
+	),
+	'auth' => array(
+		'form' => 'Formulario Web (método más habitual, requiere JavaScript)',
+		'http' => 'HTTP (para usuarios avanzados con HTTPS)',
+		'none' => 'Ninguna (peligroso)',
+		'password_form' => 'Contraseña<br /><small>(para el método de acceso mediante formulario web)</small>',
+		'password_format' => 'Al menos 7 caracteres',
+		'type' => 'Método de identificación',
+	),
+	'bdd' => array(
+		'_' => 'Base de datos',
+		'conf' => array(
+			'_' => 'Configuración de la base de datos',
+			'ko' => 'Verificar la información de tu base de datos.',
+			'ok' => 'La configuración de la base de datos ha sido guardada.',
+		),
+		'host' => 'Servidor',
+		'prefix' => 'Prefijo de la tabla',
+		'password' => 'Contraseña de la base de datos',
+		'type' => 'Tipo de base de datos',
+		'username' => 'Nombre de usuario de la base de datos',
+	),
+	'check' => array(
+		'_' => 'Verificaciones',
+		'already_installed' => '¡FreshRSS ya está instalado!',
+		'cache' => array(
+			'nok' => 'Comprueba los permisos en el directorio <em>./data/cache</em>. El servidor HTTP debe contar con permisos de escritura.',
+			'ok' => 'Los permisos del directorio cache son correctos.',
+		),
+		'ctype' => array(
+			'nok' => 'No se ha podido localizar la librería para la verificación del tipo de caracteres (php-ctype).',
+			'ok' => 'Cuentas con la librería necesaria para la verificación del tipo de caracteres (ctype).',
+		),
+		'curl' => array(
+			'nok' => 'No se ha podido localizar la librería cURL (paquete php-curl).',
+			'ok' => 'Dispones de la librería cURL.',
+		),
+		'data' => array(
+			'nok' => 'Comprueba los permisos del directorio <em>./data</em>. El servidor HTTP debe contar con permisos de escritura.',
+			'ok' => 'Los permisos del directorio data son correctos.',
+		),
+		'dom' => array(
+			'nok' => 'No se ha podido localizar la librería necesaria para explorar la DOM.',
+			'ok' => 'Dispones de la librería necesaria para explorar la DOM.',
+		),
+		'favicons' => array(
+			'nok' => 'Verifica los permisos en el directorio <em>./data/favicons</em>. El servidor HTTP debe contar con permisos de escritura.',
+			'ok' => 'Los permisos del directorio favicons son correctos.',
+		),
+		'fileinfo' => array(
+			'nok' => 'No se ha podido localizar la librería PHP fileinfo (paquete fileinfo).',
+			'ok' => 'Dispones de la librería fileinfo.',
+		),
+		'http_referer' => array(
+			'nok' => 'Por favor, comprueba que no estás alterando tu configuración HTTP REFERER.',
+			'ok' => 'La configuración HTTP REFERER es conocida y se corresponde con la de tu servidor.',
+		),
+		'json' => array(
+			'nok' => 'No se ha podido localizar la librería para procesar JSON.',
+			'ok' => 'Dispones de la librería recomendada para procesar JSON.',
+		),
+		'minz' => array(
+			'nok' => 'No se ha podido localizar el entorno Minz.',
+			'ok' => 'Dispones del entorno Minz.',
+		),
+		'pcre' => array(
+			'nok' => 'No se ha podido encontrar la librería necesaria para las expresiones regulares (php-pcre).',
+			'ok' => 'Dispones de la librería necesaria para las expresiones regulares (PCRE).',
+		),
+		'pdo' => array(
+			'nok' => 'No se ha podido localizar PDO o uno de los controladores compatibles (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'Dispones de PDO y al menos uno de los controladores compatibles (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+		),
+		'php' => array(
+			'nok' => 'Dispones de la versión PHP %s, pero FreshRSS necesita de, al menos, la versión %s.',
+			'ok' => 'Dispones de la versión PHP %s, que es compatible con FreshRSS.',
+		),
+		'users' => array(
+			'nok' => 'Revisa los permisos en el directorio <em>./data/users</em>. El servidor HTTP debe contar con permisos de escritura.',
+			'ok' => 'Los permisos en el directorio users son correctos.',
+		),
+		'xml' => array(
+			'nok' => 'No se ha podido localizar la librería necesaria para procesar XML.',
+			'ok' => 'Dispones de la librería necesaria para procesar XML.',
+		),
+	),
+	'conf' => array(
+		'_' => 'Configuración general',
+		'ok' => 'La configuración general se ha guardado.',
+	),
+	'congratulations' => '¡Enhorabuena!',
+	'default_user' => 'Nombre de usuario para el usuario por defecto <small>(máximo de 16 caracteres alfanuméricos)</small>',
+	'delete_articles_after' => 'Eliminar los artículos tras',
+	'fix_errors_before' => 'Por favor, soluciona los errores detectados antes de proceder con el siguiente paso.',
+	'javascript_is_better' => 'FreshRSS funciona mejor con JavaScript activado',
+	'js' => array(
+		'confirm_reinstall' => 'Al reinstalar FreshRSS perderás cualquier configuración anterior. ¿Seguro que quieres continuar?',
+	),
+	'language' => array(
+		'_' => 'Idioma',
+		'choose' => 'Selecciona el idioma para FreshRSS',
+		'defined' => 'Idioma seleccionado.',
+	),
+	'not_deleted' => 'Parece que ha habido un error. Debes eliminar el archivo <em>%s</em> de forma manual.',
+	'ok' => 'La instalación se ha completado correctamente.',
+	'step' => 'paso %d',
+	'steps' => 'Pasos',
+	'title' => 'Instalación · FreshRSS',
+	'this_is_the_end' => '¡Terminamos!',
+);

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

@@ -0,0 +1,66 @@
+<?php
+
+return array(
+	'api' => array(
+		'documentation' => 'Copy the following URL to use it within an external tool.',// TODO
+		'title' => 'API',// TODO
+	),
+	'category' => array(
+		'_' => 'Categoría',
+		'add' => 'Añadir a la categoría',
+		'empty' => 'Vaciar categoría',
+		'new' => 'Nueva categoría',
+	),
+	'feed' => array(
+		'add' => 'Añadir fuente RSS',
+		'advanced' => 'Avanzado',
+		'archiving' => 'Archivo',
+		'auth' => array(
+			'configuration' => 'Identificación',
+			'help' => 'Permitir acceso a fuentes RSS protegidas con HTTP',
+			'http' => 'Identificación HTTP',
+			'password' => 'Contraseña HTTP',
+			'username' => 'Nombre de usuario HTTP',
+		),
+		'css_help' => 'Recibir fuentes RSS truncadas (aviso, ¡necesita más tiempo!)',
+		'css_path' => 'Ruta a la CSS de los artículos en la web original',
+		'description' => 'Descripción',
+		'empty' => 'La fuente está vacía. Por favor, verifica que siga activa.',
+		'error' => 'Hay un problema con esta fuente. Por favor, veritica que esté disponible y prueba de nuevo.',
+		'in_main_stream' => 'Mostrar en salida principal',
+		'informations' => 'Información',
+		'keep_history' => 'Número mínimo de artículos a conservar',
+		'moved_category_deleted' => 'Al borrar una categoría todas sus fuentes pasan automáticamente a la categoría <em>%s</em>.',
+		'no_selected' => 'No hay funentes seleccionadas.',
+		'number_entries' => '%d artículos',
+		'stats' => 'Estadísticas',
+		'think_to_add' => 'Puedes añadir fuentes.',
+		'title' => 'Título',
+		'title_add' => 'Añadir fuente RSS',
+		'ttl' => 'No actualizar de forma automática con una frecuencia mayor a',
+		'url' => 'URL de la fuente',
+		'validator' => 'Verifica la validez de la fuente',
+		'website' => 'Web de la URL',
+		'pubsubhubbub' => 'Notificación inmedaiata con PubSubHubbub',
+	),
+	'import_export' => array(
+		'export' => 'Exportar',
+		'export_opml' => 'Exportar la lista de fuentes (OPML)',
+		'export_starred' => 'Exportar tus favoritos',
+		'feed_list' => 'Lista de %s artículos',
+		'file_to_import' => 'Archivo a importar<br />(OPML, JSON o ZIP)',
+		'file_to_import_no_zip' => 'Archivo a importar<br />(OPML o JSON)',
+		'import' => 'Importar',
+		'starred_list' => 'Lista de artículos favoritos',
+		'title' => 'Importar / exportar',
+	),
+	'menu' => array(
+		'bookmark' => 'Suscribirse (favorito FreshRSS)',
+		'import_export' => 'Importar / exportar',
+		'subscription_management' => 'Administración de suscripciones',
+	),
+	'title' => array(
+		'_' => 'Administración de suscripciones',
+		'feed_management' => 'Administración de fuentes RSS',
+	),
+);

+ 41 - 23
app/i18n/fr/admin.php

@@ -8,7 +8,6 @@ return array(
 		'form' => 'Formulaire (traditionnel, requiert JavaScript)',
 		'http' => 'HTTP (pour utilisateurs avancés avec HTTPS)',
 		'none' => 'Aucune (dangereux)',
-		'persona' => 'Mozilla Persona (moderne, requiert JavaScript)',
 		'title' => 'Authentification',
 		'title_reset' => 'Réinitialisation de l’authentification',
 		'token' => 'Jeton d’identification',
@@ -30,12 +29,12 @@ return array(
 			'ok' => 'La connexion à la base de données est bonne.',
 		),
 		'ctype' => array(
-			'nok' => 'Il manque une librairie pour la vérification des types de caractères (php-ctype).',
-			'ok' => 'Vous disposez du nécessaire pour la vérification des types de caractères (ctype).',
+			'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 php5-curl).',
-			'ok' => 'Vous disposez de cURL.',
+			'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',
@@ -43,8 +42,8 @@ return array(
 		),
 		'database' => 'Installation de la base de données',
 		'dom' => array(
-			'nok' => 'Il manque une librairie pour parcourir le DOM (paquet php-xml).',
-			'ok' => 'Vous disposez du nécessaire pour parcourir le DOM.',
+			'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.',
@@ -58,26 +57,26 @@ return 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.',
+			'ok' => 'Vous disposez de lextension JSON.',
 		),
 		'minz' => array(
 			'nok' => 'Vous ne disposez pas de la librairie Minz.',
 			'ok' => 'Vous disposez du framework Minz',
 		),
 		'pcre' => array(
-			'nok' => 'Il manque une librairie pour les expressions régulières (php-pcre).',
-			'ok' => 'Vous disposez du nécessaire pour les expressions régulières (PCRE).',
+			'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).',
-			'ok' => 'Vous disposez de PDO et d’au moins un des drivers supportés (pdo_mysql, pdo_sqlite).',
-		),
-		'persona' => array(
-			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/persona</em>. Le serveur HTTP doit être capable d’écrire dedans',
-			'ok' => 'Les droits sur le répertoire de Mozilla Persona sont bons.',
+			'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',
@@ -85,7 +84,7 @@ return array(
 			'ok' => 'Votre version de PHP est la %s, qui est compatible avec FreshRSS.',
 		),
 		'tables' => array(
-			'nok' => 'Il manque une ou plusieurs tables en base de données.',
+			'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',
@@ -98,21 +97,28 @@ return array(
 			'ok' => 'Les droits sur le répertoire des utilisateurs sont bons.',
 		),
 		'zip' => array(
-			'nok' => 'Vous ne disposez pas de l\'extension ZIP (paquet php5-zip).',
-			'ok' => 'Vous disposez de l\'extension ZIP.',
+			'nok' => 'Vous ne disposez pas de l’extension ZIP (paquet php-zip).',
+			'ok' => 'Vous disposez de lextension ZIP.',
 		),
 	),
 	'extensions' => array(
 		'disabled' => 'Désactivée',
-		'empty_list' => 'Il n’y a aucune extension installée.',
+		'empty_list' => 'Aucune extension installée',
 		'enabled' => 'Activée',
-		'no_configure_view' => 'Cette extension ne peut pas être configurée.',
+		'no_configure_view' => 'Cette extension n’a pas à être configurée',
 		'system' => array(
 			'_' => 'Extensions système',
-			'no_rights' => 'Extension système (vous n’avez aucun droit dessus)',
+			'no_rights' => 'Extensions système (contrôlées par l’administrateur)',
 		),
 		'title' => 'Extensions',
 		'user' => 'Extensions utilisateur',
+		'community' => 'Extensions utilisateur disponibles',
+		'name' => 'Nom',
+		'version' => 'Version',
+		'description' => 'Description',
+		'author' => 'Auteur',
+		'latest' => 'Installée',
+		'update' => 'Mise à jour disponible',
 	),
 	'stats' => array(
 		'_' => 'Statistiques',
@@ -146,6 +152,17 @@ return array(
 		'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',
@@ -158,8 +175,9 @@ return array(
 	'user' => array(
 		'articles_and_size' => '%s articles (%s)',
 		'create' => 'Créer un nouvel utilisateur',
-		'email_persona' => 'Adresse courriel de connexion<br /><small>(pour <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
 		'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',

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

@@ -72,7 +72,10 @@ return array(
 	),
 	'profile' => array(
 		'_' => 'Gestion du profil',
-		'email_persona' => 'Adresse courriel de connexion<br /><small>(pour <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
+		'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',
@@ -84,11 +87,13 @@ return array(
 		'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',
+		'sides_close_article' => 'Cliquer hors de la zone de texte ferme l’article',
 		'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(

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

@@ -21,7 +21,6 @@ return 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.',
-		'not_persona' => 'Seul le système d’authentification Persona peut être réinitialisé.',
 	),
 	'conf' => array(
 		'error' => 'Une erreur est survenue durant la sauvegarde de la configuration',
@@ -44,12 +43,12 @@ return array(
 		'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.',
+		'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.',
+		'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',
@@ -88,7 +87,7 @@ return array(
 	'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',
+		'file_is_nok' => 'Nouvelle <strong>version %s</strong> disponible, mais 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]',

+ 39 - 12
app/i18n/fr/gen.php

@@ -13,24 +13,33 @@ return array(
 		'filter' => 'Filtrer',
 		'import' => 'Importer',
 		'manage' => 'Gérer',
-		'mark_read' => 'Marquer comme lu',
 		'mark_favorite' => 'Mettre en favori',
+		'mark_read' => 'Marquer comme lu',
 		'remove' => 'Supprimer',
 		'see_website' => 'Voir le site',
 		'submit' => 'Valider',
 		'truncate' => 'Supprimer tous les articles',
 	),
 	'auth' => array(
-		'keep_logged_in' => 'Rester connecté <small>(1 mois)</small>',
+		'email' => 'Adresse courriel',
+		'keep_logged_in' => 'Rester connecté <small>(%s jours)</small>',
 		'login' => 'Connexion',
-		'login_persona' => 'Connexion avec Persona',
-		'login_persona_problem' => 'Problème de connexion à Persona ?',
 		'logout' => 'Déconnexion',
-		'password' => 'Mot de passe',
+		'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' => 'Nom d’utilisateur',
-		'username_admin' => 'Nom d’utilisateur administrateur',
-		'will_reset' => 'Le système d’authentification va être réinitialisé : un formulaire sera utilisé à la place de Persona.',
+		'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',
@@ -68,9 +77,10 @@ return array(
 		'last_month' => 'Depuis le mois dernier',
 		'last_week' => 'Depuis la semaine dernière',
 		'last_year' => 'Depuis l’année dernière',
-		'mar' => 'mar.',
+		'mar' => 'mars',
 		'march' => 'mars',
-		'may' => 'mai.',
+		'may' => 'mai',
+		'may_' => 'mai',
 		'mon' => 'lun.',
 		'month' => 'mois',
 		'nov' => 'nov.',
@@ -96,7 +106,7 @@ return array(
 		'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.',
+			'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 !',
 		),
@@ -104,10 +114,19 @@ return array(
 		'should_be_activated' => 'Le JavaScript doit être activé.',
 	),
 	'lang' => array(
+		'cz' => 'Čeština',
 		'de' => 'Deutsch',
 		'en' => 'English',
+		'es' => 'Español',
 		'fr' => 'Français',
 		'he' => 'עברית',
+		'it' => 'Italiano',
+		'kr' => '한국어',
+		'nl' => 'Nederlands',
+		'pt-br' => 'Português (Brasil)',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+		'zh-cn' => '简体中文',
 	),
 	'menu' => array(
 		'about' => 'À propos',
@@ -125,6 +144,7 @@ return array(
 		'sharing' => 'Partage',
 		'shortcuts' => 'Raccourcis',
 		'stats' => 'Statistiques',
+		'system' => 'Configuration du système',
 		'update' => 'Mise à jour',
 		'user_management' => 'Gestion des utilisateurs',
 		'user_profile' => 'Profil',
@@ -139,15 +159,21 @@ return array(
 		'previous' => 'Précédent',
 	),
 	'share' => array(
+		'Known' => 'Sites basés sur Known',
 		'blogotext' => 'Blogotext',
 		'diaspora' => 'Diaspora*',
 		'email' => 'Courriel',
 		'facebook' => 'Facebook',
 		'g+' => 'Google+',
+		'gnusocial' => 'GNU social',
+		'jdh' => 'Journal du hacker',
+		'mastodon' => 'Mastodon',
+		'movim' => 'Movim',
 		'print' => 'Imprimer',
 		'shaarli' => 'Shaarli',
 		'twitter' => 'Twitter',
-		'wallabag' => 'wallabag',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
 	),
 	'short' => array(
 		'attention' => 'Attention !',
@@ -157,6 +183,7 @@ return array(
 		'damn' => 'Arf !',
 		'default_category' => 'Sans catégorie',
 		'no' => 'Non',
+		'not_applicable' => 'Non disponible',
 		'ok' => 'Ok !',
 		'or' => 'ou',
 		'yes' => 'Oui',

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

@@ -6,7 +6,7 @@ return array(
 		'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://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.',
+		'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',

+ 29 - 17
app/i18n/fr/install.php

@@ -4,16 +4,16 @@ 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(
-		'email_persona' => 'Adresse courriel de connexion<br /><small>(pour <a href="https://persona.org/" rel="external">Mozilla Persona</a>)</small>',
 		'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',
-		'persona' => 'Mozilla Persona (moderne, requiert JavaScript)',
 		'type' => 'Méthode d’authentification',
 	),
 	'bdd' => array(
@@ -24,23 +24,24 @@ return array(
 			'ok' => 'La configuration de la base de données a été enregistrée.',
 		),
 		'host' => 'Hôte',
-		'password' => 'Mot de passe',
+		'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',
+		'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' => 'Il manque une librairie pour la vérification des types de caractères (php-ctype).',
-			'ok' => 'Vous disposez du nécessaire pour la vérification des types de caractères (ctype).',
+			'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 php5-curl).',
+			'nok' => 'Vous ne disposez pas de cURL (paquet php-curl).',
 			'ok' => 'Vous disposez de cURL.',
 		),
 		'data' => array(
@@ -48,32 +49,36 @@ return array(
 			'ok' => 'Les droits sur le répertoire de data sont bons.',
 		),
 		'dom' => array(
-			'nok' => 'Il manque une librairie pour parcourir le DOM (paquet php-xml).',
-			'ok' => 'Vous disposez du nécessaire pour parcourir le DOM.',
+			'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' => 'Il manque une librairie pour les expressions régulières (php-pcre).',
-			'ok' => 'Vous disposez du nécessaire pour les expressions régulières (PCRE).',
+			'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).',
-			'ok' => 'Vous disposez de PDO et d’au moins un des drivers supportés (pdo_mysql, pdo_sqlite).',
-		),
-		'persona' => array(
-			'nok' => 'Veuillez vérifier les droits sur le répertoire <em>./data/persona</em>. Le serveur HTTP doit être capable d’écrire dedans',
-			'ok' => 'Les droits sur le répertoire de Mozilla Persona sont bons.',
+			'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.',
@@ -83,6 +88,10 @@ return 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',
@@ -93,6 +102,9 @@ return array(
 	'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',

+ 19 - 3
app/i18n/fr/sub.php

@@ -1,6 +1,15 @@
 <?php
 
 return array(
+	'api' => array(
+		'documentation' => 'Copier l’URL suivante dans l’outil qui utilisera l’API.',
+		'title' => 'API',
+	),
+	'bookmarklet' => array(
+		'documentation' => 'Glisser ce bouton dans la barre des favoris ou cliquer droit dessus et choisir "Enregistrer ce lien". Ensuite, cliquer sur le bouton "S’abonner" sur les pages auxquelles vous voulez vous abonner.',
+		'label' => 'S’abonner',
+		'title' => 'Bookmarklet',
+	),
 	'category' => array(
 		'_' => 'Catégorie',
 		'add' => 'Ajouter une catégorie',
@@ -35,16 +44,21 @@ return array(
 		'title_add' => 'Ajouter un flux RSS',
 		'ttl' => 'Ne pas automatiquement rafraîchir plus souvent que',
 		'url' => 'URL du flux',
-		'validator' => 'Vérifier la valididé du flux',
+		'validator' => 'Vérifier la validité du flux',
 		'website' => 'URL du site',
+		'pubsubhubbub' => 'Notification instantanée par PubSubHubbub',
+	),
+	'firefox' => array(
+		'documentation' => 'Suivre les étapes décrites <a href="https://developer.mozilla.org/en-US/Firefox/Releases/2/Adding_feed_readers_to_Firefox#Adding_a_new_feed_reader_manually">ici</a> pour ajouter FreshRSS à la liste des lecteurs de flux dans Firefox.',
+		'title' => 'Lecteur de flux dans Firefox',
 	),
 	'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)',
+		'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',
@@ -53,9 +67,11 @@ return array(
 		'bookmark' => 'S’abonner (bookmark FreshRSS)',
 		'import_export' => 'Importer / exporter',
 		'subscription_management' => 'Gestion des abonnements',
+		'subscription_tools' => 'Outils d’abonnement',
 	),
 	'title' => array(
 		'_' => 'Gestion des abonnements',
 		'feed_management' => 'Gestion des flux RSS',
+		'subscription_tools' => 'Outils d’abonnement',
 	),
 );

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

@@ -0,0 +1,188 @@
+<?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',
+		'community' => 'Available community extensions', // @todo translate
+		'name' => 'Name', // @todo translate
+		'version' => 'Version', // @todo translate
+		'description' => 'Description', // @todo translate
+		'author' => 'Author', // @todo translate
+		'latest' => 'Installed', // @todo translate
+		'update' => 'Update available', // @todo translate
+	),
+	'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',
+	),
+);

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

@@ -0,0 +1,174 @@
+<?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',
+		'sides_close_article' => 'Clicking outside of article text area closes the article',	//TODO
+		'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' => 'Nuova <strong>versione %s</strong>, ma 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',
+	),
+);

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

@@ -0,0 +1,190 @@
+<?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_favorite' => 'Segna come preferito',
+		'mark_read' => 'Segna come letto',
+		'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' => 'aprile',
+		'aug' => 'ag.',
+		'august' => 'agosto',
+		'before_yesterday' => 'Meno recenti',
+		'dec' => 'dic.',
+		'december' => 'dicembre',
+		'feb' => 'febbr.',
+		'february' => 'febbraio',
+		'format_date' => 'j\\ %s Y',
+		'format_date_hour' => 'j\\ %s Y \\o\\r\\e H\\:i',
+		'fri' => 'Fri',
+		'jan' => 'genn.',
+		'january' => 'gennaio',
+		'jul' => 'jul',
+		'july' => 'luglio',
+		'jun' => 'jun',
+		'june' => 'giugno',
+		'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' => 'marzo',
+		'may' => 'maggio',
+		'may_' => 'May',
+		'mon' => 'Mon',
+		'month' => 'mesi',
+		'nov' => 'nov.',
+		'november' => 'novembre',
+		'oct' => 'ott.',
+		'october' => 'ottobre',
+		'sat' => 'Sat',
+		'sep' => 'sett.',
+		'september' => 'settembre',
+		'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',
+		'es' => 'Español',
+		'fr' => 'Français',
+		'it' => 'Italiano',
+		'kr' => '한국어',
+		'nl' => 'Nederlands',
+		'pt-br' => 'Português (Brasil)',
+		'ru' => 'Русский',
+		'tr' => 'Türkçe',
+		'zh-cn' => '简体中文',
+	),
+	'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(
+		'Known' => 'Siti basati su Known',
+		'blogotext' => 'Blogotext',
+		'diaspora' => 'Diaspora*',
+		'email' => 'Email',
+		'facebook' => 'Facebook',
+		'g+' => 'Google+',
+		'gnusocial' => 'GNU social',
+		'jdh' => 'Journal du hacker',
+		'mastodon' => 'Mastodon',
+		'movim' => 'Movim',
+		'print' => 'Stampa',
+		'shaarli' => 'Shaarli',
+		'twitter' => 'Twitter',
+		'wallabag' => 'wallabag v1',
+		'wallabagv2' => 'wallabag v2',
+	),
+	'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',
+	),
+);

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