Files
FreshRSS/cli/i18n/I18nFile.php
T
Jgolding1998 d03296f074 Add current English text to DIRTY translation comments (#9525)
* Add English references to DIRTY translation comments

Translators currently have to find the English source separately for each `// DIRTY` entry. The translation formatter now appends it on the same line, including English plural fallback for languages with additional plural forms. It keeps hints on one safe PHP comment line and refreshes them when English changes.

Only comments change in the locale files; translation values and plural rules are unchanged. Single-form translations show both English forms as `Singular: … | Plural: …`. Generated README contribution links match bare and annotated TODO/DIRTY markers and retain the end-of-line anchor.

Closes #9463.

* Show both English forms for single-form translations
2026-11-02 21:25:34 +01:00

235 lines
7.0 KiB
PHP

<?php
declare(strict_types=1);
require_once __DIR__ . '/I18nValue.php';
class I18nFile {
/**
* @param array<mixed,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 $value) {
if (!is_string($value) && !(is_array($value) && self::is_array_recursive_string($value))) {
return false;
}
}
return true;
}
/**
* @return array<string,array<string,array<string,I18nValue>>>
*/
public function load(): array {
$i18n = [];
$dirs = new DirectoryIterator(I18N_PATH);
foreach ($dirs as $dir) {
if ($dir->isDot()) {
continue;
}
$files = new DirectoryIterator($dir->getPathname());
foreach ($files as $file) {
if (!$file->isFile()) {
continue;
}
if ($file->getFilename() === 'plurals.php') {
continue;
}
$i18n[$dir->getFilename()][$file->getFilename()] = $this->flatten($this->process($file->getPathname()), $file->getBasename('.php'));
}
}
return $i18n;
}
/**
* @param array<string,array<string,array<string,I18nValue>>> $i18n
*/
public function dump(array $i18n): void {
foreach ($i18n as $language => $file) {
$dir = I18N_PATH . DIRECTORY_SEPARATOR . $language;
if (!file_exists($dir)) {
mkdir($dir, 0770, true);
}
foreach ($file as $name => $content) {
$filename = $dir . DIRECTORY_SEPARATOR . $name;
$reference = $language === 'en' ? [] : ($i18n['en'][$name] ?? []);
file_put_contents($filename, $this->format($content, $reference));
}
}
}
/**
* Process the content of an i18n file
* @return array<int|string,string|array<mixed>>
*/
private function process(string $filename): array {
$fileContent = file_get_contents($filename);
if (!is_string($fileContent)) {
return [];
}
$content = str_replace('<?php', '', $fileContent);
$content = preg_replace([
"#',\s*//\s*TODO.*#i",
"#',\s*//\s*DIRTY.*#i",
"#',\s*//\s*IGNORE.*#i",
], [
' -> todo\',',
' -> dirty\',',
' -> ignore\',',
], $content);
try {
$content = eval($content);
} catch (ParseError $ex) {
if (defined('STDERR')) {
fwrite(STDERR, "Error while processing: $filename\n");
fwrite(STDERR, $ex->getMessage());
}
die(1);
}
if (is_array($content) && self::is_array_recursive_string($content)) {
return $content;
}
return [];
}
/**
* Flatten an array of translation
*
* @param array<int|string,I18nValue|string|array<mixed>|mixed> $translation
* @return array<string,I18nValue>
*/
private function flatten(array $translation, string $prefix = ''): array {
$a = [];
if ('' !== $prefix) {
$prefix .= '.';
}
foreach ($translation as $key => $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);
}
}
return $a;
}
/**
* Unflatten an array of translation
*
* The first key is dropped since it represents the filename and we have
* no use of it.
*
* @param array<string,I18nValue> $translation
* @return array<int|string,mixed>
*/
private function unflatten(array $translation): array {
$a = [];
ksort($translation, SORT_NATURAL);
foreach ($translation as $compoundKey => $value) {
$keys = explode('.', $compoundKey);
array_shift($keys);
$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();
if ($value->isDirty()) {
// Carry the key through var_export, including when several keys share the same translation.
$current[$normalisedKey] .= ':' . base64_encode($compoundKey);
}
continue;
}
if (!isset($current[$normalisedKey]) || !is_array($current[$normalisedKey])) {
$current[$normalisedKey] = [];
}
$current =& $current[$normalisedKey];
}
unset($current);
}
return $a;
}
/**
* Format an array of translation
*
* It takes an array of translation and format it to be dumped in a
* translation file. The array is first converted to a string then some
* formatting regexes are applied to match the original content.
*
* @param array<string,I18nValue> $translation
* @param array<string,I18nValue> $reference
*/
private function format(array $translation, array $reference = []): string {
$translationKeys = array_fill_keys(array_keys($translation), true);
$translation = var_export($this->unflatten($translation), true);
$patterns = [
'/ -> todo\',/',
'/ -> ignore\',/',
'/array \(/',
'/=>\s*array/',
'/(\w) {2}/',
'/ {2}/',
];
$replacements = [
"',\t// TODO", // Double quoting is mandatory to have a tab instead of the \t string
"',\t// IGNORE", // Double quoting is mandatory to have a tab instead of the \t string
'array(',
'=> array',
'$1 ',
"\t", // Double quoting is mandatory to have a tab instead of the \t string
];
$translation = preg_replace($patterns, $replacements, $translation);
$translation = preg_replace_callback('/ -> dirty:([A-Za-z0-9+\/=]+)\',/', static function (array $matches) use ($reference, $translationKeys): string {
$key = base64_decode($matches[1], true);
$original = $key === false ? null : ($reference[$key] ?? null);
if ($original === null && $key !== false && preg_match('/^(.+)\.\d+$/', $key, $parts) === 1) {
$original = $reference[$parts[1] . '.1'] ?? $reference[$parts[1] . '.0'] ?? null;
}
if ($original === null) {
return "',\t// DIRTY";
}
$comment = $original->getValue();
if ($key !== false && preg_match('/^(.+)\.0$/', $key, $parts) === 1
&& !isset($translationKeys[$parts[1] . '.1']) && isset($reference[$key], $reference[$parts[1] . '.1'])) {
$comment = 'Singular: ' . $comment . ' | Plural: ' . $reference[$parts[1] . '.1']->getValue();
}
// Keep the reference inside its line comment, including when it contains a PHP closing tag.
$comment = trim(preg_replace('/\s+/', ' ', $comment) ?? '');
$comment = str_replace('?>', '? >', $comment);
return "',\t// DIRTY" . ($comment === '' ? '' : ' - ' . $comment);
}, $translation ?? '');
return <<<PHP
<?php
/******************************************************************************
* Each entry of that file can be associated with a comment to indicate its *
* state. When there is no comment, it means the entry is fully translated. *
* The recognized comments are (comment matching is case-insensitive): *
* + TODO: the entry has never been translated. *
* + DIRTY: the entry has been translated but needs to be updated. *
* + IGNORE: the entry does not need to be translated. *
* When a comment is not recognized, it is discarded. *
******************************************************************************/
return {$translation};
PHP;
}
}