/* eslint-env node */ /* eslint-disable no-console */ import fs from 'fs'; import path from 'path'; import CrowdinApi from 'crowdin-api'; import MultiProgress from 'multi-progress'; import ch from 'chalk'; import iso639 from 'iso-639-1'; import prompt from 'prompt'; import config from '../../config'; if (!config.crowdinApiKey) { console.error(ch.red`crowdinApiKey is required`); process.exit(126); } const PROJECT_ID = 'elyby'; const PROJECT_KEY = config.crowdinApiKey; const CROWDIN_FILE_PATH = 'accounts/site.json'; const SOURCE_LANG = 'en'; const LANG_DIR = path.resolve(`${__dirname}/../app/i18n`); const INDEX_FILE_NAME = 'index.js'; const MIN_RELEASE_PROGRESS = 80; // Minimal ready percent before translation can be published const crowdin = new CrowdinApi({ apiKey: PROJECT_KEY }); const progressBar = new MultiProgress(); /** * Locales that has been verified by core team members */ const RELEASED_LOCALES = ['be', 'fr', 'id', 'pt', 'ru', 'uk', 'vi', 'zh']; /** * Array of Crowdin locales to our internal locales representation */ const LOCALES_MAP = { 'pt-BR': 'pt', 'zh-CN': 'zh', }; /** * This array allows us to customise native languages names, because ISO-639-1 sometimes is strange */ const NATIVE_NAMES_MAP = { be: 'Беларуская', id: 'Bahasa Indonesia', lt: 'Lietuvių', pl: 'Polski', pt: 'Português do Brasil', sr: 'Српски', ro: 'Română', zh: '简体中文', }; /** * This arrays allows us to override Crowdin English languages names */ const ENGLISH_NAMES_MAP = { pt: 'Portuguese, Brazilian', sr: 'Serbian', zh: 'Simplified Chinese', }; /** * Converts Crowdin's language code to our internal value * * @param {string} code * @returns {string} */ function toInternalLocale(code: string): string { return LOCALES_MAP[code] || code; } /** * Форматирует входящий объект с переводами в итоговую строку в том формате, в каком они * хранятся в самом приложении * * @param {object} translates * @returns {string} */ function serializeToFormattedJson( translates: { [key: string]: any }, { asModule = false }: { asModule?: boolean } = {}, ): string { const src = JSON.stringify(sortByKeys(translates), null, 2); return asModule ? `module.exports = ${src};\n` : `${src}\n`; } /** * http://stackoverflow.com/a/29622653/5184751 * * @param {object} object * @returns {object} */ function sortByKeys(object: { [key: string]: any }): { [key: string]: any } { return Object.keys(object) .sort() .reduce((result, key) => { result[key] = object[key]; return result; }, {}); } interface ProjectInfoFile { node_type: 'file'; id: number; name: string; created: string; last_updated: string; last_accessed: string; last_revision: string; } interface ProjectInfoDirectory { node_type: 'directory'; id: number; name: string; files: Array; } interface ProjectInfoResponse { details: { source_language: { name: string; code: string; }; name: string; identifier: string; created: string; description: string; join_policy: string; last_build: string | null; last_activity: string; participants_count: string; // it's number, but string in the response logo_url: string | null; total_strings_count: string; // it's number, but string in the response total_words_count: string; // it's number, but string in the response duplicate_strings_count: number; duplicate_words_count: number; invite_url: { translator: string; proofreader: string; }; }; languages: Array<{ name: string; // English language name code: string; can_translate: 0 | 1; can_approve: 0 | 1; }>; files: Array; } async function pullLocales() { const { languages }: ProjectInfoResponse = await crowdin.projectInfo( PROJECT_ID, ); return languages; } interface LanguageStatusNode { node_type: 'directory' | 'file'; id: number; name: string; phrases: number; translated: number; approved: number; words: number; words_translated: number; words_approved: number; files: Array; } function findFile( root: Array, path: string, ): LanguageStatusNode | null { const [nodeToSearch, ...rest] = path.split('/'); for (const node of root) { if (node.name !== nodeToSearch) { continue; } if (rest.length === 0) { return node; } return findFile(node.files, rest.join('/')); } return null; } interface IndexFileEntry { code: string; name: string; englishName: string; progress: number; isReleased: boolean; } async function pull() { console.log('Pulling locales list...'); const locales = await pullLocales(); const checkingProgressBar = progressBar.newBar( '| Pulling locales info :bar :percent | :current/:total', { total: locales.length, incomplete: '\u2591', complete: '\u2588', width: locales.length, }, ); // Add prefix 'c' to current and total to prevent filling thees placeholders with real values const downloadingProgressBar = progressBar.newBar( '| Downloading translates :bar :percent | :cCurrent/:cTotal', { total: 100, incomplete: '\u2591', complete: '\u2588', width: locales.length, }, ); let downloadingTotal = 0; let downloadingReady = 0; const results = await Promise.all( locales.map(async locale => { const { files, }: { files: Array } = await crowdin.languageStatus( PROJECT_ID, locale.code, ); checkingProgressBar.tick(); const fileInfo = findFile(files, CROWDIN_FILE_PATH); if (fileInfo === null) { throw new Error( 'Unable to find translation file. Please check the CROWDIN_FILE_PATH param.', ); } const progress = (fileInfo.words_approved / fileInfo.words) * 100; if ( !RELEASED_LOCALES.includes(toInternalLocale(locale.code)) && progress < MIN_RELEASE_PROGRESS ) { return null; } downloadingProgressBar.update(downloadingReady / ++downloadingTotal, { cCurrent: downloadingReady, cTotal: downloadingTotal, }); const translatesFilePath = await crowdin.exportFile( PROJECT_ID, CROWDIN_FILE_PATH, locale.code, ); downloadingProgressBar.update(++downloadingReady / downloadingTotal, { cCurrent: downloadingReady, cTotal: downloadingTotal, }); return { locale, progress, translatesFilePath, }; }), ); console.log('Locales are downloaded. Writing them to file system.'); const indexFileEntries: { [key: string]: IndexFileEntry } = { en: { code: 'en', name: 'English', englishName: 'English', progress: 100, isReleased: true, }, }; await Promise.all( results.map( result => new Promise((resolve, reject) => { if (result === null) { resolve(); return; } const { locale: { code, name }, progress, translatesFilePath, } = result; const ourCode = toInternalLocale(code); indexFileEntries[ourCode] = { code: ourCode, name: NATIVE_NAMES_MAP[ourCode] || iso639.getNativeName(ourCode), englishName: ENGLISH_NAMES_MAP[ourCode] || name, progress: parseFloat(progress.toFixed(1)), isReleased: RELEASED_LOCALES.includes(ourCode), }; fs.copyFile( translatesFilePath, path.join(LANG_DIR, `${ourCode}.json`), 0, err => { err ? reject(err) : resolve(); }, ); }), ), ); console.log('Writing an index file.'); fs.writeFileSync( path.join(LANG_DIR, INDEX_FILE_NAME), serializeToFormattedJson(indexFileEntries, { asModule: true }), ); console.log(ch.green('The index file was successfully written')); } function push() { return new Promise((resolve, reject) => { prompt.start(); prompt.get( { properties: { disapprove: { description: 'Disapprove changed lines? [Y/n]', pattern: /^y|n$/i, message: 'Please enter "y" or "n"', default: 'y', before: value => value.toLowerCase() === 'y', }, }, }, async (err, { disapprove }) => { if (err) { reject(err); return; } console.log(`Publishing ${ch.bold(SOURCE_LANG)} translates file...`); await crowdin.updateFile( PROJECT_ID, { [CROWDIN_FILE_PATH]: path.join(LANG_DIR, `${SOURCE_LANG}.json`), }, { // eslint-disable-next-line @typescript-eslint/camelcase update_option: disapprove ? 'update_as_unapproved' : 'update_without_changes', }, ); console.log(ch.green('Success')); resolve(); }, ); }); } try { const action = process.argv[2]; switch (action) { case 'pull': pull(); break; case 'push': push(); break; default: console.error(`Unknown action ${action}`); } } catch (exception) { console.error(exception); }