Browse Source

Merge branch 'edge' into latest

Alexandre Alapetite 3 years ago
parent
commit
b3239256dc
100 changed files with 1295 additions and 594 deletions
  1. 32 0
      .devcontainer/Dockerfile
  2. 35 0
      .devcontainer/devcontainer.json
  3. 17 0
      .devcontainer/postCreateCommand.sh
  4. 5 5
      .github/workflows/tests.yml
  5. 1 0
      .markdownlintignore
  6. 3 1
      .typos.toml
  7. 84 1
      CHANGELOG.md
  8. 1 1
      CONTRIBUTING.md
  9. 5 0
      CREDITS.md
  10. 1 1
      Docker/Dockerfile
  11. 3 1
      Docker/Dockerfile-Alpine
  12. 1 0
      Docker/Dockerfile-Newest
  13. 3 1
      Docker/Dockerfile-Oldest
  14. 1 1
      Docker/Dockerfile-QEMU-ARM
  15. 1 1
      Docker/FreshRSS.Apache.conf
  16. 5 1
      Docker/README.md
  17. 7 3
      Docker/entrypoint.sh
  18. 22 22
      Makefile
  19. 26 33
      README.fr.md
  20. 18 14
      README.md
  21. 19 16
      app/Controllers/configureController.php
  22. 12 6
      app/Controllers/entryController.php
  23. 0 2
      app/Controllers/importExportController.php
  24. 0 2
      app/Controllers/indexController.php
  25. 4 4
      app/Controllers/javascriptController.php
  26. 8 29
      app/Controllers/statsController.php
  27. 11 7
      app/Controllers/subscriptionController.php
  28. 6 6
      app/Controllers/updateController.php
  29. 2 1
      app/Controllers/userController.php
  30. 20 10
      app/FreshRSS.php
  31. 5 3
      app/Models/BooleanSearch.php
  32. 11 4
      app/Models/Category.php
  33. 7 8
      app/Models/CategoryDAO.php
  34. 7 0
      app/Models/ConfigurationSetter.php
  35. 7 13
      app/Models/Context.php
  36. 5 3
      app/Models/Days.php
  37. 146 28
      app/Models/Entry.php
  38. 16 9
      app/Models/EntryDAO.php
  39. 4 0
      app/Models/EntryDAOSQLite.php
  40. 87 55
      app/Models/Feed.php
  41. 3 3
      app/Models/FeedDAO.php
  42. 4 0
      app/Models/Searchable.php
  43. 5 1
      app/Models/SystemConfiguration.php
  44. 41 14
      app/Models/Tag.php
  45. 28 12
      app/Models/TagDAO.php
  46. 0 1
      app/Models/Themes.php
  47. 7 1
      app/Models/UserConfiguration.php
  48. 48 50
      app/Models/UserQuery.php
  49. 1 0
      app/Models/View.php
  50. 2 3
      app/SQL/install.sql.mysql.php
  51. 2 2
      app/SQL/install.sql.pgsql.php
  52. 3 4
      app/SQL/install.sql.sqlite.php
  53. 1 2
      app/Services/ExportService.php
  54. 284 161
      app/Services/ImportService.php
  55. 16 22
      app/Utils/feverUtil.php
  56. 8 11
      app/Utils/passwordUtil.php
  57. 9 1
      app/i18n/cz/conf.php
  58. 3 0
      app/i18n/cz/gen.php
  59. 1 0
      app/i18n/cz/sub.php
  60. 9 1
      app/i18n/de/conf.php
  61. 3 0
      app/i18n/de/gen.php
  62. 1 0
      app/i18n/de/sub.php
  63. 9 1
      app/i18n/el/conf.php
  64. 3 0
      app/i18n/el/gen.php
  65. 1 0
      app/i18n/el/sub.php
  66. 9 1
      app/i18n/en-us/conf.php
  67. 3 0
      app/i18n/en-us/gen.php
  68. 1 0
      app/i18n/en-us/sub.php
  69. 9 1
      app/i18n/en/conf.php
  70. 3 0
      app/i18n/en/gen.php
  71. 1 0
      app/i18n/en/sub.php
  72. 9 1
      app/i18n/es/admin.php
  73. 3 0
      app/i18n/es/feedback.php
  74. 1 0
      app/i18n/es/index.php
  75. 9 1
      app/i18n/fr/conf.php
  76. 3 0
      app/i18n/fr/gen.php
  77. 1 0
      app/i18n/fr/sub.php
  78. 9 1
      app/i18n/he/conf.php
  79. 3 0
      app/i18n/he/gen.php
  80. 1 0
      app/i18n/he/sub.php
  81. 9 1
      app/i18n/id/conf.php
  82. 3 0
      app/i18n/id/gen.php
  83. 1 0
      app/i18n/id/sub.php
  84. 9 1
      app/i18n/it/conf.php
  85. 3 0
      app/i18n/it/gen.php
  86. 1 0
      app/i18n/it/sub.php
  87. 9 1
      app/i18n/ja/conf.php
  88. 3 0
      app/i18n/ja/gen.php
  89. 1 0
      app/i18n/ja/sub.php
  90. 9 1
      app/i18n/ko/conf.php
  91. 3 0
      app/i18n/ko/gen.php
  92. 1 0
      app/i18n/ko/sub.php
  93. 9 1
      app/i18n/nl/conf.php
  94. 3 0
      app/i18n/nl/gen.php
  95. 1 0
      app/i18n/nl/sub.php
  96. 9 1
      app/i18n/oc/conf.php
  97. 3 0
      app/i18n/oc/gen.php
  98. 1 0
      app/i18n/oc/sub.php
  99. 9 1
      app/i18n/pl/conf.php
  100. 3 0
      app/i18n/pl/gen.php

+ 32 - 0
.devcontainer/Dockerfile

@@ -0,0 +1,32 @@
+FROM alpine:3.17
+
+ENV TZ UTC
+SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
+
+RUN apk add --no-cache \
+	tzdata \
+	apache2 php-apache2 \
+	php php-curl php-gmp php-intl php-mbstring php-xml php-zip \
+	php-ctype php-dom php-fileinfo php-iconv php-json php-opcache php-openssl php-phar php-session php-simplexml php-xmlreader php-xmlwriter php-xml php-tokenizer php-zlib \
+	php-pdo_sqlite php-pdo_mysql php-pdo_pgsql \
+	bash composer curl docker-cli-buildx git gpg make nodejs npm shellcheck shfmt sudo
+
+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|remoteip|setenvif).so$/s/^\s*#//" \
+		/etc/apache2/httpd.conf && \
+	sed -r -i "/^\s*(CustomLog|ErrorLog|Listen) /s/^/#/" \
+		/etc/apache2/httpd.conf
+
+RUN adduser --ingroup www-data --disabled-password developer && \
+	echo "developer ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/developer
+
+ENV COPY_LOG_TO_SYSLOG On
+ENV COPY_SYSLOG_TO_STDERR On
+ENV CRON_MIN ''
+ENV FRESHRSS_ENV 'development'
+ENV LISTEN '0.0.0.0:8080'
+
+EXPOSE 8080

+ 35 - 0
.devcontainer/devcontainer.json

@@ -0,0 +1,35 @@
+// For format details, see https://aka.ms/devcontainer.json
+{
+	"name": "FreshRSS-dev-Alpine",
+	"build": {
+		"dockerfile": "Dockerfile"
+	},
+	"customizations": {
+		"vscode": {
+			"extensions": [
+				"bmewburn.vscode-intelephense-client",
+				"DavidAnson.vscode-markdownlint",
+				"dbaeumer.vscode-eslint",
+				"eamodio.gitlens",
+				"EditorConfig.EditorConfig",
+				"foxundermoon.shell-format",
+				"mrmlnc.vscode-apache",
+				"ms-azuretools.vscode-docker",
+				"redhat.vscode-yaml",
+				"timonwong.shellcheck",
+				"ValeryanM.vscode-phpsab"
+			]
+		}
+	},
+	"forwardPorts": [
+		8080
+	],
+	"portsAttributes": {
+		"8080": {
+			"label": "FreshRSS Apache",
+			"onAutoForward": "notify"
+		}
+	},
+	"remoteUser": "developer",
+	"postCreateCommand": "sudo .devcontainer/postCreateCommand.sh"
+}

+ 17 - 0
.devcontainer/postCreateCommand.sh

@@ -0,0 +1,17 @@
+#!/bin/sh
+
+ln -s "$(pwd)" /var/www/FreshRSS
+
+cp ./Docker/*.Apache.conf /etc/apache2/conf.d/
+
+cat <<EOT >./constants.local.php
+<?php
+define('DATA_PATH', '/home/developer/freshrss-data');
+EOT
+
+./Docker/entrypoint.sh
+
+chown -R developer:www-data /home/developer/freshrss-data
+chmod -R g+w /home/developer/freshrss-data
+
+httpd

+ 5 - 5
.github/workflows/tests.yml

@@ -55,7 +55,7 @@ jobs:
       uses: actions/setup-node@v3
       with:
         # https://nodejs.org/en/about/releases/
-        node-version: '16'
+        node-version: '18'
         cache: 'npm'
 
     - run: npm ci
@@ -79,14 +79,14 @@ jobs:
       uses: actions/cache@v3
       with:
         path: bin
-        key: ${{ runner.os }}-bin-shfmt@v3.5.1-hadolint@v2.10.0-typos@v1.10.1
+        key: ${{ runner.os }}-bin-shfmt@v3.6.0-hadolint@v2.12.0-typos@v1.13.6
 
     - name: Add ./bin/ to $PATH
       run: mkdir -p bin/ && echo "${PWD}/bin" >> $GITHUB_PATH
 
     - name: Install shfmt
       if: steps.shell-cache.outputs.cache-hit != 'true'
-      run: GOBIN=${PWD}/bin/ go install mvdan.cc/sh/v3/cmd/shfmt@v3.5.1
+      run: GOBIN=${PWD}/bin/ go install mvdan.cc/sh/v3/cmd/shfmt@v3.6.0
 
     - name: Check shell script syntax
       # shellcheck is pre-installed https://github.com/actions/virtual-environments/blob/main/images/linux/Ubuntu2204-Readme.md
@@ -94,7 +94,7 @@ jobs:
 
     - name: Install hadolint
       if: steps.shell-cache.outputs.cache-hit != 'true'
-      run: curl -sL -o ./bin/hadolint "https://github.com/hadolint/hadolint/releases/download/v2.10.0/hadolint-$(uname -s)-$(uname -m)" && chmod 700 ./bin/hadolint
+      run: curl -sL -o ./bin/hadolint "https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-$(uname -s)-$(uname -m)" && chmod 700 ./bin/hadolint
 
     - name: Check Dockerfile syntax
       run: find . -name 'Dockerfile*' -print0 | xargs -0 -n1 ./bin/hadolint --failure-threshold warning
@@ -103,7 +103,7 @@ jobs:
       if: steps.shell-cache.outputs.cache-hit != 'true'
       run: |
         cd bin ;
-        wget -q 'https://github.com/crate-ci/typos/releases/download/v1.10.1/typos-v1.10.1-x86_64-unknown-linux-musl.tar.gz' &&
+        wget -q 'https://github.com/crate-ci/typos/releases/download/v1.13.6/typos-v1.13.6-x86_64-unknown-linux-musl.tar.gz' &&
         tar -xvf *.tar.gz './typos' &&
         chmod +x typos &&
         rm *.tar.gz ;

+ 1 - 0
.markdownlintignore

@@ -1,4 +1,5 @@
 .git/
+lib/marienfressinaud/
 lib/phpgt/
 lib/phpmailer/
 node_modules/

+ 3 - 1
.typos.toml

@@ -3,7 +3,7 @@ ot = "ot"
 Ths2 = "Ths2"
 
 [default.extend-words]
-ba = "ba"
+referer = "referer"
 
 [files]
 extend-exclude = [
@@ -33,8 +33,10 @@ extend-exclude = [
 	"app/i18n/zh-cn/",
 	"bin/",
 	"CHANGELOG-old.md",
+	"composer.lock",
 	"data/",
 	"docs/fr/",
+	"lib/marienfressinaud/",
 	"lib/phpgt/",
 	"lib/phpmailer/",
 	"lib/SimplePie/",

+ 84 - 1
CHANGELOG.md

@@ -1,9 +1,92 @@
 # FreshRSS changelog
 
+## 2023-03-04 FreshRSS 1.21.0
+
+* Features
+	* New *XML+XPath* mode for fetching XML documents when there is no RSS/ATOM feed [#5076](https://github.com/FreshRSS/FreshRSS/pull/5076)
+	* Better support of feed enclosures (image / audio / video attachments) [#4944](https://github.com/FreshRSS/FreshRSS/pull/4944)
+	* User-defined time-zone [#4906](https://github.com/FreshRSS/FreshRSS/pull/4906)
+	* Improve HTML+XPath mode by allowing HTML content [#4878](https://github.com/FreshRSS/FreshRSS/pull/4878)
+	* Search only on full tag names and not on parts of tag names [#4882](https://github.com/FreshRSS/FreshRSS/pull/4882)
+	* Allows searching for parentheses with `\(` or `\)` [#4989](https://github.com/FreshRSS/FreshRSS/pull/4989)
+	* Firefox-compatible sharing service for `mailto:` links for webmail services [#4680](https://github.com/FreshRSS/FreshRSS/pull/4680)
+	* Add sharing to [archive.org](https://archive.org/) [#5096](https://github.com/FreshRSS/FreshRSS/pull/5096)
+	* Increase max HTTP timeout to 15 minutes [#5074](https://github.com/FreshRSS/FreshRSS/pull/5074)
+* Compatibility
+	* Require PHP 7.2+ (drop support for PHP 7.0 and 7.1) [#4848](https://github.com/FreshRSS/FreshRSS/pull/4848)
+	* Workaround disabled `openlog()` or `syslog()` [#5054](https://github.com/FreshRSS/FreshRSS/pull/5054)
+* Deployment
+	* Docker default image (Debian 11 Bullseye) updated to PHP 7.4.33
+	* Docker: alternative image updated to Alpine 3.17 with PHP 8.1.16 and Apache 2.4.55 [#4886](https://github.com/FreshRSS/FreshRSS/pull/4886)
+	* More uniform time-zone behaviour [#4903](https://github.com/FreshRSS/FreshRSS/pull/4903), [#4905](https://github.com/FreshRSS/FreshRSS/pull/4905)
+	* New CLI script `cli/sensitive-log.sh` to help e.g. Apache clear logs for sensitive information such as credentials [#5001](https://github.com/FreshRSS/FreshRSS/pull/5001)
+	* New CLI script `cli/access-permissions.sh` to help apply file permissions correctly [#5062](https://github.com/FreshRSS/FreshRSS/pull/5062)
+	* Improve file permissions on `./extensions/` [#4956](https://github.com/FreshRSS/FreshRSS/pull/4956)
+	* Update Apache mime type `font/woff` [#4894](https://github.com/FreshRSS/FreshRSS/pull/4894)
+	* Re-added a git `latest` branch (instead of a tag) to track the latest FreshRSS stable releases [#5148](https://github.com/FreshRSS/FreshRSS/pull/5148)
+* Bug fixing
+	* Fix allow disabling curl proxy for specific feed, when proxy is defined globally [#5082](https://github.com/FreshRSS/FreshRSS/pull/5082)
+	* NFS-friendly `is_writable()` checks [#4780](https://github.com/FreshRSS/FreshRSS/pull/4780)
+	* Fix error handling when updating feed URL [#5039](https://github.com/FreshRSS/FreshRSS/pull/5039)
+	* Fix feed favicon after editing feed URL [#4975](https://github.com/FreshRSS/FreshRSS/pull/4975)
+	* Fix allow <kbd>Ctrl</kbd>+<kbd>Click</kbd> to open *Manage feeds* in new tab [#4980](https://github.com/FreshRSS/FreshRSS/pull/4980)
+	* Fix empty window opened when pressing space after page load [#5146](https://github.com/FreshRSS/FreshRSS/pull/5146)
+	* Fix keep current view when searching [#4981](https://github.com/FreshRSS/FreshRSS/pull/4981)
+	* Fix mobile view: scroll main area again after closing slider [#5092](https://github.com/FreshRSS/FreshRSS/pull/5092)
+	* Fix change confirmation when leaving sharing service config [#5098](https://github.com/FreshRSS/FreshRSS/pull/5098)
+	* Fix sharing to Lemmy [#5020](https://github.com/FreshRSS/FreshRSS/pull/5020)
+* Security
+	* API avoid logging passwords [CVE-2023-22481](https://github.com/FreshRSS/FreshRSS/security/advisories/GHSA-8vvv-jxg6-8578)
+	* Remove execution rights on some files not needing it [#5065](https://github.com/FreshRSS/FreshRSS/pull/5065)
+	* More robust application of file access permissions [#5062](https://github.com/FreshRSS/FreshRSS/pull/5062)
+* UI
+	* Improve search box [#4994](https://github.com/FreshRSS/FreshRSS/pull/4994)
+	* Improve navigation menu structure [#4937](https://github.com/FreshRSS/FreshRSS/pull/4937)
+	* More consistent sorting of feeds alphabetically [#4841](https://github.com/FreshRSS/FreshRSS/pull/4841)
+	* Improve reader view on mobile screen [#4868](https://github.com/FreshRSS/FreshRSS/pull/4868)
+	* Various UI and style improvements [#4681](https://github.com/FreshRSS/FreshRSS/pull/4681), [#4794](https://github.com/FreshRSS/FreshRSS/pull/4794)
+		[#4800](https://github.com/FreshRSS/FreshRSS/pull/4800), [#4850](https://github.com/FreshRSS/FreshRSS/pull/4850), [#4865](https://github.com/FreshRSS/FreshRSS/pull/4865),
+		[#4872](https://github.com/FreshRSS/FreshRSS/pull/4872), [#4874](https://github.com/FreshRSS/FreshRSS/pull/4874), [#4889](https://github.com/FreshRSS/FreshRSS/pull/4889),
+		[#4890](https://github.com/FreshRSS/FreshRSS/pull/4890), [#4891](https://github.com/FreshRSS/FreshRSS/pull/4891), [#4897](https://github.com/FreshRSS/FreshRSS/pull/4897),
+		[#4899](https://github.com/FreshRSS/FreshRSS/pull/4899), [#4910](https://github.com/FreshRSS/FreshRSS/pull/4910), [#4923](https://github.com/FreshRSS/FreshRSS/pull/4923),
+		[#4927](https://github.com/FreshRSS/FreshRSS/pull/4927), [#4960](https://github.com/FreshRSS/FreshRSS/pull/4960), [#4985](https://github.com/FreshRSS/FreshRSS/pull/4985),
+		[#4998](https://github.com/FreshRSS/FreshRSS/pull/4998), [#5034](https://github.com/FreshRSS/FreshRSS/pull/5034), [#5040](https://github.com/FreshRSS/FreshRSS/pull/5040),
+		[#5055](https://github.com/FreshRSS/FreshRSS/pull/5055), [#5058](https://github.com/FreshRSS/FreshRSS/pull/5058), [#5097](https://github.com/FreshRSS/FreshRSS/pull/5097),
+		[#5100](https://github.com/FreshRSS/FreshRSS/pull/5100)
+* Themes
+	* Dark mode for *Origine* and *Origine compact* themes [#4843](https://github.com/FreshRSS/FreshRSS/pull/4843)
+	* Improve *Ansum* and *Mapco* [#4938](https://github.com/FreshRSS/FreshRSS/pull/4938), [#4959](https://github.com/FreshRSS/FreshRSS/pull/4959), [#4967](https://github.com/FreshRSS/FreshRSS/pull/4967),
+		[#4983](https://github.com/FreshRSS/FreshRSS/pull/4983), [#4995](https://github.com/FreshRSS/FreshRSS/pull/4995)
+	* Improve *Dark pink* [#4881](https://github.com/FreshRSS/FreshRSS/pull/4881)
+	* Improve *Nord theme* [#4892](https://github.com/FreshRSS/FreshRSS/pull/4892), [#4979](https://github.com/FreshRSS/FreshRSS/pull/4979)
+	* Improve *Origine* [#4893](https://github.com/FreshRSS/FreshRSS/pull/4893)
+	* Improve *Origine compact* [#4873](https://github.com/FreshRSS/FreshRSS/pull/4873)
+	* Improve *Pafat* [#4909](https://github.com/FreshRSS/FreshRSS/pull/4909)
+	* Improve *Swage* [#4875](https://github.com/FreshRSS/FreshRSS/pull/4875), [#4922](https://github.com/FreshRSS/FreshRSS/pull/4922), [#4936](https://github.com/FreshRSS/FreshRSS/pull/4936),
+		[#5029](https://github.com/FreshRSS/FreshRSS/pull/5029)
+	* Mark some themes as tentatively deprecated: *BlueLagoon*, *Flat*, *Screwdriver* [#4807](https://github.com/FreshRSS/FreshRSS/pull/4807)
+* i18n
+	* Improve Chinese [#4853](https://github.com/FreshRSS/FreshRSS/pull/4853), [#4856](https://github.com/FreshRSS/FreshRSS/pull/4856)
+* SimplePie
+	* No URL Decode for enclosure links [#768](https://github.com/simplepie/simplepie/pull/768)
+	* Fix case of multiple RSS2.0 enclosures [#769](https://github.com/simplepie/simplepie/pull/769)
+	* Sanitize thumbnail URL [#770](https://github.com/simplepie/simplepie/pull/770)
+	* Use single constant for default HTTP Accept header [#784](https://github.com/simplepie/simplepie/pull/784)
+* Misc.
+	* Increase max feed URL length and drop unicity in database [#5038](https://github.com/FreshRSS/FreshRSS/pull/5038)
+	* New support of [Development Containers](https://containers.dev) / [GitHub Codespaces](https://github.com/features/codespaces) to ease development [#4859](https://github.com/FreshRSS/FreshRSS/pull/4859)
+	* Update library `lib_opml` [#4403](https://github.com/FreshRSS/FreshRSS/pull/4403)
+	* Code improvements [#4232](https://github.com/FreshRSS/FreshRSS/pull/4232), [#4651](https://github.com/FreshRSS/FreshRSS/pull/4651),
+		[#5024](https://github.com/FreshRSS/FreshRSS/pull/5024), [#5025](https://github.com/FreshRSS/FreshRSS/pull/5025), [#5028](https://github.com/FreshRSS/FreshRSS/pull/5028),
+		[#5032](https://github.com/FreshRSS/FreshRSS/pull/5032), [#5158](https://github.com/FreshRSS/FreshRSS/pull/5158), [#5045](https://github.com/FreshRSS/FreshRSS/pull/5045),
+		[#5049](https://github.com/FreshRSS/FreshRSS/pull/5049), [#5063](https://github.com/FreshRSS/FreshRSS/pull/5063), [#5084](https://github.com/FreshRSS/FreshRSS/pull/5084)
+	* Update dev dependencies [#4993](https://github.com/FreshRSS/FreshRSS/pull/4993), [#5006](https://github.com/FreshRSS/FreshRSS/pull/5006), [#5109](https://github.com/FreshRSS/FreshRSS/pull/5109)
+
+
 ## 2022-12-08 FreshRSS 1.20.2
 
 * Security fixes
-	* Fix security vulnerability in `ext.php` [#4928](https://github.com/FreshRSS/FreshRSS/pull/4928)
+	* [CVE-2022-23497](https://github.com/FreshRSS/FreshRSS/security/advisories/GHSA-hvrj-5fwj-p7v6) Fix security vulnerability in `ext.php` [#4928](https://github.com/FreshRSS/FreshRSS/pull/4928)
 	* Apache `TraceEnable Off` [#4863](https://github.com/FreshRSS/FreshRSS/pull/4863)
 
 

+ 1 - 1
CONTRIBUTING.md

@@ -33,7 +33,7 @@ Did you want to fix a bug? To keep a great coordination between collaborators, y
 3. [Create a new branch](https://help.github.com/articles/creating-and-deleting-branches-within-your-repository/). The name of the branch must be explicit and being prefixed by the related ticket id. For instance, `783-contributing-file` to fix [ticket #783](https://github.com/FreshRSS/FreshRSS/issues/783).
 4. Make your changes to your fork and [send a pull request](https://help.github.com/articles/using-pull-requests/) on the **edge branch**. Don’t forget to add your name to `CREDITS.md` if you’re contributing to FreshRSS for the very first time.
 
-If you have to write code, please follow [our coding style recommendations](https://freshrss.github.io/FreshRSS/en/developers/01_First_steps.html).
+If you have to write code, please follow [our coding style recommendations](https://freshrss.github.io/FreshRSS/en/developers/02_First_steps.html).
 
 **Tip:** if you are searching for bugs easy to fix, have a look at the « [Good first issue](https://github.com/FreshRSS/FreshRSS/issues?q=label%3A%22good+first+issue+%3Ababy%3A%22) » and/or « [Help wanted](https://github.com/FreshRSS/FreshRSS/issues?q=label%3A%22help+wanted+%3Aoctocat%3A%22) » ticket labels.
 

+ 5 - 0
CREDITS.md

@@ -26,6 +26,7 @@ People are sorted by name so please keep this order.
 * [ArthurHoaro](https://github.com/ArthurHoaro): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:ArthurHoaro)
 * [Artur Weigandt](https://github.com/Art4): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Art4), [Web](https://ruhr.social/@Art4)
 * [ASMfreaK](https://github.com/ASMfreaK): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:ASMfreaK)
+* [Axel Leroy](https://github.com/axeleroy): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:axeleroy), [Web](https://axel.leroy.sh/)
 * [azlux](https://github.com/azlux): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:azlux), [Web](https://azlux.fr/)
 * [Bartosz Taudul](https://github.com/wolfpld): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:wolfpld), [Web](https://wolf.nereid.pl/)
 * [Benjamin Bouvier](https://github.com/bnjbvr): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:bnjbvr), [Web](https://benj.me/)
@@ -74,6 +75,7 @@ People are sorted by name so please keep this order.
 * [happymacarts](https://github.com/happymacarts): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:happymacarts)
 * [Harshad Hirapara](https://github.com/harshad389): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:harshad389)
 * [hesch](https://github.com/hesch): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:hesch)
+* [Hippolyte Thomas](https://github.com/hippothomas): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:hippothomas), [Web](https://hippolyte-thomas.fr/)
 * [hoilc](https://github.com/hoilc): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:hoilc)
 * [ibiruai](https://github.com/ibiruai): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:ibiruai)
 * [id-konstantin-stepanov](https://github.com/id-konstantin-stepanov): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:id-konstantin-stepanov)
@@ -121,6 +123,7 @@ People are sorted by name so please keep this order.
 * [Miika Launiainen](https://gitlab.com/miicat): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:miicat), [Web](https://miicat.eu/)
 * [Mike Vanbuskirk](https://github.com/codevbus): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:codevbus) [Web](http://mikevanbuskirk.io/)
 * [miles](https://github.com/miles170): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:miles170)
+* [mincerafter42](https://github.com/mincerafter42): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:mincerafter42), [Web](https://mincerafter42.github.io)
 * [MSZ](https://github.com/mszkb): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:mszkb)
 * [Myuki](https://github.com/Myuki): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Myuki)
 * [Nainor](https://github.com/Nainor): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Nainor)
@@ -170,6 +173,7 @@ People are sorted by name so please keep this order.
 * [romibi](https://github.com/romibi): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:romibi)
 * [Rosemary Le Faive](https://github.com/rosiel): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:rosiel)
 * [ryoku-cha](https://github.com/ryoku-cha): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:ryoku-cha)
+* [Sadetdin EYILI](https://github.com/sad270): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:sad270)
 * [Sandro Jäckel](https://github.com/SuperSandro2000): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:SuperSandro2000), [Web](https://supersandro.de/)
 * [Sebastian K](https://github.com/skrollme): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:skrollme)
 * [shn7798](https://github.com/shn7798): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:shn7798), [Web](http://www.code2talk.com/)
@@ -202,3 +206,4 @@ People are sorted by name so please keep this order.
 * [xnaas](https://github.com/xnaas): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:xnaas), [Web](https://xnaas.info/)
 * [Yamakuni](https://github.com/Yamakuni): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:Yamakuni), [Web](https://ofanch.me/)
 * [yzqzss|一座桥在水上](https://github.com/yzqzss): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:yzqzss), [Web](https://blog.othing.xyz/)
+* [Zhiyuan Zheng](https://github.com/zhzy0077): [contributions](https://github.com/FreshRSS/FreshRSS/pulls?q=is:pr+author:zhzy0077)

+ 1 - 1
Docker/Dockerfile

@@ -2,8 +2,8 @@ FROM debian:11-slim
 
 ENV TZ UTC
 SHELL ["/bin/bash", "-o", "pipefail", "-c"]
-RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
 
+ARG DEBIAN_FRONTEND=noninteractive
 RUN apt-get update && \
 	apt-get install --no-install-recommends -y \
 	ca-certificates cron \

+ 3 - 1
Docker/Dockerfile-Alpine

@@ -1,8 +1,10 @@
-FROM alpine:3.16
+FROM alpine:3.17
 
 ENV TZ UTC
 SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
+
 RUN apk add --no-cache \
+	tzdata \
 	apache2 php-apache2 \
 	php php-curl php-gmp php-intl php-mbstring php-xml php-zip \
 	php-ctype php-dom php-fileinfo php-iconv php-json php-opcache php-openssl php-phar php-session php-simplexml php-xmlreader php-xmlwriter php-xml php-tokenizer php-zlib \

+ 1 - 0
Docker/Dockerfile-Newest

@@ -4,6 +4,7 @@ ENV TZ UTC
 SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
 RUN echo 'http://dl-cdn.alpinelinux.org/alpine/edge/testing' >> /etc/apk/repositories && \
 	apk add --no-cache \
+	tzdata \
 	apache2 php82-apache2 \
 	php82 php82-curl php82-gmp php82-intl php82-mbstring php82-xml php82-zip \
 	php82-ctype php82-dom php82-fileinfo php82-iconv php82-json php82-opcache php82-openssl php82-phar php82-session php82-simplexml php82-xmlreader php82-xmlwriter php82-xml php82-tokenizer php82-zlib \

+ 3 - 1
Docker/Dockerfile-Oldest

@@ -1,8 +1,10 @@
-FROM alpine:3.5
+FROM alpine:3.8
 
 ENV TZ UTC
 SHELL ["/bin/ash", "-eo", "pipefail", "-c"]
+
 RUN apk add --no-cache \
+	tzdata \
 	apache2 php7-apache2 \
 	php7 php7-curl php7-gmp php7-intl php7-mbstring php7-xml php7-zip \
 	php7-ctype php7-dom php7-iconv php7-json php7-opcache php7-openssl php7-phar php7-session php7-xmlreader php7-xml php7-zlib \

+ 1 - 1
Docker/Dockerfile-QEMU-ARM

@@ -8,8 +8,8 @@ COPY ./Docker/qemu-arm-* /usr/bin/
 
 ENV TZ UTC
 SHELL ["/bin/bash", "-o", "pipefail", "-c"]
-RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
 
+ARG DEBIAN_FRONTEND=noninteractive
 RUN apt-get update && \
 	apt-get install --no-install-recommends -y \
 	ca-certificates cron \

+ 1 - 1
Docker/FreshRSS.Apache.conf

@@ -4,7 +4,7 @@ DocumentRoot /var/www/FreshRSS/p/
 RemoteIPHeader X-Forwarded-For
 RemoteIPTrustedProxy 10.0.0.1/8 172.16.0.1/12 192.168.0.1/16
 LogFormat "%a %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined_proxy
-CustomLog /dev/stdout combined_proxy
+CustomLog "|/var/www/FreshRSS/cli/sensitive-log.sh" combined_proxy
 ErrorLog /dev/stderr
 AllowEncodedSlashes On
 ServerTokens OS

+ 5 - 1
Docker/README.md

@@ -81,7 +81,7 @@ and with newer packages in general (Apache, PHP).
 
 ## Environment variables
 
-* `TZ`: (default is `UTC`) A [server timezone](http://php.net/timezones) (default is `UTC`)
+* `TZ`: (default is `UTC`) A [server timezone](http://php.net/timezones)
 * `CRON_MIN`: (default is disabled) Define minutes for the built-in cron job to automatically refresh feeds (see below for more advanced options)
 * `FRESHRSS_ENV`: (default is `production`) Enables additional development information if set to `development` (increases the level of logging and ensures that errors are displayed) (see below for more development options)
 * `COPY_LOG_TO_SYSLOG`: (default is `On`) Copy all the logs to syslog
@@ -303,6 +303,7 @@ services:
       options:
         max-size: 10m
     volumes:
+      # Recommended volume for FreshRSS persistent data such as configuration and SQLite databases
       - data:/var/www/FreshRSS/data
       # Optional volume for storing third-party extensions
       - extensions:/var/www/FreshRSS/extensions
@@ -314,8 +315,11 @@ services:
       # If you want to open a port 8080 on the local machine:
       - "8080:80"
     environment:
+      # A timezone http://php.net/timezones (default is UTC)
       TZ: Europe/Paris
+      # Cron job to refresh feeds at specified minutes
       CRON_MIN: '2,32'
+      # 'development' for additional logs; default is 'production'
       FRESHRSS_ENV: development
       # Optional advanced parameter controlling the internal Apache listening port
       LISTEN: 0.0.0.0:80

+ 7 - 3
Docker/entrypoint.sh

@@ -1,6 +1,7 @@
 #!/bin/sh
 
-php -f ./cli/prepare.php >/dev/null
+ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime
+echo "$TZ" >/etc/timezone
 
 find /etc/php*/ -type f -name php.ini -exec sed -r -i "\\#^;?date.timezone#s#^.*#date.timezone = $TZ#" {} \;
 find /etc/php*/ -type f -name php.ini -exec sed -r -i "\\#^;?post_max_size#s#^.*#post_max_size = 32M#" {} \;
@@ -21,6 +22,10 @@ if [ -n "$CRON_MIN" ]; then
 		-r "s#^[^ ]+ #$CRON_MIN #" | crontab -
 fi
 
+./cli/access-permissions.sh
+
+php -f ./cli/prepare.php >/dev/null
+
 if [ -n "$FRESHRSS_INSTALL" ]; then
 	# shellcheck disable=SC2046
 	php -f ./cli/do-install.php -- \
@@ -54,7 +59,6 @@ if [ -n "$FRESHRSS_USER" ]; then
 	fi
 fi
 
-chown -R :www-data .
-chmod -R g+r . && chmod -R g+w ./data/
+./cli/access-permissions.sh
 
 exec "$@"

+ 22 - 22
Makefile

@@ -60,40 +60,37 @@ stop: ## Stop FreshRSS container if any
 ## Tests and linter ##
 ######################
 .PHONY: test
-test: bin/phpunit ## Run the test suite
-	$(PHP) ./bin/phpunit --bootstrap ./tests/bootstrap.php ./tests
+test: vendor/bin/phpunit ## Run the test suite
+	$(PHP) vendor/bin/phpunit --bootstrap ./tests/bootstrap.php ./tests
 
 .PHONY: lint
-lint: bin/phpcs ## Run the linter on the PHP files
-	$(PHP) ./bin/phpcs . -p -s
+lint: vendor/bin/phpcs ## Run the linter on the PHP files
+	$(PHP) vendor/bin/phpcs . -p -s
 
 .PHONY: lint-fix
-lint-fix: bin/phpcbf ## Fix the errors detected by the linter
-	$(PHP) ./bin/phpcbf . -p -s
+lint-fix: vendor/bin/phpcbf ## Fix the errors detected by the linter
+	$(PHP) vendor/bin/phpcbf . -p -s
 
 bin/composer:
 	mkdir -p bin/
-	wget 'https://raw.githubusercontent.com/composer/getcomposer.org/76a7060ccb93902cd7576b67264ad91c8a2700e2/web/installer' -O - -q | php -- --quiet --install-dir='./bin/' --filename='composer'
+	wget 'https://raw.githubusercontent.com/composer/getcomposer.org/b5dbe5ebdec95ce71b3128b359bd5a85cb0a722d/web/installer' -O - -q | php -- --quiet --install-dir='./bin/' --filename='composer'
 
-bin/phpunit:
-	mkdir -p bin/
-	wget -O bin/phpunit 'https://phar.phpunit.de/phpunit-9.5.20.phar'
-	echo '6becad2da5c37f5ad101cc665ef05a2f1a6a45d2427c8edcc74f72c92fb1e05a bin/phpunit' | sha256sum -c - || rm bin/phpunit
+vendor/bin/phpunit: bin/composer
+	bin/composer install --prefer-dist --no-progress
+	ln -s ../vendor/bin/phpunit bin/phpunit
 
-bin/phpcs:
-	mkdir -p bin/
-	wget -O bin/phpcs 'https://github.com/squizlabs/PHP_CodeSniffer/releases/download/3.7.1/phpcs.phar'
-	echo '7a14323a14af9f58302d15442492ee1076a8cd72c018a816cb44965bf3a9b015 bin/phpcs' | sha256sum -c - || rm bin/phpcs
+vendor/bin/phpcs: bin/composer
+	bin/composer install --prefer-dist --no-progress
+	ln -s ../vendor/bin/phpcs bin/phpcs
 
-bin/phpcbf:
-	mkdir -p bin/
-	wget -O bin/phpcbf 'https://github.com/squizlabs/PHP_CodeSniffer/releases/download/3.7.1/phpcbf.phar'
-	echo 'c93c0e83cbda21c21f849ccf0f4b42979d20004a5a6172ed0ea270eca7ae6fa8 bin/phpcbf' | sha256sum -c - || rm bin/phpcbf
+vendor/bin/phpcbf: bin/composer
+	bin/composer install --prefer-dist --no-progress
+	ln -s ../vendor/bin/phpcbf bin/phpcbf
 
 bin/typos:
 	mkdir -p bin/
 	cd bin ; \
-	wget -q 'https://github.com/crate-ci/typos/releases/download/v1.10.1/typos-v1.10.1-x86_64-unknown-linux-musl.tar.gz' && \
+	wget -q 'https://github.com/crate-ci/typos/releases/download/v1.13.6/typos-v1.13.6-x86_64-unknown-linux-musl.tar.gz' && \
 	tar -xvf *.tar.gz './typos' && \
 	chmod +x typos && \
 	rm *.tar.gz ; \
@@ -102,6 +99,9 @@ bin/typos:
 node_modules/.bin/eslint:
 	npm install
 
+node_modules/.bin/rtlcss:
+	npm install
+
 vendor/bin/phpstan: bin/composer
 	bin/composer install --prefer-dist --no-progress
 
@@ -181,8 +181,8 @@ endif
 ## TOOLS ##
 ###########
 .PHONY: rtl
-rtl: ## Generate RTL CSS files
-	rtlcss -d p/themes/ && find p/themes/ -type f -name '*.rtl.rtl.css' -delete
+rtl: node_modules/.bin/rtlcss ## Generate RTL CSS files
+	npm run-script rtlcss
 
 .PHONY: pot
 pot: ## Generate POT templates for docs

+ 26 - 33
README.fr.md

@@ -5,7 +5,7 @@
 
 # FreshRSS
 
-FreshRSS est un agrégateur de flux RSS à auto-héberger à l’image de [Leed](https://github.com/LeedRSS/Leed) ou de [Kriss Feed](https://tontof.net/kriss/feed/).
+FreshRSS est un agrégateur de flux RSS à auto-héberger.
 
 Il se veut léger et facile à prendre en main tout en étant un outil puissant et paramétrable.
 
@@ -19,21 +19,29 @@ FreshRSS supporte nativement le moissonnage du Web (Web Scraping) basique, basé
 
 Enfin, il permet l’ajout d’[extensions](#extensions) pour encore plus de personnalisation.
 
-Les demandes de fonctionnalités, rapports de bugs, et autres contributions sont les bienvenues. Privilégiez pour cela des [demandes sur GitHub](https://github.com/FreshRSS/FreshRSS/issues).
-Nous sommes une communauté amicale.
-
 * Site officiel : <https://freshrss.org>
 * Démo : <http://demo.freshrss.org/>
 * Licence : [GNU AGPL 3](https://www.gnu.org/licenses/agpl-3.0.fr.html)
 
 ![Logo de FreshRSS](docs/img/FreshRSS-logo.png)
 
-# Avertissements
+## Contributions
 
-FreshRSS n’est fourni avec aucune garantie.
+Les demandes de fonctionnalités, rapports de bugs, et autres contributions sont les bienvenues. Privilégiez pour cela des [demandes sur GitHub](https://github.com/FreshRSS/FreshRSS/issues).
+Nous sommes une communauté amicale.
+
+Pour faciliter les contributions, l’option suivante est disponible :
+
+[![Ouvrir dans GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=edge&repo=6322699)
+
+## Capture d’écran
 
 ![Capture d’écran de FreshRSS](docs/img/FreshRSS-screenshot.png)
 
+## Avertissements
+
+FreshRSS n’est fourni avec aucune garantie.
+
 # [Documentation](https://freshrss.github.io/FreshRSS/fr/)
 
 * La [documentation utilisateurs](https://freshrss.github.io/FreshRSS/fr/users/02_First_steps.html) pour découvrir les fonctionnalités de FreshRSS.
@@ -41,28 +49,24 @@ FreshRSS n’est fourni avec aucune garantie.
 * La [documentation développeurs](https://freshrss.github.io/FreshRSS/fr/developers/01_First_steps.html) pour savoir comment contribuer et mieux comprendre le code source de FreshRSS.
 * Le [guide de contribution](https://freshrss.github.io/FreshRSS/fr/contributing.html) pour nous aider à développer FreshRSS.
 
-# Prérequis
+## Prérequis
 
 * Un navigateur Web récent tel que Firefox / IceCat, Edge, Chromium / Chrome, Opera, Safari.
 	* Fonctionne aussi sur mobile (sauf certaines fonctionnalités)
 * Serveur modeste, par exemple sous Linux ou Windows
 	* Fonctionne même sur un Raspberry Pi 1 avec des temps de réponse < 1s (testé sur 150 flux, 22k articles)
 * Serveur Web Apache2 (recommandé), ou nginx, lighttpd (non testé sur les autres)
-* PHP 7.0+
+* PHP 7.2+
 	* Requis : [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype), et [PDO_MySQL](https://www.php.net/pdo-mysql) ou [PDO_SQLite](https://www.php.net/pdo-sqlite) ou [PDO_PGSQL](https://www.php.net/pdo-pgsql)
 	* Recommandés : [GMP](https://www.php.net/gmp) (pour accès API sur plateformes < 64 bits), [IDN](https://www.php.net/intl.idn) (pour les noms de domaines internationalisés), [mbstring](https://www.php.net/mbstring) (pour le texte Unicode), [iconv](https://www.php.net/iconv) (pour conversion d’encodages), [ZIP](https://www.php.net/zip) (pour import/export), [zlib](https://www.php.net/zlib) (pour les flux compressés)
 * MySQL 5.5.3+ ou équivalent MariaDB, ou SQLite 3.7.4+, ou PostgreSQL 9.5+
 
-
-# Téléchargement
+# [Installation](https://freshrss.github.io/FreshRSS/fr/users/01_Installation.html)
 
 Si vous préférez que votre FreshRSS soit stable, vous devriez télécharger la dernière version. De nouvelles versions sont publiées tous les 2 ou 3 mois. Voir la [liste des versions](https://github.com/FreshRSS/FreshRSS/releases).
 
 Si vous voulez une publication continue (rolling release) avec les dernières nouveautés, ou bien aider à tester ou développer la future version stable, vous pouvez utiliser [la branche edge](https://github.com/FreshRSS/FreshRSS/tree/edge/).
 
-
-# [Installation](https://freshrss.github.io/FreshRSS/fr/users/01_Installation.html)
-
 ## Installation automatisée
 
 * [<img src="https://www.docker.com/wp-content/uploads/2022/03/horizontal-logo-monochromatic-white.png" width="200" alt="Docker" />](./Docker/)
@@ -83,7 +87,7 @@ Si vous voulez une publication continue (rolling release) avec les dernières no
 
 Plus d’informations sur l’installation et la configuration serveur peuvent être trouvées dans [notre documentation](https://freshrss.github.io/FreshRSS/fr/users/01_Installation.html).
 
-### Exemple d’installation complète sur Linux Debian/Ubuntu
+## Exemple d’installation complète sur Linux Debian/Ubuntu
 
 ```sh
 # Si vous utilisez le serveur Web Apache (sinon il faut un autre serveur Web)
@@ -105,11 +109,12 @@ sudo apt-get install git
 sudo git clone https://github.com/FreshRSS/FreshRSS.git
 cd FreshRSS
 
-# Si vous souhaitez utiliser la dernière version stable de FreshRSS
-sudo git checkout $(git describe --tags --abbrev=0)
+# La branche par défault “edge” est la celle de la publication continue,
+# mais vous pouvez changer de branche pour “latest” si vous préférez les versions stables de FreshRSS
+sudo git checkout latest
 
 # Mettre les droits d’accès pour le serveur Web
-sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
+sudo cli/access-permissions.sh
 # Si vous souhaitez permettre les mises à jour par l’interface Web
 sudo chmod -R g+w .
 
@@ -122,7 +127,7 @@ sudo ln -s /usr/share/FreshRSS/p /var/www/html/FreshRSS
 # Mettre à jour FreshRSS vers une nouvelle version par git
 cd /usr/share/FreshRSS
 sudo git pull
-sudo chown -R :www-data . && sudo chmod -R g+r . && sudo chmod -R g+w ./data/
+sudo cli/access-permissions.sh
 ```
 
 Voir la [documentation de la ligne de commande](cli/README.md) pour plus de détails.
@@ -155,7 +160,7 @@ Créer `/etc/cron.d/FreshRSS` avec :
 7,37 * * * * www-data php -f /usr/share/FreshRSS/app/actualize_script.php > /tmp/FreshRSS.log 2>&1
 ```
 
-## Conseils
+# Conseils
 
 * Pour une meilleure sécurité, faites en sorte que seul le répertoire `./p/` soit accessible depuis le Web, par exemple en faisant pointer un sous-domaine sur le répertoire `./p/`.
 	* En particulier, les données personnelles se trouvent dans le répertoire `./data/`.
@@ -175,19 +180,7 @@ Créer `/etc/cron.d/FreshRSS` avec :
 * Il faut conserver vos fichiers `./data/config.php` ainsi que `./data/users/*/config.php`
 * Vous pouvez exporter votre liste de flux au format OPML soit depuis l’interface Web, soit [en ligne de commande](cli/README.md)
 
-Pour sauvegarder les articles eux-mêmes :
-
-## Dans le cas où vous utilisez MySQL
-
-Vous pouvez utiliser [phpMyAdmin](https://www.phpmyadmin.net) ou les outils de MySQL :
-
-```sh
-mysqldump --skip-comments --disable-keys --user=<db_user> --password --host <db_host> --result-file=freshrss.dump.sql --databases <freshrss_db>
-```
-
-## Pour toutes les bases supportées
-
-Vous pouvez utiliser la [ligne de commande](cli/README.md) pour exporter votre base de données vers une base de données au format SQLite :
+Pour sauvegarder les articles eux-mêmes, vous pouvez utiliser la [ligne de commande](cli/README.md) pour exporter votre base de données vers une base de données au format SQLite :
 
 ```sh
 ./cli/export-sqlite-for-user.php --user <username> --filename </path/to/db.sqlite>
@@ -250,7 +243,7 @@ et [l’API Fever](https://freshrss.github.io/FreshRSS/fr/users/06_Fever_API.htm
 * [SimplePie](https://simplepie.org/)
 * [MINZ](https://framagit.org/marienfressinaud/MINZ)
 * [php-http-304](https://alexandre.alapetite.fr/doc-alex/php-http-304/)
-* [lib_opml](https://github.com/marienfressinaud/lib_opml)
+* [lib_opml](https://framagit.org/marienfressinaud/lib_opml)
 * [PhpGt/CssXPath](https://github.com/PhpGt/CssXPath)
 * [PHPMailer](https://github.com/PHPMailer/PHPMailer)
 * [Chart.js](https://www.chartjs.org)

+ 18 - 14
README.md

@@ -5,7 +5,7 @@
 
 # FreshRSS
 
-FreshRSS is a self-hosted RSS feed aggregator like [Leed](https://github.com/LeedRSS/Leed) or [Kriss Feed](https://tontof.net/kriss/feed/).
+FreshRSS is a self-hosted RSS feed aggregator.
 
 It is lightweight, easy to work with, powerful, and customizable.
 
@@ -19,21 +19,29 @@ FreshRSS natively supports basic Web scraping, based on [XPath](https://www.w3.o
 
 Finally, it supports [extensions](#extensions) for further tuning.
 
-Feature requests, bug reports, and other contributions are welcome. The best way to contribute is to [open an issue on GitHub](https://github.com/FreshRSS/FreshRSS/issues).
-We are a friendly community.
-
 * Official website: <https://freshrss.org>
 * Demo: <https://demo.freshrss.org/>
 * License: [GNU AGPL 3](https://www.gnu.org/licenses/agpl-3.0.html)
 
 ![FreshRSS logo](docs/img/FreshRSS-logo.png)
 
-# Disclaimer
+## Feedback and contributions
 
-FreshRSS comes with absolutely no warranty.
+Feature requests, bug reports, and other contributions are welcome. The best way is to [open an issue on GitHub](https://github.com/FreshRSS/FreshRSS/issues).
+We are a friendly community.
+
+To facilitate contributions, the following option is available:
+
+[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://github.com/codespaces/new?hide_repo_select=true&ref=edge&repo=6322699)
+
+## Screenshot
 
 ![FreshRSS screenshot](docs/img/FreshRSS-screenshot.png)
 
+## Disclaimer
+
+FreshRSS comes with absolutely no warranty.
+
 # [Documentation](https://freshrss.github.io/FreshRSS/en/)
 
 * [User documentation](https://freshrss.github.io/FreshRSS/en/users/02_First_steps.html), where you can discover all the possibilities offered by FreshRSS
@@ -48,21 +56,17 @@ FreshRSS comes with absolutely no warranty.
 * Light server running Linux or Windows
 	* It even works on Raspberry Pi 1 with response time under a second (tested with 150 feeds, 22k articles)
 * A web server: Apache2 (recommended), nginx, lighttpd (not tested on others)
-* PHP 7.0+
+* PHP 7.2+
 	* Required extensions: [cURL](https://www.php.net/curl), [DOM](https://www.php.net/dom), [JSON](https://www.php.net/json), [XML](https://www.php.net/xml), [session](https://www.php.net/session), [ctype](https://www.php.net/ctype), and [PDO_MySQL](https://www.php.net/pdo-mysql) or [PDO_SQLite](https://www.php.net/pdo-sqlite) or [PDO_PGSQL](https://www.php.net/pdo-pgsql)
 	* Recommended extensions: [GMP](https://www.php.net/gmp) (for API access on 32-bit platforms), [IDN](https://www.php.net/intl.idn) (for Internationalized Domain Names), [mbstring](https://www.php.net/mbstring) (for Unicode strings), [iconv](https://www.php.net/iconv) (for charset conversion), [ZIP](https://www.php.net/zip) (for import/export), [zlib](https://www.php.net/zlib) (for compressed feeds)
 * MySQL 5.5.3+ or MariaDB equivalent, or SQLite 3.7.4+, or PostgreSQL 9.5+
 
-
-# Releases
+# [Installation](https://freshrss.github.io/FreshRSS/en/admins/03_Installation.html)
 
 The latest stable release can be found [here](https://github.com/FreshRSS/FreshRSS/releases/latest). New versions are released every two to three months.
 
 If you want a rolling release with the newest features, or want to help testing or developing the next stable version, you can use [the `edge` branch](https://github.com/FreshRSS/FreshRSS/tree/edge/).
 
-
-# [Installation](https://freshrss.github.io/FreshRSS/en/admins/03_Installation.html)
-
 ## Automated install
 
 * [<img src="https://www.docker.com/wp-content/uploads/2022/03/horizontal-logo-monochromatic-white.png" width="200" alt="Docker" />](./Docker/)
@@ -83,7 +87,7 @@ If you want a rolling release with the newest features, or want to help testing
 
 More detailed information about installation and server configuration can be found in [our documentation](https://freshrss.github.io/FreshRSS/en/admins/03_Installation.html).
 
-## Advice
+# Advice
 
 * For better security, expose only the `./p/` folder to the Web.
 	* Be aware that the `./data/` folder contains all personal data, so it is a bad idea to expose it.
@@ -138,7 +142,7 @@ and [Fever API](https://freshrss.github.io/FreshRSS/en/users/06_Fever_API.html)
 * [SimplePie](https://simplepie.org/)
 * [MINZ](https://framagit.org/marienfressinaud/MINZ)
 * [php-http-304](https://alexandre.alapetite.fr/doc-alex/php-http-304/)
-* [lib_opml](https://github.com/marienfressinaud/lib_opml)
+* [lib_opml](https://framagit.org/marienfressinaud/lib_opml)
 * [PhpGt/CssXPath](https://github.com/PhpGt/CssXPath)
 * [PHPMailer](https://github.com/PHPMailer/PHPMailer)
 * [Chart.js](https://www.chartjs.org)

+ 19 - 16
app/Controllers/configureController.php

@@ -25,6 +25,7 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 	 * The options available on the page are:
 	 *   - language (default: en)
 	 *   - theme (default: Origin)
+	 *   - darkMode (default: no)
 	 *   - content width (default: thin)
 	 *   - display of read action in header
 	 *   - display of favorite action in header
@@ -42,7 +43,9 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 	public function displayAction() {
 		if (Minz_Request::isPost()) {
 			FreshRSS_Context::$user_conf->language = Minz_Request::param('language', 'en');
+			FreshRSS_Context::$user_conf->timezone = Minz_Request::param('timezone', '');
 			FreshRSS_Context::$user_conf->theme = Minz_Request::param('theme', FreshRSS_Themes::$defaultTheme);
+			FreshRSS_Context::$user_conf->darkMode = Minz_Request::param('darkMode', 'no');
 			FreshRSS_Context::$user_conf->content_width = Minz_Request::param('content_width', 'thin');
 			FreshRSS_Context::$user_conf->topline_read = Minz_Request::param('topline_read', false);
 			FreshRSS_Context::$user_conf->topline_favorite = Minz_Request::param('topline_favorite', false);
@@ -106,32 +109,32 @@ class FreshRSS_configure_Controller extends FreshRSS_ActionController {
 			FreshRSS_Context::$user_conf->posts_per_page = Minz_Request::param('posts_per_page', 10);
 			FreshRSS_Context::$user_conf->view_mode = Minz_Request::param('view_mode', 'normal');
 			FreshRSS_Context::$user_conf->default_view = Minz_Request::param('default_view', 'adaptive');
-			FreshRSS_Context::$user_conf->show_fav_unread = Minz_Request::param('show_fav_unread', false);
-			FreshRSS_Context::$user_conf->auto_load_more = Minz_Request::param('auto_load_more', false);
-			FreshRSS_Context::$user_conf->display_posts = Minz_Request::param('display_posts', false);
+			FreshRSS_Context::$user_conf->show_fav_unread = Minz_Request::paramBoolean('show_fav_unread');
+			FreshRSS_Context::$user_conf->auto_load_more = Minz_Request::paramBoolean('auto_load_more');
+			FreshRSS_Context::$user_conf->display_posts = Minz_Request::paramBoolean('display_posts');
 			FreshRSS_Context::$user_conf->display_categories = Minz_Request::param('display_categories', 'active');
 			FreshRSS_Context::$user_conf->show_tags = Minz_Request::param('show_tags', '0');
 			FreshRSS_Context::$user_conf->show_tags_max = Minz_Request::param('show_tags_max', '0');
 			FreshRSS_Context::$user_conf->show_author_date = Minz_Request::param('show_author_date', '0');
 			FreshRSS_Context::$user_conf->show_feed_name = Minz_Request::param('show_feed_name', 't');
-			FreshRSS_Context::$user_conf->hide_read_feeds = Minz_Request::param('hide_read_feeds', false);
-			FreshRSS_Context::$user_conf->onread_jump_next = Minz_Request::param('onread_jump_next', false);
-			FreshRSS_Context::$user_conf->lazyload = Minz_Request::param('lazyload', false);
-			FreshRSS_Context::$user_conf->sides_close_article = Minz_Request::param('sides_close_article', false);
-			FreshRSS_Context::$user_conf->sticky_post = Minz_Request::param('sticky_post', false);
-			FreshRSS_Context::$user_conf->reading_confirm = Minz_Request::param('reading_confirm', false);
-			FreshRSS_Context::$user_conf->auto_remove_article = Minz_Request::param('auto_remove_article', false);
-			FreshRSS_Context::$user_conf->mark_updated_article_unread = Minz_Request::param('mark_updated_article_unread', false);
+			FreshRSS_Context::$user_conf->hide_read_feeds = Minz_Request::paramBoolean('hide_read_feeds');
+			FreshRSS_Context::$user_conf->onread_jump_next = Minz_Request::paramBoolean('onread_jump_next');
+			FreshRSS_Context::$user_conf->lazyload = Minz_Request::paramBoolean('lazyload');
+			FreshRSS_Context::$user_conf->sides_close_article = Minz_Request::paramBoolean('sides_close_article');
+			FreshRSS_Context::$user_conf->sticky_post = Minz_Request::paramBoolean('sticky_post');
+			FreshRSS_Context::$user_conf->reading_confirm = Minz_Request::paramBoolean('reading_confirm');
+			FreshRSS_Context::$user_conf->auto_remove_article = Minz_Request::paramBoolean('auto_remove_article');
+			FreshRSS_Context::$user_conf->mark_updated_article_unread = Minz_Request::paramBoolean('mark_updated_article_unread');
 			FreshRSS_Context::$user_conf->sort_order = Minz_Request::param('sort_order', 'DESC');
 			FreshRSS_Context::$user_conf->mark_when = array(
-				'article' => Minz_Request::param('mark_open_article', false),
-				'gone' => Minz_Request::param('read_upon_gone', false),
+				'article' => Minz_Request::paramBoolean('mark_open_article'),
+				'gone' => Minz_Request::paramBoolean('read_upon_gone'),
 				'max_n_unread' => Minz_Request::paramBoolean('enable_keep_max_n_unread') ? Minz_Request::param('keep_max_n_unread', false) : false,
-				'reception' => Minz_Request::param('mark_upon_reception', false),
+				'reception' => Minz_Request::paramBoolean('mark_upon_reception'),
 				'same_title_in_feed' => Minz_Request::paramBoolean('enable_read_when_same_title_in_feed') ?
 					Minz_Request::param('read_when_same_title_in_feed', false) : false,
-				'scroll' => Minz_Request::param('mark_scroll', false),
-				'site' => Minz_Request::param('mark_open_site', false),
+				'scroll' => Minz_Request::paramBoolean('mark_scroll'),
+				'site' => Minz_Request::paramBoolean('mark_open_site'),
 			);
 			FreshRSS_Context::$user_conf->save();
 			invalidateHttpCache();

+ 12 - 6
app/Controllers/entryController.php

@@ -81,6 +81,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 				$feed->load(true);	//Throws FreshRSS_Feed_Exception, Minz_FileNotExistException
 				break;
 			case FreshRSS_Feed::KIND_HTML_XPATH:
+			case FreshRSS_Feed::KIND_XML_XPATH:
 				$feed->_website($url);
 				break;
 		}
@@ -172,7 +173,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 			$proxy_address = Minz_Request::param('curl_params', '');
 			$proxy_type = Minz_Request::param('proxy_type', '');
 			$opts = [];
-			if ($proxy_address !== '' && $proxy_type !== '' && in_array($proxy_type, [0, 2, 4, 5, 6, 7])) {
+			if ($proxy_type !== '') {
 				$opts[CURLOPT_PROXY] = $proxy_address;
 				$opts[CURLOPT_PROXYTYPE] = intval($proxy_type);
 			}
@@ -201,8 +202,8 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 			$timeout = intval(Minz_Request::param('timeout', 0));
 			$attributes['timeout'] = $timeout > 0 ? $timeout : null;
 
-			$feed_kind = Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS);
-			if ($feed_kind == FreshRSS_Feed::KIND_HTML_XPATH) {
+			$feed_kind = (int)Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS);
+			if ($feed_kind === FreshRSS_Feed::KIND_HTML_XPATH || $feed_kind === FreshRSS_Feed::KIND_XML_XPATH) {
 				$xPathSettings = [];
 				if (Minz_Request::param('xPathFeedTitle', '') != '') $xPathSettings['feedTitle'] = Minz_Request::param('xPathFeedTitle', '', true);
 				if (Minz_Request::param('xPathItem', '') != '') $xPathSettings['item'] = Minz_Request::param('xPathItem', '', true);
@@ -385,10 +386,15 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 				if ($simplePiePush) {
 					$simplePie = $simplePiePush;	//Used by WebSub
 				} elseif ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH) {
-					$simplePie = $feed->loadHtmlXpath(false, $isNewFeed);
-					if ($simplePie == null) {
+					$simplePie = $feed->loadHtmlXpath();
+					if ($simplePie === null) {
 						throw new FreshRSS_Feed_Exception('HTML+XPath Web scraping failed for [' . $feed->url(false) . ']');
 					}
+				} elseif ($feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) {
+					$simplePie = $feed->loadHtmlXpath();
+					if ($simplePie === null) {
+						throw new FreshRSS_Feed_Exception('XML+XPath parsing failed for [' . $feed->url(false) . ']');
+					}
 				} else {
 					$simplePie = $feed->load(false, $isNewFeed);
 				}
@@ -949,7 +955,7 @@ class FreshRSS_feed_Controller extends FreshRSS_ActionController {
 				$this->view->htmlContent = $fullContent;
 			} else {
 				$this->view->selectorSuccess = false;
-				$this->view->htmlContent = $entry->content();
+				$this->view->htmlContent = $entry->content(false);
 			}
 		} catch (Exception $e) {
 			$this->view->fatalError = _t('feedback.sub.feed.selector_preview.http_error');

+ 0 - 2
app/Controllers/importExportController.php

@@ -21,8 +21,6 @@ class FreshRSS_importExport_Controller extends FreshRSS_ActionController {
 			Minz_Error::error(403);
 		}
 
-		require_once(LIB_PATH . '/lib_opml.php');
-
 		$this->entryDAO = FreshRSS_Factory::createEntryDao();
 		$this->feedDAO = FreshRSS_Factory::createFeedDao();
 	}

+ 0 - 2
app/Controllers/indexController.php

@@ -237,8 +237,6 @@ class FreshRSS_index_Controller extends FreshRSS_ActionController {
 				return;
 		}
 
-		require_once(LIB_PATH . '/lib_opml.php');
-
 		// No layout for OPML output.
 		$this->view->_layout(false);
 		header('Content-Type: application/xml; charset=utf-8');

+ 4 - 4
app/Controllers/javascriptController.php

@@ -1,11 +1,11 @@
 <?php
 
 class FreshRSS_javascript_Controller extends FreshRSS_ActionController {
-	public function firstAction() {
+	public function firstAction(): void {
 		$this->view->_layout(false);
 	}
 
-	public function actualizeAction() {
+	public function actualizeAction(): void {
 		header('Content-Type: application/json; charset=UTF-8');
 		Minz_Session::_param('actualize_feeds', false);
 
@@ -16,7 +16,7 @@ class FreshRSS_javascript_Controller extends FreshRSS_ActionController {
 		$this->view->feeds = $feedDAO->listFeedsOrderUpdate(FreshRSS_Context::$user_conf->ttl_default);
 	}
 
-	public function nbUnreadsPerFeedAction() {
+	public function nbUnreadsPerFeedAction(): void {
 		header('Content-Type: application/json; charset=UTF-8');
 		$catDAO = FreshRSS_Factory::createCategoryDao();
 		$this->view->categories = $catDAO->listCategories(true, false);
@@ -25,7 +25,7 @@ class FreshRSS_javascript_Controller extends FreshRSS_ActionController {
 	}
 
 	//For Web-form login
-	public function nonceAction() {
+	public function nonceAction(): void {
 		header('Content-Type: application/json; charset=UTF-8');
 		header('Last-Modified: ' . gmdate('D, d M Y H:i:s \G\M\T'));
 		header('Expires: 0');

+ 8 - 29
app/Controllers/statsController.php

@@ -10,7 +10,7 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 	 * the common boiler plate for every action. It is triggered by the
 	 * underlying framework.
 	 */
-	public function firstAction() {
+	public function firstAction(): void {
 		if (!FreshRSS_Auth::hasAccess()) {
 			Minz_Error::error(403);
 		}
@@ -32,27 +32,6 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 		FreshRSS_View::prependTitle(_t('admin.stats.title') . ' · ');
 	}
 
-	private function convertToSeries($data) {
-		$series = array();
-
-		foreach ($data as $key => $value) {
-			$series[] = array($key, $value);
-		}
-
-		return $series;
-	}
-
-	private function convertToPieSeries($data) {
-		$series = array();
-
-		foreach ($data as $value) {
-			$value['data'] = array(array(0, (int) $value['data']));
-			$series[] = $value;
-		}
-
-		return $series;
-	}
-
 	/**
 	 * This action handles the statistic main page.
 	 *
@@ -64,7 +43,7 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 	 *   - number of article by category (entryByCategory)
 	 *   - list of most prolific feed (topFeed)
 	 */
-	public function indexAction() {
+	public function indexAction(): void {
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/vendor/chart.min.js?' . @filemtime(PUBLIC_PATH . '/scripts/vendor/chart.min.js')));
 
@@ -94,7 +73,7 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 
 		$last30DaysLabels = [];
 		for ($i = 0; $i < 30; $i++) {
-			$last30DaysLabels[$i] = date('d.m.Y', strtotime((-30 + $i) . ' days'));
+			$last30DaysLabels[$i] = date('d.m.Y', strtotime((-30 + $i) . ' days') ?: null);
 		}
 
 		$this->view->last30DaysLabels = $last30DaysLabels;
@@ -106,9 +85,9 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 	 * to use the subscription controller to save it,
 	 * but shows the stats idle page
 	 */
-	public function feedAction() {
-		$id = Minz_Request::param('id');
-		$ajax = Minz_Request::param('ajax');
+	public function feedAction(): void {
+		$id = '' . Minz_Request::param('id', '');
+		$ajax = '' . Minz_Request::param('ajax', '');
 		if ($ajax) {
 			$url_redirect = array('c' => 'subscription', 'a' => 'feed', 'params' => array('id' => $id, 'from' => 'stats', 'ajax' => $ajax));
 		} else {
@@ -131,7 +110,7 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 	 *   - last month
 	 *   - last week
 	 */
-	public function idleAction() {
+	public function idleAction(): void {
 		FreshRSS_View::appendScript(Minz_Url::display('/scripts/feed.js?' . @filemtime(PUBLIC_PATH . '/scripts/feed.js')));
 		$feed_dao = FreshRSS_Factory::createFeedDao();
 		$statsDAO = FreshRSS_Factory::createStatsDAO();
@@ -216,7 +195,7 @@ class FreshRSS_stats_Controller extends FreshRSS_ActionController {
 	 * @todo verify that the metrics used here make some sense. Especially
 	 *       for the average.
 	 */
-	public function repartitionAction() {
+	public function repartitionAction(): void {
 		$statsDAO 		= FreshRSS_Factory::createStatsDAO();
 		$categoryDAO 	= FreshRSS_Factory::createCategoryDao();
 		$feedDAO 		= FreshRSS_Factory::createFeedDao();

+ 11 - 7
app/Controllers/subscriptionController.php

@@ -118,8 +118,6 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 				$httpAuth = $user . ':' . $pass;
 			}
 
-			$cat = intval(Minz_Request::param('category', 0));
-
 			$feed->_ttl(intval(Minz_Request::param('ttl', FreshRSS_Feed::TTL_DEFAULT)));
 			$feed->_mute(boolval(Minz_Request::param('mute', false)));
 
@@ -149,7 +147,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 			$proxy_address = Minz_Request::param('curl_params', '');
 			$proxy_type = Minz_Request::param('proxy_type', '');
 			$opts = [];
-			if ($proxy_address !== '' && $proxy_type !== '' && in_array($proxy_type, [0, 2, 4, 5, 6, 7])) {
+			if ($proxy_type !== '') {
 				$opts[CURLOPT_PROXY] = $proxy_address;
 				$opts[CURLOPT_PROXYTYPE] = intval($proxy_type);
 			}
@@ -205,7 +203,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 			$feed->_filtersAction('read', preg_split('/[\n\r]+/', Minz_Request::param('filteractions_read', '')));
 
 			$feed->_kind(intval(Minz_Request::param('feed_kind', FreshRSS_Feed::KIND_RSS)));
-			if ($feed->kind() == FreshRSS_Feed::KIND_HTML_XPATH) {
+			if ($feed->kind() === FreshRSS_Feed::KIND_HTML_XPATH || $feed->kind() === FreshRSS_Feed::KIND_XML_XPATH) {
 				$xPathSettings = [];
 				if (Minz_Request::param('xPathItem', '') != '') $xPathSettings['item'] = Minz_Request::param('xPathItem', '', true);
 				if (Minz_Request::param('xPathItemTitle', '') != '') $xPathSettings['itemTitle'] = Minz_Request::param('xPathItemTitle', '', true);
@@ -230,7 +228,7 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 				'description' => sanitizeHTML(Minz_Request::param('description', '', true)),
 				'website' => checkUrl(Minz_Request::param('website', '')),
 				'url' => checkUrl(Minz_Request::param('url', '')),
-				'category' => $cat,
+				'category' => intval(Minz_Request::param('category', 0)),
 				'pathEntries' => Minz_Request::param('path_entries', ''),
 				'priority' => intval(Minz_Request::param('priority', FreshRSS_Feed::PRIORITY_MAIN_STREAM)),
 				'httpAuth' => $httpAuth,
@@ -258,12 +256,18 @@ class FreshRSS_subscription_Controller extends FreshRSS_ActionController {
 					$url_redirect = array('c' => 'subscription', 'params' => array('id' => $id));
 			}
 
-			if ($feedDAO->updateFeed($id, $values) !== false) {
-				$feed->_categoryId($cat);
+			if ($values['url'] != '' && $feedDAO->updateFeed($id, $values) !== false) {
+				$feed->_categoryId($values['category']);
+				// update url and website values for faviconPrepare
+				$feed->_url($values['url'], false);
+				$feed->_website($values['website'], false);
 				$feed->faviconPrepare();
 
 				Minz_Request::good(_t('feedback.sub.feed.updated'), $url_redirect);
 			} else {
+				if ($values['url'] == '') {
+					Minz_Log::warning('Invalid feed URL!');
+				}
 				Minz_Request::bad(_t('feedback.sub.feed.error'), $url_redirect);
 			}
 		}

+ 6 - 6
app/Controllers/updateController.php

@@ -14,7 +14,7 @@ class FreshRSS_update_Controller extends FreshRSS_ActionController {
 	public static function migrateToGitEdge() {
 		$errorMessage = 'Error during git checkout to edge branch. Please change branch manually!';
 
-		if (!is_writable(FRESHRSS_PATH . '/.git/')) {
+		if (!is_writable(FRESHRSS_PATH . '/.git/config')) {
 			throw new Exception($errorMessage);
 		}
 
@@ -23,7 +23,7 @@ class FreshRSS_update_Controller extends FreshRSS_ActionController {
 		if ($return != 0) {
 			throw new Exception($errorMessage);
 		}
-		$line = is_array($output) ? implode('', $output) : $output;
+		$line = implode('', $output);
 		if ($line !== 'master' && $line !== 'dev') {
 			return true;	// not on master or dev, nothing to do
 		}
@@ -54,14 +54,14 @@ class FreshRSS_update_Controller extends FreshRSS_ActionController {
 				$output = [];
 				exec('git status -sb --porcelain remote', $output, $return);
 			} else {
-				$line = is_array($output) ? implode('; ', $output) : $output;
+				$line = implode('; ', $output);
 				Minz_Log::warning('git fetch warning: ' . $line);
 			}
 		} catch (Exception $e) {
 			Minz_Log::warning('git fetch error: ' . $e->getMessage());
 		}
 		chdir($cwd);
-		$line = is_array($output) ? implode('; ', $output) : $output;
+		$line = implode('; ', $output);
 		return $line == '' ||
 			strpos($line, '[behind') !== false || strpos($line, '[ahead') !== false || strpos($line, '[gone') !== false;
 	}
@@ -118,7 +118,7 @@ class FreshRSS_update_Controller extends FreshRSS_ActionController {
 			if ($version == '') {
 				$version = 'unknown';
 			}
-			if (is_writable(FRESHRSS_PATH)) {
+			if (touch(FRESHRSS_PATH . '/index.html')) {
 				$this->view->update_to_apply = true;
 				$this->view->message = array(
 					'status' => 'good',
@@ -217,7 +217,7 @@ class FreshRSS_update_Controller extends FreshRSS_ActionController {
 	}
 
 	public function applyAction() {
-		if (!file_exists(UPDATE_FILENAME) || !is_writable(FRESHRSS_PATH) || Minz_Configuration::get('system')->disable_update) {
+		if (FreshRSS_Context::$system_conf->disable_update || !file_exists(UPDATE_FILENAME) || !touch(FRESHRSS_PATH . '/index.html')) {
 			Minz_Request::forward(array('c' => 'update'), true);
 		}
 

+ 2 - 1
app/Controllers/userController.php

@@ -242,7 +242,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 		}
 		if ($ok) {
 			if (!is_dir($homeDir)) {
-				mkdir($homeDir);
+				mkdir($homeDir, 0770, true);
 			}
 			$ok &= (file_put_contents($configPath, "<?php\n return " . var_export($userConfig, true) . ';') !== false);
 		}
@@ -344,6 +344,7 @@ class FreshRSS_user_Controller extends FreshRSS_ActionController {
 
 			$ok = self::createUser($new_user_name, $email, $passwordPlain, array(
 				'language' => Minz_Request::param('new_user_language', FreshRSS_Context::$user_conf->language),
+				'timezone' => Minz_Request::param('new_user_timezone', ''),
 				'is_admin' => Minz_Request::paramBoolean('new_user_is_admin'),
 				'enabled' => true,
 			));

+ 20 - 10
app/FreshRSS.php

@@ -18,7 +18,7 @@ class FreshRSS extends Minz_FrontController {
 	 * - Init notifications
 	 * - Enable user extensions (need all the other initializations)
 	 */
-	public function init() {
+	public function init(): void {
 		if (!isset($_SESSION)) {
 			Minz_Session::init('FreshRSS');
 		}
@@ -71,10 +71,10 @@ class FreshRSS extends Minz_FrontController {
 		Minz_ExtensionManager::callHook('freshrss_init');
 	}
 
-	private static function initAuth() {
+	private static function initAuth(): void {
 		FreshRSS_Auth::init();
 		if (Minz_Request::isPost()) {
-			if (!(FreshRSS_Auth::isCsrfOk() ||
+			if (FreshRSS_Context::$system_conf == null || !(FreshRSS_Auth::isCsrfOk() ||
 				(Minz_Request::controllerName() === 'auth' && Minz_Request::actionName() === 'login') ||
 				(Minz_Request::controllerName() === 'user' && Minz_Request::actionName() === 'create' && !FreshRSS_Auth::hasAccess('admin')) ||
 				(Minz_Request::controllerName() === 'feed' && Minz_Request::actionName() === 'actualize'
@@ -92,21 +92,30 @@ class FreshRSS extends Minz_FrontController {
 		}
 	}
 
-	private static function initI18n() {
+	private static function initI18n(): void {
 		$userLanguage = isset(FreshRSS_Context::$user_conf) ? FreshRSS_Context::$user_conf->language : null;
 		$systemLanguage = isset(FreshRSS_Context::$system_conf) ? FreshRSS_Context::$system_conf->language : null;
 		$language = Minz_Translate::getLanguage($userLanguage, Minz_Request::getPreferredLanguages(), $systemLanguage);
 
 		Minz_Session::_param('language', $language);
 		Minz_Translate::init($language);
+
+		$timezone = isset(FreshRSS_Context::$user_conf) ? FreshRSS_Context::$user_conf->timezone : '';
+		if ($timezone == '') {
+			$timezone = FreshRSS_Context::defaultTimeZone();
+		}
+		date_default_timezone_set($timezone);
 	}
 
-	private static function getThemeFileUrl($theme_id, $filename) {
+	private static function getThemeFileUrl(string $theme_id, string $filename): string {
 		$filetime = @filemtime(PUBLIC_PATH . '/themes/' . $theme_id . '/' . $filename);
 		return '/themes/' . $theme_id . '/' . $filename . '?' . $filetime;
 	}
 
-	public static function loadStylesAndScripts() {
+	public static function loadStylesAndScripts(): void {
+		if (FreshRSS_Context::$user_conf == null) {
+			return;
+		}
 		$theme = FreshRSS_Themes::load(FreshRSS_Context::$user_conf->theme);
 		if ($theme) {
 			foreach(array_reverse($theme['files']) as $file) {
@@ -140,22 +149,23 @@ class FreshRSS extends Minz_FrontController {
 		FreshRSS_View::prependScript(Minz_Url::display('/scripts/main.js?' . @filemtime(PUBLIC_PATH . '/scripts/main.js')));
 	}
 
-	private static function loadNotifications() {
+	private static function loadNotifications(): void {
 		$notif = Minz_Request::getNotification();
 		if ($notif) {
 			FreshRSS_View::_param('notification', $notif);
 		}
 	}
 
-	public static function preLayout() {
+	public static function preLayout(): void {
 		header("X-Content-Type-Options: nosniff");
 
 		FreshRSS_Share::load(join_path(APP_PATH, 'shares.php'));
 		self::loadStylesAndScripts();
 	}
 
-	private static function checkEmailValidated() {
-		$email_not_verified = FreshRSS_Auth::hasAccess() && FreshRSS_Context::$user_conf->email_validation_token !== '';
+	private static function checkEmailValidated(): void {
+		$email_not_verified = FreshRSS_Auth::hasAccess() &&
+			FreshRSS_Context::$user_conf !== null && FreshRSS_Context::$user_conf->email_validation_token !== '';
 		$action_is_allowed = (
 			Minz_Request::is('user', 'validateEmail') ||
 			Minz_Request::is('user', 'sendValidationEmail') ||

+ 5 - 3
app/Models/BooleanSearch.php

@@ -118,8 +118,9 @@ class FreshRSS_BooleanSearch {
 		$nextOperator = 'AND';
 		while ($i < $length) {
 			$c = $input[$i];
+			$backslashed = $i >= 1 ? $input[$i - 1] === '\\' : false;
 
-			if ($c === '(') {
+			if ($c === '(' && !$backslashed) {
 				$hasParenthesis = true;
 
 				$before = trim($before);
@@ -164,11 +165,12 @@ class FreshRSS_BooleanSearch {
 				$i++;
 				while ($i < $length) {
 					$c = $input[$i];
-					if ($c === '(') {
+					$backslashed = $input[$i - 1] === '\\';
+					if ($c === '(' && !$backslashed) {
 						// One nested level deeper
 						$parentheses++;
 						$sub .= $c;
-					} elseif ($c === ')') {
+					} elseif ($c === ')' && !$backslashed) {
 						$parentheses--;
 						if ($parentheses === 0) {
 							// Found the matching closing parenthesis

+ 11 - 4
app/Models/Category.php

@@ -103,9 +103,7 @@ class FreshRSS_Category extends Minz_Model {
 				$this->hasFeedsWithError |= $feed->inError();
 			}
 
-			usort($this->feeds, function ($a, $b) {
-				return strnatcasecmp($a->name(), $b->name());
-			});
+			$this->sortFeeds();
 		}
 
 		return $this->feeds;
@@ -144,6 +142,7 @@ class FreshRSS_Category extends Minz_Model {
 		}
 
 		$this->feeds = $values;
+		$this->sortFeeds();
 	}
 
 	/**
@@ -155,6 +154,8 @@ class FreshRSS_Category extends Minz_Model {
 			$this->feeds = [];
 		}
 		$this->feeds[] = $feed;
+
+		$this->sortFeeds();
 	}
 
 	public function _attributes($key, $value) {
@@ -194,7 +195,7 @@ class FreshRSS_Category extends Minz_Model {
 		} else {
 			$dryRunCategory = new FreshRSS_Category();
 			$importService = new FreshRSS_Import_Service();
-			$importService->importOpml($opml, $dryRunCategory, true, true);
+			$importService->importOpml($opml, $dryRunCategory, true);
 			if ($importService->lastStatus()) {
 				$feedDAO = FreshRSS_Factory::createFeedDao();
 
@@ -245,4 +246,10 @@ class FreshRSS_Category extends Minz_Model {
 
 		return $ok;
 	}
+
+	private function sortFeeds() {
+		usort($this->feeds, static function ($a, $b) {
+			return strnatcasecmp($a->name(), $b->name());
+		});
+	}
 }

+ 7 - 8
app/Models/CategoryDAO.php

@@ -265,7 +265,7 @@ SQL;
 			return $categories;
 		}
 
-		uasort($categories, function ($a, $b) {
+		uasort($categories, static function ($a, $b) {
 			$aPosition = $a->attributes('position');
 			$bPosition = $b->attributes('position');
 			if ($aPosition === $bPosition) {
@@ -310,9 +310,9 @@ SQL;
 	}
 
 	/** @return array<FreshRSS_Category> */
-	public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0) {
+	public function listCategoriesOrderUpdate(int $defaultCacheDuration = 86400, int $limit = 0): array {
 		$sql = 'SELECT * FROM `_category` WHERE kind = :kind AND `lastUpdate` < :lu ORDER BY `lastUpdate`'
-			. ($limit < 1 ? '' : ' LIMIT ' . intval($limit));
+			. ($limit < 1 ? '' : ' LIMIT ' . $limit);
 		$stm = $this->pdo->prepare($sql);
 		if ($stm &&
 			$stm->bindValue(':kind', FreshRSS_Category::KIND_DYNAMIC_OPML, PDO::PARAM_INT) &&
@@ -387,7 +387,7 @@ SQL;
 		return $res[0]['count'];
 	}
 
-	public function countFeed($id) {
+	public function countFeed(int $id) {
 		$sql = 'SELECT COUNT(*) AS count FROM `_feed` WHERE category=:id';
 		$stm = $this->pdo->prepare($sql);
 		$stm->bindParam(':id', $id, PDO::PARAM_INT);
@@ -396,7 +396,7 @@ SQL;
 		return $res[0]['count'];
 	}
 
-	public function countNotRead($id) {
+	public function countNotRead(int $id) {
 		$sql = 'SELECT COUNT(*) AS count FROM `_entry` e INNER JOIN `_feed` f ON e.id_feed=f.id WHERE category=:id AND e.is_read=0';
 		$stm = $this->pdo->prepare($sql);
 		$stm->bindParam(':id', $id, PDO::PARAM_INT);
@@ -409,7 +409,7 @@ SQL;
 	 * @param array<FreshRSS_Category> $categories
 	 * @param int $feed_id
 	 */
-	public static function findFeed($categories, $feed_id) {
+	public static function findFeed(array $categories, int $feed_id) {
 		foreach ($categories as $category) {
 			foreach ($category->feeds() as $feed) {
 				if ($feed->id() === $feed_id) {
@@ -422,9 +422,8 @@ SQL;
 
 	/**
 	 * @param array<FreshRSS_Category> $categories
-	 * @param int $minPriority
 	 */
-	public static function CountUnreads($categories, $minPriority = 0) {
+	public static function countUnread(array $categories, int $minPriority = 0): int {
 		$n = 0;
 		foreach ($categories as $category) {
 			foreach ($category->feeds() as $feed) {

+ 7 - 0
app/Models/ConfigurationSetter.php

@@ -234,6 +234,13 @@ class FreshRSS_ConfigurationSetter {
 		$data['sticky_post'] = $this->handleBool($value);
 	}
 
+	private function _darkMode(&$data, $value) {
+		if (!in_array($value, [ 'no', 'auto'], true)) {
+			$value = 'no';
+		}
+		$data['darkMode'] = $value;
+	}
+
 	private function _bottomline_date(&$data, $value) {
 		$data['bottomline_date'] = $this->handleBool($value);
 	}

+ 7 - 13
app/Models/Context.php

@@ -58,12 +58,7 @@ class FreshRSS_Context {
 	public static function initSystem($reload = false) {
 		if ($reload || FreshRSS_Context::$system_conf == null) {
 			//TODO: Keep in session what we need instead of always reloading from disk
-			Minz_Configuration::register('system', DATA_PATH . '/config.php', FRESHRSS_PATH . '/config.default.php');
-			/**
-			 * @var FreshRSS_SystemConfiguration $system_conf
-			 */
-			$system_conf = Minz_Configuration::get('system');
-			FreshRSS_Context::$system_conf = $system_conf;
+			FreshRSS_Context::$system_conf = FreshRSS_SystemConfiguration::init(DATA_PATH . '/config.php', FRESHRSS_PATH . '/config.default.php');
 			// Register the configuration setter for the system configuration
 			$configurationSetter = new FreshRSS_ConfigurationSetter();
 			FreshRSS_Context::$system_conf->_configurationSetter($configurationSetter);
@@ -88,17 +83,12 @@ class FreshRSS_Context {
 			(!$userMustExist || FreshRSS_user_Controller::userExists($username))) {
 			try {
 				//TODO: Keep in session what we need instead of always reloading from disk
-				Minz_Configuration::register('user',
+				FreshRSS_Context::$user_conf = FreshRSS_UserConfiguration::init(
 					USERS_PATH . '/' . $username . '/config.php',
 					FRESHRSS_PATH . '/config-user.default.php',
 					FreshRSS_Context::$system_conf->configurationSetter());
 
 				Minz_Session::_param('currentUser', $username);
-				/**
-				 * @var FreshRSS_UserConfiguration $user_conf
-				 */
-				$user_conf = Minz_Configuration::get('user');
-				FreshRSS_Context::$user_conf = $user_conf;
 			} catch (Exception $ex) {
 				Minz_Log::warning($ex->getMessage(), USERS_PATH . '/_/' . LOG_FILENAME);
 			}
@@ -163,7 +153,7 @@ class FreshRSS_Context {
 		// Update number of read / unread variables.
 		$entryDAO = FreshRSS_Factory::createEntryDao();
 		self::$total_starred = $entryDAO->countUnreadReadFavorites();
-		self::$total_unread = FreshRSS_CategoryDAO::CountUnreads(
+		self::$total_unread = FreshRSS_CategoryDAO::countUnread(
 			self::$categories, 1
 		);
 
@@ -510,4 +500,8 @@ class FreshRSS_Context {
 		return false;
 	}
 
+	public static function defaultTimeZone(): string {
+		$timezone = ini_get('date.timezone');
+		return $timezone != '' ? $timezone : 'UTC';
+	}
 }

+ 5 - 3
app/Models/Days.php

@@ -1,7 +1,9 @@
 <?php
 
+declare(strict_types=1);
+
 class FreshRSS_Days {
-	const TODAY = 0;
-	const YESTERDAY = 1;
-	const BEFORE_YESTERDAY = 2;
+	public const TODAY = 0;
+	public const YESTERDAY = 1;
+	public const BEFORE_YESTERDAY = 2;
 }

+ 146 - 28
app/Models/Entry.php

@@ -17,10 +17,14 @@ class FreshRSS_Entry extends Minz_Model {
 	 */
 	private $guid;
 
+	/** @var string */
 	private $title;
 	private $authors;
+	/** @var string */
 	private $content;
+	/** @var string */
 	private $link;
+	/** @var int */
 	private $date;
 	private $date_added = 0; //In microseconds
 	/**
@@ -67,14 +71,16 @@ class FreshRSS_Entry extends Minz_Model {
 			$dao['content'] = '';
 		}
 		if (!empty($dao['thumbnail'])) {
-			$dao['content'] .= '<p class="enclosure-content"><img src="' . $dao['thumbnail'] . '" alt="" /></p>';
+			$dao['attributes']['thumbnail'] = [
+				'url' => $dao['thumbnail'],
+			];
 		}
 		$entry = new FreshRSS_Entry(
 			$dao['id_feed'] ?? 0,
 			$dao['guid'] ?? '',
 			$dao['title'] ?? '',
 			$dao['author'] ?? '',
-			$dao['content'] ?? '',
+			$dao['content'],
 			$dao['link'] ?? '',
 			$dao['date'] ?? 0,
 			$dao['is_read'] ?? false,
@@ -116,15 +122,117 @@ class FreshRSS_Entry extends Minz_Model {
 			return $this->authors;
 		}
 	}
-	public function content(): string {
-		return $this->content;
+
+	/**
+	 * Basic test without ambition to catch all cases such as unquoted addresses, variants of entities, HTML comments, etc.
+	 */
+	private static function containsLink(string $html, string $link): bool {
+		return preg_match('/(?P<delim>[\'"])' . preg_quote($link, '/') . '(?P=delim)/', $html) == 1;
+	}
+
+	private static function enclosureIsImage(array $enclosure): bool {
+		$elink = $enclosure['url'] ?? '';
+		$length = $enclosure['length'] ?? 0;
+		$medium = $enclosure['medium'] ?? '';
+		$mime = $enclosure['type'] ?? '';
+
+		return $elink != '' && $medium === 'image' || strpos($mime, 'image') === 0 ||
+			($mime == '' && $length == 0 && preg_match('/[.](avif|gif|jpe?g|png|svg|webp)$/i', $elink));
 	}
 
-	/** @return array<array<string,string>> */
-	public function enclosures(bool $searchBodyImages = false): array {
-		$results = [];
+	/**
+	 * @param bool $withEnclosures Set to true to include the enclosures in the returned HTML, false otherwise.
+	 * @param bool $allowDuplicateEnclosures Set to false to remove obvious enclosure duplicates (based on simple string comparison), true otherwise.
+	 * @return string HTML content
+	 */
+	public function content(bool $withEnclosures = true, bool $allowDuplicateEnclosures = false): string {
+		if (!$withEnclosures) {
+			return $this->content;
+		}
+
+		$content = $this->content;
+
+		$thumbnail = $this->attributes('thumbnail');
+		if (!empty($thumbnail['url'])) {
+			$elink = $thumbnail['url'];
+			if ($allowDuplicateEnclosures || !self::containsLink($content, $elink)) {
+			$content .= <<<HTML
+<figure class="enclosure">
+	<p class="enclosure-content">
+		<img class="enclosure-thumbnail" src="{$elink}" alt="" />
+	</p>
+</figure>
+HTML;
+			}
+		}
+
+		$attributeEnclosures = $this->attributes('enclosures');
+		if (empty($attributeEnclosures)) {
+			return $content;
+		}
+
+		foreach ($attributeEnclosures as $enclosure) {
+			$elink = $enclosure['url'] ?? '';
+			if ($elink == '') {
+				continue;
+			}
+			if (!$allowDuplicateEnclosures && self::containsLink($content, $elink)) {
+				continue;
+			}
+			$credit = $enclosure['credit'] ?? '';
+			$description = $enclosure['description'] ?? '';
+			$length = $enclosure['length'] ?? 0;
+			$medium = $enclosure['medium'] ?? '';
+			$mime = $enclosure['type'] ?? '';
+			$thumbnails = $enclosure['thumbnails'] ?? [];
+			$etitle = $enclosure['title'] ?? '';
+
+			$content .= '<figure class="enclosure">';
+
+			foreach ($thumbnails as $thumbnail) {
+				$content .= '<p><img class="enclosure-thumbnail" src="' . $thumbnail . '" alt="" title="' . $etitle . '" /></p>';
+			}
+
+			if (self::enclosureIsImage($enclosure)) {
+				$content .= '<p class="enclosure-content"><img src="' . $elink . '" alt="" title="' . $etitle . '" /></p>';
+			} elseif ($medium === 'audio' || strpos($mime, 'audio') === 0) {
+				$content .= '<p class="enclosure-content"><audio preload="none" src="' . $elink
+					. ($length == null ? '' : '" data-length="' . intval($length))
+					. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
+					. '" controls="controls" title="' . $etitle . '"></audio> <a download="" href="' . $elink . '">💾</a></p>';
+			} elseif ($medium === 'video' || strpos($mime, 'video') === 0) {
+				$content .= '<p class="enclosure-content"><video preload="none" src="' . $elink
+					. ($length == null ? '' : '" data-length="' . intval($length))
+					. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
+					. '" controls="controls" title="' . $etitle . '"></video> <a download="" href="' . $elink . '">💾</a></p>';
+			} else {	//e.g. application, text, unknown
+				$content .= '<p class="enclosure-content"><a download="" href="' . $elink
+					. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
+					. ($medium == '' ? '' : '" data-medium="' . htmlspecialchars($medium, ENT_COMPAT, 'UTF-8'))
+					. '" title="' . $etitle . '">💾</a></p>';
+			}
+
+			if ($credit != '') {
+				$content .= '<p class="enclosure-credits">© ' . $credit . '</p>';
+			}
+			if ($description != '') {
+				$content .= '<figcaption class="enclosure-description">' . $description . '</figcaption>';
+			}
+			$content .= "</figure>\n";
+		}
+
+		return $content;
+	}
+
+	/** @return iterable<array<string,string>> */
+	public function enclosures(bool $searchBodyImages = false) {
+		$attributeEnclosures = $this->attributes('enclosures');
+		if (is_array($attributeEnclosures)) {
+			// FreshRSS 1.20.1+: The enclosures are saved as attributes
+			yield from $attributeEnclosures;
+		}
 		try {
-			$searchEnclosures = strpos($this->content, '<p class="enclosure-content') !== false;
+			$searchEnclosures = !is_array($attributeEnclosures) && (strpos($this->content, '<p class="enclosure-content') !== false);
 			$searchBodyImages &= (stripos($this->content, '<img') !== false);
 			$xpath = null;
 			if ($searchEnclosures || $searchBodyImages) {
@@ -133,6 +241,7 @@ class FreshRSS_Entry extends Minz_Model {
 				$xpath = new DOMXpath($dom);
 			}
 			if ($searchEnclosures) {
+				// Legacy code for database entries < FreshRSS 1.20.1
 				$enclosures = $xpath->query('//div[@class="enclosure"]/p[@class="enclosure-content"]/*[@src]');
 				foreach ($enclosures as $enclosure) {
 					$result = [
@@ -148,7 +257,7 @@ class FreshRSS_Entry extends Minz_Model {
 							case 'audio': $result['medium'] = 'audio'; break;
 						}
 					}
-					$results[] = $result;
+					yield Minz_Helper::htmlspecialchars_utf8($result);
 				}
 			}
 			if ($searchBodyImages) {
@@ -159,26 +268,31 @@ class FreshRSS_Entry extends Minz_Model {
 						$src = $img->getAttribute('data-src');
 					}
 					if ($src != null) {
-						$results[] = [
+						$result = [
 							'url' => $src,
-							'alt' => $img->getAttribute('alt'),
 						];
+						yield Minz_Helper::htmlspecialchars_utf8($result);
 					}
 				}
 			}
-			return $results;
 		} catch (Exception $ex) {
-			return $results;
+			Minz_Log::debug(__METHOD__ . ' ' . $ex->getMessage());
 		}
 	}
 
 	/**
 	 * @return array<string,string>|null
 	 */
-	public function thumbnail() {
-		foreach ($this->enclosures(true) as $enclosure) {
-			if (!empty($enclosure['url']) && empty($enclosure['type'])) {
-				return $enclosure;
+	public function thumbnail(bool $searchEnclosures = true) {
+		$thumbnail = $this->attributes('thumbnail');
+		if (!empty($thumbnail['url'])) {
+			return $thumbnail;
+		}
+		if ($searchEnclosures) {
+			foreach ($this->enclosures(true) as $enclosure) {
+				if (self::enclosureIsImage($enclosure)) {
+					return $enclosure;
+				}
 			}
 		}
 		return null;
@@ -188,6 +302,7 @@ class FreshRSS_Entry extends Minz_Model {
 	public function link(): string {
 		return $this->link;
 	}
+	/** @return string|int */
 	public function date(bool $raw = false) {
 		if ($raw) {
 			return $this->date;
@@ -587,7 +702,7 @@ class FreshRSS_Entry extends Minz_Model {
 
 			if ($entry) {
 				// l’article existe déjà en BDD, en se contente de recharger ce contenu
-				$this->content = $entry->content();
+				$this->content = $entry->content(false);
 			} else {
 				try {
 					// The article is not yet in the database, so let’s fetch it
@@ -629,7 +744,7 @@ class FreshRSS_Entry extends Minz_Model {
 			'guid' => $this->guid(),
 			'title' => $this->title(),
 			'author' => $this->authors(true),
-			'content' => $this->content(),
+			'content' => $this->content(false),
 			'link' => $this->link(),
 			'date' => $this->date(true),
 			'hash' => $this->hash(),
@@ -677,7 +792,6 @@ class FreshRSS_Entry extends Minz_Model {
 			'published' => $this->date(true),
 			// 'updated' => $this->date(true),
 			'title' => $this->title(),
-			'summary' => ['content' => $this->content()],
 			'canonical' => [
 				['href' => htmlspecialchars_decode($this->link(), ENT_QUOTES)],
 			],
@@ -697,13 +811,16 @@ class FreshRSS_Entry extends Minz_Model {
 		if ($mode === 'compat') {
 			$item['title'] = escapeToUnicodeAlternative($this->title(), false);
 			unset($item['alternate'][0]['type']);
-			if (mb_strlen($this->content(), 'UTF-8') > self::API_MAX_COMPAT_CONTENT_LENGTH) {
-				$item['summary']['content'] = mb_strcut($this->content(), 0, self::API_MAX_COMPAT_CONTENT_LENGTH, 'UTF-8');
-			}
-		} elseif ($mode === 'freshrss') {
+			$item['summary'] = [
+				'content' => mb_strcut($this->content(true), 0, self::API_MAX_COMPAT_CONTENT_LENGTH, 'UTF-8'),
+			];
+		} else {
+			$item['content'] = [
+				'content' => $this->content(false),
+			];
+		}
+		if ($mode === 'freshrss') {
 			$item['guid'] = $this->guid();
-			unset($item['summary']);
-			$item['content'] = ['content' => $this->content()];
 		}
 		if ($category != null && $mode !== 'freshrss') {
 			$item['categories'][] = 'user/-/label/' . htmlspecialchars_decode($category->name(), ENT_QUOTES);
@@ -718,10 +835,11 @@ class FreshRSS_Entry extends Minz_Model {
 			}
 		}
 		foreach ($this->enclosures() as $enclosure) {
-			if (!empty($enclosure['url']) && !empty($enclosure['type'])) {
+			if (!empty($enclosure['url'])) {
 				$media = [
 						'href' => $enclosure['url'],
-						'type' => $enclosure['type'],
+						'type' => $enclosure['type'] ?? $enclosure['medium'] ??
+							(self::enclosureIsImage($enclosure) ? 'image' : ''),
 					];
 				if (!empty($enclosure['length'])) {
 					$media['length'] = intval($enclosure['length']);

+ 16 - 9
app/Models/EntryDAO.php

@@ -10,6 +10,10 @@ class FreshRSS_EntryDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		return true;
 	}
 
+	protected static function sqlConcat($s1, $s2) {
+		return 'CONCAT(' . $s1 . ',' . $s2 . ')';	//MySQL
+	}
+
 	public static function sqlHexDecode(string $x): string {
 		return 'unhex(' . $x . ')';
 	}
@@ -943,8 +947,8 @@ SQL;
 			}
 			if ($filter->getTags()) {
 				foreach ($filter->getTags() as $tag) {
-					$sub_search .= 'AND ' . $alias . 'tags LIKE ? ';
-					$values[] = "%{$tag}%";
+					$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' LIKE ? ';
+					$values[] = "%{$tag} #%";
 				}
 			}
 			if ($filter->getInurl()) {
@@ -968,8 +972,8 @@ SQL;
 			}
 			if ($filter->getNotTags()) {
 				foreach ($filter->getNotTags() as $tag) {
-					$sub_search .= 'AND ' . $alias . 'tags NOT LIKE ? ';
-					$values[] = "%{$tag}%";
+					$sub_search .= 'AND ' . static::sqlConcat('TRIM(' . $alias . 'tags) ', " ' #'") . ' NOT LIKE ? ';
+					$values[] = "%{$tag} #%";
 				}
 			}
 			if ($filter->getNotInurl()) {
@@ -1161,10 +1165,12 @@ SQL;
 		}
 	}
 
-	public function listByIds($ids, $order = 'DESC') {
+	/** @param array<string> $ids */
+	public function listByIds(array $ids, string $order = 'DESC') {
 		if (count($ids) < 1) {
-			yield false;
-		} elseif (count($ids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
+			return;
+		}
+		if (count($ids) > FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER) {
 			// Split a query with too many variables parameters
 			$idsChunks = array_chunk($ids, FreshRSS_DatabaseDAO::MAX_VARIABLE_NUMBER);
 			foreach ($idsChunks as $idsChunk) {
@@ -1191,15 +1197,16 @@ SQL;
 
 	/**
 	 * For API
+	 * @return array<string>
 	 */
 	public function listIdsWhere($type = 'a', $id = '', $state = FreshRSS_Entry::STATE_ALL,
-			$order = 'DESC', $limit = 1, $firstId = '', $filters = null) {
+			$order = 'DESC', $limit = 1, $firstId = '', $filters = null): array {
 		list($values, $sql) = $this->sqlListWhere($type, $id, $state, $order, $limit, $firstId, $filters);
 
 		$stm = $this->pdo->prepare($sql);
 		$stm->execute($values);
 
-		return $stm->fetchAll(PDO::FETCH_COLUMN, 0);
+		return $stm->fetchAll(PDO::FETCH_COLUMN, 0) ?: [];
 	}
 
 	public function listHashForFeedGuids($id_feed, $guids) {

+ 4 - 0
app/Models/EntryDAOSQLite.php

@@ -10,6 +10,10 @@ class FreshRSS_EntryDAOSQLite extends FreshRSS_EntryDAO {
 		return false;
 	}
 
+	protected static function sqlConcat($s1, $s2) {
+		return $s1 . '||' . $s2;
+	}
+
 	public static function sqlHexDecode(string $x): string {
 		return $x;
 	}

+ 87 - 55
app/Models/Feed.php

@@ -17,6 +17,11 @@ class FreshRSS_Feed extends Minz_Model {
 	 * @var int
 	 */
 	const KIND_HTML_XPATH = 10;
+	/**
+	 * Normal XML with XPath scraping
+	 * @var int
+	 */
+	const KIND_XML_XPATH = 15;
 	/**
 	 * Normal JSON with XPath scraping
 	 * @var int
@@ -259,13 +264,14 @@ class FreshRSS_Feed extends Minz_Model {
 	}
 	public function _url(string $value, bool $validate = true) {
 		$this->hash = '';
+		$url = $value;
 		if ($validate) {
-			$value = checkUrl($value);
+			$url = checkUrl($url);
 		}
-		if ($value == '') {
+		if ($url == '') {
 			throw new FreshRSS_BadUrl_Exception($value);
 		}
-		$this->url = $value;
+		$this->url = $url;
 	}
 	public function _kind(int $value) {
 		$this->kind = $value;
@@ -502,61 +508,46 @@ class FreshRSS_Feed extends Minz_Model {
 
 			$content = html_only_entity_decode($item->get_content());
 
-			if ($item->get_enclosures() != null) {
-				$elinks = array();
+			$attributeThumbnail = $item->get_thumbnail() ?? [];
+			if (empty($attributeThumbnail['url'])) {
+				$attributeThumbnail['url'] = '';
+			}
+
+			$attributeEnclosures = [];
+			if (!empty($item->get_enclosures())) {
 				foreach ($item->get_enclosures() as $enclosure) {
 					$elink = $enclosure->get_link();
-					if ($elink != '' && empty($elinks[$elink])) {
-						$content .= '<div class="enclosure">';
-
-						if ($enclosure->get_title() != '') {
-							$content .= '<p class="enclosure-title">' . $enclosure->get_title() . '</p>';
-						}
-
-						$enclosureContent = '';
-						$elinks[$elink] = true;
+					if ($elink != '') {
+						$etitle = $enclosure->get_title() ?? '';
+						$credit = $enclosure->get_credit() ?? null;
+						$description = $enclosure->get_description() ?? '';
 						$mime = strtolower($enclosure->get_type() ?? '');
 						$medium = strtolower($enclosure->get_medium() ?? '');
 						$height = $enclosure->get_height();
 						$width = $enclosure->get_width();
 						$length = $enclosure->get_length();
-						if ($medium === 'image' || strpos($mime, 'image') === 0 ||
-							($mime == '' && $length == null && ($width != 0 || $height != 0 || preg_match('/[.](avif|gif|jpe?g|png|svg|webp)$/i', $elink)))) {
-							$enclosureContent .= '<p class="enclosure-content"><img src="' . $elink . '" alt="" /></p>';
-						} elseif ($medium === 'audio' || strpos($mime, 'audio') === 0) {
-							$enclosureContent .= '<p class="enclosure-content"><audio preload="none" src="' . $elink
-								. ($length == null ? '' : '" data-length="' . intval($length))
-								. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
-								. '" controls="controls"></audio> <a download="" href="' . $elink . '">💾</a></p>';
-						} elseif ($medium === 'video' || strpos($mime, 'video') === 0) {
-							$enclosureContent .= '<p class="enclosure-content"><video preload="none" src="' . $elink
-								. ($length == null ? '' : '" data-length="' . intval($length))
-								. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
-								. '" controls="controls"></video> <a download="" href="' . $elink . '">💾</a></p>';
-						} else {	//e.g. application, text, unknown
-							$enclosureContent .= '<p class="enclosure-content"><a download="" href="' . $elink
-								. ($mime == '' ? '' : '" data-type="' . htmlspecialchars($mime, ENT_COMPAT, 'UTF-8'))
-								. ($medium == '' ? '' : '" data-medium="' . htmlspecialchars($medium, ENT_COMPAT, 'UTF-8'))
-								. '">💾</a></p>';
-						}
 
-						$thumbnailContent = '';
-						if ($enclosure->get_thumbnails() != null) {
+						$attributeEnclosure = [
+							'url' => $elink,
+						];
+						if ($etitle != '') $attributeEnclosure['title'] = $etitle;
+						if ($credit != null) $attributeEnclosure['credit'] = $credit->get_name();
+						if ($description != '') $attributeEnclosure['description'] = $description;
+						if ($mime != '') $attributeEnclosure['type'] = $mime;
+						if ($medium != '') $attributeEnclosure['medium'] = $medium;
+						if ($length != '') $attributeEnclosure['length'] = intval($length);
+						if ($height != '') $attributeEnclosure['height'] = intval($height);
+						if ($width != '') $attributeEnclosure['width'] = intval($width);
+
+						if (!empty($enclosure->get_thumbnails())) {
 							foreach ($enclosure->get_thumbnails() as $thumbnail) {
-								if (empty($elinks[$thumbnail])) {
-									$elinks[$thumbnail] = true;
-									$thumbnailContent .= '<p><img class="enclosure-thumbnail" src="' . $thumbnail . '" alt="" /></p>';
+								if ($thumbnail !== $attributeThumbnail['url']) {
+									$attributeEnclosure['thumbnails'][] = $thumbnail;
 								}
 							}
 						}
 
-						$content .= $thumbnailContent;
-						$content .= $enclosureContent;
-
-						if ($enclosure->get_description() != '') {
-							$content .= '<p class="enclosure-description">' . $enclosure->get_description() . '</p>';
-						}
-						$content .= "</div>\n";
+						$attributeEnclosures[] = $attributeEnclosure;
 					}
 				}
 			}
@@ -586,6 +577,10 @@ class FreshRSS_Feed extends Minz_Model {
 			);
 			$entry->_tags($tags);
 			$entry->_feed($this);
+			if (!empty($attributeThumbnail['url'])) {
+				$entry->_attributes('thumbnail', $attributeThumbnail);
+			}
+			$entry->_attributes('enclosures', $attributeEnclosures);
 			$entry->hash();	//Must be computed before loading full content
 			$entry->loadCompleteContent();	// Optionally load full content for truncated feeds
 
@@ -596,7 +591,7 @@ class FreshRSS_Feed extends Minz_Model {
 	/**
 	 * @return SimplePie|null
 	 */
-	public function loadHtmlXpath(bool $loadDetails = false, bool $noCache = false) {
+	public function loadHtmlXpath() {
 		if ($this->url == '') {
 			return null;
 		}
@@ -624,8 +619,9 @@ class FreshRSS_Feed extends Minz_Model {
 			return null;
 		}
 
-		$cachePath = FreshRSS_Feed::cacheFilename($feedSourceUrl, $this->attributes(), FreshRSS_Feed::KIND_HTML_XPATH);
-		$html = httpGet($feedSourceUrl, $cachePath, 'html', $this->attributes());
+		$cachePath = FreshRSS_Feed::cacheFilename($feedSourceUrl, $this->attributes(), $this->kind());
+		$html = httpGet($feedSourceUrl, $cachePath,
+			$this->kind() === FreshRSS_Feed::KIND_XML_XPATH ? 'xml' : 'html', $this->attributes());
 		if (strlen($html) <= 0) {
 			return null;
 		}
@@ -640,7 +636,18 @@ class FreshRSS_Feed extends Minz_Model {
 			$doc = new DOMDocument();
 			$doc->recover = true;
 			$doc->strictErrorChecking = false;
-			$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
+
+			switch ($this->kind()) {
+				case FreshRSS_Feed::KIND_HTML_XPATH:
+					$doc->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
+					break;
+				case FreshRSS_Feed::KIND_XML_XPATH:
+					$doc->loadXML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING);
+					break;
+				default:
+					return null;
+			}
+
 			$xpath = new DOMXPath($doc);
 			$view->rss_title = $xPathFeedTitle == '' ? $this->name() :
 				htmlspecialchars(@$xpath->evaluate('normalize-space(' . $xPathFeedTitle . ')'), ENT_COMPAT, 'UTF-8');
@@ -653,7 +660,23 @@ class FreshRSS_Feed extends Minz_Model {
 			foreach ($nodes as $node) {
 				$item = [];
 				$item['title'] = $xPathItemTitle == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemTitle . ')', $node);
-				$item['content'] = $xPathItemContent == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemContent . ')', $node);
+
+				$item['content'] = '';
+				if ($xPathItemContent != '') {
+					$result = @$xpath->evaluate($xPathItemContent, $node);
+					if ($result instanceof DOMNodeList) {
+						// List of nodes, save as HTML
+						$content = '';
+						foreach ($result as $child) {
+							$content .= $doc->saveHTML($child) . "\n";
+						}
+						$item['content'] = $content;
+					} else {
+						// Typed expression, save as-is
+						$item['content'] = strval($result);
+					}
+				}
+
 				$item['link'] = $xPathItemUri == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemUri . ')', $node);
 				$item['author'] = $xPathItemAuthor == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemAuthor . ')', $node);
 				$item['timestamp'] = $xPathItemTimestamp == '' ? '' : @$xpath->evaluate('normalize-space(' . $xPathItemTimestamp . ')', $node);
@@ -679,8 +702,15 @@ class FreshRSS_Feed extends Minz_Model {
 					$item['guid'] = 'urn:sha1:' . sha1($item['title'] . $item['content'] . $item['link']);
 				}
 
-				if ($item['title'] . $item['content'] . $item['link'] != '') {
-					$item = Minz_Helper::htmlspecialchars_utf8($item);
+				if ($item['title'] != '' || $item['content'] != '' || $item['link'] != '') {
+					// HTML-encoding/escaping of the relevant fields (all except 'content')
+					foreach (['author', 'categories', 'guid', 'link', 'thumbnail', 'timestamp', 'title'] as $key) {
+						if (!empty($item[$key])) {
+							$item[$key] = Minz_Helper::htmlspecialchars_utf8($item[$key]);
+						}
+					}
+					// CDATA protection
+					$item['content'] = str_replace(']]>', ']]&gt;', $item['content']);
 					$view->entries[] = FreshRSS_Entry::fromArray($item);
 				}
 			}
@@ -763,8 +793,10 @@ class FreshRSS_Feed extends Minz_Model {
 	public static function cacheFilename(string $url, array $attributes, int $kind = FreshRSS_Feed::KIND_RSS): string {
 		$simplePie = customSimplePie($attributes);
 		$filename = $simplePie->get_cache_filename($url);
-		if ($kind == FreshRSS_Feed::KIND_HTML_XPATH) {
+		if ($kind === FreshRSS_Feed::KIND_HTML_XPATH) {
 			return CACHE_PATH . '/' . $filename . '.html';
+		} elseif ($kind === FreshRSS_Feed::KIND_XML_XPATH) {
+			return CACHE_PATH . '/' . $filename . '.xml';
 		} else {
 			return CACHE_PATH . '/' . $filename . '.spc';
 		}
@@ -966,14 +998,14 @@ class FreshRSS_Feed extends Minz_Model {
 					$key = $hubJson['key'];	//To renew our lease
 				}
 			} else {
-				@mkdir($path, 0777, true);
+				@mkdir($path, 0770, true);
 				$key = sha1($path . FreshRSS_Context::$system_conf->salt);
 				$hubJson = array(
 					'hub' => $this->hubUrl,
 					'key' => $key,
 				);
 				file_put_contents($hubFilename, json_encode($hubJson));
-				@mkdir(PSHB_PATH . '/keys/');
+				@mkdir(PSHB_PATH . '/keys/', 0770, true);
 				file_put_contents(PSHB_PATH . '/keys/' . $key . '.txt', $this->selfUrl);
 				$text = 'WebSub prepared for ' . $this->url;
 				Minz_Log::debug($text);

+ 3 - 3
app/Models/FeedDAO.php

@@ -49,11 +49,11 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo implements FreshRSS_Searchable {
 		}
 
 		$values = array(
-			substr($valuesTmp['url'], 0, 511),
+			$valuesTmp['url'],
 			$valuesTmp['kind'] ?? FreshRSS_Feed::KIND_RSS,
 			$valuesTmp['category'],
 			mb_strcut(trim($valuesTmp['name']), 0, FreshRSS_DatabaseDAO::LENGTH_INDEX_UNICODE, 'UTF-8'),
-			substr($valuesTmp['website'], 0, 255),
+			$valuesTmp['website'],
 			sanitizeHTML($valuesTmp['description'], '', 1023),
 			$valuesTmp['lastUpdate'],
 			isset($valuesTmp['priority']) ? intval($valuesTmp['priority']) : FreshRSS_Feed::PRIORITY_MAIN_STREAM,
@@ -434,7 +434,7 @@ SQL;
 			. '`cache_nbUnreads`=(SELECT COUNT(e2.id) FROM `_entry` e2 WHERE e2.id_feed=`_feed`.id AND e2.is_read=0)'
 			. ($id != 0 ? ' WHERE id=:id' : '');
 		$stm = $this->pdo->prepare($sql);
-		if ($id != 0) {
+		if ($stm && $id != 0) {
 			$stm->bindParam(':id', $id, PDO::PARAM_INT);
 		}
 

+ 4 - 0
app/Models/Searchable.php

@@ -2,5 +2,9 @@
 
 interface FreshRSS_Searchable {
 
+	/**
+	 * @param int|string $id
+	 * @return Minz_Model
+	 */
 	public function searchById($id);
 }

+ 5 - 1
app/Models/SystemConfiguration.php

@@ -25,6 +25,10 @@
  * @property string $unsafe_autologin_enabled
  * @property-read array<string> $trusted_sources
  */
-class FreshRSS_SystemConfiguration extends Minz_Configuration {
+final class FreshRSS_SystemConfiguration extends Minz_Configuration {
 
+	public static function init($config_filename, $default_filename = null): FreshRSS_SystemConfiguration {
+		parent::register('system', $config_filename, $default_filename);
+		return parent::get('system');
+	}
 }

+ 41 - 14
app/Models/Tag.php

@@ -5,40 +5,61 @@ class FreshRSS_Tag extends Minz_Model {
 	 * @var int
 	 */
 	private $id = 0;
+	/**
+	 * @var string
+	 */
 	private $name;
+	/**
+	 * @var array<string,mixed>
+	 */
 	private $attributes = [];
+	/**
+	 * @var int
+	 */
 	private $nbEntries = -1;
+	/**
+	 * @var int
+	 */
 	private $nbUnread = -1;
 
-	public function __construct($name = '') {
+	public function __construct(string $name = '') {
 		$this->_name($name);
 	}
 
-	public function id() {
+	public function id(): int {
 		return $this->id;
 	}
 
-	public function _id($value) {
+	/**
+	 * @param int|string $value
+	 */
+	public function _id($value): void {
 		$this->id = (int)$value;
 	}
 
-	public function name() {
+	public function name(): string {
 		return $this->name;
 	}
 
-	public function _name($value) {
+	public function _name(string $value): void {
 		$this->name = trim($value);
 	}
 
-	public function attributes($key = '') {
+	/**
+	 * @return mixed|string|array<string,mixed>|null
+	 */
+	public function attributes(string $key = '') {
 		if ($key == '') {
 			return $this->attributes;
 		} else {
-			return isset($this->attributes[$key]) ? $this->attributes[$key] : null;
+			return $this->attributes[$key] ?? null;
 		}
 	}
 
-	public function _attributes($key, $value) {
+	/**
+	 * @param mixed|string|array<string,mixed>|null $value
+	 */
+	public function _attributes(string $key, $value = null): void {
 		if ($key == '') {
 			if (is_string($value)) {
 				$value = json_decode($value, true);
@@ -53,27 +74,33 @@ class FreshRSS_Tag extends Minz_Model {
 		}
 	}
 
-	public function nbEntries() {
+	public function nbEntries(): int {
 		if ($this->nbEntries < 0) {
 			$tagDAO = FreshRSS_Factory::createTagDao();
-			$this->nbEntries = $tagDAO->countEntries($this->id());
+			$this->nbEntries = $tagDAO->countEntries($this->id()) ?: 0;
 		}
 		return $this->nbEntries;
 	}
 
-	public function _nbEntries($value) {
+	/**
+	 * @param string|int $value
+	 */
+	public function _nbEntries($value): void {
 		$this->nbEntries = (int)$value;
 	}
 
-	public function nbUnread() {
+	public function nbUnread(): int {
 		if ($this->nbUnread < 0) {
 			$tagDAO = FreshRSS_Factory::createTagDao();
-			$this->nbUnread = $tagDAO->countNotRead($this->id());
+			$this->nbUnread = $tagDAO->countNotRead($this->id()) ?: 0;
 		}
 		return $this->nbUnread;
 	}
 
-	public function _nbUnread($value) {
+	/**
+	 * @param string|int$value
+	 */
+	public function _nbUnread($value): void {
 		$this->nbUnread = (int)$value;
 	}
 }

+ 28 - 12
app/Models/TagDAO.php

@@ -267,12 +267,13 @@ SQL;
 		return $newestItemUsec;
 	}
 
+	/** @return int|false */
 	public function count() {
 		$sql = 'SELECT COUNT(*) AS count FROM `_tag`';
 		$stm = $this->pdo->query($sql);
 		if ($stm !== false) {
 			$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-			return $res[0]['count'];
+			return (int)$res[0]['count'];
 		} else {
 			$info = $this->pdo->errorInfo();
 			if ($this->autoUpdateDb($info)) {
@@ -283,16 +284,27 @@ SQL;
 		}
 	}
 
-	public function countEntries($id) {
+	/**
+	 * @return int|false
+	 */
+	public function countEntries(int $id) {
 		$sql = 'SELECT COUNT(*) AS count FROM `_entrytag` WHERE id_tag=?';
-		$stm = $this->pdo->prepare($sql);
 		$values = array($id);
-		$stm->execute($values);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		return $res[0]['count'];
+		if (($stm = $this->pdo->prepare($sql)) !== false &&
+			$stm->execute($values) &&
+			($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
+			return (int)$res[0]['count'];
+		} else {
+			$info = is_object($stm) ? $stm->errorInfo() : $this->pdo->errorInfo();
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+			return false;
+		}
 	}
 
-	public function countNotRead($id = null) {
+	/**
+	 * @return int|false
+	 */
+	public function countNotRead(?int $id = null) {
 		$sql = 'SELECT COUNT(*) AS count FROM `_entrytag` et '
 			 . 'INNER JOIN `_entry` e ON et.id_entry=e.id '
 			 . 'WHERE e.is_read=0';
@@ -303,11 +315,15 @@ SQL;
 			$values = [$id];
 		}
 
-		$stm = $this->pdo->prepare($sql);
-
-		$stm->execute($values);
-		$res = $stm->fetchAll(PDO::FETCH_ASSOC);
-		return $res[0]['count'];
+		if (($stm = $this->pdo->prepare($sql)) !== false &&
+			$stm->execute($values) &&
+			($res = $stm->fetchAll(PDO::FETCH_ASSOC)) !== false) {
+			return (int)$res[0]['count'];
+		} else {
+			$info = is_object($stm) ? $stm->errorInfo() : $this->pdo->errorInfo();
+			Minz_Log::error('SQL error ' . __METHOD__ . json_encode($info));
+			return false;
+		}
 	}
 
 	public function tagEntry($id_tag, $id_entry, $checked = true) {

+ 0 - 1
app/Models/Themes.php

@@ -79,7 +79,6 @@ class FreshRSS_Themes extends Minz_Model {
 		static $alts = array(
 			'add' => '➕',	//✚
 			'all' => '☰',
-			'bookmark' => '✨',	//★
 			'bookmark-add' => '➕',	//✚
 			'bookmark-tag' => '📑',
 			'category' => '🗂️',	//☷

+ 7 - 1
app/Models/UserConfiguration.php

@@ -28,6 +28,7 @@
  * @property-read string $is_admin
  * @property int|null $keep_history_default
  * @property string $language
+ * @property string $timezone
  * @property bool $lazyload
  * @property string $mail_login
  * @property bool $mark_updated_article_unread
@@ -52,6 +53,7 @@
  * @property bool $sides_close_article
  * @property bool $sticky_post
  * @property string $theme
+ * @property string $darkMode
  * @property string $token
  * @property bool $topline_date
  * @property bool $topline_display_authors
@@ -66,6 +68,10 @@
  * @property string $view_mode
  * @property array<string,mixed> $volatile
  */
-class FreshRSS_UserConfiguration extends Minz_Configuration {
+final class FreshRSS_UserConfiguration extends Minz_Configuration {
 
+	public static function init($config_filename, $default_filename = null, $configuration_setter = null): FreshRSS_UserConfiguration {
+		parent::register('user', $config_filename, $default_filename, $configuration_setter);
+		return parent::get('user');
+	}
 }

+ 48 - 50
app/Models/UserQuery.php

@@ -8,26 +8,35 @@
  */
 class FreshRSS_UserQuery {
 
+	/** @var bool */
 	private $deprecated = false;
-	private $get;
-	private $get_name;
-	private $get_type;
-	private $name;
-	private $order;
+	/** @var string */
+	private $get = '';
+	/** @var string */
+	private $get_name = '';
+	/** @var string */
+	private $get_type = '';
+	/** @var string */
+	private $name = '';
+	/** @var string */
+	private $order = '';
 	/** @var FreshRSS_BooleanSearch */
 	private $search;
-	private $state;
-	private $url;
+	/** @var int */
+	private $state = 0;
+	/** @var string */
+	private $url = '';
+	/** @var FreshRSS_FeedDAO|null */
 	private $feed_dao;
+	/** @var FreshRSS_CategoryDAO|null */
 	private $category_dao;
+	/** @var FreshRSS_TagDAO|null */
 	private $tag_dao;
 
 	/**
 	 * @param array<string,string> $query
-	 * @param FreshRSS_Searchable $feed_dao
-	 * @param FreshRSS_Searchable $category_dao
 	 */
-	public function __construct($query, FreshRSS_Searchable $feed_dao = null, FreshRSS_Searchable $category_dao = null, FreshRSS_Searchable $tag_dao = null) {
+	public function __construct(array $query, FreshRSS_FeedDAO $feed_dao = null, FreshRSS_CategoryDAO $category_dao = null, FreshRSS_TagDAO $tag_dao = null) {
 		$this->category_dao = $category_dao;
 		$this->feed_dao = $feed_dao;
 		$this->tag_dao = $tag_dao;
@@ -53,17 +62,17 @@ class FreshRSS_UserQuery {
 		}
 		// linked too deeply with the search object, need to use dependency injection
 		$this->search = new FreshRSS_BooleanSearch($query['search']);
-		if (isset($query['state'])) {
-			$this->state = $query['state'];
+		if (!empty($query['state'])) {
+			$this->state = intval($query['state']);
 		}
 	}
 
 	/**
 	 * Convert the current object to an array.
 	 *
-	 * @return array<string,string>
+	 * @return array<string,string|int>
 	 */
-	public function toArray() {
+	public function toArray(): array {
 		return array_filter(array(
 			'get' => $this->get,
 			'name' => $this->name,
@@ -75,29 +84,27 @@ class FreshRSS_UserQuery {
 	}
 
 	/**
-	 * Parse the get parameter in the query string to extract its name and
-	 * type
-	 *
-	 * @param string $get
+	 * Parse the get parameter in the query string to extract its name and type
 	 */
-	private function parseGet($get) {
+	private function parseGet(string $get): void {
 		$this->get = $get;
 		if (preg_match('/(?P<type>[acfst])(_(?P<id>\d+))?/', $get, $matches)) {
+			$id = intval($matches['id'] ?? '0');
 			switch ($matches['type']) {
 				case 'a':
 					$this->parseAll();
 					break;
 				case 'c':
-					$this->parseCategory($matches['id']);
+					$this->parseCategory($id);
 					break;
 				case 'f':
-					$this->parseFeed($matches['id']);
+					$this->parseFeed($id);
 					break;
 				case 's':
 					$this->parseFavorite();
 					break;
 				case 't':
-					$this->parseTag($matches['id']);
+					$this->parseTag($id);
 					break;
 			}
 		}
@@ -106,7 +113,7 @@ class FreshRSS_UserQuery {
 	/**
 	 * Parse the query string when it is an "all" query
 	 */
-	private function parseAll() {
+	private function parseAll(): void {
 		$this->get_name = 'all';
 		$this->get_type = 'all';
 	}
@@ -114,11 +121,10 @@ class FreshRSS_UserQuery {
 	/**
 	 * Parse the query string when it is a "category" query
 	 *
-	 * @param integer $id
 	 * @throws FreshRSS_DAO_Exception
 	 */
-	private function parseCategory($id) {
-		if (is_null($this->category_dao)) {
+	private function parseCategory(int $id): void {
+		if ($this->category_dao === null) {
 			throw new FreshRSS_DAO_Exception('Category DAO is not loaded in UserQuery');
 		}
 		$category = $this->category_dao->searchById($id);
@@ -133,11 +139,10 @@ class FreshRSS_UserQuery {
 	/**
 	 * Parse the query string when it is a "feed" query
 	 *
-	 * @param integer $id
 	 * @throws FreshRSS_DAO_Exception
 	 */
-	private function parseFeed($id) {
-		if (is_null($this->feed_dao)) {
+	private function parseFeed(int $id): void {
+		if ($this->feed_dao === null) {
 			throw new FreshRSS_DAO_Exception('Feed DAO is not loaded in UserQuery');
 		}
 		$feed = $this->feed_dao->searchById($id);
@@ -152,10 +157,9 @@ class FreshRSS_UserQuery {
 	/**
 	 * Parse the query string when it is a "tag" query
 	 *
-	 * @param integer $id
 	 * @throws FreshRSS_DAO_Exception
 	 */
-	private function parseTag($id) {
+	private function parseTag(int $id): void {
 		if ($this->tag_dao == null) {
 			throw new FreshRSS_DAO_Exception('Tag DAO is not loaded in UserQuery');
 		}
@@ -171,7 +175,7 @@ class FreshRSS_UserQuery {
 	/**
 	 * Parse the query string when it is a "favorite" query
 	 */
-	private function parseFavorite() {
+	private function parseFavorite(): void {
 		$this->get_name = 'favorite';
 		$this->get_type = 'favorite';
 	}
@@ -180,20 +184,16 @@ class FreshRSS_UserQuery {
 	 * Check if the current user query is deprecated.
 	 * It is deprecated if the category or the feed used in the query are
 	 * not existing.
-	 *
-	 * @return boolean
 	 */
-	public function isDeprecated() {
+	public function isDeprecated(): bool {
 		return $this->deprecated;
 	}
 
 	/**
 	 * Check if the user query has parameters.
 	 * If the type is 'all', it is considered equal to no parameters
-	 *
-	 * @return boolean
 	 */
-	public function hasParameters() {
+	public function hasParameters(): bool {
 		if ($this->get_type === 'all') {
 			return false;
 		}
@@ -214,42 +214,40 @@ class FreshRSS_UserQuery {
 
 	/**
 	 * Check if there is a search in the search object
-	 *
-	 * @return boolean
 	 */
-	public function hasSearch() {
-		return $this->search->getRawInput() != "";
+	public function hasSearch(): bool {
+		return $this->search->getRawInput() !== '';
 	}
 
-	public function getGet() {
+	public function getGet(): string {
 		return $this->get;
 	}
 
-	public function getGetName() {
+	public function getGetName(): string {
 		return $this->get_name;
 	}
 
-	public function getGetType() {
+	public function getGetType(): string {
 		return $this->get_type;
 	}
 
-	public function getName() {
+	public function getName(): string {
 		return $this->name;
 	}
 
-	public function getOrder() {
+	public function getOrder(): string {
 		return $this->order;
 	}
 
-	public function getSearch() {
+	public function getSearch(): FreshRSS_BooleanSearch {
 		return $this->search;
 	}
 
-	public function getState() {
+	public function getState(): int {
 		return $this->state;
 	}
 
-	public function getUrl() {
+	public function getUrl(): string {
 		return $this->url;
 	}
 

+ 1 - 0
app/Models/View.php

@@ -39,6 +39,7 @@ class FreshRSS_View extends Minz_View {
 	public $details;
 	public $disable_aside;
 	public $show_email_field;
+	/** @var string */
 	public $username;
 	public $users;
 

+ 2 - 3
app/SQL/install.sql.mysql.php

@@ -18,11 +18,11 @@ ENGINE = INNODB;
 
 CREATE TABLE IF NOT EXISTS `_feed` (
 	`id` INT NOT NULL AUTO_INCREMENT,	-- v0.7
-	`url` VARCHAR(511) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
+	`url` VARCHAR(32768) CHARACTER SET latin1 COLLATE latin1_bin NOT NULL,
 	`kind` SMALLINT DEFAULT 0,	-- 1.20.0
 	`category` INT DEFAULT 0,	-- 1.20.0
 	`name` VARCHAR(191) NOT NULL,
-	`website` VARCHAR(255) CHARACTER SET latin1 COLLATE latin1_bin,
+	`website` TEXT CHARACTER SET latin1 COLLATE latin1_bin,
 	`description` TEXT,
 	`lastUpdate` INT(11) DEFAULT 0,	-- Until year 2038
 	`priority` TINYINT(2) NOT NULL DEFAULT 10,
@@ -35,7 +35,6 @@ CREATE TABLE IF NOT EXISTS `_feed` (
 	`cache_nbUnreads` INT DEFAULT 0,	-- v0.7
 	PRIMARY KEY (`id`),
 	FOREIGN KEY (`category`) REFERENCES `_category`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
-	UNIQUE KEY (`url`),	-- v0.7
 	INDEX (`name`),	-- v0.7
 	INDEX (`priority`)	-- v0.7
 ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci

+ 2 - 2
app/SQL/install.sql.pgsql.php

@@ -15,11 +15,11 @@ CREATE TABLE IF NOT EXISTS `_category` (
 
 CREATE TABLE IF NOT EXISTS `_feed` (
 	"id" SERIAL PRIMARY KEY,
-	"url" VARCHAR(511) UNIQUE NOT NULL,
+	"url" VARCHAR(32768) NOT NULL,
 	"kind" SMALLINT DEFAULT 0, -- 1.20.0
 	"category" INT DEFAULT 0,	-- 1.20.0
 	"name" VARCHAR(255) NOT NULL,
-	"website" VARCHAR(255),
+	"website" VARCHAR(32768),
 	"description" TEXT,
 	"lastUpdate" INT DEFAULT 0,
 	"priority" SMALLINT NOT NULL DEFAULT 10,

+ 3 - 4
app/SQL/install.sql.sqlite.php

@@ -16,11 +16,11 @@ CREATE TABLE IF NOT EXISTS `category` (
 
 CREATE TABLE IF NOT EXISTS `feed` (
 	`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
-	`url` VARCHAR(511) NOT NULL,
+	`url` VARCHAR(32768) NOT NULL,
 	`kind` SMALLINT DEFAULT 0,	-- 1.20.0
 	`category` INTEGER DEFAULT 0,	-- 1.20.0
 	`name` VARCHAR(255) NOT NULL,
-	`website` VARCHAR(255),
+	`website` VARCHAR(32768),
 	`description` TEXT,
 	`lastUpdate` INT(11) DEFAULT 0,	-- Until year 2038
 	`priority` TINYINT(2) NOT NULL DEFAULT 10,
@@ -31,8 +31,7 @@ CREATE TABLE IF NOT EXISTS `feed` (
 	`attributes` TEXT,	-- v1.11.0
 	`cache_nbEntries` INT DEFAULT 0,
 	`cache_nbUnreads` INT DEFAULT 0,
-	FOREIGN KEY (`category`) REFERENCES `category`(`id`) ON DELETE SET NULL ON UPDATE CASCADE,
-	UNIQUE (`url`)
+	FOREIGN KEY (`category`) REFERENCES `category`(`id`) ON DELETE SET NULL ON UPDATE CASCADE
 );
 CREATE INDEX IF NOT EXISTS feed_name_index ON `feed`(`name`);
 CREATE INDEX IF NOT EXISTS feed_priority_index ON `feed`(`priority`);

+ 1 - 2
app/Services/ExportService.php

@@ -21,6 +21,7 @@ class FreshRSS_Export_Service {
 
 	const FRSS_NAMESPACE = 'https://freshrss.org/opml';
 	const TYPE_HTML_XPATH = 'HTML+XPath';
+	const TYPE_XML_XPATH = 'XML+XPath';
 	const TYPE_RSS_ATOM = 'rss';
 
 	/**
@@ -43,8 +44,6 @@ class FreshRSS_Export_Service {
 	 * @return array First item is the filename, second item is the content
 	 */
 	public function generateOpml() {
-		require_once(LIB_PATH . '/lib_opml.php');
-
 		$view = new FreshRSS_View();
 		$day = date('Y-m-d');
 		$view->categories = $this->category_dao->listCategories(true, true);

+ 284 - 161
app/Services/ImportService.php

@@ -19,8 +19,6 @@ class FreshRSS_Import_Service {
 	 * @param string $username
 	 */
 	public function __construct($username = null) {
-		require_once(LIB_PATH . '/lib_opml.php');
-
 		$this->catDAO = FreshRSS_Factory::createCategoryDao($username);
 		$this->feedDAO = FreshRSS_Factory::createFeedDao($username);
 	}
@@ -34,153 +32,194 @@ class FreshRSS_Import_Service {
 	 * This method parses and imports an OPML file.
 	 *
 	 * @param string $opml_file the OPML file content.
-	 * @param FreshRSS_Category|null $parent_cat the name of the parent category.
-	 * @param boolean $flatten true to disable categories, false otherwise.
-	 * @return array<FreshRSS_Category>|false an array of categories containing some feeds, or false if an error occurred.
+	 * @param FreshRSS_Category|null $forced_category force the feeds to be associated to this category.
+	 * @param boolean $dry_run true to not create categories and feeds in database.
 	 */
-	public function importOpml(string $opml_file, $parent_cat = null, $flatten = false, $dryRun = false) {
+	public function importOpml(string $opml_file, $forced_category = null, $dry_run = false) {
 		$this->lastStatus = true;
 		$opml_array = array();
 		try {
-			$opml_array = libopml_parse_string($opml_file, false);
-		} catch (LibOPML_Exception $e) {
-			if (FreshRSS_Context::$isCli) {
-				fwrite(STDERR, 'FreshRSS error during OPML parsing: ' . $e->getMessage() . "\n");
-			} else {
-				Minz_Log::warning($e->getMessage());
-			}
+			$libopml = new \marienfressinaud\LibOpml\LibOpml(false);
+			$opml_array = $libopml->parseString($opml_file);
+		} catch (\marienfressinaud\LibOpml\Exception $e) {
+			self::log($e->getMessage());
 			$this->lastStatus = false;
-			return false;
+			return;
 		}
 
-		return $this->addOpmlElements($opml_array['body'], $parent_cat, $flatten, $dryRun);
-	}
+		$this->catDAO->checkDefault();
+		$default_category = $this->catDAO->getDefault();
+		if (!$default_category) {
+			self::log('Cannot get the default category');
+			$this->lastStatus = false;
+			return;
+		}
 
-	/**
-	 * This method imports an OPML file based on its body.
-	 *
-	 * @param array $opml_elements an OPML element (body or outline).
-	 * @param FreshRSS_Category|null $parent_cat the name of the parent category.
-	 * @param boolean $flatten true to disable categories, false otherwise.
-	 * @return array<FreshRSS_Category> an array of categories containing some feeds
-	 */
-	private function addOpmlElements($opml_elements, $parent_cat = null, $flatten = false, $dryRun = false) {
+		// Get the categories by names so we can use this array to retrieve
+		// existing categories later.
+		$categories = $this->catDAO->listCategories(false);
+		$categories_by_names = [];
+		foreach ($categories as $category) {
+			$categories_by_names[$category->name()] = $category;
+		}
+
+		// Get current numbers of categories and feeds, and the limits to
+		// verify the user can import its categories/feeds.
+		$nb_categories = count($categories);
 		$nb_feeds = count($this->feedDAO->listFeeds());
-		$nb_cats = count($this->catDAO->listCategories(false));
 		$limits = FreshRSS_Context::$system_conf->limits;
 
-		//Sort with categories first
-		usort($opml_elements, static function ($a, $b) {
-			return strcmp(
-				(isset($a['xmlUrl']) ? 'Z' : 'A') . (isset($a['text']) ? $a['text'] : ''),
-				(isset($b['xmlUrl']) ? 'Z' : 'A') . (isset($b['text']) ? $b['text'] : ''));
-		});
-
-		$categories = [];
-
-		foreach ($opml_elements as $elt) {
-			if (isset($elt['xmlUrl'])) {
-				// If xmlUrl exists, it means it is a feed
-				if (FreshRSS_Context::$isCli && $nb_feeds >= $limits['max_feeds']) {
-					Minz_Log::warning(_t('feedback.sub.feed.over_max',
-									  $limits['max_feeds']));
-					$this->lastStatus = false;
-					continue;
-				}
+		// Process the OPML outlines to get a list of categories and a list of
+		// feeds elements indexed by their categories names.
+		list (
+			$categories_elements,
+			$categories_to_feeds,
+		) = $this->loadFromOutlines($opml_array['body'], '');
 
-				if ($this->addFeedOpml($elt, $parent_cat, $dryRun)) {
-					$nb_feeds++;
+		foreach ($categories_to_feeds as $category_name => $feeds_elements) {
+			$category_element = $categories_elements[$category_name] ?? null;
+
+			$category = null;
+			if ($forced_category) {
+				// If the category is forced, ignore the actual category name
+				$category = $forced_category;
+			} elseif (isset($categories_by_names[$category_name])) {
+				// If the category already exists, get it from $categories_by_names
+				$category = $categories_by_names[$category_name];
+			} elseif ($category_element) {
+				// Otherwise, create the category (if possible)
+				$limit_reached = $nb_categories >= $limits['max_categories'];
+				$can_create_category = FreshRSS_Context::$isCli || !$limit_reached;
+
+				if ($can_create_category) {
+					$category = $this->createCategory($category_element, $dry_run);
+					if ($category) {
+						$categories_by_names[$category->name()] = $category;
+						$nb_categories++;
+					}
 				} else {
-					$this->lastStatus = false;
+					Minz_Log::warning(
+						_t('feedback.sub.category.over_max', $limits['max_categories'])
+					);
 				}
-			} elseif (!empty($elt['text'])) {
-				// No xmlUrl? It should be a category!
-				$limit_reached = !$flatten && ($nb_cats >= $limits['max_categories']);
-				if (!FreshRSS_Context::$isCli && $limit_reached) {
-					Minz_Log::warning(_t('feedback.sub.category.over_max',
-									  $limits['max_categories']));
+			}
+
+			if (!$category) {
+				// Category can be null if the feeds weren't in a category
+				// outline, or if we weren't able to create the category.
+				$category = $default_category;
+			}
+
+			// Then, create the feeds one by one and attach them to the
+			// category we just got.
+			foreach ($feeds_elements as $feed_element) {
+				$limit_reached = $nb_feeds >= $limits['max_feeds'];
+				$can_create_feed = FreshRSS_Context::$isCli || !$limit_reached;
+				if (!$can_create_feed) {
+					Minz_Log::warning(
+						_t('feedback.sub.feed.over_max', $limits['max_feeds'])
+					);
 					$this->lastStatus = false;
-					$flatten = true;
+					break;
 				}
 
-				$category = $this->addCategoryOpml($elt, $parent_cat, $flatten, $dryRun);
-
-				if ($category) {
-					$nb_cats++;
-					$categories[] = $category;
+				if ($this->createFeed($feed_element, $category, $dry_run)) {
+					// TODO what if the feed already exists in the database?
+					$nb_feeds++;
+				} else {
+					$this->lastStatus = false;
 				}
 			}
 		}
 
-		return $categories;
+		return;
 	}
 
 	/**
-	 * This method imports an OPML feed element.
+	 * Create a feed from a feed element (i.e. OPML outline).
 	 *
-	 * @param array $feed_elt an OPML element (must be a feed element).
-	 * @param FreshRSS_Category|null $parent_cat the name of the parent category.
-	 * @return FreshRSS_Feed|null a feed.
+	 * @param array<string, string> $feed_elt An OPML element (must be a feed element).
+	 * @param FreshRSS_Category $category The category to associate to the feed.
+	 * @param boolean $dry_run true to not create the feed in database.
+	 *
+	 * @return FreshRSS_Feed|null The created feed, or null if it failed.
 	 */
-	private function addFeedOpml($feed_elt, $parent_cat, $dryRun = false) {
-		if (empty($feed_elt['xmlUrl'])) {
-			return null;
-		}
-		if ($parent_cat == null) {
-			// This feed has no parent category so we get the default one
-			$this->catDAO->checkDefault();
-			$parent_cat = $this->catDAO->getDefault();
-			if ($parent_cat == null) {
-				$this->lastStatus = false;
-				return null;
-			}
-		}
-
-		// We get different useful information
+	private function createFeed($feed_elt, $category, $dry_run) {
 		$url = Minz_Helper::htmlspecialchars_utf8($feed_elt['xmlUrl']);
-		$name = Minz_Helper::htmlspecialchars_utf8($feed_elt['text'] ?? '');
+		$name = $feed_elt['text'] ?? $feed_elt['title'] ?? '';
+		$name = Minz_Helper::htmlspecialchars_utf8($name);
 		$website = Minz_Helper::htmlspecialchars_utf8($feed_elt['htmlUrl'] ?? '');
 		$description = Minz_Helper::htmlspecialchars_utf8($feed_elt['description'] ?? '');
 
 		try {
 			// Create a Feed object and add it in DB
 			$feed = new FreshRSS_Feed($url);
-			$feed->_categoryId($parent_cat->id());
-			$parent_cat->addFeed($feed);
+			$feed->_categoryId($category->id());
+			$category->addFeed($feed);
 			$feed->_name($name);
 			$feed->_website($website);
 			$feed->_description($description);
 
-			switch ($feed_elt['type'] ?? '') {
-				case FreshRSS_Export_Service::TYPE_HTML_XPATH:
+			switch (strtolower($feed_elt['type'] ?? '')) {
+				case strtolower(FreshRSS_Export_Service::TYPE_HTML_XPATH):
 					$feed->_kind(FreshRSS_Feed::KIND_HTML_XPATH);
 					break;
-				case FreshRSS_Export_Service::TYPE_RSS_ATOM:
+				case strtolower(FreshRSS_Export_Service::TYPE_XML_XPATH):
+					$feed->_kind(FreshRSS_Feed::KIND_XML_XPATH);
+					break;
+				case strtolower(FreshRSS_Export_Service::TYPE_RSS_ATOM):
 				default:
 					$feed->_kind(FreshRSS_Feed::KIND_RSS);
 					break;
 			}
 
+			if (isset($feed_elt['frss:cssFullContent'])) {
+				$feed->_pathEntries(Minz_Helper::htmlspecialchars_utf8($feed_elt['frss:cssFullContent']));
+			}
+
+			if (isset($feed_elt['frss:cssFullContentFilter'])) {
+				$feed->_attributes('path_entries_filter', $feed_elt['frss:cssFullContentFilter']);
+			}
+
+			if (isset($feed_elt['frss:filtersActionRead'])) {
+				$feed->_filtersAction(
+					'read',
+					preg_split('/[\n\r]+/', $feed_elt['frss:filtersActionRead'])
+				);
+			}
+
 			$xPathSettings = [];
-			foreach ($feed_elt as $key => $value) {
-				if (is_array($value) && !empty($value['value']) && ($value['namespace'] ?? '') === FreshRSS_Export_Service::FRSS_NAMESPACE) {
-					switch ($key) {
-						case 'cssFullContent': $feed->_pathEntries(Minz_Helper::htmlspecialchars_utf8($value['value'])); break;
-						case 'cssFullContentFilter': $feed->_attributes('path_entries_filter', $value['value']); break;
-						case 'filtersActionRead': $feed->_filtersAction('read', preg_split('/[\n\r]+/', $value['value'])); break;
-						case 'xPathItem': $xPathSettings['item'] = $value['value']; break;
-						case 'xPathItemTitle': $xPathSettings['itemTitle'] = $value['value']; break;
-						case 'xPathItemContent': $xPathSettings['itemContent'] = $value['value']; break;
-						case 'xPathItemUri': $xPathSettings['itemUri'] = $value['value']; break;
-						case 'xPathItemAuthor': $xPathSettings['itemAuthor'] = $value['value']; break;
-						case 'xPathItemTimestamp': $xPathSettings['itemTimestamp'] = $value['value']; break;
-						case 'xPathItemTimeFormat': $xPathSettings['itemTimeFormat'] = $value['value']; break;
-						case 'xPathItemThumbnail': $xPathSettings['itemThumbnail'] = $value['value']; break;
-						case 'xPathItemCategories': $xPathSettings['itemCategories'] = $value['value']; break;
-						case 'xPathItemUid': $xPathSettings['itemUid'] = $value['value']; break;
-					}
-				}
+			if (isset($feed_elt['frss:xPathItem'])) {
+				$xPathSettings['item'] = $feed_elt['frss:xPathItem'];
+			}
+			if (isset($feed_elt['frss:xPathItemTitle'])) {
+				$xPathSettings['itemTitle'] = $feed_elt['frss:xPathItemTitle'];
+			}
+			if (isset($feed_elt['frss:xPathItemContent'])) {
+				$xPathSettings['itemContent'] = $feed_elt['frss:xPathItemContent'];
+			}
+			if (isset($feed_elt['frss:xPathItemUri'])) {
+				$xPathSettings['itemUri'] = $feed_elt['frss:xPathItemUri'];
+			}
+			if (isset($feed_elt['frss:xPathItemAuthor'])) {
+				$xPathSettings['itemAuthor'] = $feed_elt['frss:xPathItemAuthor'];
+			}
+			if (isset($feed_elt['frss:xPathItemTimestamp'])) {
+				$xPathSettings['itemTimestamp'] = $feed_elt['frss:xPathItemTimestamp'];
+			}
+			if (isset($feed_elt['frss:xPathItemTimeFormat'])) {
+				$xPathSettings['itemTimeFormat'] = $feed_elt['frss:xPathItemTimeFormat'];
 			}
+			if (isset($feed_elt['frss:xPathItemThumbnail'])) {
+				$xPathSettings['itemThumbnail'] = $feed_elt['frss:xPathItemThumbnail'];
+			}
+			if (isset($feed_elt['frss:xPathItemCategories'])) {
+				$xPathSettings['itemCategories'] = $feed_elt['frss:xPathItemCategories'];
+			}
+			if (isset($feed_elt['frss:xPathItemUid'])) {
+				$xPathSettings['itemUid'] = $feed_elt['frss:xPathItemUid'];
+			}
+
 			if (!empty($xPathSettings)) {
 				$feed->_attributes('xpath', $xPathSettings);
 			}
@@ -188,9 +227,11 @@ class FreshRSS_Import_Service {
 			// Call the extension hook
 			/** @var FreshRSS_Feed|null */
 			$feed = Minz_ExtensionManager::callHook('feed_before_insert', $feed);
-			if ($dryRun) {
+
+			if ($dry_run) {
 				return $feed;
 			}
+
 			if ($feed != null) {
 				// addFeedObject checks if feed is already in DB
 				$id = $this->feedDAO->addFeedObject($feed);
@@ -202,81 +243,163 @@ class FreshRSS_Import_Service {
 				}
 			}
 		} catch (FreshRSS_Feed_Exception $e) {
-			if (FreshRSS_Context::$isCli) {
-				fwrite(STDERR, 'FreshRSS error during OPML feed import: ' . $e->getMessage() . "\n");
-			} else {
-				Minz_Log::warning($e->getMessage());
-			}
+			self::log($e->getMessage());
 			$this->lastStatus = false;
 		}
 
-		if (FreshRSS_Context::$isCli) {
-			fwrite(STDERR, 'FreshRSS error during OPML feed import from URL: ' .
-				SimplePie_Misc::url_remove_credentials($url) . ' in category ' . $parent_cat->id() . "\n");
-		} else {
-			Minz_Log::warning('Error during OPML feed import from URL: ' .
-				SimplePie_Misc::url_remove_credentials($url) . ' in category ' . $parent_cat->id());
-		}
-
+		$clean_url = SimplePie_Misc::url_remove_credentials($url);
+		self::log("Cannot create {$clean_url} feed in category {$category->name()}");
 		return null;
 	}
 
 	/**
-	 * This method imports an OPML category element.
+	 * Create and return a category.
+	 *
+	 * @param array<string, string> $category_element An OPML element (must be a category element).
+	 * @param boolean $dry_run true to not create the category in database.
 	 *
-	 * @param array $cat_elt an OPML element (must be a category element).
-	 * @param FreshRSS_Category|null $parent_cat the name of the parent category.
-	 * @param boolean $flatten true to disable categories, false otherwise.
-	 * @return FreshRSS_Category|null a new category containing some feeds, or null if no category was created, or false if an error occurred.
+	 * @return FreshRSS_Category|null The created category, or null if it failed.
 	 */
-	private function addCategoryOpml($cat_elt, $parent_cat, $flatten = false, $dryRun = false) {
-		$error = false;
-		$cat = null;
-		if (!$flatten) {
-			$catName = Minz_Helper::htmlspecialchars_utf8($cat_elt['text']);
-			$cat = new FreshRSS_Category($catName);
-
-			foreach ($cat_elt as $key => $value) {
-				if (is_array($value) && !empty($value['value']) && ($value['namespace'] ?? '') === FreshRSS_Export_Service::FRSS_NAMESPACE) {
-					switch ($key) {
-						case 'opmlUrl':
-							$opml_url = checkUrl($value['value']);
-							if ($opml_url != '') {
-								$cat->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
-								$cat->_attributes('opml_url', $opml_url);
-							}
-							break;
-					}
-				}
+	private function createCategory($category_element, $dry_run) {
+		$name = $category_element['text'] ?? $category_element['title'] ?? '';
+		$name = Minz_Helper::htmlspecialchars_utf8($name);
+		$category = new FreshRSS_Category($name);
+
+		if (isset($category_element['frss:opmlUrl'])) {
+			$opml_url = checkUrl($category_element['frss:opmlUrl']);
+			if ($opml_url != '') {
+				$category->_kind(FreshRSS_Category::KIND_DYNAMIC_OPML);
+				$category->_attributes('opml_url', $opml_url);
 			}
+		}
 
-			if (!$dryRun) {
-				$id = $this->catDAO->addCategoryObject($cat);
-				if ($id == false) {
-					$this->lastStatus = false;
-					$error = true;
-				} else {
-					$cat->_id($id);
+		if ($dry_run) {
+			return $category;
+		}
+
+		$id = $this->catDAO->addCategoryObject($category);
+		if ($id !== false) {
+			$category->_id($id);
+			return $category;
+		} else {
+			self::log("Cannot create category {$category->name()}");
+			$this->lastStatus = false;
+			return null;
+		}
+	}
+
+	/**
+	 * Return the list of category and feed outlines by categories names.
+	 *
+	 * This method is applied to a list of outlines. It merges the different
+	 * list of feeds from several outlines into one array.
+	 *
+	 * @param array $outlines
+	 *     The outlines from which to extract the outlines.
+	 * @param string $parent_category_name
+	 *     The name of the parent category of the current outlines.
+	 *
+	 * @return array[]
+	 */
+	private function loadFromOutlines($outlines, $parent_category_name) {
+		$categories_elements = [];
+		$categories_to_feeds = [];
+
+		foreach ($outlines as $outline) {
+			// Get the categories and feeds from the child outline (it may
+			// return several categories and feeds if the outline is a category).
+			list (
+				$outline_categories,
+				$outline_categories_to_feeds,
+			) = $this->loadFromOutline($outline, $parent_category_name);
+
+			// Then, we merge the initial arrays with the arrays returned by
+			// the outline.
+			$categories_elements = array_merge($categories_elements, $outline_categories);
+
+			foreach ($outline_categories_to_feeds as $category_name => $feeds) {
+				if (!isset($categories_to_feeds[$category_name])) {
+					$categories_to_feeds[$category_name] = [];
 				}
+
+				$categories_to_feeds[$category_name] = array_merge(
+					$categories_to_feeds[$category_name],
+					$feeds
+				);
 			}
-			if ($error) {
-				if (FreshRSS_Context::$isCli) {
-					fwrite(STDERR, 'FreshRSS error during OPML category import from URL: ' . $catName . "\n");
-				} else {
-					Minz_Log::warning('Error during OPML category import from URL: ' . $catName);
-				}
+		}
+
+		return [$categories_elements, $categories_to_feeds];
+	}
+
+	/**
+	 * Return the list of category and feed outlines by categories names.
+	 *
+	 * This method is applied to a specific outline. If the outline represents
+	 * a category (i.e. @outlines key exists), it will reapply loadFromOutlines()
+	 * to its children. If the outline represents a feed (i.e. xmlUrl key
+	 * exists), it will add the outline to an array accessible by its category
+	 * name.
+	 *
+	 * @param array $outline
+	 *     The outline from which to extract the categories and feeds outlines.
+	 * @param string $parent_category_name
+	 *     The name of the parent category of the current outline.
+	 *
+	 * @return array[]
+	 */
+	private function loadFromOutline($outline, $parent_category_name) {
+		$categories_elements = [];
+		$categories_to_feeds = [];
+
+		if ($parent_category_name === '' && isset($outline['category'])) {
+			// The outline has no parent category, but its OPML category
+			// attribute is set, so we use it as the category name.
+			// lib_opml parses this attribute as an array of strings, so we
+			// rebuild a string here.
+			$parent_category_name = implode(', ', $outline['category']);
+			$categories_elements[$parent_category_name] = [
+				'text' => $parent_category_name,
+			];
+		}
+
+		if (isset($outline['@outlines'])) {
+			// The outline has children, it's probably a category
+			if (!empty($outline['text'])) {
+				$category_name = $outline['text'];
+			} elseif (!empty($outline['title'])) {
+				$category_name = $outline['title'];
 			} else {
-				$parent_cat = $cat;
+				$category_name = $parent_category_name;
 			}
+
+			list (
+				$categories_elements,
+				$categories_to_feeds,
+			) = $this->loadFromOutlines($outline['@outlines'], $category_name);
+
+			unset($outline['@outlines']);
+			$categories_elements[$category_name] = $outline;
 		}
 
-		if (isset($cat_elt['@outlines'])) {
-			// Our cat_elt contains more categories or more feeds, so we
-			// add them recursively.
-			// Note: FreshRSS does not support yet category arborescence, so always flatten from here
-			$this->addOpmlElements($cat_elt['@outlines'], $parent_cat, true, $dryRun);
+		// The xmlUrl means it's a feed URL: add the outline to the array if it
+		// exists.
+		if (isset($outline['xmlUrl'])) {
+			if (!isset($categories_to_feeds[$parent_category_name])) {
+				$categories_to_feeds[$parent_category_name] = [];
+			}
+
+			$categories_to_feeds[$parent_category_name][] = $outline;
 		}
 
-		return $cat;
+		return [$categories_elements, $categories_to_feeds];
+	}
+
+	private static function log($message) {
+		if (FreshRSS_Context::$isCli) {
+			fwrite(STDERR, "FreshRSS error during OPML import: {$message}\n");
+		} else {
+			Minz_Log::warning("Error during OPML import: {$message}");
+		}
 	}
 }

+ 16 - 22
app/Utils/feverUtil.php

@@ -1,19 +1,19 @@
 <?php
 
 class FreshRSS_fever_Util {
-	const FEVER_PATH = DATA_PATH . '/fever';
+	private const FEVER_PATH = DATA_PATH . '/fever';
 
 	/**
 	 * Make sure the fever path exists and is writable.
 	 *
-	 * @return boolean true if the path is writable, else false.
+	 * @return bool true if the path is writable, false otherwise.
 	 */
-	public static function checkFeverPath() {
+	public static function checkFeverPath(): bool {
 		if (!file_exists(self::FEVER_PATH)) {
 			@mkdir(self::FEVER_PATH, 0770, true);
 		}
 
-		$ok = is_writable(self::FEVER_PATH);
+		$ok = touch(self::FEVER_PATH . '/index.html');	// is_writable() is not reliable for a folder on NFS
 		if (!$ok) {
 			Minz_Log::error("Could not save Fever API credentials. The directory does not have write access.");
 		}
@@ -22,25 +22,21 @@ class FreshRSS_fever_Util {
 
 	/**
 	 * Return the corresponding path for a fever key.
-	 *
-	 * @param string $feverKey
-	 * @return string
 	 */
-	public static function getKeyPath($feverKey) {
+	public static function getKeyPath(string $feverKey): string {
+		if (FreshRSS_Context::$system_conf === null) {
+			throw new FreshRSS_Context_Exception('System configuration not initialised!');
+		}
 		$salt = sha1(FreshRSS_Context::$system_conf->salt);
 		return self::FEVER_PATH . '/.key-' . $salt . '-' . $feverKey . '.txt';
 	}
 
 	/**
 	 * Update the fever key of a user.
-	 *
-	 * @param string $username
-	 * @param string $passwordPlain
 	 * @return string|false the Fever key, or false if the update failed
 	 */
-	public static function updateKey($username, $passwordPlain) {
-		$ok = self::checkFeverPath();
-		if (!$ok) {
+	public static function updateKey(string $username, string $passwordPlain) {
+		if (!self::checkFeverPath()) {
 			return false;
 		}
 
@@ -48,22 +44,20 @@ class FreshRSS_fever_Util {
 
 		$feverKey = strtolower(md5("{$username}:{$passwordPlain}"));
 		$feverKeyPath = self::getKeyPath($feverKey);
-		$res = file_put_contents($feverKeyPath, $username);
-		if ($res !== false) {
+		$result = file_put_contents($feverKeyPath, $username);
+		if (is_int($result) && $result > 0) {
 			return $feverKey;
-		} else {
-			Minz_Log::warning('Could not save Fever API credentials. Unknown error.', ADMIN_LOG);
-			return false;
 		}
+		Minz_Log::warning('Could not save Fever API credentials. Unknown error.', ADMIN_LOG);
+		return false;
 	}
 
 	/**
 	 * Delete the Fever key of a user.
 	 *
-	 * @param string $username
-	 * @return boolean true if the deletion succeeded, else false.
+	 * @return bool true if the deletion succeeded, else false.
 	 */
-	public static function deleteKey($username) {
+	public static function deleteKey(string $username) {
 		$userConfig = get_user_configuration($username);
 		if ($userConfig === null) {
 			return false;

+ 8 - 11
app/Utils/passwordUtil.php

@@ -3,26 +3,25 @@
 class FreshRSS_password_Util {
 	// Will also have to be computed client side on mobile devices,
 	// so do not use a too high cost
-	const BCRYPT_COST = 9;
+	public const BCRYPT_COST = 9;
 
 	/**
 	 * Return a hash of a plain password, using BCRYPT
-	 *
-	 * @param string $passwordPlain
-	 * @return string
 	 */
-	public static function hash($passwordPlain) {
+	public static function hash(string $passwordPlain): string {
 		$passwordHash = password_hash(
 			$passwordPlain,
 			PASSWORD_BCRYPT,
 			array('cost' => self::BCRYPT_COST)
 		);
-		$passwordPlain = '';
 
 		// Compatibility with bcrypt.js
 		$passwordHash = preg_replace('/^\$2[xy]\$/', '\$2a\$', $passwordHash);
 
-		return $passwordHash == '' ? '' : $passwordHash;
+		if ($passwordHash === '' || $passwordHash === null) {
+			return '';
+		}
+		return $passwordHash;
 	}
 
 	/**
@@ -30,11 +29,9 @@ class FreshRSS_password_Util {
 	 *
 	 * A valid password is a string of at least 7 characters.
 	 *
-	 * @param string $password
-	 *
-	 * @return boolean True if the password is valid, false otherwise
+	 * @return bool True if the password is valid, false otherwise
 	 */
-	public static function check($password) {
+	public static function check(string $password): bool {
 		return strlen($password) >= 7;
 	}
 }

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

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Zobrazení',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Spodní řádek',
 			'display_authors' => 'Autoři',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'Časový limit HTML5 oznámení',
 		),
 		'show_nav_buttons' => 'Zobrazit navigační tlačítka',
-		'theme' => 'Motiv',
+		'theme' => array(
+			'_' => 'Motiv',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'Motiv „%s“ již není dostupný. Zvolte jiný motiv, prosím.',
 		'thumbnail' => array(
 			'label' => 'Náhled',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Na výšku',
 			'square' => 'Čtverec',
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'Zobrazení',
 		'width' => array(
 			'content' => 'Šířka obsahu',

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

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'Uživatelské dotazy',
 		'reading' => 'Čtení',
 		'search' => 'Hledat slova nebo #štítky',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Sdílení',
 		'shortcuts' => 'Zkratky',
 		'stats' => 'Statistika',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Známé základní stránky',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Schránka',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'E-mail',
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath pro:',
 			),
 			'rss' => 'RSS / Atom (výchozí)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Vymazat mezipaměť',

+ 9 - 1
app/i18n/de/conf.php

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Anzeige',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Fußzeile',
 			'display_authors' => 'Autoren',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'Zeitüberschreitung für HTML5-Benachrichtigung',
 		),
 		'show_nav_buttons' => 'Zeige Navigations-Buttons',
-		'theme' => 'Erscheinungsbild',
+		'theme' => array(
+			'_' => 'Layout',
+			'deprecated' => array(
+				'_' => 'Veraltet',
+				'description' => 'Diese Layout wird nicht mehr länger aktualisiert und wir in einer <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">zukünftigen Version von FreshRSS</a> entfernt sein.',
+			),
+		),
 		'theme_not_available' => 'Das Erscheinungsbild „%s“ ist nicht mehr verfügbar. Bitte ein anderes auswählen.',
 		'thumbnail' => array(
 			'label' => 'Vorschaubild',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Hochformat',
 			'square' => 'Quadrat',
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'Anzeige',
 		'width' => array(
 			'content' => 'Inhaltsbreite',

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

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'Benutzerabfragen',
 		'reading' => 'Lesen',
 		'search' => 'Suche Worte oder #Tags',
+		'search_help' => 'Siehe Dokumentation zu den <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">Suchparametern</a>',
 		'sharing' => 'Teilen',
 		'shortcuts' => 'Tastaturkürzel',
 		'stats' => 'Statistiken',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Known-Seite (https://withknown.com)',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Zwischenablage',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'E-Mail',
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath für:',
 			),
 			'rss' => 'RSS / Atom (Standard)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Zwischenspeicher leeren',

+ 9 - 1
app/i18n/el/conf.php

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Display',	// TODO
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Bottom line',	// TODO
 			'display_authors' => 'Authors',	// TODO
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'HTML5 notification timeout',	// TODO
 		),
 		'show_nav_buttons' => 'Show the navigation buttons',	// TODO
-		'theme' => 'Theme',	// TODO
+		'theme' => array(
+			'_' => 'Theme',	// TODO
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'The “%s” theme is not available anymore. Please choose another theme.',	// TODO
 		'thumbnail' => array(
 			'label' => 'Thumbnail',	// TODO
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Portrait',	// TODO
 			'square' => 'Square',	// TODO
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'Display',	// TODO
 		'width' => array(
 			'content' => 'Content width',	// TODO

+ 3 - 0
app/i18n/el/gen.php

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'User queries',	// TODO
 		'reading' => 'Reading',	// TODO
 		'search' => 'Search words or #tags',	// TODO
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Sharing',	// TODO
 		'shortcuts' => 'Shortcuts',	// TODO
 		'stats' => 'Statistics',	// TODO
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Known based sites',	// TODO
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// TODO
 		'blogotext' => 'Blogotext',	// TODO
 		'clipboard' => 'Clipboard',	// TODO
 		'diaspora' => 'Diaspora*',	// TODO
 		'email' => 'Email',	// TODO
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// TODO
 		'gnusocial' => 'GNU social',	// TODO
 		'jdh' => 'Journal du hacker',	// TODO

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath for:',	// TODO
 			),
 			'rss' => 'RSS / Atom (default)',	// TODO
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',	// TODO

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

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Display',	// IGNORE
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Bottom line',	// IGNORE
 			'display_authors' => 'Authors',	// IGNORE
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'HTML5 notification timeout',	// IGNORE
 		),
 		'show_nav_buttons' => 'Show the navigation buttons',	// IGNORE
-		'theme' => 'Theme',	// IGNORE
+		'theme' => array(
+			'_' => 'Theme',	// IGNORE
+			'deprecated' => array(
+				'_' => 'Deprecated',	// IGNORE
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// IGNORE
+			),
+		),
 		'theme_not_available' => 'The “%s” theme is not available anymore. Please choose another theme.',	// IGNORE
 		'thumbnail' => array(
 			'label' => 'Thumbnail',	// IGNORE
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Portrait',	// IGNORE
 			'square' => 'Square',	// IGNORE
 		),
+		'timezone' => 'Time zone',	// IGNORE
 		'title' => 'Display',	// IGNORE
 		'width' => array(
 			'content' => 'Content width',	// IGNORE

+ 3 - 0
app/i18n/en-us/gen.php

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'User queries',	// IGNORE
 		'reading' => 'Reading',	// IGNORE
 		'search' => 'Search words or #tags',	// IGNORE
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// IGNORE
 		'sharing' => 'Sharing',	// IGNORE
 		'shortcuts' => 'Shortcuts',	// IGNORE
 		'stats' => 'Statistics',	// IGNORE
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Known based sites',	// IGNORE
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Clipboard',	// IGNORE
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'Email',	// IGNORE
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath for:',	// IGNORE
 			),
 			'rss' => 'RSS / Atom (default)',	// IGNORE
+			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',	// IGNORE

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

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Display',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Bottom line',
 			'display_authors' => 'Authors',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'HTML5 notification timeout',
 		),
 		'show_nav_buttons' => 'Show the navigation buttons',
-		'theme' => 'Theme',
+		'theme' => array(
+			'_' => 'Theme',
+			'deprecated' => array(
+				'_' => 'Deprecated',
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',
+			),
+		),
 		'theme_not_available' => 'The “%s” theme is not available anymore. Please choose another theme.',
 		'thumbnail' => array(
 			'label' => 'Thumbnail',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Portrait',
 			'square' => 'Square',
 		),
+		'timezone' => 'Time zone',
 		'title' => 'Display',
 		'width' => array(
 			'content' => 'Content width',

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

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'User queries',
 		'reading' => 'Reading',
 		'search' => 'Search words or #tags',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Sharing',
 		'shortcuts' => 'Shortcuts',
 		'stats' => 'Statistics',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Known based sites',
+		'archiveORG' => 'archive.org',
 		'archivePH' => 'archive.ph',
 		'blogotext' => 'Blogotext',
 		'clipboard' => 'Clipboard',
 		'diaspora' => 'Diaspora*',
 		'email' => 'Email',
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',
 		'gnusocial' => 'GNU social',
 		'jdh' => 'Journal du hacker',

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath for:',
 			),
 			'rss' => 'RSS / Atom (default)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',

+ 9 - 1
app/i18n/es/admin.php

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Visualización',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Línea inferior',
 			'display_authors' => 'Autores/Autoras',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'Notificación de fin de espera HTML5',
 		),
 		'show_nav_buttons' => 'Mostrar los botones de navegación',
-		'theme' => 'Tema',
+		'theme' => array(
+			'_' => 'Tema',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'El tema “%s” ya no está disponible. Por favor, elija otro tema.',
 		'thumbnail' => array(
 			'label' => 'Miniatura',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Retrato',
 			'square' => 'Cuadrado',
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'Visualización',
 		'width' => array(
 			'content' => 'Ancho de contenido',

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

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'Peticiones de usuario',
 		'reading' => 'Lectura',
 		'search' => 'Buscar palabras o #etiquetas',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Compartir',
 		'shortcuts' => 'Atajos',
 		'stats' => 'Estadísticas',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Sitios basados en conocidos',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Portapapeles',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'Email',	// IGNORE
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath para:',
 			),
 			'rss' => 'RSS / Atom (por defecto)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Borrar caché',

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

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Affichage',
+		'darkMode' => 'Mode sombre automatique (bêta)',
 		'icon' => array(
 			'bottom_line' => 'Ligne du bas',
 			'display_authors' => 'Auteurs',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'Temps d’affichage de la notification HTML5',
 		),
 		'show_nav_buttons' => 'Afficher les boutons de navigation',
-		'theme' => 'Thème',
+		'theme' => array(
+			'_' => 'Thème',
+			'deprecated' => array(
+				'_' => 'Obsolète',
+				'description' => 'Ce thème est obsolète et sera supprimé dans une <a href="https://freshrss.github.io/FreshRSS/fr/users/05_Configuration.html#th%C3%A8me" target="_blank">future version de FreshRSS</a>',
+			),
+		),
 		'theme_not_available' => 'Le thème <em>%s</em> n’est plus disponible. Veuillez choisir un autre thème.',
 		'thumbnail' => array(
 			'label' => 'Miniature',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Portrait',	// IGNORE
 			'square' => 'Carrée',
 		),
+		'timezone' => 'Fuseau horaire',
 		'title' => 'Affichage',
 		'width' => array(
 			'content' => 'Largeur du contenu',

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

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'Filtres utilisateurs',
 		'reading' => 'Lecture',
 		'search' => 'Rechercher des mots ou des #tags',
+		'search_help' => 'Voir <a href="https://freshrss.github.io/FreshRSS/fr/users/03_Main_view.html#gr%C3%A2ce-au-champ-de-recherche" target="_blank">la documentation pour la syntaxe des recherches avancées</a>',
 		'sharing' => 'Partage',
 		'shortcuts' => 'Raccourcis',
 		'stats' => 'Statistiques',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Sites basés sur Known',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Presse-papier',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'Courriel',
+		'email-webmail-firefox-fix' => 'Courriel (pour Webmail avec Firefox)',
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath pour :',
 			),
 			'rss' => 'RSS / Atom (par défaut)',
+			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Vider le cache',

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

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'תצוגה',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'שורה תחתונה',
 			'display_authors' => 'Authors',	// TODO
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'HTML5 התראה פג תוקף',
 		),
 		'show_nav_buttons' => 'Show the navigation buttons',	// TODO
-		'theme' => 'ערכת נושא',
+		'theme' => array(
+			'_' => 'ערכת נושא',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'The “%s” theme is not available anymore. Please choose another theme.',	// TODO
 		'thumbnail' => array(
 			'label' => 'Thumbnail',	// TODO
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Portrait',	// TODO
 			'square' => 'Square',	// TODO
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'תצוגה',
 		'width' => array(
 			'content' => 'רוחב התוכן',

+ 3 - 0
app/i18n/he/gen.php

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'שאילתות',
 		'reading' => 'קריאה',
 		'search' => 'חיפוש מילים או #תגים',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'שיתוף',
 		'shortcuts' => 'קיצורי דרך',
 		'stats' => 'סטטיסטיקות',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Known based sites',	// TODO
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Clipboard',	// TODO
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'דואר אלקטרוני',
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath for:',	// TODO
 			),
 			'rss' => 'RSS / Atom (default)',	// TODO
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',	// TODO

+ 9 - 1
app/i18n/id/conf.php

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Display',	// TODO
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Bottom line',	// TODO
 			'display_authors' => 'Authors',	// TODO
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'HTML5 notification timeout',	// TODO
 		),
 		'show_nav_buttons' => 'Show the navigation buttons',	// TODO
-		'theme' => 'Theme',	// TODO
+		'theme' => array(
+			'_' => 'Theme',	// TODO
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'The “%s” theme is not available anymore. Please choose another theme.',	// TODO
 		'thumbnail' => array(
 			'label' => 'Thumbnail',	// TODO
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Portrait',	// TODO
 			'square' => 'Square',	// TODO
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'Display',	// TODO
 		'width' => array(
 			'content' => 'Content width',	// TODO

+ 3 - 0
app/i18n/id/gen.php

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'User queries',	// TODO
 		'reading' => 'Reading',	// TODO
 		'search' => 'Search words or #tags',	// TODO
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Sharing',	// TODO
 		'shortcuts' => 'Shortcuts',	// TODO
 		'stats' => 'Statistics',	// TODO
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Known based sites',	// TODO
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// TODO
 		'blogotext' => 'Blogotext',	// TODO
 		'clipboard' => 'Clipboard',	// TODO
 		'diaspora' => 'Diaspora*',	// TODO
 		'email' => 'Email',	// TODO
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// TODO
 		'gnusocial' => 'GNU social',	// TODO
 		'jdh' => 'Journal du hacker',	// TODO

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath for:',	// TODO
 			),
 			'rss' => 'RSS / Atom (default)',	// TODO
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',	// TODO

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

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Visualizzazione',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Barra in fondo',
 			'display_authors' => 'Autori',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'Notifica timeout HTML5',
 		),
 		'show_nav_buttons' => 'Mostra i pulsanti di navigazione',
-		'theme' => 'Tema',
+		'theme' => array(
+			'_' => 'Tema',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'Il tema “%s” non è più disponibile. Si prega di selezionarne un altro.',
 		'thumbnail' => array(
 			'label' => 'Miniatura',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Ritratto',
 			'square' => 'Squadrata',
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'Visualizzazione',
 		'width' => array(
 			'content' => 'Larghezza contenuto',

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

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'Ricerche personali',
 		'reading' => 'Lettura',
 		'search' => 'Ricerca parole o #tags',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Condivisione',
 		'shortcuts' => 'Comandi tastiera',
 		'stats' => 'Statistiche',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Siti basati su Known',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Appunti',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'Email',	// IGNORE
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath per:',
 			),
 			'rss' => 'RSS / Atom (predefinito)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Svuota cache',

+ 9 - 1
app/i18n/ja/conf.php

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => '表示',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => '行の下部',
 			'display_authors' => '著者',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'HTML5 の通知タイムアウト時間',
 		),
 		'show_nav_buttons' => 'ナビゲーションボタンを表示する',
-		'theme' => 'テーマ',
+		'theme' => array(
+			'_' => 'テーマ',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => '“%s”テーマはご利用いただけません。他のテーマをお選びください。',
 		'thumbnail' => array(
 			'label' => 'サムネイル',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'ポートレート',
 			'square' => '四角',
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'ディスプレイ',
 		'width' => array(
 			'content' => 'コンテンツ幅',

+ 3 - 0
app/i18n/ja/gen.php

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'ユーザークエリ',
 		'reading' => 'リーディング',
 		'search' => '単語で検索するかハッシュタグで検索する',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => '共有',
 		'shortcuts' => 'ショートカット',
 		'stats' => '統計',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'よく使われるサイト',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'クリップボード',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'Eメール',
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPathは:',
 			),
 			'rss' => 'RSS / Atom (標準)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'キャッシュのクリア',

+ 9 - 1
app/i18n/ko/conf.php

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => '표시',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => '하단',
 			'display_authors' => '저자',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'HTML5 알림 타임아웃',
 		),
 		'show_nav_buttons' => '내비게이션 버튼 보이기',
-		'theme' => '테마',
+		'theme' => array(
+			'_' => '테마',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => '“%s” 테마는 더이상 사용할 수 없습니다. 다른 테마를 선택해 주세요.',
 		'thumbnail' => array(
 			'label' => '섬네일',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => '세로 방향',
 			'square' => '정사각형',
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => '표시',
 		'width' => array(
 			'content' => '내용 표시 너비',

+ 3 - 0
app/i18n/ko/gen.php

@@ -174,6 +174,7 @@ return array(
 		'queries' => '사용자 쿼리',
 		'reading' => '읽기',
 		'search' => '단어 또는 #태그 검색',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => '공유',
 		'shortcuts' => '단축키',
 		'stats' => '통계',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Known based sites',	// IGNORE
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => '클립보드',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => '메일',
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => '다음의 XPath:',
 			),
 			'rss' => 'RSS / Atom (기본값)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => '캐쉬 지우기',

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

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Opmaak',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Onderaan',
 			'display_authors' => 'Auteurs',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'HTML5 notificatie stop',
 		),
 		'show_nav_buttons' => 'Toon navigatieknoppen',
-		'theme' => 'Thema',
+		'theme' => array(
+			'_' => 'Thema',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'Het „%s” thema is niet meer beschikbaar. Kies een ander thema.',
 		'thumbnail' => array(
 			'label' => 'Miniatuur',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Staand',
 			'square' => 'Vierkant',
 		),
+		'timezone' => 'Tijdzone',
 		'title' => 'Opmaak',
 		'width' => array(
 			'content' => 'Inhoud breedte',

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

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'Gebruikers informatie',
 		'reading' => 'Lezen',
 		'search' => 'Zoek woorden of #labels',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Delen',
 		'shortcuts' => 'Snelle toegang',
 		'stats' => 'Statistieken',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Known-gebaseerde sites',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Klembord',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'Email',	// IGNORE
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath voor:',
 			),
 			'rss' => 'RSS / Atom (standaard)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Cache leegmaken',

+ 9 - 1
app/i18n/oc/conf.php

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Afichatge',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Linha enbàs',
 			'display_authors' => 'Autors',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'Temps d’afichatge de las notificacions HTML5',
 		),
 		'show_nav_buttons' => 'Mostrar los botons de navigacion',
-		'theme' => 'Tèma',
+		'theme' => array(
+			'_' => 'Tèma',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'Lo tèma « %s » es pas pus disponible. Causissètz un autre tèma.',
 		'thumbnail' => array(
 			'label' => 'Vinheta',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Retrach',
 			'square' => 'Carrat',
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'Afichatge',
 		'width' => array(
 			'content' => 'Largor del contengut',

+ 3 - 0
app/i18n/oc/gen.php

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'Filtres utilizaire',
 		'reading' => 'Lectura',
 		'search' => 'Recercar de mots o d’#etiquetas',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Partatge',
 		'shortcuts' => 'Acorchis',
 		'stats' => 'Estatisticas',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Sites basats sus Known',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Quicha-papiers.',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'Corrièl',
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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

@@ -122,6 +122,7 @@ return array(
 				'xpath' => 'XPath per :',
 			),
 			'rss' => 'RSS / Atom (defaut)',
+			'xml_xpath' => 'XML + XPath',	// TODO
 		),
 		'maintenance' => array(
 			'clear_cache' => 'Escafar lo cache',

+ 9 - 1
app/i18n/pl/conf.php

@@ -32,6 +32,7 @@ return array(
 	),
 	'display' => array(
 		'_' => 'Wyświetlanie',
+		'darkMode' => 'Automatic dark mode (beta)',	// TODO
 		'icon' => array(
 			'bottom_line' => 'Dolny margines',
 			'display_authors' => 'Autorzy',
@@ -48,7 +49,13 @@ return array(
 			'timeout' => 'Czas wyświetlania powiadomienia HTML5',
 		),
 		'show_nav_buttons' => 'Pokaż przyciski nawigacyjne',
-		'theme' => 'Motyw',
+		'theme' => array(
+			'_' => 'Motyw',
+			'deprecated' => array(
+				'_' => 'Deprecated',	// TODO
+				'description' => 'This theme is no longer supported and will be not available anymore in a <a href="https://freshrss.github.io/FreshRSS/en/users/05_Configuration.html#theme" target="_blank">future release of FreshRSS</a>',	// TODO
+			),
+		),
 		'theme_not_available' => 'Motyw “%s” nie jest już dostępny. Wybierz inny motyw.',
 		'thumbnail' => array(
 			'label' => 'Miniaturka',
@@ -57,6 +64,7 @@ return array(
 			'portrait' => 'Portret',
 			'square' => 'Kwadrat',
 		),
+		'timezone' => 'Time zone',	// TODO
 		'title' => 'Wyświetlanie',
 		'width' => array(
 			'content' => 'Rozmiar treści',

+ 3 - 0
app/i18n/pl/gen.php

@@ -174,6 +174,7 @@ return array(
 		'queries' => 'Zapisane zapytania',
 		'reading' => 'Czytanie',
 		'search' => 'Wyszukaj wyrazy lub #tagi',
+		'search_help' => 'See documentation for advanced <a href="https://freshrss.github.io/FreshRSS/en/users/10_filter.html#with-the-search-field" target="_blank">search parameters</a>',	// TODO
 		'sharing' => 'Podawanie dalej',
 		'shortcuts' => 'Skróty klawiszowe',
 		'stats' => 'Statystyki',
@@ -191,11 +192,13 @@ return array(
 	),
 	'share' => array(
 		'Known' => 'Strony bazujące na usłudze Known',
+		'archiveORG' => 'archive.org',	// IGNORE
 		'archivePH' => 'archive.ph',	// IGNORE
 		'blogotext' => 'Blogotext',	// IGNORE
 		'clipboard' => 'Schowek',
 		'diaspora' => 'Diaspora*',	// IGNORE
 		'email' => 'E-mail',
+		'email-webmail-firefox-fix' => 'Email (webmail - fix for Firefox)',	// TODO
 		'facebook' => 'Facebook',	// IGNORE
 		'gnusocial' => 'GNU social',	// IGNORE
 		'jdh' => 'Journal du hacker',	// IGNORE

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