diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b24cd116..38f4c4cff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,7 +7,8 @@
* Bug fixing
* Work-around for `CURLOPT_FOLLOWLOCATION` `open_basedir` bug in favicons and PubSubHubbub [#1655](https://github.com/FreshRSS/FreshRSS/issues/1655)
* 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 = <<