Browse Source

Show time since when a feed has problems + new timeago() method and i18n plurals (#8670)

Closes https://github.com/FreshRSS/FreshRSS/issues/8508

Changes proposed in this pull request:

- Use an integer for `Feed::error` everywhere (follow up to #8646)
- Extract `Entry::machineReadableDate()` into function for use in HTML templates
- Add `timeago()` function that converts a unix timestamp into a "4 weeks ago" string
- Show the last successful feed update, and the last erroneous update

How to test the feature manually:

1. Update a feed
2. Modify the feed URL in the database and set it to a non-existing URL
3. Update the feed again
4. Open the "Manage feed" and see the expanded error message:

>  Blast! This feed has encountered a problem. If this situation persists, please verify that it is still reachable.
> Last successful update 3 hours ago, last erroneous update 1 hour ago. 

You can hover the relative dates to see the timestamp.

* Make Feed::error an int everywhere

Related: https://github.com/FreshRSS/FreshRSS/pull/8646

* Extract timestamptomachinedate()

.. for later usage in the feed error time display.

* Show time since when a feed has problems

We add our own "timeago" function that converts a unix timestamp
into a "4 weeks ago" string.

Resolves: https://github.com/FreshRSS/FreshRSS/issues/8508

* Add new translation keys

* i18n fr, en-US

* Minor XHTML preference

* Slightly shorter rewrite, also hopefully easier to read

* Rewrite to allow (simple) plural
I also moved some functions around for hopefully a more generic and better structure.
I made some changes for the sake of speed (e.g. second-based logic instead of datetime intervals).
Note: I used automatic translation as I was worried it would be too complicated to explain to translators... I proofread the few languages I have some familiarity with.

* Add reference to CLDR

* Slightly more compact syntax

* Always show last update, fix case of unknown error date

* Remove forgotten span

* No need for multi-lines anymore

* Fix error date thresshold

* plurals forms

* Extract gettext formula conversion script to cli

* Simplify a bit

* Escort excess parentheses to the door

* Simplify

* Avoid being too clever in localization

* Fix German

* Fix plural TODO parsing

* Ignore en-US translation

* make fix-all

* git update-index --chmod=+x cli/compile.plurals.php

* Heredoc indent PHP 7.3+

* compileAll: Continue on error

* PHP strict comparisons

* Light logical simplification

* Cache plural_message_families

* Avoid case of empty value

* A bit of documentation

---------

Co-authored-by: Alexandre Alapetite <alexandre@alapetite.fr>
Co-authored-by: Frans de Jonge <frans@clevercast.com>
Co-authored-by: Frans de Jonge <fransdejonge@gmail.com>
Christian Weiske 2 days ago
parent
commit
1acc646222
100 changed files with 2113 additions and 80 deletions
  1. 6 0
      Makefile
  2. 2 2
      README.fr.md
  3. 2 2
      README.md
  4. 1 1
      app/Models/CategoryDAO.php
  5. 16 5
      app/Models/Feed.php
  6. 1 1
      app/Models/FeedDAO.php
  7. 33 0
      app/i18n/cs/gen.php
  8. 10 0
      app/i18n/cs/plurals.php
  9. 2 0
      app/i18n/cs/sub.php
  10. 27 0
      app/i18n/de/gen.php
  11. 10 0
      app/i18n/de/plurals.php
  12. 2 0
      app/i18n/de/sub.php
  13. 27 0
      app/i18n/el/gen.php
  14. 10 0
      app/i18n/el/plurals.php
  15. 2 0
      app/i18n/el/sub.php
  16. 27 0
      app/i18n/en-US/gen.php
  17. 10 0
      app/i18n/en-US/plurals.php
  18. 2 0
      app/i18n/en-US/sub.php
  19. 28 1
      app/i18n/en/gen.php
  20. 10 0
      app/i18n/en/plurals.php
  21. 2 0
      app/i18n/en/sub.php
  22. 27 0
      app/i18n/es/gen.php
  23. 10 0
      app/i18n/es/plurals.php
  24. 2 0
      app/i18n/es/sub.php
  25. 27 0
      app/i18n/fa/gen.php
  26. 10 0
      app/i18n/fa/plurals.php
  27. 2 0
      app/i18n/fa/sub.php
  28. 27 0
      app/i18n/fi/gen.php
  29. 10 0
      app/i18n/fi/plurals.php
  30. 2 0
      app/i18n/fi/sub.php
  31. 27 0
      app/i18n/fr/gen.php
  32. 10 0
      app/i18n/fr/plurals.php
  33. 2 0
      app/i18n/fr/sub.php
  34. 27 0
      app/i18n/he/gen.php
  35. 10 0
      app/i18n/he/plurals.php
  36. 2 0
      app/i18n/he/sub.php
  37. 27 0
      app/i18n/hu/gen.php
  38. 10 0
      app/i18n/hu/plurals.php
  39. 2 0
      app/i18n/hu/sub.php
  40. 21 0
      app/i18n/id/gen.php
  41. 10 0
      app/i18n/id/plurals.php
  42. 2 0
      app/i18n/id/sub.php
  43. 27 0
      app/i18n/it/gen.php
  44. 10 0
      app/i18n/it/plurals.php
  45. 2 0
      app/i18n/it/sub.php
  46. 21 0
      app/i18n/ja/gen.php
  47. 10 0
      app/i18n/ja/plurals.php
  48. 2 0
      app/i18n/ja/sub.php
  49. 21 0
      app/i18n/ko/gen.php
  50. 10 0
      app/i18n/ko/plurals.php
  51. 2 0
      app/i18n/ko/sub.php
  52. 33 0
      app/i18n/lv/gen.php
  53. 10 0
      app/i18n/lv/plurals.php
  54. 2 0
      app/i18n/lv/sub.php
  55. 27 0
      app/i18n/nl/gen.php
  56. 10 0
      app/i18n/nl/plurals.php
  57. 2 0
      app/i18n/nl/sub.php
  58. 27 0
      app/i18n/oc/gen.php
  59. 10 0
      app/i18n/oc/plurals.php
  60. 2 0
      app/i18n/oc/sub.php
  61. 33 0
      app/i18n/pl/gen.php
  62. 10 0
      app/i18n/pl/plurals.php
  63. 2 0
      app/i18n/pl/sub.php
  64. 27 0
      app/i18n/pt-BR/gen.php
  65. 10 0
      app/i18n/pt-BR/plurals.php
  66. 2 0
      app/i18n/pt-BR/sub.php
  67. 27 0
      app/i18n/pt-PT/gen.php
  68. 10 0
      app/i18n/pt-PT/plurals.php
  69. 2 0
      app/i18n/pt-PT/sub.php
  70. 33 0
      app/i18n/ru/gen.php
  71. 10 0
      app/i18n/ru/plurals.php
  72. 2 0
      app/i18n/ru/sub.php
  73. 33 0
      app/i18n/sk/gen.php
  74. 10 0
      app/i18n/sk/plurals.php
  75. 2 0
      app/i18n/sk/sub.php
  76. 27 0
      app/i18n/tr/gen.php
  77. 10 0
      app/i18n/tr/plurals.php
  78. 2 0
      app/i18n/tr/sub.php
  79. 33 0
      app/i18n/uk/gen.php
  80. 10 0
      app/i18n/uk/plurals.php
  81. 2 0
      app/i18n/uk/sub.php
  82. 21 0
      app/i18n/zh-CN/gen.php
  83. 10 0
      app/i18n/zh-CN/plurals.php
  84. 2 0
      app/i18n/zh-CN/sub.php
  85. 21 0
      app/i18n/zh-TW/gen.php
  86. 10 0
      app/i18n/zh-TW/plurals.php
  87. 2 0
      app/i18n/zh-TW/sub.php
  88. 18 6
      app/views/helpers/feed/update.phtml
  89. 7 0
      cli/README.md
  90. 57 5
      cli/check.translation.php
  91. 74 0
      cli/compile.plurals.php
  92. 95 6
      cli/i18n/I18nCompletionValidator.php
  93. 167 4
      cli/i18n/I18nData.php
  94. 24 10
      cli/i18n/I18nFile.php
  95. 21 4
      cli/i18n/I18nUsageValidator.php
  96. 294 0
      cli/i18n/PluralFormsCompiler.php
  97. 2 1
      composer.json
  98. 196 32
      lib/Minz/Translate.php
  99. 34 0
      lib/lib_rss.php
  100. 36 0
      tests/cli/i18n/I18nCompletionValidatorTest.php

+ 6 - 0
Makefile

@@ -168,9 +168,15 @@ endif
 	$(PHP) ./cli/manipulate.translation.php --action add --language $(lang) --origin-language $(ref)
 	@echo Language added.
 
+.PHONY: i18n-compile-plurals
+i18n-compile-plurals: ## Compile plural formulas from app/i18n/*/plurals.php
+	@$(PHP) ./cli/compile.plurals.php --all
+	@echo Plural files compiled.
+
 .PHONY: i18n-format
 i18n-format: ## Format I18N files
 	@$(PHP) ./cli/manipulate.translation.php --action format
+	@$(PHP) ./cli/compile.plurals.php --all
 	@echo Files formatted.
 
 .PHONY: i18n-ignore-key

+ 2 - 2
README.fr.md

@@ -229,14 +229,14 @@ Voir le [dépôt dédié à ces extensions](https://github.com/FreshRSS/Extensio
 | - | - | - |
 | Čeština (cs) | ■■■■■■■■・・ 82% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Deutsch (de) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Ελληνικά (el) | ■■■・・・・・・・ 37% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Ελληνικά (el) | ■■■・・・・・・・ 38% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (en) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (United States) (en-US) | ■■■■■■■■■■ 100% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen-US+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Español (es) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fes+%2F%28TODO%7CDIRTY%29%24%2F) |
 | فارسی (fa) | ■■■■■■■■■・ 91% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffa+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Suomi (fi) | ■■■■■■■■■・ 93% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffi+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Français (fr) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffr+%2F%28TODO%7CDIRTY%29%24%2F) |
-| עברית (he) | ■■■■・・・・・・ 41% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%24%2F) |
+| עברית (he) | ■■■■・・・・・・ 42% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Magyar (hu) | ■■■■■■■■■・ 97% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhu+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Bahasa Indonesia (id) | ■■■■■■■■■・ 90% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fid+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Italiano (it) | ■■■■■■■■■・ 99% | [contribuer](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fit+%2F%28TODO%7CDIRTY%29%24%2F) |

+ 2 - 2
README.md

@@ -125,14 +125,14 @@ See the [repository dedicated to those extensions](https://github.com/FreshRSS/E
 | - | - | - |
 | Čeština (cs) | ■■■■■■■■・・ 82% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fcs+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Deutsch (de) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fde+%2F%28TODO%7CDIRTY%29%24%2F) |
-| Ελληνικά (el) | ■■■・・・・・・・ 37% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%24%2F) |
+| Ελληνικά (el) | ■■■・・・・・・・ 38% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fel+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (en) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen+%2F%28TODO%7CDIRTY%29%24%2F) |
 | English (United States) (en-US) | ■■■■■■■■■■ 100% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fen-US+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Español (es) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fes+%2F%28TODO%7CDIRTY%29%24%2F) |
 | فارسی (fa) | ■■■■■■■■■・ 91% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffa+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Suomi (fi) | ■■■■■■■■■・ 93% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffi+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Français (fr) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Ffr+%2F%28TODO%7CDIRTY%29%24%2F) |
-| עברית (he) | ■■■■・・・・・・ 41% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%24%2F) |
+| עברית (he) | ■■■■・・・・・・ 42% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhe+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Magyar (hu) | ■■■■■■■■■・ 97% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fhu+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Bahasa Indonesia (id) | ■■■■■■■■■・ 90% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fid+%2F%28TODO%7CDIRTY%29%24%2F) |
 | Italiano (it) | ■■■■■■■■■・ 99% | [contribute](https://github.com/search?q=repo%3AFreshRSS%2FFreshRSS+path%3Aapp%2Fi18n%2Fit+%2F%28TODO%7CDIRTY%29%24%2F) |

+ 1 - 1
app/Models/CategoryDAO.php

@@ -482,7 +482,7 @@ class FreshRSS_CategoryDAO extends Minz_ModelPdo {
 	/**
 	 * @param array<array{c_name:string,c_id:int,c_kind:int,c_last_update:int,c_error:int|bool,c_attributes?:string,
 	 * 	id?:int,name?:string,url?:string,kind?:int,website?:string,priority?:int,
-	 * 	error?:int|bool,attributes?:string,cache_nbEntries?:int,cache_nbUnreads?:int,ttl?:int}> $listDAO
+	 * 	error?:int,attributes?:string,cache_nbEntries?:int,cache_nbUnreads?:int,ttl?:int}> $listDAO
 	 * @return array<int,FreshRSS_Category> where the key is the category ID
 	 */
 	private static function daoToCategoriesPrepopulated(array $listDAO): array {

+ 16 - 5
app/Models/Feed.php

@@ -62,7 +62,7 @@ class FreshRSS_Feed extends Minz_Model {
 	private int $priority = self::PRIORITY_MAIN_STREAM;
 	private string $pathEntries = '';
 	private string $httpAuth = '';
-	private bool $error = false;
+	private int $error = 0;
 	private int $ttl = self::TTL_DEFAULT;
 	private bool $mute = false;
 	private string $hash = '';
@@ -354,10 +354,21 @@ class FreshRSS_Feed extends Minz_Model {
 		return $curl_options;
 	}
 
-	public function inError(): bool {
+	/**
+	 * Timestamp of last update error.
+	 * Legacy: may return 1 if the feed has an error but the timestamp is not available.
+	 */
+	public function lastError(): int {
 		return $this->error;
 	}
 
+	/**
+	 * If the feed has an error
+	 */
+	public function inError(): bool {
+		return $this->error > 0;
+	}
+
 	/**
 	 * @param bool $raw true for database version combined with mute information, false otherwise
 	 */
@@ -525,8 +536,8 @@ class FreshRSS_Feed extends Minz_Model {
 		$this->httpAuth = $value;
 	}
 
-	public function _error(bool|int $value): void {
-		$this->error = (bool)$value;
+	public function _error(int $value): void {
+		$this->error = $value;
 	}
 	public function _mute(bool $value): void {
 		$this->mute = $value;
@@ -754,7 +765,7 @@ class FreshRSS_Feed extends Minz_Model {
 					return $this->loadGuids($simplePie, $invalidGuidsTolerance);
 				}
 			}
-			$this->_error(true);
+			$this->_error(time());
 		}
 
 		return $guids;

+ 1 - 1
app/Models/FeedDAO.php

@@ -704,7 +704,7 @@ class FreshRSS_FeedDAO extends Minz_ModelPdo {
 
 	/**
 	 * @param array<array{id?:int,url?:string,kind?:int,category?:int,name?:string,website?:string,description?:string,lastUpdate?:int,priority?:int,
-	 * 	pathEntries?:string,httpAuth?:string,error?:int|bool,ttl?:int,attributes?:string,cache_nbUnreads?:int,cache_nbEntries?:int}> $listDAO
+	 * 	pathEntries?:string,httpAuth?:string,error?:int,ttl?:int,attributes?:string,cache_nbUnreads?:int,cache_nbEntries?:int}> $listDAO
 	 * @return array<int,FreshRSS_Feed> where the key is the feed ID
 	 */
 	public static function daoToFeeds(array $listDAO, ?int $catID = null): array {

+ 33 - 0
app/i18n/cs/gen.php

@@ -140,6 +140,39 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'O FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'před %d den',
+			1 => 'před %d dny',
+			2 => 'před %d dní',
+		),
+		'hour' => array(
+			0 => 'před %d hodina',
+			1 => 'před %d hodiny',
+			2 => 'před %d hodin',
+		),
+		'justnow' => 'právě teď',
+		'minute' => array(
+			0 => 'před %d minuta',
+			1 => 'před %d minuty',
+			2 => 'před %d minut',
+		),
+		'month' => array(
+			0 => 'před %d měsíc',
+			1 => 'před %d měsíce',
+			2 => 'před %d měsíců',
+		),
+		'second' => array(
+			0 => 'před %d sekunda',
+			1 => 'před %d sekundy',
+			2 => 'před %d sekund',
+		),
+		'year' => array(
+			0 => 'před %d rok',
+			1 => 'před %d roky',
+			2 => 'před %d let',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Prázdná kategorie',
 		'confirm_action' => 'Opravdu chcete provést tuto akci? Toto nelze zrušit!',

+ 10 - 0
app/i18n/cs/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 3,
+	'plural' => static fn (int $n): int => ($n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2)),
+);

+ 2 - 0
app/i18n/cs/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (výchozí)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Vymazat mezipaměť',
 			'clear_cache_help' => 'Vymazat mezipaměť pro tento kanál.',

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

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Über FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'vor %d Tag',
+			1 => 'vor %d Tage',
+		),
+		'hour' => array(
+			0 => 'vor %d Stunde',
+			1 => 'vor %d Stunden',
+		),
+		'justnow' => 'gerade eben',
+		'minute' => array(
+			0 => 'vor %d Minute',
+			1 => 'vor %d Minuten',
+		),
+		'month' => array(
+			0 => 'vor %d Monat',
+			1 => 'vor %d Monaten',
+		),
+		'second' => array(
+			0 => 'vor %d Sekunde',
+			1 => 'vor %d Sekunden',
+		),
+		'year' => array(
+			0 => 'vor %d Jahr',
+			1 => 'vor %d Jahre',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Kategorie leeren',
 		'confirm_action' => 'Sind Sie sicher, dass Sie diese Aktion durchführen wollen? Diese Aktion kann nicht abgebrochen werden!',

+ 10 - 0
app/i18n/de/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (Standard)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Letzte fehlerhafte Aktualisierung <time datetime="%1$s" title="%1$s">%2$s</time>.',
+		'last-update' => 'Letzte erfolgreiche Aktualisierung <time datetime="%1$s" title="%1$s">%2$s</time>.',
 		'maintenance' => array(
 			'clear_cache' => 'Zwischenspeicher leeren',
 			'clear_cache_help' => 'Zwischenspeicher für diesen Feed leeren.',

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

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Σχετικά με το FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'πριν από %d ημέρα',
+			1 => 'πριν από %d ημέρες',
+		),
+		'hour' => array(
+			0 => 'πριν από %d ώρα',
+			1 => 'πριν από %d ώρες',
+		),
+		'justnow' => 'μόλις τώρα',
+		'minute' => array(
+			0 => 'πριν από %d λεπτό',
+			1 => 'πριν από %d λεπτά',
+		),
+		'month' => array(
+			0 => 'πριν από %d μήνας',
+			1 => 'πριν από %d μήνες',
+		),
+		'second' => array(
+			0 => 'πριν από %d δευτερόλεπτο',
+			1 => 'πριν από %d δευτερόλεπτα',
+		),
+		'year' => array(
+			0 => 'πριν από %d έτος',
+			1 => 'πριν από %d έτη',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Άδειασμα κατηγορίας',
 		'confirm_action' => 'Είστε σίγουροι για την ενέργεια; Είναι μη αναστρέψιμη!',

+ 10 - 0
app/i18n/el/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (default)',	// TODO
 			'xml_xpath' => 'XML + XPath',	// TODO
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',	// TODO
 			'clear_cache_help' => 'Clear the cache for this feed.',	// TODO

+ 27 - 0
app/i18n/en-US/gen.php

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'About FreshRSS',	// IGNORE
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d day ago',	// IGNORE
+			1 => '%d days ago',	// IGNORE
+		),
+		'hour' => array(
+			0 => '%d hour ago',	// IGNORE
+			1 => '%d hours ago',	// IGNORE
+		),
+		'justnow' => 'just now',	// IGNORE
+		'minute' => array(
+			0 => '%d minute ago',	// IGNORE
+			1 => '%d minutes ago',	// IGNORE
+		),
+		'month' => array(
+			0 => '%d month ago',	// IGNORE
+			1 => '%d months ago',	// IGNORE
+		),
+		'second' => array(
+			0 => '%d second ago',	// IGNORE
+			1 => '%d seconds ago',	// IGNORE
+		),
+		'year' => array(
+			0 => '%d year ago',	// IGNORE
+			1 => '%d years ago',	// IGNORE
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Empty category',	// IGNORE
 		'confirm_action' => 'Are you sure you want to perform this action? It cannot be canceled!',

+ 10 - 0
app/i18n/en-US/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (default)',	// IGNORE
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// IGNORE
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// IGNORE
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',	// IGNORE
 			'clear_cache_help' => 'Clear the cache for this feed.',	// IGNORE

+ 28 - 1
app/i18n/en/gen.php

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',
 		'about' => 'About FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d day ago',
+			1 => '%d days ago',
+		),
+		'hour' => array(
+			0 => '%d hour ago',
+			1 => '%d hours ago',
+		),
+		'justnow' => 'just now',
+		'minute' => array(
+			0 => '%d minute ago',
+			1 => '%d minutes ago',
+		),
+		'month' => array(
+			0 => '%d month ago',
+			1 => '%d months ago',
+		),
+		'second' => array(
+			0 => '%d second ago',
+			1 => '%d seconds ago',
+		),
+		'year' => array(
+			0 => '%d year ago',
+			1 => '%d years ago',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Empty category',
 		'confirm_action' => 'Are you sure you want to perform this action? It cannot be cancelled!',
@@ -276,7 +303,7 @@ return array(
 		'raindrop' => 'Raindrop.io',
 		'reddit' => 'Reddit',
 		'shaarli' => 'Shaarli',
-		'telegram' => 'Telegram',	// IGNORE
+		'telegram' => 'Telegram',
 		'twitter' => 'Twitter',
 		'wallabag' => 'wallabag v1',
 		'wallabagv2' => 'wallabag v2',

+ 10 - 0
app/i18n/en/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (default)',
 			'xml_xpath' => 'XML + XPath',
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',
 			'clear_cache_help' => 'Clear the cache for this feed.',

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

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Acerca de FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'hace %d día',
+			1 => 'hace %d días',
+		),
+		'hour' => array(
+			0 => 'hace %d hora',
+			1 => 'hace %d horas',
+		),
+		'justnow' => 'justo ahora',
+		'minute' => array(
+			0 => 'hace %d minuto',
+			1 => 'hace %d minutos',
+		),
+		'month' => array(
+			0 => 'hace %d mes',
+			1 => 'hace %d meses',
+		),
+		'second' => array(
+			0 => 'hace %d segundo',
+			1 => 'hace %d segundos',
+		),
+		'year' => array(
+			0 => 'hace %d año',
+			1 => 'hace %d años',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Vaciar categoría',
 		'confirm_action' => '¿Seguro que quieres hacerlo? No hay marcha atrás…',

+ 10 - 0
app/i18n/es/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (por defecto)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Borrar caché',
 			'clear_cache_help' => 'Borrar la memoria caché de esta fuente.',

+ 27 - 0
app/i18n/fa/gen.php

@@ -140,6 +140,33 @@ return array(
 		'_' => ' FreshRSS',
 		'about' => 'درباره FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d روز پیش',
+			1 => '%d روز پیش',
+		),
+		'hour' => array(
+			0 => '%d ساعت پیش',
+			1 => '%d ساعت پیش',
+		),
+		'justnow' => 'همین الان',
+		'minute' => array(
+			0 => '%d دقیقه پیش',
+			1 => '%d دقیقه پیش',
+		),
+		'month' => array(
+			0 => '%d ماه پیش',
+			1 => '%d ماه پیش',
+		),
+		'second' => array(
+			0 => '%d ثانیه پیش',
+			1 => '%d ثانیه پیش',
+		),
+		'year' => array(
+			0 => '%d سال پیش',
+			1 => '%d سال پیش',
+		),
+	),
 	'js' => array(
 		'category_empty' => ' دسته خالی',
 		'confirm_action' => ' آیا مطمئن هستید که می خواهید این عمل را انجام دهید؟ نمی توان آن را لغو کرد!',

+ 10 - 0
app/i18n/fa/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n > 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n > 1) ? 1 : 0),
+);

+ 2 - 0
app/i18n/fa/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => ' RSS / Atom (پیش‌فرض)',
 			'xml_xpath' => ' XML + XPath',
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => ' کش را پاک کنید',
 			'clear_cache_help' => ' کش این فید را پاک کنید.',

+ 27 - 0
app/i18n/fi/gen.php

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Tietoja FreshRSS-sovelluksesta',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d päivä sitten',
+			1 => '%d päivää sitten',
+		),
+		'hour' => array(
+			0 => '%d tunti sitten',
+			1 => '%d tuntia sitten',
+		),
+		'justnow' => 'juuri nyt',
+		'minute' => array(
+			0 => '%d minuutti sitten',
+			1 => '%d minuuttia sitten',
+		),
+		'month' => array(
+			0 => '%d kuukausi sitten',
+			1 => '%d kuukautta sitten',
+		),
+		'second' => array(
+			0 => '%d sekunti sitten',
+			1 => '%d sekuntia sitten',
+		),
+		'year' => array(
+			0 => '%d vuosi sitten',
+			1 => '%d vuotta sitten',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Tyhjennä luokka',
 		'confirm_action' => 'Haluatko varmasti toteuttaa toiminnon? Sitä ei voi peruuttaa!',

+ 10 - 0
app/i18n/fi/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

+ 2 - 0
app/i18n/fi/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS/Atom (oletus)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Tyhjennä välimuisti',
 			'clear_cache_help' => 'Tyhjennä syötteen välimuisti.',

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

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'À propos de FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'il y a %d jour',
+			1 => 'il y a %d jours',
+		),
+		'hour' => array(
+			0 => 'il y a %d heure',
+			1 => 'il y a %d heures',
+		),
+		'justnow' => 'à l’instant',
+		'minute' => array(
+			0 => 'il y a %d minute',
+			1 => 'il y a %d minutes',
+		),
+		'month' => array(
+			0 => 'il y a %d mois',
+			1 => 'il y a %d mois',
+		),
+		'second' => array(
+			0 => 'il y a %d seconde',
+			1 => 'il y a %d secondes',
+		),
+		'year' => array(
+			0 => 'il y a %d an',
+			1 => 'il y a %d ans',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Catégorie vide',
 		'confirm_action' => 'Êtes-vous sûr(e) de vouloir continuer ? Cette action ne peut être annulée !',

+ 10 - 0
app/i18n/fr/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n > 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n > 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (par défaut)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Dernière mise à jour erronée <time datetime="%1$s" title="%1$s">%2$s</time>.',
+		'last-update' => 'Dernière mise à jour réussie <time datetime="%1$s" title="%1$s">%2$s</time>.',
 		'maintenance' => array(
 			'clear_cache' => 'Vider le cache',
 			'clear_cache_help' => 'Supprime le cache de ce flux.',

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

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// TODO
 		'about' => 'אודות FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'לפני %d יום',
+			1 => 'לפני %d ימים',
+		),
+		'hour' => array(
+			0 => 'לפני %d שעה',
+			1 => 'לפני %d שעות',
+		),
+		'justnow' => 'הרגע',
+		'minute' => array(
+			0 => 'לפני %d דקה',
+			1 => 'לפני %d דקות',
+		),
+		'month' => array(
+			0 => 'לפני %d חודש',
+			1 => 'לפני %d חודשים',
+		),
+		'second' => array(
+			0 => 'לפני %d שנייה',
+			1 => 'לפני %d שניות',
+		),
+		'year' => array(
+			0 => 'לפני %d שנה',
+			1 => 'לפני %d שנים',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Empty category',	// TODO
 		'confirm_action' => 'האם אתם בטוחים שברצונכם לבצע פעולה זו? אין אפשרות לבטל אותה!',

+ 10 - 0
app/i18n/he/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (default)',	// TODO
 			'xml_xpath' => 'XML + XPath',	// TODO
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Clear cache',	// TODO
 			'clear_cache_help' => 'Clear the cache for this feed.',	// TODO

+ 27 - 0
app/i18n/hu/gen.php

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'FreshRSS névjegy',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d nap ezelőtt',
+			1 => '%d nap ezelőtt',
+		),
+		'hour' => array(
+			0 => '%d óra ezelőtt',
+			1 => '%d óra ezelőtt',
+		),
+		'justnow' => 'épp most',
+		'minute' => array(
+			0 => '%d perc ezelőtt',
+			1 => '%d perc ezelőtt',
+		),
+		'month' => array(
+			0 => '%d hónap ezelőtt',
+			1 => '%d hónap ezelőtt',
+		),
+		'second' => array(
+			0 => '%d másodperc ezelőtt',
+			1 => '%d másodperc ezelőtt',
+		),
+		'year' => array(
+			0 => '%d év ezelőtt',
+			1 => '%d év ezelőtt',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Üres kategória',
 		'confirm_action' => 'Biztos vagy benne hogy végrehajtod ezt a műveletet? A művelet nem megszakítható!',

+ 10 - 0
app/i18n/hu/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

+ 2 - 0
app/i18n/hu/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (alapértelmezett)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Gyorsítótár törlése',
 			'clear_cache_help' => 'Gyorsítótár törlése ehhez a hírforráshoz.',

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

@@ -140,6 +140,27 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Tentang FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d hari yang lalu',
+		),
+		'hour' => array(
+			0 => '%d jam yang lalu',
+		),
+		'justnow' => 'baru saja',
+		'minute' => array(
+			0 => '%d menit yang lalu',
+		),
+		'month' => array(
+			0 => '%d bulan yang lalu',
+		),
+		'second' => array(
+			0 => '%d detik yang lalu',
+		),
+		'year' => array(
+			0 => '%d tahun yang lalu',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Kategori kosong',
 		'confirm_action' => 'Apakah Anda yakin ingin melakukan ini? Ini tidak dapat dibatalkan!',

+ 10 - 0
app/i18n/id/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=1; plural=0;
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 1,
+	'plural' => static fn (int $n): int => 0,
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (baku)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Bersihkan tembolok',
 			'clear_cache_help' => 'Bersihkan tembolok untuk umpan ini.',

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

@@ -140,6 +140,33 @@ return array(
 		'_' => 'Feed RSS Reader',
 		'about' => 'Informazioni',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d giorno fa',
+			1 => '%d giorni fa',
+		),
+		'hour' => array(
+			0 => '%d ora fa',
+			1 => '%d ore fa',
+		),
+		'justnow' => 'proprio adesso',
+		'minute' => array(
+			0 => '%d minuto fa',
+			1 => '%d minuti fa',
+		),
+		'month' => array(
+			0 => '%d mese fa',
+			1 => '%d mesi fa',
+		),
+		'second' => array(
+			0 => '%d secondo fa',
+			1 => '%d secondi fa',
+		),
+		'year' => array(
+			0 => '%d anno fa',
+			1 => '%d anni fa',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Categoria vuota',
 		'confirm_action' => 'Sei sicuro di voler continuare?',

+ 10 - 0
app/i18n/it/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (predefinito)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Svuota cache',
 			'clear_cache_help' => 'Svuota la cache per questo feed.',

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

@@ -140,6 +140,27 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'FreshRSSについて',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d日前',
+		),
+		'hour' => array(
+			0 => '%d時間前',
+		),
+		'justnow' => 'たった今',
+		'minute' => array(
+			0 => '%d分前',
+		),
+		'month' => array(
+			0 => '%dか月前',
+		),
+		'second' => array(
+			0 => '%d秒前',
+		),
+		'year' => array(
+			0 => '%d年前',
+		),
+	),
 	'js' => array(
 		'category_empty' => '空白のカテゴリ',
 		'confirm_action' => '本当に実行してもいいですか?キャンセルはできません!',

+ 10 - 0
app/i18n/ja/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=1; plural=0;
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 1,
+	'plural' => static fn (int $n): int => 0,
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (標準)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'キャッシュのクリア',
 			'clear_cache_help' => 'このフィードのキャッシュをクリアします。',

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

@@ -140,6 +140,27 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => '정보',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d일 전',
+		),
+		'hour' => array(
+			0 => '%d시간 전',
+		),
+		'justnow' => '방금 전',
+		'minute' => array(
+			0 => '%d분 전',
+		),
+		'month' => array(
+			0 => '%d개월 전',
+		),
+		'second' => array(
+			0 => '%d초 전',
+		),
+		'year' => array(
+			0 => '%d년 전',
+		),
+	),
 	'js' => array(
 		'category_empty' => '빈 카테고리',
 		'confirm_action' => '정말 이 작업을 수행하시겠습니까? 이 작업은 되돌릴 수 없습니다!',

+ 10 - 0
app/i18n/ko/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=1; plural=0;
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 1,
+	'plural' => static fn (int $n): int => 0,
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (기본값)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => '캐쉬 지우기',
 			'clear_cache_help' => '이 피드의 캐쉬 지우기.',

+ 33 - 0
app/i18n/lv/gen.php

@@ -140,6 +140,39 @@ return array(
 		'_' => 'FreshRSS',	// TODO
 		'about' => 'Par FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'pirms %d diena',
+			1 => 'pirms %d dienas',
+			2 => 'pirms %d dienu',
+		),
+		'hour' => array(
+			0 => 'pirms %d stunda',
+			1 => 'pirms %d stundas',
+			2 => 'pirms %d stundu',
+		),
+		'justnow' => 'tikko',
+		'minute' => array(
+			0 => 'pirms %d minūte',
+			1 => 'pirms %d minūtes',
+			2 => 'pirms %d minūšu',
+		),
+		'month' => array(
+			0 => 'pirms %d mēnesis',
+			1 => 'pirms %d mēneši',
+			2 => 'pirms %d mēnešu',
+		),
+		'second' => array(
+			0 => 'pirms %d sekunde',
+			1 => 'pirms %d sekundes',
+			2 => 'pirms %d sekunžu',
+		),
+		'year' => array(
+			0 => 'pirms %d gads',
+			1 => 'pirms %d gadi',
+			2 => 'pirms %d gadu',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Tukša kategorija',
 		'confirm_action' => 'Vai esat pārliecināts, ka vēlaties veikt šo darbību? To nevar atcelt!',

+ 10 - 0
app/i18n/lv/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n != 0 ? 1 : 2);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 3,
+	'plural' => static fn (int $n): int => ($n % 10 === 1 && $n % 100 !== 11 ? 0 : ($n !== 0 ? 1 : 2)),
+);

+ 2 - 0
app/i18n/lv/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (noklusējums)',
 			'xml_xpath' => 'XML + XPath',	// TODO
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Iztīrīt kešatmiņu',
 			'clear_cache_help' => 'Iztīrīt kešatmiņu priekš šīs barotnes.',

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

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Over FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d dag geleden',
+			1 => '%d dagen geleden',
+		),
+		'hour' => array(
+			0 => '%d uur geleden',
+			1 => '%d uur geleden',
+		),
+		'justnow' => 'zojuist',
+		'minute' => array(
+			0 => '%d minuut geleden',
+			1 => '%d minuten geleden',
+		),
+		'month' => array(
+			0 => '%d maand geleden',
+			1 => '%d maanden geleden',
+		),
+		'second' => array(
+			0 => '%d seconde geleden',
+			1 => '%d seconden geleden',
+		),
+		'year' => array(
+			0 => '%d jaar geleden',
+			1 => '%d jaar geleden',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Lege categorie',
 		'confirm_action' => 'Weet u zeker dat u dit wilt doen? Het kan niet ongedaan worden gemaakt!',

+ 10 - 0
app/i18n/nl/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (standaard)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Cache leegmaken',
 			'clear_cache_help' => 'Cache voor deze feed leegmaken.',

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

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'A prepaus de FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'fa %d jorn',
+			1 => 'fa %d jorns',
+		),
+		'hour' => array(
+			0 => 'fa %d ora',
+			1 => 'fa %d oras',
+		),
+		'justnow' => 'ara meteis',
+		'minute' => array(
+			0 => 'fa %d minuta',
+			1 => 'fa %d minutas',
+		),
+		'month' => array(
+			0 => 'fa %d mes',
+			1 => 'fa %d meses',
+		),
+		'second' => array(
+			0 => 'fa %d segonda',
+			1 => 'fa %d segondas',
+		),
+		'year' => array(
+			0 => 'fa %d an',
+			1 => 'fa %d ans',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Categoria voida',
 		'confirm_action' => 'Volètz vertadièrament contunhar ? Aquesta accion se pòt pas anullar !',

+ 10 - 0
app/i18n/oc/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n > 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n > 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (defaut)',
 			'xml_xpath' => 'XML + XPath',	// TODO
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Escafar lo cache',
 			'clear_cache_help' => 'Escafar lo cache d’aqueste flux sul disc',

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

@@ -140,6 +140,39 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'O oprogramowaniu FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d dzień temu',
+			1 => '%d dni temu',
+			2 => '%d dni temu',
+		),
+		'hour' => array(
+			0 => '%d godzina temu',
+			1 => '%d godziny temu',
+			2 => '%d godzin temu',
+		),
+		'justnow' => 'przed chwilą',
+		'minute' => array(
+			0 => '%d minuta temu',
+			1 => '%d minuty temu',
+			2 => '%d minut temu',
+		),
+		'month' => array(
+			0 => '%d miesiąc temu',
+			1 => '%d miesiące temu',
+			2 => '%d miesięcy temu',
+		),
+		'second' => array(
+			0 => '%d sekunda temu',
+			1 => '%d sekundy temu',
+			2 => '%d sekund temu',
+		),
+		'year' => array(
+			0 => '%d rok temu',
+			1 => '%d lata temu',
+			2 => '%d lat temu',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Pusta kategoria',
 		'confirm_action' => 'Czy jesteś pewien, że chcesz przeprowadzić daną operację? Nie można cofnąć jej rezultatów!',

+ 10 - 0
app/i18n/pl/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 3,
+	'plural' => static fn (int $n): int => ($n === 1 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2)),
+);

+ 2 - 0
app/i18n/pl/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (domyślne)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Wyczyść pamięć podręczną',
 			'clear_cache_help' => 'Czyści pamięć podręczną tego kanału.',

+ 27 - 0
app/i18n/pt-BR/gen.php

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Sobre FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'há %d dia',
+			1 => 'há %d dias',
+		),
+		'hour' => array(
+			0 => 'há %d hora',
+			1 => 'há %d horas',
+		),
+		'justnow' => 'agora mesmo',
+		'minute' => array(
+			0 => 'há %d minuto',
+			1 => 'há %d minutos',
+		),
+		'month' => array(
+			0 => 'há %d mês',
+			1 => 'há %d meses',
+		),
+		'second' => array(
+			0 => 'há %d segundo',
+			1 => 'há %d segundos',
+		),
+		'year' => array(
+			0 => 'há %d ano',
+			1 => 'há %d anos',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Categoria vazia',
 		'confirm_action' => 'Você tem certeza que deseja efetuar esta ação? Ela não poderá ser cancelada!',

+ 10 - 0
app/i18n/pt-BR/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n > 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n > 1) ? 1 : 0),
+);

+ 2 - 0
app/i18n/pt-BR/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (padrão)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Limpar o cache',
 			'clear_cache_help' => 'Limpar o cache em disco deste feed',

+ 27 - 0
app/i18n/pt-PT/gen.php

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Sobre FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'há %d dia',
+			1 => 'há %d dias',
+		),
+		'hour' => array(
+			0 => 'há %d hora',
+			1 => 'há %d horas',
+		),
+		'justnow' => 'agora mesmo',
+		'minute' => array(
+			0 => 'há %d minuto',
+			1 => 'há %d minutos',
+		),
+		'month' => array(
+			0 => 'há %d mês',
+			1 => 'há %d meses',
+		),
+		'second' => array(
+			0 => 'há %d segundo',
+			1 => 'há %d segundos',
+		),
+		'year' => array(
+			0 => 'há %d ano',
+			1 => 'há %d anos',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Categoria vazia',
 		'confirm_action' => 'Tem certeza que deseja efetuar esta ação? Ela não poderá ser revertida!',

+ 10 - 0
app/i18n/pt-PT/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n != 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n !== 1) ? 1 : 0),
+);

+ 2 - 0
app/i18n/pt-PT/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (padrão)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Limpar o cache',
 			'clear_cache_help' => 'Limpar o cache em disco deste feed',

+ 33 - 0
app/i18n/ru/gen.php

@@ -140,6 +140,39 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'О FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d день назад',
+			1 => '%d дня назад',
+			2 => '%d дней назад',
+		),
+		'hour' => array(
+			0 => '%d час назад',
+			1 => '%d часа назад',
+			2 => '%d часов назад',
+		),
+		'justnow' => 'только что',
+		'minute' => array(
+			0 => '%d минута назад',
+			1 => '%d минуты назад',
+			2 => '%d минут назад',
+		),
+		'month' => array(
+			0 => '%d месяц назад',
+			1 => '%d месяца назад',
+			2 => '%d месяцев назад',
+		),
+		'second' => array(
+			0 => '%d секунда назад',
+			1 => '%d секунды назад',
+			2 => '%d секунд назад',
+		),
+		'year' => array(
+			0 => '%d год назад',
+			1 => '%d года назад',
+			2 => '%d лет назад',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Пустая категория',
 		'confirm_action' => 'Вы уверены, что хотите выполнить это действие? Это нельзя отменить!',

+ 10 - 0
app/i18n/ru/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 3,
+	'plural' => static fn (int $n): int => ($n % 10 === 1 && $n % 100 !== 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2)),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (по умолчанию)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Очистить кэш',
 			'clear_cache_help' => 'Очистить кэш для этой ленты.',

+ 33 - 0
app/i18n/sk/gen.php

@@ -140,6 +140,39 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'O FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => 'pred %d deň',
+			1 => 'pred %d dni',
+			2 => 'pred %d dní',
+		),
+		'hour' => array(
+			0 => 'pred %d hodina',
+			1 => 'pred %d hodiny',
+			2 => 'pred %d hodín',
+		),
+		'justnow' => 'práve teraz',
+		'minute' => array(
+			0 => 'pred %d minúta',
+			1 => 'pred %d minúty',
+			2 => 'pred %d minút',
+		),
+		'month' => array(
+			0 => 'pred %d mesiac',
+			1 => 'pred %d mesiace',
+			2 => 'pred %d mesiacov',
+		),
+		'second' => array(
+			0 => 'pred %d sekunda',
+			1 => 'pred %d sekundy',
+			2 => 'pred %d sekúnd',
+		),
+		'year' => array(
+			0 => 'pred %d rok',
+			1 => 'pred %d roky',
+			2 => 'pred %d rokov',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Prázdna kategória',
 		'confirm_action' => 'Určite chcete vykonať túto akciu? Zmeny budú nezvratné!',

+ 10 - 0
app/i18n/sk/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=3; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2;
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 3,
+	'plural' => static fn (int $n): int => ($n === 1 ? 0 : ($n >= 2 && $n <= 4 ? 1 : 2)),
+);

+ 2 - 0
app/i18n/sk/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (prednastavené)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Vymazať vyrovnáciu pamäť',
 			'clear_cache_help' => 'Vymazať vyrovnáciu pamäť pre tento kanál.',

+ 27 - 0
app/i18n/tr/gen.php

@@ -140,6 +140,33 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'FreshRSS Hakkında',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d gün once',
+			1 => '%d gün once',
+		),
+		'hour' => array(
+			0 => '%d saat once',
+			1 => '%d saat once',
+		),
+		'justnow' => 'az once',
+		'minute' => array(
+			0 => '%d dakika once',
+			1 => '%d dakika once',
+		),
+		'month' => array(
+			0 => '%d ay once',
+			1 => '%d ay once',
+		),
+		'second' => array(
+			0 => '%d saniye once',
+			1 => '%d saniye once',
+		),
+		'year' => array(
+			0 => '%d yıl once',
+			1 => '%d yıl once',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Boş kategori',
 		'confirm_action' => 'Bu eylemi gerçekleştirmek istediğinizden emin misiniz? Bu işlem geri alınamaz!',

+ 10 - 0
app/i18n/tr/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=2; plural=(n > 1);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 2,
+	'plural' => static fn (int $n): int => (($n > 1) ? 1 : 0),
+);

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

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (varsayılan)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Önbelleği temizle',
 			'clear_cache_help' => 'Bu besleme için önbelleği temizle.',

+ 33 - 0
app/i18n/uk/gen.php

@@ -140,6 +140,39 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => 'Про FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d день тому',
+			1 => '%d дні тому',
+			2 => '%d днів тому',
+		),
+		'hour' => array(
+			0 => '%d година тому',
+			1 => '%d години тому',
+			2 => '%d годин тому',
+		),
+		'justnow' => 'щойно',
+		'minute' => array(
+			0 => '%d хвилина тому',
+			1 => '%d хвилини тому',
+			2 => '%d хвилин тому',
+		),
+		'month' => array(
+			0 => '%d місяць тому',
+			1 => '%d місяці тому',
+			2 => '%d місяців тому',
+		),
+		'second' => array(
+			0 => '%d секунда тому',
+			1 => '%d секунди тому',
+			2 => '%d секунд тому',
+		),
+		'year' => array(
+			0 => '%d рік тому',
+			1 => '%d роки тому',
+			2 => '%d років тому',
+		),
+	),
 	'js' => array(
 		'category_empty' => 'Порожня категорія',
 		'confirm_action' => 'Точно виконати цю дію? Її неможливо скасувати!',

+ 10 - 0
app/i18n/uk/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 3,
+	'plural' => static fn (int $n): int => ($n % 10 === 1 && $n % 100 !== 11 ? 0 : ($n % 10 >= 2 && $n % 10 <= 4 && ($n % 100 < 10 || $n % 100 >= 20) ? 1 : 2)),
+);

+ 2 - 0
app/i18n/uk/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS/Atom (типово)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => 'Очистити кеш',
 			'clear_cache_help' => 'Спорожнити кеш стрічки.',

+ 21 - 0
app/i18n/zh-CN/gen.php

@@ -140,6 +140,27 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => '关于 FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d天前',
+		),
+		'hour' => array(
+			0 => '%d小时前',
+		),
+		'justnow' => '刚刚',
+		'minute' => array(
+			0 => '%d分钟前',
+		),
+		'month' => array(
+			0 => '%d个月前',
+		),
+		'second' => array(
+			0 => '%d秒前',
+		),
+		'year' => array(
+			0 => '%d年前',
+		),
+	),
 	'js' => array(
 		'category_empty' => '清空分类',
 		'confirm_action' => '你确定要执行此操作吗?这将不可撤销!',

+ 10 - 0
app/i18n/zh-CN/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=1; plural=0;
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 1,
+	'plural' => static fn (int $n): int => 0,
+);

+ 2 - 0
app/i18n/zh-CN/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (默认)',
 			'xml_xpath' => 'XML + XPath',	// IGNORE
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => '清理缓存',
 			'clear_cache_help' => '清除该feed的缓存',

+ 21 - 0
app/i18n/zh-TW/gen.php

@@ -140,6 +140,27 @@ return array(
 		'_' => 'FreshRSS',	// IGNORE
 		'about' => '關於 FreshRSS',
 	),
+	'interval' => array(
+		'day' => array(
+			0 => '%d天前',
+		),
+		'hour' => array(
+			0 => '%d小時前',
+		),
+		'justnow' => '剛剛',
+		'minute' => array(
+			0 => '%d分鐘前',
+		),
+		'month' => array(
+			0 => '%d個月前',
+		),
+		'second' => array(
+			0 => '%d秒前',
+		),
+		'year' => array(
+			0 => '%d年前',
+		),
+	),
 	'js' => array(
 		'category_empty' => '清空分類',
 		'confirm_action' => '你確定要執行此操作嗎?這將不可撤銷!',

+ 10 - 0
app/i18n/zh-TW/plurals.php

@@ -0,0 +1,10 @@
+<?php
+
+// Plural-Forms: nplurals=1; plural=0;
+// This file is generated by cli/compile.plurals.php.
+// Edit the formula comment and run `make fix-all`.
+
+return array(
+	'nplurals' => 1,
+	'plural' => static fn (int $n): int => 0,
+);

+ 2 - 0
app/i18n/zh-TW/sub.php

@@ -185,6 +185,8 @@ return array(
 			'rss' => 'RSS / Atom (預設)',
 			'xml_xpath' => 'XML + XPath',	// TODO
 		),
+		'last-error-date' => 'Last erroneous update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
+		'last-update' => 'Last successful update <time datetime="%1$s" title="%1$s">%2$s</time>.',	// TODO
 		'maintenance' => array(
 			'clear_cache' => '清理暫存',
 			'clear_cache_help' => '清除該feed的暫存',

+ 18 - 6
app/views/helpers/feed/update.phtml

@@ -14,13 +14,25 @@
 		<a href="<?= _url('stats', 'repartition', 'id', $this->feed->id()) ?>"><?= _i('stats') ?> <?= _t('sub.feed.stats') ?></a>
 	</div>
 
-	<?php $nbEntries = $this->feed->nbEntries(); ?>
+	<?php if ($this->feed->inError()): ?>
+		<p class="alert alert-error">
+			<span class="alert-head"><?= _t('gen.short.damn') ?></span>
+			<?= _t('sub.feed.error') ?><br />
+			<?= _t('sub.feed.last-update', timestampToMachineDate($this->feed->lastUpdate()), timeago($this->feed->lastUpdate())) ?>
+			<?php if ($this->feed->lastError() > 1) { ?><br />
+				<?= _t('sub.feed.last-error-date', timestampToMachineDate($this->feed->lastError()), timeago($this->feed->lastError())) ?>
+			<?php } ?>
+		</p>
+	<?php else: ?>
+		<p class="alert alert-success">
+			<?= _t('sub.feed.last-update', timestampToMachineDate($this->feed->lastUpdate()), timeago($this->feed->lastUpdate())) ?>
+		</p>
+	<?php endif; ?>
 
-	<?php if ($this->feed->inError()) { ?>
-	<p class="alert alert-error"><span class="alert-head"><?= _t('gen.short.damn') ?></span> <?= _t('sub.feed.error') ?></p>
-	<?php } elseif ($nbEntries === 0) { ?>
-	<p class="alert alert-warn"><?= _t('sub.feed.empty') ?></p>
-	<?php } ?>
+	<?php $nbEntries = $this->feed->nbEntries(); ?>
+	<?php if ($nbEntries === 0): ?>
+		<p class="alert alert-warn"><?= _t('sub.feed.empty') ?></p>
+	<?php endif; ?>
 
 	<?php
 	$from = Minz_Request::paramString('from');

+ 7 - 0
cli/README.md

@@ -151,6 +151,13 @@ cd /usr/share/FreshRSS
 # -r, --revert revert the action (only used with ignore action).
 # -o, --origin-language selects the origin language (only used with add language action).
 
+./cli/compile.plurals.php [ --all --file app/i18n/en/plurals.php --formula 'nplurals=2; plural=(n != 1);' ]
+# Compile gettext plural formulas into PHP callables for runtime use.
+# Plural source files are driven by a leading comment such as:
+#   // Plural-Forms: nplurals=2; plural=(n != 1);
+# Run this command, or `make fix-all`, after editing those comments.
+# See examples: https://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html
+
 ./cli/check.translation.php [ ---display-result --help --language fr --display-report --generate-readme ]
 # Check if translation files have missing keys or missing translations.
 # -d, --display-result display results of check.

+ 57 - 5
cli/check.translation.php

@@ -49,7 +49,13 @@ $percentage = [];
 
 foreach ($languages as $language) {
 	if ($language === $i18nData::REFERENCE_LANGUAGE) {
-		$i18nValidator = new I18nUsageValidator($i18nData->getReferenceLanguage(), findUsedTranslations());
+		$usedTranslations = findUsedTranslations();
+		$referenceLanguage = $i18nData->getReferenceLanguage();
+		$pluralFamilies = loadPluralReferenceFamilies($referenceLanguage);
+		if ($pluralFamilies !== []) {
+			$referenceLanguage['plurals.php'] = $pluralFamilies;
+		}
+		$i18nValidator = new I18nUsageValidator($referenceLanguage, $usedTranslations['keys'], $usedTranslations['prefixes']);
 	} else {
 		$i18nValidator = new I18nCompletionValidator($i18nData->getReferenceLanguage(), $i18nData->getLanguage($language));
 	}
@@ -150,13 +156,14 @@ if (!$isValidated) {
  * Iterates through all php and phtml files in the whole project and extracts all
  * translation keys used.
  *
- * @return list<string>
+ * @return array{keys:list<string>,prefixes:list<string>}
  */
 function findUsedTranslations(): array {
-	$directory = new RecursiveDirectoryIterator(__DIR__ . '/..');
-	$iterator = new RecursiveIteratorIterator($directory);
+	$directory = new RecursiveDirectoryIterator(__DIR__ . '/..', FilesystemIterator::SKIP_DOTS);
+	$iterator = new RecursiveIteratorIterator($directory, RecursiveIteratorIterator::LEAVES_ONLY, RecursiveIteratorIterator::CATCH_GET_CHILD);
 	$regex = new RegexIterator($iterator, '/^.+\.(php|phtml)$/i', RecursiveRegexIterator::GET_MATCH);
 	$usedI18n = [];
+	$usedPrefixes = [];
 	foreach ($regex as $file => $value) {
 		if (!is_string($file) || $file === '') {
 			continue;
@@ -167,8 +174,53 @@ function findUsedTranslations(): array {
 		}
 		preg_match_all('/_t\([\'"](?P<strings>[^\'"]+)[\'"]/', $fileContent, $matches);
 		$usedI18n = array_merge($usedI18n, $matches['strings']);
+		preg_match_all('/Minz_Translate::plural\(\s*[\'"](?P<string>[^\'"]+)[\'"](?P<dynamic>\s*\.)?/', $fileContent, $pluralMatches, PREG_SET_ORDER);
+		foreach ($pluralMatches as $match) {
+			$string = $match['string'];
+			if (($match['dynamic'] ?? '') !== '') {
+				$usedPrefixes[] = $string;
+			} else {
+				$usedI18n[] = $string;
+			}
+		}
 	}
-	return $usedI18n;
+	return [
+		'keys' => array_values(array_unique($usedI18n)),
+		'prefixes' => array_values(array_unique($usedPrefixes)),
+	];
+}
+
+/**
+ * @param array<string,array<string,I18nValue>> $referenceLanguage
+ * @return array<string,I18nValue>
+ */
+function loadPluralReferenceFamilies(array $referenceLanguage): array {
+	$pluralFamilies = [];
+	foreach ($referenceLanguage as $values) {
+		foreach ($values as $key => $value) {
+			if (preg_match('/^(?P<base>.+)\.(?P<index>\d+)$/', $key, $matches) !== 1) {
+				continue;
+			}
+			$baseKey = $matches['base'];
+			$index = $matches['index'];
+			$pluralFamilies[$baseKey][(int)$index] = $value->__toString();
+		}
+	}
+
+	$normalisedFamilies = [];
+	foreach ($pluralFamilies as $baseKey => $messageFamily) {
+		$messages = [];
+		ksort($messageFamily);
+		foreach ($messageFamily as $message) {
+			if ($message !== '') {
+				$messages[] = $message;
+			}
+		}
+
+		$normalisedFamilies[$baseKey] = new I18nValue(implode(' | ', $messages));
+	}
+
+	return $normalisedFamilies;
 }
 
 /**

+ 74 - 0
cli/compile.plurals.php

@@ -0,0 +1,74 @@
+#!/usr/bin/env php
+<?php
+declare(strict_types=1);
+require_once __DIR__ . '/_cli.php';
+require_once __DIR__ . '/i18n/PluralFormsCompiler.php';
+
+$cliOptions = new class extends CliOptionsParser {
+	public bool $all;
+	public string $file;
+	public string $formula;
+	public bool $help;
+
+	public function __construct() {
+		$this->addOption('all', (new CliOption('all', 'a'))->withValueNone());
+		$this->addOption('file', new CliOption('file', 'f'));
+		$this->addOption('formula', new CliOption('formula', 'p'));
+		$this->addOption('help', (new CliOption('help', 'h'))->withValueNone());
+		parent::__construct();
+	}
+};
+
+if (!empty($cliOptions->errors)) {
+	fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
+}
+if ($cliOptions->help || (!isset($cliOptions->formula) && !isset($cliOptions->file) && !$cliOptions->all)) {
+	compilePluralsHelp();
+}
+
+$compiler = new PluralFormsCompiler();
+
+if (isset($cliOptions->formula)) {
+	echo $compiler->compileFormulaToLambda($cliOptions->formula) . "\n";
+	done();
+}
+
+if (isset($cliOptions->file)) {
+	$compiler->compileFile($cliOptions->file);
+	echo 'Compiled ' . $cliOptions->file . "\n";
+	done();
+}
+
+$changed = $compiler->compileAll();
+echo 'Compiled ' . $changed . " plural file(s).\n";
+done();
+
+function compilePluralsHelp(): never {
+	$file = str_replace(__DIR__ . '/', '', __FILE__);
+
+	echo <<<HELP
+NAME
+	$file
+
+SYNOPSIS
+	php $file [ --all | --file=<path> | --formula='<plural-forms>' ]
+
+DESCRIPTION
+	Compile gettext plural formulas into PHP callables for runtime consumption.
+
+	-a, --all		compile all app/i18n/*/plurals.php files in place.
+	-f, --file=FILE		compile a single plural file in place.
+	-p, --formula=FORMULA	output the compiled PHP lambda for a gettext plural formula.
+	-h, --help		display this help and exit.
+
+EXAMPLES
+	php $file --formula 'nplurals=2; plural=(n != 1);'
+	php $file --file app/i18n/en/plurals.php
+	php $file --all
+
+REFERENCES
+	https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html
+	https://docs.translatehouse.org/projects/localization-guide/en/latest/l10n/pluralforms.html
+HELP, PHP_EOL;
+	exit();
+}

+ 95 - 6
cli/i18n/I18nCompletionValidator.php

@@ -19,6 +19,24 @@ class I18nCompletionValidator implements I18nValidatorInterface {
 	) {
 	}
 
+	private static function isPluralVariantKey(string $key): bool {
+		return preg_match('/\.\d+$/', $key) === 1;
+	}
+
+	/**
+	 * @return array{base:string,index:int}|null
+	 */
+	private static function parsePluralVariantKey(string $key): ?array {
+		if (preg_match('/^(?P<base>.+)\.(?P<index>\d+)$/', $key, $matches) !== 1) {
+			return null;
+		}
+
+		return [
+			'base' => $matches['base'],
+			'index' => (int)$matches['index'],
+		];
+	}
+
 	#[\Override]
 	public function displayReport(bool $percentage_only = false): string {
 		if ($this->passEntries > $this->totalEntries) {
@@ -43,26 +61,97 @@ class I18nCompletionValidator implements I18nValidatorInterface {
 	public function validate(): bool {
 		foreach ($this->reference as $file => $data) {
 			foreach ($data as $refKey => $refValue) {
+				if (!$this->pluralVariantAppliesToLanguage($file, $refKey)) {
+					continue;
+				}
+
 				$this->totalEntries++;
 				if (!array_key_exists($file, $this->language) || !array_key_exists($refKey, $this->language[$file])) {
 					$this->result .= "Missing key $refKey" . PHP_EOL;
 					continue;
 				}
 
-				$value = $this->language[$file][$refKey];
-				if ($value->isIgnore()) {
-					$this->passEntries++;
+				$this->validateValue($refKey, $refValue, $this->language[$file][$refKey]);
+			}
+		}
+
+		foreach ($this->language as $file => $data) {
+			$referenceValues = $this->reference[$file] ?? [];
+			foreach ($data as $key => $value) {
+				if (!self::isPluralVariantKey($key) || array_key_exists($key, $referenceValues)) {
 					continue;
 				}
-				if ($refValue->equal($value)) {
-					$this->result .= "Untranslated key $refKey - $refValue" . PHP_EOL;
+
+				$referenceValue = $this->referenceValueForKey($referenceValues, $key);
+				if ($referenceValue === null) {
 					continue;
 				}
-				$this->passEntries++;
+
+				$this->totalEntries++;
+				$this->validateValue($key, $referenceValue, $value);
 			}
 		}
 
 		return $this->totalEntries === $this->passEntries;
 	}
 
+	/**
+	 * @param array<string,I18nValue> $referenceValues
+	 */
+	private function referenceValueForKey(array $referenceValues, string $key): ?I18nValue {
+		if (array_key_exists($key, $referenceValues)) {
+			return $referenceValues[$key];
+		}
+
+		$parsedKey = self::parsePluralVariantKey($key);
+		if ($parsedKey === null) {
+			return null;
+		}
+
+		$pluralKey = $parsedKey['base'] . '.1';
+		if (array_key_exists($pluralKey, $referenceValues)) {
+			return $referenceValues[$pluralKey];
+		}
+
+		$singularKey = $parsedKey['base'] . '.0';
+		return $referenceValues[$singularKey] ?? null;
+	}
+
+	private function validateValue(string $key, I18nValue $referenceValue, I18nValue $value): void {
+		if ($value->isIgnore()) {
+			$this->passEntries++;
+			return;
+		}
+
+		if ($referenceValue->equal($value)) {
+			$this->result .= "Untranslated key $key - $referenceValue" . PHP_EOL;
+			return;
+		}
+
+		$this->passEntries++;
+	}
+
+	private function pluralVariantAppliesToLanguage(string $file, string $key): bool {
+		$parsedKey = self::parsePluralVariantKey($key);
+		if ($parsedKey === null) {
+			return true;
+		}
+
+		$indexes = [];
+		foreach ($this->language[$file] ?? [] as $languageKey => $value) {
+			$parsedLanguageKey = self::parsePluralVariantKey($languageKey);
+			if ($parsedLanguageKey === null || $parsedLanguageKey['base'] !== $parsedKey['base']) {
+				continue;
+			}
+
+			$indexes[$parsedLanguageKey['index']] = true;
+		}
+
+		if ($indexes === []) {
+			return true;
+		}
+
+		return array_key_exists($parsedKey['index'], $indexes);
+	}
+
 }

+ 167 - 4
cli/i18n/I18nData.php

@@ -9,10 +9,29 @@ class I18nData {
 	/** @param array<string,array<string,array<string,I18nValue>>> $data */
 	public function __construct(private array $data) {
 		$this->addMissingKeysFromReference();
+		$this->addMissingPluralVariantsFromReference();
 		$this->removeExtraKeysFromOtherLanguages();
 		$this->processValueStates();
 	}
 
+	private static function isPluralVariantKey(string $key): bool {
+		return self::parsePluralVariantKey($key) !== null;
+	}
+
+	/**
+	 * @return array{base:string,index:int}|null
+	 */
+	private static function parsePluralVariantKey(string $key): ?array {
+		if (preg_match('/^(?P<base>.+)\.(?P<index>\d+)$/', $key, $matches) !== 1) {
+			return null;
+		}
+
+		return [
+			'base' => $matches['base'],
+			'index' => (int)$matches['index'],
+		];
+	}
+
 	/**
 	 * @return array<string,array<string,array<string,I18nValue>>>
 	 */
@@ -26,6 +45,9 @@ class I18nData {
 
 		foreach ($reference as $file => $refValues) {
 			foreach ($refValues as $key => $refValue) {
+				if (self::isPluralVariantKey($key)) {
+					continue;
+				}
 				foreach ($languages as $language) {
 					if (!array_key_exists($file, $this->data[$language]) || !array_key_exists($key, $this->data[$language][$file])) {
 						$this->data[$language][$file][$key] = clone $refValue;
@@ -39,11 +61,52 @@ class I18nData {
 		}
 	}
 
+	private function addMissingPluralVariantsFromReference(): void {
+		$reference = $this->getReferenceLanguage();
+
+		foreach ($this->getNonReferenceLanguages() as $language) {
+			$expectedIndexes = $this->pluralVariantIndexesForLanguage($language);
+			foreach ($reference as $file => $refValues) {
+				$pluralBases = [];
+				foreach ($refValues as $key => $refValue) {
+					$parsedKey = self::parsePluralVariantKey($key);
+					if ($parsedKey === null) {
+						continue;
+					}
+					$pluralBases[$parsedKey['base']] = true;
+				}
+
+				if (!array_key_exists($file, $this->data[$language])) {
+					$this->data[$language][$file] = [];
+				}
+
+				foreach (array_keys($pluralBases) as $pluralBase) {
+					foreach ($expectedIndexes as $index) {
+						$pluralKey = $pluralBase . '.' . $index;
+						if (array_key_exists($pluralKey, $this->data[$language][$file])) {
+							continue;
+						}
+
+						$referenceValue = $this->referenceValueForKey($refValues, $pluralKey);
+						if ($referenceValue === null) {
+							continue;
+						}
+
+						$this->data[$language][$file][$pluralKey] = clone $referenceValue;
+					}
+				}
+			}
+		}
+	}
+
 	private function removeExtraKeysFromOtherLanguages(): void {
 		$reference = $this->getReferenceLanguage();
 		foreach ($this->getNonReferenceLanguages() as $language) {
 			foreach ($this->getLanguage($language) as $file => $values) {
 				foreach ($values as $key => $value) {
+					if (self::isPluralVariantKey($key)) {
+						continue;
+					}
 					if (!array_key_exists($key, $reference[$file])) {
 						unset($this->data[$language][$file][$key]);
 					}
@@ -59,20 +122,120 @@ class I18nData {
 		foreach ($reference as $file => $refValues) {
 			foreach ($refValues as $key => $refValue) {
 				foreach ($languages as $language) {
+					if (!$this->pluralVariantAppliesToLanguage($language, $key)) {
+						continue;
+					}
+
 					$value = $this->data[$language][$file][$key];
-					if ($refValue->equal($value) && !$value->isIgnore()) {
-						$value->markAsTodo();
+					$this->syncValueState($refValue, $value);
+				}
+			}
+		}
+
+		foreach ($languages as $language) {
+			foreach ($this->getLanguage($language) as $file => $values) {
+				$referenceValues = $reference[$file] ?? [];
+				foreach ($values as $key => $value) {
+					if (!self::isPluralVariantKey($key) || array_key_exists($key, $referenceValues)) {
 						continue;
 					}
-					if (!$refValue->equal($value) && $value->isTodo()) {
-						$value->markAsDirty();
+
+					$referenceValue = $this->referenceValueForKey($referenceValues, $key);
+					if ($referenceValue === null) {
 						continue;
 					}
+
+					$this->syncValueState($referenceValue, $value);
 				}
 			}
 		}
 	}
 
+	private function syncValueState(I18nValue $referenceValue, I18nValue $value): void {
+		if ($referenceValue->equal($value) && !$value->isIgnore()) {
+			$value->markAsTodo();
+			return;
+		}
+
+		if (!$referenceValue->equal($value) && $value->isTodo()) {
+			$value->markAsDirty();
+		}
+	}
+
+	private function pluralVariantAppliesToLanguage(string $language, string $key): bool {
+		$parsedKey = self::parsePluralVariantKey($key);
+		if ($parsedKey === null) {
+			return true;
+		}
+
+		return in_array($parsedKey['index'], $this->pluralVariantIndexesForLanguage($language), true);
+	}
+
+	/**
+	 * @param array<string,I18nValue> $referenceValues
+	 */
+	private function referenceValueForKey(array $referenceValues, string $key): ?I18nValue {
+		if (array_key_exists($key, $referenceValues)) {
+			return $referenceValues[$key];
+		}
+
+		$parsedKey = self::parsePluralVariantKey($key);
+		if ($parsedKey === null) {
+			return null;
+		}
+
+		$pluralKey = $parsedKey['base'] . '.1';
+		if (array_key_exists($pluralKey, $referenceValues)) {
+			return $referenceValues[$pluralKey];
+		}
+
+		$singularKey = $parsedKey['base'] . '.0';
+		return $referenceValues[$singularKey] ?? null;
+	}
+
+	/**
+	 * @return list<int>
+	 */
+	private function pluralVariantIndexesForLanguage(string $language): array {
+		$pluralCount = $this->pluralCountForLanguage($language);
+		if ($pluralCount !== null) {
+			return range(0, $pluralCount - 1);
+		}
+
+		$indexes = [];
+		foreach ($this->data[$language] as $values) {
+			foreach (array_keys($values) as $key) {
+				$parsedKey = self::parsePluralVariantKey($key);
+				if ($parsedKey === null) {
+					continue;
+				}
+				$indexes[$parsedKey['index']] = true;
+			}
+		}
+
+		if ($indexes === []) {
+			return [0];
+		}
+
+		ksort($indexes, SORT_NUMERIC);
+		return array_map('intval', array_keys($indexes));
+	}
+
+	private function pluralCountForLanguage(string $language): ?int {
+		if (!defined('I18N_PATH')) {
+			return null;
+		}
+
+		$pluralFile = I18N_PATH . '/' . $language . '/plurals.php';
+		if (!is_file($pluralFile)) {
+			return null;
+		}
+
+		$pluralData = include $pluralFile;
+		$pluralCount = is_array($pluralData) ? ($pluralData['nplurals'] ?? null) : null;
+		return is_int($pluralCount) && $pluralCount > 0 ? $pluralCount : null;
+	}
+
 	/**
 	 * Return the available languages
 	 * @return list<string>

+ 24 - 10
cli/i18n/I18nFile.php

@@ -7,13 +7,10 @@ class I18nFile {
 
 	/**
 	 * @param array<mixed,mixed> $array
-	 * @phpstan-assert-if-true array<string,string|array<string,mixed>> $array
+	 * @phpstan-assert-if-true array<int|string,string|array<mixed>> $array
 	 */
 	public static function is_array_recursive_string(array $array): bool {
-		foreach ($array as $key => $value) {
-			if (!is_string($key)) {
-				return false;
-			}
+		foreach ($array as $value) {
 			if (!is_string($value) && !(is_array($value) && self::is_array_recursive_string($value))) {
 				return false;
 			}
@@ -36,6 +33,9 @@ class I18nFile {
 				if (!$file->isFile()) {
 					continue;
 				}
+				if ($file->getFilename() === 'plurals.php') {
+					continue;
+				}
 
 				$i18n[$dir->getFilename()][$file->getFilename()] = $this->flatten($this->process($file->getPathname()), $file->getBasename('.php'));
 			}
@@ -62,7 +62,7 @@ class I18nFile {
 
 	/**
 	 * Process the content of an i18n file
-	 * @return array<string,string|array<string,mixed>>
+	 * @return array<int|string,string|array<mixed>>
 	 */
 	private function process(string $filename): array {
 		$fileContent = file_get_contents($filename);
@@ -101,7 +101,7 @@ class I18nFile {
 	/**
 	 * Flatten an array of translation
 	 *
-	 * @param array<string,I18nValue|string|array<string,I18nValue>|mixed> $translation
+	 * @param array<int|string,I18nValue|string|array<mixed>|mixed> $translation
 	 * @return array<string,I18nValue>
 	 */
 	private function flatten(array $translation, string $prefix = ''): array {
@@ -112,7 +112,8 @@ class I18nFile {
 		}
 
 		foreach ($translation as $key => $value) {
-			if (is_array($value) && is_array_keys_string($value)) {
+			$key = (string)$key;
+			if (is_array($value) && self::is_array_recursive_string($value)) {
 				$a += $this->flatten($value, $prefix . $key);
 			} elseif (is_string($value) || $value instanceof I18nValue) {
 				$a[$prefix . $key] = new I18nValue($value);
@@ -129,7 +130,7 @@ class I18nFile {
 	 * no use of it.
 	 *
 	 * @param array<string,I18nValue> $translation
-	 * @return array<string,array<string,I18nValue>>
+	 * @return array<int|string,mixed>
 	 */
 	private function unflatten(array $translation): array {
 		$a = [];
@@ -138,7 +139,20 @@ class I18nFile {
 		foreach ($translation as $compoundKey => $value) {
 			$keys = explode('.', $compoundKey);
 			array_shift($keys);
-			eval("\$a['" . implode("']['", $keys) . "'] = '" . addcslashes($value->__toString(), "'") . "';");
+			$current =& $a;
+			$lastIndex = count($keys) - 1;
+			foreach ($keys as $index => $key) {
+				$normalisedKey = ctype_digit($key) ? (int)$key : $key;
+				if ($index === $lastIndex) {
+					$current[$normalisedKey] = $value->__toString();
+					continue;
+				}
+				if (!isset($current[$normalisedKey]) || !is_array($current[$normalisedKey])) {
+					$current[$normalisedKey] = [];
+				}
+				$current =& $current[$normalisedKey];
+			}
+			unset($current);
 		}
 
 		return $a;

+ 21 - 4
cli/i18n/I18nUsageValidator.php

@@ -12,13 +12,33 @@ class I18nUsageValidator implements I18nValidatorInterface {
 	/**
 	 * @param array<string,array<string,I18nValue>> $reference
 	 * @param array<string> $code
+	 * @param array<string> $codePrefixes
 	 */
 	public function __construct(
 		private readonly array $reference,
 		private readonly array $code,
+		private readonly array $codePrefixes = [],
 	) {
 	}
 
+	private function isUsed(string $key): bool {
+		if (preg_match('/\._$/', $key) === 1 && in_array(preg_replace('/\._$/', '', $key), $this->code, true)) {
+			return true;
+		}
+
+		if (in_array($key, $this->code, true)) {
+			return true;
+		}
+
+		foreach ($this->codePrefixes as $prefix) {
+			if (str_starts_with($key, $prefix)) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
 	#[\Override]
 	public function displayReport(bool $percentage_only = false): string {
 		if ($this->failedEntries > $this->totalEntries) {
@@ -43,10 +63,7 @@ class I18nUsageValidator implements I18nValidatorInterface {
 		foreach ($this->reference as $file => $data) {
 			foreach ($data as $key => $value) {
 				$this->totalEntries++;
-				if (preg_match('/\._$/', $key) === 1 && in_array(preg_replace('/\._$/', '', $key), $this->code, true)) {
-					continue;
-				}
-				if (!in_array($key, $this->code, true)) {
+				if (!$this->isUsed($key)) {
 					$this->result .= sprintf('Unused key %s - %s', $key, $value) . PHP_EOL;
 					$this->failedEntries++;
 					continue;

+ 294 - 0
cli/i18n/PluralFormsCompiler.php

@@ -0,0 +1,294 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * Plural form inspired by GNU gettext plural forms, converted into PHP lambdas.
+ * https://www.gnu.org/software/gettext/manual/html_node/Plural-forms.html
+ */
+final class PluralFormsCompiler {
+	private const FORMULA_PATTERN = '/^\s*nplurals\s*=\s*(\d+)\s*;\s*plural\s*=\s*(.+?)\s*;\s*$/';
+
+	private const COMMENT_PATTERN = '/^\s*\/\/\s*Plural-Forms:\s*(?P<formula>.+?)\s*$/mi';
+
+	private const COMMENT_PREFIX_PATTERN = '/^\s*\/\/\s*/';
+
+	private const ALLOWED_EXPRESSION_PATTERN = '/^[0-9n\s!<>=&|?:()%+*\/-]+$/';
+
+	/**
+	 * @return array{formula:string,nplurals:int,lambda:string}
+	 */
+	public function compileFormula(string $pluralForms): array {
+		['formula' => $formula, 'nplurals' => $pluralCount, 'expression' => $expression] =
+			$this->parsePluralHeader($pluralForms);
+		$this->validatePluralExpression($expression, $formula);
+
+		$lambdaExpression = $pluralCount === 2 && !str_contains($expression, '?')
+			? '((' . $this->transpileLeafExpression($expression) . ') ? 1 : 0)'
+			: $this->transpileExpression($expression);
+
+		return [
+			'formula' => $formula,
+			'nplurals' => $pluralCount,
+			'lambda' => 'static fn (int $n): int => ' . $lambdaExpression,
+		];
+	}
+
+	public function compileFormulaToLambda(string $pluralForms): string {
+		return $this->compileFormula($pluralForms)['lambda'];
+	}
+
+	public function compileFile(string $filePath, bool $throwOnError = true): bool {
+		try {
+			if (!is_file($filePath)) {
+				throw new InvalidArgumentException('Plural file not found: ' . $filePath);
+			}
+
+			$compiled = $this->compileFormula($this->extractPluralFormsFromFile($filePath));
+			$newContent = $this->renderCompiledFile($compiled);
+			$currentContent = file_get_contents($filePath);
+			if (!is_string($currentContent)) {
+				throw new RuntimeException('Unable to read plural file: ' . $filePath);
+			}
+
+			if ($currentContent === $newContent) {
+				return false;
+			}
+
+			if (file_put_contents($filePath, $newContent) === false) {
+				throw new RuntimeException('Unable to write plural file: ' . $filePath);
+			}
+		} catch (Throwable $e) {
+			if ($throwOnError) {
+				throw $e;
+			}
+			$message = 'Error compiling plural file `' . $filePath . '`: ' . $e->getMessage() . "\n";
+			if (defined('STDERR')) {
+				fwrite(STDERR, $message);
+			} else {
+				echo $message;
+			}
+			return false;
+		}
+
+		return true;
+	}
+
+	public function compileAll(string $globPattern = I18N_PATH . '/*/plurals.php'): int {
+		$files = glob($globPattern) ?: [];
+		sort($files, SORT_NATURAL);
+
+		$changed = 0;
+		foreach ($files as $filePath) {
+			if ($this->compileFile($filePath, throwOnError: false)) {
+				$changed++;
+			}
+		}
+
+		return $changed;
+	}
+
+	private function extractPluralFormsFromFile(string $filePath): string {
+		$fileContent = file_get_contents($filePath);
+		if (!is_string($fileContent)) {
+			throw new RuntimeException('Unable to read plural file: ' . $filePath);
+		}
+
+		if (preg_match(self::COMMENT_PATTERN, $fileContent, $matches) === 1) {
+			return $this->normalisePluralForms($matches['formula']);
+		}
+
+		return $this->extractGetTextPluralFormsFromFile($filePath);
+	}
+
+	/**
+	 * @param array{formula:string,nplurals:int,lambda:string} $compiled
+	 */
+	private function renderCompiledFile(array $compiled): string {
+		return <<<PHP
+			<?php
+
+			// Plural-Forms: {$compiled['formula']}
+			// This file is generated by cli/compile.plurals.php.
+			// Edit the formula comment and run `make fix-all`.
+
+			return array(
+				'nplurals' => {$compiled['nplurals']},
+				'plural' => {$compiled['lambda']},
+			);
+
+			PHP;
+	}
+
+	/**
+	 * @return array{formula:string,nplurals:int,expression:string}
+	 */
+	private function parsePluralHeader(string $pluralForms): array {
+		$formula = $this->normalisePluralForms($pluralForms);
+		if (!preg_match(self::FORMULA_PATTERN, $formula, $matches)) {
+			throw new InvalidArgumentException('Invalid plural formula: ' . $formula);
+		}
+
+		return [
+			'formula' => $formula,
+			'nplurals' => max(1, (int)$matches[1]),
+			'expression' => $matches[2],
+		];
+	}
+
+	private function normalisePluralForms(string $pluralForms): string {
+		$pluralForms = trim($pluralForms);
+		$pluralForms = preg_replace(self::COMMENT_PREFIX_PATTERN, '', $pluralForms) ?? $pluralForms;
+		if (preg_match('/^\s*Plural-Forms:\s*(?P<formula>.+?)\s*$/i', $pluralForms, $matches) === 1) {
+			$pluralForms = $matches['formula'];
+		}
+
+		return trim($pluralForms);
+	}
+
+	private function extractGetTextPluralFormsFromFile(string $filePath): string {
+		$pluralData = include $filePath;
+		$pluralForms = is_array($pluralData) ? ($pluralData['plural-forms'] ?? null) : null;
+		if (!is_string($pluralForms) || $pluralForms === '') {
+			throw new RuntimeException('No plural formula found in `' . $filePath . '`');
+		}
+
+		return $this->normalisePluralForms($pluralForms);
+	}
+
+	/**
+	 * Lightweight validation only. The compiler transpiles real shipped formulas heuristically.
+	 */
+	private function validatePluralExpression(string $expression, string $pluralForms): void {
+		if (!preg_match(self::ALLOWED_EXPRESSION_PATTERN, $expression)) {
+			throw new RuntimeException('Unsupported token in plural expression `' . $pluralForms . '`');
+		}
+
+		$depth = 0;
+		$length = strlen($expression);
+		for ($index = 0; $index < $length; $index++) {
+			$character = $expression[$index];
+			if ($character === '(') {
+				$depth++;
+			} elseif ($character === ')') {
+				$depth--;
+				if ($depth < 0) {
+					throw new RuntimeException('Unbalanced parentheses in plural expression `' . $pluralForms . '`');
+				}
+			}
+		}
+
+		if ($depth !== 0) {
+			throw new RuntimeException('Unbalanced parentheses in plural expression `' . $pluralForms . '`');
+		}
+
+		if (substr_count($expression, '?') !== substr_count($expression, ':')) {
+			throw new RuntimeException('Unbalanced ternary operators in plural expression `' . $pluralForms . '`');
+		}
+
+		if (str_contains($expression, '/')) {
+			throw new RuntimeException('Operator `/` is not supported in plural expression `' . $pluralForms . '`');
+		}
+	}
+
+	private function transpileExpression(string $expression): string {
+		$expression = $this->stripOuterParentheses(trim($expression));
+		[$condition, $ifTrue, $ifFalse] = $this->splitTopLevelTernary($expression);
+		if ($condition === null) {
+			return $this->transpileLeafExpression($expression);
+		}
+
+		return '(' . $this->transpileLeafExpression($condition) . ' ? ' . $this->transpileExpression($ifTrue) . ' : '
+			. $this->transpileExpression($ifFalse) . ')';
+	}
+
+	private function transpileLeafExpression(string $expression): string {
+		$expression = $this->stripOuterParentheses(trim($expression));
+		// Convert gettext variable name to PHP variable syntax
+		$expression = preg_replace('/\bn\b/', '$n', $expression) ?? $expression;
+		// Enforce strict equality
+		$expression = preg_replace('/(?<![=!<>])==(?!=)/', '===', $expression) ?? $expression;
+		// Enforce strict inequality
+		$expression = preg_replace('/!=(?!=)/', '!==', $expression) ?? $expression;
+		// Normalise operator spacing
+		$expression = preg_replace('/\s*(===|!==|==|!=|<=|>=|\|\||&&|[%*+\-<>])\s*/', ' $1 ', $expression) ?? $expression;
+		// Collapse repeated whitespace
+		$expression = preg_replace('/\s+/', ' ', trim($expression)) ?? trim($expression);
+		return $expression;
+	}
+
+	/**
+	 * @return array{0:?string,1:string,2:string}
+	 */
+	private function splitTopLevelTernary(string $expression): array {
+		$questionPosition = null;
+		$depth = 0;
+		$ternaryDepth = 0;
+		$length = strlen($expression);
+
+		for ($index = 0; $index < $length; $index++) {
+			$character = $expression[$index];
+			if ($character === '(') {
+				$depth++;
+				continue;
+			}
+
+			if ($character === ')') {
+				$depth--;
+				continue;
+			}
+
+			if ($depth !== 0) {
+				continue;
+			}
+
+			if ($character === '?') {
+				$questionPosition ??= $index;
+				$ternaryDepth++;
+				continue;
+			}
+
+			if ($character === ':' && $questionPosition !== null) {
+				$ternaryDepth--;
+				if ($ternaryDepth === 0) {
+					return [
+						trim(substr($expression, 0, $questionPosition)),
+						trim(substr($expression, $questionPosition + 1, $index - $questionPosition - 1)),
+						trim(substr($expression, $index + 1)),
+					];
+				}
+			}
+		}
+
+		return [null, '', ''];
+	}
+
+	private function stripOuterParentheses(string $expression): string {
+		$expression = trim($expression);
+		while (str_starts_with($expression, '(') && str_ends_with($expression, ')')) {
+			$depth = 0;
+			$isWrapped = true;
+			$length = strlen($expression);
+			for ($index = 0; $index < $length; $index++) {
+				$character = $expression[$index];
+				if ($character === '(') {
+					$depth++;
+				} elseif ($character === ')') {
+					$depth--;
+				}
+
+				if ($depth === 0 && $index < $length - 1) {
+					$isWrapped = false;
+					break;
+				}
+			}
+
+			if (!$isWrapped) {
+				break;
+			}
+
+			$expression = trim(substr($expression, 1, -1));
+		}
+
+		return $expression;
+	}
+}

+ 2 - 1
composer.json

@@ -72,7 +72,8 @@
 		"phpstan": "phpstan analyse --memory-limit 512M .",
 		"phpstan-next": "phpstan analyse --memory-limit 512M -c phpstan-next.neon .",
 		"phpunit": "phpunit --bootstrap ./tests/bootstrap.php --display-notices --display-deprecations --display-phpunit-deprecations ./tests",
-		"translations": "cli/manipulate.translation.php --action format && cli/check.translation.php --generate-readme",
+		"compile-plurals": "php cli/compile.plurals.php --all",
+		"translations": "cli/manipulate.translation.php --action format && php cli/compile.plurals.php --all && cli/check.translation.php --generate-readme",
 		"test": [
 			"@php-lint",
 			"@phtml-lint",

+ 196 - 32
lib/Minz/Translate.php

@@ -30,12 +30,30 @@ class Minz_Translate {
 	 */
 	private static array $lang_files = [];
 
+	/**
+	 * Dedicated plural catalogue files registered for the current language.
+	 * @var array<int,array{path:string,use_formula:bool}>
+	 */
+	private static array $plural_files = [];
+
 	/**
 	 * $translates is a cache for i18n translation.
 	 * @var array<string,mixed>
 	 */
 	private static array $translates = [];
 
+	/**
+	 * Cache of normalised plural message families by i18n key.
+	 * @var array<string,array<int,string>>
+	 */
+	private static array $plural_message_families = [];
+
+	private static bool $plural_catalogue_loaded = false;
+
+	private static ?int $plural_count = null;
+
+	private static ?\Closure $plural_function = null;
+
 	/**
 	 * Init the translation object.
 	 * @param string $lang_name the lang to show.
@@ -43,7 +61,10 @@ class Minz_Translate {
 	public static function init(string $lang_name = ''): void {
 		self::$lang_name = $lang_name;
 		self::$lang_files = [];
+		self::$plural_files = [];
 		self::$translates = [];
+		self::$plural_message_families = [];
+		self::resetPluralCache();
 		self::registerPath(APP_PATH . '/i18n');
 		foreach (self::$path_list as $path) {
 			self::loadLang($path);
@@ -57,7 +78,10 @@ class Minz_Translate {
 	public static function reset(string $lang_name): void {
 		self::$lang_name = $lang_name;
 		self::$lang_files = [];
+		self::$plural_files = [];
 		self::$translates = [];
+		self::$plural_message_families = [];
+		self::resetPluralCache();
 		foreach (self::$path_list as $path) {
 			self::loadLang($path);
 		}
@@ -132,8 +156,10 @@ class Minz_Translate {
 	 * @param string $path the path containing i18n directories.
 	 */
 	private static function loadLang(string $path): void {
+		$selected_lang_path = $path . '/' . self::$lang_name;
 		$lang_path = $path . '/' . self::$lang_name;
-		if (self::$lang_name === '' || !is_dir($lang_path)) {
+		$uses_selected_language = self::$lang_name !== '' && is_dir($selected_lang_path);
+		if (!$uses_selected_language) {
 			// The lang path does not exist, fallback to English ('en')
 			$lang_path = $path . '/en';
 			if (!is_dir($lang_path)) {
@@ -146,11 +172,20 @@ class Minz_Translate {
 			scandir($lang_path) ?: [],
 			['..', '.']
 		));
+		self::$plural_message_families = [];
 
 		// Each file basename correspond to a top-level i18n key. For each of
 		// these keys we store the file pathname and mark translations must be
 		// reloaded (by setting $translates[$i18n_key] to null).
 		foreach ($list_i18n_files as $i18n_filename) {
+			if ($i18n_filename === 'plurals.php') {
+				self::$plural_files[] = [
+					'path' => $lang_path . '/' . $i18n_filename,
+					'use_formula' => $uses_selected_language || self::$lang_name === '',
+				];
+				self::resetPluralCache();
+				continue;
+			}
 			$i18n_key = basename($i18n_filename, '.php');
 			if (!isset(self::$lang_files[$i18n_key])) {
 				self::$lang_files[$i18n_key] = [];
@@ -198,6 +233,28 @@ class Minz_Translate {
 	 *         If no value is found, return the key itself.
 	 */
 	public static function t(string $key, ...$args): string {
+		$translation_value = self::resolveKey($key);
+		if ($translation_value === null) {
+			return $key;
+		}
+
+		if (!is_string($translation_value)) {
+			$translation_value = $translation_value['_'] ?? null;
+			if (!is_string($translation_value)) {
+				Minz_Log::debug($key . ' is not a valid key');
+				return $key;
+			}
+		}
+
+		// Get the facultative arguments to replace i18n variables.
+		return empty($args) ? $translation_value : vsprintf($translation_value, $args);
+	}
+
+	/**
+	 * Resolve a translation key to its raw string or array value.
+	 * @return array<mixed>|string|null
+	 */
+	private static function resolveKey(string $key): array|string|null {
 		$group = explode('.', $key);
 
 		if (count($group) < 2) {
@@ -207,50 +264,31 @@ class Minz_Translate {
 			$top_level = array_shift($group) ?? '';
 		}
 
-		// If $translates[$top_level] is null it means we have to load the
-		// corresponding files.
-		if (empty(self::$translates[$top_level])) {
+		if ((self::$translates[$top_level] ?? null) === null) {
 			$res = self::loadKey($top_level);
 			if (!$res) {
-				return $key;
+				return null;
 			}
 		}
 
-		// Go through the i18n keys to get the correct translation value.
-		$translates = self::$translates[$top_level];
-		if (!is_array($translates)) {
-			$translates = [];
+		$translationValue = self::$translates[$top_level] ?? null;
+		if (!is_array($translationValue)) {
+			return null;
 		}
-		$size_group = count($group);
-		$level_processed = 0;
-		$translation_value = $key;
+
 		foreach ($group as $i18n_level) {
-			if (!is_array($translates)) {
-				continue;	// Not needed. To help PHPStan
-			}
-			$level_processed++;
-			if (!isset($translates[$i18n_level])) {
+			if (!is_array($translationValue) || !array_key_exists($i18n_level, $translationValue)) {
 				Minz_Log::debug($key . ' is not a valid key');
-				return $key;
-			}
-
-			if ($level_processed < $size_group) {
-				$translates = $translates[$i18n_level];
-			} else {
-				$translation_value = $translates[$i18n_level];
+				return null;
 			}
+			$translationValue = $translationValue[$i18n_level];
 		}
 
-		if (!is_string($translation_value)) {
-			$translation_value = is_array($translation_value) ? ($translation_value['_'] ?? null) : null;
-			if (!is_string($translation_value)) {
-				Minz_Log::debug($key . ' is not a valid key');
-				return $key;
-			}
+		if (!is_array($translationValue) && !is_string($translationValue)) {
+			return null;
 		}
 
-		// Get the facultative arguments to replace i18n variables.
-		return empty($args) ? $translation_value : vsprintf($translation_value, $args);
+		return $translationValue;
 	}
 
 	/**
@@ -259,6 +297,132 @@ class Minz_Translate {
 	public static function language(): string {
 		return self::$lang_name;
 	}
+
+	/**
+	 * Reset all cached plural data.
+	 */
+	private static function resetPluralCache(): void {
+		self::$plural_catalogue_loaded = false;
+		self::$plural_count = null;
+		self::$plural_function = null;
+	}
+
+	/**
+	 * Load the plural catalogue for the current language.
+	 */
+	private static function loadPluralCatalogue(): void {
+		if (self::$plural_catalogue_loaded) {
+			return;
+		}
+
+		self::$plural_catalogue_loaded = true;
+		$fallbackPluralCount = null;
+		$fallbackPluralFunction = null;
+
+		foreach (self::$plural_files as $pluralFile) {
+			$pluralData = include $pluralFile['path'];
+			if (!is_array($pluralData)) {
+				Minz_Log::warning('`' . $pluralFile['path'] . '` does not contain a PHP array');
+				continue;
+			}
+
+			$pluralCount = $pluralData['nplurals'] ?? null;
+			$pluralFunction = $pluralData['plural'] ?? null;
+			if (!is_int($pluralCount) || $pluralCount < 1 || !($pluralFunction instanceof \Closure)) {
+				Minz_Log::warning('Invalid compiled plural data in `' . $pluralFile['path'] . '`. Run `make fix-all`.');
+				continue;
+			}
+
+			if ($pluralFile['use_formula']) {
+				if (self::$plural_function === null) {
+					self::$plural_count = $pluralCount;
+					self::$plural_function = $pluralFunction;
+				} elseif (self::$plural_count !== $pluralCount) {
+					Minz_Log::warning('Conflicting compiled plural count in `' . $pluralFile['path'] . '`');
+				}
+			} elseif ($fallbackPluralFunction === null) {
+				$fallbackPluralCount = $pluralCount;
+				$fallbackPluralFunction = $pluralFunction;
+			}
+		}
+
+		if (self::$plural_function === null) {
+			self::$plural_count = $fallbackPluralCount;
+			self::$plural_function = $fallbackPluralFunction;
+		}
+	}
+
+	private static function pluralIndex(int $value): ?int {
+		if (self::$plural_count === null || self::$plural_function === null) {
+			return null;
+		}
+
+		$index = (self::$plural_function)($value);
+		if (!is_int($index)) {
+			return null;
+		}
+		$index = max(0, $index);
+		return min($index, self::$plural_count - 1);
+	}
+
+	/**
+	 * Translate a count-based key using gettext plural indexes.
+	 * @param string $baseKey Base i18n key without plural suffix (e.g. `gen.interval.second`).
+	 * @param int $value Count used for plural category and `%d` substitution.
+	 * @return string|null Translated string or null if no translation is found.
+	 */
+	public static function plural(string $baseKey, int $value): ?string {
+		self::loadPluralCatalogue();
+
+		if (!isset(self::$plural_message_families[$baseKey])) {
+			$rawMessageFamily = self::resolveKey($baseKey);
+			if (!is_array($rawMessageFamily) || $rawMessageFamily === []) {
+				Minz_Log::debug($baseKey . ' is not a valid plural key');
+				return null;
+			}
+
+			/** @var array<int,string> $messageFamily */
+			$messageFamily = [];
+			foreach ($rawMessageFamily as $index => $message) {
+				if (is_int($index)) {
+					$integerIndex = $index;
+				} elseif (ctype_digit($index)) {
+					$integerIndex = (int)$index;
+				} else {
+					$integerIndex = null;
+				}
+				if ($integerIndex === null) {
+					continue;
+				}
+				if (!is_string($message)) {
+					continue;
+				}
+				$messageFamily[$integerIndex] = $message;
+			}
+
+			if ($messageFamily === []) {
+				Minz_Log::debug($baseKey . ' is not a valid plural key');
+				return null;
+			}
+
+			ksort($messageFamily);
+			self::$plural_message_families[$baseKey] = $messageFamily;
+		}
+
+		$messageFamily = self::$plural_message_families[$baseKey];
+
+		$index = self::pluralIndex($value);
+		if ($index !== null && isset($messageFamily[$index]) && $messageFamily[$index] !== '') {
+			return vsprintf($messageFamily[$index], [$value]);
+		}
+
+		$lastMessage = end($messageFamily);
+		if ($lastMessage === false || $lastMessage === '') {
+			return null;
+		}
+
+		return vsprintf($lastMessage, [$value]);
+	}
 }
 
 

+ 34 - 0
lib/lib_rss.php

@@ -215,6 +215,40 @@ function timestamptodate(int $t, bool $hour = true): string {
 	return @date($date, $t) ?: '';
 }
 
+function timestampToMachineDate(int $t): string {
+	return @date(DATE_ATOM, $t);
+}
+
+/**
+ * Human readable string how long this timestamp is ago ("5 years ago").
+ */
+function timeago(int $timestamp, ?int $baseTimestamp = null): string {
+	$baseTimestamp ??= time();
+	$delta = abs($baseTimestamp - $timestamp);
+
+	$units = [
+		[31536000, 'year'],
+		[2592000, 'month'],
+		[86400, 'day'],
+		[3600, 'hour'],
+		[60, 'minute'],
+	];
+
+	$diff = '';
+	foreach ($units as [$unitSeconds, $unit]) {
+		if ($delta >= $unitSeconds) {
+			$unitValue = intdiv($delta, $unitSeconds);
+			$diff = Minz_Translate::plural('gen.interval.' . $unit, $unitValue) ?? ($unitValue . ' ' . $unit . ' ago');
+			break;
+		}
+	}
+
+	if ($diff === '') {
+		return Minz_Translate::t('gen.interval.justnow');
+	}
+	return $diff;
+}
+
 /**
  * Decode HTML entities but preserve XML entities.
  */

+ 36 - 0
tests/cli/i18n/I18nCompletionValidatorTest.php

@@ -140,4 +140,40 @@ final class I18nCompletionValidatorTest extends \PHPUnit\Framework\TestCase {
 		self::assertTrue($validator->validate());
 		self::assertSame('', $validator->displayResult());
 	}
+
+	public function testValidateFlagsHigherPluralVariantWhenEqualToEnglishPlural(): void {
+		$validator = new I18nCompletionValidator([
+			'gen.php' => [
+				'gen.interval.day.0' => new I18nValue('%d day ago'),
+				'gen.interval.day.1' => new I18nValue('%d days ago'),
+			],
+		], [
+			'gen.php' => [
+				'gen.interval.day.0' => new I18nValue('%d dzień temu'),
+				'gen.interval.day.1' => new I18nValue('%d dni temu'),
+				'gen.interval.day.2' => new I18nValue('%d days ago'),
+			],
+		]);
+
+		self::assertFalse($validator->validate());
+		self::assertSame("Untranslated key gen.interval.day.2 - %d days ago\n", $validator->displayResult());
+		self::assertSame("Translation is  66.7% complete.\n", $validator->displayReport());
+	}
+
+	public function testValidateSkipsEnglishPluralVariantsMissingFromOneFormLanguage(): void {
+		$validator = new I18nCompletionValidator([
+			'gen.php' => [
+				'gen.interval.day.0' => new I18nValue('%d day ago'),
+				'gen.interval.day.1' => new I18nValue('%d days ago'),
+			],
+		], [
+			'gen.php' => [
+				'gen.interval.day.0' => new I18nValue('%d hari yang lalu'),
+			],
+		]);
+
+		self::assertTrue($validator->validate());
+		self::assertSame('', $validator->displayResult());
+		self::assertSame("Translation is 100.0% complete.\n", $validator->displayReport());
+	}
 }

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