@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.
- package/.gitattributes +1 -1
- package/.vscode/settings.json +8 -0
- package/LICENSE +22 -22
- package/README.md +533 -11
- package/README.zh-CN.md +531 -0
- package/lib/config.js +22 -18
- package/lib/core/action.js +95 -68
- package/lib/core/close-port.js +173 -0
- package/lib/core/commander.js +106 -40
- package/lib/core/download.js +32 -19
- package/lib/core/help.js +17 -6
- package/lib/core/lang.js +50 -0
- package/lib/core/translate.js +1049 -0
- package/lib/extractors/index.js +694 -0
- package/lib/i18n/index.js +77 -0
- package/lib/i18n/resources/en.json +91 -0
- package/lib/i18n/resources/zh-CN.json +91 -0
- package/lib/i18n/store.js +85 -0
- package/lib/index.js +14 -6
- package/lib/template/component.jsx.ejs +11 -11
- package/lib/template/component.tsx.ejs +12 -12
- package/lib/template/component.vue.ejs +13 -13
- package/lib/template/reduxStore.jsx.ejs +16 -16
- package/lib/template/reduxTsStore.tsx.ejs +22 -22
- package/lib/utils/baidu-translate.js +78 -0
- package/lib/utils/compile-ejs.js +28 -21
- package/lib/utils/tencent-translate.js +187 -0
- package/lib/utils/translation-cache.js +73 -0
- package/lib/utils/write-file.js +16 -10
- package/package.json +11 -3
- package/test/close-port.test.js +88 -0
- package/test/translate.test.js +545 -0
|
@@ -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
|
+
}
|