i18ntk 2.6.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -16
- package/README.md +55 -19
- package/main/i18ntk-sizing.js +471 -218
- package/main/i18ntk-translate.js +833 -0
- package/main/i18ntk-validate.js +26 -14
- package/main/manage/commands/CommandRouter.js +7 -1
- package/main/manage/commands/TranslateCommand.js +463 -0
- package/main/manage/commands/ValidateCommand.js +25 -13
- package/main/manage/index.js +11 -5
- package/package.json +14 -3
- package/settings/settings-cli.js +75 -29
- package/settings/settings-manager.js +109 -1
- package/ui-locales/de.json +5 -2
- package/ui-locales/en.json +5 -2
- package/ui-locales/es.json +5 -2
- package/ui-locales/fr.json +5 -2
- package/ui-locales/ja.json +5 -2
- package/ui-locales/ru.json +7 -4
- package/ui-locales/zh.json +5 -2
- package/utils/config-manager.js +20 -4
- package/utils/security.js +4 -3
- package/utils/translate/api.js +168 -0
- package/utils/translate/cli.js +95 -0
- package/utils/translate/placeholder.js +153 -0
- package/utils/translate/protection.js +243 -0
- package/utils/translate/report.js +117 -0
- package/utils/translate/traverse.js +148 -0
- package/utils/validation-risk.js +175 -0
package/main/i18ntk-validate.js
CHANGED
|
@@ -46,9 +46,10 @@ const watchLocales = require('../utils/watch-locales');
|
|
|
46
46
|
const { getGlobalReadline, closeGlobalReadline } = require('../utils/cli');
|
|
47
47
|
const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir, displayPaths } = require('../utils/config-helper');
|
|
48
48
|
const I18nInitializer = require('./i18ntk-init');
|
|
49
|
-
const JsonOutput = require('../utils/json-output');
|
|
50
|
-
const ExitCodes = require('../utils/exit-codes');
|
|
51
|
-
const SetupEnforcer = require('../utils/setup-enforcer');
|
|
49
|
+
const JsonOutput = require('../utils/json-output');
|
|
50
|
+
const ExitCodes = require('../utils/exit-codes');
|
|
51
|
+
const SetupEnforcer = require('../utils/setup-enforcer');
|
|
52
|
+
const { detectTranslationContentRisks } = require('../utils/validation-risk');
|
|
52
53
|
|
|
53
54
|
// Ensure setup is complete before running
|
|
54
55
|
(async () => {
|
|
@@ -405,17 +406,28 @@ class I18nValidator {
|
|
|
405
406
|
}
|
|
406
407
|
}
|
|
407
408
|
|
|
408
|
-
detectRiskyKeys(obj, language, fileName, prefix = '') {
|
|
409
|
-
for (const [key, value] of Object.entries(obj || {})) {
|
|
410
|
-
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
411
|
-
if (typeof value === 'string') {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
409
|
+
detectRiskyKeys(obj, language, fileName, prefix = '') {
|
|
410
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
411
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
412
|
+
if (typeof value === 'string') {
|
|
413
|
+
const issues = detectTranslationContentRisks(value, {
|
|
414
|
+
keyPath: fullKey,
|
|
415
|
+
sourceLanguage: this.config.sourceLanguage,
|
|
416
|
+
targetLanguage: language,
|
|
417
|
+
allowedEnglishTerms: this.config.allowedEnglishTerms,
|
|
418
|
+
englishThresholdPercent: this.config.englishContentThresholdPercent
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
issues.forEach(issue => {
|
|
422
|
+
const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this);
|
|
423
|
+
const message = issue.type === 'english_content'
|
|
424
|
+
? `Possible untranslated English content in ${language}/${fileName}`
|
|
425
|
+
: `Potential risky content in ${language}/${fileName}`;
|
|
426
|
+
reporter(message, { key: fullKey, value, ...issue });
|
|
427
|
+
});
|
|
428
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
429
|
+
this.detectRiskyKeys(value, language, fileName, fullKey);
|
|
430
|
+
}
|
|
419
431
|
}
|
|
420
432
|
}
|
|
421
433
|
|
|
@@ -23,6 +23,7 @@ const BackupCommand = require('./BackupCommand');
|
|
|
23
23
|
const DoctorCommand = require('./DoctorCommand');
|
|
24
24
|
const FixerCommand = require('./FixerCommand');
|
|
25
25
|
const ScannerCommand = require('./ScannerCommand');
|
|
26
|
+
const TranslateCommand = require('./TranslateCommand');
|
|
26
27
|
|
|
27
28
|
class CommandRouter {
|
|
28
29
|
constructor(config = {}, ui = null, adminAuth = null) {
|
|
@@ -45,7 +46,8 @@ class CommandRouter {
|
|
|
45
46
|
'backup': new BackupCommand(config, ui),
|
|
46
47
|
'doctor': new DoctorCommand(config, ui),
|
|
47
48
|
'fix': new FixerCommand(config, ui),
|
|
48
|
-
'scanner': new ScannerCommand(config, ui)
|
|
49
|
+
'scanner': new ScannerCommand(config, ui),
|
|
50
|
+
'translate': new TranslateCommand(config, ui)
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -232,6 +234,9 @@ class CommandRouter {
|
|
|
232
234
|
case 'scanner':
|
|
233
235
|
return await this.commandHandlers.scanner.execute(options);
|
|
234
236
|
|
|
237
|
+
case 'translate':
|
|
238
|
+
return await this.commandHandlers.translate.execute(options);
|
|
239
|
+
|
|
235
240
|
case 'debug':
|
|
236
241
|
console.log('Debug functionality is not available in this version.');
|
|
237
242
|
return { success: false, message: 'Debug not available' };
|
|
@@ -278,6 +283,7 @@ class CommandRouter {
|
|
|
278
283
|
console.log(t('help.summaryCommand'));
|
|
279
284
|
console.log(t('help.debugCommand'));
|
|
280
285
|
console.log(t('help.scannerCommand'));
|
|
286
|
+
console.log(t('help.translateCommand'));
|
|
281
287
|
}
|
|
282
288
|
|
|
283
289
|
/**
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* I18NTK TRANSLATE COMMAND
|
|
5
|
+
*
|
|
6
|
+
* Interactive menu-driven auto-translation using Google Translate.
|
|
7
|
+
* Wraps i18ntk-translate.js behind a user-friendly menu flow.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const SecurityUtils = require('../../../utils/security');
|
|
12
|
+
const configManager = require('../../../utils/config-manager');
|
|
13
|
+
const { getUnifiedConfig } = require('../../../utils/config-helper');
|
|
14
|
+
const { loadTranslations } = require('../../../utils/i18n-helper');
|
|
15
|
+
const SetupEnforcer = require('../../../utils/setup-enforcer');
|
|
16
|
+
const {
|
|
17
|
+
createProtectionFile,
|
|
18
|
+
readProtectionFile,
|
|
19
|
+
saveProtectionFile
|
|
20
|
+
} = require('../../../utils/translate/protection');
|
|
21
|
+
|
|
22
|
+
class TranslateCommand {
|
|
23
|
+
constructor(config = {}, ui = null) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.ui = ui;
|
|
26
|
+
this.prompt = null;
|
|
27
|
+
this.isNonInteractiveMode = false;
|
|
28
|
+
this.safeClose = null;
|
|
29
|
+
this.sourceDir = null;
|
|
30
|
+
this.sourceLang = null;
|
|
31
|
+
this.targetLang = null;
|
|
32
|
+
this.configuredTargetLangs = [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setRuntimeDependencies(prompt, isNonInteractiveMode, safeClose) {
|
|
36
|
+
this.prompt = prompt;
|
|
37
|
+
this.isNonInteractiveMode = isNonInteractiveMode;
|
|
38
|
+
this.safeClose = safeClose;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async execute(options = {}) {
|
|
42
|
+
try {
|
|
43
|
+
await SetupEnforcer.checkSetupCompleteAsync();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('Setup check failed:', error.message);
|
|
46
|
+
return { success: false, error: 'Setup required' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
loadTranslations('en', path.resolve(__dirname, '..', '..', '..', 'ui-locales'));
|
|
50
|
+
|
|
51
|
+
const config = this.config || {};
|
|
52
|
+
let unified;
|
|
53
|
+
try {
|
|
54
|
+
unified = await getUnifiedConfig('translate', options);
|
|
55
|
+
} catch (_) {
|
|
56
|
+
unified = config;
|
|
57
|
+
}
|
|
58
|
+
this.autoTranslateSettings = this.getAutoTranslateSettings(unified);
|
|
59
|
+
|
|
60
|
+
const defaultSourceDir = unified.sourceDir || unified.i18nDir || path.resolve(process.cwd(), 'locales', 'en');
|
|
61
|
+
this.sourceLang = unified.sourceLanguage || 'en';
|
|
62
|
+
this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, defaultSourceDir);
|
|
63
|
+
|
|
64
|
+
console.log('\n============================================================');
|
|
65
|
+
console.log(' \u{1F310} AUTO TRANSLATE (BETA)');
|
|
66
|
+
console.log('============================================================');
|
|
67
|
+
|
|
68
|
+
if (this.isNonInteractiveMode) {
|
|
69
|
+
this.sourceDir = defaultSourceDir;
|
|
70
|
+
if (!SecurityUtils.safeExistsSync(this.sourceDir, path.dirname(this.sourceDir))) {
|
|
71
|
+
console.error(`Source locale directory not found: ${this.sourceDir}`);
|
|
72
|
+
return { success: false, error: 'Source directory not found' };
|
|
73
|
+
}
|
|
74
|
+
const jsonFiles = SecurityUtils.safeReaddirSync(this.sourceDir, path.dirname(this.sourceDir)).filter(f => f.endsWith('.json')).sort();
|
|
75
|
+
return await this.nonInteractiveFlow(jsonFiles);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { ask } = require('../../../utils/cli');
|
|
79
|
+
|
|
80
|
+
// Step 1: Choose source directory
|
|
81
|
+
this.sourceDir = await this.promptSourceDir(ask, defaultSourceDir);
|
|
82
|
+
if (!this.sourceDir) return { success: false, error: 'No source directory selected' };
|
|
83
|
+
|
|
84
|
+
// Step 2: Choose source language
|
|
85
|
+
this.sourceLang = await this.promptSourceLang(ask);
|
|
86
|
+
if (!this.sourceLang) return { success: false, error: 'No source language selected' };
|
|
87
|
+
this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, this.sourceDir);
|
|
88
|
+
|
|
89
|
+
const jsonFiles = SecurityUtils.safeReaddirSync(this.sourceDir, path.dirname(this.sourceDir))
|
|
90
|
+
.filter(f => f.endsWith('.json'))
|
|
91
|
+
.sort();
|
|
92
|
+
|
|
93
|
+
if (jsonFiles.length === 0) {
|
|
94
|
+
console.error(`No JSON files found in: ${this.sourceDir}`);
|
|
95
|
+
return { success: false, error: 'No source files found' };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return await this.interactiveFlow(jsonFiles, ask);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async promptSourceDir(ask, defaultDir) {
|
|
102
|
+
while (true) {
|
|
103
|
+
console.log('\n Source locale directory');
|
|
104
|
+
console.log(` Default: ${defaultDir}`);
|
|
105
|
+
console.log(` Current project: ${process.cwd()}`);
|
|
106
|
+
console.log(' Accepted: an absolute path, or a path relative to the current project.');
|
|
107
|
+
console.log(' Examples:');
|
|
108
|
+
console.log(' ./locales/en');
|
|
109
|
+
console.log(` ${defaultDir}`);
|
|
110
|
+
console.log(' The folder must contain the source JSON files to translate.');
|
|
111
|
+
console.log(' Press Enter to use the default.');
|
|
112
|
+
const input = await ask(' > ');
|
|
113
|
+
|
|
114
|
+
if (!input.trim()) {
|
|
115
|
+
if (!SecurityUtils.safeExistsSync(defaultDir, path.dirname(defaultDir))) {
|
|
116
|
+
console.log(` Default directory not found: ${defaultDir}`);
|
|
117
|
+
console.log(' Please enter an existing directory with JSON locale files.');
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
console.log(` Using default: ${defaultDir}`);
|
|
121
|
+
return defaultDir;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const cleanInput = input.trim().replace(/^["']|["']$/g, '');
|
|
125
|
+
const resolved = path.isAbsolute(cleanInput)
|
|
126
|
+
? path.resolve(cleanInput)
|
|
127
|
+
: path.resolve(process.cwd(), cleanInput);
|
|
128
|
+
if (!SecurityUtils.safeExistsSync(resolved, path.dirname(resolved))) {
|
|
129
|
+
console.log(` Directory not found: ${resolved}`);
|
|
130
|
+
console.log(' Enter an existing folder, for example ./locales/en.');
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const stats = SecurityUtils.safeStatSync(resolved, path.dirname(resolved));
|
|
134
|
+
if (!stats || !stats.isDirectory()) {
|
|
135
|
+
console.log(` Not a directory: ${resolved}`);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
console.log(` Using source directory: ${resolved}`);
|
|
139
|
+
return resolved;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async promptSourceLang(ask) {
|
|
144
|
+
while (true) {
|
|
145
|
+
console.log('\n Source language code');
|
|
146
|
+
console.log(` Default: ${this.sourceLang}`);
|
|
147
|
+
console.log(' This should match the language of the source JSON values.');
|
|
148
|
+
console.log(' Example: en');
|
|
149
|
+
console.log(' Press Enter to use the default.');
|
|
150
|
+
const input = await ask(' > ');
|
|
151
|
+
|
|
152
|
+
if (!input.trim()) {
|
|
153
|
+
console.log(` Using source language: ${this.sourceLang}`);
|
|
154
|
+
return this.sourceLang;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const lang = input.trim().toLowerCase();
|
|
158
|
+
if (lang.length >= 2) {
|
|
159
|
+
return lang;
|
|
160
|
+
}
|
|
161
|
+
console.log(' Invalid language code. Use 2+ characters (e.g. en, de, fr).');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async interactiveFlow(jsonFiles, ask) {
|
|
166
|
+
await this.maybeConfigureProtection(ask);
|
|
167
|
+
|
|
168
|
+
console.log('\n Target language(s)');
|
|
169
|
+
if (this.configuredTargetLangs.length > 0) {
|
|
170
|
+
console.log(` a) All configured target languages: ${this.configuredTargetLangs.join(', ')}`);
|
|
171
|
+
} else {
|
|
172
|
+
console.log(' a) All configured target languages: none configured');
|
|
173
|
+
}
|
|
174
|
+
console.log(' Or enter one or more comma/space-separated language codes.');
|
|
175
|
+
console.log(' Examples: de, es, fr or de es fr or zh');
|
|
176
|
+
console.log(` Source language "${this.sourceLang}" will be excluded automatically.`);
|
|
177
|
+
const langInput = await ask(' > ');
|
|
178
|
+
|
|
179
|
+
const targetLangs = this.parseTargetLanguages(langInput);
|
|
180
|
+
|
|
181
|
+
if (targetLangs.length === 0) {
|
|
182
|
+
console.log(' No valid target languages selected. Aborting.');
|
|
183
|
+
if (this.configuredTargetLangs.length === 0) {
|
|
184
|
+
console.log(' Configure defaultLanguages in .i18ntk-config, or enter target codes manually.');
|
|
185
|
+
}
|
|
186
|
+
return { success: false, error: 'Invalid language code' };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
console.log(`\n Target languages: ${targetLangs.join(', ')}`);
|
|
190
|
+
|
|
191
|
+
console.log(`\n Which file(s) to translate?`);
|
|
192
|
+
const filePreview = jsonFiles.length <= 6
|
|
193
|
+
? jsonFiles.join(', ')
|
|
194
|
+
: `${jsonFiles.slice(0, 6).join(', ')}, ...`;
|
|
195
|
+
console.log(` a) All JSON files (${jsonFiles.length}: ${filePreview})`);
|
|
196
|
+
jsonFiles.forEach((f, i) => {
|
|
197
|
+
console.log(` ${i + 1}) ${f}`);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const fileChoice = await ask('\n Choice [a/all or file number]: ');
|
|
201
|
+
let sourceFiles;
|
|
202
|
+
|
|
203
|
+
if (['a', 'all', '*'].includes(fileChoice.trim().toLowerCase())) {
|
|
204
|
+
sourceFiles = jsonFiles.map(f => path.join(this.sourceDir, f));
|
|
205
|
+
} else {
|
|
206
|
+
const idx = parseInt(fileChoice, 10) - 1;
|
|
207
|
+
if (isNaN(idx) || idx < 0 || idx >= jsonFiles.length) {
|
|
208
|
+
console.log(' Invalid choice. Aborting.');
|
|
209
|
+
return { success: false, error: 'Invalid file choice' };
|
|
210
|
+
}
|
|
211
|
+
sourceFiles = [path.join(this.sourceDir, jsonFiles[idx])];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (this.autoTranslateSettings.dryRunFirst !== false) {
|
|
215
|
+
// Dry-run for first language only (all languages use same source so same keys)
|
|
216
|
+
const firstLang = targetLangs[0];
|
|
217
|
+
console.log(`\n Dry-run preview for "${firstLang}"...\n`);
|
|
218
|
+
await this.runTranslate(sourceFiles, firstLang, { dryRun: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log('\n Proceed with actual translation?');
|
|
222
|
+
const answer = await ask(' [y]es / [n]o: ');
|
|
223
|
+
if (!/^y|yes$/i.test(answer.trim())) {
|
|
224
|
+
console.log(' Translation cancelled.');
|
|
225
|
+
return { success: true, cancelled: true };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let results = [];
|
|
229
|
+
for (const lang of targetLangs) {
|
|
230
|
+
console.log(`\n Translating to "${lang}"...\n`);
|
|
231
|
+
try {
|
|
232
|
+
await this.runTranslate(sourceFiles, lang, { dryRun: false });
|
|
233
|
+
results.push({ lang, ok: true });
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.error(` Failed for "${lang}": ${e.message}`);
|
|
236
|
+
results.push({ lang, ok: false, error: e.message });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log('\n Summary:');
|
|
241
|
+
for (const r of results) {
|
|
242
|
+
console.log(` ${r.ok ? '\u{2705}' : '\u{274C}'} ${r.lang}${r.error ? ' (' + r.error + ')' : ''}`);
|
|
243
|
+
}
|
|
244
|
+
console.log('\n Translation complete!');
|
|
245
|
+
return { success: true, results };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async nonInteractiveFlow(jsonFiles) {
|
|
249
|
+
console.log('\n Non-interactive mode. Use direct CLI instead:');
|
|
250
|
+
console.log(' i18ntk-translate <source> <lang> [options]');
|
|
251
|
+
return { success: false, error: 'Non-interactive mode not supported from menu' };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
getConfiguredTargetLanguages(config = {}, sourceDir = this.sourceDir) {
|
|
255
|
+
const candidates = []
|
|
256
|
+
.concat(config.defaultLanguages || [])
|
|
257
|
+
.concat(config.targetLanguages || [])
|
|
258
|
+
.concat(config.supportedLanguages || [])
|
|
259
|
+
.concat(config.settings?.defaultLanguages || []);
|
|
260
|
+
|
|
261
|
+
const parentDir = sourceDir ? path.dirname(sourceDir) : null;
|
|
262
|
+
if (parentDir && SecurityUtils.safeExistsSync(parentDir, path.dirname(parentDir))) {
|
|
263
|
+
const siblingDirs = SecurityUtils.safeReaddirSync(parentDir, path.dirname(parentDir), { withFileTypes: true })
|
|
264
|
+
.filter(entry => entry.isDirectory())
|
|
265
|
+
.map(entry => entry.name);
|
|
266
|
+
candidates.push(...siblingDirs);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return this.normalizeLanguageList(candidates);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
normalizeLanguageList(languages) {
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const sourceLang = String(this.sourceLang || '').toLowerCase();
|
|
275
|
+
const normalized = [];
|
|
276
|
+
|
|
277
|
+
for (const lang of languages || []) {
|
|
278
|
+
if (typeof lang !== 'string') continue;
|
|
279
|
+
const clean = lang.trim().toLowerCase();
|
|
280
|
+
if (!/^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})?$/i.test(clean)) continue;
|
|
281
|
+
if (clean === sourceLang || seen.has(clean)) continue;
|
|
282
|
+
seen.add(clean);
|
|
283
|
+
normalized.push(clean);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return normalized.sort();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
parseTargetLanguages(input) {
|
|
290
|
+
const clean = String(input || '').trim().toLowerCase();
|
|
291
|
+
if (['a', 'all', '*'].includes(clean)) {
|
|
292
|
+
return [...this.configuredTargetLangs];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return this.normalizeLanguageList(clean.split(/[,;\s]+/));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
getAutoTranslateSettings(config = {}) {
|
|
299
|
+
const settings = config.autoTranslate || config.settings?.autoTranslate || {};
|
|
300
|
+
return {
|
|
301
|
+
placeholderMode: ['preserve', 'skip', 'send'].includes(settings.placeholderMode)
|
|
302
|
+
? settings.placeholderMode
|
|
303
|
+
: 'preserve',
|
|
304
|
+
concurrency: this.toInt(settings.concurrency, 6, 1, 25),
|
|
305
|
+
batchSize: this.toInt(settings.batchSize, 100, 1, 10000),
|
|
306
|
+
progressInterval: this.toInt(settings.progressInterval, 25, 1, 10000),
|
|
307
|
+
retryCount: this.toInt(settings.retryCount, 3, 0, 10),
|
|
308
|
+
retryDelay: this.toInt(settings.retryDelay, 1000, 0, 30000),
|
|
309
|
+
timeout: this.toInt(settings.timeout, 15000, 1000, 120000),
|
|
310
|
+
dryRunFirst: settings.dryRunFirst !== false,
|
|
311
|
+
reportStdout: settings.reportStdout !== false,
|
|
312
|
+
bom: settings.bom === true,
|
|
313
|
+
protectionEnabled: settings.protectionEnabled !== false,
|
|
314
|
+
protectionFile: settings.protectionFile || './i18ntk-auto-translate.json',
|
|
315
|
+
promptProtectionSetup: settings.promptProtectionSetup !== false,
|
|
316
|
+
promptProtectionUpdate: settings.promptProtectionUpdate !== false
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async updateAutoTranslateSetting(key, value) {
|
|
321
|
+
try {
|
|
322
|
+
await configManager.updateConfig({ autoTranslate: { [key]: value } });
|
|
323
|
+
await configManager.saveConfig();
|
|
324
|
+
this.autoTranslateSettings[key] = value;
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.log(` Warning: could not save Auto Translate setting "${key}": ${error.message}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async maybeConfigureProtection(ask) {
|
|
331
|
+
const settings = this.autoTranslateSettings;
|
|
332
|
+
if (!settings.protectionEnabled) return;
|
|
333
|
+
|
|
334
|
+
const protectionPath = path.resolve(process.cwd(), settings.protectionFile);
|
|
335
|
+
const exists = SecurityUtils.safeExistsSync(protectionPath, path.dirname(protectionPath));
|
|
336
|
+
|
|
337
|
+
if (!exists && settings.promptProtectionSetup) {
|
|
338
|
+
console.log('\n Protected terms and keys');
|
|
339
|
+
console.log(' Auto Translate can keep brand names, product terms, exact values, or key paths unchanged.');
|
|
340
|
+
console.log(` Protection file: ${protectionPath}`);
|
|
341
|
+
console.log(' Create this JSON file now?');
|
|
342
|
+
const answer = await ask(' [y]es / [n]o / [d]on\'t ask again: ');
|
|
343
|
+
const clean = answer.trim().toLowerCase();
|
|
344
|
+
if (clean === 'd' || clean === 'dont ask again' || clean === "don't ask again") {
|
|
345
|
+
await this.updateAutoTranslateSetting('promptProtectionSetup', false);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (/^y|yes$/i.test(clean)) {
|
|
349
|
+
createProtectionFile(settings.protectionFile);
|
|
350
|
+
await this.promptProtectionEntries(ask, settings.protectionFile);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (exists && settings.promptProtectionUpdate) {
|
|
356
|
+
console.log('\n Protected terms and keys');
|
|
357
|
+
console.log(` Current protection file: ${protectionPath}`);
|
|
358
|
+
console.log(' Update protection rules for this run?');
|
|
359
|
+
const answer = await ask(' [y]es / [n]o / [d]on\'t ask again: ');
|
|
360
|
+
const clean = answer.trim().toLowerCase();
|
|
361
|
+
if (clean === 'd' || clean === 'dont ask again' || clean === "don't ask again") {
|
|
362
|
+
await this.updateAutoTranslateSetting('promptProtectionUpdate', false);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (/^y|yes$/i.test(clean)) {
|
|
366
|
+
await this.promptProtectionEntries(ask, settings.protectionFile);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
splitList(input) {
|
|
372
|
+
return String(input || '')
|
|
373
|
+
.split(/[,;\n]+/)
|
|
374
|
+
.map(item => item.trim())
|
|
375
|
+
.filter(Boolean);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
mergeList(existing, additions) {
|
|
379
|
+
const seen = new Set(existing || []);
|
|
380
|
+
for (const item of additions || []) {
|
|
381
|
+
if (!seen.has(item)) seen.add(item);
|
|
382
|
+
}
|
|
383
|
+
return Array.from(seen);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async promptProtectionEntries(ask, protectionFile) {
|
|
387
|
+
let config;
|
|
388
|
+
try {
|
|
389
|
+
config = readProtectionFile(protectionFile);
|
|
390
|
+
} catch (_) {
|
|
391
|
+
config = {
|
|
392
|
+
version: 1,
|
|
393
|
+
terms: [],
|
|
394
|
+
keys: [],
|
|
395
|
+
values: [],
|
|
396
|
+
patterns: []
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
console.log('\n Add protected terms separated by commas.');
|
|
401
|
+
console.log(' Example: BrandName, PRODUCT_CODE, API');
|
|
402
|
+
const terms = this.splitList(await ask(' Terms [Enter to skip]: '));
|
|
403
|
+
|
|
404
|
+
console.log('\n Add protected key paths separated by commas.');
|
|
405
|
+
console.log(' Exact keys and * wildcards are supported.');
|
|
406
|
+
console.log(' Example: app.brandName, legal.companyName, product.*.symbol');
|
|
407
|
+
const keys = this.splitList(await ask(' Keys [Enter to skip]: '));
|
|
408
|
+
|
|
409
|
+
console.log('\n Add exact values to copy unchanged separated by commas.');
|
|
410
|
+
console.log(' Example: BrandName Ltd, support@example.com');
|
|
411
|
+
const values = this.splitList(await ask(' Values [Enter to skip]: '));
|
|
412
|
+
|
|
413
|
+
console.log('\n Add optional JavaScript regex patterns separated by commas.');
|
|
414
|
+
console.log(' Example: [A-Z]{2,}-\\d+');
|
|
415
|
+
const patterns = this.splitList(await ask(' Patterns [Enter to skip]: '));
|
|
416
|
+
|
|
417
|
+
config.terms = this.mergeList(config.terms, terms);
|
|
418
|
+
config.keys = this.mergeList(config.keys, keys);
|
|
419
|
+
config.values = this.mergeList(config.values, values);
|
|
420
|
+
config.patterns = this.mergeList(config.patterns, patterns);
|
|
421
|
+
|
|
422
|
+
const savedPath = saveProtectionFile(protectionFile, config);
|
|
423
|
+
console.log(` Protection file saved: ${savedPath}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
toInt(value, fallback, min, max) {
|
|
427
|
+
const parsed = parseInt(value, 10);
|
|
428
|
+
if (!Number.isInteger(parsed)) return fallback;
|
|
429
|
+
return Math.min(Math.max(parsed, min), max);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async runTranslate(sourceFiles, targetLang, opts = {}) {
|
|
433
|
+
const { parseArgs, run } = require('../../i18ntk-translate');
|
|
434
|
+
const settings = this.autoTranslateSettings || this.getAutoTranslateSettings();
|
|
435
|
+
|
|
436
|
+
for (const src of sourceFiles) {
|
|
437
|
+
const args = parseArgs(['node', 'i18ntk-translate', src, targetLang]);
|
|
438
|
+
args.noConfirm = true;
|
|
439
|
+
args.sourceLang = this.sourceLang || 'en';
|
|
440
|
+
args.dryRun = opts.dryRun === true;
|
|
441
|
+
args.reportStdout = settings.reportStdout;
|
|
442
|
+
args.bom = settings.bom;
|
|
443
|
+
args.concurrency = settings.concurrency;
|
|
444
|
+
args.batchSize = settings.batchSize;
|
|
445
|
+
args.progressInterval = settings.progressInterval;
|
|
446
|
+
args.retryCount = settings.retryCount;
|
|
447
|
+
args.retryDelay = settings.retryDelay;
|
|
448
|
+
args.timeout = settings.timeout;
|
|
449
|
+
args.protectionEnabled = settings.protectionEnabled;
|
|
450
|
+
args.protectionFile = settings.protectionFile;
|
|
451
|
+
args.preservePlaceholders = settings.placeholderMode === 'preserve';
|
|
452
|
+
args.skipPlaceholders = settings.placeholderMode === 'skip';
|
|
453
|
+
args.sendPlaceholders = settings.placeholderMode === 'send';
|
|
454
|
+
|
|
455
|
+
const result = await run(args);
|
|
456
|
+
if (!result || result.success !== true) {
|
|
457
|
+
throw new Error(result?.error || `Translation failed for ${src}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
module.exports = TranslateCommand;
|
|
@@ -18,8 +18,9 @@ const watchLocales = require('../../../utils/watch-locales');
|
|
|
18
18
|
const { getGlobalReadline, closeGlobalReadline } = require('../../../utils/cli');
|
|
19
19
|
const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir, displayPaths } = require('../../../utils/config-helper');
|
|
20
20
|
const I18nInitializer = require('../../i18ntk-init');
|
|
21
|
-
const JsonOutput = require('../../../utils/json-output');
|
|
22
|
-
const ExitCodes = require('../../../utils/exit-codes');
|
|
21
|
+
const JsonOutput = require('../../../utils/json-output');
|
|
22
|
+
const ExitCodes = require('../../../utils/exit-codes');
|
|
23
|
+
const { detectTranslationContentRisks } = require('../../../utils/validation-risk');
|
|
23
24
|
|
|
24
25
|
loadTranslations('en', path.resolve(__dirname, '../../../ui-locales'));
|
|
25
26
|
|
|
@@ -383,17 +384,28 @@ class ValidateCommand {
|
|
|
383
384
|
}
|
|
384
385
|
}
|
|
385
386
|
|
|
386
|
-
detectRiskyKeys(obj, language, fileName, prefix = '') {
|
|
387
|
-
for (const [key, value] of Object.entries(obj || {})) {
|
|
388
|
-
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
389
|
-
if (typeof value === 'string') {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
387
|
+
detectRiskyKeys(obj, language, fileName, prefix = '') {
|
|
388
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
389
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
390
|
+
if (typeof value === 'string') {
|
|
391
|
+
const issues = detectTranslationContentRisks(value, {
|
|
392
|
+
keyPath: fullKey,
|
|
393
|
+
sourceLanguage: this.config.sourceLanguage,
|
|
394
|
+
targetLanguage: language,
|
|
395
|
+
allowedEnglishTerms: this.config.allowedEnglishTerms,
|
|
396
|
+
englishThresholdPercent: this.config.englishContentThresholdPercent
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
issues.forEach(issue => {
|
|
400
|
+
const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this);
|
|
401
|
+
const message = issue.type === 'english_content'
|
|
402
|
+
? `Possible untranslated English content in ${language}/${fileName}`
|
|
403
|
+
: `Potential risky content in ${language}/${fileName}`;
|
|
404
|
+
reporter(message, { key: fullKey, value, ...issue });
|
|
405
|
+
});
|
|
406
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
407
|
+
this.detectRiskyKeys(value, language, fileName, fullKey);
|
|
408
|
+
}
|
|
397
409
|
}
|
|
398
410
|
}
|
|
399
411
|
|
package/main/manage/index.js
CHANGED
|
@@ -878,6 +878,7 @@ class I18nManager {
|
|
|
878
878
|
console.log(`11. ${t('menu.options.help')}`);
|
|
879
879
|
console.log(`12. ${t('menu.options.language')}`);
|
|
880
880
|
console.log(`13. ${t('menu.options.scanner')}`);
|
|
881
|
+
console.log(`14. ${t('menu.options.translate')}`);
|
|
881
882
|
console.log(`0. ${t('menu.options.exit')}`);
|
|
882
883
|
|
|
883
884
|
console.log('\n' + t('menu.nonInteractiveModeWarning'));
|
|
@@ -903,6 +904,7 @@ class I18nManager {
|
|
|
903
904
|
console.log(`11. ${t('menu.options.help')}`);
|
|
904
905
|
console.log(`12. ${t('menu.options.language')}`);
|
|
905
906
|
console.log(`13. ${t('menu.options.scanner')}`);
|
|
907
|
+
console.log(`14. ${t('menu.options.translate')}`);
|
|
906
908
|
console.log(`0. ${t('menu.options.exit')}`);
|
|
907
909
|
|
|
908
910
|
const choice = await this.prompt('\n' + t('menu.selectOptionPrompt'));
|
|
@@ -1007,11 +1009,15 @@ class I18nManager {
|
|
|
1007
1009
|
case '12':
|
|
1008
1010
|
await this.showLanguageMenu();
|
|
1009
1011
|
break;
|
|
1010
|
-
case '13':
|
|
1011
|
-
await this.executeCommand('scanner', {fromMenu: true});
|
|
1012
|
-
await this.showInteractiveMenu();
|
|
1013
|
-
return;
|
|
1014
|
-
case '
|
|
1012
|
+
case '13':
|
|
1013
|
+
await this.executeCommand('scanner', {fromMenu: true});
|
|
1014
|
+
await this.showInteractiveMenu();
|
|
1015
|
+
return;
|
|
1016
|
+
case '14':
|
|
1017
|
+
await this.executeCommand('translate', {fromMenu: true});
|
|
1018
|
+
await this.showInteractiveMenu();
|
|
1019
|
+
return;
|
|
1020
|
+
case '0':
|
|
1015
1021
|
console.log(t('menu.goodbye'));
|
|
1016
1022
|
this.safeClose();
|
|
1017
1023
|
process.exit(0);
|