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.

360 lines (318 loc) 11.5 kB
import ISO6391 from "iso-639-1"; import ansiColors from "ansi-colors"; import fs from "fs"; import path from "path"; /** * @param delayDuration - time (in ms) to delay * @returns a promise that resolves after delayDuration */ export function delay(delayDuration: number): Promise<void> { // eslint-disable-next-line no-promise-executor-return return new Promise((resolve) => setTimeout(resolve, delayDuration)); } /** * @param error - the error message */ export function printError(error: string): void { console.error(ansiColors.redBright(error)); } /** * @param warn - the warning message */ export function printWarn(warn: string): void { console.warn(ansiColors.yellowBright(warn)); } /** * @param info - the message */ export function printInfo(info: string): void { console.log(ansiColors.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 */ export async function retryJob<Type>( job: (...args: any) => Promise<Type>, jobArgs: Array<any>, maxRetries: number, firstTry: boolean, delayDuration?: number, sendError = true, ): Promise<Type> { 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 */ export function getLanguageCodeFromFilename(filename: string): string { const base = path.basename(filename); const [prefix] = base.split("."); if (ISO6391.validate(prefix)) return prefix; const [baseTag] = prefix.split("-"); if (ISO6391.validate(baseTag)) return baseTag; return prefix; } /** * @returns all language codes */ export function getAllLanguageCodes(): string[] { return ISO6391.getAllCodes(); } /** * @param languageCode - the language code to validate * @returns whether the language code is valid */ export function isValidLanguageCode(languageCode: string): boolean { return ISO6391.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 */ export function getLanguageName(languageCode: string): string { const name = ISO6391.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 */ export function resolveLanguageCode(raw: string): string { if (!raw) return raw; if (ISO6391.validate(raw)) return raw; const normalized = raw.trim().toLowerCase(); for (const code of ISO6391.getAllCodes()) { if (ISO6391.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 */ export function getAllFilesInPath(directory: string): Array<string> { const files: Array<string> = []; for (const fileOrDir of fs.readdirSync(directory)) { const fullPath = path.join(directory, fileOrDir); if (fs.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. */ export const 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 */ export function getTranslationDirectoryPath( sourceFilePath: string, inputLanguageCode: string, outputLanguageCode?: string, ): string { 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 */ export function getTranslationDirectoryKey( sourceFilePath: string, key: string, inputLanguageCode: string, outputLanguageCode?: string, ): string { const outputPath = getTranslationDirectoryPath( sourceFilePath, inputLanguageCode, outputLanguageCode, ); return `${outputPath}${DIRECTORY_KEY_DELIMITER}${key}`; } /** * @param response - the message from the LLM * @returns whether the response includes NAK */ export function isNAK(response: string): boolean { return response.includes("NAK") && !response.includes("ACK"); } /** * @param response - the message from the LLM * @returns whether the response only contains ACK and not NAK */ export function isACK(response: string): boolean { 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 */ export function getMissingVariables( originalTemplateStrings: string[], translatedTemplateStrings: string[], ): string[] { 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 */ export function getTemplatedStringRegex( templatedStringPrefix: string, templatedStringSuffix: string, ): RegExp { return new RegExp( `${templatedStringPrefix}[^{}]+${templatedStringSuffix}`, "g", ); } /** * @param startTime - the startTime * @param prefix - the prefix of the Execution Time */ export function printExecutionTime(startTime: number, prefix?: string): void { 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 */ export function printProgress( title: string, startTime: number, totalItems: number, processedItems: number, ): void { const roundedEstimatedTimeLeftSeconds = Math.round( (((Date.now() - startTime) / (processedItems + 1)) * (totalItems - processedItems)) / 1000, ); const percentage = ((processedItems / totalItems) * 100).toFixed(0); process.stdout.write( `\r${ansiColors.blueBright(title)} | ${ansiColors.greenBright(`Completed ${percentage}%`)} | ${ansiColors.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 */ export function getOutputPathFromInputPath( inputPath: string, outputLanguageCode: string, ): string { const dir = path.dirname(inputPath); const filename = `${outputLanguageCode}${path.extname(inputPath)}`; return path.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 */ export function resolveInputPath(input: string): string { if (path.isAbsolute(input)) { return path.resolve(input); } const jsonFolder = path.resolve(process.cwd(), "jsons"); const underJsons = path.resolve(jsonFolder, input); if (fs.existsSync(underJsons)) { return underJsons; } return path.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 */ export function resolveOutputPath(output: string): string { if (path.isAbsolute(output)) { return path.resolve(output); } const jsonFolder = path.resolve(process.cwd(), "jsons"); if (fs.existsSync(jsonFolder)) { return path.resolve(jsonFolder, output); } return path.resolve(process.cwd(), output); }