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
text/typescript
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}`);
}
}
});
}