Browse Source

Merge pull request #1346 from FreshRSS/dev

Merge 1.6.0-dev in master
Alexandre Alapetite 9 years ago
parent
commit
1d3e5bdee0
100 changed files with 1995 additions and 1048 deletions
  1. 52 1
      CHANGELOG.md
  2. 55 25
      README.fr.md
  3. 58 28
      README.md
  4. 2 3
      app/Controllers/categoryController.php
  5. 1 1
      app/Controllers/configureController.php
  6. 165 153
      app/Controllers/feedController.php
  7. 241 138
      app/Controllers/importExportController.php
  8. 38 6
      app/Controllers/indexController.php
  9. 29 7
      app/Controllers/statsController.php
  10. 84 77
      app/Controllers/userController.php
  11. 14 0
      app/Exceptions/AlreadySubscribedException.php
  12. 14 0
      app/Exceptions/FeedNotAddedException.php
  13. 14 0
      app/Exceptions/ZipException.php
  14. 4 0
      app/Exceptions/ZipMissingException.php
  15. 1 1
      app/FreshRSS.php
  16. 14 7
      app/Models/CategoryDAO.php
  17. 1 0
      app/Models/ConfigurationSetter.php
  18. 16 4
      app/Models/Context.php
  19. 43 0
      app/Models/DatabaseDAOPGSQL.php
  20. 103 65
      app/Models/EntryDAO.php
  21. 31 0
      app/Models/EntryDAOPGSQL.php
  22. 14 6
      app/Models/EntryDAOSQLite.php
  23. 26 16
      app/Models/Factory.php
  24. 29 12
      app/Models/Feed.php
  25. 39 18
      app/Models/FeedDAO.php
  26. 2 2
      app/Models/FeedDAOSQLite.php
  27. 33 69
      app/Models/StatsDAO.php
  28. 67 0
      app/Models/StatsDAOPGSQL.php
  29. 4 55
      app/Models/StatsDAOSQLite.php
  30. 40 14
      app/Models/UserDAO.php
  31. 6 1
      app/SQL/install.sql.mysql.php
  32. 63 0
      app/SQL/install.sql.pgsql.php
  33. 20 16
      app/SQL/install.sql.sqlite.php
  34. 2 1
      app/actualize_script.php
  35. 4 4
      app/i18n/cz/admin.php
  36. 3 3
      app/i18n/cz/feedback.php
  37. 3 3
      app/i18n/cz/install.php
  38. 2 2
      app/i18n/cz/sub.php
  39. 4 4
      app/i18n/de/admin.php
  40. 3 3
      app/i18n/de/feedback.php
  41. 4 4
      app/i18n/de/install.php
  42. 1 1
      app/i18n/de/sub.php
  43. 10 10
      app/i18n/en/admin.php
  44. 3 3
      app/i18n/en/feedback.php
  45. 13 13
      app/i18n/en/install.php
  46. 2 2
      app/i18n/en/sub.php
  47. 14 14
      app/i18n/fr/admin.php
  48. 3 3
      app/i18n/fr/feedback.php
  49. 13 13
      app/i18n/fr/install.php
  50. 2 2
      app/i18n/fr/sub.php
  51. 4 4
      app/i18n/it/admin.php
  52. 3 3
      app/i18n/it/feedback.php
  53. 6 6
      app/i18n/it/install.php
  54. 2 2
      app/i18n/it/sub.php
  55. 4 4
      app/i18n/nl/admin.php
  56. 3 3
      app/i18n/nl/feedback.php
  57. 5 5
      app/i18n/nl/install.php
  58. 2 2
      app/i18n/nl/sub.php
  59. 4 4
      app/i18n/ru/admin.php
  60. 3 3
      app/i18n/ru/feedback.php
  61. 5 5
      app/i18n/ru/install.php
  62. 2 2
      app/i18n/ru/sub.php
  63. 4 4
      app/i18n/tr/admin.php
  64. 3 3
      app/i18n/tr/feedback.php
  65. 5 5
      app/i18n/tr/install.php
  66. 2 2
      app/i18n/tr/sub.php
  67. 58 138
      app/install.php
  68. 2 2
      app/layout/aside_feed.phtml
  69. 1 1
      app/layout/aside_subscription.phtml
  70. 3 0
      app/layout/layout.phtml
  71. 5 2
      app/layout/nav_menu.phtml
  72. 1 1
      app/views/auth/index.phtml
  73. 2 2
      app/views/configure/sharing.phtml
  74. 3 3
      app/views/feed/add.phtml
  75. 3 3
      app/views/helpers/feed/update.phtml
  76. 2 2
      app/views/helpers/index/normal/entry_bottom.phtml
  77. 2 2
      app/views/helpers/index/normal/entry_header.phtml
  78. 2 1
      app/views/helpers/pagination.phtml
  79. 1 1
      app/views/importExport/index.phtml
  80. 4 1
      app/views/index/global.phtml
  81. 1 1
      app/views/index/normal.phtml
  82. 6 6
      app/views/stats/index.phtml
  83. 4 4
      app/views/stats/repartition.phtml
  84. 2 2
      app/views/user/manage.phtml
  85. 3 0
      cli/.htaccess
  86. 58 0
      cli/README.md
  87. 49 0
      cli/_cli.php
  88. 23 0
      cli/actualize-user.php
  89. 48 0
      cli/create-user.php
  90. 32 0
      cli/delete-user.php
  91. 101 0
      cli/do-install.php
  92. 24 0
      cli/export-opml-for-user.php
  93. 30 0
      cli/export-zip-for-user.php
  94. 35 0
      cli/import-for-user.php
  95. 0 0
      cli/index.html
  96. 14 0
      cli/list-users.php
  97. 1 1
      constants.php
  98. 3 7
      data/.gitignore
  99. 3 1
      data/PubSubHubbub/feeds/.gitignore
  100. 3 0
      data/config.default.php

+ 52 - 1
CHANGELOG.md

@@ -1,5 +1,56 @@
 # Changelog
 
+## 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)
+* Mics.
+	* 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
@@ -343,7 +394,7 @@
 	* 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
+	* Support OPML, Json (like Google Reader) and ZIP archives
 	* Can export and import articles (specific option for favorites)
 * Refactor "Origine" theme
 	* Some improvements

+ 55 - 25
README.fr.md

@@ -7,6 +7,7 @@ Il se veut léger et facile à prendre en main tout en étant un outil puissant
 
 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).
 
 * Site officiel : http://freshrss.org
 * Démo : http://demo.freshrss.org/
@@ -17,11 +18,9 @@ Il supporte [PubSubHubbub](https://code.google.com/p/pubsubhubbub/) pour des not
 # Téléchargement
 Voir la [liste des versions](../../releases).
 
-## Note sur les branches
-**Ce logiciel est en développement permanent !** Veuillez vous assurer d'utiliser la branche qui vous correspond :
-
+## À propos des branches
 * Utilisez [la branche master](https://github.com/FreshRSS/FreshRSS/tree/master/) si vous visez la stabilité.
-* Pour les développeurs et ceux qui veulent aider à tester les toutes dernières fonctionnalités, [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 !
 
 # Avertissements
 Cette application a été développée pour s’adapter principalement à des besoins personnels, et aucune garantie n'est fournie.
@@ -33,9 +32,9 @@ Nous sommes une communauté amicale.
 	* 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.3.3+ (PHP 5.4+ recommandé, et PHP 5.5+ pour les performances, et PHP 7+ pour d’encore meilleures performances)
-	* Requis : [DOM](http://php.net/dom), [XML](http://php.net/xml), [PDO_MySQL](http://php.net/pdo-mysql) ou [PDO_SQLite](http://php.net/pdo-sqlite), [cURL](http://php.net/curl)
-	* 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+
+	* Requis : [cURL](http://php.net/curl), [DOM](http://php.net/dom), [XML](http://php.net/xml), et [PDO_MySQL](http://php.net/pdo-mysql) ou [PDO_SQLite](http://php.net/pdo-sqlite) ou [PDO_PGSQL](http://php.net/pdo-pgsql)
+	* Recommandés : [JSON](http://php.net/json), [GMP](http://php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](http://php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](http://php.net/mbstring) et/ou [iconv](http://php.net/iconv) (pour conversion d’encodages), [ZIP](http://php.net/zip) (pour import/export), [zlib](http://php.net/zlib) (pour les flux compressés)
+* MySQL 5.5.3+ (recommandé), ou SQLite 3.7.4+, ou PostgreSQL (experimental)
 * Un navigateur Web récent tel Firefox, Internet Explorer 11 / Edge, Chrome, Opera, Safari.
 	* Fonctionne aussi sur mobile
 
@@ -46,48 +45,59 @@ Nous sommes une communauté amicale.
 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 accédés depuis [config.php](./data/config.default.php).
 
 ## Installation automatisée
-[![DP deploy](https://raw.githubusercontent.com/DFabric/DPlatform-ShellCore/gh-pages/img/deploy.png)](https://dfabric.github.io/DPlatform-ShellCore)
+* [![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
-# (optionnel) Si vous voulez un serveur de base de données MySQL
-sudo apt-get install mysql-server mysql-client php5-mysql
-# Composants principaux (pour Ubuntu <= 15.10, Debian <= 8 Jessie)
+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
-# Composants principaux (pour Ubuntu >= 16.04, Debian >= 9 Stretch)
-sudo apt install php libapache2-mod-php php-curl php-gmp php-intl php-mbstring php-sqlite3 php-xml php-zip
-# Redémarrage du serveur Web
+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
-# Mettre les droits d’accès pour le serveur Web
 cd FreshRSS
-sudo chown -R :www-data .
-sudo chmod -R g+w ./data/
+
+# 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/
 # 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.
+# 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
 cd /usr/share/FreshRSS
-sudo git reset --hard
 sudo git pull
-sudo chown -R :www-data .
-sudo chmod -R g+w ./data/
+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 un contrôle d’accès HTTP défini par votre serveur Web
@@ -101,18 +111,28 @@ 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 /votre-chemin/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`.
 
+
 # Sauvegarde
 * Il faut conserver vos fichiers `./data/config.php` ainsi que `./data/*_user.php`
 * Vous pouvez exporter votre liste de flux depuis FreshRSS au format OPML
+	* 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
@@ -138,3 +158,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
+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)

+ 58 - 28
README.md

@@ -7,6 +7,7 @@ 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).
 
 * Official website: http://freshrss.org
 * Demo: http://demo.freshrss.org/
@@ -17,15 +18,13 @@ It supports [PubSubHubbub](https://code.google.com/p/pubsubhubbub/) for instant
 # Releases
 See the [list of releases](../../releases).
 
-## Note on branches
-**This application is under continuous development!** Please use the branch that suits your needs:
-
+## About branches
 * Use [the master branch](https://github.com/FreshRSS/FreshRSS/tree/master/) if you need a stable version.
-* For developers and tech savvy persons willing to help testing the latest features, [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 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 issues on GitHub](https://github.com/FreshRSS/FreshRSS/issues).
+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
@@ -33,9 +32,9 @@ We are a friendly community.
 	* It even works on Raspberry Pi 1 with response time under a second (tested with 150 feeds, 22k articles)
 * A web server: Apache2 (recommended), nginx, lighttpd (not tested on others)
 * PHP 5.3.3+ (PHP 5.4+ recommended, and PHP 5.5+ for performance, and PHP 7 for even higher performance)
-	* Required extensions: [DOM](http://php.net/dom), [XML](http://php.net/xml), [PDO_MySQL](http://php.net/pdo-mysql) or [PDO_SQLite](http://php.net/pdo-sqlite), [cURL](http://php.net/curl)
-	* 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+
+	* Required extensions: [cURL](http://php.net/curl), [DOM](http://php.net/dom), [XML](http://php.net/xml), and [PDO_MySQL](http://php.net/pdo-mysql) or [PDO_SQLite](http://php.net/pdo-sqlite) or [PDO_PGSQL](http://php.net/pdo-pgsql)
+	* Recommended extensions: [JSON](http://php.net/json), [GMP](http://php.net/gmp) (for API access on platforms < 64 bits), [IDN](http://php.net/intl.idn) (for Internationalized Domain Names), [mbstring](http://php.net/mbstring) and/or [iconv](http://php.net/iconv) (for charset conversion), [ZIP](http://php.net/zip) (for import/export), [zlib](http://php.net/zlib) (for compressed feeds)
+* MySQL 5.5.3+ (recommended), or SQLite 3.7.4+, or PostgreSQL (experimental)
 * A recent browser like Firefox, Internet Explorer 11 / Edge, Chrome, Opera, Safari.
 	* Works on mobile
 
@@ -46,23 +45,32 @@ We are a friendly community.
 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. Everything should be working :) If you encounter any problem, feel free to contact me.
+	* or use the [Command-Line Interface](./cli/README.md)
+5. Everything should be working :) If you encounter any problem, feel free [contact us](https://github.com/FreshRSS/FreshRSS/issues).
 6. Advanced configuration settings can be seen in [config.php](./data/config.default.php).
 
 ## Automated install
-[![DP deploy](https://raw.githubusercontent.com/DFabric/DPlatform-ShellCore/gh-pages/img/deploy.png)](https://dfabric.github.io/DPlatform-ShellCore)
+* [![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
-# (Optional) If you want a MySQL database server
-sudo apt-get install mysql-server mysql-client php5-mysql
-# Main components (for Ubuntu <= 15.10, Debian <= 8 Jessie)
+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
-# Main components (for Ubuntu >= 16.04, Debian >= 9 Stretch)
-sudo apt install php libapache2-mod-php php-curl php-gmp php-intl php-mbstring php-sqlite3 php-xml php-zip
+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
 
@@ -70,49 +78,61 @@ sudo service apache2 restart
 cd /usr/share/
 sudo apt-get install git
 sudo git clone https://github.com/FreshRSS/FreshRSS.git
-# Set the rights so that your Web browser can access the files
 cd FreshRSS
-sudo chown -R :www-data .
-sudo chmod -R g+w ./data/
+
+# 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/
 # 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.
+# 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
 cd /usr/share/FreshRSS
-sudo git reset --hard
 sudo git pull
-sudo chown -R :www-data .
-sudo chmod -R g+w ./data/
+sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
 ```
 
-# Access control
+## Access control
 It is needed for the multi-user mode to limit access to FreshRSS. You can:
 * use form authentication (need JavaScript and PHP 5.3.7+, works with some PHP 5.3.3+)
 * 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)…).
-Its 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:
 
 ```
-7 * * * * php /your-path/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
+9 * * * * php /usr/share/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
 ```
 
+### Example on Debian / Ubuntu
+Create `/etc/cron.d/FreshRSS` with:
+
+```
+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.
 	* Be aware that the `./data/` folder contains all personal data, so it is a bad idea to expose it.
 * The `./constants.php` file defines access to application folder. If you want to customize your installation, every thing happens here.
 * If you encounter any problem, logs are accessible from the interface or manually in `./data/log/*.log` files.
 
+
 # Backup
 * You need to keep `./data/config.php`, and `./data/*_user.php` files
 * You can export your feed list in OPML format from FreshRSS
+	* either from the Web interface, or from the [Command-Line Interface](./cli/README.md)
 * To save articles, you can use [phpMyAdmin](http://www.phpmyadmin.net) or MySQL tools:
 
 ```bash
@@ -138,3 +158,13 @@ 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
+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)

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

+ 1 - 1
app/Controllers/configureController.php

@@ -139,7 +139,7 @@ class FreshRSS_configure_Controller extends Minz_ActionController {
 	 */
 	public function sharingAction() {
 		if (Minz_Request::isPost()) {
-			$params = Minz_Request::fetchGET();
+			$params = Minz_Request::fetchPOST();
 			FreshRSS_Context::$user_conf->sharing = $params['share'];
 			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();

+ 165 - 153
app/Controllers/feedController.php

@@ -26,6 +26,62 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 	}
 
+	public static function addFeed($url, $title = '', $cat_id = 0, $new_cat_name = '', $http_auth = '') {
+		@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 +115,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,26 +129,13 @@ 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
@@ -105,103 +147,24 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 				$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);
-			}
-
-			$feed->_httpAuth($http_auth);
-
-			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
-				);
-			}
-
-			if ($feedDAO->searchByUrl($feed->url())) {
-				Minz_Request::bad(
-					_t('feedback.sub.feed.already_subscribed', $feed->name()),
-					$url_redirect
-				);
-			}
-
-			$feed->_category($cat);
-
-			// Call the extension hook
-			$name = $feed->name();
-			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
-			if ($feed === null) {
-				Minz_Request::bad(_t('feedback.sub.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();
-			//$feed->pubSubHubbubPrepare();	//TODO: prepare PubSubHubbub already when adding the feed
-
-			$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.
-			$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 ($entry === null) {
-					// An extension has returned a null value, there is nothing to insert.
-					continue;
-				}
-
-				$values = $entry->toArray();
-				$entryDAO->addEntry($values);
-			}
-			$feedDAO->updateLastUpdate($feed->id());
-			if ($feedDAO->inTransaction()) {
-				$feedDAO->commit();
+				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);
 			}
 
 			// Entries are in DB, we redirect to feed configuration page.
@@ -211,6 +174,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 {
@@ -261,38 +225,23 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 	}
 
-	/**
-	 * This action actualizes entries from one or several feeds.
-	 *
-	 * Parameters are:
-	 *   - id (default: false): Feed ID
-	 *   - url (default: false): Feed URL
-	 *   - force (default: false)
-	 * If id and url are not specified, all the feeds are actualized. But if force is
-	 * false, process stops at 10 feeds to avoid time execution problem.
-	 */
-	public function actualizeAction($simplePiePush = null) {
+	public static function actualizeFeed($feed_id, $feed_url, $force, $simplePiePush = null, $isNewFeed = false) {
 		@set_time_limit(300);
 
 		$feedDAO = FreshRSS_Factory::createFeedDao();
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 
-		Minz_Session::_param('actualize_feeds', false);
-		$id = Minz_Request::param('id');
-		$url = Minz_Request::param('url');
-		$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 || $url) {
-			$feed = $id ? $feedDAO->searchById($id) : $feedDAO->searchByUrl($url);
+		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.
@@ -309,13 +258,29 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			$url = $feed->url();	//For detection of HTTP 301
 
 			$pubSubHubbubEnabled = $pubsubhubbubEnabledGeneral && $feed->pubSubHubbubEnabled();
-			if ((!$simplePiePush) && (!$id) && $pubSubHubbubEnabled && ($feed->lastUpdate() > $pshbMinAge)) {
+			if ((!$simplePiePush) && (!$feed_id) && $pubSubHubbubEnabled && ($feed->lastUpdate() > $pshbMinAge)) {
 				//$text = 'Skip pull of feed using PubSubHubbub: ' . $url;
 				//Minz_Log::debug($text);
 				//file_put_contents(USERS_PATH . '/_/log_pshb.txt', date('c') . "\t" . $text . "\n", FILE_APPEND);
 				continue;	//When PubSubHubbub is used, do not pull refresh so often
 			}
 
+			$mtime = 0;
+			$ttl = $feed->ttl();
+			if ($ttl == -1) {
+				continue;	//Feed refresh is disabled
+			}
+			if ((!$simplePiePush) && (!$feed_id) &&
+				($feed->lastUpdate() + 10 >= time() - ($ttl == -2 ? FreshRSS_Context::$user_conf->ttl_default : $ttl))) {
+				//Too early to refresh from source, but check whether the feed was updated by another user
+				$mtime = $feed->cacheModifiedTime();
+				if ($feed->lastUpdate() + 10 >= $mtime) {
+					continue;	//Nothing newer from other users
+				}
+				//Minz_Log::debug($feed->url() . ' was updated at ' . date('c', $mtime) . ' by another user');
+				//Will take advantage of the newer cache
+			}
+
 			if (!$feed->lock()) {
 				Minz_Log::notice('Feed already being actualized: ' . $feed->url());
 				continue;
@@ -325,7 +290,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 				if ($simplePiePush) {
 					$feed->loadEntries($simplePiePush);	//Used by PubSubHubbub
 				} else {
-					$feed->load(false);
+					$feed->load(false, $isNewFeed);
 				}
 			} catch (FreshRSS_Feed_Exception $e) {
 				Minz_Log::warning($e->getMessage());
@@ -335,7 +300,9 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			}
 
 			$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;
@@ -346,7 +313,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			if (count($entries) > 0) {
 				$newGuids = array();
 				foreach ($entries as $entry) {
-					$newGuids[] = $entry->guid();
+					$newGuids[] = safe_ascii($entry->guid());
 				}
 				// For this feed, check existing GUIDs already in database.
 				$existingHashForGuids = $entryDAO->listHashForFeedGuids($feed->id(), $newGuids);
@@ -375,7 +342,9 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 						// This entry should not be added considering configuration and date.
 						$oldGuids[] = $entry->guid();
 					} else {
-						if ($entry_date < $date_min) {
+						if ($isNewFeed) {
+							$id = min(time(), $entry_date) . uSecString();
+						} elseif ($entry_date < $date_min) {
 							$id = min(time(), $entry_date) . uSecString();
 							$entry->_isRead(true);	//Old article that was not in database. Probably an error, so mark as read
 						} else {
@@ -404,7 +373,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 						$entryDAO->addEntry($entry->toArray());
 					}
 				}
-				$entryDAO->updateLastSeen($feed->id(), $oldGuids);
+				$entryDAO->updateLastSeen($feed->id(), $oldGuids, $mtime);
 			}
 
 			if ($feed_history >= 0 && rand(0, 30) === 1) {
@@ -423,7 +392,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 				}
 			}
 
-			$feedDAO->updateLastUpdate($feed->id(), 0, $entryDAO->inTransaction());
+			$feedDAO->updateLastUpdate($feed->id(), false, $entryDAO->inTransaction(), $mtime);
 			if ($entryDAO->inTransaction()) {
 				$entryDAO->commit();
 			}
@@ -464,6 +433,26 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 				break;
 			}
 		}
+		return array($updated_feeds, reset($feeds));
+	}
+
+	/**
+	 * This action actualizes entries from one or several feeds.
+	 *
+	 * Parameters are:
+	 *   - id (default: false): Feed ID
+	 *   - url (default: false): Feed URL
+	 *   - force (default: false)
+	 * If id and url are not specified, all the feeds are actualized. But if force is
+	 * false, process stops at 10 feeds to avoid time execution problem.
+	 */
+	public function actualizeAction() {
+		Minz_Session::_param('actualize_feeds', false);
+		$id = Minz_Request::param('id');
+		$url = Minz_Request::param('url');
+		$force = Minz_Request::param('force');
+
+		list($updated_feeds, $feed) = self::actualizeFeed($id, $url, $force);
 
 		if (Minz_Request::param('ajax')) {
 			// Most of the time, ajax request is for only one feed. But since
@@ -479,7 +468,6 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		} else {
 			// 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())
 				));
@@ -492,6 +480,36 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		return $updated_feeds;
 	}
 
+	public static function renameFeed($feed_id, $feed_name) {
+		if ($feed_id <= 0 || $feed_name == '') {
+			return false;
+		}
+		$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;
+		}
+
+		$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));
+	}
+
 	/**
 	 * This action changes the category of a feed.
 	 *
@@ -512,20 +530,7 @@ 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
 		} else {
 			Minz_Log::warning('Cannot move feed `' . $feed_id . '` ' .
@@ -534,6 +539,21 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		}
 	}
 
+	public static function deleteFeed($feed_id) {
+		$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.
 	 *
@@ -552,21 +572,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);

+ 241 - 138
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::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'));
-		}
+	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::warning('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::warning('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,29 +66,88 @@ 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
@@ -126,7 +163,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 +183,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 +208,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 +258,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 +300,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 +309,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 +335,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 +370,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,29 +399,36 @@ 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);
+		unset($newGuids);
+
 		// Then, articles are imported.
 		$this->entryDAO->beginTransaction();
 		foreach ($article_object['items'] as $item) {
@@ -376,7 +445,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			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,13 +458,17 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$entry->_tags($tags);
 
 			$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);
+			if (isset($existingHashForGuids[$entry->guid()])) {
+				$id = $this->entryDAO->updateEntry($values);
+			} else {
+				$id = $this->entryDAO->addEntry($values);
+			}
 
 			if (!$error && ($id === false)) {
 				$error = true;
@@ -403,7 +476,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 		}
 		$this->entryDAO->commit();
 
-		return $error;
+		return !$error;
 	}
 
 	/**
@@ -415,8 +488,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];
@@ -426,13 +497,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);
@@ -443,67 +514,98 @@ 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);
+
+		if ($export_feeds === true) {
+			//All feeds
+			$export_feeds = $this->feedDAO->listFeedsIds();
+		}
+		if (!is_array($export_feeds)) {
+			$export_feeds = array();
+		}
 
-		$export_opml = Minz_Request::param('export_opml', false);
-		$export_starred = Minz_Request::param('export_starred', false);
-		$export_feeds = Minz_Request::param('export_feeds', array());
+		$day = date('Y-m-d');
 
 		$export_files = array();
 		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);
 		}
@@ -532,7 +634,7 @@ 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') {
@@ -542,12 +644,12 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 			$this->view->entries = $this->entryDAO->listWhere(
 				'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(
 				'f', $feed->id(), FreshRSS_Entry::STATE_ALL, 'ASC',
-				FreshRSS_Context::$user_conf->posts_per_page
+				$maxFeedEntries
 			);
 			$this->view->feed = $feed;
 		}
@@ -561,7 +663,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();
 		}
@@ -579,7 +681,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);
 	}
@@ -592,16 +695,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');

+ 38 - 6
app/Controllers/indexController.php

@@ -34,7 +34,9 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 
 		$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) {
@@ -154,8 +156,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();
@@ -180,10 +188,14 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 		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));
 	}
 
 	/**
@@ -201,11 +213,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;
 	}
 
 	/**

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

+ 84 - 77
app/Controllers/userController.php

@@ -24,6 +24,16 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		}
 	}
 
+	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.
 	 */
@@ -41,12 +51,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 			if ($passwordPlain != '') {
 				Minz_Request::_param('newPasswordPlain');	//Discard plain-text password ASAP
 				$_POST['newPasswordPlain'] = '';
-				if (!function_exists('password_hash')) {
-					include_once(LIB_PATH . '/password_compat.php');
-				}
-				$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-				$passwordPlain = '';
-				$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
+				$passwordHash = self::hashPassword($passwordPlain);
 				$ok &= ($passwordHash != '');
 				FreshRSS_Context::$user_conf->passwordHash = $passwordHash;
 			}
@@ -54,12 +59,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 
 			$passwordPlain = Minz_Request::param('apiPasswordPlain', '', true);
 			if ($passwordPlain != '') {
-				if (!function_exists('password_hash')) {
-					include_once(LIB_PATH . '/password_compat.php');
-				}
-				$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-				$passwordPlain = '';
-				$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
+				$passwordHash = self::hashPassword($passwordPlain);
 				$ok &= ($passwordHash != '');
 				FreshRSS_Context::$user_conf->apiPasswordHash = $passwordHash;
 			}
@@ -99,6 +99,50 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		$this->view->size_user = $entryDAO->size();
 	}
 
+	public static function createUser($new_user_name, $passwordPlain, $apiPasswordPlain, $userConfig = array(), $insertDefaultFeeds = true) {
+		if (!is_array($userConfig)) {
+			$userConfig = array();
+		}
+
+		$ok = ($new_user_name != '') && ctype_alnum($new_user_name);
+
+		if ($ok) {
+			$languages = Minz_Translate::availableLanguages();
+			if (empty($userConfig['language']) || !in_array($userConfig['language'], $languages)) {
+				$userConfig['language'] = 'en';
+			}
+
+			$ok &= !in_array(strtoupper($new_user_name), array_map('strtoupper', listUsers()));	//Not an existing user, case-insensitive
+
+			$configPath = join_path(DATA_PATH, 'users', $new_user_name, 'config.php');
+			$ok &= !file_exists($configPath);
+		}
+		if ($ok) {
+			$passwordHash = '';
+			if ($passwordPlain != '') {
+				$passwordHash = self::hashPassword($passwordPlain);
+				$ok &= ($passwordHash != '');
+			}
+
+			$apiPasswordHash = '';
+			if ($apiPasswordPlain != '') {
+				$apiPasswordHash = self::hashPassword($apiPasswordPlain);
+				$ok &= ($apiPasswordHash != '');
+			}
+		}
+		if ($ok) {
+			mkdir(join_path(DATA_PATH, 'users', $new_user_name));
+			$userConfig['passwordHash'] = $passwordHash;
+			$userConfig['apiPasswordHash'] = $apiPasswordHash;
+			$ok &= (file_put_contents($configPath, "<?php\n return " . var_export($userConfig, true) . ';') !== false);
+		}
+		if ($ok) {
+			$userDAO = new FreshRSS_UserDAO();
+			$ok &= $userDAO->createUser($new_user_name, $userConfig['language'], $insertDefaultFeeds);
+		}
+		return $ok;
+	}
+
 	/**
 	 * This action creates a new user.
 	 *
@@ -116,57 +160,13 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 				FreshRSS_Auth::hasAccess('admin') ||
 				!max_registrations_reached()
 		)) {
-			$db = FreshRSS_Context::$system_conf->db;
-			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
-
-			$new_user_language = Minz_Request::param('new_user_language', FreshRSS_Context::$user_conf->language);
-			$languages = Minz_Translate::availableLanguages();
-			if (!isset($languages[$new_user_language])) {
-				$new_user_language = FreshRSS_Context::$user_conf->language;
-			}
-
 			$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
+			$passwordPlain = Minz_Request::param('new_user_passwordPlain', '', true);
+			$new_user_language = Minz_Request::param('new_user_language', FreshRSS_Context::$user_conf->language);
 
-				$configPath = join_path(DATA_PATH, 'users', $new_user_name, 'config.php');
-				$ok &= !file_exists($configPath);
-			}
-			if ($ok) {
-				$passwordPlain = Minz_Request::param('new_user_passwordPlain', '', true);
-				$passwordHash = '';
-				if ($passwordPlain != '') {
-					Minz_Request::_param('new_user_passwordPlain');	//Discard plain-text password ASAP
-					$_POST['new_user_passwordPlain'] = '';
-					if (!function_exists('password_hash')) {
-						include_once(LIB_PATH . '/password_compat.php');
-					}
-					$passwordHash = password_hash($passwordPlain, PASSWORD_BCRYPT, array('cost' => self::BCRYPT_COST));
-					$passwordPlain = '';
-					$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
-					$ok &= ($passwordHash != '');
-				}
-				if (empty($passwordHash)) {
-					$passwordHash = '';
-				}
-			}
-			if ($ok) {
-				mkdir(join_path(DATA_PATH, 'users', $new_user_name));
-				$config_array = array(
-					'language' => $new_user_language,
-					'passwordHash' => $passwordHash,
-				);
-				$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);
-			}
+			$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(
@@ -183,6 +183,27 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 		Minz_Request::forward($redirect_url, true);
 	}
 
+	public static function deleteUser($username) {
+		$db = FreshRSS_Context::$system_conf->db;
+		require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
+
+		$ok = ctype_alnum($username);
+		if ($ok) {
+			$default_user = FreshRSS_Context::$system_conf->default_user;
+			$ok &= (strcasecmp($username, $default_user) !== 0);	//It is forbidden to delete the default user
+		}
+		$user_data = join_path(DATA_PATH, 'users', $username);
+		if ($ok) {
+			$ok &= is_dir($user_data);
+		}
+		if ($ok) {
+			$userDAO = new FreshRSS_UserDAO();
+			$ok &= $userDAO->deleteUser($username);
+			$ok &= recursive_unlink($user_data);
+		}
+		return $ok;
+	}
+
 	/**
 	 * This action delete an existing user.
 	 *
@@ -204,16 +225,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 				FreshRSS_Auth::hasAccess('admin') ||
 				$self_deletion
 		)) {
-			$db = FreshRSS_Context::$system_conf->db;
-			require_once(APP_PATH . '/SQL/install.sql.' . $db['type'] . '.php');
-
-			$ok = ctype_alnum($username);
-			$user_data = join_path(DATA_PATH, 'users', $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
-			}
+			$ok = true;
 			if ($ok && $self_deletion) {
 				// We check the password if it's a self-destruction
 				$nonce = Minz_Session::param('nonce');
@@ -225,12 +237,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 				);
 			}
 			if ($ok) {
-				$ok &= is_dir($user_data);
-			}
-			if ($ok) {
-				$userDAO = new FreshRSS_UserDAO();
-				$ok &= $userDAO->deleteUser($username);
-				$ok &= recursive_unlink($user_data);
+				$ok &= self::deleteUser($username);
 			}
 			if ($ok && $self_deletion) {
 				FreshRSS_Auth::removeAccess();

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

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

+ 1 - 1
app/FreshRSS.php

@@ -49,7 +49,7 @@ class FreshRSS extends Minz_FrontController {
 		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);
 		}

+ 14 - 7
app/Models/CategoryDAO.php

@@ -1,6 +1,9 @@
 <?php
 
 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,7 +13,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 		);
 
 		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]);
@@ -50,6 +53,9 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	}
 
 	public function deleteCategory($id) {
+		if ($id <= self::defaultCategoryId) {
+			return false;
+		}
 		$sql = 'DELETE FROM `' . $this->prefix . 'category` WHERE id=?';
 		$stm = $this->bd->prepare($sql);
 
@@ -100,7 +106,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	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, c_id '
@@ -117,7 +123,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo implements FreshRSS_Searchable
 	}
 
 	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 implements FreshRSS_Searchable
 		}
 	}
 	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 implements FreshRSS_Searchable
 
 		$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 implements FreshRSS_Searchable
 		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;

+ 1 - 0
app/Models/ConfigurationSetter.php

@@ -282,6 +282,7 @@ class FreshRSS_ConfigurationSetter {
 
 		switch ($value['type']) {
 		case 'mysql':
+		case 'pgsql':
 			if (empty($value['host']) ||
 					empty($value['user']) ||
 					empty($value['base']) ||

+ 16 - 4
app/Models/Context.php

@@ -35,6 +35,9 @@ class FreshRSS_Context {
 	public static $first_id = '';
 	public static $next_id = '';
 	public static $id_max = '';
+	public static $sinceHours = 0;
+
+	public static $isCli = false;
 
 	/**
 	 * Initialize the context.
@@ -45,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();
 	}
 
 	/**
@@ -139,15 +139,22 @@ 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.
@@ -198,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;

+ 43 - 0
app/Models/DatabaseDAOPGSQL.php

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

+ 103 - 65
app/Models/EntryDAO.php

@@ -3,13 +3,21 @@
 class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 	public function isCompressed() {
-		return parent::$sharedDbType !== 'sqlite';
+		return parent::$sharedDbType === 'mysql';
 	}
 
 	public function hasNativeHex() {
 		return parent::$sharedDbType !== 'sqlite';
 	}
 
+	public function sqlHexDecode($x) {
+		return 'unhex(' . $x . ')';
+	}
+
+	public function sqlHexEncode($x) {
+		return 'hex(' . $x . ')';
+	}
+
 	protected function addColumn($name) {
 		Minz_Log::warning('FreshRSS_EntryDAO::addColumn: ' . $name);
 		$hasTransaction = false;
@@ -20,7 +28,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 					$this->bd->beginTransaction();
 					$hasTransaction = true;
 				}
-				$stm = $this->bd->prepare('ALTER TABLE `' . $this->prefix . 'entry` ADD COLUMN lastSeen INT(11) DEFAULT 0');
+				$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()) {
@@ -105,32 +113,45 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		if ($this->addEntryPrepared === null) {
 			$sql = 'INSERT INTO `' . $this->prefix . 'entry` (id, guid, title, author, '
 			     . ($this->isCompressed() ? 'content_bin' : 'content')
-			     . ', link, date, lastSeen, hash, is_read, is_favorite, id_feed, tags) '
-			     . 'VALUES(?, ?, ?, ?, '
-			     . ($this->isCompressed() ? 'COMPRESS(?)' : '?')
-			     . ', ?, ?, ?, '
-			     . ($this->hasNativeHex() ? 'X?' : '?')
-			     . ', ?, ?, ?, ?)';
+			     . ', link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags) '
+			     . 'VALUES(:id, :guid, :title, :author, '
+			     . ($this->isCompressed() ? 'COMPRESS(:content)' : ':content')
+			     . ', :link, :date, :last_seen, '
+			     . $this->sqlHexDecode(':hash')
+			     . ', :is_read, :is_favorite, :id_feed, :tags)';
 			$this->addEntryPrepared = $this->bd->prepare($sql);
 		}
+		$this->addEntryPrepared->bindParam(':id', $valuesTmp['id']);
+		$valuesTmp['guid'] = substr($valuesTmp['guid'], 0, 760);
+		$valuesTmp['guid'] = safe_ascii($valuesTmp['guid']);
+		$this->addEntryPrepared->bindParam(':guid', $valuesTmp['guid']);
+		$valuesTmp['title'] = substr($valuesTmp['title'], 0, 255);
+		$this->addEntryPrepared->bindParam(':title', $valuesTmp['title']);
+		$valuesTmp['author'] = substr($valuesTmp['author'], 0, 255);
+		$this->addEntryPrepared->bindParam(':author', $valuesTmp['author']);
+		$this->addEntryPrepared->bindParam(':content', $valuesTmp['content']);
+		$valuesTmp['link'] = substr($valuesTmp['link'], 0, 1023);
+		$valuesTmp['link'] = safe_ascii($valuesTmp['link']);
+		$this->addEntryPrepared->bindParam(':link', $valuesTmp['link']);
+		$this->addEntryPrepared->bindParam(':date', $valuesTmp['date'], PDO::PARAM_INT);
+		$valuesTmp['lastSeen'] = time();
+		$this->addEntryPrepared->bindParam(':last_seen', $valuesTmp['lastSeen'], PDO::PARAM_INT);
+		$valuesTmp['is_read'] = $valuesTmp['is_read'] ? 1 : 0;
+		$this->addEntryPrepared->bindParam(':is_read', $valuesTmp['is_read'], PDO::PARAM_INT);
+		$valuesTmp['is_favorite'] = $valuesTmp['is_favorite'] ? 1 : 0;
+		$this->addEntryPrepared->bindParam(':is_favorite', $valuesTmp['is_favorite'], PDO::PARAM_INT);
+		$this->addEntryPrepared->bindParam(':id_feed', $valuesTmp['id_feed'], PDO::PARAM_INT);
+		$valuesTmp['tags'] = substr($valuesTmp['tags'], 0, 1023);
+		$this->addEntryPrepared->bindParam(':tags', $valuesTmp['tags']);
+
+		if ($this->hasNativeHex()) {
+			$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hash']);
+		} else {
+			$valuesTmp['hashBin'] = pack('H*', $valuesTmp['hash']);	//hex2bin() is PHP5.4+
+			$this->addEntryPrepared->bindParam(':hash', $valuesTmp['hashBin']);
+		}
 
-		$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'],
-			time(),
-			$this->hasNativeHex() ? $valuesTmp['hash'] : pack('H*', $valuesTmp['hash']),	// X'09AF' hexadecimal literals do not work with SQLite/PDO	//hex2bin() is PHP5.4+
-			$valuesTmp['is_read'] ? 1 : 0,
-			$valuesTmp['is_favorite'] ? 1 : 0,
-			$valuesTmp['id_feed'],
-			substr($valuesTmp['tags'], 0, 1023),
-		);
-
-		if ($this->addEntryPrepared && $this->addEntryPrepared->execute($values)) {
+		if ($this->addEntryPrepared && $this->addEntryPrepared->execute()) {
 			return $this->bd->lastInsertId();
 		} else {
 			$info = $this->addEntryPrepared == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $this->addEntryPrepared->errorInfo();
@@ -153,35 +174,44 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 		if ($this->updateEntryPrepared === null) {
 			$sql = 'UPDATE `' . $this->prefix . 'entry` '
-			     . 'SET title=?, author=?, '
-			     . ($this->isCompressed() ? 'content_bin=COMPRESS(?)' : 'content=?')
-			     . ', link=?, date=?, lastSeen=?, hash='
-			     . ($this->hasNativeHex() ? 'X?' : '?')
-			     . ', ' . ($valuesTmp['is_read'] === null ? '' : 'is_read=?, ')
-			     . 'tags=? '
-			     . 'WHERE id_feed=? AND guid=?';
+			     . '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);
 		}
 
-		$values = array(
-			substr($valuesTmp['title'], 0, 255),
-			substr($valuesTmp['author'], 0, 255),
-			$valuesTmp['content'],
-			substr($valuesTmp['link'], 0, 1023),
-			$valuesTmp['date'],
-			time(),
-			$this->hasNativeHex() ? $valuesTmp['hash'] : pack('H*', $valuesTmp['hash']),
-		);
+		$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) {
-			$values[] = $valuesTmp['is_read'] ? 1 : 0;
+			$this->updateEntryPrepared->bindParam(':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']);
 		}
-		$values = array_merge($values, array(
-			substr($valuesTmp['tags'], 0, 1023),
-			$valuesTmp['id_feed'],
-			substr($valuesTmp['guid'], 0, 760),
-		));
 
-		if ($this->updateEntryPrepared && $this->updateEntryPrepared->execute($values)) {
+		if ($this->updateEntryPrepared && $this->updateEntryPrepared->execute()) {
 			return $this->bd->lastInsertId();
 		} else {
 			$info = $this->updateEntryPrepared == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $this->updateEntryPrepared->errorInfo();
@@ -246,15 +276,19 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		 .	'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';
+		 . '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);
@@ -309,7 +343,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		} 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);
@@ -430,17 +464,17 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		}
 		$this->bd->beginTransaction();
 
-		$sql = 'UPDATE `' . $this->prefix . 'entry` e '
-			 . 'SET e.is_read=1 '
-			 . 'WHERE e.id_feed=? AND e.is_read=0 AND e.id <= ?';
+		$sql = 'UPDATE `' . $this->prefix . 'entry` '
+			 . 'SET is_read=1 '
+			 . 'WHERE id_feed=? AND is_read=0 AND id <= ?';
 		$values = array($id_feed, $idMax);
 
-		list($searchValues, $search) = $this->sqlListEntriesWhere('e.', $filter, $state);
+		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;
 		}
@@ -448,13 +482,13 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 
 		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_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;
 			}
@@ -658,7 +692,8 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		if (count($guids) < 1) {
 			return array();
 		}
-		$sql = 'SELECT guid, hex(hash) AS hexHash FROM `' . $this->prefix . 'entry` WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1). '?)';
+		$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_feed);
 		$values = array_merge($values, $guids);
@@ -666,7 +701,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 			$result = array();
 			$rows = $stm->fetchAll(PDO::FETCH_ASSOC);
 			foreach ($rows as $row) {
-				$result[$row['guid']] = $row['hexHash'];
+				$result[$row['guid']] = $row['hex_hash'];
 			}
 			return $result;
 		} else {
@@ -680,13 +715,16 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		}
 	}
 
-	public function updateLastSeen($id_feed, $guids) {
+	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). '?)';
+		$sql = 'UPDATE `' . $this->prefix . 'entry` SET `lastSeen`=? WHERE id_feed=? AND guid IN (' . str_repeat('?,', count($guids) - 1). '?)';
 		$stm = $this->bd->prepare($sql);
-		$values = array(time(), $id_feed);
+		if ($mtime <= 0) {
+			$mtime = time();
+		}
+		$values = array($mtime, $id_feed);
 		$values = array_merge($values, $guids);
 		if ($stm && $stm->execute($values)) {
 			return $stm->rowCount();

+ 31 - 0
app/Models/EntryDAOPGSQL.php

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

+ 14 - 6
app/Models/EntryDAOSQLite.php

@@ -2,6 +2,10 @@
 
 class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 
+	public function sqlHexDecode($x) {
+		return $x;
+	}
+
 	protected function autoUpdateDb($errorInfo) {
 		if (empty($errorInfo[0]) || $errorInfo[0] == '42S22') {	//ER_BAD_FIELD_ERROR
 			//autoAddColumn
@@ -24,17 +28,21 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 
 	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);
@@ -82,7 +90,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);

+ 26 - 16
app/Models/Factory.php

@@ -4,37 +4,47 @@ 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);
+		switch ($conf->db['type']) {
+			case 'sqlite':
+				return new FreshRSS_FeedDAOSQLite($username);
+			default:
+				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);
 		}
 	}
 

+ 29 - 12
app/Models/Feed.php

@@ -131,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) {
@@ -216,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(
@@ -268,7 +281,7 @@ class FreshRSS_Feed extends Minz_Model {
 					$this->_url($clean_url);
 				}
 
-				if (($mtime === true) || ($mtime > $this->lastUpdate)) {
+				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 {
@@ -309,11 +322,11 @@ class FreshRSS_Feed extends Minz_Model {
 					$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>';
 					} else {
 						unset($elinks[$elink]);
 					}
@@ -340,6 +353,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)) {
@@ -460,7 +477,7 @@ class FreshRSS_Feed extends Minz_Model {
 				CURLOPT_URL => $this->hubUrl,
 				CURLOPT_FOLLOWLOCATION => true,
 				CURLOPT_RETURNTRANSFER => true,
-				CURLOPT_USERAGENT => _t('gen.freshrss') . '/' . FRESHRSS_VERSION . ' (' . PHP_OS . '; ' . FRESHRSS_WEBSITE . ')',
+				CURLOPT_USERAGENT => 'FreshRSS/' . FRESHRSS_VERSION . ' (' . PHP_OS . '; ' . FRESHRSS_WEBSITE . ')',
 				CURLOPT_POSTFIELDS => 'hub.verify=sync'
 					. '&hub.mode=' . ($state ? 'subscribe' : 'unsubscribe')
 					. '&hub.topic=' . urlencode($this->selfUrl)

+ 39 - 18
app/Models/FeedDAO.php

@@ -2,9 +2,12 @@
 
 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 +19,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		);
 
 		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,6 +58,13 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	}
 
 	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 . '=?, ';
@@ -82,22 +92,26 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		}
 	}
 
-	public function updateLastUpdate($id, $inError = 0, $updateCache = true) {
+	public function updateLastUpdate($id, $inError = false, $updateCache = true, $mtime = 0) {
 		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=? '
+			     . '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=? '
+			     . 'SET `lastUpdate`=?, error=? '
 			     . 'WHERE id=?';
 		}
 
+		if ($mtime <= 0) {
+			$mtime = time();
+		}
+
 		$values = array(
-			time(),
-			$inError,
+			$mtime,
+			$inError ? 1 : 0,
 			$id,
 		);
 
@@ -198,6 +212,13 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		}
 	}
 
+	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 +243,14 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		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
@@ -282,7 +303,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		     .	'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';
+		     . 'SET f.`cache_nbEntries`=x.nbEntries, f.`cache_nbUnreads`=x.nbUnreads';
 		$stm = $this->bd->prepare($sql);
 
 		if ($stm && $stm->execute()) {
@@ -308,7 +329,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		$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))) {
@@ -326,7 +347,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		$sql = 'DELETE FROM `' . $this->prefix . 'entry` '
 		     . 'WHERE id_feed=:id_feed AND id<=:id_max '
 		     . 'AND is_favorite=0 '	//Do not remove favourites
-		     . 'AND lastSeen < (SELECT maxLastSeen FROM (SELECT (MAX(e3.lastSeen)-99) AS maxLastSeen FROM `' . $this->prefix . 'entry` e3 WHERE e3.id_feed=:id_feed) recent) '	//Do not remove the most newly seen articles, plus a few seconds of tolerance
+		     . 'AND `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);
 

+ 2 - 2
app/Models/FeedDAOSQLite.php

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

+ 33 - 69
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;
@@ -61,14 +65,16 @@ SQL;
 	 */
 	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
@@ -173,7 +158,7 @@ SQL;
 			$repartition[(int) $value['period']] = (int) $value['count'];
 		}
 
-		return $this->convertToSerie($repartition);
+		return $repartition;
 	}
 
 	/**
@@ -222,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);
@@ -266,8 +251,8 @@ SQL;
 		$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
@@ -276,7 +261,7 @@ SQL;
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 
-		return $this->convertToPieSerie($res);
+		return $res;
 	}
 
 	/**
@@ -289,9 +274,9 @@ SQL;
 		$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
@@ -301,7 +286,7 @@ SQL;
 		$stm->execute();
 		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
 
-		return $this->convertToPieSerie($res);
+		return $res;
 	}
 
 	/**
@@ -315,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
@@ -340,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
@@ -351,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 $serie;
-	}
-
-	protected function convertToPieSerie($data) {
-		$serie = array();
-
-		foreach ($data as $value) {
-			$value['data'] = array(array(0, (int) $value['data']));
-			$serie[] = $value;
-		}
-
-		return $serie;
-	}
-
 	/**
 	 * Gets days ready for graphs
 	 *

+ 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 object.
-	 *
-	 * @return JSON object
-	 */
-	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;
 	}
 
 }

+ 40 - 14
app/Models/UserDAO.php

@@ -1,34 +1,60 @@
 <?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, $bd_prefix_user, _t('gen.short.default_category'));
+				$stm = $userPDO->bd->prepare($sql);
+				$ok = $stm && $stm->execute();
+			} else {	//E.g. SQLite
+				global $SQL_CREATE_TABLES;
+				if (is_array($SQL_CREATE_TABLES)) {
+					$ok = true;
+					foreach ($SQL_CREATE_TABLES as $instruction) {
+						$sql = sprintf($instruction, $bd_prefix_user, _t('gen.short.default_category'));
+						$stm = $userPDO->bd->prepare($sql);
+						$ok &= ($stm && $stm->execute());
+					}
+				}
+			}
+			if ($insertDefaultFeeds) {
+				if (defined('SQL_INSERT_FEEDS')) {	//E.g. MySQL
+					$sql = sprintf(SQL_INSERT_FEEDS, $bd_prefix_user);
 					$stm = $userPDO->bd->prepare($sql);
-					$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;
 		}
 	}

+ 6 - 1
app/SQL/install.sql.mysql.php

@@ -1,4 +1,6 @@
 <?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
@@ -57,11 +59,14 @@ CREATE TABLE IF NOT EXISTS `%1$sentry` (
 ENGINE = INNODB;
 
 INSERT IGNORE INTO `%1$scategory` (id, name) VALUES(1, "%2$s");
+');
+
+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 TABLES %1$sentry, %1$sfeed, %1$scategory');
+define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS `%1$sentry`, `%1$sfeed`, `%1$scategory`');
 
 define('SQL_UPDATE_UTF8MB4', '
 ALTER DATABASE `%2$s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

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

@@ -0,0 +1,63 @@
+<?php
+define('SQL_CREATE_DB', 'CREATE DATABASE %1$s ENCODING \'UTF8\';');
+
+global $SQL_CREATE_TABLES;
+$SQL_CREATE_TABLES = array(
+'CREATE TABLE IF NOT EXISTS "%1$scategory" (
+	"id" SERIAL PRIMARY KEY,
+	"name" VARCHAR(255) UNIQUE NOT NULL
+);',
+
+'CREATE TABLE IF NOT EXISTS "%1$sfeed" (
+	"id" SERIAL PRIMARY KEY,
+	"url" varchar(511) UNIQUE NOT NULL,
+	"category" SMALLINT DEFAULT 0,
+	"name" VARCHAR(255) NOT NULL,
+	"website" VARCHAR(255),
+	"description" text,
+	"lastUpdate" INT DEFAULT 0,
+	"priority" SMALLINT NOT NULL DEFAULT 10,
+	"pathEntries" VARCHAR(511) DEFAULT NULL,
+	"httpAuth" VARCHAR(511) DEFAULT NULL,
+	"error" smallint DEFAULT 0,
+	"keep_history" INT NOT NULL DEFAULT -2,
+	"ttl" INT NOT NULL DEFAULT -2,
+	"cache_nbEntries" INT DEFAULT 0,
+	"cache_nbUnreads" INT DEFAULT 0,
+	FOREIGN KEY ("category") REFERENCES "%1$scategory" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);',
+'CREATE INDEX %1$sname_index ON "%1$sfeed" ("name");',
+'CREATE INDEX %1$spriority_index ON "%1$sfeed" ("priority");',
+'CREATE INDEX %1$skeep_history_index ON "%1$sfeed" ("keep_history");',
+
+'CREATE TABLE IF NOT EXISTS "%1$sentry" (
+	"id" BIGINT NOT NULL PRIMARY KEY,
+	"guid" VARCHAR(760) UNIQUE NOT NULL,
+	"title" VARCHAR(255) NOT NULL,
+	"author" VARCHAR(255),
+	"content" TEXT,
+	"link" VARCHAR(1023) NOT NULL,
+	"date" INT,
+	"lastSeen" INT DEFAULT 0,
+	"hash" BYTEA,
+	"is_read" SMALLINT NOT NULL DEFAULT 0,
+	"is_favorite" SMALLINT NOT NULL DEFAULT 0,
+	"id_feed" SMALLINT,
+	"tags" VARCHAR(1023),
+	FOREIGN KEY ("id_feed") REFERENCES "%1$sfeed" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
+	UNIQUE ("id_feed","guid")
+);',
+'CREATE INDEX %1$sis_favorite_index ON "%1$sentry" ("is_favorite");',
+'CREATE INDEX %1$sis_read_index ON "%1$sentry" ("is_read");',
+'CREATE INDEX %1$sentry_lastSeen_index ON "%1$sentry" ("lastSeen");',
+
+'INSERT INTO "%1$scategory" (name) SELECT \'%2$s\' WHERE NOT EXISTS (SELECT id FROM "%1$scategory" WHERE id = 1);',
+);
+
+global $SQL_INSERT_FEEDS;
+$SQL_INSERT_FEEDS = array(
+'INSERT INTO "%1$sfeed" (url, category, name, website, description, ttl) SELECT \'http://freshrss.org/feeds/all.atom.xml\', 1, \'FreshRSS.org\', \'http://freshrss.org/\', \'FreshRSS, a free, self-hostable aggregator…\', 86400 WHERE NOT EXISTS (SELECT id FROM "%1$sfeed" WHERE url = \'http://freshrss.org/feeds/all.atom.xml\');',
+'INSERT INTO "%1$sfeed" (url, category, name, website, description, ttl) SELECT \'https://github.com/FreshRSS/FreshRSS/releases.atom\', 1, \'FreshRSS @ GitHub\', \'https://github.com/FreshRSS/FreshRSS/\', \'FreshRSS releases @ GitHub\', 86400 WHERE NOT EXISTS (SELECT id FROM "%1$sfeed" WHERE url = \'https://github.com/FreshRSS/FreshRSS/releases.atom\');',
+);
+
+define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS "%1$sentry", "%1$sfeed", "%1$scategory"');

+ 20 - 16
app/SQL/install.sql.sqlite.php

@@ -1,16 +1,16 @@
 <?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,
@@ -23,15 +23,15 @@ $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 `%1$sfeed`(`name`);',
-'CREATE INDEX IF NOT EXISTS feed_priority_index ON `%1$sfeed`(`priority`);',
-'CREATE INDEX IF NOT EXISTS feed_keep_history_index ON `%1$sfeed`(`keep_history`);',
+'CREATE INDEX IF NOT EXISTS feed_name_index ON `feed`(`name`);',
+'CREATE INDEX IF NOT EXISTS feed_priority_index ON `feed`(`priority`);',
+'CREATE INDEX IF NOT EXISTS feed_keep_history_index ON `feed`(`keep_history`);',
 
-'CREATE TABLE IF NOT EXISTS `%1$sentry` (
+'CREATE TABLE IF NOT EXISTS `entry` (
 	`id` bigint NOT NULL,
 	`guid` varchar(760) NOT NULL,
 	`title` varchar(255) NOT NULL,
@@ -46,17 +46,21 @@ $SQL_CREATE_TABLES = array(
 	`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 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_lastSeen_index ON `%1$sentry`(`lastSeen`);',	//v1.1.1
+'CREATE INDEX IF NOT EXISTS entry_is_favorite_index ON `entry`(`is_favorite`);',
+'CREATE INDEX IF NOT EXISTS entry_is_read_index ON `entry`(`is_read`);',
+'CREATE INDEX IF NOT EXISTS entry_lastSeen_index ON `entry`(`lastSeen`);',	//v1.1.1
 
-'INSERT OR IGNORE INTO `%1$scategory` (id, name) VALUES(1, "%2$s");',
-'INSERT OR IGNORE INTO `%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 OR IGNORE INTO `%1$sfeed` (url, category, name, website, description, ttl) VALUES("https://github.com/FreshRSS/FreshRSS/releases.atom", 1, "FreshRSS releases", "https://github.com/FreshRSS/FreshRSS/", "FreshRSS releases @ GitHub", 86400);',
+'INSERT OR IGNORE INTO `category` (id, name) VALUES(1, "%2$s");',
 );
 
-define('SQL_DROP_TABLES', 'DROP TABLES %1$sentry, %1$sfeed, %1$scategory');
+global $SQL_INSERT_FEEDS;
+$SQL_INSERT_FEEDS = array(
+'INSERT OR IGNORE INTO `feed` (url, category, name, website, description, ttl) VALUES("http://freshrss.org/feeds/all.atom.xml", 1, "FreshRSS.org", "http://freshrss.org/", "FreshRSS, a free, self-hostable aggregator…", 86400);',
+'INSERT OR IGNORE INTO `feed` (url, category, name, website, description, ttl) VALUES("https://github.com/FreshRSS/FreshRSS/releases.atom", 1, "FreshRSS releases", "https://github.com/FreshRSS/FreshRSS/", "FreshRSS releases @ GitHub", 86400);',
+);
+
+define('SQL_DROP_TABLES', 'DROP TABLE IF EXISTS entry, feed, category');

+ 2 - 1
app/actualize_script.php

@@ -28,12 +28,13 @@ $app = new FreshRSS();
 
 $system_conf = Minz_Configuration::get('system');
 $system_conf->auth_type = 'none';  // avoid necessity to be logged in (not saved!)
+FreshRSS_Context::$isCli = true;
 
 // Create the list of users to actualize.
 // Users are processed in a random order but always start with admin
 $users = listUsers();
 shuffle($users);
-if ($system_conf->default_user !== ''){
+if ($system_conf->default_user !== '') {
 	array_unshift($users, $system_conf->default_user);
 	$users = array_unique($users);
 }

+ 4 - 4
app/i18n/cz/admin.php

@@ -33,7 +33,7 @@ return array(
 			'ok' => 'Máte požadovanou knihovnu pro ověřování znaků (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'Nemáte cURL (balíček php5-curl).',
+			'nok' => 'Nemáte cURL (balíček php-curl).',
 			'ok' => 'Máte rozšíření cURL.',
 		),
 		'data' => array(
@@ -71,8 +71,8 @@ return array(
 			'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).',
-			'ok' => 'Máte PDO a alespoň jeden z podporovaných ovladačů (pdo_mysql, pdo_sqlite).',
+			'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',
@@ -93,7 +93,7 @@ return array(
 			'ok' => 'Oprávnění adresáře users jsou v pořádku.',
 		),
 		'zip' => array(
-			'nok' => 'Nemáte rozšíření ZIP (balíček php5-zip).',
+			'nok' => 'Nemáte rozšíření ZIP (balíček php-zip).',
 			'ok' => 'Máte rozšíření ZIP.',
 		),
 	),

+ 3 - 3
app/i18n/cz/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'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.',
+		'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ě.',
+		'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',

+ 3 - 3
app/i18n/cz/install.php

@@ -41,7 +41,7 @@ return array(
 			'ok' => 'Je nainstalována požadovaná knihovna pro ověřování znaků (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'Nemáte cURL (balíček php5-curl).',
+			'nok' => 'Nemáte cURL (balíček php-curl).',
 			'ok' => 'Máte rozšíření cURL.',
 		),
 		'data' => array(
@@ -73,8 +73,8 @@ return array(
 			'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).',
-			'ok' => 'Máte PDO a alespoň jeden z podporovaných ovladačů (pdo_mysql, pdo_sqlite).',
+			'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.',

+ 2 - 2
app/i18n/cz/sub.php

@@ -44,8 +44,8 @@ return array(
 		'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)',
+		'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',

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

@@ -33,7 +33,7 @@ return array(
 			'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(
@@ -71,8 +71,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).',
+			'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',
@@ -93,7 +93,7 @@ return array(
 			'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.',
 		),
 	),

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

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s existiert nicht',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Die Zip-Erweiterung fehlt auf Ihrem Server. Bitte versuchen Sie die Dateien eine nach der anderen zu exportieren.',
+		'export_no_zip_extension' => 'Die ZIP-Erweiterung fehlt auf Ihrem Server. Bitte versuchen Sie die Dateien eine nach der anderen zu exportieren.',
 		'feeds_imported' => 'Ihre Feeds sind importiert worden und werden jetzt aktualisiert',
 		'feeds_imported_with_errors' => 'Ihre Feeds sind importiert worden, aber es traten einige Fehler auf',
 		'file_cannot_be_uploaded' => 'Die Datei kann nicht hochgeladen werden!',
-		'no_zip_extension' => 'Die Zip-Erweiterung ist auf Ihrem Server nicht vorhanden.',
-		'zip_error' => 'Ein Fehler trat während des Zip-Imports auf.',
+		'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',

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

@@ -4,7 +4,7 @@ return array(
 	'action' => array(
 		'finish' => 'Installation fertigstellen',
 		'fix_errors_before' => 'Bitte Fehler korrigieren, bevor zum nächsten Schritt gesprungen wird.',
-		'keep_install' => 'Vorherige Installation beibehalten (Daten)',
+		'keep_install' => 'Vorherige Konfiguration beibehalten',
 		'next_step' => 'Zum nächsten Schritt springen',
 		'reinstall' => 'Neuinstallation von FreshRSS',
 	),
@@ -41,7 +41,7 @@ return array(
 			'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(
@@ -73,8 +73,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).',
+			'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.',

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

@@ -44,7 +44,7 @@ return array(
 		'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',

+ 10 - 10
app/i18n/en/admin.php

@@ -29,12 +29,12 @@ return array(
 			'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',
@@ -42,7 +42,7 @@ 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(
@@ -59,20 +59,20 @@ return array(
 		),
 		'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).',
+			'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',
@@ -93,7 +93,7 @@ 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.',
 		),
 	),

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

@@ -43,12 +43,12 @@ return array(
 		'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',
 		'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' => 'Actualise',

+ 13 - 13
app/i18n/en/install.php

@@ -4,7 +4,7 @@ return array(
 	'action' => array(
 		'finish' => 'Complete installation',
 		'fix_errors_before' => 'Please fix errors before skipping to the next step.',
-		'keep_install' => 'Keep previous installation',
+		'keep_install' => 'Keep previous configuration',
 		'next_step' => 'Go to the next step',
 		'reinstall' => 'Reinstall FreshRSS',
 	),
@@ -25,9 +25,9 @@ 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',
@@ -37,19 +37,19 @@ return array(
 			'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.',
+			'nok' => 'Cannot find a required library to browse the DOM.',
 			'ok' => 'You have the required library to browse the DOM.',
 		),
 		'favicons' => array(
@@ -61,20 +61,20 @@ return array(
 			'ok' => 'Your HTTP REFERER is known and corresponds to your server.',
 		),
 		'json' => array(
-			'nok' => 'You lack a recommended library to parse JSON.',
+			'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).',
+			'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.',
@@ -85,7 +85,7 @@ return array(
 			'ok' => 'Permissions on users directory are good.',
 		),
 		'xml' => array(
-			'nok' => 'You lack the required library to parse XML.',
+			'nok' => 'Cannot find the required library to parse XML.',
 			'ok' => 'You have the required library to parse XML.',
 		),
 	),

+ 2 - 2
app/i18n/en/sub.php

@@ -44,8 +44,8 @@ return array(
 		'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',

+ 14 - 14
app/i18n/fr/admin.php

@@ -29,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',
@@ -42,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.',
@@ -60,19 +60,19 @@ return array(
 		'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).',
+			'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',
@@ -80,7 +80,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',
@@ -93,8 +93,8 @@ 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(

+ 3 - 3
app/i18n/fr/feedback.php

@@ -43,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',

+ 13 - 13
app/i18n/fr/install.php

@@ -24,10 +24,10 @@ 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',
@@ -37,11 +37,11 @@ return array(
 			'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(
@@ -49,8 +49,8 @@ return array(
 			'ok' => 'Les droits sur le répertoire de data sont bons.',
 		),
 		'dom' => array(
-			'nok' => 'Il manque une librairie pour parcourir le DOM.',
-			'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',
@@ -61,7 +61,7 @@ return array(
 			'ok' => 'Le HTTP REFERER est connu et semble correspondre à votre serveur.',
 		),
 		'json' => array(
-			'nok' => 'Il manque une librairie recommandée pour JSON.',
+			'nok' => 'Impossible de trouver une librairie recommandée pour JSON.',
 			'ok' => 'Vouz disposez de la librairie recommandée pour JSON.',
 		),
 		'minz' => array(
@@ -69,12 +69,12 @@ return array(
 			'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).',
+			'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.',
@@ -85,7 +85,7 @@ return array(
 			'ok' => 'Les droits sur le répertoire des utilisateurs sont bons.',
 		),
 		'xml' => array(
-			'nok' => 'Il manque une librairie requise pour XML.',
+			'nok' => 'Impossible de trouver une librairie requise pour XML.',
 			'ok' => 'Vouz disposez de la librairie requise pour XML.',
 		),
 	),

+ 2 - 2
app/i18n/fr/sub.php

@@ -44,8 +44,8 @@ return array(
 		'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',

+ 4 - 4
app/i18n/it/admin.php

@@ -33,7 +33,7 @@ return array(
 			'ok' => 'Libreria richiesta per il controllo dei caratteri presente (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'Manca il supporto per cURL (pacchetto php5-curl).',
+			'nok' => 'Manca il supporto per cURL (pacchetto php-curl).',
 			'ok' => 'Estensione cURL presente.',
 		),
 		'data' => array(
@@ -71,8 +71,8 @@ return array(
 			'ok' => 'Libreria richiesta per le regular expressions presente (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'Manca PDO o uno degli altri driver supportati (pdo_mysql, pdo_sqlite).',
-			'ok' => 'PDO e altri driver supportati (pdo_mysql, pdo_sqlite).',
+			'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',
@@ -93,7 +93,7 @@ return array(
 			'ok' => 'I permessi sulla cartella users sono corretti.',
 		),
 		'zip' => array(
-			'nok' => 'Manca estensione ZIP (pacchetto php5-zip).',
+			'nok' => 'Manca estensione ZIP (pacchetto php-zip).',
 			'ok' => 'Estensione ZIP presente.',
 		),
 	),

+ 3 - 3
app/i18n/it/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s non disponibile',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Estensione Zip non presente sul server. Per favore esporta i files singolarmente.',
+		'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',
+		'no_zip_extension' => 'Estensione ZIP non presente sul server.',
+		'zip_error' => 'Si è verificato un errore importando il file ZIP',
 	),
 	'sub' => array(
 		'actualize' => 'Aggiorna',

+ 6 - 6
app/i18n/it/install.php

@@ -4,7 +4,7 @@ return array(
 	'action' => array(
 		'finish' => 'Installazione completata',
 		'fix_errors_before' => 'Per favore correggi gli errori prima di passare al passaggio successivo.',
-		'keep_install' => 'Mantieni installazione precedente',
+		'keep_install' => 'Mantieni configurazione precedente',
 		'next_step' => 'Vai al prossimo passaggio',
 		'reinstall' => 'Reinstalla FreshRSS',
 	),
@@ -25,9 +25,9 @@ return array(
 		),
 		'host' => 'Host',
 		'prefix' => 'Prefisso tabella',
-		'password' => 'HTTP password',
+		'password' => 'Password del database',
 		'type' => 'Tipo di database',
-		'username' => 'HTTP username',
+		'username' => 'Nome utente del database',
 	),
 	'check' => array(
 		'_' => 'Controlli',
@@ -41,7 +41,7 @@ return array(
 			'ok' => 'Libreria richiesta per il controllo dei caratteri presente (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'Manca il supporto per cURL (pacchetto php5-curl).',
+			'nok' => 'Manca il supporto per cURL (pacchetto php-curl).',
 			'ok' => 'Estensione cURL presente.',
 		),
 		'data' => array(
@@ -73,8 +73,8 @@ return array(
 			'ok' => 'Libreria richiesta per le regular expressions presente (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'Manca PDO o uno degli altri driver supportati (pdo_mysql, pdo_sqlite).',
-			'ok' => 'PDO e altri driver supportati (pdo_mysql, pdo_sqlite).',
+			'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',

+ 2 - 2
app/i18n/it/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Esporta tutta la lista dei feed (OPML)',
 		'export_starred' => 'Esporta i tuoi preferiti',
 		'feed_list' => 'Elenco di %s articoli',
-		'file_to_import' => 'File da importare<br />(OPML, Json o Zip)',
-		'file_to_import_no_zip' => 'File da importare<br />(OPML o Json)',
+		'file_to_import' => 'File da importare<br />(OPML, JSON o ZIP)',
+		'file_to_import_no_zip' => 'File da importare<br />(OPML o JSON)',
 		'import' => 'Importa',
 		'starred_list' => 'Elenco articoli preferiti',
 		'title' => 'Importa / esporta',

+ 4 - 4
app/i18n/nl/admin.php

@@ -33,7 +33,7 @@ return array(
 			'ok' => 'U hebt de benodigde bibliotheek voor character type checking (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'U mist de cURL (php5-curl package).',
+			'nok' => 'U mist de cURL (php-curl package).',
 			'ok' => 'U hebt de cURL uitbreiding.',
 		),
 		'data' => array(
@@ -71,8 +71,8 @@ return array(
 			'ok' => 'U hebt de benodigde bibliotheek voor regular expressions (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'U mist PDO of een van de ondersteunde drivers (pdo_mysql, pdo_sqlite).',
-			'ok' => 'U hebt PDO en ten minste één van de ondersteunde drivers (pdo_mysql, pdo_sqlite).',
+			'nok' => 'U mist PDO of een van de ondersteunde drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'U hebt PDO en ten minste één van de ondersteunde drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
 		),
 		'php' => array(
 			'_' => 'PHP installatie',
@@ -93,7 +93,7 @@ return array(
 			'ok' => 'Permissies op de users map zijn goed.',
 		),
 		'zip' => array(
-			'nok' => 'U mist ZIP uitbreiding (php5-zip package).',
+			'nok' => 'U mist ZIP uitbreiding (php-zip package).',
 			'ok' => 'U hebt ZIP uitbreiding.',
 		),
 	),

+ 3 - 3
app/i18n/nl/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s bestaat niet',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Zip uitbreiding is niet aanwezig op uw server. Exporteer a.u.b. uw bestanden één voor één.',
+		'export_no_zip_extension' => 'ZIP uitbreiding is niet aanwezig op uw server. Exporteer a.u.b. uw bestanden één voor één.',
 		'feeds_imported' => 'Uw feeds zijn geimporteerd en worden nu vernieuwd',
 		'feeds_imported_with_errors' => 'Uw feeds zijn geimporteerd maar er zijn enige fouten opgetreden',
 		'file_cannot_be_uploaded' => 'Bestand kan niet worden verzonden!',
-		'no_zip_extension' => 'Zip uitbreiding is niet aanwezig op uw server.',
-		'zip_error' => 'Er is een fout opgetreden tijdens het imporeren van het Zip bestand.',
+		'no_zip_extension' => 'ZIP uitbreiding is niet aanwezig op uw server.',
+		'zip_error' => 'Er is een fout opgetreden tijdens het imporeren van het ZIP bestand.',
 	),
 	'sub' => array(
 		'actualize' => 'Actualiseren',

+ 5 - 5
app/i18n/nl/install.php

@@ -25,9 +25,9 @@ return array(
 		),
 		'host' => 'Host',
 		'prefix' => 'Tabel voorvoegsel',
-		'password' => 'HTTP wachtwoord',
+		'password' => 'Database wachtwoord',
 		'type' => 'Type database',
-		'username' => 'HTTP gebruikersnaam',
+		'username' => 'Database gebruikersnaam',
 	),
 	'check' => array(
 		'_' => 'Controles',
@@ -41,7 +41,7 @@ return array(
 			'ok' => 'U hebt de benodigde bibliotheek voor character type checking (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'U mist cURL (php5-curl package).',
+			'nok' => 'U mist cURL (php-curl package).',
 			'ok' => 'U hebt de cURL uitbreiding.',
 		),
 		'data' => array(
@@ -73,8 +73,8 @@ return array(
 			'ok' => 'U hebt de benodigde bibliotheek voor regular expressions (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'U mist PDO of één van de ondersteunde (pdo_mysql, pdo_sqlite).',
-			'ok' => 'U hebt PDO en ten minste één van de ondersteunde drivers (pdo_mysql, pdo_sqlite).',
+			'nok' => 'U mist PDO of één van de ondersteunde (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'U hebt PDO en ten minste één van de ondersteunde drivers (pdo_mysql, pdo_sqlite, pdo_pgsql).',
 		),
 		'php' => array(
 			'nok' => 'Uw PHP versie is %s maar FreshRSS benodigd tenminste versie %s.',

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

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Exporteer lijst van feeds (OPML)',
 		'export_starred' => 'Exporteer je fovorieten',
 		'feed_list' => 'Lijst van %s artikelen',
-		'file_to_import' => 'Bestand om te importeren<br />(OPML, Json of Zip)',
-		'file_to_import_no_zip' => 'Bestand om te importeren<br />(OPML of Json)',
+		'file_to_import' => 'Bestand om te importeren<br />(OPML, JSON of ZIP)',
+		'file_to_import_no_zip' => 'Bestand om te importeren<br />(OPML of JSON)',
 		'import' => 'Importeer',
 		'starred_list' => 'Lijst van favoriete artikelen',
 		'title' => 'Importeren / exporteren',

+ 4 - 4
app/i18n/ru/admin.php

@@ -33,7 +33,7 @@ return array(
 			'ok' => 'У вас не установлена библиотека для проверки типов символов (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'У вас не установлено расширение cURL (пакет php5-curl).',
+			'nok' => 'У вас не установлено расширение cURL (пакет php-curl).',
 			'ok' => 'У вас установлено расширение cURL.',
 		),
 		'data' => array(
@@ -71,8 +71,8 @@ return array(
 			'ok' => 'У вас установлена необходимая библиотека для работы с регулярными выражениями (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'У вас не установлен PDO или один из необходимых драйверов (pdo_mysql, pdo_sqlite).',
-			'ok' => 'У вас установлен PDO и как минимум один из поддерживаемых драйверов (pdo_mysql, pdo_sqlite).',
+			'nok' => 'У вас не установлен PDO или один из необходимых драйверов (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'У вас установлен PDO и как минимум один из поддерживаемых драйверов (pdo_mysql, pdo_sqlite, pdo_pgsql).',
 		),
 		'php' => array(
 			'_' => 'PHP installation',
@@ -93,7 +93,7 @@ return array(
 			'ok' => 'Права на папку users  в порядке.',
 		),
 		'zip' => array(
-			'nok' => 'You lack ZIP extension (php5-zip package).',
+			'nok' => 'You lack ZIP extension (php-zip package).',
 			'ok' => 'You have ZIP extension.',
 		),
 	),

+ 3 - 3
app/i18n/ru/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'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',
 		'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' => 'Actualise',

+ 5 - 5
app/i18n/ru/install.php

@@ -25,9 +25,9 @@ return array(
 		),
 		'host' => 'Хост',
 		'prefix' => 'Префикс таблицы',
-		'password' => 'Пароль HTTP',
+		'password' => 'Пароль базы данных',
 		'type' => 'Тип базы данных',
-		'username' => 'Имя пользователя HTTP',
+		'username' => 'Имя пользователя базы данных',
 	),
 	'check' => array(
 		'_' => 'Проверки',
@@ -41,7 +41,7 @@ return array(
 			'ok' => 'У вас установлена необходимая библиотека для проверки типов символов (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'У вас нет расширения cURL (пакет php5-curl).',
+			'nok' => 'У вас нет расширения cURL (пакет php-curl).',
 			'ok' => 'У вас установлено расширение cURL.',
 		),
 		'data' => array(
@@ -69,8 +69,8 @@ return array(
 			'ok' => 'У вас установлена необходимая библиотека для работы с регулярными выражениями (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'У вас не установлен PDO или один из необходимых драйверов (pdo_mysql, pdo_sqlite).',
-			'ok' => 'У вас установлен PDO и как минимум один из поддерживаемых драйверов (pdo_mysql, pdo_sqlite).',
+			'nok' => 'У вас не установлен PDO или один из необходимых драйверов (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'У вас установлен PDO и как минимум один из поддерживаемых драйверов (pdo_mysql, pdo_sqlite, pdo_pgsql).',
 		),
 		'php' => array(
 			'nok' => 'У вас установлен PHP версии %s, но FreshRSS необходима версия не ниже %s.',

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

@@ -44,8 +44,8 @@ return array(
 		'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',

+ 4 - 4
app/i18n/tr/admin.php

@@ -33,7 +33,7 @@ return array(
 			'ok' => 'Karakter yazım kontrolü için kütüphane sorunsuz (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'cURL eksik (php5-curl package).',
+			'nok' => 'cURL eksik (php-curl package).',
 			'ok' => 'cURL eklentisi sorunsuz.',
 		),
 		'data' => array(
@@ -71,8 +71,8 @@ return array(
 			'ok' => 'Düzenli ifadeler kütüphanesi sorunsuz (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'PDO veya PDO destekli bir sürücü eksik (pdo_mysql, pdo_sqlite).',
-			'ok' => 'PDO sorunsuz (pdo_mysql, pdo_sqlite).',
+			'nok' => 'PDO veya PDO destekli bir sürücü eksik (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'PDO sorunsuz (pdo_mysql, pdo_sqlite, pdo_pgsql).',
 		),
 		'php' => array(
 			'_' => 'PHP kurulumu',
@@ -93,7 +93,7 @@ return array(
 			'ok' => 'Kullanıcılar klasörü yetkileri sorunsuz.',
 		),
 		'zip' => array(
-			'nok' => 'ZIP eklentisi eksik (php5-zip package).',
+			'nok' => 'ZIP eklentisi eksik (php-zip package).',
 			'ok' => 'ZIP eklentisi sorunsuz.',
 		),
 	),

+ 3 - 3
app/i18n/tr/feedback.php

@@ -43,12 +43,12 @@ return array(
 		'not_found' => '%s bulunmamaktadır',
 	),
 	'import_export' => array(
-		'export_no_zip_extension' => 'Zip eklentisi mevcut sunucunuzda yer almıyor. Lütfen başka dosya formatında dışarı aktarmayı deneyin.',
+		'export_no_zip_extension' => 'ZIP eklentisi mevcut sunucunuzda yer almıyor. Lütfen başka dosya formatında dışarı aktarmayı deneyin.',
 		'feeds_imported' => 'Akışlarınız içe aktarıldı ve şimdi güncellenecek',
 		'feeds_imported_with_errors' => 'Akışlarınız içeri aktarıldı ama bazı hatalar meydana geldi',
 		'file_cannot_be_uploaded' => 'Dosya yüklenemedi!',
-		'no_zip_extension' => 'Zip eklentisi mevcut sunucunuzda yer almıyor.',
-		'zip_error' => 'Zip içe aktarımı sırasında hata meydana geldi.',
+		'no_zip_extension' => 'ZIP eklentisi mevcut sunucunuzda yer almıyor.',
+		'zip_error' => 'ZIP içe aktarımı sırasında hata meydana geldi.',
 	),
 	'sub' => array(
 		'actualize' => 'Güncelleme',

+ 5 - 5
app/i18n/tr/install.php

@@ -25,9 +25,9 @@ return array(
 		),
 		'host' => 'Sunucu',
 		'prefix' => 'Tablo ön eki',
-		'password' => 'HTTP şifre',
+		'password' => 'Veritabanı şifresi',
 		'type' => 'Veritabanı türü',
-		'username' => 'HTTP kullanıcı adı',
+		'username' => 'Veritabanı kullanıcı adı',
 	),
 	'check' => array(
 		'_' => 'Kontroller',
@@ -41,7 +41,7 @@ return array(
 			'ok' => 'Karakter yazım kontrolü için kütüphane sorunsuz (ctype).',
 		),
 		'curl' => array(
-			'nok' => 'cURL eksik (php5-curl package).',
+			'nok' => 'cURL eksik (php-curl package).',
 			'ok' => 'cURL eklentisi sorunsuz.',
 		),
 		'data' => array(
@@ -73,8 +73,8 @@ return array(
 			'ok' => 'Düzenli ifadeler kütüphanesi sorunsuz (PCRE).',
 		),
 		'pdo' => array(
-			'nok' => 'PDO veya PDO destekli bir sürücü eksik (pdo_mysql, pdo_sqlite).',
-			'ok' => 'PDO sorunsuz (pdo_mysql, pdo_sqlite).',
+			'nok' => 'PDO veya PDO destekli bir sürücü eksik (pdo_mysql, pdo_sqlite, pdo_pgsql).',
+			'ok' => 'PDO sorunsuz (pdo_mysql, pdo_sqlite, pdo_pgsql).',
 		),
 		'php' => array(
 			'nok' => 'PHP versiyonunuz %s fakat FreshRSS için gerekli olan en düşük sürüm %s.',

+ 2 - 2
app/i18n/tr/sub.php

@@ -44,8 +44,8 @@ return array(
 		'export_opml' => 'Akış listesini dışarı aktar (OPML)',
 		'export_starred' => 'Favorileri dışarı aktar',
 		'feed_list' => '%s makalenin listesi',
-		'file_to_import' => 'Dosyadan içe aktar<br />(OPML, Json or Zip)',
-		'file_to_import_no_zip' => 'Dosyadan içe aktar<br />(OPML or Json)',
+		'file_to_import' => 'Dosyadan içe aktar<br />(OPML, JSON or ZIP)',
+		'file_to_import_no_zip' => 'Dosyadan içe aktar<br />(OPML or JSON)',
 		'import' => 'İçe aktar',
 		'starred_list' => 'Favori makaleleirn listesi',
 		'title' => 'İçe / dışa aktar',

+ 58 - 138
app/install.php

@@ -4,23 +4,18 @@ if (function_exists('opcache_reset')) {
 }
 header("Content-Security-Policy: default-src 'self'");
 
-define('BCRYPT_COST', 9);
+require(LIB_PATH . '/lib_install.php');
 
 session_name('FreshRSS');
 session_set_cookie_params(0, dirname(empty($_SERVER['REQUEST_URI']) ? '/' : dirname($_SERVER['REQUEST_URI'])), null, false, true);
 session_start();
 
-Minz_Configuration::register('default_system', join_path(DATA_PATH, 'config.default.php'));
-Minz_Configuration::register('default_user', join_path(USERS_PATH, '_', 'config.default.php'));
-
 if (isset($_GET['step'])) {
 	define('STEP',(int)$_GET['step']);
 } else {
 	define('STEP', 0);
 }
 
-define('SQL_CREATE_DB', 'CREATE DATABASE IF NOT EXISTS %1$s DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;');
-
 if (STEP === 3 && isset($_POST['type'])) {
 	$_SESSION['bd_type'] = $_POST['type'];
 }
@@ -28,10 +23,13 @@ if (STEP === 3 && isset($_POST['type'])) {
 if (isset($_SESSION['bd_type'])) {
 	switch ($_SESSION['bd_type']) {
 	case 'mysql':
-		include(APP_PATH . '/SQL/install.sql.mysql.php');
+		include_once(APP_PATH . '/SQL/install.sql.mysql.php');
 		break;
 	case 'sqlite':
-		include(APP_PATH . '/SQL/install.sql.sqlite.php');
+		include_once(APP_PATH . '/SQL/install.sql.sqlite.php');
+		break;
+	case 'pgsql':
+		include_once(APP_PATH . '/SQL/install.sql.pgsql.php');
 		break;
 	}
 }
@@ -130,12 +128,7 @@ function saveStep2() {
 
 		$password_plain = param('passwordPlain', false);
 		if ($password_plain !== false && cryptAvailable()) {
-			if (!function_exists('password_hash')) {
-				include_once(LIB_PATH . '/password_compat.php');
-			}
-			$passwordHash = password_hash($password_plain, PASSWORD_BCRYPT, array('cost' => BCRYPT_COST));
-			$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);	//Compatibility with bcrypt.js
-			$_SESSION['passwordHash'] = $passwordHash;
+			$_SESSION['passwordHash'] = FreshRSS_user_Controller::hashPassword($password_plain);
 		}
 
 		if (empty($_SESSION['old_entries']) ||
@@ -148,7 +141,7 @@ function saveStep2() {
 			return false;
 		}
 
-		$_SESSION['salt'] = sha1(uniqid(mt_rand(), true).implode('', stat(__FILE__)));
+		$_SESSION['salt'] = generateSalt();
 		if ((!ctype_digit($_SESSION['old_entries'])) ||($_SESSION['old_entries'] < 1)) {
 			$_SESSION['old_entries'] = $user_default_config->old_entries;
 		}
@@ -170,7 +163,7 @@ function saveStep2() {
 
 		recursive_unlink($user_dir);
 		mkdir($user_dir);
-		file_put_contents($user_config_path, "<?php\n return " . var_export($config_array, true) . ';');
+		file_put_contents($user_config_path, "<?php\n return " . var_export($config_array, true) . ";\n");
 
 		header('Location: index.php?step=3');
 	}
@@ -199,6 +192,9 @@ function saveStep3() {
 			$_SESSION['bd_prefix'] = substr($_POST['prefix'], 0, 16);
 			$_SESSION['bd_prefix_user'] = $_SESSION['bd_prefix'] . (empty($_SESSION['default_user']) ? '' : ($_SESSION['default_user'] . '_'));
 		}
+		if ($_SESSION['bd_type'] === 'pgsql') {
+			$_SESSION['bd_base'] = strtolower($_SESSION['bd_base']);
+		}
 
 		// We use dirname to remove the /i part
 		$base_url = dirname(Minz_Request::guessBaseUrl());
@@ -221,55 +217,30 @@ function saveStep3() {
 		);
 
 		@unlink(join_path(DATA_PATH, 'config.php'));	//To avoid access-rights problems
-		file_put_contents(join_path(DATA_PATH, 'config.php'), "<?php\n return " . var_export($config_array, true) . ';');
+		file_put_contents(join_path(DATA_PATH, 'config.php'), "<?php\n return " . var_export($config_array, true) . ";\n");
 
-		$res = checkBD();
+		$config_array['db']['default_user'] = $config_array['default_user'];
+		$config_array['db']['prefix_user'] = $_SESSION['bd_prefix_user'];
+		$ok = checkDb($config_array['db']) && checkDbUser($config_array['db']);
+		if (!$ok) {
+			@unlink(join_path(DATA_PATH, 'config.php'));
+		}
 
-		if ($res) {
+		if ($ok) {
 			$_SESSION['bd_error'] = '';
 			header('Location: index.php?step=4');
-		} elseif (empty($_SESSION['bd_error'])) {
-			$_SESSION['bd_error'] = 'Unknown error!';
+		} else {
+			$_SESSION['bd_error'] = empty($config_array['db']['bd_error']) ? 'Unknown error!' : $config_array['db']['bd_error'];
 		}
 	}
 	invalidateHttpCache();
 }
 
-function newPdo() {
-	switch ($_SESSION['bd_type']) {
-	case 'mysql':
-		$str = 'mysql:host=' . $_SESSION['bd_host'] . ';dbname=' . $_SESSION['bd_base'];
-		$driver_options = array(
-			PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4',
-		);
-		break;
-	case 'sqlite':
-		$str = 'sqlite:' . join_path(USERS_PATH, $_SESSION['default_user'], 'db.sqlite');
-		$driver_options = array(
-			PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
-		);
-		break;
-	default:
-		return false;
-	}
-	return new PDO($str, $_SESSION['bd_user'], $_SESSION['bd_password'], $driver_options);
-}
-
-function deleteInstall() {
-	$res = unlink(join_path(DATA_PATH, 'do-install.txt'));
-
-	if (!$res) {
-		return false;
-	}
-
-	header('Location: index.php');
-}
-
 
 /*** VÉRIFICATIONS ***/
 function checkStep() {
 	$s0 = checkStep0();
-	$s1 = checkStep1();
+	$s1 = checkRequirements();
 	$s2 = checkStep2();
 	$s3 = checkStep3();
 	if (STEP > 0 && $s0['all'] != 'ok') {
@@ -295,47 +266,6 @@ function checkStep0() {
 	);
 }
 
-function checkStep1() {
-	$php = version_compare(PHP_VERSION, '5.3.3') >= 0;
-	$minz = file_exists(join_path(LIB_PATH, 'Minz'));
-	$curl = extension_loaded('curl');
-	$pdo_mysql = extension_loaded('pdo_mysql');
-	$pdo_sqlite = extension_loaded('pdo_sqlite');
-	$pdo = $pdo_mysql || $pdo_sqlite;
-	$pcre = extension_loaded('pcre');
-	$ctype = extension_loaded('ctype');
-	$dom = class_exists('DOMDocument');
-	$xml = function_exists('xml_parser_create');
-	$json = function_exists('json_encode');
-	$data = DATA_PATH && is_writable(DATA_PATH);
-	$cache = CACHE_PATH && is_writable(CACHE_PATH);
-	$users = USERS_PATH && is_writable(USERS_PATH);
-	$favicons = is_writable(join_path(DATA_PATH, 'favicons'));
-	$http_referer = is_referer_from_same_domain();
-
-	return array(
-		'php' => $php ? 'ok' : 'ko',
-		'minz' => $minz ? 'ok' : 'ko',
-		'curl' => $curl ? 'ok' : 'ko',
-		'pdo-mysql' => $pdo_mysql ? 'ok' : 'ko',
-		'pdo-sqlite' => $pdo_sqlite ? 'ok' : 'ko',
-		'pdo' => $pdo ? 'ok' : 'ko',
-		'pcre' => $pcre ? 'ok' : 'ko',
-		'ctype' => $ctype ? 'ok' : 'ko',
-		'dom' => $dom ? 'ok' : 'ko',
-		'xml' => $xml ? 'ok' : 'ko',
-		'json' => $json ? 'ok' : 'ko',
-		'data' => $data ? 'ok' : 'ko',
-		'cache' => $cache ? 'ok' : 'ko',
-		'users' => $users ? 'ok' : 'ko',
-		'favicons' => $favicons ? 'ok' : 'ko',
-		'http_referer' => $http_referer ? 'ok' : 'ko',
-		'all' => $php && $minz && $curl && $pdo && $pcre && $ctype && $dom && $xml &&
-		         $data && $cache && $users && $favicons && $http_referer ?
-		         'ok' : 'ko'
-	);
-}
-
 function freshrss_already_installed() {
 	$conf_path = join_path(DATA_PATH, 'config.php');
 	if (!file_exists($conf_path)) {
@@ -406,43 +336,15 @@ function checkStep3() {
 	);
 }
 
-function checkBD() {
+function checkDbUser(&$dbOptions) {
 	$ok = false;
-
+	$str = $dbOptions['dsn'];
+	$driver_options = $dbOptions['options'];
 	try {
-		$str = '';
-		$driver_options = null;
-		switch ($_SESSION['bd_type']) {
-		case 'mysql':
-			$driver_options = array(
-				PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8mb4'
-			);
-
-			try {	// on ouvre une connexion juste pour créer la base si elle n'existe pas
-				$str = 'mysql:host=' . $_SESSION['bd_host'] . ';';
-				$c = new PDO($str, $_SESSION['bd_user'], $_SESSION['bd_password'], $driver_options);
-				$sql = sprintf(SQL_CREATE_DB, $_SESSION['bd_base']);
-				$res = $c->query($sql);
-			} catch (PDOException $e) {
-			}
-
-			// on écrase la précédente connexion en sélectionnant la nouvelle BDD
-			$str = 'mysql:host=' . $_SESSION['bd_host'] . ';dbname=' . $_SESSION['bd_base'];
-			break;
-		case 'sqlite':
-			$str = 'sqlite:' . join_path(USERS_PATH, $_SESSION['default_user'], 'db.sqlite');
-			$driver_options = array(
-				PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
-			);
-			break;
-		default:
-			return false;
-		}
-
-		$c = new PDO($str, $_SESSION['bd_user'], $_SESSION['bd_password'], $driver_options);
+		$c = new PDO($str, $dbOptions['user'], $dbOptions['password'], $driver_options);
 
 		if (defined('SQL_CREATE_TABLES')) {
-			$sql = sprintf(SQL_CREATE_TABLES, $_SESSION['bd_prefix_user'], _t('gen.short.default_category'));
+			$sql = sprintf(SQL_CREATE_TABLES, $dbOptions['prefix_user'], _t('gen.short.default_category'));
 			$stm = $c->prepare($sql);
 			$ok = $stm->execute();
 		} else {
@@ -450,7 +352,22 @@ function checkBD() {
 			if (is_array($SQL_CREATE_TABLES)) {
 				$ok = true;
 				foreach ($SQL_CREATE_TABLES as $instruction) {
-					$sql = sprintf($instruction, $_SESSION['bd_prefix_user'], _t('gen.short.default_category'));
+					$sql = sprintf($instruction, $dbOptions['prefix_user'], _t('gen.short.default_category'));
+					$stm = $c->prepare($sql);
+					$ok &= $stm->execute();
+				}
+			}
+		}
+
+		if (defined('SQL_INSERT_FEEDS')) {
+			$sql = sprintf(SQL_INSERT_FEEDS, $dbOptions['prefix_user']);
+			$stm = $c->prepare($sql);
+			$ok &= $stm->execute();
+		} else {
+			global $SQL_INSERT_FEEDS;
+			if (is_array($SQL_INSERT_FEEDS)) {
+				foreach ($SQL_INSERT_FEEDS as $instruction) {
+					$sql = sprintf($instruction, $dbOptions['prefix_user']);
 					$stm = $c->prepare($sql);
 					$ok &= $stm->execute();
 				}
@@ -458,13 +375,8 @@ function checkBD() {
 		}
 	} catch (PDOException $e) {
 		$ok = false;
-		$_SESSION['bd_error'] = $e->getMessage();
+		$dbOptions['bd_error'] = $e->getMessage();
 	}
-
-	if (!$ok) {
-		@unlink(join_path(DATA_PATH, 'config.php'));
-	}
-
 	return $ok;
 }
 
@@ -507,7 +419,7 @@ function printStep0() {
 
 // @todo refactor this view with the check_install action
 function printStep1() {
-	$res = checkStep1();
+	$res = checkRequirements();
 ?>
 	<noscript><p class="alert alert-warn"><span class="alert-head"><?php echo _t('gen.short.attention'); ?></span> <?php echo _t('install.javascript_is_better'); ?></p></noscript>
 
@@ -690,7 +602,7 @@ function printStep3() {
 	<p class="alert alert-error"><span class="alert-head"><?php echo _t('gen.short.damn'); ?></span> <?php echo _t('install.bdd.conf.ko'),(empty($_SESSION['bd_error']) ? '' : ' : ' . $_SESSION['bd_error']); ?></p>
 	<?php } ?>
 
-	<form action="index.php?step=3" method="post">
+	<form action="index.php?step=3" method="post" autocomplete="off">
 		<legend><?php echo _t('install.bdd.conf'); ?></legend>
 		<div class="form-group">
 			<label class="group-name" for="type"><?php echo _t('install.bdd.type'); ?></label>
@@ -708,6 +620,12 @@ function printStep3() {
 					SQLite
 				</option>
 				<?php }?>
+				<?php if (extension_loaded('pdo_pgsql')) {?>
+				<option value="pgsql"
+					<?php echo(isset($_SESSION['bd_type']) && $_SESSION['bd_type'] === 'pgsql') ? 'selected="selected"' : ''; ?>>
+					PostgreSQL (⚠️ experimental)
+				</option>
+				<?php }?>
 				</select>
 			</div>
 		</div>
@@ -716,7 +634,7 @@ function printStep3() {
 		<div class="form-group">
 			<label class="group-name" for="host"><?php echo _t('install.bdd.host'); ?></label>
 			<div class="group-controls">
-				<input type="text" id="host" name="host" pattern="[0-9A-Za-z_.-]{1,64}" value="<?php echo isset($_SESSION['bd_host']) ? $_SESSION['bd_host'] : $system_default_config->db['host']; ?>" tabindex="2" />
+				<input type="text" id="host" name="host" pattern="[0-9A-Za-z_.-]{1,64}(:[0-9]{2,5})?" value="<?php echo isset($_SESSION['bd_host']) ? $_SESSION['bd_host'] : $system_default_config->db['host']; ?>" tabindex="2" />
 			</div>
 		</div>
 
@@ -730,7 +648,7 @@ function printStep3() {
 		<div class="form-group">
 			<label class="group-name" for="pass"><?php echo _t('install.bdd.password'); ?></label>
 			<div class="group-controls">
-				<input type="password" id="pass" name="pass" value="<?php echo isset($_SESSION['bd_password']) ? $_SESSION['bd_password'] : ''; ?>" tabindex="4" />
+				<input type="password" id="pass" name="pass" value="<?php echo isset($_SESSION['bd_password']) ? $_SESSION['bd_password'] : ''; ?>" tabindex="4" autocomplete="off" />
 			</div>
 		</div>
 
@@ -796,7 +714,9 @@ case 3:
 case 4:
 	break;
 case 5:
-	deleteInstall();
+	if (deleteInstall()) {
+		header('Location: index.php');
+	}
 	break;
 }
 ?>

+ 2 - 2
app/layout/aside_feed.phtml

@@ -79,13 +79,13 @@
 		<?php if (FreshRSS_Auth::hasAccess()) { ?>
 		<li class="item"><a href="<?php echo _url('stats', 'repartition', 'id', '------'); ?>"><?php echo _t('index.menu.stats'); ?></a></li>
 		<?php } ?>
-		<li class="item"><a target="_blank" href="http://example.net/"><?php echo _t('gen.action.see_website'); ?></a></li>
+		<li class="item"><a target="_blank" rel="noreferrer" href="http://example.net/"><?php echo _t('gen.action.see_website'); ?></a></li>
 		<?php if (FreshRSS_Auth::hasAccess()) { ?>
 		<li class="separator"></li>
 		<li class="item"><a href="<?php echo _url('subscription', 'index', 'id', '------'); ?>"><?php echo _t('gen.action.manage'); ?></a></li>
 		<li class="item"><a href="<?php echo _url('feed', 'actualize', 'id', '------'); ?>"><?php echo _t('gen.action.actualize'); ?></a></li>
 		<li class="item">
-			<?php $confirm = FreshRSS_Context::$user_conf->reading_confirm ? 'confirm' : ''; ?>
+			<?php $confirm = FreshRSS_Context::$user_conf->reading_confirm ? 'confirm" disabled="disabled' : ''; ?>
 			<button class="read_all as-link <?php echo $confirm; ?>"
 			        form="mark-read-aside"
 			        formaction="<?php echo _url('entry', 'read', 'get', 'f_------'); ?>"

+ 1 - 1
app/layout/aside_subscription.phtml

@@ -10,7 +10,7 @@
 	</li>
 
 	<li class="item">
-		<a class="bookmarkClick" href="javascript:(function(){var%20url%20=%20location.href;window.open('<?php echo Minz_Url::display(array('c' => 'feed', 'a' => 'add'), 'html', true); ?>&amp;url_rss='+encodeURIComponent(url), '_blank');})();">
+		<a class="bookmarkClick" href="javascript:(function(){var%20url%20=%20location.href;var%20otherWindow=window.open('about:blank','_blank');otherWindow.opener=null;otherWindow.location='<?php echo Minz_Url::display(array('c' => 'feed', 'a' => 'add'), 'html', true); ?>&amp;url_rss='+encodeURIComponent(url);})();">
 			<?php echo _t('sub.menu.bookmark'); ?>
 		</a>
 	</li>

+ 3 - 0
app/layout/layout.phtml

@@ -42,6 +42,9 @@
 	} if (isset($this->rss_title)) {
 		$url_rss = $url_base;
 		$url_rss['a'] = 'rss';
+		if (FreshRSS_Context::$user_conf->since_hours_posts_per_rss) {
+			$url_rss['params']['hours'] = FreshRSS_Context::$user_conf->since_hours_posts_per_rss;
+		}
 ?>
 		<link rel="alternate" type="application/rss+xml" title="<?php echo $this->rss_title; ?>" href="<?php echo Minz_Url::display($url_rss); ?>" />
 <?php } if (FreshRSS_Context::$system_conf->allow_robots) { ?>

+ 5 - 2
app/layout/nav_menu.phtml

@@ -83,7 +83,7 @@
 
 	<div class="stick" id="nav_menu_read_all">
 		<form id="mark-read-menu" method="post">
-		<?php $confirm = FreshRSS_Context::$user_conf->reading_confirm ? 'confirm' : ''; ?>
+		<?php $confirm = FreshRSS_Context::$user_conf->reading_confirm ? 'confirm" disabled="disabled' : ''; ?>
 		<button class="read_all btn <?php echo $confirm; ?>"
 		        form="mark-read-menu"
 		        formaction="<?php echo Minz_Url::display($mark_read_url); ?>"
@@ -151,8 +151,11 @@
 			if (FreshRSS_Context::$user_conf->token) {
 				$url_output['params']['token'] = FreshRSS_Context::$user_conf->token;
 			}
+			if (FreshRSS_Context::$user_conf->since_hours_posts_per_rss) {
+				$url_output['params']['hours'] = FreshRSS_Context::$user_conf->since_hours_posts_per_rss;
+			}
 		?>
-		<a class="view_rss btn" target="_blank" title="<?php echo _t('index.menu.rss_view'); ?>" href="<?php echo Minz_Url::display($url_output); ?>">
+		<a class="view_rss btn" target="_blank" rel="noreferrer" title="<?php echo _t('index.menu.rss_view'); ?>" href="<?php echo Minz_Url::display($url_output); ?>">
 			<?php echo _i('rss'); ?>
 		</a>
 	</div>

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

@@ -60,7 +60,7 @@
 				<input type="text" id="token" name="token" value="<?php echo $token; ?>" placeholder="<?php echo _t('gen.short.blank_to_disable'); ?>"<?php
 					echo FreshRSS_Auth::accessNeedsAction() ? '' : ' disabled="disabled"'; ?> data-leave-validation="<?php echo $token; ?>"/>
 				<?php echo _i('help'); ?> <?php echo _t('admin.auth.token_help'); ?>
-				<kbd><?php echo Minz_Url::display(array('params' => array('output' => 'rss', 'token' => $token)), 'html', true); ?></kbd>
+				<kbd><?php echo Minz_Url::display(array('a' => 'rss', 'params' => array('token' => $token, 'hours' => FreshRSS_Context::$user_conf->since_hours_posts_per_rss)), 'html', true); ?></kbd>
 			</div>
 		</div>
 		<?php } ?>

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

@@ -13,7 +13,7 @@
 			<input type="text" id="share_##key##_name" name="share[##key##][name]" class="extend" value="" placeholder="<?php echo _t('conf.sharing.share_name'); ?>" size="64" />
 			<input type="url" id="share_##key##_url" name="share[##key##][url]" class="extend" value="" placeholder="<?php echo _t('conf.sharing.share_url'); ?>" size="64" />
 			<a href="#" class="remove btn btn-attention" data-remove="group-share-##key##"><?php echo _i('close'); ?></a></div>
-			<a target="_blank" class="btn" title="<?php echo _t('conf.sharing.more_information'); ?>" href="##help##"><?php echo _i('help'); ?></a>
+			<a target="_blank" rel="noreferrer" class="btn" title="<?php echo _t('conf.sharing.more_information'); ?>" href="##help##"><?php echo _i('help'); ?></a>
 			</div></div>'>
 		<input type="hidden" name="_csrf" value="<?php echo FreshRSS_Auth::csrfToken(); ?>" />
 		<legend><?php echo _t('conf.sharing'); ?></legend>
@@ -38,7 +38,7 @@
 						<a href='#' class='remove btn btn-attention' data-remove="group-share-<?php echo $key; ?>"><?php echo _i('close'); ?></a>
 					</div>
 					<?php if ($share->formType() === 'advanced') { ?>
-						<a target="_blank" class="btn" title="<?php echo _t('conf.sharing.more_information'); ?>" href="<?php echo $share->help(); ?>"><?php echo _i('help'); ?></a>
+						<a target="_blank" rel="noreferrer" class="btn" title="<?php echo _t('conf.sharing.more_information'); ?>" href="<?php echo $share->help(); ?>"><?php echo _i('help'); ?></a>
 					<?php } ?>
 				</div>
 			</div>

+ 3 - 3
app/views/feed/add.phtml

@@ -30,7 +30,7 @@
 			<label class="group-name"><?php echo _t('sub.feed.website'); ?></label>
 			<div class="group-controls">
 				<?php echo $this->feed->website(); ?>
-				<a class="btn" target="_blank" href="<?php echo $this->feed->website(); ?>"><?php echo _i('link'); ?></a>
+				<a class="btn" target="_blank" rel="noreferrer" href="<?php echo $this->feed->website(); ?>"><?php echo _i('link'); ?></a>
 			</div>
 		</div>
 		<?php } ?>
@@ -40,9 +40,9 @@
 			<div class="group-controls">
 				<div class="stick">
 					<input type="text" name="url_rss" id="url" class="extend" value="<?php echo $this->feed->url(); ?>" />
-					<a class="btn" target="_blank" href="<?php echo $this->feed->url(); ?>"><?php echo _i('link'); ?></a>
+					<a class="btn" target="_blank" rel="noreferrer" href="<?php echo $this->feed->url(); ?>"><?php echo _i('link'); ?></a>
 				</div>
-				<a class="btn" target="_blank" href="http://validator.w3.org/feed/check.cgi?url=<?php echo $this->feed->url(); ?>"><?php echo _t('sub.feed.validator'); ?></a>
+				<a class="btn" target="_blank" rel="noreferrer" href="http://validator.w3.org/feed/check.cgi?url=<?php echo $this->feed->url(); ?>"><?php echo _t('sub.feed.validator'); ?></a>
 			</div>
 		</div>
 		<div class="form-group">

+ 3 - 3
app/views/helpers/feed/update.phtml

@@ -37,7 +37,7 @@
 			<div class="group-controls">
 				<div class="stick">
 					<input type="text" name="website" id="website" class="extend" value="<?php echo $this->feed->website(); ?>" />
-					<a class="btn" target="_blank" href="<?php echo $this->feed->website(); ?>"><?php echo _i('link'); ?></a>
+					<a class="btn" target="_blank" rel="noreferrer" href="<?php echo $this->feed->website(); ?>"><?php echo _i('link'); ?></a>
 				</div>
 			</div>
 		</div>
@@ -46,10 +46,10 @@
 			<div class="group-controls">
 				<div class="stick">
 					<input type="text" name="url" id="url" class="extend" value="<?php echo $this->feed->url(); ?>" />
-					<a class="btn" target="_blank" href="<?php echo $this->feed->url(); ?>"><?php echo _i('link'); ?></a>
+					<a class="btn" target="_blank" rel="noreferrer" href="<?php echo $this->feed->url(); ?>"><?php echo _i('link'); ?></a>
 				</div>
 
-				<a class="btn" target="_blank" href="http://validator.w3.org/feed/check.cgi?url=<?php echo rawurlencode(htmlspecialchars_decode($this->feed->url(), ENT_QUOTES)); ?>"><?php echo _t('sub.feed.validator'); ?></a>
+				<a class="btn" target="_blank" rel="noreferrer" href="http://validator.w3.org/feed/check.cgi?url=<?php echo rawurlencode(htmlspecialchars_decode($this->feed->url(), ENT_QUOTES)); ?>"><?php echo _t('sub.feed.validator'); ?></a>
 			</div>
 		</div>
 		<div class="form-group">

+ 2 - 2
app/views/helpers/index/normal/entry_bottom.phtml

@@ -52,7 +52,7 @@
 						$share_options['title'] = $title;
 						$share->update($share_options);
 				?><li class="item share">
-					<a target="_blank" href="<?php echo $share->url(); ?>"><?php echo $share->name(); ?></a>
+					<a target="_blank" rel="noreferrer" href="<?php echo $share->url(); ?>"><?php echo $share->name(); ?></a>
 				</li><?php
 					}
 			?></ul>
@@ -81,6 +81,6 @@
 		?><li class="item date"><?php echo $this->entry->date(); ?></li><?php
 	}
 	if ($bottomline_link) {
-		?><li class="item link"><a target="_blank" href="<?php echo $this->entry->link(); ?>"><?php echo _i('link'); ?></a></li><?php
+		?><li class="item link"><a target="_blank" rel="noreferrer" href="<?php echo $this->entry->link(); ?>"><?php echo _i('link'); ?></a></li><?php
 	} ?>
 </ul>

+ 2 - 2
app/views/helpers/index/normal/entry_header.phtml

@@ -27,7 +27,7 @@
 		}
 	}
 	?><li class="item website"><a href="<?php echo _url('index', 'index', 'get', 'f_' . $this->feed->id()); ?>"><img class="favicon" src="<?php echo $this->feed->favicon(); ?>" alt="✇" /> <span><?php echo $this->feed->name(); ?></span></a></li>
-	<li class="item title"><a target="_blank" href="<?php echo $this->entry->link(); ?>"><?php echo $this->entry->title(); ?></a></li>
+	<li class="item title"><a target="_blank" rel="noreferrer" href="<?php echo $this->entry->link(); ?>"><?php echo $this->entry->title(); ?></a></li>
 	<?php if ($topline_date) { ?><li class="item date"><?php echo $this->entry->date(); ?> </li><?php } ?>
-	<?php if ($topline_link) { ?><li class="item link"><a target="_blank" href="<?php echo $this->entry->link(); ?>"><?php echo _i('link'); ?></a></li><?php } ?>
+	<?php if ($topline_link) { ?><li class="item link"><a target="_blank" rel="noreferrer" href="<?php echo $this->entry->link(); ?>"><?php echo _i('link'); ?></a></li><?php } ?>
 </ul>

+ 2 - 1
app/views/helpers/pagination.phtml

@@ -1,6 +1,7 @@
 <?php
 	$url_next = Minz_Request::currentRequest();
 	$url_next['params']['next'] = FreshRSS_Context::$next_id;
+	$url_next['params']['state'] = FreshRSS_Context::$state;
 	$url_next['params']['ajax'] = 1;
 
 	$url_mark_read = array(
@@ -26,7 +27,7 @@
 		</a>
 	<?php } elseif ($url_mark_read) { ?>
 		<button id="bigMarkAsRead"
-		        class="as-link <?php echo FreshRSS_Context::$user_conf->reading_confirm ? 'confirm' : ''; ?>"
+		        class="as-link <?php echo FreshRSS_Context::$user_conf->reading_confirm ? 'confirm" disabled="disabled' : ''; ?>"
 		        form="mark-read-pagination"
 		        formaction="<?php echo Minz_Url::display($url_mark_read); ?>"
 		        type="submit">

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

@@ -44,7 +44,7 @@
 						$select_args = ' size="' . min(10, count($this->feeds)) .'" multiple="multiple"';
 					}
 				?>
-				<select name="export_feeds[]"<?php echo $select_args; ?>>
+				<select name="export_feeds[]"<?php echo $select_args; ?> size="10">
 					<?php echo extension_loaded('zip') ? '' : '<option></option>'; ?>
 					<?php foreach ($this->feeds as $feed) { ?>
 					<option value="<?php echo $feed->id(); ?>"><?php echo $feed->name(); ?></option>

+ 4 - 1
app/views/index/global.phtml

@@ -11,10 +11,13 @@
 
 <div id="stream" class="global<?php echo $class; ?>">
 <?php
+	$params = Minz_Request::fetchGET();
+	unset($params['c']);
+	unset($params['a']);
 	$url_base = array(
 		'c' => 'index',
 		'a' => 'normal',
-		'params' => Minz_Request::fetchGET(),
+		'params' => $params,
 	);
 
 	foreach ($this->categories as $cat) {

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

@@ -66,7 +66,7 @@ if (!empty($this->entries)) {
 
 		?><div class="flux_content">
 			<div class="content <?php echo $content_width; ?>">
-				<h1 class="title"><a target="_blank" href="<?php echo $this->entry->link(); ?>"><?php echo $this->entry->title(); ?></a></h1>
+				<h1 class="title"><a target="_blank" rel="noreferrer" href="<?php echo $this->entry->link(); ?>"><?php echo $this->entry->title(); ?></a></h1>
 				<?php
 					$author = $this->entry->author();
 					echo $author != '' ? '<div class="author">' . _t('gen.short.by_author', $author) . '</div>' : '',

+ 6 - 6
app/views/stats/index.phtml

@@ -23,18 +23,18 @@
 				</tr>
 				<tr>
 					<th><?php echo _t('admin.stats.status_read'); ?></th>
-					<td class="numeric"><?php echo format_number($this->repartition['main_stream']['read']); ?></td>
-					<td class="numeric"><?php echo format_number($this->repartition['all_feeds']['read']); ?></td>
+					<td class="numeric"><?php echo format_number($this->repartition['main_stream']['count_reads']); ?></td>
+					<td class="numeric"><?php echo format_number($this->repartition['all_feeds']['count_reads']); ?></td>
 				</tr>
 				<tr>
 					<th><?php echo _t('admin.stats.status_unread'); ?></th>
-					<td class="numeric"><?php echo format_number($this->repartition['main_stream']['unread']); ?></td>
-					<td class="numeric"><?php echo format_number($this->repartition['all_feeds']['unread']); ?></td>
+					<td class="numeric"><?php echo format_number($this->repartition['main_stream']['count_unreads']); ?></td>
+					<td class="numeric"><?php echo format_number($this->repartition['all_feeds']['count_unreads']); ?></td>
 				</tr>
 				<tr>
 					<th><?php echo _t('admin.stats.status_favorites'); ?></th>
-					<td class="numeric"><?php echo format_number($this->repartition['main_stream']['favorite']); ?></td>
-					<td class="numeric"><?php echo format_number($this->repartition['all_feeds']['favorite']); ?></td>
+					<td class="numeric"><?php echo format_number($this->repartition['main_stream']['count_favorites']); ?></td>
+					<td class="numeric"><?php echo format_number($this->repartition['all_feeds']['count_favorites']); ?></td>
 				</tr>
 			</tbody>
 		</table>

+ 4 - 4
app/views/stats/repartition.phtml

@@ -12,7 +12,7 @@
 		if (!empty($feeds)) {
 			echo '<optgroup label="', $category->name(), '">';
 			foreach ($feeds as $feed) {
-				if ($this->feed && $feed->id() == $this->feed->id()){
+				if ($this->feed && $feed->id() == $this->feed->id()) {
 					echo '<option value="', $feed->id(), '" selected="selected" data-url="', _url('stats', 'repartition', 'id', $feed->id()), '">', $feed->name(), '</option>';
 				} else {
 					echo '<option value="', $feed->id(), '" data-url="', _url('stats', 'repartition', 'id', $feed->id()), '">', $feed->name(), '</option>';
@@ -39,9 +39,9 @@
 		</tr>
 		<tr>
 			<td class="numeric"><?php echo $this->repartition['total']; ?></td>
-			<td class="numeric"><?php echo $this->repartition['read']; ?></td>
-			<td class="numeric"><?php echo $this->repartition['unread']; ?></td>
-			<td class="numeric"><?php echo $this->repartition['favorite']; ?></td>
+			<td class="numeric"><?php echo $this->repartition['count_reads']; ?></td>
+			<td class="numeric"><?php echo $this->repartition['count_unreads']; ?></td>
+			<td class="numeric"><?php echo $this->repartition['count_favorites']; ?></td>
 		</tr>
 		</table>
 	</div>

+ 2 - 2
app/views/user/manage.phtml

@@ -3,7 +3,7 @@
 <div class="post">
 	<a href="<?php echo _url('index', 'index'); ?>"><?php echo _t('gen.action.back_to_rss_feeds'); ?></a>
 
-	<form method="post" action="<?php echo _url('user', 'create'); ?>">
+	<form method="post" action="<?php echo _url('user', 'create'); ?>" autocomplete="off">
 		<input type="hidden" name="_csrf" value="<?php echo FreshRSS_Auth::csrfToken(); ?>" />
 		<legend><?php echo _t('admin.user.create'); ?></legend>
 
@@ -30,7 +30,7 @@
 			<label class="group-name" for="new_user_passwordPlain"><?php echo _t('admin.user.password_form'); ?></label>
 			<div class="group-controls">
 				<div class="stick">
-					<input type="password" id="new_user_passwordPlain" name="new_user_passwordPlain" autocomplete="off" pattern=".{7,}" />
+					<input type="password" id="new_user_passwordPlain" name="new_user_passwordPlain" autocomplete="new-password" pattern=".{7,}" />
 					<a class="btn toggle-password" data-toggle="new_user_passwordPlain"><?php echo _i('key'); ?></a>
 				</div>
 				<?php echo _i('help'); ?> <?php echo _t('admin.user.password_format'); ?>

+ 3 - 0
cli/.htaccess

@@ -0,0 +1,3 @@
+Order	Allow,Deny
+Deny	from all
+Satisfy	all

+ 58 - 0
cli/README.md

@@ -0,0 +1,58 @@
+* Back to [main read-me](../README.md)
+
+# FreshRSS Command-Line Interface (CLI)
+
+## Note on access rights
+
+When using the command-line interface, remember that your user might not be the same as the one used by your Web server.
+This might create some access right problems.
+
+It is recommended to invoke commands using the same user as your Web server:
+
+```sh
+cd /usr/share/FreshRSS
+sudo -u www-data sh -c './cli/list-users.php'
+```
+
+In any case, when you are done with a series of commands, you should re-apply the access rights:
+
+```sh
+cd /usr/share/FreshRSS
+sudo chown -R :www-data .
+sudo chmod -R g+r .
+sudo chmod -R g+w ./data/
+```
+
+
+## Commands
+
+Options in parenthesis are optional.
+
+
+```sh
+cd /usr/share/FreshRSS
+
+./cli/do-install.php --default_user admin --auth_type form  ( --environment production --base_url https://rss.example.net/ --title FreshRSS --allow_anonymous --api_enabled --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123 --db-base freshrss --db-prefix freshrss )
+# --auth_type can be: 'form' (recommended), 'http_auth' (using the Web server access control), 'none' (dangerous)
+# --db-type can be: 'sqlite' (default), 'mysql' (MySQL or MariaDB), 'pgsql' (PostgreSQL)
+# --environment can be: 'production' (default), 'development' (for additional log messages)
+# --db-prefix is an optional prefix in front of the names of the tables
+# This command does not create the default user. Do that with ./cli/create-user.php
+
+./cli/create-user.php --user username ( --password 'password' --api-password 'api_password' --language en --email user@example.net --token 'longRandomString' --no-default-feeds )
+# --language can be: 'en' (default), 'fr', or one of the [supported languages](../app/i18n/)
+
+./cli/delete-user.php --user username
+
+./cli/list-users.php
+# Return a list of users, with the default/admin user first
+
+./cli/actualize-user.php --user username
+
+./cli/import-for-user.php --user username --filename /path/to/file.ext
+# The extension of the file { .json, .opml, .xml, .zip } is used to detect the type of import
+
+./cli/export-opml-for-user.php --user username > /path/to/file.opml.xml
+
+./cli/export-zip-for-user.php --user username ( --max-feed-entries 100 ) > /path/to/file.zip
+```

+ 49 - 0
cli/_cli.php

@@ -0,0 +1,49 @@
+<?php
+if (php_sapi_name() !== 'cli') {
+	die('FreshRSS error: This PHP script may only be invoked from command line!');
+}
+
+require(dirname(__FILE__) . '/../constants.php');
+require(LIB_PATH . '/lib_rss.php');
+
+Minz_Configuration::register('system',
+	DATA_PATH . '/config.php',
+	DATA_PATH . '/config.default.php');
+FreshRSS_Context::$system_conf = Minz_Configuration::get('system');
+Minz_Translate::init('en');
+
+FreshRSS_Context::$isCli = true;
+
+function fail($message) {
+	fwrite(STDERR, $message . "\n");
+	die(1);
+}
+
+function cliInitUser($username) {
+	if (!ctype_alnum($username)) {
+		fail('FreshRSS error: invalid username: ' . $username . "\n");
+	}
+
+	$usernames = listUsers();
+	if (!in_array($username, $usernames)) {
+		fail('FreshRSS error: user not found: ' . $username . "\n");
+	}
+
+	FreshRSS_Context::$user_conf = get_user_configuration($username);
+	if (FreshRSS_Context::$user_conf == null) {
+		fail('FreshRSS error: invalid configuration for user: ' . $username . "\n");
+	}
+	new Minz_ModelPdo($username);
+
+	return $username;
+}
+
+function accessRights() {
+	echo '• Remember to re-apply the appropriate access rights, such as:' , "\n",
+		"\t", 'sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/', "\n";
+}
+
+function done($ok = true) {
+	fwrite(STDERR, 'Result: ' . ($ok ? 'success' : 'fail') . "\n");
+	exit($ok ? 0 : 1);
+}

+ 23 - 0
cli/actualize-user.php

@@ -0,0 +1,23 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username");
+}
+
+$username = cliInitUser($options['user']);
+
+fwrite(STDERR, 'FreshRSS actualizing user “' . $username . "”…\n");
+
+list($nbUpdatedFeeds, $feed) = FreshRSS_feed_Controller::actualizeFeed(0, '', true);
+
+echo "FreshRSS actualized $nbUpdatedFeeds feeds for $username\n";
+
+invalidateHttpCache($username);
+
+done($nbUpdatedFeeds > 0);

+ 48 - 0
cli/create-user.php

@@ -0,0 +1,48 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+		'password:',
+		'api-password:',
+		'language:',
+		'email:',
+		'token:',
+		'no-default-feeds',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username ( --password 'password' --api-password 'api_password'" .
+		" --language en --email user@example.net --token 'longRandomString --no-default-feeds' )");
+}
+$username = $options['user'];
+if (!ctype_alnum($username)) {
+	fail('FreshRSS error: invalid username “' . $username . '”');
+}
+
+$usernames = listUsers();
+if (preg_grep("/^$username$/i", $usernames)) {
+	fail('FreshRSS error: username already taken “' . $username . '”');
+}
+
+echo 'FreshRSS creating user “', $username, "”…\n";
+
+$ok = FreshRSS_user_Controller::createUser($username,
+	empty($options['password']) ? '' : $options['password'],
+	empty($options['api-password']) ? '' : $options['api-password'],
+	array(
+		'language' => empty($options['language']) ? '' : $options['language'],
+		'token' => empty($options['token']) ? '' : $options['token'],
+	),
+	!isset($options['no-default-feeds']));
+
+if (!$ok) {
+	fail('FreshRSS could not create user!');
+}
+
+invalidateHttpCache(FreshRSS_Context::$system_conf->default_user);
+
+accessRights();
+
+done($ok);

+ 32 - 0
cli/delete-user.php

@@ -0,0 +1,32 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username");
+}
+$username = $options['user'];
+if (!ctype_alnum($username)) {
+	fail('FreshRSS error: invalid username “' . $username . '”');
+}
+
+$usernames = listUsers();
+if (!preg_grep("/^$username$/i", $usernames)) {
+	fail('FreshRSS error: username not found “' . $username . '”');
+}
+
+if (strcasecmp($username, FreshRSS_Context::$system_conf->default_user) === 0) {
+	fail('FreshRSS error: default user must not be deleted: “' . $username . '”');
+}
+
+echo 'FreshRSS deleting user “', $username, "”…\n";
+
+$ok = FreshRSS_user_Controller::deleteUser($username);
+
+invalidateHttpCache(FreshRSS_Context::$system_conf->default_user);
+
+done($ok);

+ 101 - 0
cli/do-install.php

@@ -0,0 +1,101 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+require(LIB_PATH . '/lib_install.php');
+
+$params = array(
+		'environment:',
+		'base_url:',
+		'title:',
+		'default_user:',
+		'allow_anonymous',
+		'allow_anonymous_refresh',
+		'auth_type:',
+		'api_enabled',
+		'allow_robots',
+	);
+
+$dBparams = array(
+		'db-type:',
+		'db-host:',
+		'db-user:',
+		'db-password:',
+		'db-base:',
+		'db-prefix:',
+	);
+
+$options = getopt('', array_merge($params, $dBparams));
+
+if (empty($options['default_user']) || empty($options['auth_type'])) {
+	fail('Usage: ' . basename(__FILE__) . " --default_user admin --auth_type form" .
+		" ( --environment production --base_url https://rss.example.net/" .
+		" --title FreshRSS --allow_anonymous --api_enabled" .
+		" --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123" .
+		" --db-base freshrss --db-prefix freshrss )");
+}
+
+fwrite(STDERR, 'FreshRSS install…' . "\n");
+
+$requirements = checkRequirements();
+if ($requirements['all'] !== 'ok') {
+	$message = 'FreshRSS install failed requirements:' . "\n";
+	foreach ($requirements as $requirement => $check) {
+		if ($check !== 'ok' && $requirement !== 'all') {
+			$message .= '• ' . $requirement . "\n";
+		}
+	}
+	fail($message);
+}
+
+if (!ctype_alnum($options['default_user'])) {
+	fail('FreshRSS invalid default username (must be ASCII alphanumeric): ' . $options['default_user']);
+}
+
+if (!in_array($options['auth_type'], array('form', 'http_auth', 'none'))) {
+	fail('FreshRSS invalid authentication method (auth_type must be one of { form, http_auth, none }: ' . $options['auth_type']);
+}
+
+$config = array(
+		'salt' => generateSalt(),
+		'db' => FreshRSS_Context::$system_conf->db,
+	);
+
+foreach ($params as $param) {
+	$param = rtrim($param, ':');
+	if (isset($options[$param])) {
+		$config[$param] = $options[$param] === false ? true : $options[$param];
+	}
+}
+
+if ((!empty($config['base_url'])) && server_is_public($config['base_url'])) {
+	$config['pubsubhubbub_enabled'] = true;
+}
+
+foreach ($dBparams as $dBparam) {
+	$dBparam = rtrim($dBparam, ':');
+	if (!empty($options[$dBparam])) {
+		$param = substr($dBparam, strlen('db-'));
+		$config['db'][$param] = $options[$dBparam];
+	}
+}
+
+if (file_put_contents(join_path(DATA_PATH, 'config.php'), "<?php\n return " . var_export($config, true) . ";\n") === false) {
+	fail('FreshRSS could not write configuration file!: ' . join_path(DATA_PATH, 'config.php'));
+}
+
+$config['db']['default_user'] = $config['default_user'];
+if (!checkDb($config['db'])) {
+	@unlink(join_path(DATA_PATH, 'config.php'));
+	fail('FreshRSS database error: ' . (empty($config['db']['bd_error']) ? 'Unknown error' : $config['db']['bd_error']));
+}
+
+echo '• Remember to create the default user: ', $config['default_user'] , "\n",
+	"\t", './cli/create-user.php --user ', $config['default_user'] , " --password 'password' --more-options\n";
+
+accessRights();
+
+if (!deleteInstall()) {
+	fail('FreshRSS access right problem while deleting install file!');
+}
+
+done();

+ 24 - 0
cli/export-opml-for-user.php

@@ -0,0 +1,24 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username > /path/to/file.opml.xml");
+}
+
+$username = cliInitUser($options['user']);
+
+fwrite(STDERR, 'FreshRSS exporting OPML for user “' . $username . "”…\n");
+
+$importController = new FreshRSS_importExport_Controller();
+
+$ok = false;
+$ok = $importController->exportFile(true, false, array(), 0, $username);
+
+invalidateHttpCache($username);
+
+done($ok);

+ 30 - 0
cli/export-zip-for-user.php

@@ -0,0 +1,30 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+		'max-feed-entries:',
+	));
+
+if (empty($options['user'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username ( --max-feed-entries 100 ) > /path/to/file.zip");
+}
+
+$username = cliInitUser($options['user']);
+
+fwrite(STDERR, 'FreshRSS exporting ZIP for user “' . $username . "”…\n");
+
+$importController = new FreshRSS_importExport_Controller();
+
+$ok = false;
+try {
+	$ok = $importController->exportFile(true, true, true,
+		empty($options['max-feed-entries']) ? 100 : intval($options['max-feed-entries']),
+		$username);
+} catch (FreshRSS_ZipMissing_Exception $zme) {
+	fail('FreshRSS error: Lacking php-zip extension!');
+}
+invalidateHttpCache($username);
+
+done($ok);

+ 35 - 0
cli/import-for-user.php

@@ -0,0 +1,35 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$options = getopt('', array(
+		'user:',
+		'filename:',
+	));
+
+if (empty($options['user']) || empty($options['filename'])) {
+	fail('Usage: ' . basename(__FILE__) . " --user username --filename /path/to/file.ext");
+}
+
+$username = cliInitUser($options['user']);
+
+$filename = $options['filename'];
+if (!is_readable($filename)) {
+	fail('FreshRSS error: file is not readable “' . $filename . '”');
+}
+
+echo 'FreshRSS importing ZIP/OPML/JSON for user “', $username, "”…\n";
+
+$importController = new FreshRSS_importExport_Controller();
+
+$ok = false;
+try {
+	$ok = $importController->importFile($filename, $filename, $username);
+} catch (FreshRSS_ZipMissing_Exception $zme) {
+	fail('FreshRSS error: Lacking php-zip extension!');
+} catch (FreshRSS_Zip_Exception $ze) {
+	fail('FreshRSS error: ZIP archive cannot be imported! Error code: ' . $ze->zipErrorCode());
+}
+invalidateHttpCache($username);
+
+done($ok);

+ 0 - 0
data/persona/index.html → cli/index.html


+ 14 - 0
cli/list-users.php

@@ -0,0 +1,14 @@
+#!/usr/bin/php
+<?php
+require('_cli.php');
+
+$users = listUsers();
+sort($users);
+if (FreshRSS_Context::$system_conf->default_user !== '') {
+	array_unshift($users, FreshRSS_Context::$system_conf->default_user);
+	$users = array_unique($users);
+}
+
+foreach ($users as $user) {
+	echo $user, "\n";
+}

+ 1 - 1
constants.php

@@ -1,5 +1,5 @@
 <?php
-define('FRESHRSS_VERSION', '1.5.0');
+define('FRESHRSS_VERSION', '1.6.0');
 define('FRESHRSS_WEBSITE', 'http://freshrss.org');
 define('FRESHRSS_WIKI', 'http://doc.freshrss.org');
 

+ 3 - 7
data/.gitignore

@@ -1,10 +1,6 @@
-application.ini
 config.php
-*.sqlite
-touch.txt
-no-cache.txt
-*.bak.php
-*.lock.txt
+config.php.bak.php
+force-https.txt
 last_update.txt
+no-cache.txt
 update.php
-force-https.txt

+ 3 - 1
data/PubSubHubbub/feeds/.gitignore

@@ -1 +1,3 @@
-*/*
+*/
+*/*.json
+*/*.txt

+ 3 - 0
data/config.default.php

@@ -27,6 +27,9 @@ return array(
 	# Title of this FreshRSS instance in the Web user interface.
 	'title' => 'FreshRSS',
 
+	# Meta description used when `allow_robots` is true.
+	'meta_description' => '',
+
 	# Name of the user that has administration rights.
 	'default_user' => '_',
 

Some files were not shown because too many files changed in this diff