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>
This commit is contained in:
Christian Weiske
2026-04-07 22:56:02 +02:00
committed by GitHub
parent cc64991c16
commit 1acc646222
105 changed files with 2373 additions and 95 deletions
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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' => 'à linstant',
'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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 daqueste flux sul disc',
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
}
+171 -8
View File
@@ -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,18 +122,118 @@ 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();
continue;
}
if (!$refValue->equal($value) && $value->isTodo()) {
$value->markAsDirty();
continue;
}
$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;
}
$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;
}
/**
+24 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+207 -43
View File
@@ -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,51 +233,13 @@ class Minz_Translate {
* If no value is found, return the key itself.
*/
public static function t(string $key, ...$args): string {
$group = explode('.', $key);
if (count($group) < 2) {
Minz_Log::debug($key . ' is not in a valid format');
$top_level = 'gen';
} else {
$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])) {
$res = self::loadKey($top_level);
if (!$res) {
return $key;
}
}
// Go through the i18n keys to get the correct translation value.
$translates = self::$translates[$top_level];
if (!is_array($translates)) {
$translates = [];
}
$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])) {
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];
}
$translation_value = self::resolveKey($key);
if ($translation_value === null) {
return $key;
}
if (!is_string($translation_value)) {
$translation_value = is_array($translation_value) ? ($translation_value['_'] ?? null) : null;
$translation_value = $translation_value['_'] ?? null;
if (!is_string($translation_value)) {
Minz_Log::debug($key . ' is not a valid key');
return $key;
@@ -253,12 +250,179 @@ class Minz_Translate {
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) {
Minz_Log::debug($key . ' is not in a valid format');
$top_level = 'gen';
} else {
$top_level = array_shift($group) ?? '';
}
if ((self::$translates[$top_level] ?? null) === null) {
$res = self::loadKey($top_level);
if (!$res) {
return null;
}
}
$translationValue = self::$translates[$top_level] ?? null;
if (!is_array($translationValue)) {
return null;
}
foreach ($group as $i18n_level) {
if (!is_array($translationValue) || !array_key_exists($i18n_level, $translationValue)) {
Minz_Log::debug($key . ' is not a valid key');
return null;
}
$translationValue = $translationValue[$i18n_level];
}
if (!is_array($translationValue) && !is_string($translationValue)) {
return null;
}
return $translationValue;
}
/**
* Return the current language.
*/
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
View File
@@ -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.
*/
@@ -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 have changed in this diff Show More