import { readFileSync, writeFileSync, existsSync, readdirSync } from "fs"; import { resolve } from "path"; import { execSync } from "child_process"; import * as ts from "typescript"; import * as YAML from "yaml"; function parseExpression(expr: ts.Expression): string { switch (expr.kind) { case ts.SyntaxKind.StringLiteral: return (expr as ts.StringLiteral).text; case ts.SyntaxKind.BinaryExpression: const bin = expr as ts.BinaryExpression; return parseExpression(bin.left) + parseExpression(bin.right); default: return ""; } } export interface ItemSource { file: string; line: number; character: number; comment?: string; } export interface TranslationItem { original: string; translation: string; sources: ItemSource[]; } export interface Translation { language: string; commit: string; date: Date; items: TranslationItem[]; } function extract(files: ts.SourceFile[]) { const items = new Map(); function traverseFile(file: ts.SourceFile) { function traverseNode(node: ts.Node) { if (node.kind === ts.SyntaxKind.CallExpression) { const call = node as ts.CallExpression; if ((call.expression as ts.Identifier).escapedText === "$l") { const fileText = file.getText(); const { line, character } = file.getLineAndCharacterOfPosition(call.getFullStart()); const keyExpr = call.arguments[0]; const original = parseExpression(keyExpr); if (original) { if (!items.has(original)) { items.set(original, { original, translation: "", sources: [], }); } const item = items.get(original)!; const source: ItemSource = { file: file.fileName, line, character, }; const comments = ts.getLeadingCommentRanges(fileText, keyExpr.getFullStart()) || ts.getTrailingCommentRanges(fileText, keyExpr.getFullStart()); if (comments && comments.length) { const rawComment = fileText.substring(comments[0].pos, comments[0].end); const matchComment = rawComment.match(/@tcomment: ([^\*\n]+)/); if (matchComment && matchComment[1]) { source.comment = matchComment[1].trim(); } } item.sources.push(source); return; } } } ts.forEachChild(node, traverseNode); } traverseNode(file); } files.forEach(traverseFile); return [...items.values()]; } export function toYAML({ language, date, commit, items }: Translation) { const doc = new YAML.Document(); doc.commentBefore = ` Padloc Translation File language: ${language} date: ${date.toISOString()} commit: ${commit} `; doc.contents = YAML.createNode(items.flatMap((item) => [item.original, item.translation])) as any; for (const [i, item] of items.entries()) { const node = (doc.contents as any).items[i * 2]; node.commentBefore = item.sources .map( ({ file, line, character, comment }) => ` ${file}:${line},${character}${comment ? ` (${comment})` : ""}` ) .join("\n"); (node as any).spaceBefore = true; } return doc.toString(); } export function fromYAML(str: string, language: string): Translation { const raw = YAML.parse(str) as string[]; const items: TranslationItem[] = []; for (let i = 0; i < raw.length; i += 2) { items.push({ original: raw[i], translation: raw[i + 1], sources: [] }); } return { language, date: new Date(), commit: "", items, }; } export function toModule(translation: Translation) { return `\ import { parse } from "yaml"; export default parse(\` ${toYAML(translation)} \`); `; } export function toJSON(translation: Translation) { return JSON.stringify( translation.items.map(({ original, translation }) => [original, translation]), null, 2 ); } export function fromJSON(str: string, language: string) { const items = JSON.parse(str).map(([original, translation]: [string, string]) => ({ original, translation, sources: [], })); return { language, date: new Date(), commit: "", items, }; } export function fromSource(fileNames: string[], language: string): Translation { const files = fileNames.map((path) => ts.createSourceFile(path, readFileSync(resolve(path), "utf8"), ts.ScriptTarget.ES2017, false) ); const commit = execSync("git rev-parse HEAD").toString().trim(); const items = extract(files); return { language, commit, date: new Date(), items, }; } export function merge(curr: Translation, prev: Translation) { for (const item of curr.items) { const prevItemIndex = prev.items.findIndex((i) => i.original === item.original); if (prevItemIndex !== -1) { item.translation = prev.items[prevItemIndex].translation; prev.items.splice(prevItemIndex, 1); } } } export function updateTranslation(sources: string[], language: string, dest: string) { const destPath = resolve(dest, language + ".json"); const backupPath = resolve(dest, language + ".backup.json"); const translation = fromSource(sources, language); if (existsSync(destPath)) { const previous = fromJSON(readFileSync(destPath, "utf-8"), language); merge(translation, previous); writeFileSync(backupPath, toJSON(previous)); } writeFileSync(destPath, toJSON(translation)); } function main() { const translationsDir = resolve(__dirname, "../res/translations/"); const [, , ...args] = process.argv; const languages: string[] = []; const files: string[] = []; while (args.length) { // Languages can be specified explictly using the "-l" flag if (args[0] === "-l") { if (!args[1]) { throw "No language provided after -l flag"; } languages.push(args[1]); args.splice(0, 2); } else { files.push(args.shift() as string); } } // If not languagese were specified explicitly, update all existing language files if (!languages.length) { for (const file of readdirSync(translationsDir)) { const match = file.match(/^([^\.]+)\.json$/); if (match) { languages.push(match[1]); } } } for (const lang of languages) { console.log("updating translation keys for language: " + lang); updateTranslation(files, lang, translationsDir); } } main();