UNPKG

i18n-ai-translate

Version:

AI-powered localization CLI, Node library, and GitHub Action. Translate i18next JSON, Gettext PO, Java .properties, and iOS .strings with ChatGPT, Claude, Gemini, or local Ollama models.

373 lines 19.4 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.translateDirectory = translateDirectory; exports.translateDirectoryDiff = translateDirectoryDiff; const utils_1 = require("./utils"); const constants_1 = require("./constants"); const diff_1 = require("diff"); const flat_1 = require("flat"); const registry_1 = require("./formats/registry"); const translate_1 = require("./translate"); const safe_1 = __importDefault(require("colors/safe")); const fs_1 = __importDefault(require("fs")); const path_1 = __importStar(require("path")); /** * Wraps translate to take all keys of all files in a directory and re-create the exact * directory structure and translations for the target language * @param options - The directory translation's options */ async function translateDirectory(options) { const fullBasePath = (0, utils_1.resolveInputPath)(options.baseDirectory); const sourceLanguagePath = path_1.default.resolve(fullBasePath, options.inputLanguageCode); if (!fs_1.default.existsSync(sourceLanguagePath)) { throw new Error(`Source language path does not exist. sourceLanguagePath = ${sourceLanguagePath}`); } const sourceFilePaths = (0, utils_1.getAllFilesInPath)(sourceLanguagePath); const inputJSON = {}; // Per source file, remember the adapter that handled it and the // sidecar from its read, keyed by the *output* path that // getTranslationDirectoryKey embeds. The write loop recovers the // same path by splitting the compound key, then uses these to // reconstruct each file in its own format. const fileAdapters = {}; for (const sourceFilePath of sourceFilePaths) { const adapter = options.format ? (0, registry_1.getAdapterByName)(options.format) : (0, registry_1.getAdapterForFile)(sourceFilePath); if (!adapter) { throw new Error(`Unknown format: ${options.format}`); } const { flat, sidecar } = adapter.read(fs_1.default.readFileSync(sourceFilePath, "utf-8")); fileAdapters[(0, utils_1.getTranslationDirectoryPath)(sourceFilePath, options.inputLanguageCode, options.outputLanguageCode)] = { adapter, sidecar }; for (const key in flat) { if (Object.prototype.hasOwnProperty.call(flat, key)) { inputJSON[(0, utils_1.getTranslationDirectoryKey)(sourceFilePath, key, options.inputLanguageCode, options.outputLanguageCode)] = flat[key]; } } } const inputLanguage = options.inputLanguageCode; let outputLanguage = ""; if (options.forceLanguageName) { outputLanguage = options.forceLanguageName; } else { outputLanguage = options.outputLanguageCode; } try { const outputJSON = (await (0, translate_1.translate)({ ...options, inputJSON, inputLanguageCode: inputLanguage, outputLanguageCode: outputLanguage, })); // Regroup the flat compound-keyed output back into per-file // objects. A value may be a nested object (JSON, which translate // re-nests on the FLATTEN_DELIMITER) or a string (formats whose // keys carry no delimiter); re-flattening below normalises both // back to each adapter's flat-map contract. const filesToObj = {}; for (const pathWithKey in outputJSON) { if (Object.prototype.hasOwnProperty.call(outputJSON, pathWithKey)) { const filePath = pathWithKey .split(utils_1.DIRECTORY_KEY_DELIMITER) .slice(0, -1) .join(utils_1.DIRECTORY_KEY_DELIMITER); if (!filesToObj[filePath]) { filesToObj[filePath] = {}; } const key = pathWithKey.split(utils_1.DIRECTORY_KEY_DELIMITER).pop(); filesToObj[filePath][key] = outputJSON[pathWithKey]; } } for (const perFilePath in filesToObj) { if (!Object.prototype.hasOwnProperty.call(filesToObj, perFilePath)) { continue; } const { adapter, sidecar } = fileAdapters[perFilePath]; const perFileFlat = (0, flat_1.flatten)(filesToObj[perFilePath], { delimiter: constants_1.FLATTEN_DELIMITER, }); const outputText = adapter.write(perFileFlat, sidecar, inputLanguage, outputLanguage); if (!options.dryRun) { fs_1.default.mkdirSync((0, path_1.dirname)(perFilePath), { recursive: true }); fs_1.default.writeFileSync(perFilePath, outputText); } else { // TODO: find a cleaner way to get the input file from here // Might lead to a bug if the path has the language code multiple times const relativeOutputPath = path_1.default.relative(options.baseDirectory, perFilePath); const inputRaw = fs_1.default.readFileSync(perFilePath.replace(`/${outputLanguage}/`, `/${inputLanguage}/`), "utf-8"); fs_1.default.mkdirSync((0, path_1.dirname)(`${options.dryRun.basePath}/${relativeOutputPath}`), { recursive: true }); fs_1.default.writeFileSync(`${options.dryRun.basePath}/${relativeOutputPath}`, outputText); const patch = (0, diff_1.createPatch)(perFilePath, // Use the absolute path for the patch header inputRaw, outputText); fs_1.default.writeFileSync(`${options.dryRun.basePath}/${relativeOutputPath}.patch`, patch); (0, utils_1.printInfo)(`Wrote new ${adapter.name} to ${options.dryRun.basePath}/${relativeOutputPath}`); (0, utils_1.printInfo)(`Wrote patch to ${options.dryRun.basePath}/${relativeOutputPath}.patch`); // The colored inline diff is JSON-aware; other adapters // still emit the patch file but skip the terminal diff. if (options.verbose && adapter.name === "json") { const translationDiff = (0, diff_1.diffJson)(inputRaw, outputText); for (const part of translationDiff) { const colorFns = { green: safe_1.default.green, grey: safe_1.default.grey, red: safe_1.default.red, }; let color; if (part.added) { color = "green"; } else if (part.removed) { color = "red"; } else { color = "grey"; } process.stderr.write(colorFns[color](part.value)); } } console.log(); } } } catch (err) { (0, utils_1.printError)(`Failed to translate directory to ${outputLanguage}: ${err}`); throw err; } } /** * Wraps translateDiff to take the changed keys of all files in a directory * and write the translation of those keys in the target translation * @param options - The directory translation diff's options */ async function translateDirectoryDiff(options) { const fullBasePath = (0, utils_1.resolveInputPath)(options.baseDirectory); const sourceLanguagePathBefore = path_1.default.resolve(fullBasePath, options.inputFolderNameBefore); const sourceLanguagePathAfter = path_1.default.resolve(fullBasePath, options.inputFolderNameAfter); if (!fs_1.default.existsSync(sourceLanguagePathBefore)) { throw new Error(`Source language path before does not exist. sourceLanguagePathBefore = ${sourceLanguagePathBefore}`); } if (!fs_1.default.existsSync(sourceLanguagePathAfter)) { throw new Error(`Source language path after does not exist. sourceLanguagePathAfter = ${sourceLanguagePathAfter}`); } const resolveAdapter = (file) => { const adapter = options.format ? (0, registry_1.getAdapterByName)(options.format) : (0, registry_1.getAdapterForFile)(file); if (!adapter) { throw new Error(`Unknown format: ${options.format}`); } return adapter; }; // TODO: abstract to fn const sourceFilePathsBefore = (0, utils_1.getAllFilesInPath)(sourceLanguagePathBefore); const inputJSONBefore = {}; for (const sourceFilePath of sourceFilePathsBefore) { const { flat } = resolveAdapter(sourceFilePath).read(fs_1.default.readFileSync(sourceFilePath, "utf-8")); for (const key in flat) { if (Object.prototype.hasOwnProperty.call(flat, key)) { inputJSONBefore[(0, utils_1.getTranslationDirectoryKey)(sourceFilePath, key, options.inputLanguageCode)] = flat[key]; } } } // The *after* catalogue is what every target file is rebuilt from, // so keep each file's adapter + sidecar keyed by the before-folder // path that the compound keys carry (writeLanguageOutput recovers // the same path by splitting the key). const fileAdapters = {}; const sourceFilePathsAfter = (0, utils_1.getAllFilesInPath)(sourceLanguagePathAfter); const inputJSONAfter = {}; for (const sourceFilePath of sourceFilePathsAfter) { const adapter = resolveAdapter(sourceFilePath); const { flat, sidecar } = adapter.read(fs_1.default.readFileSync(sourceFilePath, "utf-8")); const beforeEquivalentPath = sourceFilePath.replace(options.inputFolderNameAfter, options.inputFolderNameBefore); fileAdapters[(0, utils_1.getTranslationDirectoryPath)(beforeEquivalentPath, options.inputLanguageCode)] = { adapter, sidecar }; for (const key in flat) { if (Object.prototype.hasOwnProperty.call(flat, key)) { inputJSONAfter[(0, utils_1.getTranslationDirectoryKey)(beforeEquivalentPath, key, options.inputLanguageCode)] = flat[key]; } } } const excludeSet = new Set(options.excludeLanguages ?? []); const outputLanguagePaths = fs_1.default .readdirSync(options.baseDirectory) .filter((folder) => folder !== path_1.default.basename(options.inputFolderNameBefore) && folder !== path_1.default.basename(options.inputFolderNameAfter)) .filter((folder) => !excludeSet.has(folder)) .map((folder) => path_1.default.resolve(options.baseDirectory, folder)); const toUpdateJSONs = {}; for (const outputLanguagePath of outputLanguagePaths) { const files = (0, utils_1.getAllFilesInPath)(outputLanguagePath); for (const file of files) { const adapter = resolveAdapter(file); const raw = fs_1.default.readFileSync(file, "utf-8"); // Existing target translations: read msgstr-style slots when // the format separates source from target, else fall back. const flat = adapter.readTranslated ? adapter.readTranslated(raw).flat : adapter.read(raw).flat; const relative = path_1.default.relative(options.baseDirectory, outputLanguagePath); const segments = relative.split(path_1.default.sep).filter(Boolean); const language = segments[0]; if (!toUpdateJSONs[language]) { toUpdateJSONs[language] = {}; } for (const key in flat) { if (Object.prototype.hasOwnProperty.call(flat, key)) { toUpdateJSONs[language][(0, utils_1.getTranslationDirectoryKey)(`${fullBasePath}/${file.replace(outputLanguagePath, options.inputFolderNameBefore)}`, key, options.inputLanguageCode)] = flat[key]; } } } } const inputLanguage = options.inputLanguageCode; // Split one language's flat `filepath:key → value` map into per-file // flat maps, then write each file. The same code path serves the // non-dry-run write case and the dry-run patch-emission case. const writeLanguageOutput = (outputLanguage, flatOutputJSON) => { const beforeBaseName = path_1.default.basename(path_1.default.resolve(options.baseDirectory, options.inputFolderNameBefore)); // Regroup the flat compound-keyed output per source file. In diff // mode translateDiff re-flattens, so each value is already a // string keyed by the adapter's own flat key — no per-file // unflatten is needed before handing it back to the adapter. const filesToFlat = {}; for (const pathWithKey in flatOutputJSON) { if (!Object.prototype.hasOwnProperty.call(flatOutputJSON, pathWithKey)) { continue; } const beforePath = pathWithKey .split(utils_1.DIRECTORY_KEY_DELIMITER) .slice(0, -1) .join(utils_1.DIRECTORY_KEY_DELIMITER); if (!filesToFlat[beforePath]) filesToFlat[beforePath] = {}; const key = pathWithKey.split(utils_1.DIRECTORY_KEY_DELIMITER).pop(); filesToFlat[beforePath][key] = flatOutputJSON[pathWithKey]; } for (const beforePath in filesToFlat) { if (!Object.prototype.hasOwnProperty.call(filesToFlat, beforePath)) { continue; } const meta = fileAdapters[beforePath]; if (!meta) continue; const outputFilePath = beforePath.replace(`/${beforeBaseName}/`, `/${outputLanguage}/`); const outputText = meta.adapter.write(filesToFlat[beforePath], meta.sidecar, inputLanguage, outputLanguage); if (!options.dryRun) { fs_1.default.mkdirSync((0, path_1.dirname)(outputFilePath), { recursive: true }); fs_1.default.writeFileSync(outputFilePath, outputText); } else { const relativeOutputPath = path_1.default.relative(options.baseDirectory, outputFilePath); const rawBefore = fs_1.default.existsSync(outputFilePath) ? fs_1.default.readFileSync(outputFilePath, "utf-8") : ""; fs_1.default.mkdirSync((0, path_1.dirname)(`${options.dryRun.basePath}/${relativeOutputPath}`), { recursive: true }); fs_1.default.writeFileSync(`${options.dryRun.basePath}/${relativeOutputPath}`, outputText); const patch = (0, diff_1.createPatch)(outputFilePath, // Use the absolute path for the patch header rawBefore, outputText); fs_1.default.writeFileSync(`${options.dryRun.basePath}/${relativeOutputPath}.patch`, patch); (0, utils_1.printInfo)(`Wrote new ${meta.adapter.name} to ${options.dryRun.basePath}/${relativeOutputPath}`); (0, utils_1.printInfo)(`Wrote patch to ${options.dryRun.basePath}/${relativeOutputPath}.patch`); // Colored inline diff is JSON-aware; other adapters still // emit the patch file but skip the terminal diff. if (options.verbose && meta.adapter.name === "json" && rawBefore) { const translationDiff = (0, diff_1.diffJson)(rawBefore, outputText); for (const part of translationDiff) { const colorFns = { green: safe_1.default.green, grey: safe_1.default.grey, red: safe_1.default.red, }; let color; if (part.added) { color = "green"; } else if (part.removed) { color = "red"; } else { color = "grey"; } process.stderr.write(colorFns[color](part.value)); } } } console.log(); } }; try { await (0, translate_1.translateDiff)({ ...options, inputJSONAfter, inputJSONBefore, inputLanguageCode: inputLanguage, // Persist each language as it finishes so a later crash // doesn't discard earlier work. Dry-run still emits its // patches here — the emission is still streaming, but each // dry-run language's patches land together. onLanguageComplete: (outputLanguage, _unflattened, flat) => writeLanguageOutput(outputLanguage, flat), toUpdateJSONs, }); } catch (err) { (0, utils_1.printError)(`Failed to translate directory diff: ${err}`); throw err; } // Remove any files in before not in after const fileNamesBefore = sourceFilePathsBefore.map((x) => x.slice(sourceLanguagePathBefore.length)); const fileNamesAfter = sourceFilePathsAfter.map((x) => x.slice(sourceLanguagePathAfter.length)); const removedFiles = fileNamesBefore.filter((x) => !fileNamesAfter.includes(x)); for (const languagePath of outputLanguagePaths) { for (const removedFile of removedFiles) { const removedFilePath = languagePath + removedFile; fs_1.default.rmSync(removedFilePath); // Recursively cleanup parent folders if they're also empty let folder = path_1.default.dirname(removedFilePath); while (fs_1.default.readdirSync(folder).length === 0) { const parentFolder = path_1.default.resolve(folder, ".."); fs_1.default.rmdirSync(folder); folder = parentFolder; } } } } //# sourceMappingURL=translate_directory.js.map