dsh-cait 1.0.3 → 1.0.4

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.
@@ -1,339 +1,339 @@
1
- import { createHash, randomUUID } from 'node:crypto'
2
- import { CatError } from './errors.js'
3
- import { clamp, clampInt } from './similarity.js'
4
-
5
- export { clamp, clampInt }
6
-
7
- export const MAX_SEGMENTS = 20000
8
- export const CAIT_VERSION = '1.0.3'
9
- export const MAX_SEGMENT_CHARS = 20000
10
- export const MAX_IMPORT_CHARS = 8_000_000
11
- export const MAX_DOCX_BASE64 = 28_000_000
12
- export const MAX_STORE_BYTES = 64 * 1024 * 1024
13
- export const STORE_VERSION = 2
14
- export const STORE_FILENAME = 'store.json'
15
- export const DEFAULT_TM_ID = 'tm_default'
16
- export const DEFAULT_GLOSSARY_ID = 'glossary_default'
17
- export const DEFAULT_TM_NAME = 'Default TM'
18
- export const DEFAULT_GLOSSARY_NAME = 'Default glossary'
19
-
20
- // UI languages the workbench can switch between; 'auto' follows the DSH locale.
21
- export const UI_LANG_CODES = ['auto', 'zh', 'en', 'ja']
22
-
23
- export const DEFAULT_SETTINGS = {
24
- sourceLang: 'en',
25
- targetLang: 'zh-CN',
26
- fuzzyThreshold: 0.55,
27
- tmLimit: 5,
28
- uiLang: 'auto',
29
- mt: {
30
- provider: 'mock',
31
- model: 'cait-mock',
32
- temperature: 0.2,
33
- },
34
- }
35
-
36
- export function now() {
37
- return new Date().toISOString()
38
- }
39
-
40
- export function makeId(prefix) {
41
- return `${prefix}_${randomUUID().slice(0, 8)}`
42
- }
43
-
44
- export function documentFingerprint(sourceLang, targetLang, segments, format = '') {
45
- const hash = createHash('sha256')
46
- hash.update(String(sourceLang))
47
- hash.update('\u0000')
48
- hash.update(String(targetLang))
49
- hash.update('\u0000')
50
- hash.update(String(format ?? ''))
51
- hash.update('\u0000')
52
- for (const segment of segments) {
53
- hash.update(String(segment?.source ?? ''))
54
- hash.update('\u0000')
55
- hash.update(JSON.stringify(segment?.meta ?? null))
56
- hash.update('\u0000')
57
- }
58
- return hash.digest('hex')
59
- }
60
-
61
- export function cleanName(value, fallback = '未命名文档') {
62
- const name = String(value ?? '').replace(/[\r\n\t]/g, ' ').trim()
63
- return name.slice(0, 120) || fallback
64
- }
65
-
66
- export function canonicalLanguage(value) {
67
- const lang = String(value ?? '').trim()
68
- if (!lang) return ''
69
- if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,34}$/.test(lang)) {
70
- throw new CatError('INVALID_LANGUAGE', `语言代码不合法: ${lang}`)
71
- }
72
- try {
73
- return Intl.getCanonicalLocales(lang.replace(/_/g, '-'))[0]
74
- } catch {
75
- throw new CatError('INVALID_LANGUAGE', `语言代码不合法: ${lang}`)
76
- }
77
- }
78
-
79
- export function cleanLang(value, fallback) {
80
- const lang = String(value ?? '').trim()
81
- if (!lang) return fallback
82
- return canonicalLanguage(lang)
83
- }
84
-
85
- export function cleanText(value, field = '文本') {
86
- const text = String(value ?? '').replace(/\r\n?/g, '\n').trim()
87
- if (text.length > MAX_SEGMENT_CHARS) {
88
- throw new CatError('TEXT_TOO_LONG', `${field}超过 ${MAX_SEGMENT_CHARS} 字符上限`)
89
- }
90
- return text
91
- }
92
-
93
- export function escapeRegExp(value) {
94
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
95
- }
96
-
97
- export function countWords(value) {
98
- const text = String(value ?? '')
99
- const cjk = text.match(/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/g)?.length ?? 0
100
- const latin = text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g)?.length ?? 0
101
- return cjk + latin
102
- }
103
-
104
- export function isIsoDate(value) {
105
- return typeof value === 'string' && !Number.isNaN(Date.parse(value))
106
- }
107
-
108
- export function restoredDate(value, fallback = now()) {
109
- return isIsoDate(value) ? new Date(value).toISOString() : fallback
110
- }
111
-
112
- export function restoredLang(value, fallback) {
113
- const lang = String(value ?? '').trim()
114
- if (!lang) return fallback
115
- try {
116
- return canonicalLanguage(lang)
117
- } catch {
118
- throw new Error(`数据文件中的语言代码不合法: ${lang}`)
119
- }
120
- }
121
-
122
- export function restoredText(value, field, { required = false } = {}) {
123
- if (typeof value !== 'string') throw new Error(`数据文件中的${field}必须是字符串`)
124
- const text = value.replace(/\r\n?/g, '\n').trim()
125
- if (required && !text) throw new Error(`数据文件中的${field}不能为空`)
126
- if (text.length > MAX_SEGMENT_CHARS) throw new Error(`数据文件中的${field}超过长度上限`)
127
- return text
128
- }
129
-
130
- function cleanStringList(value, maxItems, maxLength) {
131
- if (!Array.isArray(value)) return null
132
- const list = value
133
- .filter((item) => typeof item === 'string')
134
- .map((item) => item.trim().slice(0, maxLength))
135
- .filter(Boolean)
136
- .slice(0, maxItems)
137
- return list.length ? list : null
138
- }
139
-
140
- function cleanInlineTokens(value) {
141
- if (!Array.isArray(value)) return null
142
- const tokens = []
143
- const seen = new Set()
144
- for (const item of value) {
145
- if (!item || typeof item !== 'object' || Array.isArray(item)) continue
146
- const token = typeof item.token === 'string' ? item.token.trim() : ''
147
- const xml = typeof item.xml === 'string' ? item.xml.trim() : ''
148
- if (!/^\{\d+\}$/.test(token) || !xml || xml.length > 2000 || seen.has(token)) continue
149
- seen.add(token)
150
- tokens.push({ token, xml })
151
- if (tokens.length >= 50) break
152
- }
153
- return tokens.length ? tokens : null
154
- }
155
-
156
- /**
157
- * Persisted per-segment metadata for exchange formats (cue timing, PO context,
158
- * XLIFF unit id and inline placeholders). Only JSON primitives are kept;
159
- * unknown keys are discarded.
160
- */
161
- export function cleanSegmentMeta(value) {
162
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
163
- const meta = {}
164
- for (const key of ['start', 'end', 'xlsxRow', 'pptxParagraph']) {
165
- const number = Number(value[key])
166
- if (Number.isFinite(number) && number >= 0) meta[key] = Math.round(number)
167
- }
168
- for (const key of ['settings', 'identifier', 'context', 'msgidPlural', 'xliffId', 'format', 'jsonPath', 'pptxSlide']) {
169
- if (typeof value[key] === 'string' && value[key].trim()) {
170
- meta[key] = value[key].slice(0, 500)
171
- }
172
- }
173
- if (typeof value.plural === 'boolean') meta.plural = value.plural
174
- const comments = cleanStringList(value.comments, 20, 500)
175
- if (comments) meta.comments = comments
176
- const plurals = cleanStringList(value.plurals, 6, 5000)
177
- if (plurals) meta.plurals = plurals
178
- const inline = cleanInlineTokens(value.inline)
179
- if (inline) meta.inline = inline
180
- return Object.keys(meta).length ? meta : null
181
- }
182
-
183
- /**
184
- * Document-level exchange metadata. Currently only PO obsolete entries are
185
- * preserved as raw blocks so export can round-trip them without treating them
186
- * as active translation segments.
187
- */
188
- export function cleanFormatMeta(value) {
189
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
190
- const meta = {}
191
- const obsolete = cleanStringList(value.poObsolete, 200, 5000)
192
- if (obsolete) meta.poObsolete = obsolete
193
- if (typeof value.jsonTemplate === 'string' && value.jsonTemplate.length <= 8 * 1024 * 1024) {
194
- meta.jsonTemplate = value.jsonTemplate
195
- }
196
- return Object.keys(meta).length ? meta : null
197
- }
198
-
199
- export function restoreSettings(raw) {
200
- const settings = structuredClone(DEFAULT_SETTINGS)
201
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return settings
202
- try {
203
- if (String(raw.sourceLang ?? '').trim()) {
204
- settings.sourceLang = restoredLang(raw.sourceLang, settings.sourceLang)
205
- }
206
- if (String(raw.targetLang ?? '').trim()) {
207
- settings.targetLang = restoredLang(raw.targetLang, settings.targetLang)
208
- }
209
- const threshold = Number(raw.fuzzyThreshold)
210
- if (Number.isFinite(threshold)) settings.fuzzyThreshold = clamp(threshold, 0, 1)
211
- const limit = Number(raw.tmLimit)
212
- if (Number.isFinite(limit)) settings.tmLimit = clampInt(limit, 1, 20, settings.tmLimit)
213
- const uiLang = String(raw.uiLang ?? '').trim().toLowerCase()
214
- if (UI_LANG_CODES.includes(uiLang)) settings.uiLang = uiLang
215
-
216
- if (raw.mt && typeof raw.mt === 'object' && !Array.isArray(raw.mt)) {
217
- const provider = String(raw.mt.provider ?? '').trim()
218
- if (provider) settings.mt.provider = provider.slice(0, 120)
219
- if (raw.mt.model !== undefined) settings.mt.model = String(raw.mt.model ?? '').trim().slice(0, 200)
220
- const temperature = Number(raw.mt.temperature)
221
- if (Number.isFinite(temperature)) settings.mt.temperature = clamp(temperature, 0, 2)
222
- }
223
- return settings
224
- } catch {
225
- return structuredClone(DEFAULT_SETTINGS)
226
- }
227
- }
228
-
229
- export function cleanLibraryName(value, fallback = 'Untitled library') {
230
- const name = String(value ?? '').replace(/[\r\n\t]/g, ' ').trim()
231
- if (name.length > 80) throw new CatError('INVALID_LIBRARY_NAME', '库名称不能超过 80 字符')
232
- return name || fallback
233
- }
234
-
235
- export function restoreLibraries(raw, idKey, defaultId, defaultName, entries) {
236
- const libraries = new Map()
237
- const add = (id, name, createdAt, updatedAt) => {
238
- if (typeof id !== 'string' || !id || libraries.has(id)) return
239
- libraries.set(id, {
240
- [idKey]: id,
241
- name: cleanLibraryName(name, id === defaultId ? defaultName : `${idKey === 'tmId' ? 'TM' : 'Glossary'} ${id}`),
242
- createdAt: restoredDate(createdAt),
243
- updatedAt: restoredDate(updatedAt),
244
- })
245
- }
246
-
247
- if (Array.isArray(raw)) {
248
- for (const item of raw) {
249
- if (!item || typeof item !== 'object') continue
250
- add(item[idKey], item.name, item.createdAt, item.updatedAt)
251
- }
252
- }
253
- if (!libraries.has(defaultId)) add(defaultId, defaultName)
254
- for (const entry of entries) {
255
- if (entry[idKey] && !libraries.has(entry[idKey])) add(entry[idKey])
256
- }
257
-
258
- const result = [...libraries.values()]
259
- const defaultIndex = result.findIndex((item) => item[idKey] === defaultId)
260
- if (defaultIndex > 0) result.unshift(result.splice(defaultIndex, 1)[0])
261
- return result
262
- }
263
-
264
- export function restoreMountIds(raw, libraries, idKey, fallbackId) {
265
- const valid = new Set(libraries.map((item) => item[idKey]))
266
- const requested = Array.isArray(raw) ? raw : []
267
- const unique = [...new Set(requested.filter((id) => typeof id === 'string' && valid.has(id)))]
268
- return unique.length ? unique : [fallbackId]
269
- }
270
-
271
- export function mdCell(value) {
272
- return String(value ?? '')
273
- .replace(/\|/g, '\\|')
274
- .replace(/\n+/g, '<br>')
275
- .trim()
276
- }
277
-
278
- export function csvCell(value) {
279
- const text = String(value ?? '')
280
- return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
281
- }
282
-
283
- export function safeBaseName(value) {
284
- const base = String(value ?? 'cait-export')
285
- .replace(/\.(txt|md|markdown|docx|tmx|csv|tsv|json|srt|vtt|po|pot|xliff|xlf)$/i, '')
286
- .replace(/[\\/:*?"<>|]+/g, '_')
287
- .replace(/\s+/g, '-')
288
- .trim()
289
- return (base || 'cait-export').slice(0, 80)
290
- }
291
-
292
- export function parseGlossaryCsv(text) {
293
- const rows = []
294
- let row = []
295
- let field = ''
296
- let quoted = false
297
- const input = String(text ?? '').replace(/\r\n?/g, '\n')
298
-
299
- for (let i = 0; i < input.length; i += 1) {
300
- const char = input[i]
301
- if (quoted) {
302
- if (char === '"') {
303
- if (input[i + 1] === '"') {
304
- field += '"'
305
- i += 1
306
- } else {
307
- quoted = false
308
- }
309
- } else {
310
- field += char
311
- }
312
- continue
313
- }
314
- if (char === '"') {
315
- quoted = true
316
- } else if (char === ',' || char === '\t') {
317
- row.push(field)
318
- field = ''
319
- } else if (char === '\n') {
320
- row.push(field)
321
- if (row.some((cell) => cell.trim())) rows.push(row)
322
- row = []
323
- field = ''
324
- } else {
325
- field += char
326
- }
327
- }
328
- row.push(field)
329
- if (row.some((cell) => cell.trim())) rows.push(row)
330
-
331
- const entries = []
332
- for (const cells of rows) {
333
- const [source, target, note, lang] = cells.map((cell) => cell.trim())
334
- if (!source || !target) continue
335
- if (/^(source|term|原文|术语)$/i.test(source) && /^(target|translation|译文)$/i.test(target)) continue
336
- entries.push({ source, target, note: note || '', sourceLang: lang || '' })
337
- }
338
- return entries
339
- }
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { CatError } from './errors.js'
3
+ import { clamp, clampInt } from './similarity.js'
4
+
5
+ export { clamp, clampInt }
6
+
7
+ export const MAX_SEGMENTS = 20000
8
+ export const CAIT_VERSION = '1.0.4'
9
+ export const MAX_SEGMENT_CHARS = 20000
10
+ export const MAX_IMPORT_CHARS = 8_000_000
11
+ export const MAX_DOCX_BASE64 = 28_000_000
12
+ export const MAX_STORE_BYTES = 64 * 1024 * 1024
13
+ export const STORE_VERSION = 2
14
+ export const STORE_FILENAME = 'store.json'
15
+ export const DEFAULT_TM_ID = 'tm_default'
16
+ export const DEFAULT_GLOSSARY_ID = 'glossary_default'
17
+ export const DEFAULT_TM_NAME = 'Default TM'
18
+ export const DEFAULT_GLOSSARY_NAME = 'Default glossary'
19
+
20
+ // UI languages the workbench can switch between; 'auto' follows the DSH locale.
21
+ export const UI_LANG_CODES = ['auto', 'zh', 'en', 'ja']
22
+
23
+ export const DEFAULT_SETTINGS = {
24
+ sourceLang: 'en',
25
+ targetLang: 'zh-CN',
26
+ fuzzyThreshold: 0.55,
27
+ tmLimit: 5,
28
+ uiLang: 'auto',
29
+ mt: {
30
+ provider: 'mock',
31
+ model: 'cait-mock',
32
+ temperature: 0.2,
33
+ },
34
+ }
35
+
36
+ export function now() {
37
+ return new Date().toISOString()
38
+ }
39
+
40
+ export function makeId(prefix) {
41
+ return `${prefix}_${randomUUID().slice(0, 8)}`
42
+ }
43
+
44
+ export function documentFingerprint(sourceLang, targetLang, segments, format = '') {
45
+ const hash = createHash('sha256')
46
+ hash.update(String(sourceLang))
47
+ hash.update('\u0000')
48
+ hash.update(String(targetLang))
49
+ hash.update('\u0000')
50
+ hash.update(String(format ?? ''))
51
+ hash.update('\u0000')
52
+ for (const segment of segments) {
53
+ hash.update(String(segment?.source ?? ''))
54
+ hash.update('\u0000')
55
+ hash.update(JSON.stringify(segment?.meta ?? null))
56
+ hash.update('\u0000')
57
+ }
58
+ return hash.digest('hex')
59
+ }
60
+
61
+ export function cleanName(value, fallback = '未命名文档') {
62
+ const name = String(value ?? '').replace(/[\r\n\t]/g, ' ').trim()
63
+ return name.slice(0, 120) || fallback
64
+ }
65
+
66
+ export function canonicalLanguage(value) {
67
+ const lang = String(value ?? '').trim()
68
+ if (!lang) return ''
69
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{1,34}$/.test(lang)) {
70
+ throw new CatError('INVALID_LANGUAGE', `语言代码不合法: ${lang}`)
71
+ }
72
+ try {
73
+ return Intl.getCanonicalLocales(lang.replace(/_/g, '-'))[0]
74
+ } catch {
75
+ throw new CatError('INVALID_LANGUAGE', `语言代码不合法: ${lang}`)
76
+ }
77
+ }
78
+
79
+ export function cleanLang(value, fallback) {
80
+ const lang = String(value ?? '').trim()
81
+ if (!lang) return fallback
82
+ return canonicalLanguage(lang)
83
+ }
84
+
85
+ export function cleanText(value, field = '文本') {
86
+ const text = String(value ?? '').replace(/\r\n?/g, '\n').trim()
87
+ if (text.length > MAX_SEGMENT_CHARS) {
88
+ throw new CatError('TEXT_TOO_LONG', `${field}超过 ${MAX_SEGMENT_CHARS} 字符上限`)
89
+ }
90
+ return text
91
+ }
92
+
93
+ export function escapeRegExp(value) {
94
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
95
+ }
96
+
97
+ export function countWords(value) {
98
+ const text = String(value ?? '')
99
+ const cjk = text.match(/[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/g)?.length ?? 0
100
+ const latin = text.match(/[A-Za-z0-9]+(?:['’-][A-Za-z0-9]+)*/g)?.length ?? 0
101
+ return cjk + latin
102
+ }
103
+
104
+ export function isIsoDate(value) {
105
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value))
106
+ }
107
+
108
+ export function restoredDate(value, fallback = now()) {
109
+ return isIsoDate(value) ? new Date(value).toISOString() : fallback
110
+ }
111
+
112
+ export function restoredLang(value, fallback) {
113
+ const lang = String(value ?? '').trim()
114
+ if (!lang) return fallback
115
+ try {
116
+ return canonicalLanguage(lang)
117
+ } catch {
118
+ throw new Error(`数据文件中的语言代码不合法: ${lang}`)
119
+ }
120
+ }
121
+
122
+ export function restoredText(value, field, { required = false } = {}) {
123
+ if (typeof value !== 'string') throw new Error(`数据文件中的${field}必须是字符串`)
124
+ const text = value.replace(/\r\n?/g, '\n').trim()
125
+ if (required && !text) throw new Error(`数据文件中的${field}不能为空`)
126
+ if (text.length > MAX_SEGMENT_CHARS) throw new Error(`数据文件中的${field}超过长度上限`)
127
+ return text
128
+ }
129
+
130
+ function cleanStringList(value, maxItems, maxLength) {
131
+ if (!Array.isArray(value)) return null
132
+ const list = value
133
+ .filter((item) => typeof item === 'string')
134
+ .map((item) => item.trim().slice(0, maxLength))
135
+ .filter(Boolean)
136
+ .slice(0, maxItems)
137
+ return list.length ? list : null
138
+ }
139
+
140
+ function cleanInlineTokens(value) {
141
+ if (!Array.isArray(value)) return null
142
+ const tokens = []
143
+ const seen = new Set()
144
+ for (const item of value) {
145
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue
146
+ const token = typeof item.token === 'string' ? item.token.trim() : ''
147
+ const xml = typeof item.xml === 'string' ? item.xml.trim() : ''
148
+ if (!/^\{\d+\}$/.test(token) || !xml || xml.length > 2000 || seen.has(token)) continue
149
+ seen.add(token)
150
+ tokens.push({ token, xml })
151
+ if (tokens.length >= 50) break
152
+ }
153
+ return tokens.length ? tokens : null
154
+ }
155
+
156
+ /**
157
+ * Persisted per-segment metadata for exchange formats (cue timing, PO context,
158
+ * XLIFF unit id and inline placeholders). Only JSON primitives are kept;
159
+ * unknown keys are discarded.
160
+ */
161
+ export function cleanSegmentMeta(value) {
162
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
163
+ const meta = {}
164
+ for (const key of ['start', 'end', 'xlsxRow', 'pptxParagraph']) {
165
+ const number = Number(value[key])
166
+ if (Number.isFinite(number) && number >= 0) meta[key] = Math.round(number)
167
+ }
168
+ for (const key of ['settings', 'identifier', 'context', 'msgidPlural', 'xliffId', 'format', 'jsonPath', 'pptxSlide']) {
169
+ if (typeof value[key] === 'string' && value[key].trim()) {
170
+ meta[key] = value[key].slice(0, 500)
171
+ }
172
+ }
173
+ if (typeof value.plural === 'boolean') meta.plural = value.plural
174
+ const comments = cleanStringList(value.comments, 20, 500)
175
+ if (comments) meta.comments = comments
176
+ const plurals = cleanStringList(value.plurals, 6, 5000)
177
+ if (plurals) meta.plurals = plurals
178
+ const inline = cleanInlineTokens(value.inline)
179
+ if (inline) meta.inline = inline
180
+ return Object.keys(meta).length ? meta : null
181
+ }
182
+
183
+ /**
184
+ * Document-level exchange metadata. Currently only PO obsolete entries are
185
+ * preserved as raw blocks so export can round-trip them without treating them
186
+ * as active translation segments.
187
+ */
188
+ export function cleanFormatMeta(value) {
189
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null
190
+ const meta = {}
191
+ const obsolete = cleanStringList(value.poObsolete, 200, 5000)
192
+ if (obsolete) meta.poObsolete = obsolete
193
+ if (typeof value.jsonTemplate === 'string' && value.jsonTemplate.length <= 8 * 1024 * 1024) {
194
+ meta.jsonTemplate = value.jsonTemplate
195
+ }
196
+ return Object.keys(meta).length ? meta : null
197
+ }
198
+
199
+ export function restoreSettings(raw) {
200
+ const settings = structuredClone(DEFAULT_SETTINGS)
201
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return settings
202
+ try {
203
+ if (String(raw.sourceLang ?? '').trim()) {
204
+ settings.sourceLang = restoredLang(raw.sourceLang, settings.sourceLang)
205
+ }
206
+ if (String(raw.targetLang ?? '').trim()) {
207
+ settings.targetLang = restoredLang(raw.targetLang, settings.targetLang)
208
+ }
209
+ const threshold = Number(raw.fuzzyThreshold)
210
+ if (Number.isFinite(threshold)) settings.fuzzyThreshold = clamp(threshold, 0, 1)
211
+ const limit = Number(raw.tmLimit)
212
+ if (Number.isFinite(limit)) settings.tmLimit = clampInt(limit, 1, 20, settings.tmLimit)
213
+ const uiLang = String(raw.uiLang ?? '').trim().toLowerCase()
214
+ if (UI_LANG_CODES.includes(uiLang)) settings.uiLang = uiLang
215
+
216
+ if (raw.mt && typeof raw.mt === 'object' && !Array.isArray(raw.mt)) {
217
+ const provider = String(raw.mt.provider ?? '').trim()
218
+ if (provider) settings.mt.provider = provider.slice(0, 120)
219
+ if (raw.mt.model !== undefined) settings.mt.model = String(raw.mt.model ?? '').trim().slice(0, 200)
220
+ const temperature = Number(raw.mt.temperature)
221
+ if (Number.isFinite(temperature)) settings.mt.temperature = clamp(temperature, 0, 2)
222
+ }
223
+ return settings
224
+ } catch {
225
+ return structuredClone(DEFAULT_SETTINGS)
226
+ }
227
+ }
228
+
229
+ export function cleanLibraryName(value, fallback = 'Untitled library') {
230
+ const name = String(value ?? '').replace(/[\r\n\t]/g, ' ').trim()
231
+ if (name.length > 80) throw new CatError('INVALID_LIBRARY_NAME', '库名称不能超过 80 字符')
232
+ return name || fallback
233
+ }
234
+
235
+ export function restoreLibraries(raw, idKey, defaultId, defaultName, entries) {
236
+ const libraries = new Map()
237
+ const add = (id, name, createdAt, updatedAt) => {
238
+ if (typeof id !== 'string' || !id || libraries.has(id)) return
239
+ libraries.set(id, {
240
+ [idKey]: id,
241
+ name: cleanLibraryName(name, id === defaultId ? defaultName : `${idKey === 'tmId' ? 'TM' : 'Glossary'} ${id}`),
242
+ createdAt: restoredDate(createdAt),
243
+ updatedAt: restoredDate(updatedAt),
244
+ })
245
+ }
246
+
247
+ if (Array.isArray(raw)) {
248
+ for (const item of raw) {
249
+ if (!item || typeof item !== 'object') continue
250
+ add(item[idKey], item.name, item.createdAt, item.updatedAt)
251
+ }
252
+ }
253
+ if (!libraries.has(defaultId)) add(defaultId, defaultName)
254
+ for (const entry of entries) {
255
+ if (entry[idKey] && !libraries.has(entry[idKey])) add(entry[idKey])
256
+ }
257
+
258
+ const result = [...libraries.values()]
259
+ const defaultIndex = result.findIndex((item) => item[idKey] === defaultId)
260
+ if (defaultIndex > 0) result.unshift(result.splice(defaultIndex, 1)[0])
261
+ return result
262
+ }
263
+
264
+ export function restoreMountIds(raw, libraries, idKey, fallbackId) {
265
+ const valid = new Set(libraries.map((item) => item[idKey]))
266
+ const requested = Array.isArray(raw) ? raw : []
267
+ const unique = [...new Set(requested.filter((id) => typeof id === 'string' && valid.has(id)))]
268
+ return unique.length ? unique : [fallbackId]
269
+ }
270
+
271
+ export function mdCell(value) {
272
+ return String(value ?? '')
273
+ .replace(/\|/g, '\\|')
274
+ .replace(/\n+/g, '<br>')
275
+ .trim()
276
+ }
277
+
278
+ export function csvCell(value) {
279
+ const text = String(value ?? '')
280
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
281
+ }
282
+
283
+ export function safeBaseName(value) {
284
+ const base = String(value ?? 'cait-export')
285
+ .replace(/\.(txt|md|markdown|docx|tmx|csv|tsv|json|srt|vtt|po|pot|xliff|xlf)$/i, '')
286
+ .replace(/[\\/:*?"<>|]+/g, '_')
287
+ .replace(/\s+/g, '-')
288
+ .trim()
289
+ return (base || 'cait-export').slice(0, 80)
290
+ }
291
+
292
+ export function parseGlossaryCsv(text) {
293
+ const rows = []
294
+ let row = []
295
+ let field = ''
296
+ let quoted = false
297
+ const input = String(text ?? '').replace(/\r\n?/g, '\n')
298
+
299
+ for (let i = 0; i < input.length; i += 1) {
300
+ const char = input[i]
301
+ if (quoted) {
302
+ if (char === '"') {
303
+ if (input[i + 1] === '"') {
304
+ field += '"'
305
+ i += 1
306
+ } else {
307
+ quoted = false
308
+ }
309
+ } else {
310
+ field += char
311
+ }
312
+ continue
313
+ }
314
+ if (char === '"') {
315
+ quoted = true
316
+ } else if (char === ',' || char === '\t') {
317
+ row.push(field)
318
+ field = ''
319
+ } else if (char === '\n') {
320
+ row.push(field)
321
+ if (row.some((cell) => cell.trim())) rows.push(row)
322
+ row = []
323
+ field = ''
324
+ } else {
325
+ field += char
326
+ }
327
+ }
328
+ row.push(field)
329
+ if (row.some((cell) => cell.trim())) rows.push(row)
330
+
331
+ const entries = []
332
+ for (const cells of rows) {
333
+ const [source, target, note, lang] = cells.map((cell) => cell.trim())
334
+ if (!source || !target) continue
335
+ if (/^(source|term|原文|术语)$/i.test(source) && /^(target|translation|译文)$/i.test(target)) continue
336
+ entries.push({ source, target, note: note || '', sourceLang: lang || '' })
337
+ }
338
+ return entries
339
+ }