Ver Fonte

Merge pull request #2298 from FreshRSS/dev

FreshRSS 1.14.0
Alexandre Alapetite há 7 anos atrás
pai
commit
d413f67dd2
100 ficheiros alterados com 2773 adições e 1404 exclusões
  1. 2 0
      .gitignore
  2. 0 1
      .travis.yml
  3. 41 0
      CHANGELOG.md
  4. 2 0
      CREDITS.md
  5. 24 20
      Docker/Dockerfile
  6. 34 0
      Docker/Dockerfile-Alpine
  7. 1 0
      Docker/FreshRSS.Apache.conf
  8. 44 24
      Docker/README.md
  9. 2 1
      README.fr.md
  10. 2 1
      README.md
  11. 6 3
      app/Controllers/authController.php
  12. 7 6
      app/Controllers/entryController.php
  13. 15 24
      app/Controllers/feedController.php
  14. 1 1
      app/Controllers/importExportController.php
  15. 1 0
      app/Controllers/indexController.php
  16. 1 0
      app/Controllers/statsController.php
  17. 4 3
      app/Controllers/subscriptionController.php
  18. 2 3
      app/Controllers/userController.php
  19. 24 15
      app/FreshRSS.php
  20. 5 5
      app/Models/Auth.php
  21. 17 25
      app/Models/Context.php
  22. 113 0
      app/Models/Entry.php
  23. 2 2
      app/Models/EntryDAO.php
  24. 3 1
      app/Models/EntryDAOPGSQL.php
  25. 104 0
      app/Models/Feed.php
  26. 45 0
      app/Models/FilterAction.php
  27. 30 18
      app/Models/TagDAO.php
  28. 4 0
      app/i18n/cz/sub.php
  29. 4 0
      app/i18n/de/sub.php
  30. 3 6
      app/i18n/en/conf.php
  31. 4 0
      app/i18n/en/sub.php
  32. 4 0
      app/i18n/es/sub.php
  33. 5 5
      app/i18n/fr/conf.php
  34. 4 0
      app/i18n/fr/sub.php
  35. 4 0
      app/i18n/he/sub.php
  36. 4 0
      app/i18n/it/sub.php
  37. 4 4
      app/i18n/kr/admin.php
  38. 3 3
      app/i18n/kr/conf.php
  39. 2 2
      app/i18n/kr/feedback.php
  40. 6 6
      app/i18n/kr/gen.php
  41. 3 3
      app/i18n/kr/index.php
  42. 2 2
      app/i18n/kr/install.php
  43. 9 5
      app/i18n/kr/sub.php
  44. 4 0
      app/i18n/nl/sub.php
  45. 1 1
      app/i18n/oc/admin.php
  46. 2 2
      app/i18n/oc/gen.php
  47. 1 1
      app/i18n/oc/index.php
  48. 4 0
      app/i18n/oc/sub.php
  49. 4 0
      app/i18n/pt-br/sub.php
  50. 1 1
      app/i18n/ru/conf.php
  51. 4 0
      app/i18n/ru/sub.php
  52. 1 1
      app/i18n/tr/conf.php
  53. 4 0
      app/i18n/tr/sub.php
  54. 10 10
      app/i18n/zh-cn/admin.php
  55. 13 13
      app/i18n/zh-cn/conf.php
  56. 5 5
      app/i18n/zh-cn/feedback.php
  57. 1 1
      app/i18n/zh-cn/gen.php
  58. 3 3
      app/i18n/zh-cn/index.php
  59. 2 2
      app/i18n/zh-cn/install.php
  60. 11 7
      app/i18n/zh-cn/sub.php
  61. 3 3
      app/layout/aside_feed.phtml
  62. 0 10
      app/views/entry/read.phtml
  63. 13 0
      app/views/helpers/feed/update.phtml
  64. 1 1
      app/views/helpers/index/normal/entry_bottom.phtml
  65. 3 2
      app/views/helpers/javascript_vars.phtml
  66. 1 1
      app/views/index/reader.phtml
  67. 1 1
      app/views/user/manage.phtml
  68. 25 0
      cli/_cli.php
  69. 1 1
      cli/_update-or-create-user.php
  70. 6 4
      cli/actualize-user.php
  71. 6 4
      cli/db-optimize.php
  72. 6 4
      cli/delete-user.php
  73. 3 3
      cli/do-install.php
  74. 6 4
      cli/export-opml-for-user.php
  75. 7 5
      cli/export-zip-for-user.php
  76. 16 0
      cli/i18n/ignore/kr.php
  77. 4 0
      cli/i18n/ignore/zh-cn.php
  78. 7 5
      cli/import-for-user.php
  79. 2 0
      cli/list-users.php
  80. 8 0
      cli/reconfigure.php
  81. 50 29
      cli/user-info.php
  82. 2 2
      constants.php
  83. 3 3
      docs/en/users/06_Fever_API.md
  84. 3 0
      docs/fr/users/06_Fever_API.md
  85. 25 0
      lib/Minz/Request.php
  86. 2 7
      lib/lib_rss.php
  87. 1 1
      p/api/.htaccess
  88. 7 7
      p/api/fever.php
  89. 99 79
      p/scripts/category.js
  90. 241 0
      p/scripts/extra.js
  91. 74 54
      p/scripts/global_view.js
  92. 810 707
      p/scripts/main.js
  93. 2 2
      p/scripts/repartition.js
  94. 0 225
      p/scripts/shortcut.js
  95. 4 4
      p/scripts/stats.js
  96. 352 0
      p/themes/Ansum/_components.scss
  97. 90 0
      p/themes/Ansum/_configuration.scss
  98. 12 0
      p/themes/Ansum/_divers.scss
  99. 56 0
      p/themes/Ansum/_fonts.scss
  100. 153 0
      p/themes/Ansum/_forms.scss

+ 2 - 0
.gitignore

@@ -13,3 +13,5 @@ constants.local.php
 *.save
 *.autosav
 *.autosave
+*.sass-cache
+sass.sh

+ 0 - 1
.travis.yml

@@ -6,7 +6,6 @@ php:
   - '7.0'
   - '7.1'
   - '7.2'
-  - hhvm
 
 install:
   # newest version without https://github.com/squizlabs/PHP_CodeSniffer/pull/1404

+ 41 - 0
CHANGELOG.md

@@ -1,5 +1,46 @@
 # FreshRSS changelog
 
+## 2019-03-31 FreshRSS 1.14.0
+
+* Features
+	* *Filter actions* feature, to auto-mark-as-read based on a search query per feed [#2275](https://github.com/FreshRSS/FreshRSS/pull/2275)
+	* Improve account change when using the *unsafe automatic login* [#2288](https://github.com/FreshRSS/FreshRSS/issues/2288)
+* UI
+	* New themes *Ansum* and *Mapco* [#2245](https://github.com/FreshRSS/FreshRSS/pull/2245)
+	* Rewrite jQuery and keyboard shortcut code as native JavaScript ES6 (except for graphs on the statistics pages) [#2234](https://github.com/FreshRSS/FreshRSS/pull/2234)
+	* Batch scroll-as-read for better client-side and server-side performance [#2199](https://github.com/FreshRSS/FreshRSS/pull/2199)
+	* Keyboard-shortcut navigation at end of feed or category continues to the next one [#2255](https://github.com/FreshRSS/FreshRSS/pull/2255)
+	* Changed jump behaviour after marking articles as read [#2206](https://github.com/FreshRSS/FreshRSS/issues/2206)
+	* More reactive auto-loading of articles [#2268](https://github.com/FreshRSS/FreshRSS/pull/2268)
+* Deployment
+	* New default Docker image based on Ubuntu (~3 times faster, but ~2.5 times larger) [#2205](https://github.com/FreshRSS/FreshRSS/pull/2205)
+		* Using Ubuntu 18.10 with PHP 7.2.15 and Apache 2.4.34
+	* Alpine version updated to Alpine 3.9 with PHP 7.2.14 and Apache 2.4.38 [#2238](https://github.com/FreshRSS/FreshRSS/pull/2238)
+* Bug fixing
+	* Fix feed option for marking modified articles as unread [#2200](https://github.com/FreshRSS/FreshRSS/issues/2200)
+	* Fix API HTTP Authorization case-sensitivity issue introduced in FreshRSS 1.13.1 [#2233](https://github.com/FreshRSS/FreshRSS/issues/2233)
+	* Fix breaking warning in Fever API [#2239](https://github.com/FreshRSS/FreshRSS/issues/2239)
+	* Fix encoding problem in Fever API [#2241](https://github.com/FreshRSS/FreshRSS/issues/2241)
+	* Fix author semi-colon prefix in Fever API [#2281](https://github.com/FreshRSS/FreshRSS/issues/2281)
+	* Fix the reading of the environment variable `COPY_SYSLOG_TO_STDERR` [#2260](https://github.com/FreshRSS/FreshRSS/pull/2260)
+	* Session fix when form login + HTTP auth are used [#2286](https://github.com/FreshRSS/FreshRSS/pull/2286)
+	* Fix `cli/user-info.php` for accounts using a version of the database older than 1.12.0 [#2291](https://github.com/FreshRSS/FreshRSS/issues/2291)
+* CLI
+	* Better validation of parameters [#2046](https://github.com/FreshRSS/FreshRSS/issues/2046)
+	* New option `--header` to `cli/user-info.php` [#2296](https://github.com/FreshRSS/FreshRSS/pull/2296)
+* API
+	* Supported by [Readably](https://play.google.com/store/apps/details?id=com.isaiasmatewos.readably) (client for Android using Fever API)
+* I18n
+	* Improved Korean [#2242](https://github.com/FreshRSS/FreshRSS/pull/2242)
+	* Improve Occitan [#2253](https://github.com/FreshRSS/FreshRSS/pull/2253)
+* Security
+	* Reworked the CSRF token interaction with the session in some edge cases [#2290](https://github.com/FreshRSS/FreshRSS/pull/2290)
+	* Remove deprecated CSP `child-src` instruction (was already replaced by `frame-src`) [#2250](https://github.com/FreshRSS/FreshRSS/pull/2250)
+	* Ensure entry IDs are unique and cannot be set by feeds [#2273](https://github.com/FreshRSS/FreshRSS/issues/2273)
+* Misc.
+	* Remove HHMV from Travis continuous integration [#2249](https://github.com/FreshRSS/FreshRSS/pull/2249)
+
+
 ## 2019-01-26 FreshRSS 1.13.1
 
 * Features

+ 2 - 0
CREDITS.md

@@ -13,6 +13,7 @@ People are sorted by name so please keep this order.
 * [Amaury Carrade](https://github.com/AmauryCarrade): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=AmauryCarrade), [Web](https://amaury.carrade.eu/)
 * [Anton Smirnov](https://github.com/sandfoxme): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:sandfoxme), [Web](http://sandfox.me/)
 * [ASMfreaK](https://github.com/ASMfreaK): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=ASMfreaK)
+* [Benjamin Bouvier](https://github.com/bnjbvr): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:bnjbvr), [Web](https://benj.me/)
 * [chemical1979](https://github.com/chemical1979): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=chemical1979)
 * [Craig Andrews](https://github.com/candrews): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:candrews), [Web](http://candrews.integralblue.com/)
 * [Crupuk](https://github.com/Crupuk): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Crupuk)
@@ -64,6 +65,7 @@ People are sorted by name so please keep this order.
 * [Thomas Citharel](https://github.com/tcitworld): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:tomgue), [Web](https://www.tcit.fr/)
 * [thomas-gt](https://github.com/thomas-gt): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:thomas-gt)
 * [tomgue](https://github.com/tomgue): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=tomgue)
+* [Thomas Guesnon](https://github.com/patjennings): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:patjennings), [Web](http://www.thomasguesnon.fr/)
 * [Twilek-de](https://github.com/Twilek-de): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Twilek-de)
 * [Uncovery](https://github.com/uncovery): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:uncovery)
 * [Wanabo](https://github.com/Wanabo): [contributions](https://github.com/FreshRSS/FreshRSS/commits?author=Wanabo)

+ 24 - 20
Docker/Dockerfile

@@ -1,28 +1,31 @@
-FROM alpine:3.8
+FROM ubuntu:18.10
 
 ENV TZ UTC
+RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
 
-RUN apk add --no-cache \
-	apache2 php7-apache2 \
-	php7 php7-curl php7-gmp php7-intl php7-mbstring php7-xml php7-zip \
-	php7-ctype php7-dom php7-fileinfo php7-iconv php7-json php7-session php7-simplexml php7-xmlreader php7-zlib \
-	php7-pdo_sqlite php7-pdo_mysql php7-pdo_pgsql
+RUN apt update && \
+	apt install --no-install-recommends -y \
+	ca-certificates cron \
+	apache2 libapache2-mod-php \
+	php-curl php-intl php-mbstring php-xml php-zip \
+	php-sqlite3 php-mysql php-pgsql && \
+	rm -rf /var/lib/apt/lists/
 
-RUN mkdir -p /var/www/FreshRSS /run/apache2/
+RUN mkdir -p /var/www/FreshRSS /run/apache2/ /run/php/
 WORKDIR /var/www/FreshRSS
 
 COPY . /var/www/FreshRSS
-COPY ./Docker/*.Apache.conf /etc/apache2/conf.d/
-
-RUN rm -f /etc/apache2/conf.d/languages.conf /etc/apache2/conf.d/info.conf \
-		/etc/apache2/conf.d/status.conf /etc/apache2/conf.d/userdir.conf && \
-	sed -r -i "/^\s*LoadModule .*mod_(alias|autoindex|negotiation|status).so$/s/^/#/" \ 
-		/etc/apache2/httpd.conf && \
-	sed -r -i "/^\s*#\s*LoadModule .*mod_(deflate|expires|headers|mime|setenvif).so$/s/^\s*#//" \ 
-		/etc/apache2/httpd.conf && \
-	sed -r -i "/^\s*(CustomLog|ErrorLog|Listen) /s/^/#/" \
-		/etc/apache2/httpd.conf && \
-	echo "17,37 * * * * su apache -s /bin/sh -c 'php /var/www/FreshRSS/app/actualize_script.php' 2>> /proc/1/fd/2 > /tmp/FreshRSS.log" >> \
+COPY ./Docker/*.Apache.conf /etc/apache2/sites-available/
+
+RUN a2dismod -f alias autoindex negotiation status && \
+	a2enmod deflate expires headers mime setenvif && \
+	a2disconf '*' && \
+	a2dissite '*' && \
+	a2ensite 'FreshRSS*'
+
+RUN sed -r -i "/^\s*(CustomLog|ErrorLog|Listen) /s/^/#/" /etc/apache2/apache2.conf && \
+	sed -r -i "/^\s*Listen /s/^/#/" /etc/apache2/ports.conf && \
+	echo "17,37 su apache -s /bin/sh -c 'php /var/www/FreshRSS/app/actualize_script.php' 2>> /proc/1/fd/2 > /tmp/FreshRSS.log" >> \
 		/var/spool/cron/crontabs/root
 
 ENV COPY_SYSLOG_TO_STDERR On
@@ -30,5 +33,6 @@ ENV CRON_MIN ''
 ENTRYPOINT ["./Docker/entrypoint.sh"]
 
 EXPOSE 80
-CMD ([ -z "$CRON_MIN" ] || crond -d 6) && \
-	exec httpd -D FOREGROUND
+CMD ([ -z "$CRON_MIN" ] || cron) && \
+	. /etc/apache2/envvars && \
+	exec apache2 -D FOREGROUND

+ 34 - 0
Docker/Dockerfile-Alpine

@@ -0,0 +1,34 @@
+FROM alpine:3.9
+
+ENV TZ UTC
+
+RUN apk add --no-cache \
+	apache2 php7-apache2 \
+	php7 php7-curl php7-gmp php7-intl php7-mbstring php7-xml php7-zip \
+	php7-ctype php7-dom php7-fileinfo php7-iconv php7-json php7-session php7-simplexml php7-xmlreader php7-zlib \
+	php7-pdo_sqlite php7-pdo_mysql php7-pdo_pgsql
+
+RUN mkdir -p /var/www/FreshRSS /run/apache2/
+WORKDIR /var/www/FreshRSS
+
+COPY . /var/www/FreshRSS
+COPY ./Docker/*.Apache.conf /etc/apache2/conf.d/
+
+RUN rm -f /etc/apache2/conf.d/languages.conf /etc/apache2/conf.d/info.conf \
+		/etc/apache2/conf.d/status.conf /etc/apache2/conf.d/userdir.conf && \
+	sed -r -i "/^\s*LoadModule .*mod_(alias|autoindex|negotiation|status).so$/s/^/#/" \ 
+		/etc/apache2/httpd.conf && \
+	sed -r -i "/^\s*#\s*LoadModule .*mod_(deflate|expires|headers|mime|setenvif).so$/s/^\s*#//" \ 
+		/etc/apache2/httpd.conf && \
+	sed -r -i "/^\s*(CustomLog|ErrorLog|Listen) /s/^/#/" \
+		/etc/apache2/httpd.conf && \
+	echo "17,37 * * * * su apache -s /bin/sh -c 'php /var/www/FreshRSS/app/actualize_script.php' 2>> /proc/1/fd/2 > /tmp/FreshRSS.log" >> \
+		/var/spool/cron/crontabs/root
+
+ENV COPY_SYSLOG_TO_STDERR On
+ENV CRON_MIN ''
+ENTRYPOINT ["./Docker/entrypoint.sh"]
+
+EXPOSE 80
+CMD ([ -z "$CRON_MIN" ] || crond -d 6) && \
+	exec httpd -D FOREGROUND

+ 1 - 0
Docker/FreshRSS.Apache.conf

@@ -4,6 +4,7 @@ DocumentRoot /var/www/FreshRSS/p/
 CustomLog /dev/stdout combined
 ErrorLog /dev/stderr
 AllowEncodedSlashes On
+ServerTokens OS
 
 <Directory />
 	AllowOverride None

+ 44 - 24
Docker/README.md

@@ -10,21 +10,6 @@ sh get-docker.sh
 ```
 
 
-## Optional: Build Docker image of FreshRSS
-Optional, as a *less recent* online image can be automatically fetched during the next step (run),
-but online images are not available for as many platforms (e.g. Raspberry Pi / ARM) as if you build yourself.
-
-```sh
-# First time only
-git clone https://github.com/FreshRSS/FreshRSS.git
-
-cd ./FreshRSS/
-git pull
-sudo docker pull alpine:3.8
-sudo docker build --tag freshrss/freshrss -f Docker/Dockerfile .
-```
-
-
 ## Create an isolated network
 ```sh
 sudo docker network create freshrss-network
@@ -36,10 +21,12 @@ Here is the recommended configuration using automatic [Let’s Encrypt](https://
 
 ```sh
 sudo docker volume create traefik-letsencrypt
+sudo docker volume create traefik-tmp
 
 # Just change your e-mail address in the command below:
 sudo docker run -d --restart unless-stopped --log-opt max-size=10m \
   -v traefik-letsencrypt:/etc/traefik/acme \
+  -v traefik-tmp:/tmp \
   -v /var/run/docker.sock:/var/run/docker.sock:ro \
   --net freshrss-network \
   -p 80:80 \
@@ -59,6 +46,8 @@ See [more information about Docker and Let’s Encrypt in Træfik](https://docs.
 Example using the built-in refresh cron job (see further below for alternatives).
 You must first chose a domain (DNS) or sub-domain, e.g. `freshrss.example.net`.
 
+> **N.B.:** For platforms other than x64 (Intel, AMD), such as ARM (e.g. Raspberry Pi), see the section *Build Docker image* further below.
+
 ```sh
 sudo docker volume create freshrss-data
 
@@ -121,15 +110,6 @@ Browse to your server https://freshrss.example.net/ to complete the installation
 or use the command line described below.
 
 
-## Command line
-
-```sh
-sudo docker exec --user apache -it freshrss php ./cli/list-users.php
-```
-
-See the [CLI documentation](../cli/) for all the other commands.
-
-
 ## How to update
 
 ```sh
@@ -145,6 +125,42 @@ sudo docker rm freshrss_old
 ```
 
 
+## [Docker tags](https://hub.docker.com/r/freshrss/freshrss/tags)
+The tags correspond to FreshRSS branches and versions:
+* `:latest` (default) is the `master` branch, more stable
+* `:dev` is the `dev` branch, rolling release
+* `:x.y.z` are specific FreshRSS releases
+
+### Linux: Ubuntu vs. Alpine
+Our default image is based on [Ubuntu](https://www.ubuntu.com/server). We offer an alternative based on [Alpine](https://alpinelinux.org/) (with the `-alpine` tag suffix).
+In [our tests](https://github.com/FreshRSS/FreshRSS/pull/2205), Ubuntu is ~3 times faster,
+while Alpine is ~2.5 times [smaller on disk](https://hub.docker.com/r/freshrss/freshrss/tags) (and much faster to build).
+
+
+## Optional: Build Docker image of FreshRSS
+Building your own Docker image is optional because online images can be fetched automatically.
+Note that prebuilt images are less recent and only available for x64 (Intel, AMD) platforms.
+
+```sh
+# First time only
+git clone https://github.com/FreshRSS/FreshRSS.git
+
+cd ./FreshRSS/
+git pull
+sudo docker pull ubuntu:18.10
+sudo docker build --tag freshrss/freshrss -f Docker/Dockerfile .
+```
+
+
+## Command line
+
+```sh
+sudo docker exec --user apache -it freshrss php ./cli/list-users.php
+```
+
+See the [CLI documentation](../cli/) for all the other commands.
+
+
 ## Debugging
 
 ```sh
@@ -289,6 +305,10 @@ server {
 		proxy_set_header X-Forwarded-Proto $scheme;
 		proxy_set_header X-Forwarded-Port $server_port;
 		proxy_read_timeout 90;
+
+		# Forward the Authorization header for the Google Reader API.
+		proxy_set_header Authorization $http_authorization;
+		proxy_pass_header Authorization;
 	}
 }
 ```

+ 2 - 1
README.fr.md

@@ -196,6 +196,8 @@ Voir notre [documentation sur l’API Fever](https://freshrss.github.io/FreshRSS
 
 Tout client supportant une API de type Fever ; Sélection :
 
+* Android
+	* [Readably](https://play.google.com/store/apps/details?id=com.isaiasmatewos.readably) (Propriétaire)
 * iOS
 	* [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303) (Propriétaire)
 	* [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153) (Propriétaire)
@@ -210,7 +212,6 @@ Tout client supportant une API de type Fever ; Sélection :
 * [php-http-304](https://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [jQuery](https://jquery.com/)
 * [lib_opml](https://github.com/marienfressinaud/lib_opml)
-* [keyboard_shortcuts](http://www.openjs.com/scripts/events/keyboard_shortcuts/)
 * [flotr2](http://www.humblesoftware.com/flotr2)
 
 ## Uniquement pour certaines options ou configurations

+ 2 - 1
README.md

@@ -196,6 +196,8 @@ See our [Fever API documentation](https://freshrss.github.io/FreshRSS/en/users/0
 
 Supported clients are:
 
+* Android
+	* [Readably](https://play.google.com/store/apps/details?id=com.isaiasmatewos.readably) (Closed source)
 * iOS
 	* [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303) (Closed source)
 	* [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153) (Closed source)
@@ -210,7 +212,6 @@ Supported clients are:
 * [php-http-304](https://alexandre.alapetite.fr/doc-alex/php-http-304/)
 * [jQuery](https://jquery.com/)
 * [lib_opml](https://github.com/marienfressinaud/lib_opml)
-* [keyboard_shortcuts](http://www.openjs.com/scripts/events/keyboard_shortcuts/)
 * [flotr2](http://www.humblesoftware.com/flotr2)
 
 ## Only for some options or configurations

+ 6 - 3
app/Controllers/authController.php

@@ -69,7 +69,7 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 	 * the user is already connected.
 	 */
 	public function loginAction() {
-		if (FreshRSS_Auth::hasAccess()) {
+		if (FreshRSS_Auth::hasAccess() && Minz_Request::param('u', '') == '') {
 			Minz_Request::forward(array('c' => 'index', 'a' => 'index'), true);
 		}
 
@@ -109,8 +109,7 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 	public function formLoginAction() {
 		invalidateHttpCache();
 
-		$file_mtime = @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js');
-		Minz_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . $file_mtime));
+		Minz_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')));
 
 		$conf = Minz_Configuration::get('system');
 		$limits = $conf->limits;
@@ -134,6 +133,7 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 				// Set session parameter to give access to the user.
 				Minz_Session::_param('currentUser', $username);
 				Minz_Session::_param('passwordHash', $conf->passwordHash);
+				Minz_Session::_param('csrf');
 				FreshRSS_Auth::giveAccess();
 
 				// Set cookie parameter if nedded.
@@ -162,6 +162,8 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 				return;
 			}
 
+			FreshRSS_FormAuth::deleteCookie();
+
 			$conf = get_user_configuration($username);
 			if ($conf == null) {
 				return;
@@ -177,6 +179,7 @@ class FreshRSS_auth_Controller extends Minz_ActionController {
 			if ($ok) {
 				Minz_Session::_param('currentUser', $username);
 				Minz_Session::_param('passwordHash', $s);
+				Minz_Session::_param('csrf');
 				FreshRSS_Auth::giveAccess();
 
 				Minz_Request::good(_t('feedback.auth.login.success'),

+ 7 - 6
app/Controllers/entryController.php

@@ -97,14 +97,15 @@ class FreshRSS_entry_Controller extends Minz_ActionController {
 				}
 			}
 		} else {
-			$entryDAO->markRead($id, $is_read);
-
+			$ids = is_array($id) ? $id : array($id);
+			$entryDAO->markRead($ids, $is_read);
 			$tagDAO = FreshRSS_Factory::createTagDao();
-			foreach ($tagDAO->getTagsForEntry($id) as $tag) {
-				if (!empty($tag['checked'])) {
-					$this->view->tags[] = $tag['id'];
-				}
+			$tagsForEntries = $tagDAO->getTagsForEntries($ids);
+			$tags = array();
+			foreach ($tagsForEntries as $line) {
+				$tags['t_' . $line['id_tag']][] = $line['id_entry'];
 			}
+			$this->view->tags = $tags;
 		}
 
 		if (!$this->ajax) {

+ 15 - 24
app/Controllers/feedController.php

@@ -289,7 +289,8 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 			}
 			$ttl = $feed->ttl();
 			if ((!$simplePiePush) && (!$feed_id) &&
-				($feed->lastUpdate() + 10 >= time() - ($ttl == FreshRSS_Feed::TTL_DEFAULT ? FreshRSS_Context::$user_conf->ttl_default : $ttl))) {
+				($feed->lastUpdate() + 10 >= time() - (
+					$ttl == FreshRSS_Feed::TTL_DEFAULT ? 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) {
@@ -347,8 +348,8 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 					$entry_date = $entry->date(true);
 					if (isset($existingHashForGuids[$entry->guid()])) {
 						$existingHash = $existingHashForGuids[$entry->guid()];
-						if (strcasecmp($existingHash, $entry->hash()) === 0 || trim($existingHash, '0') == '') {
-							//This entry already exists and is unchanged. TODO: Remove the test with the zero'ed hash in FreshRSS v1.3
+						if (strcasecmp($existingHash, $entry->hash()) === 0) {
+							//This entry already exists and is unchanged.
 							$oldGuids[] = $entry->guid();
 						} else {	//This entry already exists but has been updated
 							//Minz_Log::debug('Entry with GUID `' . $entry->guid() . '` updated in feed ' . $feed->url(false) .
@@ -357,7 +358,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 									$feed->attributes('mark_updated_article_unread')
 								) : FreshRSS_Context::$user_conf->mark_updated_article_unread;
 							$needFeedCacheRefresh = $mark_updated_article_unread;
-							$entry->_isRead(FreshRSS_Context::$user_conf->mark_updated_article_unread ? false : null);	//Change is_read according to policy.
+							$entry->_isRead($mark_updated_article_unread ? false : null);	//Change is_read according to policy.
 
 							$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
 							if ($entry === null) {
@@ -374,20 +375,13 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 						// This entry should not be added considering configuration and date.
 						$oldGuids[] = $entry->guid();
 					} else {
-						$read_upon_reception = $feed->attributes('read_upon_reception') !== null ? (
-								$feed->attributes('read_upon_reception')
-							) : FreshRSS_Context::$user_conf->mark_when['reception'];
-						if ($isNewFeed) {
-							$id = min(time(), $entry_date) . uSecString();
-							$entry->_isRead($read_upon_reception);
-						} elseif ($entry_date < $date_min) {
-							$id = min(time(), $entry_date) . uSecString();
+						$id = uTimeString();
+						$entry->_id($id);
+						if ($entry_date < $date_min) {
 							$entry->_isRead(true);	//Old article that was not in database. Probably an error, so mark as read
-						} else {
-							$id = uTimeString();
-							$entry->_isRead($read_upon_reception);
 						}
-						$entry->_id($id);
+
+						$entry->applyFilterActions();
 
 						$entry = Minz_ExtensionManager::callHook('entry_before_insert', $entry);
 						if ($entry === null) {
@@ -396,7 +390,8 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 						}
 
 						if ($pubSubHubbubEnabled && !$simplePiePush) {	//We use push, but have discovered an article by pull!
-							$text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' . $url . ' GUID ' . $entry->guid();
+							$text = 'An article was discovered by pull although we use PubSubHubbub!: Feed ' . $url .
+								' GUID ' . $entry->guid();
 							Minz_Log::warning($text, PSHB_LOG);
 							Minz_Log::warning($text);
 							$pubSubHubbubEnabled = false;
@@ -420,9 +415,7 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 					$entryDAO->beginTransaction();
 				}
 
-				$nb = $entryDAO->cleanOldEntries($feed->id(),
-				                                $date_min,
-				                                max($feed_history, count($entries) + 10));
+				$nb = $entryDAO->cleanOldEntries($feed->id(), $date_min, max($feed_history, count($entries) + 10));
 				if ($nb > 0) {
 					$needFeedCacheRefresh = true;
 					Minz_Log::debug($nb . ' old entries cleaned in feed [' . $feed->url(false) . ']');
@@ -602,11 +595,9 @@ class FreshRSS_feed_Controller extends Minz_ActionController {
 		if (self::moveFeed($feed_id, $cat_id)) {
 			// TODO: return something useful
 			// Log a notice to prevent "Empty IF statement" warning in PHP_CodeSniffer
-			Minz_Log::notice('Moved feed `' . $feed_id . '` ' .
-			                 'in the category `' . $cat_id . '`');;
+			Minz_Log::notice('Moved feed `' . $feed_id . '` in the category `' . $cat_id . '`');
 		} else {
-			Minz_Log::warning('Cannot move feed `' . $feed_id . '` ' .
-			                  'in the category `' . $cat_id . '`');
+			Minz_Log::warning('Cannot move feed `' . $feed_id . '` in the category `' . $cat_id . '`');
 			Minz_Error::error(404);
 		}
 	}

+ 1 - 1
app/Controllers/importExportController.php

@@ -585,7 +585,7 @@ class FreshRSS_importExport_Controller extends Minz_ActionController {
 				$feed_id, $item['id'], $item['title'], $author,
 				$content, $url, $published, $is_read, $is_starred
 			);
-			$entry->_id(min(time(), $entry->date(true)) . uSecString());
+			$entry->_id(uTimeString());
 			$entry->_tags($tags);
 
 			if (isset($newGuids[$entry->guid()])) {

+ 1 - 0
app/Controllers/indexController.php

@@ -104,6 +104,7 @@ class FreshRSS_index_Controller extends Minz_ActionController {
 			return;
 		}
 
+		Minz_View::appendScript(Minz_Url::display('/scripts/extra.js?' . @filemtime(PUBLIC_PATH . '/scripts/extra.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/global_view.js?' . @filemtime(PUBLIC_PATH . '/scripts/global_view.js')));
 
 		try {

+ 1 - 0
app/Controllers/statsController.php

@@ -52,6 +52,7 @@ class FreshRSS_stats_Controller extends Minz_ActionController {
 	 */
 	public function indexAction() {
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
+		Minz_View::prependScript(Minz_Url::display('/scripts/jquery.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/jquery.min.js')));
 		Minz_View::appendScript(Minz_Url::display('/scripts/flotr2.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/flotr2.min.js')));
 		$this->view->repartition = $statsDAO->calculateEntryRepartition();
 		$entryCount = $statsDAO->calculateEntryCount();

+ 4 - 3
app/Controllers/subscriptionController.php

@@ -29,8 +29,7 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 	 * It displays categories and associated feeds.
 	 */
 	public function indexAction() {
-		Minz_View::appendScript(Minz_Url::display('/scripts/category.js?' .
-		                        @filemtime(PUBLIC_PATH . '/scripts/category.js')));
+		Minz_View::appendScript(Minz_Url::display('/scripts/category.js?' . @filemtime(PUBLIC_PATH . '/scripts/category.js')));
 		Minz_View::prependTitle(_t('sub.title') . ' · ');
 
 		$this->view->onlyFeedsWithError = Minz_Request::paramTernary('error');
@@ -111,6 +110,8 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 				$feed->_attributes('timeout', null);
 			}
 
+			$feed->_filtersAction('read', preg_split('/[\n\r]+/', Minz_Request::param('filteractions_read', '')));
+
 			$values = array(
 				'name' => Minz_Request::param('name', ''),
 				'description' => sanitizeHTML(Minz_Request::param('description', '', true)),
@@ -122,7 +123,7 @@ class FreshRSS_subscription_Controller extends Minz_ActionController {
 				'httpAuth' => $httpAuth,
 				'keep_history' => intval(Minz_Request::param('keep_history', FreshRSS_Feed::KEEP_HISTORY_DEFAULT)),
 				'ttl' => $ttl * ($mute ? -1 : 1),
-				'attributes' => $feed->attributes()
+				'attributes' => $feed->attributes(),
 			);
 
 			invalidateHttpCache();

+ 2 - 3
app/Controllers/userController.php

@@ -128,9 +128,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 	public function profileAction() {
 		Minz_View::prependTitle(_t('conf.profile.title') . ' · ');
 
-		Minz_View::appendScript(Minz_Url::display(
-			'/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')
-		));
+		Minz_View::appendScript(Minz_Url::display('/scripts/bcrypt.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/bcrypt.min.js')));
 
 		if (Minz_Request::isPost()) {
 			$passwordPlain = Minz_Request::param('newPasswordPlain', '', true);
@@ -249,6 +247,7 @@ class FreshRSS_user_Controller extends Minz_ActionController {
 				$user_conf = get_user_configuration($new_user_name);
 				Minz_Session::_param('currentUser', $new_user_name);
 				Minz_Session::_param('passwordHash', $user_conf->passwordHash);
+				Minz_Session::_param('csrf');
 				FreshRSS_Auth::giveAccess();
 			}
 

+ 24 - 15
app/FreshRSS.php

@@ -57,18 +57,26 @@ class FreshRSS extends Minz_FrontController {
 
 	private static function initAuth() {
 		FreshRSS_Auth::init();
-		if (Minz_Request::isPost() && !(is_referer_from_same_domain() && FreshRSS_Auth::isCsrfOk())) {
-			// Basic protection against XSRF attacks
-			FreshRSS_Auth::removeAccess();
-			$http_referer = empty($_SERVER['HTTP_REFERER']) ? '' : $_SERVER['HTTP_REFERER'];
-			Minz_Translate::init('en');	//TODO: Better choice of fallback language
-			Minz_Error::error(
-				403,
-				array('error' => array(
-					_t('feedback.access.denied'),
-					' [HTTP_REFERER=' . htmlspecialchars($http_referer, ENT_NOQUOTES, 'UTF-8') . ']'
-				))
-			);
+		if (Minz_Request::isPost()) {
+			if (!is_referer_from_same_domain()) {
+				// Basic protection against XSRF attacks
+				FreshRSS_Auth::removeAccess();
+				$http_referer = empty($_SERVER['HTTP_REFERER']) ? '' : $_SERVER['HTTP_REFERER'];
+				Minz_Translate::init('en');	//TODO: Better choice of fallback language
+				Minz_Error::error(403, array('error' => array(
+						_t('feedback.access.denied'),
+						' [HTTP_REFERER=' . htmlspecialchars($http_referer, ENT_NOQUOTES, 'UTF-8') . ']'
+					)));
+			}
+			if ((!FreshRSS_Auth::isCsrfOk()) &&
+				(Minz_Request::controllerName() !== 'auth' || Minz_Request::actionName() !== 'login')) {
+				// Token-based protection against XSRF attacks, except for the login form itself
+				Minz_Translate::init('en');	//TODO: Better choice of fallback language
+				Minz_Error::error(403, array('error' => array(
+						_t('feedback.access.denied'),
+						' [CSRF]'
+					)));
+			}
 		}
 	}
 
@@ -94,9 +102,10 @@ class FreshRSS extends Minz_FrontController {
 			}
 		}
 		//Use prepend to insert before extensions. Added in reverse order.
+		if (Minz_Request::controllerName() !== 'index') {
+			Minz_View::prependScript(Minz_Url::display('/scripts/extra.js?' . @filemtime(PUBLIC_PATH . '/scripts/extra.js')));
+		}
 		Minz_View::prependScript(Minz_Url::display('/scripts/main.js?' . @filemtime(PUBLIC_PATH . '/scripts/main.js')));
-		Minz_View::prependScript(Minz_Url::display('/scripts/shortcut.js?' . @filemtime(PUBLIC_PATH . '/scripts/shortcut.js')));
-		Minz_View::prependScript(Minz_Url::display('/scripts/jquery.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/jquery.min.js')));
 	}
 
 	private static function loadNotifications() {
@@ -116,7 +125,7 @@ class FreshRSS extends Minz_FrontController {
 					}
 				}, FreshRSS_Context::$user_conf->sharing));
 				$connectSrc = count($urlToAuthorize) ? sprintf("; connect-src 'self' %s", implode(' ', $urlToAuthorize)) : '';
-				header(sprintf("Content-Security-Policy: default-src 'self'; child-src *; frame-src *; img-src * data:; media-src *%s", $connectSrc));
+				header(sprintf("Content-Security-Policy: default-src 'self'; frame-src *; img-src * data:; media-src *%s", $connectSrc));
 				break;
 			case 'stats':
 				header("Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'");

+ 5 - 5
app/Models/Auth.php

@@ -13,7 +13,7 @@ class FreshRSS_Auth {
 	 * This method initializes authentication system.
 	 */
 	public static function init() {
-		if (Minz_Session::param('REMOTE_USER', '') !== httpAuthUser()) {
+		if (isset($_SESSION['REMOTE_USER']) && $_SESSION['REMOTE_USER'] !== httpAuthUser()) {
 			//HTTP REMOTE_USER has changed
 			self::removeAccess();
 		}
@@ -24,6 +24,7 @@ class FreshRSS_Auth {
 			$conf = Minz_Configuration::get('system');
 			$current_user = $conf->default_user;
 			Minz_Session::_param('currentUser', $current_user);
+			Minz_Session::_param('csrf');
 		}
 
 		if (self::$login_ok) {
@@ -56,6 +57,7 @@ class FreshRSS_Auth {
 				$current_user = trim($credentials[0]);
 				Minz_Session::_param('currentUser', $current_user);
 				Minz_Session::_param('passwordHash', trim($credentials[1]));
+				Minz_Session::_param('csrf');
 			}
 			return $current_user != '';
 		case 'http_auth':
@@ -63,6 +65,7 @@ class FreshRSS_Auth {
 			$login_ok = $current_user != '' && FreshRSS_UserDAO::exists($current_user);
 			if ($login_ok) {
 				Minz_Session::_param('currentUser', $current_user);
+				Minz_Session::_param('csrf');
 			}
 			return $login_ok;
 		case 'none':
@@ -196,13 +199,10 @@ class FreshRSS_Auth {
 	}
 	public static function isCsrfOk($token = null) {
 		$csrf = Minz_Session::param('csrf');
-		if ($csrf == '') {
-			return true;	//Not logged in yet
-		}
 		if ($token === null) {
 			$token = Minz_Request::fetchPOST('_csrf');
 		}
-		return $token === $csrf;
+		return $token != '' && $token === $csrf;
 	}
 }
 

+ 17 - 25
app/Models/Context.php

@@ -252,37 +252,29 @@ class FreshRSS_Context {
 			$found_current_get = false;
 			switch ($get[0]) {
 			case 'f':
-				// We search the next feed with at least one unread article in
-				// same category as the currend feed.
+				// We search the next unread feed with the following priorities: next in same category, or previous in same category, or next, or previous.
 				foreach (self::$categories as $cat) {
-					if ($cat->id() != self::$current_get['category']) {
-						// We look into the category of the current feed!
-						continue;
-					}
-
+					$sameCat = false;
 					foreach ($cat->feeds() as $feed) {
-						if ($feed->id() == self::$current_get['feed']) {
-							// Here is our current feed! Fine, the next one will
-							// be a potential candidate.
+						if ($found_current_get) {
+							if ($feed->nbNotRead() > 0) {
+								$another_unread_id = $feed->id();
+								break 2;
+							}
+						} elseif ($feed->id() == self::$current_get['feed']) {
 							$found_current_get = true;
-							continue;
-						}
-
-						if ($feed->nbNotRead() > 0) {
+						} elseif ($feed->nbNotRead() > 0) {
 							$another_unread_id = $feed->id();
-							if ($found_current_get) {
-								// We have found our current feed and now we
-								// have an feed with unread articles. Leave the
-								// loop!
-								break;
-							}
+							$sameCat = true;
 						}
 					}
-					break;
+					if ($found_current_get && $sameCat) {
+						break;
+					}
 				}
 
-				// If no feed have been found, next_get is the current category.
-				self::$next_get = empty($another_unread_id) ? 'c_' . self::$current_get['category'] : 'f_' . $another_unread_id;
+				// If there is no more unread feed, show main stream
+				self::$next_get = $another_unread_id == '' ? 'a' : 'f_' . $another_unread_id;
 				break;
 			case 'c':
 				// We search the next category with at least one unread article.
@@ -304,8 +296,8 @@ class FreshRSS_Context {
 					}
 				}
 
-				// No unread category? The main stream will be our destination!
-				self::$next_get = empty($another_unread_id) ? 'a' : 'c_' . $another_unread_id;
+				// If there is no more unread category, show main stream
+				self::$next_get = $another_unread_id == '' ? 'a' : 'c_' . $another_unread_id;
 				break;
 			}
 		}

+ 113 - 0
app/Models/Entry.php

@@ -185,6 +185,119 @@ class FreshRSS_Entry extends Minz_Model {
 		$this->tags = $value;
 	}
 
+	public function matches($booleanSearch) {
+		if (!$booleanSearch || count($booleanSearch->searches()) <= 0) {
+			return true;
+		}
+		foreach ($booleanSearch->searches() as $filter) {
+			$ok = true;
+			if ($ok && $filter->getMinPubdate()) {
+				$ok &= $this->date >= $filter->getMinPubdate();
+			}
+			if ($ok && $filter->getMaxPubdate()) {
+				$ok &= $this->date <= $filter->getMaxPubdate();
+			}
+			if ($ok && $filter->getMinDate()) {
+				$ok &= strnatcmp($this->id, $filter->getMinDate() . '000000') >= 0;
+			}
+			if ($ok && $filter->getMaxDate()) {
+				$ok &= strnatcmp($this->id, $filter->getMaxDate() . '000000') <= 0;
+			}
+			if ($ok && $filter->getInurl()) {
+				foreach ($filter->getInurl() as $url) {
+					$ok &= stripos($this->link, $url) !== false;
+				}
+			}
+			if ($ok && $filter->getNotInurl()) {
+				foreach ($filter->getNotInurl() as $url) {
+					$ok &= stripos($this->link, $url) === false;
+				}
+			}
+			if ($ok && $filter->getAuthor()) {
+				foreach ($filter->getAuthor() as $author) {
+					$ok &= stripos($this->authors, $author) !== false;
+				}
+			}
+			if ($ok && $filter->getNotAuthor()) {
+				foreach ($filter->getNotAuthor() as $author) {
+					$ok &= stripos($this->authors, $author) === false;
+				}
+			}
+			if ($ok && $filter->getIntitle()) {
+				foreach ($filter->getIntitle() as $title) {
+					$ok &= stripos($this->title, $title) !== false;
+				}
+			}
+			if ($ok && $filter->getNotIntitle()) {
+				foreach ($filter->getNotIntitle() as $title) {
+					$ok &= stripos($this->title, $title) === false;
+				}
+			}
+			if ($ok && $filter->getTags()) {
+				foreach ($filter->getTags() as $tag2) {
+					$found = false;
+					foreach ($this->tags as $tag1) {
+						if (strcasecmp($tag1, $tag2) === 0) {
+							$found = true;
+						}
+					}
+					$ok &= $found;
+				}
+			}
+			if ($ok && $filter->getNotTags()) {
+				foreach ($filter->getNotTags() as $tag2) {
+					$found = false;
+					foreach ($this->tags as $tag1) {
+						if (strcasecmp($tag1, $tag2) === 0) {
+							$found = true;
+						}
+					}
+					$ok &= !$found;
+				}
+			}
+			if ($ok && $filter->getSearch()) {
+				foreach ($filter->getSearch() as $needle) {
+					$ok &= (stripos($this->title, $needle) !== false || stripos($this->content, $needle) !== false);
+				}
+			}
+			if ($ok && $filter->getNotSearch()) {
+				foreach ($filter->getNotSearch() as $needle) {
+					$ok &= (stripos($this->title, $needle) === false && stripos($this->content, $needle) === false);
+				}
+			}
+			if ($ok) {
+				return true;
+			}
+		}
+		return false;
+	}
+
+	public function applyFilterActions() {
+		if ($this->feed != null) {
+			if ($this->feed->attributes('read_upon_reception') ||
+				($this->feed->attributes('read_upon_reception') === null && FreshRSS_Context::$user_conf->mark_when['reception'])) {
+				$this->_isRead(true);
+			}
+			foreach ($this->feed->filterActions() as $filterAction) {
+				if ($this->matches($filterAction->booleanSearch())) {
+					foreach ($filterAction->actions() as $action => $params) {
+						switch ($action) {
+							case 'read':
+								$this->_isRead(true);
+								break;
+							case 'star':
+								$this->_is_favorite(true);
+								break;
+							case 'label':
+								//TODO: Implement more actions
+								break;
+						}
+					}
+				}
+			}
+		}
+	}
+
 	public function isDay($day, $today) {
 		$date = $this->dateAdded(true);
 		switch ($day) {

+ 2 - 2
app/Models/EntryDAO.php

@@ -383,7 +383,7 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	 */
 	public function markRead($ids, $is_read = true) {
 		FreshRSS_UserDAO::touch();
-		if (is_array($ids)) {	//Many IDs at once (used by API)
+		if (is_array($ids)) {	//Many IDs at once
 			if (count($ids) < 6) {	//Speed heuristics
 				$affected = 0;
 				foreach ($ids as $id) {
@@ -1065,7 +1065,7 @@ SQL;
 				$dao['date'],
 				$dao['is_read'],
 				$dao['is_favorite'],
-				$dao['tags']
+				isset($dao['tags']) ? $dao['tags'] : ''
 			);
 		if (isset($dao['id'])) {
 			$entry->_id($dao['id']);

+ 3 - 1
app/Models/EntryDAOPGSQL.php

@@ -37,7 +37,9 @@ BEGIN
 	INSERT INTO `' . $this->prefix . 'entry` (id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags)
 		(SELECT rank + row_number() OVER(ORDER BY date) AS id, guid, title, author, content, link, date, `lastSeen`, hash, is_read, is_favorite, id_feed, tags
 			FROM `' . $this->prefix . 'entrytmp` AS etmp
-			WHERE NOT EXISTS (SELECT 1 FROM `' . $this->prefix . 'entry` AS ereal WHERE etmp.id_feed = ereal.id_feed AND etmp.guid = ereal.guid)
+			WHERE NOT EXISTS (
+				SELECT 1 FROM `' . $this->prefix . 'entry` AS ereal
+				WHERE (etmp.id = ereal.id) OR (etmp.id_feed = ereal.id_feed AND etmp.guid = ereal.guid))
 			ORDER BY date);
 	DELETE FROM `' . $this->prefix . 'entrytmp` WHERE id <= maxrank;
 END $$;';

+ 104 - 0
app/Models/Feed.php

@@ -32,6 +32,7 @@ class FreshRSS_Feed extends Minz_Model {
 	private $lockPath = '';
 	private $hubUrl = '';
 	private $selfUrl = '';
+	private $filterActions = null;
 
 	public function __construct($url, $validate = true) {
 		if ($validate) {
@@ -498,6 +499,109 @@ class FreshRSS_Feed extends Minz_Model {
 		@unlink($this->lockPath);
 	}
 
+	public function filterActions() {
+		if ($this->filterActions == null) {
+			$this->filterActions = array();
+			$filters = $this->attributes('filters');
+			if (is_array($filters)) {
+				foreach ($filters as $filter) {
+					$filterAction = FreshRSS_FilterAction::fromJSON($filter);
+					if ($filterAction != null) {
+						$this->filterActions[] = $filterAction;
+					}
+				}
+			}
+		}
+		return $this->filterActions;
+	}
+
+	private function _filterActions($filterActions) {
+		$this->filterActions = $filterActions;
+		if (is_array($this->filterActions) && !empty($this->filterActions)) {
+			$this->_attributes('filters', array_map(function ($af) {
+					return $af == null ? null : $af->toJSON();
+				}, $this->filterActions));
+		} else {
+			$this->_attributes('filters', null);
+		}
+	}
+
+	public function filtersAction($action) {
+		$action = trim($action);
+		if ($action == '') {
+			return array();
+		}
+		$filters = array();
+		$filterActions = $this->filterActions();
+		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
+			$filterAction = $filterActions[$i];
+			if ($filterAction != null && $filterAction->booleanSearch() != null &&
+				$filterAction->actions() != null && in_array($action, $filterAction->actions(), true)) {
+				$filters[] = $filterAction->booleanSearch();
+			}
+		}
+		return $filters;
+	}
+
+	public function _filtersAction($action, $filters) {
+		$action = trim($action);
+		if ($action == '' || !is_array($filters)) {
+			return false;
+		}
+		$filters = array_unique(array_map('trim', $filters));
+		$filterActions = $this->filterActions();
+
+		//Check existing filters
+		for ($i = count($filterActions) - 1; $i >= 0; $i--) {
+			$filterAction = $filterActions[$i];
+			if ($filterAction == null || !is_array($filterAction->actions()) ||
+				$filterAction->booleanSearch() == null || trim($filterAction->booleanSearch()->getRawInput()) == '') {
+				array_splice($filterAction, $i, 1);
+				continue;
+			}
+			$actions = $filterAction->actions();
+			//Remove existing rules with same action
+			for ($j = count($actions) - 1; $j >= 0; $j--) {
+				if ($actions[$j] === $action) {
+					array_splice($actions, $j, 1);
+				}
+			}
+			//Update existing filter with new action
+			for ($k = count($filters) - 1; $k >= 0; $k --) {
+				$filter = $filters[$k];
+				if ($filter === $filterAction->booleanSearch()->getRawInput()) {
+					$actions[] = $action;
+					array_splice($filters, $k, 1);
+				}
+			}
+			//Save result
+			if (empty($actions)) {
+				array_splice($filterActions, $i, 1);
+			} else {
+				$filterAction->_actions($actions);
+			}
+		}
+
+		//Add new filters
+		for ($k = count($filters) - 1; $k >= 0; $k --) {
+			$filter = $filters[$k];
+			if ($filter != '') {
+				$filterAction = FreshRSS_FilterAction::fromJSON(array(
+						'search' => $filter,
+						'actions' => array($action),
+					));
+				if ($filterAction != null) {
+					$filterActions[] = $filterAction;
+				}
+			}
+		}
+
+		if (empty($filterActions)) {
+			$filterActions = null;
+		}
+		$this->_filterActions($filterActions);
+	}
+
 	//<WebSub>
 
 	public function pubSubHubbubEnabled() {

+ 45 - 0
app/Models/FilterAction.php

@@ -0,0 +1,45 @@
+<?php
+
+class FreshRSS_FilterAction {
+
+	private $booleanSearch = null;
+	private $actions = null;
+
+	private function __construct($booleanSearch, $actions) {
+		$this->booleanSearch = $booleanSearch;
+		$this->_actions($actions);
+	}
+
+	public function booleanSearch() {
+		return $this->booleanSearch;
+	}
+
+	public function actions() {
+		return $this->actions;
+	}
+
+	public function _actions($actions) {
+		if (is_array($actions)) {
+			$this->actions = array_unique($actions);
+		} else {
+			$this->actions = null;
+		}
+	}
+
+	public function toJSON() {
+		if (is_array($this->actions) && $this->booleanSearch != null) {
+			return array(
+					'search' => $this->booleanSearch->getRawInput(),
+					'actions' => $this->actions,
+				);
+		}
+		return '';
+	}
+
+	public static function fromJSON($json) {
+		if (!empty($json['search']) && !empty($json['actions']) && is_array($json['actions'])) {
+			return new FreshRSS_FilterAction(new FreshRSS_BooleanSearch($json['search']), $json['actions']);
+		}
+		return null;
+	}
+}

+ 30 - 18
app/Models/TagDAO.php

@@ -187,9 +187,17 @@ class FreshRSS_TagDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 	public function count() {
 		$sql = 'SELECT COUNT(*) AS count FROM `' . $this->prefix . 'tag`';
 		$stm = $this->bd->prepare($sql);
-		$stm->execute();
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		return $res[0]['count'];
+		if ($stm && $stm->execute()) {
+			$res = $stm->fetchAll(PDO::FETCH_ASSOC);
+			return $res[0]['count'];
+		} else {
+			$info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo();
+			if ($this->autoUpdateDb($info)) {
+				return $this->count();
+			}
+			Minz_Log::error('SQL error TagDAO::count: ' . $info[2]);
+			return false;
+		}
 	}
 
 	public function countEntries($id) {
@@ -256,9 +264,8 @@ class FreshRSS_TagDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		}
 	}
 
-	//For API
-	public function getEntryIdsTagNames($entries) {
-		$sql = 'SELECT et.id_entry, t.name '
+	public function getTagsForEntries($entries) {
+		$sql = 'SELECT et.id_entry, et.id_tag, t.name '
 			 . 'FROM `' . $this->prefix . 'tag` t '
 			 . 'INNER JOIN `' . $this->prefix . 'entrytag` et ON et.id_tag = t.id';
 
@@ -282,26 +289,31 @@ class FreshRSS_TagDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		$stm = $this->bd->prepare($sql);
 
 		if ($stm && $stm->execute($values)) {
-			$result = array();
-			foreach ($stm->fetchAll(PDO::FETCH_ASSOC) as $line) {
-				$entryId = 'e_' . $line['id_entry'];
-				$tagName = $line['name'];
-				if (empty($result[$entryId])) {
-					$result[$entryId] = array();
-				}
-				$result[$entryId][] = $tagName;
-			}
-			return $result;
+			return $stm->fetchAll(PDO::FETCH_ASSOC);
 		} else {
 			$info = $stm == null ? array(0 => '', 1 => '', 2 => 'syntax error') : $stm->errorInfo();
 			if ($this->autoUpdateDb($info)) {
-				return $this->getTagNamesEntryIds($id_entry);
+				return $this->getTagsForEntries($entries);
 			}
-			Minz_Log::error('SQL error getTagNamesEntryIds: ' . $info[2]);
+			Minz_Log::error('SQL error getTagsForEntries: ' . $info[2]);
 			return false;
 		}
 	}
 
+	//For API
+	public function getEntryIdsTagNames($entries) {
+		$result = array();
+		foreach ($this->getTagsForEntries($entries) as $line) {
+			$entryId = 'e_' . $line['id_entry'];
+			$tagName = $line['name'];
+			if (empty($result[$entryId])) {
+				$result[$entryId] = array();
+			}
+			$result[$entryId][] = $tagName;
+		}
+		return $result;
+	}
+
 	public static function daoToTag($listDAO) {
 		$list = array();
 		if (!is_array($listDAO)) {

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Popis',
 		'empty' => 'Kanál je prázdný. Ověřte prosím zda je ještě autorem udržován.',
 		'error' => 'Vyskytl se problém s kanálem. Ověřte že je vždy dostupný, prosím, a poté jej aktualizujte.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'Informace',
 		'keep_history' => 'Zachovat tento minimální počet článků',
 		'moved_category_deleted' => 'Po smazání kategorie budou v ní obsažené kanály automaticky přesunuty do <em>%s</em>.',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Beschreibung',
 		'empty' => 'Dieser Feed ist leer. Bitte stellen Sie sicher, dass er noch gepflegt wird.',
 		'error' => 'Dieser Feed ist auf ein Problem gestoßen. Bitte stellen Sie sicher, dass er immer lesbar ist und aktualisieren Sie ihn dann.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'Information',
 		'keep_history' => 'Minimale Anzahl an Artikeln, die behalten wird',
 		'moved_category_deleted' => 'Wenn Sie eine Kategorie entfernen, werden deren Feeds automatisch in die Kategorie <em>%s</em> eingefügt.',

+ 3 - 6
app/i18n/en/conf.php

@@ -92,7 +92,7 @@ return array(
 		'auto_remove_article' => 'Hide articles after reading',
 		'confirm_enabled' => 'Display a confirmation dialog on “mark all as read” actions',
 		'display_articles_unfolded' => 'Show articles unfolded by default',
-		'display_categories_unfolded' => 'Show categories folded by default',
+		'display_categories_unfolded' => 'Show categories unfolded by default',
 		'hide_read_feeds' => 'Hide categories & feeds with no unread articles (does not work with “Show all articles” configuration)',
 		'img_with_lazyload' => 'Use "lazy load" mode to load pictures',
 		'jump_next' => 'jump to next unread sibling (feed or category)',
@@ -158,15 +158,12 @@ return array(
 		'javascript' => 'JavaScript must be enabled in order to use shortcuts',
 		'last_article' => 'Open the last article',
 		'load_more' => 'Load more articles',
-		'mark_favorite' => 'Mark as favourite',
-		'mark_read' => 'Mark as read',
+		'mark_favorite' => 'Toggle favourite',
+		'mark_read' => 'Toggle read',
 		'navigation' => 'Navigation',
 		'navigation_help' => 'With the "Shift" modifier, navigation shortcuts apply on feeds.<br/>With the "Alt" modifier, navigation shortcuts apply on categories.',
 		'navigation_no_mod_help' => 'The following navigation shortcuts do not support modifiers.',
 		'next_article' => 'Open the next article',
-		'other_action' => 'Other actions',
-		'previous_article' => 'Open the previous article',
-		'next_article' => 'Open the next article',
 		'normal_view' => 'Switch to normal view',
 		'other_action' => 'Other actions',
 		'previous_article' => 'Open the previous article',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Description',
 		'empty' => 'This feed is empty. Please verify that it is still maintained.',
 		'error' => 'This feed has encountered a problem. Please verify that it is always reachable then update it.',
+		'filteractions' => array(
+			'_' => 'Filter actions',
+			'help' => 'Write one search filter per line.',
+		),
 		'informations' => 'Information',
 		'keep_history' => 'Minimum number of articles to keep',
 		'moved_category_deleted' => 'When you delete a category, its feeds are automatically classified under <em>%s</em>.',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Descripción',
 		'empty' => 'La fuente está vacía. Por favor, verifica que siga activa.',
 		'error' => 'Hay un problema con esta fuente. Por favor, veritica que esté disponible y prueba de nuevo.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'Información',
 		'keep_history' => 'Número mínimo de artículos a conservar',
 		'moved_category_deleted' => 'Al borrar una categoría todas sus fuentes pasan automáticamente a la categoría <em>%s</em>.',

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

@@ -158,11 +158,11 @@ return array(
 		'javascript' => 'Le JavaScript doit être activé pour pouvoir profiter des raccourcis.',
 		'last_article' => 'Passer au dernier article',
 		'load_more' => 'Charger plus d’articles',
-		'mark_favorite' => 'Mettre en favori',
-		'mark_read' => 'Marquer comme lu',
+		'mark_favorite' => 'Basculer l’indicateur de favori',
+		'mark_read' => 'Basculer l’indicateur de lecture',
 		'navigation' => 'Navigation',
 		'navigation_help' => 'Avec le modificateur "Shift", les raccourcis de navigation s’appliquent aux flux.<br/>Avec le modificateur "Alt", les raccourcis de navigation s’appliquent aux catégories.',
-		'navigation_no_mod_help' => 'The following navigation shortcuts do not support modifiers.',	//TODO - Translation
+		'navigation_no_mod_help' => 'Les raccourcis suivant ne supportent pas les modificateurs.',
 		'next_article' => 'Passer à l’article suivant',
 		'normal_view' => 'Basculer vers la vue normale',
 		'other_action' => 'Autres actions',
@@ -171,8 +171,8 @@ return array(
 		'rss_view' => 'Ouvrir le flux RSS dans un nouvel onglet',
 		'see_on_website' => 'Voir sur le site d’origine',
 		'shift_for_all_read' => '+ <code>shift</code> pour marquer tous les articles comme lus',
-		'skip_next_article' => 'Focus next without opening',	//TODO - Translation
-		'skip_previous_article' => 'Focus previous without opening',	//TODO - Translation
+		'skip_next_article' => 'Passer au suivant sans ouvrir',
+		'skip_previous_article' => 'Passer au précédent sans ouvrir',
 		'title' => 'Raccourcis',
 		'user_filter' => 'Accéder aux filtres utilisateur',
 		'user_filter_help' => 'S’il n’y a qu’un filtre utilisateur, celui-ci est utilisé automatiquement. Sinon ils sont accessibles par leur numéro.',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Description',
 		'empty' => 'Ce flux est vide. Veuillez vérifier qu’il est toujours maintenu.',
 		'error' => 'Ce flux a rencontré un problème. Veuillez vérifier qu’il est toujours accessible puis actualisez-le.',
+		'filteractions' => array(
+			'_' => 'Filtres d’action',
+			'help' => 'Écrivez une recherche par ligne.',
+		),
 		'informations' => 'Informations',
 		'keep_history' => 'Nombre minimum d’articles à conserver',
 		'moved_category_deleted' => 'Lors de la suppression d’une catégorie, ses flux seront automatiquement classés dans <em>%s</em>.',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'תיאור',
 		'empty' => 'הזנה זו ריקה. אנא ודאו שהיא עדיין מתוחזקת.',
 		'error' => 'הזנה זו נתקלה בשגיאה, אנא ודאו שהיא תקינה ואז נסו שנית.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'מידע',
 		'keep_history' => 'מסםר מינימלי של מאמרים לשמור',
 		'moved_category_deleted' => 'כאשר הקטגוריה נמחקת ההזנות שבתוכה אוטומטית מקוטלגות תחת  <em>%s</em>.',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Descrizione',
 		'empty' => 'Questo feed non contiene articoli. Per favore verifica il sito direttamente.',
 		'error' => 'Questo feed ha generato un errore. Per favore verifica se ancora disponibile.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'Informazioni',
 		'keep_history' => 'Numero minimo di articoli da mantenere',
 		'moved_category_deleted' => 'Cancellando una categoria i feed al suo interno verranno classificati automaticamente come <em>%s</em>.',

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

@@ -67,8 +67,8 @@ return array(
 			'ok' => 'JSON  확장 기능이 설치되어 있습니다.',
 		),
 		'mbstring' => array(
-			'nok' => 'Cannot find the recommended library mbstring for Unicode.',	//TODO - Translation
-			'ok' => 'You have the recommended library mbstring for Unicode.',	//TODO - Translation
+			'nok' => '유니코드 지원을 위한 mbstring 라이브러리를 찾을 수 없습니다.',
+			'ok' => '유니코드 지원을 위한 mbstring 라이브러리가 설치되어 있습니다.',
 		),
 		'minz' => array(
 			'nok' => 'Minz 프레임워크를 찾을 수 없습니다.',
@@ -163,8 +163,8 @@ return array(
 		'max-categories' => '사용자별 카테고리 개수 제한',
 		'max-feeds' => '사용자별 피드 개수 제한',
 		'cookie-duration' => array(
-			'help' => 'in seconds', // @todo translate
-			'number' => 'Duration to keep logged in', // @todo translate
+			'help' => '초',
+			'number' => '로그인 유지 시간',
 		),
 		'registration' => array(
 			'help' => '0: 제한 없음',

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

@@ -162,7 +162,7 @@ return array(
 		'mark_read' => '읽음으로 표시',
 		'navigation' => '탐색',
 		'navigation_help' => '"Shift" 키를 누른 상태에선 탐색 단축키가 피드에 적용됩니다.<br/>"Alt" 키를 누른 상태에선 탐색 단축키가 카테고리에 적용됩니다.',
-		'navigation_no_mod_help' => 'The following navigation shortcuts do not support modifiers.',	//TODO - Translation
+		'navigation_no_mod_help' => '아래 탐색 단축키에는 "Shift"와 "Alt" 키가 적용되지 않습니다.',
 		'next_article' => '다음 글 보기',
 		'normal_view' => '일반 모드로 전환',
 		'other_action' => '다른 동작',
@@ -171,8 +171,8 @@ return array(
 		'rss_view' => '새 탭에서 RSS 피드 열기',
 		'see_on_website' => '글이 게재된 웹사이트에서 보기',
 		'shift_for_all_read' => '+ <code>shift</code>를 누른 상태에선 모두 읽음으로 표시',
-		'skip_next_article' => 'Focus next without opening',	//TODO - Translation
-		'skip_previous_article' => 'Focus previous without opening',	//TODO - Translation
+		'skip_next_article' => '다음 글로 커서 이동',
+		'skip_previous_article' => '이전 글로 커서 이동',
 		'title' => '단축키',
 		'user_filter' => '사용자 필터 사용하기',
 		'user_filter_help' => '사용자 필터가 하나만 설정되어 있다면 해당 필터를 사용하고, 그렇지 않다면 필터를 번호로 선택할 수 있습니다.',

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

@@ -57,8 +57,8 @@ return array(
 	'sub' => array(
 		'actualize' => '피드를 가져오는 중입니다',
 		'articles' => array(
-			'marked_read' => 'The selected articles have been marked as read.',	//TODO - Translation
-			'marked_unread' => 'The articles have been marked as unread.',	//TODO - Translation
+			'marked_read' => '선택된 글들을 읽음으로 표시하였습니다.',
+			'marked_unread' => '선택된 글들을 읽지 않음으로 표시하였습니다.',
 		),
 		'category' => array(
 			'created' => '%s 카테고리가 생성되었습니다.',

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

@@ -152,13 +152,13 @@ return array(
 		'user_profile' => '프로필',
 	),
 	'pagination' => array(
-		'first' => 'First',
-		'last' => 'Last',
+		'first' => '처음으로',
+		'last' => '마지막으로',
 		'load_more' => '글 더 불러오기',
 		'mark_all_read' => '모두 읽음으로 표시',
-		'next' => 'Next',
+		'next' => '다음',
 		'nothing_to_load' => '더 이상 글이 없습니다',
-		'previous' => 'Previous',
+		'previous' => '이전',
 	),
 	'share' => array(
 		'blogotext' => 'Blogotext',
@@ -183,13 +183,13 @@ return array(
 	'short' => array(
 		'attention' => '경고!',
 		'blank_to_disable' => '빈 칸으로 두면 비활성화',
-		'by_author' => 'By:',
+		'by_author' => '글쓴이:',
 		'by_default' => '기본값',
 		'damn' => '이런!',
 		'default_category' => '분류 없음',
 		'no' => '아니요',
 		'not_applicable' => '사용할 수 없음',
-		'ok' => 'Ok!',
+		'ok' => '좋습니다!',
 		'or' => '또는',
 		'yes' => '네',
 	),

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

@@ -40,7 +40,7 @@ return array(
 		'mark_all_read' => '모두 읽음으로 표시',
 		'mark_cat_read' => '카테고리를 읽음으로 표시',
 		'mark_feed_read' => '피드를 읽음으로 표시',
-		'mark_selection_unread' => 'Mark selection as unread',	//TODO - Translation
+		'mark_selection_unread' => '선택된 글을 읽지 않음으로 표시',
 		'newer_first' => '최근 글 먼저',
 		'non-starred' => '즐겨찾기를 제외하고 표시',
 		'normal_view' => '일반 모드',
@@ -53,11 +53,11 @@ return array(
 		'starred' => '즐겨찾기만 표시',
 		'stats' => '통계',
 		'subscription' => '구독 관리',
-		'tags' => 'My labels',	//TODO - Translation
+		'tags' => '내 라벨',
 		'unread' => '읽지 않은 글만 표시',
 	),
 	'share' => '공유',
 	'tag' => array(
-		'related' => '관련 태그',	//TODO - Translation
+		'related' => '관련 태그',
 	),
 );

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

@@ -69,8 +69,8 @@ return array(
 			'ok' => 'JSON  확장 기능이 설치되어 있습니다.',
 		),
 		'mbstring' => array(
-			'nok' => 'Cannot find the recommended library mbstring for Unicode.',	//TODO - Translation
-			'ok' => 'You have the recommended library mbstring for Unicode.',	//TODO - Translation
+			'nok' => '유니코드 지원을 위한 mbstring 라이브러리를 찾을 수 없습니다.',
+			'ok' => '유니코드 지원을 위한 mbstring 라이브러리가 설치되어 있습니다.',
 		),
 		'minz' => array(
 			'nok' => 'Minz 프레임워크를 찾을 수 없습니다.',

+ 9 - 5
app/i18n/kr/sub.php

@@ -27,12 +27,16 @@ return array(
 			'password' => 'HTTP 암호',
 			'username' => 'HTTP 사용자 이름',
 		),
-		'clear_cache' => 'Always clear cache',	//TODO - Translation
+		'clear_cache' => '항상 캐시 지우기',
 		'css_help' => '글의 일부가 포함된 RSS 피드를 가져옵니다 (주의, 시간이 좀 더 걸립니다!)',
 		'css_path' => '웹사이트 상의 글 본문에 해당하는 CSS 경로',
 		'description' => '설명',
 		'empty' => '이 피드는 비어있습니다. 피드가 계속 운영되고 있는지 확인하세요.',
 		'error' => '이 피드에 문제가 발생했습니다. 이 피드에 접근 권한이 있는지 확인하세요.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => '정보',
 		'keep_history' => '최소 유지 글 개수',
 		'moved_category_deleted' => '카테고리를 삭제하면, 해당 카테고리 아래에 있던 피드들은 자동적으로 <em>%s</em> 아래로 분류됩니다.',
@@ -47,11 +51,11 @@ return array(
 		),
 		'websub' => 'WebSub을 사용한 즉시 알림',
 		'show' => array(
-			'all' => 'Show all feeds',	//TODO - Translation
-			'error' => 'Show only feeds with error',	//TODO - Translation
+			'all' => '모든 피드 보기',
+			'error' => '오류가 발생한 피드만 보기',
 		),
 		'showing' => array(
-			'error' => 'Showing only feeds with error',	//TODO - Translation
+			'error' => '오류가 발생한 피드만 보여주고 있습니다',
 		),
 		'ssl_verify' => 'SSL 유효성 검사',
 		'stats' => '통계',
@@ -72,7 +76,7 @@ return array(
 		'export' => '내보내기',
 		'export_opml' => '피드 목록 내보내기 (OPML)',
 		'export_starred' => '즐겨찾기 내보내기',
-		'export_labelled' => 'Export your labelled articles',	//TODO
+		'export_labelled' => '라벨이 표시된 글들 내보내기',
 		'feed_list' => '%s 개의 글 목록',
 		'file_to_import' => '불러올 파일<br />(OPML, JSON 또는 ZIP)',
 		'file_to_import_no_zip' => '불러올 파일<br />(OPML 또는 JSON)',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Omschrijving',
 		'empty' => 'Deze feed is leeg. Controleer of deze nog actueel is.',
 		'error' => 'Deze feed heeft problemen. Verifieer a.u.b het doeladres en actualiseer het.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'Informatie',
 		'keep_history' => 'Minimum aantal artikelen om te houden',
 		'moved_category_deleted' => 'Als u een categorie verwijderd, worden de feeds automatisch geclassificeerd onder <em>%s</em>.',

+ 1 - 1
app/i18n/oc/admin.php

@@ -130,7 +130,7 @@ return array(
 		'category' => 'Categoria',
 		'entry_count' => 'Nombre d’articles',
 		'entry_per_category' => 'Articles per categoria',
-		'entry_per_day' => 'Nombre d’articles per jorn (30 darrièrs jorns)',
+		'entry_per_day' => 'Nombre d’articles per jorn (darrièrs 30 jorns)',
 		'entry_per_day_of_week' => 'Per jorn de la setmana (mejana : %.2f messatges)',
 		'entry_per_hour' => 'Per ora (mejana : %.2f messatges)',
 		'entry_per_month' => 'Per mes (mejana : %.2f messatges)',

+ 2 - 2
app/i18n/oc/gen.php

@@ -68,8 +68,8 @@ return array(
 		'Jun' => '\\j\\u\\n\\h',
 		'jun' => 'junh',
 		'june' => 'junh',
-		'last_3_month' => 'Dempuèi los tres darrièrs meses',
-		'last_6_month' => 'Dempuèi los sièis darrièrs meses',
+		'last_3_month' => 'Dempuèi los darrièrs tres meses',
+		'last_6_month' => 'Dempuèi los darrièrs sièis meses',
 		'last_month' => 'Dempuèi lo mes passat',
 		'last_week' => 'Dempuèi la setmana passada',
 		'last_year' => 'Dempuèi l’annada passada',

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

@@ -6,7 +6,7 @@ return array(
 		'agpl3' => '<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPL 3</a>',
 		'bugs_reports' => 'Senhalament de problèmas',
 		'credits' => 'Crèdits',
-		'credits_content' => 'Unes elements de l’estil venon del <a href="http://twitter.github.io/bootstrap/">projècte Bootstrap</a> encara que FreshRSS utilize pas aqueste framework. Las<a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">icònas</a> venon del <a href="https://www.gnome.org/">projècte GNOME</a>. La polissa <em>Open Sans</em> utilizada foguèt creada per en <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS es basat sus <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, un framework PHP.',
+		'credits_content' => 'Unes elements de l’estil venon del <a href="http://twitter.github.io/bootstrap/">projècte Bootstrap</a> encara que FreshRSS utilize pas aqueste framework. Las <a href="https://git.gnome.org/browse/gnome-icon-theme-symbolic">icònas</a> venon del <a href="https://www.gnome.org/">projècte GNOME</a>. La polissa <em>Open Sans</em> utilizada foguèt creada per en <a href="https://fonts.google.com/specimen/Open+Sans">Steve Matteson</a>. FreshRSS es basat sus <a href="https://github.com/marienfressinaud/MINZ">Minz</a>, un framework PHP.',
 		'freshrss_description' => 'FreshRSS es un agregador de fluxes RSS per l’auto-albergar tal coma <a href="http://tontof.net/kriss/feed/">Kriss Feed</a> o <a href="http://projet.idleman.fr/leed/">Leed</a>. Sa tòca es d’èsser leugièr e de bon utilizar de prima abòrd mas tanben d’èsser potent e parametrable.',
 		'github' => '<a href="https://github.com/FreshRSS/FreshRSS/issues">on Github</a>',
 		'license' => 'Licéncia',

+ 4 - 0
app/i18n/oc/sub.php

@@ -32,6 +32,10 @@ return array(
 		'description' => 'Descripcion',
 		'empty' => 'Aqueste flux es void. Assegurats-vos qu’es totjorn mantengut.',
 		'error' => 'Aqueste flux a rescontrat un problèma. Volgatz verificar que siá totjorn accessible puèi actualizatz-lo.',
+		'filteractions' => array(
+			'_' => 'Filtre d’accion',
+			'help' => 'Escrivètz una recèrca per linha.',
+		),
 		'informations' => 'Informacions',
 		'keep_history' => 'Nombre minimum d’articles de servar',
 		'moved_category_deleted' => 'Quand escafatz una categoria, sos fluxes son automaticament classats dins <em>%s</em>.',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Descrição',
 		'empty' => 'Este feed está vazio. Por favor verifique ele ainda é mantido.',
 		'error' => 'Este feed encontra-se com problema. Por favor verifique se ele ainda está disponível e atualize-o.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'Informações',
 		'keep_history' => 'Número mínimo de artigos para manter',
 		'moved_category_deleted' => 'Quando você deleta uma categoria, seus feeds são automaticamente classificados como <em>%s</em>.',

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

@@ -92,7 +92,7 @@ return array(
 		'auto_remove_article' => 'Hide articles after reading',	//TODO - Translation
 		'confirm_enabled' => 'Display a confirmation dialog on “mark all as read” actions',	//TODO - Translation
 		'display_articles_unfolded' => 'Show articles unfolded by default',	//TODO - Translation
-		'display_categories_unfolded' => 'Show categories folded by default',	//TODO - Translation
+		'display_categories_unfolded' => 'Show categories unfolded by default',	//TODO - Translation
 		'hide_read_feeds' => 'Hide categories & feeds with no unread article (does not work with “Show all articles” configuration)',	//TODO - Translation
 		'img_with_lazyload' => 'Use "lazy load" mode to load pictures',	//TODO - Translation
 		'jump_next' => 'jump to next unread sibling (feed or category)',	//TODO - Translation

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Description',	//TODO - Translation
 		'empty' => 'This feed is empty. Please verify that it is still maintained.',	//TODO - Translation
 		'error' => 'This feed has encountered a problem. Please verify that it is always reachable then actualize it.',	//TODO - Translation
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'Information',	//TODO - Translation
 		'keep_history' => 'Minimum number of articles to keep',	//TODO - Translation
 		'moved_category_deleted' => 'When you delete a category, its feeds are automatically classified under <em>%s</em>.',	//TODO - Translation

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

@@ -92,7 +92,7 @@ return array(
 		'auto_remove_article' => 'Okuduktan sonra makaleleri gizle',
 		'confirm_enabled' => '"Hepsini okundu say" eylemi için onay iste',
 		'display_articles_unfolded' => 'Show articles unfolded by default',
-		'display_categories_unfolded' => 'Show categories folded by default',
+		'display_categories_unfolded' => 'Show categories unfolded by default',
 		'hide_read_feeds' => 'Okunmamış makalesi olmayan kategori veya akışı gizle ("Tüm makaleleri göster" komutunda çalışmaz)',
 		'img_with_lazyload' => 'Resimleri yüklemek için "tembel modu" kullan',
 		'jump_next' => 'Bir sonraki benzer okunmamışa geç (akış veya kategori)',

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

@@ -33,6 +33,10 @@ return array(
 		'description' => 'Tanım',
 		'empty' => 'Bu akış boş. Lütfen akışın aktif olduğuna emin olun.',
 		'error' => 'Bu akışda bir hatayla karşılaşıldı. Lütfen akışın sürekli ulaşılabilir olduğuna emin olun.',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => 'Bilgi',
 		'keep_history' => 'En az tutulacak makale sayısı',
 		'moved_category_deleted' => 'Bir kategoriyi silerseniz, içerisindeki akışlar <em>%s</em> içerisine yerleşir.',

+ 10 - 10
app/i18n/zh-cn/admin.php

@@ -64,11 +64,11 @@ return array(
 		'files' => '文件相关',
 		'json' => array(
 			'nok' => '找不到 JSON 扩展 (php-json ) 。',
-			'ok' => '已找到 JSON 扩展',
+			'ok' => '已找到 JSON 扩展',
 		),
 		'mbstring' => array(
-			'nok' => 'Cannot find the recommended library mbstring for Unicode.',	//TODO - Translation
-			'ok' => 'You have the recommended library mbstring for Unicode.',	//TODO - Translation
+			'nok' => '找不到推荐的 Unicode 解析库 (mbstring)。',
+			'ok' => '已找到推荐的 Unicode 解析库 (mbstring)。',
 		),
 		'minz' => array(
 			'nok' => '找不到 Minz 框架。',
@@ -163,8 +163,8 @@ return array(
 		'max-categories' => '每用户分类限制',
 		'max-feeds' => '每用户 RSS 源限制',
 		'cookie-duration' => array(
-			'help' => 'in seconds', // @todo translate
-			'number' => 'Duration to keep logged in', // @todo translate
+			'help' => '单位(秒)',
+			'number' => '保持登录的时长',
 		),
 		'registration' => array(
 			'help' => '0 表示无账户数限制',
@@ -183,15 +183,15 @@ return array(
 	'user' => array(
 		'articles_and_size' => '%s 篇文章 (%s)',
 		'create' => '创建新用户',
-		'delete_users' => 'Delete user',	//TODO - Translation
+		'delete_users' => '删除用户',
 		'language' => '语言',
-		'number' => '已有 %d 个户',
-		'numbers' => '已有 %d 个户',
+		'number' => '已有 %d 个户',
+		'numbers' => '已有 %d 个户',
 		'password_form' => '密码<br /><small>(用于 Web-form 登录方式)</small>',
 		'password_format' => '至少 7 个字符',
-		'selected' => 'Selected user',	//TODO - Translation
+		'selected' => '已选中用户',
 		'title' => '用户管理',
-		'update_users' => 'Update user',	//TODO - Translation
+		'update_users' => '更新用户',
 		'user_list' => '用户列表',
 		'username' => '用户名',
 		'users' => '用户',

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

@@ -28,7 +28,7 @@ return array(
 			'seconds' => '秒 (0 表示不超时)',
 			'timeout' => 'HTML5 通知超时时间',
 		),
-		'show_nav_buttons' => 'Show the navigation buttons',	//TODO - Translation
+		'show_nav_buttons' => '显示导航按钮',
 		'theme' => '主题',
 		'title' => '显示',
 		'width' => array(
@@ -53,7 +53,7 @@ return array(
 	'query' => array(
 		'_' => '自定义查询',
 		'deprecated' => '此查询不再有效。相关的分类或 RSS 源已被删除。',
-		'display' => 'Display user query results',	//TODO - Translation
+		'display' => '显示查询结果',
 		'filter' => '生效的过滤器:',
 		'get_all' => '显示所有文章',
 		'get_category' => '显示分类 "%s"',
@@ -64,7 +64,7 @@ return array(
 		'number' => '查询 n°%d',
 		'order_asc' => '由旧到新显示文章',
 		'order_desc' => '由新到旧显示文章',
-		'remove' => 'Remove user query',	//TODO - Translation
+		'remove' => '删除查询',
 		'search' => '搜索 "%s"',
 		'state_0' => '显示所有文章',
 		'state_1' => '显示已读文章',
@@ -128,7 +128,7 @@ return array(
 	),
 	'sharing' => array(
 		'_' => '分享',
-		'add' => 'Add a sharing method',	//TODO - Translation
+		'add' => '添加分享方式',
 		'blogotext' => 'Blogotext',
 		'diaspora' => 'Diaspora*',
 		'email' => 'Email',
@@ -136,7 +136,7 @@ return array(
 		'g+' => 'Google+',
 		'more_information' => '更多信息',
 		'print' => '打印',
-		'remove' => 'Remove sharing method',	//TODO - Translation
+		'remove' => '删除分享方式',
 		'shaarli' => 'Shaarli',
 		'share_name' => '名称',
 		'share_url' => '地址',
@@ -148,31 +148,31 @@ return array(
 		'_' => '快捷键',
 		'article_action' => '文章操作',
 		'auto_share' => '分享',
-		'auto_share_help' => '如果有多种分享式,则会按照它们的编号依次访问。',
+		'auto_share_help' => '如果有多种分享式,则会按照它们的编号依次访问。',
 		'close_dropdown' => '关闭菜单',
 		'collapse_article' => '收起文章',
-		'first_article' => '跳转到第一篇文章',
+		'first_article' => '打开第一篇文章',
 		'focus_search' => '聚焦到搜索框',
 		'global_view' => '切换到全屏视图',
 		'help' => '显示帮助文档',
 		'javascript' => '若要使用快捷键,必须启用 JavaScript',
-		'last_article' => '跳转到最后一篇文章',
+		'last_article' => '打开最后一篇文章',
 		'load_more' => '载入更多文章',
 		'mark_favorite' => '加入收藏',
 		'mark_read' => '设为已读',
 		'navigation' => '浏览',
 		'navigation_help' => '搭配 "Shift" 键,浏览快捷键将生效于 RSS 源。<br/>搭配 "Alt" 键,浏览快捷键将生效于分类。',
-		'navigation_no_mod_help' => 'The following navigation shortcuts do not support modifiers.',	//TODO - Translation
-		'next_article' => '跳转到下一篇文章',
+		'navigation_no_mod_help' => '以下快捷键不支持组合键 (Shift 或 Alt)',
+		'next_article' => '打开下一篇文章',
 		'normal_view' => '切换到普通视图',
 		'other_action' => '其他操作',
-		'previous_article' => '跳转到上一篇文章',
+		'previous_article' => '打开上一篇文章',
 		'reading_view' => '切换到阅读视图',
 		'rss_view' => '在新标签中打开 RSS 视图',
 		'see_on_website' => '在原网站上查看',
 		'shift_for_all_read' => '+ <code>shift</code> 可以将全部文章设为已读',
-		'skip_next_article' => 'Focus next without opening',	//TODO - Translation
-		'skip_previous_article' => 'Focus previous without opening',	//TODO - Translation
+		'skip_next_article' => '跳转到下一篇文章而不打开',
+		'skip_previous_article' => '跳转到上一篇文章而不打开',
 		'title' => '快捷键',
 		'user_filter' => '显示自定义查询',
 		'user_filter_help' => '如果有多个自定义过滤器,则会按照它们的编号依次访问。',

+ 5 - 5
app/i18n/zh-cn/feedback.php

@@ -57,8 +57,8 @@ return array(
 	'sub' => array(
 		'actualize' => '获取',
 		'articles' => array(
-			'marked_read' => 'The selected articles have been marked as read.',	//TODO - Translation
-			'marked_unread' => 'The articles have been marked as unread.',	//TODO - Translation
+			'marked_read' => '选中文章已标记为已读',
+			'marked_unread' => '文章已标记为未读',
 		),
 		'category' => array(
 			'created' => '分类 %s 已创建。',
@@ -80,7 +80,7 @@ return array(
 			'already_subscribed' => '你已订阅 <em>%s</em>',
 			'deleted' => 'RSS 源已删除',
 			'error' => 'RSS 源更新失败',
-			'internal_problem' => 'RSS 源添加失败。<a href="%s">检查 FreshRSS 日志</a> 查看详情。',	//TODO - Translation
+			'internal_problem' => 'RSS 源添加失败。<a href="%s">检查 FreshRSS 日志</a> 查看详情。你可以在URL后附加 <code>#force_feed</code> 从而尝试强制添加。',
 			'invalid_url' => 'URL <em>%s</em> 无效',
 			'n_actualized' => '%d 个 RSS 源已更新',
 			'n_entries_deleted' => '%d 篇文章已删除',
@@ -109,8 +109,8 @@ return array(
 			'error' => '用户 %s 删除失败',
 		),
 		'updated' => array(
-			'_' => 'User %s has been updated',	//TODO - Translation
-			'error' => 'User %s has not been updated',	//TODO - Translation
+			'_' => '用户 %s 已更新',
+			'error' => '用户 %s 更新失败',
 		),
 	),
 );

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

@@ -19,7 +19,7 @@ return array(
 		'see_website' => '查看网站',
 		'submit' => '提交',
 		'truncate' => '删除所有文章',
-		'update' => 'Update',	//TODO - Translation
+		'update' => '更新',	//TODO - Translation
 	),
 	'auth' => array(
 		'email' => 'Email 地址',

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

@@ -40,7 +40,7 @@ return array(
 		'mark_all_read' => '全部设为已读',
 		'mark_cat_read' => '此分类设为已读',
 		'mark_feed_read' => '此源设为已读',
-		'mark_selection_unread' => 'Mark selection as unread',	//TODO - Translation
+		'mark_selection_unread' => '选中设为已读',
 		'newer_first' => '由新到旧',
 		'non-starred' => '显示未收藏',
 		'normal_view' => '普通视图',
@@ -53,11 +53,11 @@ return array(
 		'starred' => '显示收藏',
 		'stats' => '统计',
 		'subscription' => '订阅管理',
-		'tags' => 'My labels',	//TODO - Translation
+		'tags' => '我的标签',
 		'unread' => '显示未读',
 	),
 	'share' => '分享',
 	'tag' => array(
-		'related' => '相关标签',	//TODO - Translation
+		'related' => '文章标签',
 	),
 );

+ 2 - 2
app/i18n/zh-cn/install.php

@@ -69,8 +69,8 @@ return array(
 			'ok' => '已找到推荐的 JSON 解析库。',
 		),
 		'mbstring' => array(
-			'nok' => 'Cannot find the recommended library mbstring for Unicode.',	//TODO - Translation
-			'ok' => 'You have the recommended library mbstring for Unicode.',	//TODO - Translation
+			'nok' => '找不到推荐的 Unicode 解析库 (mbstring)。',
+			'ok' => '已找到推荐的 Unicode 解析库 (mbstring)。',
 		),
 		'minz' => array(
 			'nok' => '找不到 Minz 框架。',

+ 11 - 7
app/i18n/zh-cn/sub.php

@@ -27,12 +27,16 @@ return array(
 			'password' => 'HTTP 密码',
 			'username' => 'HTTP 用户名',
 		),
-		'clear_cache' => 'Always clear cache',	//TODO - Translation
+		'clear_cache' => '总是清除缓存',
 		'css_help' => '用于获取全文(注意,这将耗费更多时间!)',
 		'css_path' => '原文的 CSS 选择器',
 		'description' => '描述',
 		'empty' => '此源为空。请确认它是否正常更新。',
 		'error' => '此源遇到一些问题。请在确认是否能正常访问后重试。',
+		'filteractions' => array(
+			'_' => 'Filter actions',	//TODO - Translation
+			'help' => 'Write one search filter per line.',	//TODO - Translation
+		),
 		'informations' => '信息',
 		'keep_history' => '至少保存的文章数',
 		'moved_category_deleted' => '删除分类时,其中的 RSS 源会自动归类到 <em>%s</em>',
@@ -47,16 +51,16 @@ return array(
 		),
 		'websub' => 'WebSub 即时通知',
 		'show' => array(
-			'all' => 'Show all feeds',	//TODO - Translation
-			'error' => 'Show only feeds with error',	//TODO - Translation
+			'all' => '显示所有 RSS 源',
+			'error' => '仅显示有错误的 RSS 源',
 		),
 		'showing' => array(
-			'error' => 'Showing only feeds with error',	//TODO - Translation
+			'error' => '正在显示有错误的 RSS 源',
 		),
-		'ssl_verify' => 'Verify SSL security',	//TODO - Translation
+		'ssl_verify' => '验证 SSL 安全',
 		'stats' => '统计',
 		'think_to_add' => '你可以添加一些 RSS 源。',
-		'timeout' => 'Timeout in seconds',	//TODO - Translation
+		'timeout' => '超时时间(秒)',
 		'title' => '标题',
 		'title_add' => '添加 RSS 源',
 		'ttl' => '最小自动更新时间',
@@ -72,7 +76,7 @@ return array(
 		'export' => '导出',
 		'export_opml' => '导出 RSS 源列表 (OPML)',
 		'export_starred' => '导出你的收藏',
-		'export_labelled' => 'Export your labelled articles',	//TODO
+		'export_labelled' => '导出有标签的文章',
 		'feed_list' => '%s 文章列表',
 		'file_to_import' => '需要导入的文件<br />(OPML, JSON 或 ZIP)',
 		'file_to_import_no_zip' => '需要导入的文件<br />(OPML 或 JSON)',

+ 3 - 3
app/layout/aside_feed.phtml

@@ -37,13 +37,14 @@
 
 		<?php
 			$t_active = FreshRSS_Context::isCurrentGet('T');
+			$t_show = $t_active || FreshRSS_Context::$user_conf->display_categories;
 		?>
 		<li class="tree-folder category tags<?php echo $t_active ? ' active' : ''; ?>">
 			<div class="tree-folder-title">
 				<a class="dropdown-toggle" href="#"><?php echo _i($t_active ? 'up' : 'down'); ?></a>
 				<a class="title" data-unread="<?php echo format_number($this->nbUnreadTags); ?>" href="<?php echo _url('index', $actual_view, 'get', 'T'); ?>"><?php echo _t('index.menu.tags'); ?></a>
 			</div>
-			<ul class="tree-folder-items<?php echo $t_active ? ' active' : ''; ?>">
+			<ul class="tree-folder-items<?php echo $t_show ? ' active' : ''; ?>">
 				<?php
 					foreach ($this->tags as $tag):
 				?>
@@ -64,8 +65,7 @@
 				$feeds = $cat->feeds();
 				if (!empty($feeds)) {
 					$c_active = FreshRSS_Context::isCurrentGet('c_' . $cat->id());
-					$c_show = $c_active && (!FreshRSS_Context::$user_conf->display_categories ||
-					                        FreshRSS_Context::$current_get['feed']);
+					$c_show = $c_active || FreshRSS_Context::$user_conf->display_categories;
 		?>
 		<li class="tree-folder category<?php echo $c_active ? ' active' : ''; ?>" data-unread="<?php echo $cat->nbNotRead(); ?>">
 			<div class="tree-folder-title">

+ 0 - 10
app/views/entry/read.phtml

@@ -1,17 +1,7 @@
 <?php
 header('Content-Type: application/json; charset=UTF-8');
 
-$url = array(
-	'c' => Minz_Request::controllerName(),
-	'a' => Minz_Request::actionName(),
-	'params' => Minz_Request::fetchGET(),
-);
-
-$url['params']['is_read'] = Minz_Request::param('is_read', true) ? '0' : '1';
-
 FreshRSS::loadStylesAndScripts();
 echo json_encode(array(
-		'url' => str_ireplace('&amp;', '&', Minz_Url::display($url)),
-		'icon' => _i($url['params']['is_read'] === '1' ? 'unread' : 'read'),
 		'tags' => $this->tags,
 	));

+ 13 - 0
app/views/helpers/feed/update.phtml

@@ -234,6 +234,19 @@
 		</div>
 		<?php } ?>
 
+		<legend><?php echo _t('sub.feed.filteractions'); ?></legend>
+		<div class="form-group">
+			<label class="group-name" for="filteractions_read"><?php echo _t('conf.reading.read.when'); ?></label>
+			<div class="group-controls">
+				<textarea name="filteractions_read" id="filteractions_read"><?php
+					foreach ($this->feed->filtersAction('read') as $filterRead) {
+						echo htmlspecialchars($filterRead->getRawInput(), ENT_NOQUOTES, 'UTF-8'), "\n\n";
+					}
+				?></textarea>
+				<?php echo _i('help'); ?> <?php echo _t('sub.feed.filteractions.help'); ?>
+			</div>
+		</div>
+
 		<div class="form-group form-actions">
 			<div class="group-controls">
 				<button type="submit" class="btn btn-important"><?php echo _t('gen.action.submit'); ?></button>

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

@@ -93,7 +93,7 @@
 						<a target="_blank" rel="noreferrer" href="<?php echo $share->url(); ?>"><?php echo $share->name(); ?></a>
 					<?php } else {?>
 						<a href="POST"><?php echo $share->name(); ?></a>
-						<form method="POST" data-url="<?php echo $share->url(); ?>">
+						<form method="POST" action="<?php echo $share->url(); ?>" disabled="disabled">
 							<input type="hidden" value="<?php echo $link; ?>" name="<?php echo $share->field(); ?>"/>
 						</form>
 					<?php } ?>

+ 3 - 2
app/views/helpers/javascript_vars.phtml

@@ -42,7 +42,7 @@ echo htmlspecialchars(json_encode(array(
 		'reading_view' => @$s['reading_view'],
 		'rss_view' => @$s['rss_view'],
 	),
-	'url' => array(
+	'urls' => array(
 		'index' => _url('index', 'index'),
 		'login' => Minz_Url::display(array('c' => 'auth', 'a' => 'login'), 'php'),
 		'logout' => Minz_Url::display(array('c' => 'auth', 'a' => 'logout'), 'php'),
@@ -56,6 +56,7 @@ echo htmlspecialchars(json_encode(array(
 		'category_empty' => _t('gen.js.category_empty'),
 	),
 	'icons' => array(
-		'close' => _i('close'),
+		'read' => rawurlencode(_i('read')),
+		'unread' => rawurlencode(_i('unread')),
 	),
 ), JSON_UNESCAPED_UNICODE), ENT_NOQUOTES, 'UTF-8');

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

@@ -42,7 +42,7 @@ if (!empty($this->entries)) {
 				<a class="bookmark" href="<?php echo Minz_Url::display($favoriteUrl); ?>">
 					<?php echo _i($item->isFavorite() ? 'starred' : 'non-starred'); ?>
 				</a>
-				<a href="<?php echo _url('index', 'reader', 'get', 'f_' . $feed->id()); ?>">
+				<a class="website" href="<?php echo _url('index', 'reader', 'get', 'f_' . $feed->id()); ?>">
 					<img class="favicon" src="<?php echo $feed->favicon(); ?>" alt="✇" /> <span><?php echo $feed->name(); ?></span>
 				</a>
 				<h1 class="title"><a target="_blank" rel="noreferrer" class="go_website" href="<?php echo $item->link(); ?>"><?php echo $item->title(); ?></a></h1>

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

@@ -53,7 +53,7 @@
 		<div class="form-group">
 			<label class="group-name" for="current_user"><?php echo _t('admin.user.selected'); ?></label>
 			<div class="group-controls">
-				<select id="current_user" class="select-change" name="username">
+				<select id="current_user" name="username">
 					<option selected="selected"> </option>
 					<?php foreach (listUsers() as $username) { ?>
 					<option value="<?php echo $username; ?>"><?php echo $username; ?></option>

+ 25 - 0
cli/_cli.php

@@ -3,6 +3,9 @@ if (php_sapi_name() !== 'cli') {
 	die('FreshRSS error: This PHP script may only be invoked from command line!');
 }
 
+const REGEX_INPUT_OPTIONS = '/^--/';
+const REGEX_PARAM_OPTIONS = '/:*$/';
+
 require(__DIR__ . '/../constants.php');
 require(LIB_PATH . '/lib_rss.php');	//Includes class autoloader
 require(LIB_PATH . '/lib_install.php');
@@ -64,3 +67,25 @@ function performRequirementCheck($databaseType) {
 		fail($message);
 	}
 }
+
+function getLongOptions($options, $regex) {
+	$longOptions = array_filter($options, function($a) use ($regex) {
+		return preg_match($regex, $a);
+	});
+	return array_map(function($a) use ($regex) {
+		return preg_replace($regex, '', $a);
+	}, $longOptions);
+}
+
+function validateOptions($input, $params) {
+	$sanitizeInput = getLongOptions($input, REGEX_INPUT_OPTIONS);
+	$sanitizeParams = getLongOptions($params, REGEX_PARAM_OPTIONS);
+	$unknownOptions = array_diff($sanitizeInput, $sanitizeParams);
+
+	if (0 === count($unknownOptions)) {
+		return true;
+	}
+
+	fwrite(STDERR, sprintf("FreshRSS error: unknown options: %s\n", implode (', ', $unknownOptions)));
+	return false;
+}

+ 1 - 1
cli/_update-or-create-user.php

@@ -22,7 +22,7 @@ if (!$isUpdate) {
 
 $options = getopt('', $params);
 
-if (empty($options['user'])) {
+if (!validateOptions($argv, $params) || empty($options['user'])) {
 	fail('Usage: ' . basename($_SERVER['SCRIPT_FILENAME']) .
 		" --user username ( --password 'password' --api_password 'api_password'" .
 		" --language en --email user@example.net --token 'longRandomString'" .

+ 6 - 4
cli/actualize-user.php

@@ -2,11 +2,13 @@
 <?php
 require(__DIR__ . '/_cli.php');
 
-$options = getopt('', array(
-		'user:',
-	));
+$params = array(
+	'user:',
+);
 
-if (empty($options['user'])) {
+$options = getopt('', $params);
+
+if (!validateOptions($argv, $params) || empty($options['user'])) {
 	fail('Usage: ' . basename(__FILE__) . " --user username");
 }
 

+ 6 - 4
cli/db-optimize.php

@@ -2,11 +2,13 @@
 <?php
 require(__DIR__ . '/_cli.php');
 
-$options = getopt('', array(
-		'user:',
-	));
+$params = array(
+	'user:',
+);
 
-if (empty($options['user'])) {
+$options = getopt('', $params);
+
+if (!validateOptions($argv, $params) || empty($options['user'])) {
 	fail('Usage: ' . basename(__FILE__) . " --user username");
 }
 

+ 6 - 4
cli/delete-user.php

@@ -2,11 +2,13 @@
 <?php
 require(__DIR__ . '/_cli.php');
 
-$options = getopt('', array(
-		'user:',
-	));
+$params = array(
+	'user:',
+);
 
-if (empty($options['user'])) {
+$options = getopt('', $params);
+
+if (!validateOptions($argv, $params) || empty($options['user'])) {
 	fail('Usage: ' . basename(__FILE__) . " --user username");
 }
 $username = $options['user'];

+ 3 - 3
cli/do-install.php

@@ -31,10 +31,10 @@ $dBparams = array(
 
 $options = getopt('', array_merge($params, $dBparams));
 
-if (empty($options['default_user'])) {
+if (!validateOptions($argv, array_merge($params, $dBparams)) || empty($options['default_user'])) {
 	fail('Usage: ' . basename(__FILE__) . " --default_user admin ( --auth_type form" .
-		" --environment production --base_url https://rss.example.net" .
-		" --language en --title FreshRSS --allow_anonymous --api_enabled" .
+		" --environment production --base_url https://rss.example.net --allow_robots" .
+		" --language en --title FreshRSS --allow_anonymous --allow_anonymous_refresh --api_enabled" .
 		" --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123" .
 		" --db-base freshrss --db-prefix freshrss_ --disable_update )");
 }

+ 6 - 4
cli/export-opml-for-user.php

@@ -2,11 +2,13 @@
 <?php
 require(__DIR__ . '/_cli.php');
 
-$options = getopt('', array(
-		'user:',
-	));
+$params = array(
+	'user:',
+);
 
-if (empty($options['user'])) {
+$options = getopt('', $params);
+
+if (!validateOptions($argv, $params) || empty($options['user'])) {
 	fail('Usage: ' . basename(__FILE__) . " --user username > /path/to/file.opml.xml");
 }
 

+ 7 - 5
cli/export-zip-for-user.php

@@ -2,12 +2,14 @@
 <?php
 require(__DIR__ . '/_cli.php');
 
-$options = getopt('', array(
-		'user:',
-		'max-feed-entries:',
-	));
+$params = array(
+	'user:',
+	'max-feed-entries:',
+);
 
-if (empty($options['user'])) {
+$options = getopt('', $params);
+
+if (!validateOptions($argv, $params) || empty($options['user'])) {
 	fail('Usage: ' . basename(__FILE__) . " --user username ( --max-feed-entries 100 ) > /path/to/file.zip");
 }
 

+ 16 - 0
cli/i18n/ignore/kr.php

@@ -8,6 +8,18 @@ return array(
 	'conf.sharing.shaarli',
 	'conf.sharing.twitter',
 	'conf.sharing.wallabag',
+	'gen.date.Apr',
+	'gen.date.Aug',
+	'gen.date.Dec',
+	'gen.date.Feb',
+	'gen.date.Jan',
+	'gen.date.Jul',
+	'gen.date.Jun',
+	'gen.date.Mar',
+	'gen.date.May',
+	'gen.date.Nov',
+	'gen.date.Oct',
+	'gen.date.Sep',
 	'gen.lang.cz',
 	'gen.lang.de',
 	'gen.lang.en',
@@ -17,6 +29,7 @@ return array(
 	'gen.lang.it',
 	'gen.lang.kr',
 	'gen.lang.nl',
+	'gen.lang.oc',
 	'gen.lang.pt-br',
 	'gen.lang.ru',
 	'gen.lang.tr',
@@ -29,8 +42,11 @@ return array(
 	'gen.share.g+',
 	'gen.share.gnusocial',
 	'gen.share.jdh',
+	'gen.share.linkedin',
 	'gen.share.mastodon',
 	'gen.share.movim',
+	'gen.share.pinboard',
+	'gen.share.pocket',
 	'gen.share.shaarli',
 	'gen.share.twitter',
 	'gen.share.wallabag',

+ 4 - 0
cli/i18n/ignore/zh-cn.php

@@ -18,6 +18,7 @@ return array(
 	'gen.lang.it',
 	'gen.lang.kr',
 	'gen.lang.nl',
+	'gen.lang.oc',
 	'gen.lang.pt-br',
 	'gen.lang.ru',
 	'gen.lang.tr',
@@ -29,8 +30,11 @@ return array(
 	'gen.share.g+',
 	'gen.share.gnusocial',
 	'gen.share.jdh',
+	'gen.share.linkedin',
 	'gen.share.mastodon',
 	'gen.share.movim',
+	'gen.share.pinboard',
+	'gen.share.pocket',
 	'gen.share.shaarli',
 	'gen.share.twitter',
 	'gen.share.wallabag',

+ 7 - 5
cli/import-for-user.php

@@ -2,12 +2,14 @@
 <?php
 require(__DIR__ . '/_cli.php');
 
-$options = getopt('', array(
-		'user:',
-		'filename:',
-	));
+$params = array(
+	'user:',
+	'filename:',
+);
 
-if (empty($options['user']) || empty($options['filename'])) {
+$options = getopt('', $params);
+
+if (!validateOptions($argv, $params) || empty($options['user']) || empty($options['filename'])) {
 	fail('Usage: ' . basename(__FILE__) . " --user username --filename /path/to/file.ext");
 }
 

+ 2 - 0
cli/list-users.php

@@ -13,3 +13,5 @@ if (FreshRSS_Context::$system_conf->default_user !== ''
 foreach ($users as $user) {
 	echo $user, "\n";
 }
+
+done();

+ 8 - 0
cli/reconfigure.php

@@ -27,6 +27,14 @@ $dBparams = array(
 
 $options = getopt('', array_merge($params, $dBparams));
 
+if (!validateOptions($argv, array_merge($params, $dBparams))) {
+	fail('Usage: ' . basename(__FILE__) . " --default_user admin ( --auth_type form" .
+		" --environment production --base_url https://rss.example.net --allow_robots" .
+		" --language en --title FreshRSS --allow_anonymous --allow_anonymous_refresh --api_enabled" .
+		" --db-type mysql --db-host localhost:3306 --db-user freshrss --db-password dbPassword123" .
+		" --db-base freshrss --db-prefix freshrss_ --disable_update )");
+}
+
 fwrite(STDERR, 'Reconfiguring FreshRSS…' . "\n");
 
 $config = Minz_Configuration::get('system');

+ 50 - 29
cli/user-info.php

@@ -2,19 +2,46 @@
 <?php
 require(__DIR__ . '/_cli.php');
 
-$options = getopt('h', array(
-		'user:',
-	));
+const DATA_FORMAT = "%-7s | %-20s | %-25s | %-15s | %-10s | %-10s | %-10s | %-10s | %-10s | %-10s\n";
+
+$params = array(
+	'user:',
+	'header',
+);
+$options = getopt('h', $params);
+
+if (!validateOptions($argv, $params)) {
+	fail('Usage: ' . basename(__FILE__) . ' (-h --header --user username --user username …)');
+}
 
 if (empty($options['user'])) {
-	fail('Usage: ' . basename(__FILE__) . " -h --user username");
+	$users = listUsers();
+} elseif (is_array($options['user'])) {
+	$users = $options['user'];
+} else {
+	$users = array($options['user']);
 }
 
-$users = $options['user'] === '*' ? listUsers() : array($options['user']);
+sort($users);
+
+if (array_key_exists('header', $options)) {
+	printf(
+		DATA_FORMAT,
+		'default',
+		'user',
+		'last update',
+		'space used',
+		'categories',
+		'feeds',
+		'reads',
+		'unreads',
+		'favourites',
+		'tags'
+	);
+}
 
 foreach ($users as $username) {
 	$username = cliInitUser($username);
-	echo $username === FreshRSS_Context::$system_conf->default_user ? '*' : ' ', "\t";
 
 	$catDAO = FreshRSS_Factory::createCategoryDao();
 	$feedDAO = FreshRSS_Factory::createFeedDao($username);
@@ -25,29 +52,23 @@ foreach ($users as $username) {
 	$nbEntries = $entryDAO->countUnreadRead();
 	$nbFavorites = $entryDAO->countUnreadReadFavorites();
 
+	$data = array(
+		'default' => $username === FreshRSS_Context::$system_conf->default_user ? '*' : '',
+		'user' => $username,
+		'lastUpdate' => FreshRSS_UserDAO::mtime($username),
+		'spaceUsed' => $databaseDAO->size(),
+		'categories' => $catDAO->count(),
+		'feeds' => count($feedDAO->listFeedsIds()),
+		'reads' => $nbEntries['read'],
+		'unreads' => $nbEntries['unread'],
+		'favourites' => $nbFavorites['all'],
+		'tags' => $tagDAO->count(),
+	);
 	if (isset($options['h'])) {	//Human format
-		echo
-			$username, "\t",
-			date('c', FreshRSS_UserDAO::mtime($username)), "\t",
-			format_bytes($databaseDAO->size()), "\t",
-			$catDAO->count(), " categories\t",
-			count($feedDAO->listFeedsIds()), " feeds\t",
-			$nbEntries['read'], " reads\t",
-			$nbEntries['unread'], " unreads\t",
-			$nbFavorites['all'], " favourites\t",
-			$tagDAO->count(), " tags\t",
-			"\n";
-	} else {
-		echo
-			$username, "\t",
-			FreshRSS_UserDAO::mtime($username), "\t",
-			$databaseDAO->size(), "\t",
-			$catDAO->count(), "\t",
-			count($feedDAO->listFeedsIds()), "\t",
-			$nbEntries['read'], "\t",
-			$nbEntries['unread'], "\t",
-			$nbFavorites['all'], "\t",
-			$tagDAO->count(), "\t",
-			"\n";
+		$data['lastUpdate'] = date('c', $data['lastUpdate']);
+		$data['spaceUsed'] = format_bytes($data['spaceUsed']);
 	}
+	vprintf(DATA_FORMAT, $data);
 }
+
+done();

+ 2 - 2
constants.php

@@ -2,7 +2,7 @@
 //NB: Do not edit; use ./constants.local.php instead.
 
 //<Not customisable>
-define('FRESHRSS_VERSION', '1.13.1');
+define('FRESHRSS_VERSION', '1.14.0');
 define('FRESHRSS_WEBSITE', 'https://freshrss.org');
 define('FRESHRSS_WIKI', 'https://freshrss.github.io/FreshRSS/');
 
@@ -33,7 +33,7 @@ safe_define('FRESHRSS_USERAGENT', 'FreshRSS/' . FRESHRSS_VERSION . ' (' . PHP_OS
 safe_define('PHP_COMPRESSION', false);
 
 // For cases when syslog is not available
-safe_define('COPY_SYSLOG_TO_STDERR', isset($_SERVER['COPY_SYSLOG_TO_STDERR']) ? filter_var($_SERVER['COPY_SYSLOG_TO_STDERR'], FILTER_VALIDATE_BOOLEAN) : false);
+safe_define('COPY_SYSLOG_TO_STDERR', filter_var(getenv('COPY_SYSLOG_TO_STDERR'), FILTER_VALIDATE_BOOLEAN));
 
 // Maximum log file size in Bytes, before it will be divided by two
 safe_define('MAX_LOG_SIZE', 1048576);

+ 3 - 3
docs/en/users/06_Fever_API.md

@@ -20,6 +20,9 @@ Then point your mobile application to the URL of `fever.php` (e.g. `https://fres
 
 Tested with:
 
+- Android
+  - [Readably](https://play.google.com/store/apps/details?id=com.isaiasmatewos.readably)
+
 - iOS
   - [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303)
   - [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153)
@@ -28,9 +31,6 @@ Tested with:
 - MacOS
   - [Readkit](https://itunes.apple.com/app/readkit/id588726889)
 
-- Android
-  -Until now, we don't know about compatible Android clients. Please leave your feedback, if you tested the Fever API with Android apps.
-  - Please note, that *Press* is NOT compatible: it was a popular RSS client with Fever support, but its development stopped a while ago. It uses the Fever API in a wrong way, which we don't support.
 
 ## Features
 

+ 3 - 0
docs/fr/users/06_Fever_API.md

@@ -7,6 +7,9 @@ et des généralités sur l’accès par API.
 
 Testé avec:
 
+- Android
+  [Readably](https://play.google.com/store/apps/details?id=com.isaiasmatewos.readably)
+
 - iOS
   - [Fiery Feeds](https://itunes.apple.com/app/fiery-feeds-rss-reader/id1158763303)
   - [Unread](https://itunes.apple.com/app/unread-rss-reader/id1252376153)

+ 25 - 0
lib/Minz/Request.php

@@ -95,6 +95,7 @@ class Minz_Request {
 	 */
 	public static function init() {
 		self::magicQuotesOff();
+		self::initJSON();
 	}
 
 	/**
@@ -237,6 +238,30 @@ class Minz_Request {
 		}
 	}
 
+	/**
+	 * Allows receiving POST data as application/json
+	 */
+	private static function initJSON() {
+		$contentType = isset($_SERVER['CONTENT_TYPE']) ? $_SERVER['CONTENT_TYPE'] : '';
+		if ($contentType == '') {	//PHP < 5.3.16
+			$contentType = isset($_SERVER['HTTP_CONTENT_TYPE']) ? $_SERVER['HTTP_CONTENT_TYPE'] : '';
+		}
+		$contentType = strtolower(trim($contentType));
+		if ($contentType === 'application/json') {
+			$ORIGINAL_INPUT = file_get_contents('php://input', false, null, 0, 1048576);
+			if ($ORIGINAL_INPUT != '') {
+				$json = json_decode($ORIGINAL_INPUT, true);
+				if ($json != null) {
+					foreach ($json as $k => $v) {
+						if (!isset($_POST[$k])) {
+							$_POST[$k] = $v;
+						}
+					}
+				}
+			}
+		}
+	}
+
 	/**
 	 * Permet de récupérer une variable de type $_POST
 	 * @param $param nom de la variable

+ 2 - 7
lib/lib_rss.php

@@ -170,7 +170,7 @@ function format_bytes($bytes, $precision = 2, $system = 'IEC') {
 	$pow = $bytes === 0 ? 0 : floor(log($bytes) / log($base));
 	$pow = min($pow, count($units) - 1);
 	$bytes /= pow($base, $pow);
-	return format_number($bytes, $precision) . ' ' . $units[$pow];
+	return format_number($bytes, $precision) . ' ' . $units[$pow];
 }
 
 function timestamptodate ($t, $hour = true) {
@@ -303,12 +303,7 @@ function lazyimg($content) {
 
 function uTimeString() {
 	$t = @gettimeofday();
-	return $t['sec'] . str_pad($t['usec'], 6, '0');
-}
-
-function uSecString() {
-	$t = @gettimeofday();
-	return str_pad($t['usec'], 6, '0');
+	return $t['sec'] . str_pad($t['usec'], 6, '0', STR_PAD_LEFT);
 }
 
 function invalidateHttpCache($username = '') {

+ 1 - 1
p/api/.htaccess

@@ -1,5 +1,5 @@
 <IfModule mod_setenvif.c>
-	SetEnvIf "^Authorization$" "(.*)" HTTP_AUTHORIZATION=$1
+	SetEnvIfNoCase "^Authorization$" "(.*)" HTTP_AUTHORIZATION=$1
 </IfModule>
 <IfModule !mod_setenvif.c>
 	<IfModule mod_rewrite.c>

+ 7 - 7
p/api/fever.php

@@ -324,9 +324,9 @@ class FeverAPI
 			$feeds[] = array(
 				'id' => $feed->id(),
 				'favicon_id' => $feed->id(),
-				'title' => $feed->name(),
-				'url' => $feed->url(),
-				'site_url' => $feed->website(),
+				'title' => escapeToUnicodeAlternative($feed->name(), true),
+				'url' => htmlspecialchars_decode($feed->url(), ENT_QUOTES),
+				'site_url' => htmlspecialchars_decode($feed->website(), ENT_QUOTES),
 				'is_spark' => 0, // unsupported
 				'last_updated_on_time' => $feed->lastUpdate(),
 			);
@@ -349,7 +349,7 @@ class FeverAPI
 		foreach ($categories as $category) {
 			$groups[] = array(
 				'id' => $category->id(),
-				'title' => $category->name(),
+				'title' => escapeToUnicodeAlternative($category->name(), true),
 			);
 		}
 
@@ -535,10 +535,10 @@ class FeverAPI
 			$items[] = array(
 				'id' => $entry->id(),
 				'feed_id' => $entry->feed(false),
-				'title' => $entry->title(),
-				'author' => $entry->authors(true),
+				'title' => escapeToUnicodeAlternative($entry->title(), false),
+				'author' => escapeToUnicodeAlternative(trim($entry->authors(true), '; '), false),
 				'html' => $entry->content(),
-				'url' => $entry->link(),
+				'url' => htmlspecialchars_decode($entry->link(), ENT_QUOTES),
 				'is_saved' => $entry->isFavorite() ? 1 : 0,
 				'is_read' => $entry->isRead() ? 1 : 0,
 				'created_on_time' => $entry->date(true),

+ 99 - 79
p/scripts/category.js

@@ -1,6 +1,6 @@
 "use strict";
-/* globals i18n */
-/* jshint globalstrict: true */
+/* globals context */
+/* jshint esversion:6, strict:global */
 
 var loading = false,
 	dnd_successful = false;
@@ -9,7 +9,7 @@ function dragend_process(t) {
 	t.setAttribute('draggable', 'false');
 
 	if (loading) {
-		window.setTimeout(function() {
+		setTimeout(function() {
 			dragend_process(t);
 		}, 50);
 		return;
@@ -20,11 +20,11 @@ function dragend_process(t) {
 		t.style.opacity = '';
 		t.setAttribute('draggable', 'true');
 	} else {
-		var parent = $(t.parentNode);
-		$(t).remove();
+		const p = t.parentElement;
+		t.remove();
 
-		if (parent.children().length <= 0) {
-			parent.append('<li class="item disabled" dropzone="move">' + i18n.category_empty + '</li>');
+		if (p.childElementCount <= 0) {
+			p.insertAdjacentHTML('beforeend', '<li class="item disabled" dropzone="move">' + context.i18n.category_empty + '</li>');
 		}
 	}
 }
@@ -33,89 +33,109 @@ var dragFeedId = '',
 	dragHtml = '';
 
 function init_draggable() {
-	if (!(window.$ && window.i18n)) {
+	if (!window.context) {
 		if (window.console) {
-			console.log('FreshRSS waiting for JS…');
+			console.log('FreshRSS category waiting for JS…');
 		}
-		window.setTimeout(init_draggable, 50);
+		setTimeout(init_draggable, 50);
 		return;
 	}
 
-	var draggable = '[draggable="true"]',
-	    dropzone = '[dropzone="move"]';
-
-	$('.drop-section').on('dragstart', draggable, function(e) {
-		var drag = $(e.target).closest('[draggable]')[0];
-		e.originalEvent.dataTransfer.effectAllowed = 'move';
-		dragHtml = drag.outerHTML;
-		dragFeedId = drag.getAttribute('data-feed-id');
-		e.originalEvent.dataTransfer.setData('text', dragFeedId);
-		drag.style.opacity = 0.3;
-
-		dnd_successful = false;
-	});
-	$('.drop-section').on('dragend', draggable, function(e) {
-		dragend_process(e.target);
-	});
-
-	$('.drop-section').on('dragenter', dropzone, function(e) {
-		$(this).addClass('drag-hover');
-
-		e.preventDefault();
-	});
-	$('.drop-section').on('dragleave', dropzone, function(e) {
-		var pos_this = $(this).position(),
-		    scroll_top = $(document).scrollTop(),
-		    top = pos_this.top,
-		    left = pos_this.left,
-		    right = left + $(this).width(),
-		    bottom = top + $(this).height(),
-		    mouse_x = e.originalEvent.screenX,
-		    mouse_y = e.originalEvent.clientY + scroll_top;
-
-		if (left <= mouse_x && mouse_x <= right &&
-			top <= mouse_y && mouse_y <= bottom) {
-			// HACK because dragleave is triggered when hovering children!
-			return;
-		}
-		$(this).removeClass('drag-hover');
-	});
-	$('.drop-section').on('dragover', dropzone, function(e) {
-		e.originalEvent.dataTransfer.dropEffect = "move";
-
-		e.preventDefault();
-		return false;
-	});
-	$('.drop-section').on('drop', dropzone, function(e) {
-		loading = true;
-
-		$.ajax({
-			type: 'POST',
-			url: './?c=feed&a=move',
-			data: {
-				f_id: dragFeedId,
-				c_id: e.target.parentNode.getAttribute('data-cat-id'),
-				_csrf: context.csrf,
+	const draggable = '[draggable="true"]',
+		dropzone = '[dropzone="move"]',
+		dropSection = document.querySelector('.drop-section');
+
+	dropSection.ondragstart = function(ev) {
+			const li = ev.target.closest ? ev.target.closest(draggable) : null;
+			if (li) {
+				const drag = ev.target.closest('[draggable]');
+				ev.dataTransfer.effectAllowed = 'move';
+				dragHtml = drag.outerHTML;
+				dragFeedId = drag.getAttribute('data-feed-id');
+				ev.dataTransfer.setData('text', dragFeedId);
+				drag.style.opacity = 0.3;
+				dnd_successful = false;
 			}
-		}).done(function() {
-			$(e.target).after(dragHtml);
-			if ($(e.target).hasClass('disabled')) {
-				$(e.target).remove();
+		};
+
+	dropSection.ondragend = function(ev) {
+			const li = ev.target.closest ? ev.target.closest(draggable) : null;
+			if (li) {
+				dragend_process(li);
 			}
-			dnd_successful = true;
-		}).always(function() {
-			loading = false;
-			dragFeedId = '';
-			dragHtml = '';
-		});
+		};
 
-		$(this).removeClass('drag-hover');
+	dropSection.ondragenter = function(ev) {
+			const li = ev.target.closest ? ev.target.closest(dropzone) : null;
+			if (li) {
+				li.classList.add('drag-hover');
+				return false;
+			}
+		};
+
+	dropSection.onddragleave = function(ev) {
+			const li = ev.target.closest ? ev.target.closest(dropzone) : null;
+			if (li) {
+				const scroll_top = document.documentElement.scrollTop,
+					top = li.offsetTop,
+					left = li.offsetLeft,
+					right = left + li.clientWidth,
+					bottom = top + li.clientHeight,
+					mouse_x = ev.screenX,
+					mouse_y = ev.clientY + scroll_top;
+
+				if (left <= mouse_x && mouse_x <= right &&
+					top <= mouse_y && mouse_y <= bottom) {
+					// HACK because dragleave is triggered when hovering children!
+					return;
+				}
+				li.classList.remove('drag-hover');
+			}
+		};
 
-		e.preventDefault();
-	});
+	dropSection.ondragover = function(ev) {
+			const li = ev.target.closest ? ev.target.closest(dropzone) : null;
+			if (li) {
+				ev.dataTransfer.dropEffect = "move";
+				return false;
+			}
+		};
+
+	dropSection.ondrop = function(ev) {
+			const li = ev.target.closest ? ev.target.closest(dropzone) : null;
+			if (li) {
+				loading = true;
+
+				const req = new XMLHttpRequest();
+				req.open('POST', './?c=feed&a=move', true);
+				req.responseType = 'json';
+				req.onload = function (e) {
+						if (this.status == 200) {
+							li.insertAdjacentHTML('afterend', dragHtml);
+							if (li.classList.contains('disabled')) {
+								li.remove();
+							}
+							dnd_successful = true;
+						}
+					};
+				req.onloadend = function (e) {
+						loading = false;
+						dragFeedId = '';
+						dragHtml = '';
+					};
+				req.setRequestHeader('Content-Type', 'application/json');
+				req.send(JSON.stringify({
+						f_id: dragFeedId,
+						c_id: li.parentElement.getAttribute('data-cat-id'),
+						_csrf: context.csrf,
+					}));
+
+				li.classList.remove('drag-hover');
+				return false;
+			}
+		};
 }
 
-
 if (document.readyState && document.readyState !== 'loading') {
 	init_draggable();
 } else if (document.addEventListener) {

+ 241 - 0
p/scripts/extra.js

@@ -0,0 +1,241 @@
+"use strict";
+/* globals context, openNotification, xmlHttpRequestJson */
+/* jshint esversion:6, strict:global */
+
+//<crypto form (Web login)>
+function poormanSalt() {	//If crypto.getRandomValues is not available
+	const base = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789/abcdefghijklmnopqrstuvwxyz';
+	let text = '$2a$04$';
+	for (let i = 22; i > 0; i--) {
+		text += base.charAt(Math.floor(Math.random() * 64));
+	}
+	return text;
+}
+
+function init_crypto_form() {
+	/* globals dcodeIO */
+	const crypto_form = document.getElementById('crypto-form');
+	if (!crypto_form) {
+		return;
+	}
+
+	if (!(window.dcodeIO)) {
+		if (window.console) {
+			console.log('FreshRSS waiting for bcrypt.js…');
+		}
+		setTimeout(init_crypto_form, 100);
+		return;
+	}
+
+	crypto_form.onsubmit = function (e) {
+		const submit_button = this.querySelector('button[type="submit"]');
+		submit_button.disabled = true;
+		let success = false;
+
+		const req = new XMLHttpRequest();
+		req.open('GET', './?c=javascript&a=nonce&user=' + document.getElementById('username').value, false);
+		req.onerror = function () {
+				openNotification('Communication error!', 'bad');
+			};
+		req.send();
+		if (req.status == 200) {
+			const json = xmlHttpRequestJson(req);
+			if (!json.salt1 || !json.nonce) {
+				openNotification('Invalid user!', 'bad');
+			} else {
+				try {
+					const strong = window.Uint32Array && window.crypto && (typeof window.crypto.getRandomValues === 'function'),
+						s = dcodeIO.bcrypt.hashSync(document.getElementById('passwordPlain').value, json.salt1),
+						c = dcodeIO.bcrypt.hashSync(json.nonce + s, strong ? dcodeIO.bcrypt.genSaltSync(4) : poormanSalt());
+					document.getElementById('challenge').value = c;
+					if (!s || !c) {
+						openNotification('Crypto error!', 'bad');
+					} else {
+						success = true;
+					}
+				} catch (ex) {
+					openNotification('Crypto exception! ' + ex, 'bad');
+				}
+			}
+		} else {
+			req.onerror();
+		}
+
+		submit_button.disabled = false;
+		return success;
+	};
+}
+//</crypto form (Web login)>
+
+function init_share_observers() {
+	let shares = document.querySelectorAll('.group-share').length;
+	const shareAdd = document.querySelector('.share.add');
+	if (shareAdd) {
+		shareAdd.onclick = function (ev) {
+				const s = this.parentElement.querySelector('select'),
+					opt = s.options[s.selectedIndex];
+				let row = this.closest('form').getAttribute('data-' + opt.getAttribute('data-form'));
+				row = row.replace(/##label##/g, opt.text);
+				row = row.replace(/##type##/g, opt.value);
+				row = row.replace(/##help##/g, opt.getAttribute('data-help'));
+				row = row.replace(/##key##/g, shares);
+				row = row.replace(/##method##/g, opt.getAttribute('data-method'));
+				row = row.replace(/##field##/g, opt.getAttribute('data-field'));
+				this.closest('.form-group').insertAdjacentHTML('beforebegin', row);
+				shares++;
+				return false;
+			};
+	}
+}
+
+
+function init_remove_observers() {
+	document.querySelectorAll('.post').forEach(function (div) {
+			div.onclick = function (ev) {
+					const a = ev.target.closest('a.remove');
+					if (a) {
+						const remove_what = a.getAttribute('data-remove');
+						if (remove_what !== undefined) {
+							const d = document.getElementById(remove_what);
+							if (d) {
+								d.remove();
+							}
+						}
+						return false;
+					}
+				};
+		});
+}
+
+function init_feed_observers() {
+	const s = document.getElementById('category');
+	if (s && s.matches('select')) {
+		s.onchange = function (ev) {
+				const detail = document.getElementById('new_category_name').parentElement;
+				if (this.value === 'nc') {
+					detail.setAttribute('aria-hidden', 'false');
+					detail.querySelector('input').focus();
+				} else {
+					detail.setAttribute('aria-hidden', 'true');
+				}
+			};
+	}
+}
+
+function init_password_observers() {
+	document.querySelectorAll('.toggle-password').forEach(function (a) {
+			a.onmousedown = function (ev) {
+					const passwordField = document.getElementById(this.getAttribute('data-toggle'));
+					passwordField.setAttribute('type', 'text');
+					this.classList.add('active');
+					return false;
+				};
+			a.onmouseup = function (ev) {
+					const passwordField = document.getElementById(this.getAttribute('data-toggle'));
+					passwordField.setAttribute('type', 'password');
+					this.classList.remove('active');
+					return false;
+				};
+		});
+}
+
+function init_select_observers() {
+	document.querySelectorAll('.select-change').forEach(function (s) {
+			s.onchange = function (ev) {
+					const opt = s.options[s.selectedIndex],
+						url = opt.getAttribute('data-url');
+					if (url) {
+						s.form.querySelectorAll('[type=submit]').forEach(function (b) {
+								b.disabled = true;
+							});
+						location.href = url;
+					}
+				};
+		});
+}
+
+function init_slider_observers() {
+	const slider = document.getElementById('slider'),
+		closer = document.getElementById('close-slider');
+	if (!slider) {
+		return;
+	}
+
+	document.querySelector('.post').onclick = function (ev) {
+			const a = ev.target.closest('.open-slider');
+			if (a) {
+				if (!context.ajax_loading) {
+					context.ajax_loading = true;
+
+					const req = new XMLHttpRequest();
+					req.open('GET', a.href + '&ajax=1', true);
+					req.responseType = 'document';
+					req.onload = function (e) {
+							slider.innerHTML = this.response.body.innerHTML;
+							slider.classList.add('active');
+							closer.classList.add('active');
+							context.ajax_loading = false;
+						};
+					req.send();
+					return false;
+				}
+			}
+		};
+
+	closer.onclick = function (ev) {
+			closer.classList.remove('active');
+			slider.classList.remove('active');
+			return false;
+		};
+}
+
+function init_configuration_alert() {
+	window.onsubmit = function (e) {
+			window.hasSubmit = true;
+		};
+	window.onbeforeunload = function (e) {
+			if (window.hasSubmit) {
+				return;
+			}
+			const ds = document.querySelectorAll('[data-leave-validation]');
+			for (let i = ds.length - 1; i >= 0; i--) {
+				const input = ds[i];
+				if (input.type === 'checkbox' || input.type === 'radio') {
+					if (input.checked != input.getAttribute('data-leave-validation')) {
+						return false;
+					}
+				} else if (input.value != input.getAttribute('data-leave-validation')) {
+					return false;
+				}
+			}
+		};
+}
+
+function init_extra() {
+	if (!window.context) {
+		if (window.console) {
+			console.log('FreshRSS extra waiting for JS…');
+		}
+		window.setTimeout(init_extra, 50);	//Wait for all js to be loaded
+		return;
+	}
+	init_crypto_form();
+	init_share_observers();
+	init_remove_observers();
+	init_feed_observers();
+	init_password_observers();
+	init_select_observers();
+	init_slider_observers();
+	init_configuration_alert();
+}
+
+if (document.readyState && document.readyState !== 'loading') {
+	init_extra();
+} else {
+	document.addEventListener('DOMContentLoaded', function () {
+			if (window.console) {
+				console.log('FreshRSS extra waiting for DOMContentLoaded…');
+			}
+			init_extra();
+		}, false);
+}

+ 74 - 54
p/scripts/global_view.js

@@ -1,6 +1,6 @@
 "use strict";
-/* globals init_load_more, init_posts, init_stream */
-/* jshint globalstrict: true */
+/* globals context, init_load_more, init_posts, init_stream */
+/* jshint esversion:6, strict:global */
 
 var panel_loading = false;
 
@@ -11,68 +11,88 @@ function load_panel(link) {
 
 	panel_loading = true;
 
-	$.get(link, function (data) {
-		$("#panel").append($(".nav_menu, #stream .day, #stream .flux, #stream .pagination, #stream.prompt", data));
-
-		$("#panel .nav_menu").children().not("#nav_menu_read_all").remove();
-
-		init_load_more($("#panel"));
-		init_posts();
-
-		$("#overlay").fadeIn();
-		$("#panel").slideToggle();
-
-		// force le démarrage du scroll en haut.
-		// Sans ça, si l'on scroll en lisant une catégorie par exemple,
-		// en en ouvrant une autre ensuite, on se retrouve au même point de scroll
-		$("#panel").scrollTop(0);
-		$(window).scrollTop(0);
-
-		$('#panel').on('click', '#nav_menu_read_all button, #bigMarkAsRead', function () {
-			console.log($(this).attr("formaction"));
-			$.ajax({
-				type: "POST",
-				url: $(this).attr("formaction"),
-				data: {
-					_csrf: context.csrf,
-				},
-				async: false
-			});
-			window.location.reload(false);
-			return false;
-		});
-
-		panel_loading = false;
-	});
+	const req = new XMLHttpRequest();
+	req.open('GET', link, true);
+	req.responseType = 'document';
+	req.onload = function (e) {
+			if (this.status != 200) {
+				return;
+			}
+			const html = this.response,
+				foreign = html.querySelectorAll('.nav_menu, #stream .day, #stream .flux, #stream .pagination, #stream.prompt'),
+				panel = document.getElementById('panel');
+			foreign.forEach(function (el) {
+					panel.appendChild(document.adoptNode(el));
+				});
+			panel.querySelectorAll('.nav_menu > :not([id="nav_menu_read_all"])').forEach(function (el) {
+					el.remove();
+				});
+
+			init_load_more(panel);
+			init_posts();
+
+			document.getElementById('overlay').classList.add('visible');
+			panel.classList.add('visible');
+
+			// force le démarrage du scroll en haut.
+			// Sans ça, si l'on scroll en lisant une catégorie par exemple,
+			// en en ouvrant une autre ensuite, on se retrouve au même point de scroll
+			panel.scrollTop = 0;
+			document.documentElement.scrollTop = 0;
+
+			//We already have a click listener in main.js
+			panel.addEventListener('click', function (ev) {
+					const b = ev.target.closest('#nav_menu_read_all button, #bigMarkAsRead');
+					if (b) {
+						console.log(b.formAction);
+
+						const req2 = new XMLHttpRequest();
+						req2.open('POST', b.formAction, false);
+						req2.setRequestHeader('Content-Type', 'application/json');
+						req2.send(JSON.stringify({
+								_csrf: context.csrf,
+							}));
+						if (req2.status == 200) {
+							location.reload(false);
+							return false;
+						}
+					}
+				});
+
+			panel_loading = false;
+		};
+	req.send();
 }
 
 function init_close_panel() {
-	$("#overlay .close").click(function () {
-		$("#panel").html('');
-		$("#panel").slideToggle();
-		$("#overlay").fadeOut();
-
-		return false;
-	});
+	const panel = document.getElementById('panel');
+	document.querySelector('#overlay .close').onclick = function (ev) {
+			panel.innerHTML = '';
+			panel.classList.remove('visible');
+			document.getElementById('overlay').classList.remove('visible');
+			return false;
+		};
 }
 
 function init_global_view() {
-	// TODO: should be based on generic classes.
-	$(".box a").click(function () {
-		var link = $(this).attr("href");
-
-		load_panel(link);
-
-		return false;
-	});
+	// TODO: should be based on generic classes
+	document.querySelectorAll('.box a').forEach(function (a) {
+			a.onclick = function (ev) {
+					load_panel(a.href);
+					return false;
+				};
+		});
 
-	$(".nav_menu #nav_menu_read_all, .nav_menu .toggle_aside").remove();
+	document.querySelectorAll('.nav_menu #nav_menu_read_all, .nav_menu .toggle_aside').forEach(function (el) {
+			el.remove();
+		});
 
-	init_stream($("#panel"));
+	const panel = document.getElementById('panel');
+	init_stream(panel);
 }
 
 function init_all_global_view() {
-	if (!(window.$ && window.init_stream)) {
+	if (!window.context) {
 		if (window.console) {
 			console.log('FreshRSS Global view waiting for JS…');
 		}
@@ -85,7 +105,7 @@ function init_all_global_view() {
 
 if (document.readyState && document.readyState !== 'loading') {
 	init_all_global_view();
-} else if (document.addEventListener) {
+} else {
 	document.addEventListener('DOMContentLoaded', function () {
 		init_all_global_view();
 	}, false);

Diff do ficheiro suprimidas por serem muito extensas
+ 810 - 707
p/scripts/main.js


+ 2 - 2
p/scripts/repartition.js

@@ -1,6 +1,6 @@
 "use strict";
 /* globals Flotr, numberFormat */
-/* jshint globalstrict: true */
+/* jshint esversion:6, strict:global */
 
 function initStats() {
 	if (!window.Flotr) {
@@ -10,7 +10,7 @@ function initStats() {
 		window.setTimeout(initStats, 50);
 		return;
 	}
-	var jsonRepartition = document.getElementById('jsonRepartition'),
+	const jsonRepartition = document.getElementById('jsonRepartition'),
 		stats = JSON.parse(jsonRepartition.innerHTML);
 	jsonRepartition.outerHTML = '';
 	// Entry per hour

+ 0 - 225
p/scripts/shortcut.js

@@ -1,225 +0,0 @@
-/**
- * http://www.openjs.com/scripts/events/keyboard_shortcuts/
- * Version : 2.01.B
- * By Binny V A
- * License : BSD
- */
-shortcut = {
-	'all_shortcuts':{},//All the shortcuts are stored in this array
-	'add': function(shortcut_combination,callback,opt) {
-		//Provide a set of default options
-		var default_options = {
-			'type':'keydown',
-			'propagate':false,
-			'disable_in_input':false,
-			'target':document,
-			'keycode':false
-		}
-		if(!opt) opt = default_options;
-		else {
-			for(var dfo in default_options) {
-				if(typeof opt[dfo] == 'undefined') opt[dfo] = default_options[dfo];
-			}
-		}
-
-		var ele = opt.target;
-		if(typeof opt.target == 'string') ele = document.getElementById(opt.target);
-		var ths = this;
-		shortcut_combination = shortcut_combination.toLowerCase();
-
-		//The function to be called at keypress
-		var func = function(e) {
-			e = e || window.event;
-			
-			if(opt['disable_in_input']) { //Don't enable shortcut keys in Input, Textarea fields
-				var element;
-				if(e.target) element=e.target;
-				else if(e.srcElement) element=e.srcElement;
-				if(element.nodeType==3) element=element.parentNode;
-
-				if(element.tagName == 'INPUT' || element.tagName == 'TEXTAREA') return;
-			}
-	
-			//Find Which key is pressed
-			if (e.keyCode) code = e.keyCode;
-			else if (e.which) code = e.which;
-			if (code == 32 || (code >= 48 && code <= 90) || (code >= 96 && code <= 111) || (code >= 186 && code <= 192) || (code >= 219 && code <= 222)) {	//FreshRSS
-				var character = String.fromCharCode(code).toLowerCase();
-			}
-			
-			if(code == 188) character=","; //If the user presses , when the type is onkeydown
-			if(code == 190) character="."; //If the user presses , when the type is onkeydown
-
-			var keys = shortcut_combination.split("+");
-			//Key Pressed - counts the number of valid keypresses - if it is same as the number of keys, the shortcut function is invoked
-			var kp = 0;
-			
-			//Work around for stupid Shift key bug created by using lowercase - as a result the shift+num combination was broken
-			var shift_nums = {
-				"`":"~",
-				"1":"!",
-				"2":"@",
-				"3":"#",
-				"4":"$",
-				"5":"%",
-				"6":"^",
-				"7":"&",
-				"8":"*",
-				"9":"(",
-				"0":")",
-				"-":"_",
-				"=":"+",
-				";":":",
-				"'":"\"",
-				",":"<",
-				".":">",
-				"/":"?",
-				"\\":"|"
-			}
-			//Special Keys - and their codes
-			var special_keys = {
-				'esc':27,
-				'escape':27,
-				'tab':9,
-				'space':32,
-				'return':13,
-				'enter':13,
-				'backspace':8,
-	
-				'scrolllock':145,
-				'scroll_lock':145,
-				'scroll':145,
-				'capslock':20,
-				'caps_lock':20,
-				'caps':20,
-				'numlock':144,
-				'num_lock':144,
-				'num':144,
-				
-				'pause':19,
-				'break':19,
-				
-				'insert':45,
-				'home':36,
-				'delete':46,
-				'end':35,
-				
-				'pageup':33,
-				'page_up':33,
-				'pu':33,
-	
-				'pagedown':34,
-				'page_down':34,
-				'pd':34,
-	
-				'left':37,
-				'up':38,
-				'right':39,
-				'down':40,
-	
-				'f1':112,
-				'f2':113,
-				'f3':114,
-				'f4':115,
-				'f5':116,
-				'f6':117,
-				'f7':118,
-				'f8':119,
-				'f9':120,
-				'f10':121,
-				'f11':122,
-				'f12':123
-			}
-	
-			var modifiers = { 
-				shift: { wanted:false, pressed:false},
-				ctrl : { wanted:false, pressed:false},
-				alt  : { wanted:false, pressed:false},
-				meta : { wanted:false, pressed:false}	//Meta is Mac specific
-			};
-                        
-			if(e.ctrlKey)	modifiers.ctrl.pressed = true;
-			if(e.shiftKey)	modifiers.shift.pressed = true;
-			if(e.altKey)	modifiers.alt.pressed = true;
-			if(e.metaKey)   modifiers.meta.pressed = true;
-                        
-			for(var i=0; k=keys[i],i<keys.length; i++) {
-				//Modifiers
-				if(k == 'ctrl' || k == 'control') {
-					kp++;
-					modifiers.ctrl.wanted = true;
-
-				} else if(k == 'shift') {
-					kp++;
-					modifiers.shift.wanted = true;
-
-				} else if(k == 'alt') {
-					kp++;
-					modifiers.alt.wanted = true;
-				} else if(k == 'meta') {
-					kp++;
-					modifiers.meta.wanted = true;
-				} else if(k.length > 1) { //If it is a special key
-					if(special_keys[k] == code) kp++;
-					
-				} else if(opt['keycode']) {
-					if(opt['keycode'] == code) kp++;
-
-				} else { //The special keys did not match
-					if(character == k) kp++;
-					else {
-						if(shift_nums[character] && e.shiftKey) { //Stupid Shift key bug created by using lowercase
-							character = shift_nums[character]; 
-							if(character == k) kp++;
-						}
-					}
-				}
-			}
-			
-			if(kp == keys.length && 
-						modifiers.ctrl.pressed == modifiers.ctrl.wanted &&
-						modifiers.shift.pressed == modifiers.shift.wanted &&
-						modifiers.alt.pressed == modifiers.alt.wanted &&
-						modifiers.meta.pressed == modifiers.meta.wanted) {
-				callback(e);
-	
-				if(!opt['propagate']) { //Stop the event
-					//e.cancelBubble is supported by IE - this will kill the bubbling process.
-					e.cancelBubble = true;
-					e.returnValue = false;
-	
-					//e.stopPropagation works in Firefox.
-					if (e.stopPropagation) {
-						e.stopPropagation();
-						e.preventDefault();
-					}
-					return false;
-				}
-			}
-		}
-		this.all_shortcuts[shortcut_combination] = {
-			'callback':func, 
-			'target':ele, 
-			'event': opt['type']
-		};
-		//Attach the function with the event
-		if(ele.addEventListener) ele.addEventListener(opt['type'], func, false);
-		else if(ele.attachEvent) ele.attachEvent('on'+opt['type'], func);
-		else ele['on'+opt['type']] = func;
-	},
-
-	//Remove the shortcut - just specify the shortcut and I will remove the binding
-	'remove':function(shortcut_combination) {
-		shortcut_combination = shortcut_combination.toLowerCase();
-		var binding = this.all_shortcuts[shortcut_combination];
-		delete(this.all_shortcuts[shortcut_combination])
-		if(!binding) return;
-		var type = binding['event'];
-		var ele = binding['target'];
-		var callback = binding['callback'];
-
-		if(ele.detachEvent) ele.detachEvent('on'+type, callback);
-		else if(ele.removeEventListener) ele.removeEventListener(type, callback, false);
-		else ele['on'+type] = false;
-	}
-}

+ 4 - 4
p/scripts/stats.js

@@ -1,6 +1,6 @@
 "use strict";
 /* globals Flotr, numberFormat */
-/* jshint globalstrict: true */
+/* jshint esversion:6, strict:global */
 
 function initStats() {
 	if (!window.Flotr) {
@@ -10,12 +10,12 @@ function initStats() {
 		window.setTimeout(initStats, 50);
 		return;
 	}
-	var jsonStats = document.getElementById('jsonStats'),
+	const jsonStats = document.getElementById('jsonStats'),
 		stats = JSON.parse(jsonStats.innerHTML);
 	jsonStats.outerHTML = '';
 	// Entry per day
-	var avg = [];
-	for (var i = -31; i <= 0; i++) {
+	const avg = [];
+	for (let i = -31; i <= 0; i++) {
 		avg.push([i, stats.average]);
 	}
 	Flotr.draw(document.getElementById('statsEntryPerDay'),

+ 352 - 0
p/themes/Ansum/_components.scss

@@ -0,0 +1,352 @@
+/*=== COMPONENTS */
+/*===============*/
+/*=== Forms */
+// parti dans _forms.scss
+
+/*=== Horizontal-list */
+.horizontal-list {
+    margin: 0;
+    padding: 0.1rem 0;
+
+    .item{
+	vertical-align: middle;
+
+	&:first-child{
+	    padding-left: 0.5rem;
+	}
+
+    }
+}
+
+/*=== Dropdown */
+.dropdown-menu {
+    background: $grey-lighter;
+    margin: 0;
+    font-size: 1rem;
+    text-align: left;
+    padding: 0.5rem 0 1rem 0;
+    border: none;
+    border-radius: 3px;
+
+    -webkit-box-shadow: 0px 6px 8px 0px rgba(0,0,0,0.35);
+    -moz-box-shadow: 0px 6px 8px 0px rgba(0,0,0,0.35);
+    box-shadow: 0px 6px 8px 0px rgba(0,0,0,0.35);
+
+    &::after {
+	content: "";
+	position: absolute;
+	top: -4px;
+	right: 13px;
+	width: 10px;
+	height: 10px;
+	z-index: -10;
+	transform: rotate(45deg);
+	-moz-transform: rotate(45deg);
+	-webkit-transform: rotate(45deg);
+	-ms-transform: rotate(45deg);
+	background: white;
+	// border-top: 1px solid #95a5a6;
+	// border-left: 1px solid #95a5a6;
+    }
+    
+    .dropdown-header {
+	// padding: 0 5px 5px;
+	margin: 1.75rem 0 0.5rem 2rem;
+	font-weight: bold;
+	text-align: left;
+	color: $grey-dark;
+	text-transform: uppercase;
+	letter-spacing: 1px;
+
+	
+    }
+
+    .item{
+	@include transition(all, 0.075s, ease-in-out);
+	a, span, .as-link{
+	    padding: 0 2rem;
+	    line-height: 2.5em;
+	    font-size: 1rem;
+	    color: $main-font-color;
+	}
+	&:hover{
+	    background: $main-first;
+	    color: $white;
+
+	    a, button{
+		text-decoration: none;
+		color: $white;
+	    }
+	}
+	&[aria-checked="true"]{
+	    a::before{
+		font-weight: bold;
+		margin: 0 0 0 -14px;
+	    }
+	}
+    }
+    .input{
+	select, input{
+	    margin: 0 auto 5px;
+	    padding: 2px 5px;
+	    border-radius: 3px;
+	}
+    }
+    .separator {
+	margin: 0.75rem 0;
+	border-bottom: 1px solid $grey-light;
+	// display: none;
+    }
+
+}
+.tree .tree-folder .tree-folder-items .dropdown-menu,
+.tree .tree-folder .tree-folder-items .dropdown-menu{
+    // tout ça sert à restaurer l'apparence du dropdown dans un contexte de sidebar sombre
+    
+    .item{
+	padding: 0;
+	
+	a,
+	button{
+	    color: $main-font-color;
+	    
+	    &:hover{
+		color: $white;
+	    }
+	}
+	&:hover{
+	    background: $main-first;
+	}
+    }
+}
+
+
+
+
+/*=== Alerts */
+.alert {
+    margin: 1rem 0;
+    // width: 100%;
+    padding: 1rem;
+    font-size: 1rem;
+    background: $grey-lighter;
+    border: 1px solid $grey-medium;
+    border-radius: 3px;
+    color: $grey-dark;
+    text-shadow: 0 0 1px $grey-light;
+}
+.alert-head {
+    font-size: 1.15em;
+}
+.alert > a {
+    text-decoration: underline;
+    color: inherit;
+}
+.alert-warn {
+    background: $warning-light;
+    border: 1px solid unquote($warning-text+'33'); // on ajoute l'opacité à la fin
+    color: $warning-text;
+}
+.alert-success {
+    background: $success-light;
+    border: 1px solid unquote($success-text+'33');
+    color: $success-text;
+}
+.alert-error {
+    background: $alert-light;
+    border: 1px solid unquote($alert-text+'33');
+    color: $alert-text;
+}
+
+/*=== Pagination */
+.pagination {
+    text-align: center;
+    font-size: 0.8em;
+    background: $grey-light;
+    color: $main-font-color;
+
+    .item{
+	&.pager-current {
+	    font-weight: bold;
+	    font-size: 1.5em;
+	    background: $sid-bg;
+	    color: $grey-light;
+	}
+	a {
+	    display: block;
+	    font-style: italic;
+	    line-height: 3em;
+	    text-decoration: none;
+	    color: $main-font-color;
+
+	    &:hover{
+		background: $main-font-color;
+		color: $grey-light;
+	    }
+	}
+    }
+
+    .loading,
+    a:hover.loading {
+	font-size: 0;
+	background: url("loader.gif") center center no-repeat #34495e;
+    }
+}
+.content .pagination {
+    margin: 0;
+    padding: 0;
+}
+
+
+/*=== Boxes */
+.box {
+    // border: 1px solid #ddd;
+    border: none;
+    border-radius: 3px;
+    background: $white;
+
+    -webkit-box-shadow: 0px 2px 2px 0px rgba(0,0,0,0.25);
+    -moz-box-shadow: 0px 2px 2px 0px rgba(0,0,0,0.25);
+    box-shadow: 0px 2px 2px 0px rgba(0,0,0,0.25);
+
+    .box-title {
+	margin: 0;
+	padding: 5px 10px;
+	background: $grey-light;
+	color: $main-font-color;
+	// border-bottom: 1px solid #ddd;
+	border-radius: 2px 2px 0 0;
+
+	form{
+	    input{
+		width: 85%;
+	    }
+	    .dropdown{
+		float: right;
+		a.dropdown-toggle{
+		    padding: 0;
+		    background-image: url(icons/more.svg);
+		    background-repeat: no-repeat;
+		    background-position: right 8px;
+		    // float: right;
+		    border-radius: 0;
+		    img{
+			display: none;
+		    }
+		}
+	    }
+	}
+    }
+    .box-content {
+	// max-height: 260px;
+
+	.item {
+	    padding: 0.5rem 0.75rem;
+	    font-size: 1rem;
+	    color: $main-font-color;
+	    line-height: 1.7em;
+	    border-bottom: 1px solid $grey-light;
+
+	    img{
+		margin-right: 0.75rem;
+	    }
+
+	    .configure {
+		visibility: hidden;
+		width: 1.75rem;
+		height: 1.75rem;
+		display: block;
+		border-radius: 2px;
+		float: left;
+		margin-right: 0.5rem;
+		background: url("icons/cog.svg") no-repeat 4px 4px;
+		
+		.icon {
+		    vertical-align: middle;
+		    border-radius: 3px;
+		    display: none;
+		}
+		&:hover{
+		    // background: $main-first;
+		    background: url("icons/cog-white.svg") no-repeat 4px 4px $main-first;
+		}
+	    }
+	    &:hover .configure {
+		visibility: visible;
+	    }
+	}
+	.item:last-child{
+	    border-bottom: none;
+	}
+    }
+}
+
+/*=== "Load more" part */
+#bigMarkAsRead {
+    text-align: center;
+    text-decoration: none;
+    background: $main-first-light;
+    color: $main-first;
+
+    @include transition(all, 0.15s, ease-in-out);
+
+    &:hover {
+	background: $main-first;
+	color: #fff;
+
+	.bigTick{
+	    background: url(icons/tick-white.svg) center no-repeat;
+	}
+    }
+    .bigTick{
+	margin: 0.5rem 0;
+	display: inline-block;
+	text-indent: -9999px;               
+	background: url(icons/tick-color.svg) center no-repeat;  
+	height: 64px;                       
+	width: 64px;
+	white-space: nowrap; 
+    }
+}
+
+// page de login
+.formLogin{
+    background: $sid-bg;
+
+    .header{
+	.configure{
+	    padding-right: 1rem;
+	    img{
+		margin-right: 0.5rem;
+	    }
+	    
+	    a.signin{
+		color: $white;
+	    }
+	}
+    }
+
+    h1{
+	color: $white;
+    }
+    form#crypto-form{
+	div{
+	    margin-bottom: 1rem;
+	    
+	    label{
+		font-size: 1rem;
+		color: $grey-medium;
+
+		
+	    }
+	    input{
+		background: $main-first-darker;
+
+		&:focus{
+		    background: $grey-lighter;
+		    color: $main-font-color;
+		}
+	    }
+	}	
+    }
+}

+ 90 - 0
p/themes/Ansum/_configuration.scss

@@ -0,0 +1,90 @@
+/*=== Configuration pages */
+.post {
+    padding: 1rem 2rem;
+    font-size: 1rem;
+
+    form {
+	margin: 1rem 0;
+
+	// Gestion des extensions
+	.horizontal-list{
+	    margin-bottom: 0.5rem;
+	    
+	    .item{
+		.stick{
+		    // width: 65%;
+		    // margin-right: 1rem;
+		    // display:flex;
+		    
+		}
+		.btn{
+		    // width: 8rem;
+		    // flex-grow: 1;
+		}
+	    }
+	    
+	}
+    }
+    &.content {
+	max-width: 550px;
+    }
+
+    h1, h2{ // pages titles
+	font-size: 3rem;
+	margin-top: 1.75rem;
+	font-weight: 300;
+	line-height: 1.2em;
+	// font-family: "spectral";
+	color: $main-font-color;
+    }
+
+    a[href="./"]{ // C'est le bouton "Retour à vos flux"
+	display: inline-block;
+	// min-height: 38px;
+	min-width: 15px;
+	line-height: 25px;
+	margin: 0;
+	padding: 0.75rem 1.5rem;
+	font-size: 1rem;
+	vertical-align: middle;
+	cursor: pointer;
+	overflow: hidden;
+	background: $grey-lighter;
+	border: 1px solid $grey-medium-light;
+	border-radius: 5px;
+	// border: none;
+	color: $grey-dark;
+
+	&:hover{
+	    text-decoration: none;
+	    background: $main-first;
+	    color: white;
+	    border: 1px solid $main-first;
+	}
+    }
+    
+}
+
+
+
+#slider{
+    border-left: none;
+    
+    -webkit-box-shadow: 0px 6px 8px 0px rgba(0,0,0,0.35);
+    -moz-box-shadow: 0px 6px 8px 0px rgba(0,0,0,0.35);
+    box-shadow: 0px 6px 8px 0px rgba(0,0,0,0.35);
+}
+
+.slide-container{
+    .properties{
+	background: rgba(0, 0, 0, 0.75);
+	border: 0;
+	padding: 1rem;
+	color: white;
+
+	.page-number{
+	    right: 1rem;
+	    top: 1rem;
+	}
+    }
+}

+ 12 - 0
p/themes/Ansum/_divers.scss

@@ -0,0 +1,12 @@
+/*=== DIVERS */
+/*===========*/
+.aside.aside_feed .nav-form input,
+.aside.aside_feed .nav-form select {
+	width: 140px;
+}
+.aside.aside_feed .nav-form .dropdown .dropdown-menu {
+	right: -20px;
+}
+.aside.aside_feed .nav-form .dropdown .dropdown-menu::after {
+	right: 33px;
+}

+ 56 - 0
p/themes/Ansum/_fonts.scss

@@ -0,0 +1,56 @@
+@font-face {
+    font-family: "lato";
+    font-style: normal;
+    font-stretch: normal;
+    font-weight: 400;
+    src: local("Lato"), url("../fonts/LatoLatin-Regular.woff") format("woff");
+}
+@font-face {
+    font-family: "lato";
+    font-style: italic;
+    font-stretch: normal;
+    font-weight: 400;
+    src: local("Lato"), url("../fonts/LatoLatin-Italic.woff") format("woff");
+}
+@font-face {
+    font-family: "lato";
+    font-style: normal;
+    font-stretch: normal;
+    font-weight: 700;
+    src: local("Lato"), url("../fonts/LatoLatin-Bold.woff") format("woff");
+}
+@font-face {
+    font-family: "lato";
+    font-style: italic;
+    font-stretch: normal;
+    font-weight: 700;
+    src: local("Lato"), url("../fonts/LatoLatin-BoldItalic.woff") format("woff");
+}
+@font-face {
+    font-family: "spectral";
+    font-style: normal;
+    font-stretch: normal;
+    font-weight: 400;
+    src: local("Spectral"), url("../fonts/Spectral-Regular.woff") format("woff");
+}
+@font-face {
+    font-family: "spectral";
+    font-style: italic;
+    font-stretch: normal;
+    font-weight: 400;
+    src: local("Spectral"), url("../fonts/Spectral-Italic.woff") format("woff");
+}
+@font-face {
+    font-family: "spectral";
+    font-style: normal;
+    font-stretch: normal;
+    font-weight: 700;
+    src: local("Spectral"), url("../fonts/Spectral-Bold.woff") format("woff");
+}
+@font-face {
+    font-family: "spectral";
+    font-style: italic;
+    font-stretch: normal;
+    font-weight: 700;
+    src: local("Spectral"), url("../fonts/Spectral-BoldItalic.woff") format("woff");
+}

+ 153 - 0
p/themes/Ansum/_forms.scss

@@ -0,0 +1,153 @@
+/* btns */
+
+.btn {
+    display: inline-block;
+    min-height: 38px;
+    min-width: 15px;
+    line-height: 25px;
+    margin: 0;
+    padding: 0.5rem 1.5rem;
+    font-size: 1rem;
+    vertical-align: middle;
+    cursor: pointer;
+    overflow: hidden;
+    background: $grey-lighter;
+    border-radius: 5px;
+    border: none;
+    color: $grey-dark;
+
+    @include transition(all, 0.15s, ease-in-out);
+
+    &.btn-important {
+	background: $main-first;
+	color: $white;
+
+	// @include transition(all, 0.15s, ease-in-out);
+
+	&:hover,
+	&:active {
+	    background: $main-first-alt;
+	}
+    }
+
+
+    &.btn-attention {
+	background: $alert-bg;
+	color: #fff;
+
+	&:hover,
+	&:active {
+	    background: $alert-text;
+	}
+    }
+
+    &:hover {
+	text-decoration: none;
+    }
+}
+
+a.btn {
+    min-height: 25px;
+    line-height: 25px;
+}
+
+/*=== Forms */
+legend {
+    display: inline-block;
+    width: auto;
+    margin: 2rem 0 1rem 0;
+    padding: 0;
+    font-size: 1rem;
+    clear: both;
+    text-transform: uppercase;
+    letter-spacing: 1px;
+    font-weight: 700;
+}
+label {
+    min-height: 25px;
+    padding: 5px 0;
+    cursor: pointer;
+    color: $grey-dark;
+}
+textarea {
+    width: 360px;
+    height: 100px;
+}
+input, select, textarea, button {
+    font-family: "lato", "Helvetica", "Arial", sans-serif;
+    min-height: 25px;
+    padding: 5px 10px;
+    line-height: 25px;
+    vertical-align: middle;
+    background: $white;
+    border: 1px solid $grey-light;
+    font-size: 1rem;
+    color: $grey-dark;
+    border-radius: 2px;
+}
+option {
+    padding: 0 .5em;
+}
+input:focus, select:focus, textarea:focus {
+    color: $main-font-color;
+    border-color: $main-first;
+}
+input:invalid, select:invalid {
+    color: $alert-bg;
+    border-color: $alert-bg;
+    box-shadow: none;
+}
+input:disabled, select:disabled {
+    background: $grey-light;
+}
+input.extend {
+    transition: width 200ms linear;
+    -moz-transition: width 200ms linear;
+    -webkit-transition: width 200ms linear;
+    -o-transition: width 200ms linear;
+    -ms-transition: width 200ms linear;
+}
+
+
+.form-group {
+    padding: 5px;
+    border-radius: 3px;
+
+    &::after {
+	content: "";
+	display: block;
+	clear: both;
+    }
+    &:hover {
+	// background: #fff;
+	// 	border: 1px solid #eee;
+	// 	border-radius: 3px;
+	// 	border: 1px solid #eee;
+    }
+    .group-name {
+	padding: 10px 0;
+	text-align: right;
+    }
+    .group-controls {
+	min-height: 25px;
+	padding: 5px 0;
+    }
+    .group-controls .control {
+	line-height: 2.0em;
+    }
+    table {
+	margin: 10px 0 0 220px;
+    }
+
+    &.form-actions {
+	margin: 15px 0 25px;
+	padding: 5px 0;
+	// background: #333;
+    }
+    &.form-actions .btn {
+	margin: 0 0.5rem 0 0;
+    }
+}
+
+
+

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff