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