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.

226 lines (211 loc) 8.99 kB
import { CLI_HELP, DEFAULT_TEMPLATED_STRING_PREFIX, DEFAULT_TEMPLATED_STRING_SUFFIX, } from "./constants"; import { Command } from "commander"; import { DEFAULT_CACHE_PATH, loadCache, saveCache } from "./cache"; import { loadGlossary } from "./glossary"; import { printError, printInfo, resolveInputPath } from "./utils"; import { processModelArgs, processOverridePromptFile } from "./cli_helpers"; import { translateDirectoryDiff } from "./translate_directory"; import { translateFileDiff } from "./translate_file"; import ChatPool from "./chat_pool"; import RateLimiter from "./rate_limiter"; import fs, { mkdtempSync } from "fs"; import path from "path"; import type { TranslationCache } from "./cache"; import type DryRun from "./interfaces/dry_run"; import type Glossary from "./interfaces/glossary"; import type OverridePrompt from "./interfaces/override_prompt"; /** * Builds the diff command for comparing i18n files or directories. * @returns the diff command with its options and action. */ export default function buildDiffCommand(): Command { return new 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) .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) .option("--prompt-mode <prompt-mode>", CLI_HELP.PromptMode) .option("--batch-max-tokens <batch-max-tokens>", CLI_HELP.MaxTokens) .option("--dry-run", CLI_HELP.DryRun, false) .option("--no-continue-on-error", CLI_HELP.NoContinueOnError) .option("--concurrency <concurrency>", CLI_HELP.Concurrency) .option("--context <context>", CLI_HELP.Context) .option( "--exclude-languages [language codes...]", CLI_HELP.ExcludeLanguages, ) .option("--tokens-per-minute <tpm>", CLI_HELP.TokensPerMinute) .option("--file-format <format>", CLI_HELP.FileFormat) .option("--cache [path]", CLI_HELP.Cache) .option("--glossary <path>", CLI_HELP.Glossary) .action(async (options: any) => { const modelArgs = processModelArgs(options); // Shared pool + limiter mirroring cli_translate.ts. Diff // currently has a single run per invocation (no language // fan-out here), but plumbing it through keeps the option // shape symmetric and prevents surprises if diff grows a // --language-concurrency later. const sharedRateLimiter = new RateLimiter( modelArgs.rateLimitMs, Boolean(options.verbose), modelArgs.tokensPerMinute, ); const sharedPool = ChatPool.create({ apiKey: modelArgs.apiKey, chatParams: modelArgs.chatParams, concurrency: Math.max(1, modelArgs.concurrency), engine: options.engine, host: modelArgs.host, model: modelArgs.model, rateLimiter: sharedRateLimiter, }); let cachePath: string | undefined; let cache: TranslationCache | undefined; if (options.cache) { const resolvedPath = typeof options.cache === "string" ? options.cache : DEFAULT_CACHE_PATH; cachePath = resolvedPath; cache = loadCache(resolvedPath); } let glossary: Glossary | undefined; if (options.glossary) { try { glossary = loadGlossary(options.glossary); } catch (e) { printError(`${e}`); process.exit(2); } } const sharedOptions = { ...modelArgs, cache, context: options.context, continueOnError: options.continueOnError, ensureChangedTranslation: options.ensureChangedTranslation, excludeLanguages: options.excludeLanguages, format: options.fileFormat, glossary, pool: sharedPool, rateLimiter: sharedRateLimiter, skipStylingVerification: options.skipStylingVerification, skipTranslationVerification: options.skipTranslationVerification, templatedStringPrefix: options.templatedStringPrefix, templatedStringSuffix: options.templatedStringSuffix, verbose: options.verbose, }; let overridePrompt: OverridePrompt | undefined; if (options.overridePrompt) { overridePrompt = processOverridePromptFile( options.overridePrompt, ); } let dryRun: DryRun | undefined; if (options.dryRun) { dryRun = { basePath: mkdtempSync( `/tmp/i18n-ai-translate-${new Date().toISOString().replace(/[:.]/g, "-")}-`, ), }; } const beforeInputPath = resolveInputPath(options.before); const afterInputPath = resolveInputPath(options.after); if ( fs.statSync(beforeInputPath).isFile() !== fs.statSync(afterInputPath).isFile() ) { printError( "--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) ) { printError("Input files are not in the same directory"); return; } await translateFileDiff({ ...sharedOptions, dryRun, engine: options.engine, inputAfterFileOrPath: afterInputPath, inputBeforeFileOrPath: beforeInputPath, inputLanguageCode: options.inputLanguage, overridePrompt, }); } else { await translateDirectoryDiff({ ...sharedOptions, baseDirectory: path.resolve(beforeInputPath, ".."), dryRun, engine: options.engine, inputFolderNameAfter: afterInputPath, inputFolderNameBefore: beforeInputPath, inputLanguageCode: options.inputLanguage, overridePrompt, }); } // Persist the translation memory after the diff completes. // Dry-run is a no-write preview, so leave the cache untouched. if (cache && cachePath && !options.dryRun) { saveCache(cachePath, cache); if (options.verbose) { printInfo(`Wrote translation cache to ${cachePath}`); } } }); }