i18ntk
Version:
i18n Tool Kit - Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.
535 lines (466 loc) • 26.9 kB
JavaScript
/**
* I18NTK TRANSLATE COMMAND
*
* Interactive menu-driven auto-translation using Google Translate.
* Wraps i18ntk-translate.js behind a user-friendly menu flow.
*/
const path = require('path');
const SecurityUtils = require('../../../utils/security');
const configManager = require('../../../utils/config-manager');
const { getUnifiedConfig } = require('../../../utils/config-helper');
const { loadTranslations, t } = require('../../../utils/i18n-helper');
const { parseConfirmation } = require('../../../utils/localized-confirm');
const { DEFAULT_CONCURRENCY, getProviderConcurrencyLimit } = require('../../../utils/translate/api');
const SetupEnforcer = require('../../../utils/setup-enforcer');
const {
createProtectionFile,
readProtectionFile,
saveProtectionFile
} = require('../../../utils/translate/protection');
class TranslateCommand {
constructor(config = {}, ui = null) {
this.config = config;
this.ui = ui;
this.prompt = null;
this.isNonInteractiveMode = false;
this.safeClose = null;
this.sourceDir = null;
this.sourceLang = null;
this.targetLang = null;
this.configuredTargetLangs = [];
}
setRuntimeDependencies(prompt, isNonInteractiveMode, safeClose) {
this.prompt = prompt;
this.isNonInteractiveMode = isNonInteractiveMode;
this.safeClose = safeClose;
}
tr(key, replacements = {}, fallback = '') {
const value = t(key, replacements);
return value && value !== key ? value : fallback;
}
async execute(options = {}) {
try {
await SetupEnforcer.checkSetupCompleteAsync();
} catch (error) {
console.error('Setup check failed:', error.message);
return { success: false, error: 'Setup required' };
}
const config = this.config || {};
let unified;
try {
unified = { ...(await getUnifiedConfig('translate', options)), ...config };
} catch (_) {
unified = config;
}
const uiLanguage = unified.uiLanguage || unified.language || 'en';
loadTranslations(uiLanguage, path.resolve(__dirname, '..', '..', '..', 'ui-locales'));
this.autoTranslateSettings = this.getAutoTranslateSettings(unified);
const defaultSourceDir = unified.sourceDir || unified.i18nDir || path.resolve(process.cwd(), 'locales', 'en');
this.sourceLang = unified.sourceLanguage || 'en';
this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, defaultSourceDir);
console.log('\n============================================================');
console.log(` ${t('translate.title') || '\u{1F310} Auto Translate'}`);
console.log('============================================================');
if (this.isNonInteractiveMode) {
this.sourceDir = defaultSourceDir;
if (!SecurityUtils.safeExistsSync(this.sourceDir, path.dirname(this.sourceDir))) {
console.error(this.tr('translate.errors.sourceDirectoryNotFound', { dir: this.sourceDir }, `Source locale directory not found: ${this.sourceDir}`));
return { success: false, error: 'Source directory not found' };
}
const resolvedSource = this.resolveSourceDirectoryForLanguage(this.sourceDir, this.sourceLang);
if (!resolvedSource.ok) {
console.error(resolvedSource.message);
return { success: false, error: 'No source files found' };
}
this.sourceDir = resolvedSource.sourceDir;
this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, this.sourceDir);
const jsonFiles = resolvedSource.jsonFiles;
return await this.nonInteractiveFlow(jsonFiles);
}
const { ask } = require('../../../utils/cli');
// Step 1: Choose source directory
this.sourceDir = await this.promptSourceDir(ask, defaultSourceDir);
if (!this.sourceDir) return { success: false, error: 'No source directory selected' };
// Step 2: Choose source language
this.sourceLang = await this.promptSourceLang(ask);
if (!this.sourceLang) return { success: false, error: 'No source language selected' };
const resolvedSource = this.resolveSourceDirectoryForLanguage(this.sourceDir, this.sourceLang);
if (!resolvedSource.ok) {
console.error(resolvedSource.message);
return { success: false, error: 'No source files found' };
}
this.sourceDir = resolvedSource.sourceDir;
this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, this.sourceDir);
return await this.interactiveFlow(resolvedSource.jsonFiles, ask);
}
async promptSourceDir(ask, defaultDir) {
while (true) {
console.log('\n ' + this.tr('translate.sourceDirectory.title', {}, 'Source locale directory'));
console.log(' ' + this.tr('translate.common.default', { value: defaultDir }, `Default: ${defaultDir}`));
console.log(' ' + this.tr('translate.sourceDirectory.currentProject', { dir: process.cwd() }, `Current project: ${process.cwd()}`));
console.log(' ' + this.tr('translate.sourceDirectory.accepted', {}, 'Accepted: an absolute path, or a path relative to the current project.'));
console.log(' ' + this.tr('translate.common.examples', {}, 'Examples:'));
console.log(' ./locales/en');
console.log(' ' + this.tr('translate.sourceDirectory.localeRootExample', {}, './locales (then choose source language: en)'));
console.log(` ${defaultDir}`);
console.log(' ' + this.tr('translate.sourceDirectory.folderHint', {}, 'The folder can contain JSON files directly, or language folders such as ./locales/en.'));
console.log(' ' + this.tr('translate.common.pressEnterDefault', {}, 'Press Enter to use the default.'));
const input = await ask(' > ');
if (!input.trim()) {
if (!SecurityUtils.safeExistsSync(defaultDir, path.dirname(defaultDir))) {
console.log(' ' + this.tr('translate.sourceDirectory.defaultNotFound', { dir: defaultDir }, `Default directory not found: ${defaultDir}`));
console.log(' ' + this.tr('translate.sourceDirectory.enterExisting', {}, 'Please enter an existing directory with JSON locale files.'));
continue;
}
console.log(' ' + this.tr('translate.common.usingDefault', { value: defaultDir }, `Using default: ${defaultDir}`));
return defaultDir;
}
const cleanInput = input.trim().replace(/^["']|["']$/g, '');
const resolved = path.isAbsolute(cleanInput)
? path.resolve(cleanInput)
: path.resolve(process.cwd(), cleanInput);
if (!SecurityUtils.safeExistsSync(resolved, path.dirname(resolved))) {
console.log(' ' + this.tr('translate.sourceDirectory.directoryNotFound', { dir: resolved }, `Directory not found: ${resolved}`));
console.log(' ' + this.tr('translate.sourceDirectory.enterFolderExample', {}, 'Enter an existing folder, for example ./locales/en.'));
continue;
}
const stats = SecurityUtils.safeStatSync(resolved, path.dirname(resolved));
if (!stats || !stats.isDirectory()) {
console.log(' ' + this.tr('translate.sourceDirectory.notDirectory', { dir: resolved }, `Not a directory: ${resolved}`));
continue;
}
console.log(' ' + this.tr('translate.sourceDirectory.using', { dir: resolved }, `Using source directory: ${resolved}`));
return resolved;
}
}
async promptSourceLang(ask) {
while (true) {
console.log('\n ' + this.tr('translate.sourceLanguage.title', {}, 'Source language code'));
console.log(' ' + this.tr('translate.common.default', { value: this.sourceLang }, `Default: ${this.sourceLang}`));
console.log(' ' + this.tr('translate.sourceLanguage.hint', {}, 'This should match the language of the source JSON values.'));
console.log(' ' + this.tr('translate.common.exampleValue', { value: 'en' }, 'Example: en'));
console.log(' ' + this.tr('translate.common.pressEnterDefault', {}, 'Press Enter to use the default.'));
const input = await ask(' > ');
if (!input.trim()) {
console.log(' ' + this.tr('translate.sourceLanguage.using', { lang: this.sourceLang }, `Using source language: ${this.sourceLang}`));
return this.sourceLang;
}
const lang = input.trim().toLowerCase();
if (lang.length >= 2) {
return lang;
}
console.log(' ' + this.tr('translate.sourceLanguage.invalid', {}, 'Invalid language code. Use 2+ characters (e.g. en, de, fr).'));
}
}
async interactiveFlow(jsonFiles, ask) {
await this.maybeConfigureProtection(ask);
console.log('\n ' + this.tr('translate.targetLanguages.title', {}, 'Target language(s)'));
if (this.configuredTargetLangs.length > 0) {
console.log(' ' + this.tr('translate.targetLanguages.allConfigured', { languages: this.configuredTargetLangs.join(', ') }, `a) All configured target languages: ${this.configuredTargetLangs.join(', ')}`));
} else {
console.log(' ' + this.tr('translate.targetLanguages.noneConfigured', {}, 'a) All configured target languages: none configured'));
}
console.log(' ' + this.tr('translate.targetLanguages.enterCodes', {}, 'Or enter one or more comma/space-separated language codes.'));
console.log(' ' + this.tr('translate.common.examplesInline', { examples: 'de, es, fr or de es fr or zh' }, 'Examples: de, es, fr or de es fr or zh'));
console.log(' ' + this.tr('translate.targetLanguages.sourceExcluded', { lang: this.sourceLang }, `Source language "${this.sourceLang}" will be excluded automatically.`));
const langInput = await ask(' > ');
const targetLangs = this.parseTargetLanguages(langInput);
if (targetLangs.length === 0) {
console.log(' ' + this.tr('translate.targetLanguages.noneSelected', {}, 'No valid target languages selected. Aborting.'));
if (this.configuredTargetLangs.length === 0) {
console.log(' ' + this.tr('translate.targetLanguages.configureHint', {}, 'Configure defaultLanguages in .i18ntk-config, or enter target codes manually.'));
}
return { success: false, error: 'Invalid language code' };
}
console.log('\n ' + this.tr('translate.targetLanguages.selected', { languages: targetLangs.join(', ') }, `Target languages: ${targetLangs.join(', ')}`));
console.log('\n ' + this.tr('translate.files.title', {}, 'Which file(s) to translate?'));
const filePreview = jsonFiles.length <= 6
? jsonFiles.join(', ')
: `${jsonFiles.slice(0, 6).join(', ')}, ...`;
console.log(' ' + this.tr('translate.files.all', { count: jsonFiles.length, files: filePreview }, `a) All JSON files (${jsonFiles.length}: ${filePreview})`));
jsonFiles.forEach((f, i) => {
console.log(` ${i + 1}) ${f}`);
});
const fileChoice = await ask('\n ' + this.tr('translate.files.choicePrompt', {}, 'Choice [a/all or file number]: '));
let sourceFiles;
if (['a', 'all', '*'].includes(fileChoice.trim().toLowerCase())) {
sourceFiles = jsonFiles.map(f => path.join(this.sourceDir, f));
} else {
const idx = parseInt(fileChoice, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= jsonFiles.length) {
console.log(' ' + this.tr('translate.files.invalidChoice', {}, 'Invalid choice. Aborting.'));
return { success: false, error: 'Invalid file choice' };
}
sourceFiles = [path.join(this.sourceDir, jsonFiles[idx])];
}
if (this.autoTranslateSettings.dryRunFirst !== false) {
// Dry-run for first language only (all languages use same source so same keys)
const firstLang = targetLangs[0];
console.log('\n ' + this.tr('translate.dryRun.previewFor', { lang: firstLang }, `Dry-run preview for "${firstLang}"...`) + '\n');
await this.runTranslate(sourceFiles, firstLang, { dryRun: true });
}
console.log('\n ' + this.tr('translate.confirm.proceed', {}, 'Proceed with actual translation?'));
const answer = await ask(' ' + this.tr('translate.confirm.yesNoPrompt', {}, '[y]es / [n]o: '));
if (!parseConfirmation(answer, { language: this.config.uiLanguage || this.config.language || 'en', defaultValue: false })) {
console.log(' ' + this.tr('translate.confirm.cancelled', {}, 'Translation cancelled.'));
return { success: true, cancelled: true };
}
let results = [];
for (const lang of targetLangs) {
console.log('\n ' + this.tr('translate.run.translatingTo', { lang }, `Translating to "${lang}"...`) + '\n');
try {
await this.runTranslate(sourceFiles, lang, { dryRun: false });
results.push({ lang, ok: true });
} catch (e) {
console.error(' ' + this.tr('translate.run.failedFor', { lang, error: e.message }, `Failed for "${lang}": ${e.message}`));
results.push({ lang, ok: false, error: e.message });
}
}
console.log('\n ' + this.tr('translate.summary.title', {}, 'Summary:'));
for (const r of results) {
console.log(` ${r.ok ? '\u{2705}' : '\u{274C}'} ${r.lang}${r.error ? ' (' + r.error + ')' : ''}`);
}
const failed = results.filter(r => !r.ok);
if (failed.length > 0) {
const message = failed.length === 1
? `Translation finished with warnings for ${failed[0].lang}. Review the Auto Translate resume report for unresolved keys.`
: `Translation finished with warnings for ${failed.length} languages. Review the Auto Translate resume report for unresolved keys.`;
console.log('\n ' + this.tr('translate.summary.incomplete', { count: failed.length }, message));
return { success: false, results, error: message };
}
console.log('\n ' + this.tr('translate.summary.complete', {}, 'Translation complete!'));
return { success: true, results };
}
async nonInteractiveFlow(jsonFiles) {
console.log('\n ' + this.tr('translate.nonInteractive.useDirect', {}, 'Non-interactive mode. Use direct CLI instead:'));
console.log(' i18ntk-translate <source> <lang> [options]');
return { success: false, error: 'Non-interactive mode not supported from menu' };
}
getJsonFiles(sourceDir) {
return SecurityUtils.safeReaddirSync(sourceDir, path.dirname(sourceDir))
.filter(f => f.endsWith('.json'))
.sort();
}
resolveSourceDirectoryForLanguage(selectedDir, sourceLang, options = {}) {
const shouldLog = options.log !== false;
const jsonFiles = this.getJsonFiles(selectedDir);
if (jsonFiles.length > 0) {
return { ok: true, sourceDir: selectedDir, jsonFiles };
}
const cleanLang = String(sourceLang || '').trim().toLowerCase();
if (cleanLang) {
const languageDir = path.join(selectedDir, cleanLang);
const languageStats = SecurityUtils.safeStatSync(languageDir, path.dirname(languageDir));
if (languageStats && languageStats.isDirectory()) {
const languageJsonFiles = this.getJsonFiles(languageDir);
if (languageJsonFiles.length > 0) {
if (shouldLog) {
console.log(' ' + this.tr('translate.sourceDirectory.noJsonDirect', { dir: selectedDir }, `No JSON files found directly in: ${selectedDir}`));
console.log(' ' + this.tr('translate.sourceDirectory.usingLanguageFolder', { dir: languageDir }, `Using source language folder: ${languageDir}`));
}
return { ok: true, sourceDir: languageDir, jsonFiles: languageJsonFiles };
}
}
}
const checkedLanguageDir = cleanLang ? path.join(selectedDir, cleanLang) : null;
const message = checkedLanguageDir
? this.tr('translate.sourceDirectory.noJsonWithLanguageFolder', { dir: selectedDir, languageDir: checkedLanguageDir }, `No JSON files found in: ${selectedDir}\nAlso checked source language folder: ${checkedLanguageDir}`)
: this.tr('translate.sourceDirectory.noJson', { dir: selectedDir }, `No JSON files found in: ${selectedDir}`);
return { ok: false, sourceDir: selectedDir, jsonFiles: [], message };
}
getConfiguredTargetLanguages(config = {}, sourceDir = this.sourceDir) {
const candidates = []
.concat(config.defaultLanguages || [])
.concat(config.targetLanguages || [])
.concat(config.supportedLanguages || [])
.concat(config.settings?.defaultLanguages || []);
const parentDir = sourceDir ? path.dirname(sourceDir) : null;
if (parentDir && SecurityUtils.safeExistsSync(parentDir, path.dirname(parentDir))) {
const siblingDirs = SecurityUtils.safeReaddirSync(parentDir, path.dirname(parentDir), { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
candidates.push(...siblingDirs);
}
return this.normalizeLanguageList(candidates);
}
normalizeLanguageList(languages) {
const seen = new Set();
const sourceLang = String(this.sourceLang || '').toLowerCase();
const normalized = [];
for (const lang of languages || []) {
if (typeof lang !== 'string') continue;
const clean = lang.trim().toLowerCase();
if (!/^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})?$/i.test(clean)) continue;
if (clean === sourceLang || seen.has(clean)) continue;
seen.add(clean);
normalized.push(clean);
}
return normalized.sort();
}
parseTargetLanguages(input) {
const clean = String(input || '').trim().toLowerCase();
if (['a', 'all', '*'].includes(clean)) {
return [...this.configuredTargetLangs];
}
return this.normalizeLanguageList(clean.split(/[,;\s]+/));
}
getAutoTranslateSettings(config = {}) {
const settings = config.autoTranslate || config.settings?.autoTranslate || {};
return {
placeholderMode: ['preserve', 'skip', 'send'].includes(settings.placeholderMode)
? settings.placeholderMode
: 'preserve',
concurrency: this.toInt(settings.concurrency, DEFAULT_CONCURRENCY, 1, getProviderConcurrencyLimit(settings.provider || 'google')),
batchSize: this.toInt(settings.batchSize, 100, 1, 10000),
progressInterval: this.toInt(settings.progressInterval, 25, 1, 10000),
retryCount: this.toInt(settings.retryCount, 3, 0, 10),
retryDelay: this.toInt(settings.retryDelay, 1000, 0, 30000),
timeout: this.toInt(settings.timeout, 15000, 1000, 120000),
dryRunFirst: settings.dryRunFirst !== false,
onlyMissingOrEnglish: settings.onlyMissingOrEnglish !== false,
reportStdout: settings.reportStdout !== false,
bom: settings.bom === true,
protectionEnabled: settings.protectionEnabled !== false,
protectionFile: settings.protectionFile || './i18ntk-auto-translate.json',
promptProtectionSetup: settings.promptProtectionSetup !== false,
promptProtectionUpdate: settings.promptProtectionUpdate !== false
};
}
async updateAutoTranslateSetting(key, value) {
try {
await configManager.updateConfig({ autoTranslate: { [key]: value } });
await configManager.saveConfig();
this.autoTranslateSettings[key] = value;
} catch (error) {
console.log(` Warning: could not save Auto Translate setting "${key}": ${error.message}`);
}
}
async maybeConfigureProtection(ask) {
const settings = this.autoTranslateSettings;
if (!settings.protectionEnabled) return;
const protectionPath = path.resolve(process.cwd(), settings.protectionFile);
const exists = SecurityUtils.safeExistsSync(protectionPath, path.dirname(protectionPath));
if (!exists && settings.promptProtectionSetup) {
console.log('\n Protected terms and keys');
console.log(' Auto Translate can keep brand names, product terms, exact values, or key paths unchanged.');
console.log(` Protection file: ${protectionPath}`);
console.log(' Create this JSON file now?');
const answer = await ask(' [y]es / [n]o / [d]on\'t ask again: ');
const clean = answer.trim().toLowerCase();
if (clean === 'd' || clean === 'dont ask again' || clean === "don't ask again") {
await this.updateAutoTranslateSetting('promptProtectionSetup', false);
return;
}
if (/^y|yes$/i.test(clean)) {
createProtectionFile(settings.protectionFile);
await this.promptProtectionEntries(ask, settings.protectionFile);
return;
}
}
if (exists && settings.promptProtectionUpdate) {
console.log('\n Protected terms and keys');
console.log(` Current protection file: ${protectionPath}`);
console.log(' Update protection rules for this run?');
const answer = await ask(' [y]es / [n]o / [d]on\'t ask again: ');
const clean = answer.trim().toLowerCase();
if (clean === 'd' || clean === 'dont ask again' || clean === "don't ask again") {
await this.updateAutoTranslateSetting('promptProtectionUpdate', false);
return;
}
if (/^y|yes$/i.test(clean)) {
await this.promptProtectionEntries(ask, settings.protectionFile);
}
}
}
splitList(input) {
return String(input || '')
.split(/[,;\n]+/)
.map(item => item.trim())
.filter(Boolean);
}
mergeList(existing, additions) {
const seen = new Set(existing || []);
for (const item of additions || []) {
if (!seen.has(item)) seen.add(item);
}
return Array.from(seen);
}
async promptProtectionEntries(ask, protectionFile) {
let config;
try {
config = readProtectionFile(protectionFile);
} catch (_) {
config = {
version: 1,
terms: [],
keys: [],
values: [],
patterns: []
};
}
console.log('\n Add protected terms separated by commas.');
console.log(' Example: BrandName, PRODUCT_CODE, API');
const terms = this.splitList(await ask(' Terms [Enter to skip]: '));
console.log('\n Add protected key paths separated by commas.');
console.log(' Exact keys and * wildcards are supported.');
console.log(' Example: app.brandName, legal.companyName, product.*.symbol');
const keys = this.splitList(await ask(' Keys [Enter to skip]: '));
console.log('\n Add exact values to copy unchanged separated by commas.');
console.log(' Example: BrandName Ltd, support@example.com');
const values = this.splitList(await ask(' Values [Enter to skip]: '));
console.log('\n Add optional JavaScript regex patterns separated by commas.');
console.log(' Example: [A-Z]{2,}-\\d+');
const patterns = this.splitList(await ask(' Patterns [Enter to skip]: '));
config.terms = this.mergeList(config.terms, terms);
config.keys = this.mergeList(config.keys, keys);
config.values = this.mergeList(config.values, values);
config.patterns = this.mergeList(config.patterns, patterns);
const savedPath = saveProtectionFile(protectionFile, config);
console.log(` Protection file saved: ${savedPath}`);
}
toInt(value, fallback, min, max) {
const parsed = parseInt(value, 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
async runTranslate(sourceFiles, targetLang, opts = {}) {
const failures = [];
for (const src of sourceFiles) {
const result = await this.runSingleTranslate(src, targetLang, opts);
if (!result || result.success !== true) {
failures.push({
src,
error: result?.error || `Translation failed for ${src}`
});
}
}
if (failures.length > 0) {
throw new Error(failures.map(item => item.error).join('; '));
}
}
async runSingleTranslate(src, targetLang, opts = {}) {
const { parseArgs, run } = require('../../i18ntk-translate');
const settings = this.autoTranslateSettings || this.getAutoTranslateSettings();
const args = parseArgs(['node', 'i18ntk-translate', src, targetLang]);
args.noConfirm = true;
args.sourceLang = this.sourceLang || 'en';
args.dryRun = opts.dryRun === true;
args.onlyMissingOrEnglish = settings.onlyMissingOrEnglish;
args.reportStdout = settings.reportStdout;
args.bom = settings.bom;
args.concurrency = settings.concurrency;
args.batchSize = settings.batchSize;
args.progressInterval = settings.progressInterval;
args.retryCount = settings.retryCount;
args.retryDelay = settings.retryDelay;
args.timeout = settings.timeout;
args.protectionEnabled = settings.protectionEnabled;
args.protectionFile = settings.protectionFile;
args.preservePlaceholders = settings.placeholderMode === 'preserve';
args.skipPlaceholders = settings.placeholderMode === 'skip';
args.sendPlaceholders = settings.placeholderMode === 'send';
return await run(args);
}
}
module.exports = TranslateCommand;