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.

260 lines 12 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.translateFile = translateFile; exports.translateFileDiff = translateFileDiff; const constants_1 = require("./constants"); const diff_1 = require("diff"); const flat_1 = require("flat"); const registry_1 = require("./formats/registry"); const utils_1 = require("./utils"); const translate_1 = require("./translate"); const safe_1 = __importDefault(require("colors/safe")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); /** * Wraps translate to take an input file and output its translation to another file * @param options - The file translation's options */ async function translateFile(options) { const adapter = options.format ? (0, registry_1.getAdapterByName)(options.format) : (0, registry_1.getAdapterForFile)(options.inputFilePath); if (!adapter) { (0, utils_1.printError)(`Unknown format: ${options.format}`); return; } let rawInput; try { rawInput = fs_1.default.readFileSync(options.inputFilePath, "utf-8"); } catch (e) { (0, utils_1.printError)(`Failed to read input file: ${e}`); return; } let flatInput; let sidecar; try { ({ flat: flatInput, sidecar } = adapter.read(rawInput)); } catch (e) { (0, utils_1.printError)(`Invalid input (${adapter.name}): ${e}`); return; } const inputLanguage = (0, utils_1.getLanguageCodeFromFilename)(options.inputFilePath); let outputLanguage = ""; if (options.forceLanguageName) { outputLanguage = options.forceLanguageName; } else { outputLanguage = (0, utils_1.getLanguageCodeFromFilename)(options.outputFilePath); } try { const outputJSON = await (0, translate_1.translate)({ ...options, inputJSON: flatInput, inputLanguageCode: inputLanguage, outputLanguageCode: outputLanguage, }); // translate() returns an unflattened object; the adapter // contract takes a flat map. Re-flattening is cheap and keeps // the adapter agnostic of the pipeline's internal shape. const flatOutput = (0, flat_1.flatten)(outputJSON, { delimiter: constants_1.FLATTEN_DELIMITER, }); const outputText = adapter.write(flatOutput, sidecar, inputLanguage, outputLanguage); if (!options.dryRun) { fs_1.default.writeFileSync(options.outputFilePath, outputText); } else { fs_1.default.writeFileSync(`${options.dryRun.basePath}/${path_1.default.basename(options.outputFilePath)}.new.${adapter.name}`, outputText); const patch = (0, diff_1.createPatch)(options.outputFilePath, rawInput, outputText); fs_1.default.writeFileSync(`${options.dryRun.basePath}/${path_1.default.basename(options.outputFilePath)}.patch`, patch); (0, utils_1.printInfo)(`Wrote new ${adapter.name} to ${options.dryRun.basePath}/${path_1.default.basename(options.outputFilePath)}.new.${adapter.name}`); (0, utils_1.printInfo)(`Wrote patch to ${options.dryRun.basePath}/${path_1.default.basename(options.outputFilePath)}.patch`); // Colored inline diff is JSON-aware today; future adapters // can bring their own formatter if this limits them. if (options.verbose && adapter.name === "json") { const unflattenedOutput = (0, flat_1.unflatten)(flatOutput, { delimiter: constants_1.FLATTEN_DELIMITER, }); const translationDiff = (0, diff_1.diffJson)(JSON.parse(rawInput), unflattenedOutput); 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 file to ${outputLanguage}: ${err}`); throw err; } } /** * Wraps translateDiff to take two versions of a source file and update * the target translation's file by only modifying keys that changed in the source * @param options - The file diff translation's options */ async function translateFileDiff(options) { const adapter = options.format ? (0, registry_1.getAdapterByName)(options.format) : (0, registry_1.getAdapterForFile)(options.inputBeforeFileOrPath); if (!adapter) { (0, utils_1.printError)(`Unknown format: ${options.format}`); return; } // Sibling target files share the source's format/extension. const excludeSet = new Set(options.excludeLanguages ?? []); const outputFilesOrPaths = fs_1.default .readdirSync(path_1.default.dirname(options.inputBeforeFileOrPath)) .filter((file) => adapter.extensions.some((ext) => file.toLowerCase().endsWith(ext.toLowerCase()))) .filter((file) => file !== path_1.default.basename(options.inputBeforeFileOrPath) && file !== path_1.default.basename(options.inputAfterFileOrPath)) .filter((file) => { // Filter by extracted language code; accept either the // full filename or just the code in --exclude-languages. if (excludeSet.size === 0) return true; const code = (0, utils_1.getLanguageCodeFromFilename)(file); return !excludeSet.has(code) && !excludeSet.has(file); }) .map((file) => path_1.default.resolve(path_1.default.dirname(options.inputBeforeFileOrPath), file)); const inputBeforePath = (0, utils_1.resolveInputPath)(options.inputBeforeFileOrPath); const inputAfterPath = (0, utils_1.resolveInputPath)(options.inputAfterFileOrPath); const outputPaths = outputFilesOrPaths.map(utils_1.resolveInputPath); // Read both source revisions through the adapter. The *after* // sidecar is the catalogue we rebuild every target file from, so it // carries the current comments/structure/headers. let inputBeforeFlat; let inputAfterFlat; let afterSidecar; try { inputBeforeFlat = adapter.read(fs_1.default.readFileSync(inputBeforePath, "utf-8")).flat; const afterRead = adapter.read(fs_1.default.readFileSync(inputAfterPath, "utf-8")); inputAfterFlat = afterRead.flat; afterSidecar = afterRead.sidecar; } catch (e) { (0, utils_1.printError)(`Invalid input (${adapter.name}): ${e}`); return; } // Existing target files are read with readTranslated when the format // distinguishes source from target (PO: msgstr vs msgid); formats // that don't (JSON) fall back to read. const readTarget = (raw) => adapter.readTranslated ? adapter.readTranslated(raw).flat : adapter.read(raw).flat; const toUpdateJSONs = {}; const languageCodeToOutputPath = {}; // Raw target contents retained for dry-run patch/diff "before" text. const rawTargetByLanguage = {}; // Validate every target locale up front so a bad code fails fast // before the first API call, instead of aborting mid-batch after // some locales have already incurred cost. const invalidTargets = []; for (const outputPath of outputPaths) { const languageCode = (0, utils_1.getLanguageCodeFromFilename)(path_1.default.basename(outputPath)); if (!languageCode || !(0, utils_1.isValidLanguageCode)(languageCode)) { invalidTargets.push(path_1.default.basename(outputPath)); continue; } try { const raw = fs_1.default.readFileSync(outputPath, "utf-8"); toUpdateJSONs[languageCode] = readTarget(raw); languageCodeToOutputPath[languageCode] = outputPath; rawTargetByLanguage[languageCode] = raw; } catch (e) { (0, utils_1.printError)(`Invalid output (${adapter.name}): ${e}`); } } if (invalidTargets.length > 0) { (0, utils_1.printWarn)(`Skipping ${invalidTargets.length} file(s) with unrecognised language codes: ${invalidTargets.join(", ")}`); } // Write (or, for dry-run, emit artifacts for) one finished language. const writeTarget = (languageCode, flatMap) => { const outputPath = languageCodeToOutputPath[languageCode]; if (!outputPath) return; const outputText = adapter.write(flatMap, afterSidecar, options.inputLanguageCode, languageCode); if (!options.dryRun) { fs_1.default.writeFileSync(outputPath, outputText); return; } const rawBefore = rawTargetByLanguage[languageCode] ?? ""; fs_1.default.writeFileSync(`${options.dryRun.basePath}/${path_1.default.basename(outputPath)}.new.${adapter.name}`, outputText); const patch = (0, diff_1.createPatch)(outputPath, rawBefore, outputText); fs_1.default.writeFileSync(`${options.dryRun.basePath}/${path_1.default.basename(outputPath)}.patch`, patch); (0, utils_1.printInfo)(`Wrote new ${adapter.name} to ${options.dryRun.basePath}/${path_1.default.basename(outputPath)}.new.${adapter.name}`); (0, utils_1.printInfo)(`Wrote patch to ${options.dryRun.basePath}/${path_1.default.basename(outputPath)}.patch`); if (options.verbose && adapter.name === "json") { const translationDiff = (0, diff_1.diffJson)(rawBefore ? JSON.parse(rawBefore) : {}, (0, flat_1.unflatten)(flatMap, { delimiter: constants_1.FLATTEN_DELIMITER })); 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 { const outputJSON = await (0, translate_1.translateDiff)({ ...options, inputJSONAfter: inputAfterFlat, inputJSONBefore: inputBeforeFlat, // Persist each locale as soon as it finishes so a crash // later in the run doesn't discard already-translated work. // Dry-run output is emitted below in aggregate instead. onLanguageComplete: options.dryRun ? undefined : (languageCode, _unflattened, flat) => writeTarget(languageCode, flat), toUpdateJSONs, }); if (options.dryRun) { for (const language in outputJSON) { if (Object.prototype.hasOwnProperty.call(outputJSON, language)) { const flatMap = (0, flat_1.flatten)(outputJSON[language], { delimiter: constants_1.FLATTEN_DELIMITER, }); writeTarget(language, flatMap); } } } } catch (err) { (0, utils_1.printError)(`Failed to translate file diff: ${err}`); throw err; } } //# sourceMappingURL=translate_file.js.map