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
JavaScript
;
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