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.
307 lines • 12.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.DIRECTORY_KEY_DELIMITER = void 0;
exports.delay = delay;
exports.printError = printError;
exports.printWarn = printWarn;
exports.printInfo = printInfo;
exports.retryJob = retryJob;
exports.getLanguageCodeFromFilename = getLanguageCodeFromFilename;
exports.getAllLanguageCodes = getAllLanguageCodes;
exports.isValidLanguageCode = isValidLanguageCode;
exports.getLanguageName = getLanguageName;
exports.resolveLanguageCode = resolveLanguageCode;
exports.getAllFilesInPath = getAllFilesInPath;
exports.getTranslationDirectoryPath = getTranslationDirectoryPath;
exports.getTranslationDirectoryKey = getTranslationDirectoryKey;
exports.isNAK = isNAK;
exports.isACK = isACK;
exports.getMissingVariables = getMissingVariables;
exports.getTemplatedStringRegex = getTemplatedStringRegex;
exports.printExecutionTime = printExecutionTime;
exports.printProgress = printProgress;
exports.getOutputPathFromInputPath = getOutputPathFromInputPath;
exports.resolveInputPath = resolveInputPath;
exports.resolveOutputPath = resolveOutputPath;
const iso_639_1_1 = __importDefault(require("iso-639-1"));
const ansi_colors_1 = __importDefault(require("ansi-colors"));
const fs_1 = __importDefault(require("fs"));
const path_1 = __importDefault(require("path"));
/**
* @param delayDuration - time (in ms) to delay
* @returns a promise that resolves after delayDuration
*/
function delay(delayDuration) {
// eslint-disable-next-line no-promise-executor-return
return new Promise((resolve) => setTimeout(resolve, delayDuration));
}
/**
* @param error - the error message
*/
function printError(error) {
console.error(ansi_colors_1.default.redBright(error));
}
/**
* @param warn - the warning message
*/
function printWarn(warn) {
console.warn(ansi_colors_1.default.yellowBright(warn));
}
/**
* @param info - the message
*/
function printInfo(info) {
console.log(ansi_colors_1.default.cyanBright(info));
}
/**
* @param job - the function to retry
* @param jobArgs - arguments to pass to job
* @param maxRetries - retries of job before throwing
* @param firstTry - whether this is the first try
* @param delayDuration - time (in ms) before attempting job retry
* @param sendError - whether to send a warning or error
* @returns the result of job
*/
async function retryJob(job, jobArgs, maxRetries, firstTry, delayDuration, sendError = true) {
if (!firstTry && delayDuration) {
await delay(delayDuration);
}
return job(...jobArgs).catch((err) => {
if (sendError) {
printError(`err = ${err}`);
}
else {
printWarn(`err = ${err}`);
}
if (maxRetries <= 0) {
throw err;
}
return retryJob(job, jobArgs, maxRetries - 1, false, delayDuration);
});
}
/**
* Extract the language code from a filename like `fr.json` or
* `es-ES.json`. If the full prefix (e.g. `es-ES`) is not a valid
* ISO-639-1 code, fall back to the portion before the first hyphen —
* BCP-47 locale tags like `es-ES` / `pt-BR` / `zh-CN` are common in
* i18next projects and should be accepted. If neither form is valid,
* the raw prefix is returned so the caller can surface a clear error.
* @param filename - the filename to get the language from
* @returns the language code from the filename
*/
function getLanguageCodeFromFilename(filename) {
const base = path_1.default.basename(filename);
const [prefix] = base.split(".");
if (iso_639_1_1.default.validate(prefix))
return prefix;
const [baseTag] = prefix.split("-");
if (iso_639_1_1.default.validate(baseTag))
return baseTag;
return prefix;
}
/**
* @returns all language codes
*/
function getAllLanguageCodes() {
return iso_639_1_1.default.getAllCodes();
}
/**
* @param languageCode - the language code to validate
* @returns whether the language code is valid
*/
function isValidLanguageCode(languageCode) {
return iso_639_1_1.default.validate(languageCode);
}
/**
* Expand an ISO-639-1 code to its English display name (e.g. "en" →
* "English"). Used in prompts because language names steer the LLM
* much better than the two-letter code does. Falls back to the raw
* code if the lookup fails so prompts never break.
* @param languageCode - the ISO-639-1 code
* @returns the English display name, or the raw code if unknown
*/
function getLanguageName(languageCode) {
const name = iso_639_1_1.default.getName(languageCode);
return name || languageCode;
}
/**
* Accept both ISO-639-1 codes ("en") and English language names
* ("English", "english", "ENGLISH") and normalise to the code. Returns
* the input unchanged when no match is found so the caller's existing
* validation can surface a clear error.
*
* This covers a common footgun flagged in BUG_REPORT.md and issue #5 —
* users passed `-l English` based on older docs and got a cryptic
* 'Invalid input language code: English' instead of a hint.
* @param raw - the user-supplied language identifier
* @returns the resolved ISO-639-1 code, or the raw input if unresolved
*/
function resolveLanguageCode(raw) {
if (!raw)
return raw;
if (iso_639_1_1.default.validate(raw))
return raw;
const normalized = raw.trim().toLowerCase();
for (const code of iso_639_1_1.default.getAllCodes()) {
if (iso_639_1_1.default.getName(code).toLowerCase() === normalized) {
return code;
}
}
return raw;
}
/**
* @param directory - the directory to list all files for
* @returns all files with their absolute path that exist within the directory, recursively
*/
function getAllFilesInPath(directory) {
const files = [];
for (const fileOrDir of fs_1.default.readdirSync(directory)) {
const fullPath = path_1.default.join(directory, fileOrDir);
if (fs_1.default.lstatSync(fullPath).isDirectory()) {
files.push(...getAllFilesInPath(fullPath));
}
else {
files.push(fullPath);
}
}
return files;
}
/**
* ASCII Unit Separator (0x1F). Used to join a file path with an i18n
* key into a single compound key string. Chosen because no legal file
* path on any platform can contain it — unlike `:`, which is a drive
* letter separator on Windows and broke directory mode on that OS.
*/
exports.DIRECTORY_KEY_DELIMITER = "\x1f";
/**
* Swap the input-language segment of a source file path for the output
* language, normalising separators. This is the path half of
* {@link getTranslationDirectoryKey}; the directory wrappers key
* per-file adapter state (sidecar, chosen adapter) by it so the write
* loop can recover that state after translation. Normalizing to forward
* slashes keeps the language-segment match working on Windows (where
* path.resolve returns backslash paths) and POSIX alike.
* @param sourceFilePath - the source file's path
* @param inputLanguageCode - the source language code
* @param outputLanguageCode - the target language code (defaults to input)
* @returns the output file path with forward slashes
*/
function getTranslationDirectoryPath(sourceFilePath, inputLanguageCode, outputLanguageCode) {
const normalized = sourceFilePath.replace(/\\/g, "/");
return normalized.replace(`/${inputLanguageCode}/`, `/${outputLanguageCode ?? inputLanguageCode}/`);
}
/**
* @param sourceFilePath - the source file's path
* @param key - the key associated with the translation
* @param inputLanguageCode - the language code of the source language
* @param outputLanguageCode - the language code of the output language
* @returns a key to use when translating a key from a directory;
* swaps the input language code with the output language code
*/
function getTranslationDirectoryKey(sourceFilePath, key, inputLanguageCode, outputLanguageCode) {
const outputPath = getTranslationDirectoryPath(sourceFilePath, inputLanguageCode, outputLanguageCode);
return `${outputPath}${exports.DIRECTORY_KEY_DELIMITER}${key}`;
}
/**
* @param response - the message from the LLM
* @returns whether the response includes NAK
*/
function isNAK(response) {
return response.includes("NAK") && !response.includes("ACK");
}
/**
* @param response - the message from the LLM
* @returns whether the response only contains ACK and not NAK
*/
function isACK(response) {
return response.includes("ACK") && !response.includes("NAK");
}
/**
* @param originalTemplateStrings - the template strings in the original text
* @param translatedTemplateStrings - the template strings in the translated text
* @returns the missing template string from the original
*/
function getMissingVariables(originalTemplateStrings, translatedTemplateStrings) {
if (originalTemplateStrings.length === 0)
return [];
const translatedTemplateStringsSet = new Set(translatedTemplateStrings);
const missingTemplateStrings = originalTemplateStrings.filter((originalTemplateString) => !translatedTemplateStringsSet.has(originalTemplateString));
return missingTemplateStrings;
}
/**
* @param templatedStringPrefix - templated String Prefix
* @param templatedStringSuffix - templated String Suffix
* @returns the regex needed to get the templated Strings
*/
function getTemplatedStringRegex(templatedStringPrefix, templatedStringSuffix) {
return new RegExp(`${templatedStringPrefix}[^{}]+${templatedStringSuffix}`, "g");
}
/**
* @param startTime - the startTime
* @param prefix - the prefix of the Execution Time
*/
function printExecutionTime(startTime, prefix) {
const endTime = Date.now();
const roundedSeconds = Math.round((endTime - startTime) / 1000);
printInfo(`${prefix}${roundedSeconds} seconds\n`);
}
/**
* @param title - the title
* @param startTime - the startTime
* @param totalItems - the totalItems
* @param processedItems - the processedItems
*/
function printProgress(title, startTime, totalItems, processedItems) {
const roundedEstimatedTimeLeftSeconds = Math.round((((Date.now() - startTime) / (processedItems + 1)) *
(totalItems - processedItems)) /
1000);
const percentage = ((processedItems / totalItems) * 100).toFixed(0);
process.stdout.write(`\r${ansi_colors_1.default.blueBright(title)} | ${ansi_colors_1.default.greenBright(`Completed ${percentage}%`)} | ${ansi_colors_1.default.yellowBright(`ETA: ${roundedEstimatedTimeLeftSeconds}s`)}`);
}
/**
* @param inputPath - the input path
* @param outputLanguageCode - the output language code
* @returns the output path based on the input path and output language code
*/
function getOutputPathFromInputPath(inputPath, outputLanguageCode) {
const dir = path_1.default.dirname(inputPath);
const filename = `${outputLanguageCode}${path_1.default.extname(inputPath)}`;
return path_1.default.join(dir, filename);
}
/**
* Legacy path-resolution convention: an absolute path resolves as-is,
* a relative path is tried first under `./jsons/` and then under cwd.
* @param input - the user-supplied path
* @returns the resolved absolute path
*/
function resolveInputPath(input) {
if (path_1.default.isAbsolute(input)) {
return path_1.default.resolve(input);
}
const jsonFolder = path_1.default.resolve(process.cwd(), "jsons");
const underJsons = path_1.default.resolve(jsonFolder, input);
if (fs_1.default.existsSync(underJsons)) {
return underJsons;
}
return path_1.default.resolve(process.cwd(), input);
}
/**
* For output paths — unlike input paths, the file doesn't exist yet, so
* we decide based on whether the `./jsons/` directory is present.
* @param output - the user-supplied output path
* @returns the resolved absolute path
*/
function resolveOutputPath(output) {
if (path_1.default.isAbsolute(output)) {
return path_1.default.resolve(output);
}
const jsonFolder = path_1.default.resolve(process.cwd(), "jsons");
if (fs_1.default.existsSync(jsonFolder)) {
return path_1.default.resolve(jsonFolder, output);
}
return path_1.default.resolve(process.cwd(), output);
}
//# sourceMappingURL=utils.js.map