accounts-frontend/packages/scripts/i18n-crowdin.ts

300 lines
8.9 KiB
TypeScript
Raw Normal View History

/* eslint-env node */
2020-01-17 15:14:22 +05:30
/* eslint-disable */
import fs from 'fs';
import path from 'path';
2020-05-24 04:38:24 +05:30
import CrowdinApi, { LanguageStatusNode, LanguageStatusResponse, ProjectInfoResponse } from 'crowdin-api';
import MultiProgress from 'multi-progress';
import ch from 'chalk';
import iso639 from 'iso-639-1';
import prompt from 'prompt';
2020-01-17 15:14:22 +05:30
import { ValuesType } from 'utility-types';
import config from '../../config';
2019-11-09 18:12:02 +05:30
if (!config.crowdinApiKey) {
2020-05-24 04:38:24 +05:30
console.error(ch.red`crowdinApiKey is required`);
process.exit(126);
}
const PROJECT_ID = 'elyby';
2019-11-09 18:12:02 +05:30
const PROJECT_KEY = config.crowdinApiKey;
const CROWDIN_FILE_PATH = 'accounts/site.json';
const SOURCE_LANG = 'en';
const LANG_DIR = path.resolve(`${__dirname}/../app/i18n`);
2019-12-07 16:58:52 +05:30
const INDEX_FILE_NAME = 'index.js';
const MIN_RELEASE_PROGRESS = 80; // Minimal ready percent before translation can be published
2020-01-17 15:14:22 +05:30
const crowdin = new CrowdinApi({
2020-05-24 04:38:24 +05:30
apiKey: PROJECT_KEY,
projectName: PROJECT_ID,
2020-01-17 15:14:22 +05:30
});
const progressBar = new MultiProgress();
/**
* Locales that has been verified by core team members
*/
2020-05-24 04:38:24 +05:30
const RELEASED_LOCALES: Array<string> = ['be', 'fr', 'id', 'pt', 'ru', 'uk', 'vi', 'zh'];
/**
* Array of Crowdin locales to our internal locales representation
*/
2020-01-17 15:14:22 +05:30
const LOCALES_MAP: Record<string, string> = {
2020-05-24 04:38:24 +05:30
'pt-BR': 'pt',
'zh-CN': 'zh',
};
/**
* This array allows us to customise native languages names, because ISO-639-1 sometimes is strange
*/
2020-01-17 15:14:22 +05:30
const NATIVE_NAMES_MAP: Record<string, string> = {
2020-05-24 04:38:24 +05:30
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
*/
2020-01-17 15:14:22 +05:30
const ENGLISH_NAMES_MAP: Record<string, string> = {
2020-05-24 04:38:24 +05:30
pt: 'Portuguese, Brazilian',
sr: 'Serbian',
zh: 'Simplified Chinese',
};
/**
* Converts Crowdin's language code to our internal value
*/
function toInternalLocale(code: string): string {
2020-05-24 04:38:24 +05:30
return LOCALES_MAP[code] || code;
}
/**
* Форматирует входящий объект с переводами в итоговую строку в том формате, в каком они
* хранятся в самом приложении
*/
2020-01-17 15:14:22 +05:30
function serializeToModule(translates: Record<string, any>): string {
2020-05-24 04:38:24 +05:30
const src = JSON.stringify(sortByKeys(translates), null, 2);
2019-12-07 16:58:52 +05:30
2020-05-24 04:38:24 +05:30
return `module.exports = ${src};\n`;
}
2020-01-17 15:14:22 +05:30
// http://stackoverflow.com/a/29622653/5184751
function sortByKeys<T extends Record<string, any>>(object: T): T {
2020-05-24 04:38:24 +05:30
return Object.keys(object)
.sort()
.reduce((result, key) => {
// @ts-ignore
result[key] = object[key];
return result;
}, {} as T);
}
2020-01-17 15:14:22 +05:30
async function pullLocales(): Promise<ProjectInfoResponse['languages']> {
2020-05-24 04:38:24 +05:30
const { languages } = await crowdin.projectInfo();
2020-05-24 04:38:24 +05:30
return languages;
}
2020-05-24 04:38:24 +05:30
function findFile(root: LanguageStatusResponse['files'], path: string): LanguageStatusNode | null {
const [nodeToSearch, ...rest] = path.split('/');
2020-05-24 04:38:24 +05:30
for (const node of root) {
if (node.name !== nodeToSearch) {
continue;
}
2020-05-24 04:38:24 +05:30
if (rest.length === 0) {
return node;
}
2020-05-24 04:38:24 +05:30
return findFile(node.files, rest.join('/'));
}
2020-05-24 04:38:24 +05:30
return null;
}
interface IndexFileEntry {
2020-05-24 04:38:24 +05:30
code: string;
name: string;
englishName: string;
progress: number;
isReleased: boolean;
}
async function pull() {
2020-05-24 04:38:24 +05:30
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;
interface Result {
locale: ValuesType<typeof locales>;
progress: number;
translatesFilePath: string;
}
2020-05-24 04:38:24 +05:30
const results = await Promise.all(
// TODO: there is should be some way to reimplement this
// with reduce to avoid null values
locales.map(
async (locale): Promise<Result | null> => {
const { files } = await crowdin.languageStatus(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(CROWDIN_FILE_PATH, locale.code);
downloadingProgressBar.update(++downloadingReady / downloadingTotal, {
cCurrent: downloadingReady,
cTotal: downloadingTotal,
});
return {
locale,
progress,
translatesFilePath,
};
},
),
);
2020-05-24 04:38:24 +05:30
console.log('Locales are downloaded. Writing them to file system.');
2020-05-24 04:38:24 +05:30
const indexFileEntries: { [key: string]: IndexFileEntry } = {
en: {
code: 'en',
name: 'English',
englishName: 'English',
progress: 100,
isReleased: true,
},
};
await Promise.all(
results
.filter((result): result is Result => result !== null)
.map(
(result) =>
new Promise((resolve, reject) => {
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();
});
}),
),
);
2020-05-24 04:38:24 +05:30
console.log('Writing an index file.');
fs.writeFileSync(path.join(LANG_DIR, INDEX_FILE_NAME), serializeToModule(indexFileEntries));
2020-05-24 04:38:24 +05:30
console.log(ch.green('The index file was successfully written'));
}
function push() {
2020-05-24 04:38:24 +05:30
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(
{
[CROWDIN_FILE_PATH]: path.join(LANG_DIR, `${SOURCE_LANG}.json`),
},
{
update_option: disapprove ? 'update_as_unapproved' : 'update_without_changes',
},
);
console.log(ch.green('Success'));
resolve();
},
);
2020-05-24 04:38:24 +05:30
});
}
try {
2020-05-24 04:38:24 +05:30
const action = process.argv[2];
switch (action) {
case 'pull':
pull();
break;
case 'push':
push();
break;
default:
console.error(`Unknown action ${action}`);
}
} catch (exception) {
2020-05-24 04:38:24 +05:30
console.error(exception);
}