From ac60e35f6a6849f2dc13b3644d94fae59d83db77 Mon Sep 17 00:00:00 2001 From: Alexis Degrugillier Date: Sun, 8 Oct 2017 19:05:19 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 3 +- app/layout/nav_menu.phtml | 6 +- app/views/configure/system.phtml | 2 +- tools/I18nCompletionValidator.php | 49 +++++++++++++ tools/I18nData.php | 118 ++++++++++++++++++++++++++++++ tools/I18nFile.php | 92 +++++++++++++++++++++++ tools/I18nUsageValidator.php | 47 ++++++++++++ tools/I18nValidatorInterface.php | 26 +++++++ tools/check.translation.php | 111 ++++++++++++---------------- tools/ignore/en.php | 105 ++++++++++++++++++++++++++ tools/ignore/fr.php | 55 ++++++++++++++ tools/manipulate.translation.php | 79 ++++++++++++++++++++ tools/translation.ignore.php | 58 --------------- 13 files changed, 622 insertions(+), 129 deletions(-) create mode 100644 tools/I18nCompletionValidator.php create mode 100644 tools/I18nData.php create mode 100644 tools/I18nFile.php create mode 100644 tools/I18nUsageValidator.php create mode 100644 tools/I18nValidatorInterface.php create mode 100644 tools/ignore/en.php create mode 100644 tools/ignore/fr.php create mode 100644 tools/manipulate.translation.php delete mode 100644 tools/translation.ignore.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 28c16a37a..43c582919 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/layout/nav_menu.phtml b/app/layout/nav_menu.phtml index 04ee03cd6..2bc693e5d 100644 --- a/app/layout/nav_menu.phtml +++ b/app/layout/nav_menu.phtml @@ -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; ?> - + diff --git a/app/views/configure/system.phtml b/app/views/configure/system.phtml index 935b49fda..37b68c991 100644 --- a/app/views/configure/system.phtml +++ b/app/views/configure/system.phtml @@ -33,7 +33,7 @@
1 ? 'admin.user.numbers' : 'admin.user.number', $number); + echo ($number > 1 ? _t('admin.user.numbers', $number) : _t('admin.user.number', $number)); ?>
diff --git a/tools/I18nCompletionValidator.php b/tools/I18nCompletionValidator.php new file mode 100644 index 000000000..2cb71acd5 --- /dev/null +++ b/tools/I18nCompletionValidator.php @@ -0,0 +1,49 @@ +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; + } + +} diff --git a/tools/I18nData.php b/tools/I18nData.php new file mode 100644 index 000000000..cd8ba0765 --- /dev/null +++ b/tools/I18nData.php @@ -0,0 +1,118 @@ +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); + } + +} diff --git a/tools/I18nFile.php b/tools/I18nFile.php new file mode 100644 index 000000000..d8c16a6eb --- /dev/null +++ b/tools/I18nFile.php @@ -0,0 +1,92 @@ +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(' $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; + } + +} diff --git a/tools/I18nUsageValidator.php b/tools/I18nUsageValidator.php new file mode 100644 index 000000000..8ab934971 --- /dev/null +++ b/tools/I18nUsageValidator.php @@ -0,0 +1,47 @@ +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; + } + +} diff --git a/tools/I18nValidatorInterface.php b/tools/I18nValidatorInterface.php new file mode 100644 index 000000000..edfe7aac0 --- /dev/null +++ b/tools/I18nValidatorInterface.php @@ -0,0 +1,26 @@ +load(); + +$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[^/]+)/(?P[^/]*.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[^\'"]+)[\'"]/', $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; } /** diff --git a/tools/ignore/en.php b/tools/ignore/en.php new file mode 100644 index 000000000..e231afdda --- /dev/null +++ b/tools/ignore/en.php @@ -0,0 +1,105 @@ +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 = <<