UNPKG

i18n-ai-translate

Version:

Use LLMs to translate your i18n JSON to any language.

592 lines (542 loc) 21.1 kB
import { CLI_HELP, DEFAULT_BATCH_SIZE, DEFAULT_MODEL, DEFAULT_TEMPLATED_STRING_PREFIX, DEFAULT_TEMPLATED_STRING_SUFFIX, VERSION, } from "./constants"; import { OVERRIDE_PROMPT_KEYS } from "./interfaces/override_prompt"; import { config } from "dotenv"; import { getAllLanguageCodes, getLanguageCodeFromFilename } from "./utils"; import { program } from "commander"; import { translateDirectory, translateDirectoryDiff, translateFile, translateFileDiff, } from "./translate"; import Engine from "./enums/engine"; import fs from "fs"; import path from "path"; import type { ChatParams, Model, ModelArgs } from "./types"; import type OverridePrompt from "./interfaces/override_prompt"; config({ path: path.resolve(process.cwd(), ".env") }); const processModelArgs = (options: any): ModelArgs => { let model: Model; let chatParams: ChatParams; let rateLimitMs = Number(options.rateLimitMs); let apiKey: string | undefined; let host: string | undefined; switch (options.engine) { case Engine.Gemini: model = options.model || DEFAULT_MODEL[Engine.Gemini]; chatParams = {}; if (!options.rateLimitMs) { // gemini-2.0-flash-exp limits us to 10 RPM => 1 call every 6 seconds rateLimitMs = 6000; } if (!process.env.GEMINI_API_KEY && !options.apiKey) { throw new Error("GEMINI_API_KEY not found in .env file"); } else { apiKey = options.apiKey || process.env.GEMINI_API_KEY; } break; case Engine.ChatGPT: model = options.model || DEFAULT_MODEL[Engine.ChatGPT]; chatParams = { messages: [], model, seed: 69420, }; if (!options.rateLimitMs) { // Free-tier rate limits are 3 RPM => 1 call every 20 seconds // Tier 1 is a reasonable 500 RPM => 1 call every 120ms // TODO: token limits rateLimitMs = 120; } if (!process.env.OPENAI_API_KEY && !options.apiKey) { throw new Error("OPENAI_API_KEY not found in .env file"); } else { apiKey = options.apiKey || process.env.OPENAI_API_KEY; } break; case Engine.Ollama: model = options.model || DEFAULT_MODEL[Engine.Ollama]; chatParams = { messages: [], model, seed: 69420, }; host = options.host || process.env.OLLAMA_HOSTNAME; break; case Engine.Claude: model = options.model || DEFAULT_MODEL[Engine.Claude]; chatParams = { messages: [], model, seed: 69420, }; if (!options.rateLimitMs) { // Anthropic limits us to 50 RPM on the first tier => 1200ms between calls rateLimitMs = 1200; } if (!process.env.ANTHROPIC_API_KEY && !options.apiKey) { throw new Error("ANTHROPIC_API_KEY not found in .env file"); } else { apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY; } break; default: { throw new Error("Invalid engine"); } } return { apiKey, chatParams, host, model: options.model || DEFAULT_MODEL[options.engine as Engine], rateLimitMs, }; }; const processOverridePromptFile = ( overridePromptFilePath: string, ): OverridePrompt => { const filePath = path.resolve(process.cwd(), overridePromptFilePath); if (!fs.existsSync(filePath)) { throw new Error( `The override prompt file does not exist at ${filePath}`, ); } let overridePrompt: OverridePrompt; try { overridePrompt = JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch (err) { throw new Error( `Failed to read the override prompt file. err = ${err}`, ); } if (Object.keys(overridePrompt).length === 0) { throw new Error( `Received an empty object for the override prompt file. Valid keys are: ${OVERRIDE_PROMPT_KEYS.join(", ")}`, ); } for (const key of Object.keys(overridePrompt) as (keyof OverridePrompt)[]) { if (!OVERRIDE_PROMPT_KEYS.includes(key)) { throw new Error( `Received an unexpected key ${key} in the override prompt file. Valid keys are: ${OVERRIDE_PROMPT_KEYS.join(", ")}`, ); } } for (const value of Object.values(overridePrompt)) { if (typeof value !== "string") { throw new Error( `Expected a string as a key for every entry in the override prompt file. Received: ${typeof value}`, ); } } return overridePrompt; }; program .name("i18n-ai-translate") .description( "Use ChatGPT, Gemini, Ollama, or Anthropic to translate your i18n JSON to any language", ) .version(VERSION); program .command("translate") .requiredOption( "-i, --input <input>", "Source i18n file or path of source language, in the jsons/ directory if a relative path is given", ) .option( "-o, --output-languages [language codes...]", "A list of languages to translate to", ) .requiredOption("-e, --engine <engine>", CLI_HELP.Engine) .option("-m, --model <model>", CLI_HELP.Model) .option("-r, --rate-limit-ms <rateLimitMs>", CLI_HELP.RateLimit) .option( "-f, --force-language-name <language name>", "Force output language name", ) .option("-A, --all-languages", "Translate to all supported languages") .option( "-p, --templated-string-prefix <prefix>", "Prefix for templated strings", DEFAULT_TEMPLATED_STRING_PREFIX, ) .option( "-s, --templated-string-suffix <suffix>", "Suffix for templated strings", DEFAULT_TEMPLATED_STRING_SUFFIX, ) .option("-k, --api-key <API key>", "API key") .option("-h, --host <hostIP:port>", CLI_HELP.OllamaHost) .option( "--ensure-changed-translation", CLI_HELP.EnsureChangedTranslation, false, ) .option( "-n, --batch-size <batchSize>", CLI_HELP.BatchSize, String(DEFAULT_BATCH_SIZE), ) .option( "--skip-translation-verification", CLI_HELP.SkipTranslationVerification, false, ) .option( "--skip-styling-verification", CLI_HELP.SkipStylingVerification, false, ) .option( "--override-prompt <path to JSON file>", CLI_HELP.OverridePromptFile, ) .option("--verbose", CLI_HELP.Verbose, false) .action(async (options: any) => { const { model, chatParams, rateLimitMs, apiKey, host } = processModelArgs(options); let overridePrompt: OverridePrompt | undefined; if (options.overridePrompt) { overridePrompt = processOverridePromptFile(options.overridePrompt); } if (options.outputLanguages) { if (options.forceLanguageName) { console.error( "Cannot use both --output-languages and --force-language", ); return; } if (options.allLanguages) { console.error( "Cannot use both --all-languages and --output-languages", ); return; } if (options.outputLanguages.length === 0) { console.error("No languages specified"); return; } if (options.verbose) { console.log( `Translating to ${options.outputLanguages.join(", ")}...`, ); } const jsonFolder = path.resolve(process.cwd(), "jsons"); let inputPath: string; if (path.isAbsolute(options.input)) { inputPath = path.resolve(options.input); } else { inputPath = path.resolve(jsonFolder, options.input); if (!fs.existsSync(inputPath)) { inputPath = path.resolve(process.cwd(), options.input); } } if (fs.statSync(inputPath).isFile()) { let i = 0; for (const languageCode of options.outputLanguages) { i++; if (options.verbose) { console.log( `Translating ${i}/${options.outputLanguages.length} languages...`, ); } const output = options.input.replace( getLanguageCodeFromFilename(options.input), languageCode, ); if (options.input === output) { continue; } let outputPath: string; if (path.isAbsolute(output)) { outputPath = path.resolve(output); } else { outputPath = path.resolve(jsonFolder, output); if (!fs.existsSync(jsonFolder)) { outputPath = path.resolve(process.cwd(), output); } } try { // eslint-disable-next-line no-await-in-loop await translateFile({ apiKey, batchSize: options.batchSize, chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host, inputFilePath: inputPath, model, outputFilePath: outputPath, overridePrompt, rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, }); } catch (err) { console.error( `Failed to translate file to ${languageCode}: ${err}`, ); } } } else { let i = 0; for (const languageCode of options.outputLanguages) { i++; if (options.verbose) { console.log( `Translating ${i}/${options.outputLanguages.length} languages...`, ); } const output = options.input.replace( getLanguageCodeFromFilename(options.input), languageCode, ); if (options.input === output) { continue; } try { // eslint-disable-next-line no-await-in-loop await translateDirectory({ apiKey, baseDirectory: path.resolve(inputPath, ".."), batchSize: options.batchSize, chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host, inputLanguage: path.basename(inputPath), model, outputLanguage: languageCode, overridePrompt, rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, }); } catch (err) { console.error( `Failed to translate directory to ${languageCode}: ${err}`, ); } } } } else { if (options.forceLanguageName) { console.error( "Cannot use both --all-languages and --force-language", ); return; } console.warn( "Some languages may fail to translate due to the model's limitations", ); let i = 0; for (const languageCode of getAllLanguageCodes()) { i++; if (options.verbose) { console.log( `Translating ${i}/${getAllLanguageCodes().length} languages...`, ); } const output = options.input.replace( getLanguageCodeFromFilename(options.input), languageCode, ); if (options.input === output) { continue; } try { // eslint-disable-next-line no-await-in-loop await translateFile({ apiKey, batchSize: options.batchSize, chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host, inputFilePath: options.input, model, outputFilePath: output, overridePrompt, rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, }); } catch (err) { console.error( `Failed to translate to ${languageCode}: ${err}`, ); } } } }); program .command("diff") .requiredOption( "-b, --before <fileOrDirectoryBefore>", "Source i18n file or directory before changes, in the jsons/ directory if a relative path is given", ) .requiredOption( "-a, --after <fileOrDirectoryAfter>", "Source i18n file or directory after changes, in the jsons/ directory if a relative path is given", ) .requiredOption( "-l, --input-language <inputLanguageCode>", "The input language's code, in ISO6391 (e.g. en, fr)", ) .requiredOption("-e, --engine <engine>", CLI_HELP.Engine) .option("-m, --model <model>", CLI_HELP.Model) .option("-r, --rate-limit-ms <rateLimitMs>", CLI_HELP.RateLimit) .option("-k, --api-key <API key>", "API key") .option("-h, --host <hostIP:port>", CLI_HELP.OllamaHost) .option( "--ensure-changed-translation", CLI_HELP.EnsureChangedTranslation, false, ) .option( "-p, --templated-string-prefix <prefix>", "Prefix for templated strings", DEFAULT_TEMPLATED_STRING_PREFIX, ) .option( "-s, --templated-string-suffix <suffix>", "Suffix for templated strings", DEFAULT_TEMPLATED_STRING_SUFFIX, ) .option( "-n, --batch-size <batchSize>", CLI_HELP.BatchSize, String(DEFAULT_BATCH_SIZE), ) .option( "--skip-translation-verification", CLI_HELP.SkipTranslationVerification, false, ) .option( "--skip-styling-verification", CLI_HELP.SkipStylingVerification, false, ) .option( "--override-prompt <path to JSON file>", CLI_HELP.OverridePromptFile, ) .option("--verbose", CLI_HELP.Verbose, false) .action(async (options: any) => { const { model, chatParams, rateLimitMs, apiKey, host } = processModelArgs(options); let overridePrompt: OverridePrompt | undefined; if (options.overridePrompt) { overridePrompt = processOverridePromptFile(options.overridePrompt); } const jsonFolder = path.resolve(process.cwd(), "jsons"); let beforeInputPath: string; if (path.isAbsolute(options.before)) { beforeInputPath = path.resolve(options.before); } else { beforeInputPath = path.resolve(jsonFolder, options.before); if (!fs.existsSync(beforeInputPath)) { beforeInputPath = path.resolve(process.cwd(), options.before); } } let afterInputPath: string; if (path.isAbsolute(options.after)) { afterInputPath = path.resolve(options.after); } else { afterInputPath = path.resolve(jsonFolder, options.after); if (!fs.existsSync(afterInputPath)) { afterInputPath = path.resolve(process.cwd(), options.after); } } if ( fs.statSync(beforeInputPath).isFile() !== fs.statSync(afterInputPath).isFile() ) { console.error( "--before and --after arguments must be both files or both directories", ); return; } if (fs.statSync(beforeInputPath).isFile()) { // Ensure they're in the same path if ( path.dirname(beforeInputPath) !== path.dirname(afterInputPath) ) { console.error("Input files are not in the same directory"); return; } await translateFileDiff({ apiKey, batchSize: options.batchSize, chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host, inputAfterFileOrPath: afterInputPath, inputBeforeFileOrPath: beforeInputPath, inputLanguageCode: options.inputLanguage, model, overridePrompt, rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, }); } else { await translateDirectoryDiff({ apiKey, baseDirectory: path.resolve(beforeInputPath, ".."), batchSize: options.batchSize, chatParams, engine: options.engine, ensureChangedTranslation: options.ensureChangedTranslation, host, inputFolderNameAfter: afterInputPath, inputFolderNameBefore: beforeInputPath, inputLanguageCode: options.inputLanguage, model, overridePrompt, rateLimitMs, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, }); } }); program.parse();