Improve translation tools

I was not happy with the previous version. I refactored everything to make it reusable.
It allows me do do more verifications and to build a tool to handle the files themselves.
pull/1658/head
Alexis Degrugillier 7 years ago
parent 863ce029dd
commit ac60e35f6a
  1. 3
      CHANGELOG.md
  2. 6
      app/layout/nav_menu.phtml
  3. 2
      app/views/configure/system.phtml
  4. 49
      tools/I18nCompletionValidator.php
  5. 118
      tools/I18nData.php
  6. 92
      tools/I18nFile.php
  7. 47
      tools/I18nUsageValidator.php
  8. 26
      tools/I18nValidatorInterface.php
  9. 111
      tools/check.translation.php
  10. 105
      tools/ignore/en.php
  11. 55
      tools/ignore/fr.php
  12. 79
      tools/manipulate.translation.php
  13. 58
      tools/translation.ignore.php

@ -3,7 +3,8 @@
## 2017-1X-XX FreshRSS 1.8.1-dev
* Misc.
* Travis translation validation tool [#1653](https://github.com/FreshRSS/FreshRSS/pull/1653)
* Translation validation tool [#1653](https://github.com/FreshRSS/FreshRSS/pull/1653)
* Translation manipulation tool [#1658](https://github.com/FreshRSS/FreshRSS/pull/1658)
## 2017-10-01 FreshRSS 1.8.0

@ -186,16 +186,16 @@
if (FreshRSS_Context::$order === 'DESC') {
$order = 'ASC';
$icon = 'up';
$title = 'index.menu.older_first';
$title = _t('index.menu.older_first');
} else {
$order = 'DESC';
$icon = 'down';
$title = 'index.menu.newer_first';
$title = _t('index.menu.newer_first');
}
$url_order = Minz_Request::currentRequest();
$url_order['params']['order'] = $order;
?>
<a id="toggle-order" class="btn" href="<?php echo Minz_Url::display($url_order); ?>" title="<?php echo _t($title); ?>">
<a id="toggle-order" class="btn" href="<?php echo Minz_Url::display($url_order); ?>" title="<?php echo $title; ?>">
<?php echo _i($icon); ?>
</a>

@ -33,7 +33,7 @@
<div class="group-controls">
<?php
$number = count(listUsers());
echo _t($number > 1 ? 'admin.user.numbers' : 'admin.user.number', $number);
echo ($number > 1 ? _t('admin.user.numbers', $number) : _t('admin.user.number', $number));
?>
</div>
</div>

@ -0,0 +1,49 @@
<?php
require_once __DIR__ . '/I18nValidatorInterface.php';
class I18nCompletionValidator implements I18nValidatorInterface {
private $reference;
private $language;
private $totalEntries = 0;
private $passEntries = 0;
private $result = '';
public function __construct($reference, $language) {
$this->reference = $reference;
$this->language = $language;
}
public function displayReport() {
return sprintf('Translation is %5.1f%% complete.', $this->passEntries / $this->totalEntries * 100) . PHP_EOL;
}
public function displayResult() {
return $this->result;
}
public function validate($ignore) {
foreach ($this->reference as $file => $data) {
foreach ($data as $key => $value) {
$this->totalEntries++;
if (is_array($ignore) && in_array($key, $ignore)) {
$this->passEntries++;
continue;
}
if (!array_key_exists($key, $this->language[$file])) {
$this->result .= sprintf('Missing key %s', $key) . PHP_EOL;
continue;
}
if ($value === $this->language[$file][$key]) {
$this->result .= sprintf('Untranslated key %s - %s', $key, $value) . PHP_EOL;
continue;
}
$this->passEntries++;
}
}
return $this->totalEntries === $this->passEntries;
}
}

@ -0,0 +1,118 @@
<?php
class I18nData {
const REFERENCE_LANGUAGE = 'en';
private $data = array();
private $originalData = array();
public function __construct($data) {
$this->data = $data;
$this->originalData = $data;
}
public function getData() {
return $this->data;
}
/**
* Return the available languages
*
* @return array
*/
public function getAvailableLanguages() {
$languages = array_keys($this->data);
sort($languages);
return $languages;
}
/**
* Add a new language. It's a copy of the reference language.
*
* @param string $language
*/
public function addLanguage($language) {
if (array_key_exists($language, $this->data)) {
throw new Exception('The selected language already exist.');
}
$this->data[$language] = $this->data[static::REFERENCE_LANGUAGE];
}
/**
* Add a key in the reference language
*
* @param string $key
* @param string $value
*/
public function addKey($key, $value) {
if (array_key_exists($key, $this->data[static::REFERENCE_LANGUAGE][$this->getFilenamePrefix($key)])) {
throw new Exception('The selected key already exist.');
}
$this->data[static::REFERENCE_LANGUAGE][$this->getFilenamePrefix($key)][$key] = $value;
}
/**
* Duplicate a key from the reference language to all other languages
*
* @param string $key
*/
public function duplicateKey($key) {
if (!array_key_exists($key, $this->data[static::REFERENCE_LANGUAGE][$this->getFilenamePrefix($key)])) {
throw new Exception('The selected key does not exist.');
}
$value = $this->data[static::REFERENCE_LANGUAGE][$this->getFilenamePrefix($key)][$key];
foreach ($this->getAvailableLanguages() as $language) {
if (static::REFERENCE_LANGUAGE === $language) {
continue;
}
if (array_key_exists($key, $this->data[$language][$this->getFilenamePrefix($key)])) {
throw new Exception(sprintf('The selected key already exist in %s.', $language));
}
$this->data[$language][$this->getFilenamePrefix($key)][$key] = $value;
}
}
/**
* Remove a key in all languages
*
* @param string $key
*/
public function removeKey($key) {
if (!array_key_exists($key, $this->data[static::REFERENCE_LANGUAGE][$this->getFilenamePrefix($key)])) {
throw new Exception('The selected key does not exist.');
}
foreach ($this->getAvailableLanguages() as $language) {
if (array_key_exists($key, $this->data[$language][$this->getFilenamePrefix($key)])) {
unset($this->data[$language][$this->getFilenamePrefix($key)][$key]);
}
}
}
/**
* Check if the data has changed
*
* @return bool
*/
public function hasChanged() {
return $this->data !== $this->originalData;
}
public function getLanguage($language) {
return $this->data[$language];
}
public function getReferenceLanguage() {
return $this->getLanguage(static::REFERENCE_LANGUAGE);
}
/**
* @param string $key
* @return string
*/
private function getFilenamePrefix($key) {
return preg_replace('/\..*/', '.php', $key);
}
}

@ -0,0 +1,92 @@
<?php
require_once __DIR__ . '/I18nData.php';
class i18nFile {
private $i18nPath;
public function __construct() {
$this->i18nPath = __DIR__ . '/../app/i18n';
}
public function load() {
$dirs = new DirectoryIterator($this->i18nPath);
foreach ($dirs as $dir) {
if ($dir->isDot()) {
continue;
}
$files = new DirectoryIterator($dir->getPathname());
foreach ($files as $file) {
if (!$file->isFile()) {
continue;
}
$i18n[$dir->getFilename()][$file->getFilename()] = $this->flatten(include $file->getPathname(), $file->getBasename('.php'));
}
}
return new I18nData($i18n);
}
public function dump(I18nData $i18n) {
foreach ($i18n->getData() as $language => $file) {
$dir = $this->i18nPath . DIRECTORY_SEPARATOR . $language;
if (!file_exists($dir)) {
mkdir($dir);
}
foreach ($file as $name => $content) {
$filename = $dir . DIRECTORY_SEPARATOR . $name;
$fullContent = var_export($this->unflatten($content), true);
file_put_contents($filename, sprintf('<?php return %s;', $fullContent));
}
}
}
/**
* Flatten an array of translation
*
* @param array $translation
* @param string $prefix
* @return array
*/
private function flatten($translation, $prefix = '') {
$a = array();
if ('' !== $prefix) {
$prefix .= '.';
}
foreach ($translation as $key => $value) {
if (is_array($value)) {
$a += $this->flatten($value, $prefix . $key);
} else {
$a[$prefix . $key] = $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 $translation
* @return array
*/
private function unflatten($translation) {
$a = array();
ksort($translation);
foreach ($translation as $compoundKey => $value) {
$keys = explode('.', $compoundKey);
array_shift($keys);
eval("\$a['" . implode("']['", $keys) . "'] = '" . $value . "';");
}
return $a;
}
}

@ -0,0 +1,47 @@
<?php
require_once __DIR__ . '/I18nValidatorInterface.php';
class I18nUsageValidator implements I18nValidatorInterface {
private $code;
private $reference;
private $totalEntries = 0;
private $failedEntries = 0;
private $result = '';
public function __construct($reference, $code) {
$this->code = $code;
$this->reference = $reference;
}
public function displayReport() {
return sprintf('%5.1f%% of translation keys are unused.', $this->failedEntries / $this->totalEntries * 100) . PHP_EOL;
}
public function displayResult() {
return $this->result;
}
public function validate($ignore) {
foreach ($this->reference as $file => $data) {
foreach ($data as $key => $value) {
$this->totalEntries++;
if (preg_match('/\._$/', $key) && in_array(preg_replace('/\._$/', '', $key), $this->code)) {
continue;
}
if (is_array($ignore) && in_array($key, $ignore)) {
continue;
}
if (!in_array($key, $this->code)) {
$this->result .= sprintf('Unused key %s - %s', $key, $value) . PHP_EOL;
$this->failedEntries++;
continue;
}
}
}
return 0 === $this->failedEntries;
}
}

@ -0,0 +1,26 @@
<?php
interface I18nValidatorInterface {
/**
* Display the validation result.
* Empty if there are no errors.
*
* @return array
*/
public function displayResult();
/**
* @param array $ignore Keys to ignore for validation
* @return bool
*/
public function validate($ignore);
/**
* Display the validation report.
*
* @return array
*/
public function displayReport();
}

@ -1,104 +1,83 @@
<?php
$options = getopt("dhl:r");
require_once __DIR__ . '/I18nFile.php';
require_once __DIR__ . '/I18nCompletionValidator.php';
require_once __DIR__ . '/I18nUsageValidator.php';
$i18nFile = new I18nFile();
$i18nData = $i18nFile->load();
$ignore = include __DIR__ . '/translation.ignore.php';
$options = getopt("dhl:r");
if (array_key_exists('h', $options)) {
help();
}
if (array_key_exists('l', $options)) {
$langPattern = sprintf('/%s/', $options['l']);
$languages = array($options['l']);
} else {
$langPattern = '/*/';
$languages = $i18nData->getAvailableLanguages();
}
$displayErrors = array_key_exists('d', $options);
$displayResults = array_key_exists('d', $options);
$displayReport = array_key_exists('r', $options);
$i18nPath = __DIR__ . '/../app/i18n/';
$errors = array();
$isValidated = true;
$result = array();
$report = array();
foreach (glob($i18nPath . 'en/*.php') as $i18nFileReference) {
$en = flatten(include $i18nFileReference);
foreach (glob(str_replace('/en/', $langPattern, $i18nFileReference)) as $i18nFile) {
preg_match('#(?P<lang>[^/]+)/(?P<file>[^/]*.php)#', $i18nFile, $matches);
$lang = $matches['lang'];
$file = $matches['file'];
if ('en' === $lang) {
continue;
}
if (!array_key_exists($lang, $report)) {
$report[$lang]['total'] = 0;
$report[$lang]['errors'] = 0;
}
$i18n = flatten(include $i18nFile);
foreach ($en as $key => $value) {
$report[$lang]['total'] ++;
if (array_key_exists($lang, $ignore) && array_key_exists($file, $ignore[$lang]) && in_array($key, $ignore[$lang][$file])) {
continue;
}
if (!array_key_exists($key, $i18n)) {
$errors[$lang][$file][] = sprintf('Missing key %s', $key);
$report[$lang]['errors'] ++;
continue;
}
if ($i18n[$key] === $value) {
$errors[$lang][$file][] = sprintf('Untranslated key %s - %s', $key, $value);
$report[$lang]['errors'] ++;
continue;
}
foreach ($languages as $language) {
if ($language === $i18nData::REFERENCE_LANGUAGE) {
$i18nValidator = new I18nUsageValidator($i18nData->getReferenceLanguage(), findUsedTranslations());
$isValidated = $i18nValidator->validate(include __DIR__ . '/ignore/' . $language . '.php') && $isValidated;
} else {
$i18nValidator = new I18nCompletionValidator($i18nData->getReferenceLanguage(), $i18nData->getLanguage($language));
if (file_exists(__DIR__ . '/ignore/' . $language . '.php')) {
$isValidated = $i18nValidator->validate(include __DIR__ . '/ignore/' . $language . '.php') && $isValidated;
} else {
$isValidated = $i18nValidator->validate(null) && $isValidated;
}
}
$report[$language] = sprintf('%-5s - %s', $language, $i18nValidator->displayReport());
$result[$language] = $i18nValidator->displayResult();
}
if ($displayErrors) {
foreach ($errors as $lang => $value) {
if ($displayResults) {
foreach ($result as $lang => $value) {
echo 'Language: ', $lang, PHP_EOL;
foreach ($value as $file => $messages) {
echo ' - File: ', $file, PHP_EOL;
foreach ($messages as $message) {
echo ' - ', $message, PHP_EOL;
}
}
print_r($value);
echo PHP_EOL;
}
}
if ($displayReport) {
foreach ($report as $lang => $value) {
$completion = ($value['total'] - $value['errors']) / $value['total'] * 100;
echo sprintf('Translation for %-5s is %5.1f%% complete.', $lang, $completion), PHP_EOL;
foreach ($report as $value) {
echo $value;
}
}
if (!empty($errors)) {
if (!$isValidated) {
exit(1);
}
/**
* Flatten an array of translation
* Find used translation keys in the project
*
* Iterates through all php and phtml files in the whole project and extracts all
* translation keys used.
*
* @param array $translation
* @param string $prependKey
* @return array
*/
function flatten($translation, $prependKey = '') {
$a = array();
if ('' !== $prependKey) {
$prependKey .= '.';
function findUsedTranslations() {
$directory = new RecursiveDirectoryIterator(__DIR__ . '/..');
$iterator = new RecursiveIteratorIterator($directory);
$regex = new RegexIterator($iterator, '/^.+\.(php|phtml)$/i', RecursiveRegexIterator::GET_MATCH);
$usedI18n = array();
foreach (array_keys(iterator_to_array($regex)) as $file) {
$fileContent = file_get_contents($file);
preg_match_all('/_t\([\'"](?P<strings>[^\'"]+)[\'"]/', $fileContent, $matches);
$usedI18n = array_merge($usedI18n, $matches['strings']);
}
foreach ($translation as $key => $value) {
if (is_array($value)) {
$a += flatten($value, $prependKey . $key);
} else {
$a[$prependKey . $key] = $value;
}
}
return $a;
return $usedI18n;
}
/**

@ -0,0 +1,105 @@
<?php
return array(
'admin.check_install.cache.nok',
'admin.check_install.cache.ok',
'admin.check_install.categories.nok',
'admin.check_install.categories.ok',
'admin.check_install.connection.nok',
'admin.check_install.connection.ok',
'admin.check_install.ctype.nok',
'admin.check_install.ctype.ok',
'admin.check_install.curl.nok',
'admin.check_install.curl.ok',
'admin.check_install.data.nok',
'admin.check_install.data.ok',
'admin.check_install.dom.nok',
'admin.check_install.dom.ok',
'admin.check_install.entries.nok',
'admin.check_install.entries.ok',
'admin.check_install.favicons.nok',
'admin.check_install.favicons.ok',
'admin.check_install.feeds.nok',
'admin.check_install.feeds.ok',
'admin.check_install.fileinfo.nok',
'admin.check_install.fileinfo.ok',
'admin.check_install.json.nok',
'admin.check_install.json.ok',
'admin.check_install.minz.nok',
'admin.check_install.minz.ok',
'admin.check_install.pcre.nok',
'admin.check_install.pcre.ok',
'admin.check_install.pdo.nok',
'admin.check_install.pdo.ok',
'admin.check_install.php.nok',
'admin.check_install.php.ok',
'admin.check_install.tables.nok',
'admin.check_install.tables.ok',
'admin.check_install.tokens.nok',
'admin.check_install.tokens.ok',
'admin.check_install.users.nok',
'admin.check_install.users.ok',
'admin.check_install.zip.nok',
'admin.check_install.zip.ok',
'conf.query.get_all',
'conf.query.get_category',
'conf.query.get_favorite',
'conf.query.get_feed',
'conf.query.order_asc',
'conf.query.order_desc',
'conf.query.state_0',
'conf.query.state_1',
'conf.query.state_2',
'conf.query.state_3',
'conf.query.state_4',
'conf.query.state_5',
'conf.query.state_6',
'conf.query.state_7',
'conf.query.state_8',
'conf.query.state_9',
'conf.query.state_10',
'conf.query.state_11',
'conf.query.state_12',
'conf.query.state_13',
'conf.query.state_14',
'conf.query.state_15',
'conf.sharing.blogotext',
'conf.sharing.diaspora',
'conf.sharing.email',
'conf.sharing.facebook',
'conf.sharing.g+',
'conf.sharing.print',
'conf.sharing.shaarli',
'conf.sharing.twitter',
'conf.sharing.wallabag',
'gen.lang.cz',
'gen.lang.de',
'gen.lang.en',
'gen.lang.es',
'gen.lang.fr',
'gen.lang.it',
'gen.lang.kr',
'gen.lang.nl',
'gen.lang.pt-br',
'gen.lang.ru',
'gen.lang.tr',
'gen.lang.zh-cn',
'gen.share.blogotext',
'gen.share.diaspora',
'gen.share.email',
'gen.share.facebook',
'gen.share.g+',
'gen.share.movim',
'gen.share.print',
'gen.share.shaarli',
'gen.share.twitter',
'gen.share.wallabag',
'gen.share.wallabagv2',
'gen.share.jdh',
'gen.share.Known',
'gen.share.gnusocial',
'index.menu.non-starred',
'index.menu.read',
'index.menu.starred',
'index.menu.unread',
);

@ -0,0 +1,55 @@
<?php
return array(
'admin.extensions.title',
'admin.stats.number_entries',
'admin.user.articles_and_size',
'conf.display.width.large',
'conf.sharing.blogotext',
'conf.sharing.diaspora',
'conf.sharing.facebook',
'conf.sharing.g+',
'conf.sharing.print',
'conf.sharing.shaarli',
'conf.sharing.twitter',
'conf.sharing.wallabag',
'conf.shortcut.navigation',
'conf.user.articles_and_size',
'gen.freshrss._',
'gen.lang.cz',
'gen.lang.de',
'gen.lang.en',
'gen.lang.es',
'gen.lang.fr',
'gen.lang.it',
'gen.lang.kr',
'gen.lang.nl',
'gen.lang.pt-br',
'gen.lang.ru',
'gen.lang.tr',
'gen.lang.zh-cn',
'gen.menu.admin',
'gen.menu.configuration',
'gen.menu.extensions',
'gen.menu.logs',
'gen.share.blogotext',
'gen.share.diaspora',
'gen.share.facebook',
'gen.share.g+',
'gen.share.movim',
'gen.share.shaarli',
'gen.share.twitter',
'gen.share.wallabag',
'gen.share.wallabagv2',
'gen.share.jdh',
'gen.share.gnusocial',
'index.about.agpl3',
'index.about.version',
'index.log._',
'index.log.title',
'install.title',
'install.this_is_the_end',
'sub.bookmarklet.title',
'sub.feed.description',
'sub.feed.number_entries',
);

@ -0,0 +1,79 @@
<?php
$options = getopt("h");
if (array_key_exists('h', $options)) {
help();
}
if (1 === $argc || 4 < $argc) {
help();
}
require_once __DIR__ . '/I18nFile.php';
$i18nFile = new I18nFile();
$i18nData = $i18nFile->load();
switch ($argv[1]) {
case 'add_language' :
$i18nData->addLanguage($argv[2]);
break;
case 'add_key' :
if (3 === $argc) {
help();
}
$i18nData->addKey($argv[2], $argv[3]);
break;
case 'duplicate_key' :
$i18nData->duplicateKey($argv[2]);
break;
case 'delete_key' :
$i18nData->removeKey($argv[2]);
break;
default :
help();
}
if ($i18nData->hasChanged()) {
$i18nFile->dump($i18nData);
}
/**
* Output help message.
*/
function help() {
$help = <<<HELP
NAME
%s
SYNOPSIS
php %s [OPTION] [OPERATION] [KEY] [VALUE]
DESCRIPTION
Manipulate translation files. Available operations are
Check if translation files have missing keys or missing translations.
-h display this help and exit.
OPERATION
add_language
add a new language by duplicating the referential. This operation
needs only a KEY.
add_key add a new key in the referential. This operation needs a KEY and
a VALUE.
duplicate_key
duplicate a referential key in other languages. This operation
needs only a KEY.
delete_key
delete a referential key from all languages. This operation needs
only a KEY.
HELP;
$file = str_replace(__DIR__ . '/', '', __FILE__);
echo sprintf($help, $file, $file);
exit;
}

@ -1,58 +0,0 @@
<?php
$ignore = array();
// ignore for FR
$ignore['fr']['admin.php'][] = 'extensions.title';
$ignore['fr']['admin.php'][] = 'stats.number_entries';
$ignore['fr']['admin.php'][] = 'user.articles_and_size';
$ignore['fr']['conf.php'][] = 'display.width.large';
$ignore['fr']['conf.php'][] = 'sharing.blogotext';
$ignore['fr']['conf.php'][] = 'sharing.diaspora';
$ignore['fr']['conf.php'][] = 'sharing.facebook';
$ignore['fr']['conf.php'][] = 'sharing.g+';
$ignore['fr']['conf.php'][] = 'sharing.print';
$ignore['fr']['conf.php'][] = 'sharing.shaarli';
$ignore['fr']['conf.php'][] = 'sharing.twitter';
$ignore['fr']['conf.php'][] = 'sharing.wallabag';
$ignore['fr']['conf.php'][] = 'shortcut.navigation';
$ignore['fr']['conf.php'][] = 'user.articles_and_size';
$ignore['fr']['gen.php'][] = 'freshrss._';
$ignore['fr']['gen.php'][] = 'lang.cz';
$ignore['fr']['gen.php'][] = 'lang.de';
$ignore['fr']['gen.php'][] = 'lang.en';
$ignore['fr']['gen.php'][] = 'lang.es';
$ignore['fr']['gen.php'][] = 'lang.fr';
$ignore['fr']['gen.php'][] = 'lang.it';
$ignore['fr']['gen.php'][] = 'lang.kr';
$ignore['fr']['gen.php'][] = 'lang.nl';
$ignore['fr']['gen.php'][] = 'lang.pt-br';
$ignore['fr']['gen.php'][] = 'lang.ru';
$ignore['fr']['gen.php'][] = 'lang.tr';
$ignore['fr']['gen.php'][] = 'lang.zh-cn';
$ignore['fr']['gen.php'][] = 'menu.admin';
$ignore['fr']['gen.php'][] = 'menu.configuration';
$ignore['fr']['gen.php'][] = 'menu.extensions';
$ignore['fr']['gen.php'][] = 'menu.logs';
$ignore['fr']['gen.php'][] = 'share.blogotext';
$ignore['fr']['gen.php'][] = 'share.diaspora';
$ignore['fr']['gen.php'][] = 'share.facebook';
$ignore['fr']['gen.php'][] = 'share.g+';
$ignore['fr']['gen.php'][] = 'share.movim';
$ignore['fr']['gen.php'][] = 'share.shaarli';
$ignore['fr']['gen.php'][] = 'share.twitter';
$ignore['fr']['gen.php'][] = 'share.wallabag';
$ignore['fr']['gen.php'][] = 'share.wallabagv2';
$ignore['fr']['gen.php'][] = 'share.jdh';
$ignore['fr']['gen.php'][] = 'share.gnusocial';
$ignore['fr']['index.php'][] = 'about.agpl3';
$ignore['fr']['index.php'][] = 'about.version';
$ignore['fr']['index.php'][] = 'log._';
$ignore['fr']['index.php'][] = 'log.title';
$ignore['fr']['install.php'][] = 'title';
$ignore['fr']['install.php'][] = 'this_is_the_end';
$ignore['fr']['sub.php'][] = 'bookmarklet.title';
$ignore['fr']['sub.php'][] = 'feed.description';
$ignore['fr']['sub.php'][] = 'feed.number_entries';
return $ignore;
Loading…
Cancel
Save