i18ntk 2.5.1 → 3.0.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 +385 -0
- package/README.md +56 -47
- package/main/i18ntk-analyze.js +4 -4
- package/main/i18ntk-scanner.js +14 -12
- package/main/i18ntk-translate.js +502 -0
- package/main/i18ntk-validate.js +25 -18
- package/main/manage/commands/AnalyzeCommand.js +7 -4
- package/main/manage/commands/CommandRouter.js +7 -1
- package/main/manage/commands/FixerCommand.js +11 -1
- package/main/manage/commands/ScannerCommand.js +12 -10
- package/main/manage/commands/TranslateCommand.js +242 -0
- package/main/manage/commands/ValidateCommand.js +21 -17
- package/main/manage/index.js +17 -12
- package/package.json +13 -3
- package/runtime/enhanced.js +64 -10
- package/runtime/i18ntk.d.ts +10 -6
- package/runtime/index.js +45 -22
- package/ui-locales/de.json +3 -0
- package/ui-locales/en.json +3 -0
- package/ui-locales/es.json +3 -0
- package/ui-locales/fr.json +3 -0
- package/ui-locales/ja.json +3 -0
- package/ui-locales/ru.json +3 -1
- package/ui-locales/zh.json +3 -0
- package/utils/admin-auth.js +4 -1
- package/utils/config-helper.js +43 -37
- package/utils/config-manager.js +59 -49
- package/utils/config.js +13 -4
- package/utils/env-manager.js +3 -1
- package/utils/i18n-helper.js +41 -13
- package/utils/init-helper.js +23 -21
- package/utils/secure-errors.js +10 -6
- package/utils/security.js +30 -4
- package/utils/setup-enforcer.js +22 -33
- package/utils/translate/api.js +168 -0
- package/utils/translate/cli.js +91 -0
- package/utils/translate/placeholder.js +93 -0
- package/utils/translate/report.js +90 -0
- package/utils/translate/traverse.js +148 -0
- package/utils/watch-locales.js +12 -5
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* I18NTK TRANSLATION GENERATOR
|
|
5
|
+
*
|
|
6
|
+
* Zero-dependency translation utility that converts English source JSON
|
|
7
|
+
* language files into any target language via Google's free Translate API.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* i18ntk-translate <source-file> <target-lang> [options]
|
|
11
|
+
* i18ntk-translate locales/en/common.json de
|
|
12
|
+
* i18ntk-translate locales/en/common.json fr --no-confirm --skip-placeholders
|
|
13
|
+
* i18ntk-translate locales/en/common.json es --dry-run
|
|
14
|
+
*
|
|
15
|
+
* Options:
|
|
16
|
+
* --source-dir <dir> Source directory (default: ./locales/en)
|
|
17
|
+
* --output-dir <dir> Output directory (default: ./locales/<lang>)
|
|
18
|
+
* --custom-regex <regex> Additional placeholder regex pattern
|
|
19
|
+
* --no-confirm Skip all confirmation dialogs
|
|
20
|
+
* --skip-placeholders Skip all strings containing placeholders
|
|
21
|
+
* --send-placeholders Translate all strings including placeholders
|
|
22
|
+
* --concurrency <n> Max concurrent API requests (default: 3)
|
|
23
|
+
* --dry-run Preview mode without API calls
|
|
24
|
+
* --report-file <path> Write report to file
|
|
25
|
+
* --report-stdout Print report to stdout
|
|
26
|
+
* --bom Output UTF-8 with BOM
|
|
27
|
+
* --translate-fn <module> Path to custom translation function module
|
|
28
|
+
* --retry-count <n> Max retries per request (default: 3)
|
|
29
|
+
* --retry-delay <ms> Base delay for retry backoff (default: 1000)
|
|
30
|
+
* --timeout <ms> HTTP request timeout (default: 15000)
|
|
31
|
+
* --source-lang <code> Source language code (default: en)
|
|
32
|
+
* --files <pattern> Glob pattern for multiple files (e.g. *.json)
|
|
33
|
+
* -h, --help Show help
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require('fs');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
const packageJson = require('../package.json');
|
|
39
|
+
const ExitCodes = require('../utils/exit-codes');
|
|
40
|
+
const { isInteractive } = require('../utils/prompt-helper');
|
|
41
|
+
const { detectPlaceholders, maskPlaceholders, unmaskPlaceholders } = require('../utils/translate/placeholder');
|
|
42
|
+
const { translateBatch } = require('../utils/translate/api');
|
|
43
|
+
const { collectLeaves, setLeaf, deepClone } = require('../utils/translate/traverse');
|
|
44
|
+
const { generateReport, writeReport, formatSummaryLine } = require('../utils/translate/report');
|
|
45
|
+
const {
|
|
46
|
+
confirmGlobalChoice,
|
|
47
|
+
confirmPerKey,
|
|
48
|
+
previewSkipped,
|
|
49
|
+
} = require('../utils/translate/cli');
|
|
50
|
+
|
|
51
|
+
const BOM = '\uFEFF';
|
|
52
|
+
|
|
53
|
+
function printHelp() {
|
|
54
|
+
console.log([
|
|
55
|
+
'',
|
|
56
|
+
`I18NTK Translation Generator - v${packageJson.version}`,
|
|
57
|
+
'',
|
|
58
|
+
'Usage:',
|
|
59
|
+
' i18ntk-translate <source-file> <target-lang> [options]',
|
|
60
|
+
' i18ntk-translate <source-file> <target-lang> --source-dir <dir> [options]',
|
|
61
|
+
'',
|
|
62
|
+
'Examples:',
|
|
63
|
+
' i18ntk-translate locales/en/common.json de',
|
|
64
|
+
' i18ntk-translate locales/en/common.json fr --dry-run',
|
|
65
|
+
' i18ntk-translate locales/en/ es --files "*.json"',
|
|
66
|
+
' i18ntk-translate locales/en/common.json ja --no-confirm --skip-placeholders',
|
|
67
|
+
' i18ntk-translate locales/en/common.json ko --report-file report.txt',
|
|
68
|
+
'',
|
|
69
|
+
'Options:',
|
|
70
|
+
' --source-dir <dir> Source directory containing locale files',
|
|
71
|
+
' --output-dir <dir> Output directory for translated files',
|
|
72
|
+
' --source-lang <code> Source language code (default: en)',
|
|
73
|
+
' --custom-regex <regex> Additional placeholder regex pattern',
|
|
74
|
+
' --no-confirm Automate: skip confirmation dialogs',
|
|
75
|
+
' --skip-placeholders Skip all strings with placeholder tokens',
|
|
76
|
+
' --send-placeholders Translate all strings including placeholders',
|
|
77
|
+
' --concurrency <n> Max concurrent API requests (default: 3)',
|
|
78
|
+
' --dry-run Preview: show what would be skipped',
|
|
79
|
+
' --report-file <path> Write post-translation report to file',
|
|
80
|
+
' --report-stdout Print post-translation report to stdout',
|
|
81
|
+
' --bom Write output files with UTF-8 BOM',
|
|
82
|
+
' --translate-fn <module> Path to custom translation function module',
|
|
83
|
+
' --retry-count <n> Max retries per failed request (default: 3)',
|
|
84
|
+
' --retry-delay <ms> Base backoff delay in ms (default: 1000)',
|
|
85
|
+
' --timeout <ms> HTTP request timeout in ms (default: 15000)',
|
|
86
|
+
' -h, --help Show this help',
|
|
87
|
+
].join('\n'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseArgs(argv) {
|
|
91
|
+
const args = {
|
|
92
|
+
sourceFile: null,
|
|
93
|
+
targetLang: null,
|
|
94
|
+
sourceDir: null,
|
|
95
|
+
outputDir: null,
|
|
96
|
+
sourceLang: 'en',
|
|
97
|
+
customRegex: [],
|
|
98
|
+
noConfirm: false,
|
|
99
|
+
skipPlaceholders: false,
|
|
100
|
+
sendPlaceholders: false,
|
|
101
|
+
concurrency: 3,
|
|
102
|
+
dryRun: false,
|
|
103
|
+
reportFile: null,
|
|
104
|
+
reportStdout: false,
|
|
105
|
+
bom: false,
|
|
106
|
+
translateFnPath: null,
|
|
107
|
+
retryCount: 3,
|
|
108
|
+
retryDelay: 1000,
|
|
109
|
+
timeout: 15000,
|
|
110
|
+
filesPattern: null,
|
|
111
|
+
help: false,
|
|
112
|
+
unknown: [],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const positional = [];
|
|
116
|
+
for (let i = 2; i < argv.length; i++) {
|
|
117
|
+
const arg = argv[i];
|
|
118
|
+
if (arg === '-h' || arg === '--help') { args.help = true; }
|
|
119
|
+
else if (arg === '--no-confirm') { args.noConfirm = true; }
|
|
120
|
+
else if (arg === '--skip-placeholders') { args.skipPlaceholders = true; }
|
|
121
|
+
else if (arg === '--send-placeholders') { args.sendPlaceholders = true; }
|
|
122
|
+
else if (arg === '--dry-run') { args.dryRun = true; }
|
|
123
|
+
else if (arg === '--report-stdout') { args.reportStdout = true; }
|
|
124
|
+
else if (arg === '--bom') { args.bom = true; }
|
|
125
|
+
else if (arg === '--source-dir' && i + 1 < argv.length) { args.sourceDir = argv[++i]; }
|
|
126
|
+
else if (arg === '--output-dir' && i + 1 < argv.length) { args.outputDir = argv[++i]; }
|
|
127
|
+
else if (arg === '--source-lang' && i + 1 < argv.length) { args.sourceLang = argv[++i]; }
|
|
128
|
+
else if (arg === '--custom-regex' && i + 1 < argv.length) { args.customRegex.push(argv[++i]); }
|
|
129
|
+
else if (arg === '--concurrency' && i + 1 < argv.length) { args.concurrency = parseInt(argv[++i], 10) || 3; }
|
|
130
|
+
else if (arg === '--report-file' && i + 1 < argv.length) { args.reportFile = argv[++i]; }
|
|
131
|
+
else if (arg === '--translate-fn' && i + 1 < argv.length) { args.translateFnPath = argv[++i]; }
|
|
132
|
+
else if (arg === '--retry-count' && i + 1 < argv.length) { args.retryCount = parseInt(argv[++i], 10) || 3; }
|
|
133
|
+
else if (arg === '--retry-delay' && i + 1 < argv.length) { args.retryDelay = parseInt(argv[++i], 10) || 1000; }
|
|
134
|
+
else if (arg === '--timeout' && i + 1 < argv.length) { args.timeout = parseInt(argv[++i], 10) || 15000; }
|
|
135
|
+
else if (arg === '--files' && i + 1 < argv.length) { args.filesPattern = argv[++i]; }
|
|
136
|
+
else if (arg.startsWith('-')) { args.unknown.push(arg); }
|
|
137
|
+
else { positional.push(arg); }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (positional.length >= 1) args.sourceFile = positional[0];
|
|
141
|
+
if (positional.length >= 2) args.targetLang = positional[1];
|
|
142
|
+
|
|
143
|
+
if (args.sendPlaceholders && args.skipPlaceholders) {
|
|
144
|
+
console.error('Error: --skip-placeholders and --send-placeholders are mutually exclusive.');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return args;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function loadCustomTranslateFn(modulePath) {
|
|
152
|
+
if (!modulePath) return null;
|
|
153
|
+
try {
|
|
154
|
+
const resolved = path.isAbsolute(modulePath) ? modulePath : path.resolve(process.cwd(), modulePath);
|
|
155
|
+
const mod = require(resolved);
|
|
156
|
+
if (typeof mod === 'function') return mod;
|
|
157
|
+
if (mod && typeof mod.translate === 'function') return mod.translate;
|
|
158
|
+
if (mod && typeof mod.default === 'function') return mod.default;
|
|
159
|
+
console.error(`Warning: Custom translate module "${modulePath}" does not export a function.`);
|
|
160
|
+
return null;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.error(`Error: Failed to load translate function module "${modulePath}": ${e.message}`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function resolveSourceFiles(sourceFile, sourceDir, filesPattern) {
|
|
168
|
+
if (sourceDir) {
|
|
169
|
+
const resolvedDir = path.resolve(process.cwd(), sourceDir);
|
|
170
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
171
|
+
console.error(`Error: Source directory "${resolvedDir}" does not exist.`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
const entries = fs.readdirSync(resolvedDir);
|
|
175
|
+
const pattern = filesPattern || '*.json';
|
|
176
|
+
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
|
|
177
|
+
const files = entries.filter((f) => regex.test(f) && f.endsWith('.json')).sort();
|
|
178
|
+
if (files.length === 0) {
|
|
179
|
+
console.error(`Error: No JSON files matching "${pattern}" found in "${resolvedDir}".`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
return files.map((f) => path.join(resolvedDir, f));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (sourceFile) {
|
|
186
|
+
const resolved = path.resolve(process.cwd(), sourceFile);
|
|
187
|
+
if (!fs.existsSync(resolved)) {
|
|
188
|
+
console.error(`Error: Source file "${resolved}" does not exist.`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
return [resolved];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.error('Error: No source file specified. Use --source-dir or provide a source file.');
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function classifyLeaves(leaves, customRegex) {
|
|
199
|
+
const withPlaceholders = [];
|
|
200
|
+
const withoutPlaceholders = [];
|
|
201
|
+
|
|
202
|
+
for (const leaf of leaves) {
|
|
203
|
+
const placeholders = detectPlaceholders(leaf.value, customRegex);
|
|
204
|
+
if (placeholders.length > 0) {
|
|
205
|
+
withPlaceholders.push({ ...leaf, placeholders });
|
|
206
|
+
} else {
|
|
207
|
+
withoutPlaceholders.push(leaf);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { withPlaceholders, withoutPlaceholders };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function resolvePlaceholderStrategy(args) {
|
|
215
|
+
const interactive = isInteractive({ noPrompt: args.noConfirm });
|
|
216
|
+
|
|
217
|
+
if (args.sendPlaceholders) {
|
|
218
|
+
return { strategy: 'send', interactiveMode: false };
|
|
219
|
+
}
|
|
220
|
+
if (args.skipPlaceholders) {
|
|
221
|
+
return { strategy: 'skip', interactiveMode: false };
|
|
222
|
+
}
|
|
223
|
+
if (args.noConfirm) {
|
|
224
|
+
return { strategy: 'skip', interactiveMode: false };
|
|
225
|
+
}
|
|
226
|
+
if (!interactive) {
|
|
227
|
+
return { strategy: 'skip', interactiveMode: false };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const choice = await confirmGlobalChoice();
|
|
231
|
+
return { strategy: choice.strategy, interactiveMode: choice.interactive };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function resolvePerKeyDecisions(withPlaceholders, interactive) {
|
|
235
|
+
const decisions = {};
|
|
236
|
+
|
|
237
|
+
if (!interactive) {
|
|
238
|
+
for (const leaf of withPlaceholders) {
|
|
239
|
+
decisions[leaf.keyPath] = 'skip';
|
|
240
|
+
}
|
|
241
|
+
return decisions;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let bulkDecision = null;
|
|
245
|
+
for (const leaf of withPlaceholders) {
|
|
246
|
+
if (bulkDecision) {
|
|
247
|
+
decisions[leaf.keyPath] = bulkDecision;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const choice = await confirmPerKey(leaf.keyPath, leaf.value, leaf.placeholders);
|
|
251
|
+
if (choice === 'skip-all') {
|
|
252
|
+
bulkDecision = 'skip';
|
|
253
|
+
decisions[leaf.keyPath] = 'skip';
|
|
254
|
+
} else if (choice === 'send-all') {
|
|
255
|
+
bulkDecision = 'send';
|
|
256
|
+
decisions[leaf.keyPath] = 'send';
|
|
257
|
+
} else {
|
|
258
|
+
decisions[leaf.keyPath] = choice;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return decisions;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, decisions) {
|
|
266
|
+
const toTranslate = [];
|
|
267
|
+
const toSkip = [];
|
|
268
|
+
|
|
269
|
+
for (const leaf of withoutPlaceholders) {
|
|
270
|
+
toTranslate.push(leaf);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (strategy === 'send') {
|
|
274
|
+
for (const leaf of withPlaceholders) {
|
|
275
|
+
toTranslate.push(leaf);
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
for (const leaf of withPlaceholders) {
|
|
279
|
+
const decision = decisions[leaf.keyPath] || 'skip';
|
|
280
|
+
if (decision === 'send') {
|
|
281
|
+
toTranslate.push(leaf);
|
|
282
|
+
} else {
|
|
283
|
+
toSkip.push(leaf);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { toTranslate, toSkip };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function maskAllForTranslation(toTranslate, customRegex) {
|
|
292
|
+
return toTranslate.map((leaf) => {
|
|
293
|
+
const { masked, map } = maskPlaceholders(leaf.value, customRegex);
|
|
294
|
+
return { ...leaf, masked, placeholderMap: map, needsUnmask: map.size > 0 };
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function runTranslation(maskedBatch, targetLang, options) {
|
|
299
|
+
const batchItems = maskedBatch.map((item) => ({ value: item.masked, keyPath: item.keyPath }));
|
|
300
|
+
const results = await translateBatch(batchItems, targetLang, options);
|
|
301
|
+
return results;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function applyResults(sourceData, translatedResults, maskedBatch, toSkip, bom) {
|
|
305
|
+
const output = deepClone(sourceData);
|
|
306
|
+
|
|
307
|
+
for (let i = 0; i < maskedBatch.length; i++) {
|
|
308
|
+
const item = maskedBatch[i];
|
|
309
|
+
const translated = translatedResults[i];
|
|
310
|
+
let finalValue;
|
|
311
|
+
if (item.needsUnmask) {
|
|
312
|
+
finalValue = unmaskPlaceholders(translated, item.placeholderMap);
|
|
313
|
+
} else {
|
|
314
|
+
finalValue = translated;
|
|
315
|
+
}
|
|
316
|
+
setLeaf(output, item.keyPath, finalValue);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const leaf of toSkip) {
|
|
320
|
+
setLeaf(output, leaf.keyPath, leaf.value);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return output;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function writeOutput(outputData, outputPath, bom) {
|
|
327
|
+
const dir = path.dirname(outputPath);
|
|
328
|
+
if (!fs.existsSync(dir)) {
|
|
329
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
330
|
+
}
|
|
331
|
+
let content = JSON.stringify(outputData, null, 2) + '\n';
|
|
332
|
+
if (bom) {
|
|
333
|
+
content = BOM + content;
|
|
334
|
+
}
|
|
335
|
+
fs.writeFileSync(outputPath, content, 'utf-8');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function processFile(sourcePath, targetLang, args) {
|
|
339
|
+
const fileName = path.basename(sourcePath);
|
|
340
|
+
const targetDir = args.outputDir || path.join(path.dirname(path.dirname(sourcePath)), targetLang);
|
|
341
|
+
const targetPath = path.join(targetDir, fileName);
|
|
342
|
+
|
|
343
|
+
let sourceData;
|
|
344
|
+
try {
|
|
345
|
+
const raw = fs.readFileSync(sourcePath, 'utf-8');
|
|
346
|
+
sourceData = JSON.parse(raw);
|
|
347
|
+
} catch (e) {
|
|
348
|
+
console.error(`Error reading "${sourcePath}": ${e.message}`);
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const leaves = collectLeaves(sourceData);
|
|
353
|
+
if (leaves.length === 0) {
|
|
354
|
+
console.log(`[${fileName}] No translatable strings found.`);
|
|
355
|
+
writeOutput(sourceData, targetPath, args.bom);
|
|
356
|
+
return { total: 0, translated: 0, skipped: 0, skippedKeys: [] };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const { withPlaceholders, withoutPlaceholders } = classifyLeaves(leaves, args.customRegex);
|
|
360
|
+
|
|
361
|
+
if (args.dryRun && withPlaceholders.length > 0) {
|
|
362
|
+
await previewSkipped(withPlaceholders);
|
|
363
|
+
return {
|
|
364
|
+
total: leaves.length,
|
|
365
|
+
translated: withoutPlaceholders.length,
|
|
366
|
+
skipped: withPlaceholders.length,
|
|
367
|
+
skippedKeys: withPlaceholders,
|
|
368
|
+
dryRun: true,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (args.dryRun) {
|
|
373
|
+
console.log(`[${fileName}] Dry-run: ${leaves.length} strings, all would be translated.`);
|
|
374
|
+
return {
|
|
375
|
+
total: leaves.length,
|
|
376
|
+
translated: leaves.length,
|
|
377
|
+
skipped: 0,
|
|
378
|
+
skippedKeys: [],
|
|
379
|
+
dryRun: true,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const { strategy, interactiveMode } = await resolvePlaceholderStrategy(args);
|
|
384
|
+
const decisions = await resolvePerKeyDecisions(withPlaceholders, interactiveMode);
|
|
385
|
+
const { toTranslate, toSkip } = buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, decisions);
|
|
386
|
+
|
|
387
|
+
if (toSkip.length > 0) {
|
|
388
|
+
console.log(`[${fileName}] Skipping ${toSkip.length} keys with placeholders.`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const maskedBatch = maskAllForTranslation(toTranslate, args.customRegex);
|
|
392
|
+
|
|
393
|
+
const translateOptions = {
|
|
394
|
+
sourceLang: args.sourceLang,
|
|
395
|
+
concurrency: args.concurrency,
|
|
396
|
+
retryCount: args.retryCount,
|
|
397
|
+
retryDelay: args.retryDelay,
|
|
398
|
+
timeout: args.timeout,
|
|
399
|
+
customFn: args.translateFn,
|
|
400
|
+
onProgress: (info) => {
|
|
401
|
+
if (info.completed % 10 === 0 || info.completed === info.total) {
|
|
402
|
+
process.stdout.write(`\r[${fileName}] Translating... ${info.completed}/${info.total}`);
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
onError: (err) => {
|
|
406
|
+
console.error(`\n[${fileName}] Warning: Failed to translate key "${err.item.keyPath}": ${err.message}`);
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
let translatedResults;
|
|
411
|
+
if (maskedBatch.length > 0) {
|
|
412
|
+
translatedResults = await runTranslation(maskedBatch, targetLang, translateOptions);
|
|
413
|
+
process.stdout.write('\n');
|
|
414
|
+
} else {
|
|
415
|
+
translatedResults = [];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const output = applyResults(sourceData, translatedResults, maskedBatch, toSkip, args.bom);
|
|
419
|
+
writeOutput(output, targetPath, args.bom);
|
|
420
|
+
|
|
421
|
+
console.log(`[${fileName}] Written: ${targetPath}`);
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
total: leaves.length,
|
|
425
|
+
translated: translatedResults.length,
|
|
426
|
+
skipped: toSkip.length,
|
|
427
|
+
skippedKeys: toSkip,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function main() {
|
|
432
|
+
const args = parseArgs(process.argv);
|
|
433
|
+
|
|
434
|
+
if (args.help) {
|
|
435
|
+
printHelp();
|
|
436
|
+
process.exit(ExitCodes.SUCCESS);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (args.unknown.length > 0) {
|
|
440
|
+
console.error(`Unknown options: ${args.unknown.join(', ')}`);
|
|
441
|
+
console.error('Use --help for usage information.');
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (!args.targetLang) {
|
|
446
|
+
console.error('Error: Target language code is required.');
|
|
447
|
+
console.error('Usage: i18ntk-translate <source-file> <target-lang> [options]');
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (args.translateFnPath) {
|
|
452
|
+
args.translateFn = loadCustomTranslateFn(args.translateFnPath);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const sourceFiles = resolveSourceFiles(args.sourceFile, args.sourceDir, args.filesPattern);
|
|
456
|
+
|
|
457
|
+
const allSkippedKeys = [];
|
|
458
|
+
let grandTotal = 0;
|
|
459
|
+
let grandTranslated = 0;
|
|
460
|
+
let grandSkipped = 0;
|
|
461
|
+
|
|
462
|
+
for (const srcPath of sourceFiles) {
|
|
463
|
+
const result = await processFile(srcPath, args.targetLang, args);
|
|
464
|
+
if (result) {
|
|
465
|
+
grandTotal += result.total;
|
|
466
|
+
grandTranslated += result.translated;
|
|
467
|
+
grandSkipped += result.skipped;
|
|
468
|
+
if (result.skippedKeys && result.skippedKeys.length > 0) {
|
|
469
|
+
allSkippedKeys.push(...result.skippedKeys);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
console.log('');
|
|
475
|
+
console.log(formatSummaryLine(grandSkipped, grandTranslated, grandTotal));
|
|
476
|
+
|
|
477
|
+
if (allSkippedKeys.length > 0 || args.reportFile || args.reportStdout) {
|
|
478
|
+
const report = generateReport(allSkippedKeys, grandTranslated, grandTotal, {
|
|
479
|
+
sourceFile: sourceFiles.length === 1 ? sourceFiles[0] : `${sourceFiles.length} files`,
|
|
480
|
+
targetLang: args.targetLang,
|
|
481
|
+
dryRun: args.dryRun,
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
if (args.reportStdout || (!args.reportFile && allSkippedKeys.length > 0)) {
|
|
485
|
+
console.log('');
|
|
486
|
+
console.log(report);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (args.reportFile) {
|
|
490
|
+
const reportPath = path.resolve(process.cwd(), args.reportFile);
|
|
491
|
+
writeReport(report, reportPath);
|
|
492
|
+
console.log(`Report written: ${reportPath}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
process.exit(ExitCodes.SUCCESS);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
main().catch((err) => {
|
|
500
|
+
console.error('Fatal error:', err.message);
|
|
501
|
+
process.exit(1);
|
|
502
|
+
});
|
package/main/i18ntk-validate.js
CHANGED
|
@@ -33,7 +33,7 @@ if (isUppercase) {
|
|
|
33
33
|
console.error(' npm run i18ntk:manage');
|
|
34
34
|
console.error('');
|
|
35
35
|
console.error('📖 For more information, run: npx i18ntk --help');
|
|
36
|
-
process.exit(
|
|
36
|
+
process.exit(1);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const fs = require('fs');
|
|
@@ -112,8 +112,8 @@ class I18nValidator {
|
|
|
112
112
|
} else {
|
|
113
113
|
console.warn(t('config.dirFallbackWarning', { dir: this.sourceDir, fallback: this.sourceLanguageDir }) ||
|
|
114
114
|
`Warning: Directory ${this.sourceDir} not found. Using ${this.sourceLanguageDir}.`);
|
|
115
|
-
if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir)) {
|
|
116
|
-
|
|
115
|
+
if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir, process.cwd())) {
|
|
116
|
+
SecurityUtils.safeMkdirSync(this.sourceLanguageDir, process.cwd(), { recursive: true });
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
}
|
|
@@ -204,13 +204,17 @@ class I18nValidator {
|
|
|
204
204
|
throw new Error(`Source directory not found: ${this.sourceDir}`);
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
207
|
+
const items = SecurityUtils.safeReaddirSync(this.sourceDir, process.cwd(), { withFileTypes: true });
|
|
208
|
+
if (!items) {
|
|
209
|
+
throw new Error(`Source directory not found: ${this.sourceDir}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const languages = items
|
|
213
|
+
.filter(item => {
|
|
214
|
+
return item.isDirectory() &&
|
|
215
|
+
item.name !== this.config.sourceLanguage &&
|
|
216
|
+
!this.isExcludedLanguageDirectory(item.name);
|
|
217
|
+
}).map(item => item.name);
|
|
214
218
|
|
|
215
219
|
return languages;
|
|
216
220
|
} catch (error) {
|
|
@@ -228,11 +232,14 @@ class I18nValidator {
|
|
|
228
232
|
return [];
|
|
229
233
|
}
|
|
230
234
|
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
235
|
+
const items = SecurityUtils.safeReaddirSync(languageDir, process.cwd(), { withFileTypes: true });
|
|
236
|
+
if (!items) return [];
|
|
237
|
+
|
|
238
|
+
const files = items
|
|
239
|
+
.filter(item => {
|
|
240
|
+
return item.isFile() && item.name.endsWith('.json') &&
|
|
241
|
+
!this.config.excludeFiles.includes(item.name);
|
|
242
|
+
}).map(item => item.name);
|
|
236
243
|
|
|
237
244
|
return files;
|
|
238
245
|
} catch (error) {
|
|
@@ -682,10 +689,10 @@ class I18nValidator {
|
|
|
682
689
|
|
|
683
690
|
// Delete old validation report if it exists
|
|
684
691
|
const reportPath = path.join(process.cwd(), 'validation-report.txt');
|
|
685
|
-
SecurityUtils.validatePath(reportPath);
|
|
692
|
+
const validatedPath = SecurityUtils.validatePath(reportPath, process.cwd());
|
|
686
693
|
|
|
687
|
-
if (SecurityUtils.safeExistsSync(
|
|
688
|
-
|
|
694
|
+
if (validatedPath && SecurityUtils.safeExistsSync(validatedPath, process.cwd())) {
|
|
695
|
+
SecurityUtils.safeUnlinkSync(validatedPath, process.cwd());
|
|
689
696
|
console.log(t('validate.deletedOldReport'));
|
|
690
697
|
|
|
691
698
|
SecurityUtils.logSecurityEvent(t('validate.fileDeleted'), 'info', {
|
|
@@ -129,10 +129,13 @@ class AnalyzeCommand {
|
|
|
129
129
|
throw new Error(`Source directory does not exist or is not a directory: ${safePath}`);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
132
|
+
try {
|
|
133
|
+
const stat = SecurityUtils.safeStatSync(safePath, process.cwd());
|
|
134
|
+
if (!stat || !stat.isDirectory()) {
|
|
135
|
+
throw new Error(`Source directory is not accessible: ${safePath}`);
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(`Insufficient permissions for source directory: ${safePath}`);
|
|
136
139
|
}
|
|
137
140
|
}
|
|
138
141
|
|
|
@@ -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
|
/**
|
|
@@ -373,7 +373,17 @@ class FixerCommand {
|
|
|
373
373
|
if (backupDirs.length <= keepCount) return;
|
|
374
374
|
|
|
375
375
|
for (const staleDir of backupDirs.slice(keepCount)) {
|
|
376
|
-
|
|
376
|
+
try {
|
|
377
|
+
SecurityUtils.safeUnlinkSync(staleDir.path, process.cwd());
|
|
378
|
+
} catch (_) {
|
|
379
|
+
// Directory not empty, use recursive removal
|
|
380
|
+
try {
|
|
381
|
+
const { rmSync } = require('fs');
|
|
382
|
+
rmSync(staleDir.path, { recursive: true, force: true });
|
|
383
|
+
} catch (_) {
|
|
384
|
+
// Best-effort cleanup
|
|
385
|
+
}
|
|
386
|
+
}
|
|
377
387
|
}
|
|
378
388
|
} catch (error) {
|
|
379
389
|
console.warn(`Failed to clean old backups: ${error.message}`);
|