diff --git a/CHANGELOG.md b/CHANGELOG.md index e2cacf6b..6bb0a55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,13 +19,13 @@ way to update this template, but currently, we follow a pattern: [#960](https://github.com/sharetribe/flex-template-web/pull/960) * [fix] Remove unused translation keys and update PasswordChangePage title [#959](https://github.com/sharetribe/flex-template-web/pull/959) +* [fix] Add translations CLI tool [#955](https://github.com/sharetribe/flex-template-web/pull/955) ## [v2.3.2] 2018-11-20 * [fix] Take 2: don't set currentUserHasListings if fetched listing is in draft state. [#956](https://github.com/sharetribe/flex-template-web/pull/956) -* [fix] PriceFilter styles - [#954](https://github.com/sharetribe/flex-template-web/pull/954) +* [fix] PriceFilter styles [#954](https://github.com/sharetribe/flex-template-web/pull/954) [v2.3.2]: https://github.com/sharetribe/flex-template-web/compare/v2.3.1...v2.3.2 @@ -322,4 +322,3 @@ way to update this template, but currently, we follow a pattern: ## v0.2.0 * Starting a change log for Flex Template for Web. - diff --git a/docs/assets/translations/translations_cli.gif b/docs/assets/translations/translations_cli.gif new file mode 100644 index 00000000..efab523f Binary files /dev/null and b/docs/assets/translations/translations_cli.gif differ diff --git a/docs/translations.md b/docs/translations.md index 5e8e2c8e..c6241f7e 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -161,3 +161,26 @@ in tests. To change the translation file used in tests change the `messages` var ```js import messages from '../translations/es.json'; ``` + +## Managing translations + +In case you have added a new language translation file and are pulling translation updates to +`en.json` from the upstream repo there is a command line tool to help keeping the translation files +in sync. Running the following command in the project root + +``` +node translations.js +``` + +will start a command line application: + +![Translations CLI](./assets/translations/translations_cli.gif) + +The command line application can be used to match a translation file against the English +translations. If your new translations file follows the `.json` naming, the CLI will pick +it up automatically. In order to improve readability, you can add the language name to the +`TARGET_LANG_NAMES` map in `translations.js` if it is not yet in there and the CLI will use the +correct name for your language instead of the language code when prompting about translations. + +In case you wish to use something else than English as the source language, modify the `SOURCE_LANG` +object in `translations.js` to match your needs. diff --git a/package.json b/package.json index c421ca3f..8bde7ca5 100644 --- a/package.json +++ b/package.json @@ -56,9 +56,11 @@ "url": "^0.11.0" }, "devDependencies": { + "chalk": "^2.4.1", "enzyme": "^3.3.0", "enzyme-adapter-react-16": "^1.1.1", "enzyme-to-json": "^3.3.3", + "inquirer": "^6.2.0", "nodemon": "^1.17.2", "nsp": "^3.2.1", "nsp-preprocessor-yarn": "^1.0.1", diff --git a/translations.js b/translations.js new file mode 100644 index 00000000..55d33b9a --- /dev/null +++ b/translations.js @@ -0,0 +1,300 @@ +/** + * A translations too that can be used to keep a translation file(s) + * up to date with one source language file. + * + * Possible target languages are resolved from the translation files + * available. When adding a new tranlation file, the language name can + * be added to the TARGET_LANG_NAMES map for clearer prompt messages. + * By default the translations are matched agains English translations + * but that can be changed by modifying the SOURCE_LANG. + */ + +const inquirer = require('inquirer'); +const difference = require('lodash/difference'); +const fs = require('fs'); +const chalk = require('chalk'); + +const PATH = './src/translations/'; + +const SOURCE_LANG = { name: 'English', code: 'en' }; +const TARGET_LANG_NAMES = { + es: 'Spanish', + de: 'German', + fr: 'French', +}; + +class BreakToRun {} +class BreakToRunWithTarget {} + +/** + * Resolves translation file path for a lang code: en, es, de, etc. + */ +const filePath = lang => `${PATH}${lang}.json`; + +/** + * Resolves name of a target language based on + * a language code: en, es, de, etc. + * + * If a lang name is not found, language code is returned. + */ +const targetLangName = code => { + const name = TARGET_LANG_NAMES[code]; + + return name || code; +}; + +/** + * Resolve a list of target language choices by finding all possible + * translation files and filtering out the SOURCE_LANG. Provides a list + * of objects that can be passed to inquirer. + * + * Relies on translation file naming: .json + */ +const targetLangChoices = () => { + const folder = './src/translations/'; + const filenames = fs.readdirSync(folder); + + const choices = filenames + .filter(name => name.endsWith('.json')) + .map(name => name.split('.')[0]) + .filter(code => code !== SOURCE_LANG.code) + .map(code => { + return { + name: targetLangName(code), + value: code, + }; + }); + + return choices; +}; + +/** + * Sorts an object by key. + * + * Relies on an object keeping the order in which entries are added for string keys. + * See: http://exploringjs.com/es6/ch_oop-besides-classes.html#_traversal-order-of-properties + */ +const sort = obj => { + const sorted = {}; + Object.keys(obj) + .sort() + .forEach(key => (sorted[key] = obj[key])); + + return sorted; +}; + +/** + * Reads a file with JSON content and returns + * a JS object. + * + * @param filepath + */ +const readFileToJSON = filepath => { + const rawdata = fs.readFileSync(filepath); + return JSON.parse(rawdata); +}; + +/** + * Run the application. + * + */ +const run = () => { + const choices = targetLangChoices(); + + selectLanguage(choices) + .then(answers => { + runWithTarget(answers.lang); + }) + .catch(err => { + console.log(chalk.red(`An error occurred due to: ${err.message}`)); + }); +}; + +/** + * When a language is selected this will prompt + * the user about adding translations to that language. + * + * @param {String} targetLang Target language code + */ +const runWithTarget = targetLang => { + let key; + let translation; + + const sourceLang = SOURCE_LANG.code; + const source = readFileToJSON(filePath(sourceLang)); + const target = readFileToJSON(filePath(targetLang)); + const sourceKeys = Object.keys(source); + const targetKeys = Object.keys(target); + const diff = difference(sourceKeys, targetKeys); + + if (diff.length === 0) { + console.log(`No translations missing from the ${targetLangName(targetLang)} translations`); + run(); + } else { + translateLanguage(targetLang, source, target, diff); + } +}; + +/** + * Does the actual prompting for translations. + * + * @param {String} targetLang Target language code + * @param {Object} source Source translations + * @param {Object} target Target translations + * @param {Array} diff Missing keys in target + */ +const translateLanguage = (targetLang, source, target, diff) => { + selectKey(targetLang, diff, source, target) + .then(answers => { + key = answers.key; + if (key === null) { + throw new BreakToRun(); + } + if (key != null) { + return addTranslation(targetLang, key, source); + } + }) + .then(answers => { + translation = answers.value; + return confirmTranslation(targetLang, key, translation); + }) + .then(answers => { + // y|Y|n|N + const confirmation = answers.value; + + if (/n/i.test(confirmation)) { + console.log('Discarding new translation'); + throw new BreakToRunWithTarget(); + } + + target[key] = translation; + + const sorted = sort(target); + const data = JSON.stringify(sorted, null, 2); + + return updateTranslationsFile(data, targetLang); + }) + .then(() => { + runWithTarget(targetLang); + }) + .catch(err => { + if (err instanceof BreakToRun) { + // break out of Promise chain, start over + run(); + } else if (err instanceof BreakToRunWithTarget) { + // break out of Promise chain, continue with this language + runWithTarget(targetLang); + } else { + console.log(chalk.red(`An error occurred due to: ${err.message}`)); + } + }); +}; + +/** + * Prompt for selecting the language of which + * translations are inspected. + * + * @return a Promise + */ +const selectLanguage = choices => { + return inquirer.prompt([ + { + type: 'list', + name: 'lang', + message: 'Which language would you like to inspect? (Or hit Ctrl+C to quit)', + choices: choices, + }, + ]); +}; + +/** + * Prompt for selecting a key that is missing from the target language translations. + * + * @param {String} targetLang The language that is being translated + * @param {Array} diff Keys missing frrom target language translations + * + * @return a Promise + */ +const selectKey = (targetLang, diff, source, target) => { + const choices = [{ name: 'Back to languages', value: null }, ...diff]; + return inquirer.prompt([ + { + type: 'list', + name: 'key', + message: `The following translation keys (${ + diff.length + }) are missing from the ${targetLangName( + targetLang + )} translations. Select a key to add a translation.`, + choices: choices, + pageSize: 30, + }, + ]); +}; + +/** + * Prompt for a new translation in target language. + * + * @param {String} targetLang The language that is being translated + * @param {String} key Key for the new translation + * @param {Object} source Source language translations + * + * @return a Promise + */ +const addTranslation = (targetLang, key, source) => { + return inquirer.prompt([ + { + type: 'input', + name: 'value', + message: `Please provide a translation in ${targetLangName( + targetLang + )} for the key ${chalk.blueBright(key)}. The current ${ + SOURCE_LANG.name + } translation is ${chalk.green(source[key])} \n`, + }, + ]); +}; + +/** + * Prompt for a confirming a new translation with 'y' or 'N'. + * + * @param {String} targetLang The language that is being translated + * @param {String} key Key for the new translation + * @param {Object} source Source language translations + * + * @return a Promise with with answers.value being 'y' or 'N' + */ +const confirmTranslation = (targetLang, key, translation) => { + return inquirer.prompt([ + { + type: 'input', + name: 'value', + message: `Do you want to add the ${targetLangName(targetLang)} translation ${chalk.green( + translation + )} for the key ${chalk.blueBright(key)}? (y/n)`, + validate: value => /y/i.test(value) || /n/i.test(value), + }, + ]); +}; + +/** + * Update translations file. + * + * @param {String} data String data to be written to file + * @param {String} targetLang Language code of the translation file + * + * @return a Promise that resolves when file is written + */ +const updateTranslationsFile = (data, targetLang) => { + return new Promise((resolve, reject) => { + fs.writeFile(filePath(targetLang), data, err => { + if (err) { + reject(err); + } + console.log(chalk.bold('The translation file is updated')); + resolve(); + }); + }); +}; + +run(); diff --git a/yarn.lock b/yarn.lock index 32e04d64..e315fa1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1512,6 +1512,11 @@ change-emitter@^0.1.2: version "0.1.6" resolved "https://registry.yarnpkg.com/change-emitter/-/change-emitter-0.1.6.tgz#e8b2fe3d7f1ab7d69a32199aff91ea6931409515" +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + charenc@~0.0.1: version "0.0.2" resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" @@ -3005,6 +3010,15 @@ external-editor@^2.0.4: jschardet "^1.4.2" tmp "^0.0.33" +external-editor@^3.0.0: + version "3.0.3" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27" + integrity sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + extglob@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" @@ -3819,6 +3833,13 @@ iconv-lite@0.4.23, iconv-lite@^0.4.4, iconv-lite@~0.4.13: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@^0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -3925,6 +3946,25 @@ inquirer@3.3.0, inquirer@^3.0.6, inquirer@^3.3.0: strip-ansi "^4.0.0" through "^2.3.6" +inquirer@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.2.0.tgz#51adcd776f661369dc1e894859c2560a224abdd8" + integrity sha512-QIEQG4YyQ2UYZGDC4srMZ7BjHOmNk1lR2JQj5UknBapklm6WHA+VVH7N+sUdX3A7NeCfGF8o4X1S3Ao7nAcIeg== + dependencies: + ansi-escapes "^3.0.0" + chalk "^2.0.0" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^3.0.0" + figures "^2.0.0" + lodash "^4.17.10" + mute-stream "0.0.7" + run-async "^2.2.0" + rxjs "^6.1.0" + string-width "^2.1.0" + strip-ansi "^4.0.0" + through "^2.3.6" + internal-ip@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-1.2.0.tgz#ae9fbf93b984878785d50a8de1b356956058cf5c" @@ -7312,6 +7352,13 @@ rx-lite@*, rx-lite@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444" +rxjs@^6.1.0: + version "6.3.3" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.3.3.tgz#3c6a7fa420e844a81390fb1158a9ec614f4bad55" + integrity sha512-JTWmoY9tWCs7zvIk/CvRjhjGaOd+OVBM987mxFo+OW66cGpdKjZcpmc74ES1sB//7Kl/PAe8+wEakuhG4pcgOw== + dependencies: + tslib "^1.9.0" + safe-buffer@5.1.1, safe-buffer@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" @@ -8106,6 +8153,11 @@ trim-right@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" +tslib@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" + integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== + tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"