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.
- package/CHANGELOG.md +367 -352
- package/LICENSE +201 -201
- package/README.ja.md +4 -3
- package/README.md +424 -423
- package/README.zh-CN.md +391 -390
- package/client/components.js +2377 -2377
- package/client/helpers.js +103 -103
- package/client/locales.js +730 -730
- package/client/rpc.js +93 -93
- package/client/styles.js +258 -258
- package/client.js +3556 -3556
- package/icon.svg +8 -8
- package/index.js +45 -45
- package/lib/api.js +223 -223
- package/lib/docx.js +389 -389
- package/lib/errors.js +8 -8
- package/lib/formats.js +917 -917
- package/lib/glossary.js +178 -178
- package/lib/import-export.js +605 -605
- package/lib/libraries.js +183 -183
- package/lib/markdown.js +28 -28
- package/lib/mt.js +152 -152
- package/lib/persistence.js +321 -321
- package/lib/pptx.js +89 -89
- package/lib/segmenter.js +84 -84
- package/lib/similarity.js +113 -113
- package/lib/store-utils.js +339 -339
- package/lib/store.js +734 -734
- package/lib/tm.js +160 -160
- package/lib/tmx.js +85 -85
- package/lib/tools.js +472 -472
- package/lib/xlsx.js +218 -218
- package/lib/xml.js +31 -31
- package/locale/en.json +11 -11
- package/locale/zh.json +11 -11
- package/package.json +83 -83
- package/scripts/build-client.mjs +13 -13
package/lib/store-utils.js
CHANGED
|
@@ -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.
|
|
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
|
+
}
|