Files
FreshRSS/cli/check.translation.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

264 lines
8.0 KiB
PHP
Executable File

#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__ . '/_cli.php';
require_once __DIR__ . '/i18n/I18nCompletionValidator.php';
require_once __DIR__ . '/i18n/I18nData.php';
require_once __DIR__ . '/i18n/I18nFile.php';
require_once __DIR__ . '/i18n/I18nUsageValidator.php';
require_once dirname(__DIR__) . '/constants.php';
$cliOptions = new class extends CliOptionsParser {
/** @var array<int,string> $language */
public array $language;
public bool $displayResult;
public bool $help;
public bool $displayReport;
public bool $generateReadme;
public function __construct() {
$this->addOption('language', (new CliOption('language', 'l'))->typeOfArrayOfString());
$this->addOption('displayResult', (new CliOption('display-result', 'd'))->withValueNone());
$this->addOption('help', (new CliOption('help', 'h'))->withValueNone());
$this->addOption('displayReport', (new CliOption('display-report', 'r'))->withValueNone());
$this->addOption('generateReadme', (new CliOption('generate-readme', 'g'))->withValueNone());
parent::__construct();
}
};
if (!empty($cliOptions->errors)) {
fail('FreshRSS error: ' . array_shift($cliOptions->errors) . "\n" . $cliOptions->usage);
}
if ($cliOptions->help) {
checkHelp();
}
$i18nFile = new I18nFile();
$i18nData = new I18nData($i18nFile->load());
if (!empty($cliOptions->language)) {
$languages = $cliOptions->language;
} else {
$languages = $i18nData->getAvailableLanguages();
}
$isValidated = true;
$languageNameIssues = $i18nData->validateLanguageNames();
foreach ($languageNameIssues as $issue) {
fwrite(STDERR, "Error: {$issue}\n");
$isValidated = false;
}
$result = [];
$report = [];
$percentage = [];
foreach ($languages as $language) {
if ($language === $i18nData::REFERENCE_LANGUAGE) {
$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));
}
$isValidated = $i18nValidator->validate() && $isValidated;
$report[$language] = sprintf('%-5s - %s', $language, $i18nValidator->displayReport());
$percentage[$language] = $i18nValidator->displayReport(percentage_only: true);
$result[$language] = $i18nValidator->displayResult();
}
if ($cliOptions->displayResult) {
foreach ($result as $lang => $value) {
echo 'Language: ', $lang, PHP_EOL;
print_r($value);
echo PHP_EOL;
}
}
if ($cliOptions->displayReport) {
foreach ($report as $value) {
echo $value;
}
}
function writeToReadme(string $readmePath, string $markdownTable): void {
$language = explode('.', $readmePath)[1];
// expecting `README.md` for `en` or `README.fr.md` for `fr`
if ($language === 'md') {
$language = 'en';
}
Minz_Translate::init($language);
$placeholders = [];
if (preg_match_all('/__.*?__/', $markdownTable, $placeholders) === false) {
echo 'Error: Fail while matching translation placeholders', PHP_EOL;
exit(1);
}
foreach (array_unique($placeholders[0]) as $_ => $placeholder) {
$markdownTable = str_replace($placeholder, _t('gen.readme.' . substr($placeholder, 2, -2)), $markdownTable);
}
$readme = file_get_contents($readmePath);
if ($readme === false) {
echo 'Error: Unable to open ' . $readmePath, PHP_EOL;
exit(1);
}
if (file_put_contents($readmePath, preg_replace('/<translations>(.*?)<\/translations>/s', <<<EOF
<translations>
<!-- This section is automatically generated by `./cli/check.translation.php -g` -->
$markdownTable
</translations>
EOF, $readme)) === false) {
echo 'Error: Fail while writing to ' . $readmePath, PHP_EOL;
exit(1);
}
echo 'Successfully written translation status into ' . $readmePath, PHP_EOL;
}
if ($cliOptions->generateReadme) {
if ($languageNameIssues !== []) {
// Refuse to regenerate the README when language directory names and
// `gen.lang.*` keys disagree, otherwise we would silently produce a
// corrupt translation table (e.g. literal `gen.lang.*` keys instead of
// localised language names). Routine incomplete translations are fine.
exit(1);
}
$markdownTable = <<<EOF
| __language__ | __translated__ | |
| - | - | - |
EOF;
$markdownTable .= "\n";
foreach ($percentage as $lang => $value) {
$percentageInt = intval(rtrim($value, '%'));
$completed = intval($percentageInt / 10);
$uncompleted = intval(ceil((100 - $percentageInt) / 10));
$progressBar = str_repeat('■', $completed) . str_repeat('・', $uncompleted);
$ghSearchUrl = 'https://github.com/search?q=' . urlencode("repo:FreshRSS/FreshRSS path:app/i18n/$lang /(TODO|DIRTY)( - .*)?$/");
$markdownTable .= '| ' . implode(' | ', [
_t('gen.lang.' . $lang) . " ($lang)",
$progressBar . ' ' . $percentageInt . '%',
"[__contribute__]($ghSearchUrl)",
]) . " |\n";
}
// In case we're located in ./cli/
if (!file_exists('constants.php')) {
chdir('..');
}
foreach (array_merge(['README.md'], glob('README.*.md') ?: []) as $readmePath) {
writeToReadme($readmePath, rtrim($markdownTable));
}
exit();
}
if (!$isValidated) {
exit(1);
}
/**
* Find used translation keys in the project
*
* Iterates through all php and phtml files in the whole project and extracts all
* translation keys used.
*
* @return array{keys:list<string>,prefixes:list<string>}
*/
function findUsedTranslations(): array {
$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;
}
$fileContent = file_get_contents($file);
if ($fileContent === false) {
continue;
}
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 [
'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;
}
/**
* Output help message.
*/
function checkHelp(): never {
$file = str_replace(__DIR__ . '/', '', __FILE__);
echo <<<HELP
NAME
$file
SYNOPSIS
php $file [OPTION]...
DESCRIPTION
Check if translation files have missing keys or missing translations.
-d, --display-result display results.
-h, --help display this help and exit.
-l, --language=LANG filter by LANG.
-r, --display-report display completion report.
-g, --generate-readme generate translation progress section in readme.
HELP;
exit();
}