@ansstory/hias 1.0.4 → 1.0.6

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.
@@ -0,0 +1,1049 @@
1
+ // 翻译命令处理 - 实现文件/文件夹翻译功能,支持百度翻译、腾讯翻译
2
+
3
+ const fs = require('fs')
4
+ const fsp = fs.promises
5
+ const path = require('path')
6
+ const os = require('os')
7
+ const inquirer = require('inquirer')
8
+ const chalk = require('chalk')
9
+ const ora = require('ora')
10
+ const { minimatch } = require('minimatch')
11
+ const { baiduTranslate } = require('../utils/baidu-translate')
12
+ const { tencentTranslate } = require('../utils/tencent-translate')
13
+ const writeFile = require('../utils/write-file')
14
+ const i18n = require('../i18n')
15
+ const extractors = require('../extractors')
16
+ const translationCache = require('../utils/translation-cache')
17
+ const {
18
+ normalizeExistingCalls,
19
+ stripExistingI18nCalls,
20
+ extractChineseWithPositions,
21
+ applyReplacements,
22
+ applyJsonTranslation,
23
+ mergeAndWriteLocaleFiles,
24
+ } = extractors
25
+
26
+ /**
27
+ * 读取配置:优先项目级 .hias/setting.json,其次全局 ~/.hias-cli/config.json,最后使用默认值
28
+ */
29
+ function resolveSetting(baseDir) {
30
+ const defaults = {
31
+ locales: ['zh-CN', 'en-US'],
32
+ outDir: '.hias/lang',
33
+ fallbackToKey: true,
34
+ provider: 'tencent',
35
+ appId: '',
36
+ secretKey: '',
37
+ replaceOriginalFile: false,
38
+ i18nCallTemplate: '$t',
39
+ extensions: [],
40
+ }
41
+
42
+ let settings = null
43
+
44
+ // 1. 项目级配置
45
+ const projectPath = path.join(baseDir, '.hias', 'setting.json')
46
+ if (fs.existsSync(projectPath)) {
47
+ try {
48
+ const raw = JSON.parse(fs.readFileSync(projectPath, 'utf-8'))
49
+ settings = { ...defaults, ...(raw.translationSetting || raw) }
50
+ } catch (e) {
51
+ const t = i18n.getT()
52
+ console.warn(chalk.yellow(t('messages.translateSettingParseError')))
53
+ }
54
+ }
55
+
56
+ // 2. 全局配置(仅在未找到项目配置时使用)
57
+ if (!settings) {
58
+ const globalPath = path.join(os.homedir(), '.hias-cli', 'config.json')
59
+ if (fs.existsSync(globalPath)) {
60
+ try {
61
+ const raw = JSON.parse(fs.readFileSync(globalPath, 'utf-8'))
62
+ if (raw.translationSetting) {
63
+ settings = { ...defaults, ...raw.translationSetting }
64
+ }
65
+ } catch (e) {
66
+ // 静默忽略全局配置解析错误
67
+ }
68
+ }
69
+ }
70
+
71
+ // 3. 最终兜底使用默认值
72
+ if (!settings) {
73
+ settings = { ...defaults }
74
+ }
75
+
76
+ // 校验并修复配置项
77
+ validateAndFixSettings(settings, defaults)
78
+
79
+ // 校验翻译 API 配置
80
+ const t = i18n.getT()
81
+ if (settings.provider === 'baidu' && (!settings.appId || !settings.secretKey)) {
82
+ console.warn(chalk.yellow(t('messages.translateMissingBaiduConfig')))
83
+ } else if (settings.provider === 'tencent' && (!settings.appId || !settings.secretKey)) {
84
+ console.warn(chalk.yellow(t('messages.translateMissingTencentConfig')))
85
+ }
86
+
87
+ return settings
88
+ }
89
+
90
+ function validateAndFixSettings(settings, defaults) {
91
+ const t = i18n.getT()
92
+ const warns = []
93
+
94
+ if (!Array.isArray(settings.locales) || settings.locales.length < 2) {
95
+ warns.push('locales: must be an array with at least 2 items (source + target), reset to default')
96
+ settings.locales = defaults.locales
97
+ }
98
+ if (!['baidu', 'tencent'].includes(settings.provider)) {
99
+ warns.push(`provider: "${settings.provider}" is invalid, must be "baidu" or "tencent", reset to "${defaults.provider}"`)
100
+ settings.provider = defaults.provider
101
+ }
102
+ if (typeof settings.appId !== 'string') {
103
+ warns.push('appId: must be a string, reset to empty')
104
+ settings.appId = ''
105
+ }
106
+ if (typeof settings.secretKey !== 'string') {
107
+ warns.push('secretKey: must be a string, reset to empty')
108
+ settings.secretKey = ''
109
+ }
110
+ if (typeof settings.outDir !== 'string') {
111
+ warns.push('outDir: must be a string, reset to default')
112
+ settings.outDir = defaults.outDir
113
+ }
114
+ if (typeof settings.replaceOriginalFile !== 'boolean') {
115
+ warns.push('replaceOriginalFile: must be a boolean, reset to default')
116
+ settings.replaceOriginalFile = defaults.replaceOriginalFile
117
+ }
118
+ if (typeof settings.fallbackToKey !== 'boolean') {
119
+ warns.push('fallbackToKey: must be a boolean, reset to default')
120
+ settings.fallbackToKey = defaults.fallbackToKey
121
+ }
122
+ if (typeof settings.i18nCallTemplate !== 'string') {
123
+ warns.push('i18nCallTemplate: must be a string, reset to default')
124
+ settings.i18nCallTemplate = defaults.i18nCallTemplate
125
+ }
126
+ if (!Array.isArray(settings.extensions)) {
127
+ settings.extensions = defaults.extensions
128
+ }
129
+
130
+ for (const w of warns) {
131
+ console.warn(chalk.yellow('Config warning: ' + w))
132
+ }
133
+ }
134
+
135
+ /**
136
+ * 将英文字本转换为合法的 key 名称:小写、空格/特殊字符转为下划线
137
+ */
138
+ function sanitizeKey(text, maxLen = 40) {
139
+ let key = text
140
+ .toLowerCase()
141
+ .replace(/[^a-z0-9]+/g, '_')
142
+ .replace(/^_|_$/g, '')
143
+ if (key.length > maxLen) {
144
+ key = key.substring(0, maxLen).replace(/_+$/, '')
145
+ }
146
+ return key || 'key'
147
+ }
148
+
149
+ /**
150
+ * 根据英文本列表生成去重后的 key,key 基于英文本内容
151
+ */
152
+ function generateKeysFromTranslations(translations) {
153
+ const keys = []
154
+ const usedKeys = new Map()
155
+ for (const text of translations) {
156
+ let key = sanitizeKey(text)
157
+ if (usedKeys.has(key)) {
158
+ const count = usedKeys.get(key) + 1
159
+ usedKeys.set(key, count)
160
+ key = `${key}_${count}`
161
+ } else {
162
+ usedKeys.set(key, 0)
163
+ }
164
+ keys.push(key)
165
+ }
166
+ return keys
167
+ }
168
+
169
+ /**
170
+ * 将语言代码(如 zh-CN)转为 API 用的简码(如 zh)
171
+ * 百度 API 简码与标准不一致,需要映射
172
+ */
173
+ function shortLang(locale) {
174
+ if (!locale) return 'zh'
175
+ const code = locale.split('-')[0].split('_')[0].toLowerCase()
176
+ // 百度翻译 API 特殊映射:jp→jp, kor→ko, fra→fr, spa→es, etc.
177
+ const BAIDU_LANG_MAP = {
178
+ ja: 'jp',
179
+ kor: 'ko',
180
+ fra: 'fr',
181
+ spa: 'es',
182
+ ru: 'ru',
183
+ ara: 'ar',
184
+ th: 'th',
185
+ }
186
+ return BAIDU_LANG_MAP[code] || code
187
+ }
188
+
189
+ /**
190
+ * 从中文文本生成简短的 fallback key,取首段前 20 个字符
191
+ */
192
+ function generateFallbackKey(text) {
193
+ const firstLine = text.split(/\r?\n/)[0].trim()
194
+ const short = firstLine.replace(/[,,。!?、;:""''《》()\s]+/g, '_').replace(/^_|_$/g, '')
195
+ return short.substring(0, 40) || 'key'
196
+ }
197
+
198
+ /**
199
+ * 交互式确认提示
200
+ */
201
+ function promptConfirm(message) {
202
+ return inquirer.prompt([{ type: 'confirm', name: 'ok', message, default: false }]).then((a) => a.ok)
203
+ }
204
+
205
+ /**
206
+ * 将文本数组按字节长度分批
207
+ * @param {string[]} texts
208
+ * @param {number} maxBytes - 每批最大字节数(含 \n 分隔符)
209
+ * @returns {string[][]}
210
+ */
211
+ function splitByByteLength(texts, maxBytes) {
212
+ const batches = []
213
+ let currentBatch = []
214
+ let currentBytes = 0
215
+ for (const text of texts) {
216
+ const textBytes = Buffer.byteLength(text, 'utf-8')
217
+ const sepBytes = currentBatch.length > 0 ? 1 : 0 // \n 分隔符
218
+ if (currentBytes + sepBytes + textBytes > maxBytes) {
219
+ if (currentBatch.length > 0) {
220
+ batches.push(currentBatch)
221
+ }
222
+ // 单个文本超长时仍单独成批,API 会自行处理截断或报错
223
+ currentBatch = [text]
224
+ currentBytes = textBytes
225
+ } else {
226
+ currentBatch.push(text)
227
+ currentBytes += sepBytes + textBytes
228
+ }
229
+ }
230
+ if (currentBatch.length > 0) {
231
+ batches.push(currentBatch)
232
+ }
233
+ return batches
234
+ }
235
+
236
+ async function translateBatch(texts, appId, secretKey, provider = 'baidu', from = 'zh', to = 'en') {
237
+ if (texts.length === 0) return []
238
+
239
+ if (provider === 'tencent') {
240
+ return await tencentTranslate(texts, appId, secretKey, from, to)
241
+ }
242
+
243
+ // 百度翻译 API 支持用换行符分隔多个文本,一次请求翻译
244
+ // 单次请求长度限制约 6000 字节,按 5000 字节分批留余量
245
+ const BATCH_MAX_BYTES = 5000
246
+ const batches = splitByByteLength(texts, BATCH_MAX_BYTES)
247
+ const allResults = []
248
+
249
+ for (const batch of batches) {
250
+ const combined = batch.join('\n')
251
+ let translated
252
+ try {
253
+ translated = await baiduTranslate(combined, appId, secretKey, from, to)
254
+ } catch {
255
+ translated = null
256
+ }
257
+
258
+ if (translated && translated.length === batch.length) {
259
+ allResults.push(...translated)
260
+ } else {
261
+ // 逐文本翻译,失败则回退到原文
262
+ for (const text of batch) {
263
+ try {
264
+ const [result] = await baiduTranslate(text, appId, secretKey, from, to)
265
+ allResults.push(result || text)
266
+ } catch {
267
+ allResults.push(text)
268
+ }
269
+ }
270
+ }
271
+ }
272
+
273
+ return allResults
274
+ }
275
+
276
+ /**
277
+ * 解析 name 参数:优先使用 --name/--n,否则从路径推断
278
+ */
279
+ function resolveName(options, fallbackPath) {
280
+ let name = options.name || options.n
281
+ if (!name) {
282
+ name = path.basename(fallbackPath)
283
+ }
284
+ return name
285
+ }
286
+
287
+ /**
288
+ * 生成 locale maps (textToKey + 各语言的 map)
289
+ */
290
+ function generateLocaleMaps(name, uniqueTexts, translatedTexts, translationPerformed, targetLocale) {
291
+ const map = { [name]: {} }
292
+ const textToKey = new Map()
293
+
294
+ if (translationPerformed) {
295
+ const subKeys = generateKeysFromTranslations(translatedTexts)
296
+ uniqueTexts.forEach((text, index) => {
297
+ const subKey = subKeys[index]
298
+ if (!textToKey.has(text)) textToKey.set(text, `${name}.${subKey}`)
299
+ map[name][subKey] = translatedTexts[index]
300
+ })
301
+ } else {
302
+ const keyCount = new Map()
303
+ const usedFallbackKeys = new Set()
304
+ uniqueTexts.forEach((text) => {
305
+ let subKey = generateFallbackKey(text)
306
+ if (usedFallbackKeys.has(subKey)) {
307
+ const count = (keyCount.get(subKey) || 0) + 1
308
+ keyCount.set(subKey, count)
309
+ subKey = `${subKey}_${count}`
310
+ }
311
+ usedFallbackKeys.add(subKey)
312
+ if (!textToKey.has(text)) textToKey.set(text, `${name}.${subKey}`)
313
+ map[name][subKey] = text
314
+ })
315
+ }
316
+
317
+ return { map, textToKey }
318
+ }
319
+
320
+ /**
321
+ * 调用翻译 API(带缓存),失败时回退到原文
322
+ * 逐文本处理:每段文本各自尝试翻译,失败仅影响自身
323
+ */
324
+ async function translateOrFallback(uniqueTexts, settings, fromLang, toLang) {
325
+ const baseDir = process.cwd()
326
+ const t = i18n.getT()
327
+
328
+ // 先查缓存,筛选出未缓存文本
329
+ const results = new Array(uniqueTexts.length)
330
+ const uncachedIndices = []
331
+ const uncachedTexts = []
332
+
333
+ for (let i = 0; i < uniqueTexts.length; i++) {
334
+ const cached = translationCache.getCachedTranslation(uniqueTexts[i], fromLang, toLang, baseDir)
335
+ if (cached !== null) {
336
+ results[i] = cached
337
+ } else {
338
+ uncachedIndices.push(i)
339
+ uncachedTexts.push(uniqueTexts[i])
340
+ }
341
+ }
342
+
343
+ if (uncachedTexts.length === 0) {
344
+ return { translatedTexts: results, translationPerformed: true }
345
+ }
346
+
347
+ // 调用翻译 API
348
+ if (settings.appId && settings.secretKey && (settings.provider === 'baidu' || settings.provider === 'tencent')) {
349
+ try {
350
+ const apiResults = await translateBatch(uncachedTexts, settings.appId, settings.secretKey, settings.provider, shortLang(fromLang), shortLang(toLang))
351
+ // 回填结果,翻译失败处保留原文
352
+ for (let j = 0; j < uncachedTexts.length; j++) {
353
+ const translated = apiResults[j] || uncachedTexts[j]
354
+ results[uncachedIndices[j]] = translated
355
+ translationCache.setCachedTranslation(uncachedTexts[j], fromLang, toLang, translated, baseDir)
356
+ }
357
+ // 保存缓存
358
+ translationCache.saveCache(baseDir)
359
+ return { translatedTexts: results, translationPerformed: true }
360
+ } catch (err) {
361
+ console.log(chalk.red.bold(t('messages.translateTranslateFailed', { error: err.message })))
362
+ }
363
+ }
364
+
365
+ if (settings.appId && settings.secretKey) {
366
+ console.log(chalk.yellow(' \u26A0 ' + t('messages.translateApiFallback')))
367
+ } else {
368
+ console.log(chalk.yellow(' \u26A0 ' + t('messages.translateApiNotConfigured')))
369
+ }
370
+
371
+ // 回退:未缓存的用原文填充
372
+ for (let i = 0; i < uncachedTexts.length; i++) {
373
+ results[uncachedIndices[i]] = uncachedTexts[i]
374
+ }
375
+ return { translatedTexts: results, translationPerformed: false }
376
+ }
377
+
378
+ /**
379
+ * 输出翻译后的文件
380
+ */
381
+ function writeTranslatedFile(filePath, translatedContent, outDir, settings) {
382
+ if (settings.replaceOriginalFile) {
383
+ fs.writeFileSync(filePath, translatedContent, 'utf-8')
384
+ } else {
385
+ const fullPath = path.join(outDir, path.basename(filePath))
386
+ const dir = path.dirname(fullPath)
387
+ if (!fs.existsSync(dir)) {
388
+ fs.mkdirSync(dir, { recursive: true })
389
+ }
390
+ fs.writeFileSync(fullPath, translatedContent, 'utf-8')
391
+ }
392
+ }
393
+
394
+ /**
395
+ * 展示预览差异
396
+ */
397
+ function showDryRunDiff(origContent, previewContent, extractions, settings, filePath, outDir, t) {
398
+ const origLines = origContent.split(/\r?\n/)
399
+ const newLines = previewContent.split(/\r?\n/)
400
+ let changedCount = 0
401
+ const maxLines = Math.max(origLines.length, newLines.length)
402
+ for (let i = 0; i < maxLines; i++) {
403
+ const origLine = i < origLines.length ? origLines[i] : undefined
404
+ const newLine = i < newLines.length ? newLines[i] : undefined
405
+ if (origLine !== newLine) {
406
+ changedCount++
407
+ if (origLine !== undefined) {
408
+ console.log(` ${chalk.red('~')} L${i + 1}: ${chalk.gray(origLine.trim().substring(0, 80))}`)
409
+ }
410
+ if (newLine !== undefined) {
411
+ console.log(` ${chalk.green('+')} ${chalk.white(newLine.trim().substring(0, 80))}`)
412
+ }
413
+ }
414
+ }
415
+ if (changedCount === 0) {
416
+ console.log(` ${chalk.gray(t('messages.translateNoChanges'))}`)
417
+ }
418
+ for (const item of extractions) {
419
+ console.log(` ${chalk.gray(item.text)} \u2192 ${chalk.green(item.key)}`)
420
+ }
421
+ if (settings.replaceOriginalFile) {
422
+ console.log(chalk.yellow(t('messages.translateDryRunReplace', { file: filePath })))
423
+ } else {
424
+ console.log(chalk.yellow(t('messages.translateDryRunOutput', { dir: path.join(settings.outDir, name) })))
425
+ }
426
+ }
427
+
428
+ /**
429
+ * 处理单个文件的翻译
430
+ */
431
+ async function handleTranslateFileAction(filePath, options) {
432
+ const t = i18n.getT()
433
+ const settings = resolveSetting(process.cwd())
434
+ const cwd = process.cwd()
435
+ const fullPath = path.resolve(cwd, filePath)
436
+
437
+ if (!fs.existsSync(fullPath)) {
438
+ console.log(chalk.red.bold(t('messages.translateFileNotFound', { file: filePath })))
439
+ return
440
+ }
441
+
442
+ const fileExt = path.extname(fullPath).toLowerCase()
443
+ if (!extractors.getSupportedExtensions(settings).includes(fileExt)) {
444
+ console.log(chalk.red.bold(t('messages.translateUnsupportedType', { type: fileExt })))
445
+ return
446
+ }
447
+
448
+ // 未提供 name 时使用父级目录名
449
+ let name = resolveName(options, path.dirname(fullPath))
450
+
451
+ let content = fs.readFileSync(fullPath, 'utf-8')
452
+ const fileType = fileExt.slice(1)
453
+
454
+ // 将已有的国际化调用命名空间更新为当前 name
455
+ content = normalizeExistingCalls(content, name, settings.i18nCallTemplate)
456
+
457
+ // 移除已有的 $t() 调用,防止二次包裹
458
+ const extractionContent = stripExistingI18nCalls(content, settings.i18nCallTemplate)
459
+
460
+ // 提取中文文本
461
+ const extractions = extractChineseWithPositions(extractionContent, fileType)
462
+
463
+ if (extractions.length === 0) {
464
+ console.log(chalk.yellow.bold(t('messages.translateNoChineseFound', { file: filePath })))
465
+ return
466
+ }
467
+
468
+ // 提取预览模式:仅展示提取结果不翻译
469
+ if (options.showExtractions) {
470
+ console.log(chalk.cyan(t('messages.translateDryRunHeader', { file: filePath })))
471
+ for (const item of extractions) {
472
+ console.log(` ${chalk.gray(item.text)} (${item.context || fileType}, pos ${item.start}-${item.end})`)
473
+ }
474
+ return
475
+ }
476
+
477
+ // 提取所有中文文本用于翻译(去重)
478
+ const uniqueTexts = [...new Set(extractions.map((item) => item.text))]
479
+
480
+ const fromLang = settings.locales[0] || 'zh-CN'
481
+ const toLangs = settings.locales.length > 1 ? settings.locales.slice(1) : ['en-US']
482
+
483
+ // 翻译到各目标语言,生成 locale maps
484
+ const localeMaps = {}
485
+ let textToKey = null
486
+
487
+ for (const toLang of toLangs) {
488
+ const { translatedTexts, translationPerformed } = await translateOrFallback(uniqueTexts, settings, fromLang, toLang)
489
+ const result = generateLocaleMaps(name, uniqueTexts, translatedTexts, translationPerformed)
490
+ if (!textToKey) textToKey = result.textToKey
491
+ // source map 仅第一次写入
492
+ if (!localeMaps[fromLang]) localeMaps[fromLang] = {}
493
+ if (!localeMaps[fromLang][name]) localeMaps[fromLang][name] = {}
494
+ Object.assign(localeMaps[fromLang][name], result.map[name])
495
+ localeMaps[toLang] = result.map
496
+ }
497
+
498
+ extractions.forEach((item) => {
499
+ item.key = textToKey.get(item.text)
500
+ })
501
+
502
+ // 确定输出目录
503
+ const outDir = path.resolve(cwd, settings.outDir, name)
504
+
505
+ // 预览模式
506
+ if (options.dryRun) {
507
+ console.log(chalk.cyan(t('messages.translateDryRunHeader', { file: filePath })))
508
+ const primaryTarget = toLangs[0]
509
+ const previewContent =
510
+ fileType === 'json'
511
+ ? applyJsonTranslation(content, extractions, localeMaps[primaryTarget] || localeMaps[fromLang], name)
512
+ : applyReplacements(content, extractions, name, fileType, settings.i18nCallTemplate)
513
+ showDryRunDiff(content, previewContent, extractions, settings, filePath, outDir, t)
514
+ const confirmed = await promptConfirm(chalk.cyan('Apply changes? (y/N) '))
515
+ if (!confirmed) {
516
+ console.log(chalk.gray(t('messages.translateDryRunCancelled')))
517
+ return
518
+ }
519
+ }
520
+
521
+ // 写入语言文件(合并已有,移除不再引用的 key)
522
+ await mergeAndWriteLocaleFiles(outDir, fromLang, toLangs, name, localeMaps, [content], settings.i18nCallTemplate)
523
+
524
+ // 生成翻译后的文件内容并输出
525
+ const primaryTarget = toLangs[0]
526
+ const translatedContent =
527
+ fileType === 'json'
528
+ ? applyJsonTranslation(content, extractions, localeMaps[primaryTarget] || localeMaps[fromLang], name)
529
+ : applyReplacements(content, extractions, name, fileType, settings.i18nCallTemplate)
530
+
531
+ await saveBackupSync(name, settings.replaceOriginalFile ? [fullPath] : [], cwd, settings.replaceOriginalFile)
532
+ writeTranslatedFile(fullPath, translatedContent, outDir, settings)
533
+
534
+ if (settings.replaceOriginalFile) {
535
+ console.log(chalk.green.bold(t('messages.translateOriginalReplaced', { file: filePath })))
536
+ }
537
+ console.log(chalk.green.bold(t('messages.translateOutputSuccess', { dir: path.join(settings.outDir, name) })))
538
+ }
539
+
540
+ /**
541
+ * 处理文件夹的递归翻译
542
+ */
543
+ async function handleTranslateFolderAction(folderPath, options) {
544
+ const t = i18n.getT()
545
+ const settings = resolveSetting(process.cwd())
546
+ const cwd = process.cwd()
547
+ const fullFolderPath = path.resolve(cwd, folderPath)
548
+
549
+ if (!fs.existsSync(fullFolderPath)) {
550
+ console.log(chalk.red.bold(t('messages.translateFolderNotFound', { folder: folderPath })))
551
+ return
552
+ }
553
+
554
+ // 未提供 name 时使用文件夹名
555
+ let name = resolveName(options, fullFolderPath)
556
+
557
+ // 递归查找所有支持的文件
558
+ const files = []
559
+ function walkDir(dir) {
560
+ const entries = fs.readdirSync(dir, { withFileTypes: true })
561
+ for (const entry of entries) {
562
+ const fullPath = path.join(dir, entry.name)
563
+ if (entry.isDirectory()) {
564
+ walkDir(fullPath)
565
+ } else if (entry.isFile()) {
566
+ const fileExt = path.extname(entry.name).toLowerCase()
567
+ if (extractors.getSupportedExtensions(settings).includes(fileExt)) {
568
+ files.push(fullPath)
569
+ }
570
+ }
571
+ }
572
+ }
573
+ walkDir(fullFolderPath)
574
+
575
+ // 应用 --exclude 过滤(支持 glob 模式,如 **/*.test.js 或 node_modules/**)
576
+ const excludePatterns = []
577
+ const raw = options.exclude
578
+ if (Array.isArray(raw)) {
579
+ excludePatterns.push(...raw)
580
+ } else if (typeof raw === 'string') {
581
+ for (const p of raw.split(',')) {
582
+ const t = p.trim()
583
+ if (t) excludePatterns.push(t)
584
+ }
585
+ }
586
+ const filteredFiles = excludePatterns.length > 0
587
+ ? files.filter((fp) => {
588
+ const rel = path.relative(fullFolderPath, fp).replace(/\\/g, '/')
589
+ return !excludePatterns.some((pattern) => minimatch(rel, pattern, { dot: true, matchBase: false }))
590
+ })
591
+ : files
592
+
593
+ if (filteredFiles.length === 0) {
594
+ console.log(chalk.yellow.bold(t('messages.translateNoSupportedFiles')))
595
+ return
596
+ }
597
+
598
+ if (excludePatterns.length > 0 && filteredFiles.length < files.length) {
599
+ console.log(chalk.gray(t('messages.translateExcludedCount', { count: files.length - filteredFiles.length })))
600
+ }
601
+
602
+ console.log(chalk.cyan(t('messages.translateFoundFiles', { count: filteredFiles.length })))
603
+
604
+ // 处理每个文件,收集所有中文文本
605
+ const allExtractions = []
606
+ const fileEntries = []
607
+ const isVerbose = options.verbose
608
+ let progress
609
+ if (!isVerbose) {
610
+ progress = ora(t('messages.translateProgress', { current: 0, total: filteredFiles.length, file: '' })).start()
611
+ }
612
+
613
+ for (const [idx, file] of filteredFiles.entries()) {
614
+ const relFile = path.relative(fullFolderPath, file)
615
+ if (isVerbose) {
616
+ console.log(chalk.gray(t('messages.translateVerboseProgress', { current: idx + 1, total: filteredFiles.length, file: relFile })))
617
+ } else {
618
+ progress.text = t('messages.translateProgress', { current: idx + 1, total: filteredFiles.length, file: relFile })
619
+ }
620
+
621
+ // 大文件警告
622
+ const stat = fs.statSync(file)
623
+ if (stat.size > 10 * 1024 * 1024) {
624
+ const warnMsg = t('messages.translateLargeFile', { file: relFile, size: Math.round(stat.size / 1024 / 1024) })
625
+ if (isVerbose) {
626
+ console.log(chalk.yellow(warnMsg))
627
+ } else {
628
+ progress.warn(warnMsg)
629
+ }
630
+ }
631
+
632
+ let content = fs.readFileSync(file, 'utf-8')
633
+ const ext = path.extname(file).slice(1)
634
+
635
+ // 将已有的国际化调用命名空间更新为当前 name
636
+ content = normalizeExistingCalls(content, name, settings.i18nCallTemplate)
637
+ const extractionContent = stripExistingI18nCalls(content, settings.i18nCallTemplate)
638
+ const extractions = extractChineseWithPositions(extractionContent, ext)
639
+
640
+ const relativeDir = path.relative(fullFolderPath, path.dirname(file))
641
+ const outputFile = relativeDir ? path.join(relativeDir, path.basename(file)) : path.basename(file)
642
+
643
+ const entryIdx = fileEntries.length
644
+ fileEntries.push({
645
+ file,
646
+ content,
647
+ ext,
648
+ extractions,
649
+ outputFile,
650
+ })
651
+
652
+ extractions.forEach((e) => {
653
+ allExtractions.push({ ...e, fileIdx: entryIdx })
654
+ })
655
+ }
656
+
657
+ if (!isVerbose && progress) {
658
+ progress.succeed(t('messages.translateFilesLoaded', { count: filteredFiles.length }))
659
+ }
660
+
661
+ // 去重:相同的中文文本只翻译一次
662
+ const seen = new Map()
663
+ const uniqueTexts = []
664
+
665
+ for (const item of allExtractions) {
666
+ if (!seen.has(item.text)) {
667
+ seen.set(item.text, uniqueTexts.length)
668
+ uniqueTexts.push(item.text)
669
+ }
670
+ }
671
+
672
+ if (uniqueTexts.length === 0) {
673
+ console.log(chalk.yellow.bold(t('messages.translateNoChineseFound', { file: folderPath })))
674
+ return
675
+ }
676
+
677
+ // 提取预览模式:仅展示提取结果不翻译
678
+ if (options.showExtractions) {
679
+ console.log(chalk.cyan(t('messages.translateDryRunFolderHeader', { folder: folderPath })))
680
+ for (const item of allExtractions) {
681
+ const relFile = path.relative(fullFolderPath, fileEntries[item.fileIdx].file)
682
+ console.log(` ${chalk.gray(relFile)}: "${chalk.white(item.text)}" (pos ${item.start}-${item.end})`)
683
+ }
684
+ return
685
+ }
686
+
687
+ // 翻译到各目标语言,生成 locale maps
688
+ const fromLang = settings.locales[0] || 'zh-CN'
689
+ const toLangs = settings.locales.length > 1 ? settings.locales.slice(1) : ['en-US']
690
+
691
+ const localeMaps = {}
692
+ let textToKey = null
693
+ let anyTranslated = false
694
+
695
+ for (const toLang of toLangs) {
696
+ const { translatedTexts, translationPerformed } = await translateOrFallback(uniqueTexts, settings, fromLang, toLang)
697
+ const result = generateLocaleMaps(name, uniqueTexts, translatedTexts, translationPerformed)
698
+ if (!textToKey) textToKey = result.textToKey
699
+ if (!localeMaps[fromLang]) localeMaps[fromLang] = {}
700
+ if (!localeMaps[fromLang][name]) localeMaps[fromLang][name] = {}
701
+ Object.assign(localeMaps[fromLang][name], result.map[name])
702
+ localeMaps[toLang] = result.map
703
+ if (translationPerformed) anyTranslated = true
704
+ }
705
+
706
+ if (anyTranslated) {
707
+ console.log(chalk.green.bold(t('messages.translateTranslateSuccess')))
708
+ }
709
+
710
+ // 预览模式:仅打印不做实际写入
711
+ if (options.dryRun) {
712
+ console.log(chalk.cyan(t('messages.translateDryRunFolderHeader', { folder: folderPath })))
713
+ for (const entry of fileEntries) {
714
+ const relPath = path.relative(fullFolderPath, entry.file)
715
+ console.log(` ${chalk.white(relPath)}`)
716
+ const keyedExtractions = entry.extractions.map((item) => {
717
+ const key = textToKey.get(item.text)
718
+ return key ? { ...item, key } : item
719
+ })
720
+ const previewContent =
721
+ entry.ext === 'json'
722
+ ? applyJsonTranslation(entry.content, keyedExtractions, localeMaps[toLangs[0]] || localeMaps[fromLang], name)
723
+ : applyReplacements(entry.content, keyedExtractions, name, entry.ext, settings.i18nCallTemplate)
724
+ const origLines = entry.content.split(/\r?\n/)
725
+ const newLines = previewContent.split(/\r?\n/)
726
+ let changedCount = 0
727
+ for (let i = 0; i < origLines.length; i++) {
728
+ if (origLines[i] !== newLines[i]) {
729
+ changedCount++
730
+ console.log(` ${chalk.red('~')} L${i + 1}: ${chalk.gray(origLines[i].trim().substring(0, 80))}`)
731
+ console.log(` ${chalk.green('+')} ${chalk.white(newLines[i].trim().substring(0, 80))}`)
732
+ }
733
+ }
734
+ if (changedCount === 0) console.log(` ${chalk.gray(t('messages.translateNoChanges'))}`)
735
+ }
736
+ const confirmed = await promptConfirm(chalk.cyan('Apply changes? (y/N) '))
737
+ if (!confirmed) {
738
+ console.log(chalk.gray(t('messages.translateDryRunCancelled')))
739
+ return
740
+ }
741
+ }
742
+
743
+ // 输出目录
744
+ const outDir = path.resolve(cwd, settings.outDir, name)
745
+
746
+ // 写入语言文件(合并已有,移除不再引用的 key)
747
+ const allContents = fileEntries.map((e) => e.content)
748
+ await mergeAndWriteLocaleFiles(outDir, fromLang, toLangs, name, localeMaps, allContents, settings.i18nCallTemplate)
749
+
750
+ // 备份所有原文件用于回滚
751
+ if (fileEntries.length > 0) {
752
+ await saveBackupSync(name, settings.replaceOriginalFile ? fileEntries.map((e) => e.file) : [], cwd, settings.replaceOriginalFile)
753
+ }
754
+
755
+ // 写入每个翻译后的文件
756
+ for (const [idx, entry] of fileEntries.entries()) {
757
+ const relPath = path.relative(fullFolderPath, entry.file)
758
+ console.log(chalk.gray(t('messages.translateProgress', { current: idx + 1, total: fileEntries.length, file: relPath })))
759
+ const keyedExtractions = entry.extractions.map((item) => {
760
+ const key = textToKey.get(item.text)
761
+ return { ...item, key }
762
+ })
763
+
764
+ let translatedContent
765
+ if (entry.ext === 'json') {
766
+ translatedContent = applyJsonTranslation(entry.content, keyedExtractions, localeMaps[toLangs[0]] || localeMaps[fromLang], name)
767
+ } else {
768
+ translatedContent = applyReplacements(entry.content, keyedExtractions, name, entry.ext, settings.i18nCallTemplate)
769
+ }
770
+
771
+ if (settings.replaceOriginalFile) {
772
+ // replaceOriginalFile 时仅替换原文件,不拷贝到 outDir
773
+ fs.writeFileSync(entry.file, translatedContent, 'utf-8')
774
+ } else {
775
+ // 否则输出到 outDir
776
+ await writeFile(path.join(outDir, entry.outputFile), translatedContent)
777
+ }
778
+ }
779
+
780
+ if (settings.replaceOriginalFile) {
781
+ console.log(chalk.green.bold(t('messages.translateOriginalReplacedFolder', { folder: folderPath })))
782
+ }
783
+
784
+ console.log(chalk.green.bold(t('messages.translateFolderOutputSuccess', { dir: path.join(settings.outDir, name) })))
785
+ }
786
+
787
+ /**
788
+ * 在项目中生成 .hias/setting.json 配置文件
789
+ */
790
+ async function handleSettingAction() {
791
+ const t = i18n.getT()
792
+ const cwd = process.cwd()
793
+ const projectPath = path.join(cwd, '.hias', 'setting.json')
794
+
795
+ if (fs.existsSync(projectPath)) {
796
+ console.log(chalk.yellow.bold(t('messages.translateSettingExists', { file: projectPath })))
797
+ return
798
+ }
799
+
800
+ const content = {
801
+ translationSetting: {
802
+ locales: ['zh-CN', 'en-US'],
803
+ outDir: '.hias/lang',
804
+ fallbackToKey: true,
805
+ replaceOriginalFile: false,
806
+ provider: 'tencent',
807
+ i18nCallTemplate: '$t',
808
+ appId: '',
809
+ secretKey: '',
810
+ },
811
+ }
812
+
813
+ await writeFile(projectPath, JSON.stringify(content, null, 2))
814
+ console.log(chalk.green.bold(t('messages.translateSettingCreated', { file: path.join('.hias', 'setting.json') })))
815
+ }
816
+
817
+ /**
818
+ * 生成全局 ~/.hias-cli/config.json 配置文件
819
+ */
820
+ async function handleGlobalSettingAction() {
821
+ const t = i18n.getT()
822
+ const globalPath = path.join(os.homedir(), '.hias-cli', 'config.json')
823
+ let config = {}
824
+
825
+ if (fs.existsSync(globalPath)) {
826
+ try {
827
+ config = JSON.parse(fs.readFileSync(globalPath, 'utf-8'))
828
+ } catch (e) {
829
+ // 忽略
830
+ }
831
+ }
832
+
833
+ // 保留已有配置(如 language),只更新 translationSetting
834
+ config.translationSetting = {
835
+ locales: ['zh-CN', 'en-US'],
836
+ outDir: '.hias/lang',
837
+ fallbackToKey: true,
838
+ replaceOriginalFile: false,
839
+ provider: 'tencent',
840
+ i18nCallTemplate: '$t',
841
+ appId: '',
842
+ secretKey: '',
843
+ }
844
+
845
+ const configDir = path.dirname(globalPath)
846
+ if (!fs.existsSync(configDir)) {
847
+ fs.mkdirSync(configDir, { recursive: true })
848
+ }
849
+ fs.writeFileSync(globalPath, JSON.stringify(config, null, 2), 'utf-8')
850
+ console.log(chalk.green.bold(t('messages.translateSettingCreated', { file: globalPath })))
851
+ }
852
+
853
+ // === 回滚功能:备份与恢复 ===
854
+
855
+ const BACKUP_ROOT = '.hias/.langbackup'
856
+
857
+ /**
858
+ * 备份原文件内容到 .hias/.langbackup/<timestamp>_<name>/manifest.json,保留所有历史
859
+ */
860
+ async function saveBackupSync(name, filePaths, cwd, replaceOriginalFile) {
861
+ const now = new Date()
862
+ const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`
863
+ const backupDir = path.join(cwd, BACKUP_ROOT, `${ts}_${name}`)
864
+ await fsp.mkdir(backupDir, { recursive: true })
865
+
866
+ const files = {}
867
+ if (replaceOriginalFile) {
868
+ for (const fp of filePaths) {
869
+ const relativePath = path.relative(cwd, fp).replace(/\\/g, '/')
870
+ files[relativePath] = await fsp.readFile(fp, 'utf-8')
871
+ }
872
+ }
873
+
874
+ const manifest = {
875
+ name,
876
+ timestamp: now.toISOString(),
877
+ replaceOriginalFile,
878
+ files,
879
+ outputDir: path.join('.hias', 'lang', name).replace(/\\/g, '/'),
880
+ }
881
+
882
+ await fsp.writeFile(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf-8')
883
+ }
884
+
885
+ /**
886
+ * 单步回滚:恢复最近一次备份,保留其他历史备份
887
+ */
888
+ async function handleRollbackAction(options = {}) {
889
+ const t = i18n.getT()
890
+ const cwd = process.cwd()
891
+ const backupRoot = path.join(cwd, BACKUP_ROOT)
892
+
893
+ if (!fs.existsSync(backupRoot)) {
894
+ console.log(chalk.red.bold(t('messages.translateRollbackNoBackup')))
895
+ return
896
+ }
897
+
898
+ const entries = fs
899
+ .readdirSync(backupRoot, { withFileTypes: true })
900
+ .filter((e) => e.isDirectory())
901
+ .map((e) => e.name)
902
+ .sort()
903
+ .reverse()
904
+
905
+ if (entries.length === 0) {
906
+ console.log(chalk.red.bold(t('messages.translateRollbackNoBackup')))
907
+ return
908
+ }
909
+
910
+ // --list 模式:仅列出备份
911
+ if (options.list) {
912
+ console.log(chalk.cyan(t('messages.translateRollbackListHeader')))
913
+ for (const entry of entries) {
914
+ const mp = path.join(backupRoot, entry, 'manifest.json')
915
+ if (fs.existsSync(mp)) {
916
+ try {
917
+ const m = JSON.parse(fs.readFileSync(mp, 'utf-8'))
918
+ console.log(` ${chalk.white(entry)} ${chalk.gray(m.name)} ${m.replaceOriginalFile ? chalk.yellow('replace') : chalk.gray('copy')}`)
919
+ } catch {
920
+ console.log(` ${chalk.white(entry)} ${chalk.red(t('messages.translateRollbackParseError'))}`)
921
+ }
922
+ }
923
+ }
924
+ return
925
+ }
926
+
927
+ // --keep 模式:清理旧备份
928
+ if (options.keep !== undefined) {
929
+ const keepCount = parseInt(options.keep, 10)
930
+ if (isNaN(keepCount) || keepCount < 0) {
931
+ console.log(chalk.red('--keep must be a non-negative number'))
932
+ return
933
+ }
934
+ const sortedAsc = [...entries].sort()
935
+ if (sortedAsc.length <= keepCount) {
936
+ console.log(chalk.gray(t('messages.translateRollbackKeepNone')))
937
+ return
938
+ }
939
+ const toRemove = sortedAsc.slice(0, sortedAsc.length - keepCount)
940
+ for (const dir of toRemove) {
941
+ const fullDir = path.join(backupRoot, dir)
942
+ fs.rmSync(fullDir, { recursive: true, force: true })
943
+ console.log(chalk.gray('Removed backup: ' + dir))
944
+ }
945
+ if (fs.readdirSync(backupRoot).length === 0) {
946
+ fs.rmSync(backupRoot, { recursive: true, force: true })
947
+ }
948
+ console.log(chalk.green.bold(t('messages.translateRollbackKeepDone', { keep: keepCount })))
949
+ return
950
+ }
951
+
952
+ // --name 模式:回滚指定备份
953
+ let target = entries[0]
954
+ if (options.name) {
955
+ const found = entries.find((e) => e === options.name)
956
+ if (found) {
957
+ target = found
958
+ } else {
959
+ console.log(chalk.red.bold(t('messages.translateRollbackNotFound', { name: options.name })))
960
+ return
961
+ }
962
+ }
963
+
964
+ const latestDir = path.join(backupRoot, target)
965
+ const manifestPath = path.join(latestDir, 'manifest.json')
966
+
967
+ if (!fs.existsSync(manifestPath)) {
968
+ console.log(chalk.red.bold(t('messages.translateRollbackParseError')))
969
+ return
970
+ }
971
+
972
+ let manifest
973
+ try {
974
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
975
+ } catch (e) {
976
+ console.log(chalk.red.bold(t('messages.translateRollbackParseError')))
977
+ return
978
+ }
979
+
980
+ // 恢复原文件
981
+ if (manifest.replaceOriginalFile && manifest.files) {
982
+ for (const [relativePath, originalContent] of Object.entries(manifest.files)) {
983
+ const originalFile = path.resolve(cwd, relativePath)
984
+ fs.writeFileSync(originalFile, originalContent, 'utf-8')
985
+ console.log(chalk.gray(t('messages.translateRollbackRestored', { file: relativePath })))
986
+ }
987
+ }
988
+
989
+ // 删除该次备份对应的输出目录
990
+ const outputDir = path.resolve(cwd, manifest.outputDir)
991
+ if (fs.existsSync(outputDir)) {
992
+ fs.rmSync(outputDir, { recursive: true, force: true })
993
+ }
994
+
995
+ // 只删除本次备份目录
996
+ fs.rmSync(latestDir, { recursive: true, force: true })
997
+
998
+ // 备份根目录为空时一并删除
999
+ if (fs.readdirSync(backupRoot).length === 0) {
1000
+ fs.rmSync(backupRoot, { recursive: true, force: true })
1001
+ }
1002
+
1003
+ console.log(chalk.green.bold(t('messages.translateRollbackSuccess')))
1004
+ }
1005
+
1006
+ /**
1007
+ * 将 .hias 目录添加到 .gitignore
1008
+ */
1009
+ function handleGitignoreAction() {
1010
+ const t = i18n.getT()
1011
+ const cwd = process.cwd()
1012
+ const gitignorePath = path.join(cwd, '.gitignore')
1013
+
1014
+ const entry = '.hias'
1015
+
1016
+ if (fs.existsSync(gitignorePath)) {
1017
+ const content = fs.readFileSync(gitignorePath, 'utf-8')
1018
+ const lines = content.split(/\r?\n/).map((l) => l.trim())
1019
+ if (lines.includes(entry) || lines.some((l) => l === '/' + entry)) {
1020
+ console.log(chalk.green(t('messages.translateGitignoreExists')))
1021
+ return
1022
+ }
1023
+ fs.writeFileSync(gitignorePath, content.replace(/\r?\n$/, '') + '\n' + entry + '\n', 'utf-8')
1024
+ console.log(chalk.green(t('messages.translateGitignoreCreated')))
1025
+ } else {
1026
+ fs.writeFileSync(gitignorePath, entry + '\n', 'utf-8')
1027
+ console.log(chalk.green(t('messages.translateGitignoreCreateFile')))
1028
+ }
1029
+ }
1030
+
1031
+ module.exports = {
1032
+ handleTranslateFileAction,
1033
+ handleTranslateFolderAction,
1034
+ handleSettingAction,
1035
+ handleGlobalSettingAction,
1036
+ handleRollbackAction,
1037
+ handleGitignoreAction,
1038
+ // 导出的内部函数,用于单元测试
1039
+ resolveSetting,
1040
+ sanitizeKey,
1041
+ generateKeysFromTranslations,
1042
+ shortLang,
1043
+ generateFallbackKey,
1044
+ splitByByteLength,
1045
+ translateBatch,
1046
+ saveBackupSync,
1047
+ // 从 extractors 模块重新导出所有提取相关函数
1048
+ ...extractors,
1049
+ }