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/persistence.js
CHANGED
|
@@ -1,321 +1,321 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
-
import { join, resolve } from 'node:path'
|
|
4
|
-
import { CatError } from './errors.js'
|
|
5
|
-
import { normalizeText } from './similarity.js'
|
|
6
|
-
import { documentFingerprint, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_SETTINGS, DEFAULT_TM_ID, DEFAULT_TM_NAME, MAX_SEGMENTS, MAX_STORE_BYTES, STORE_FILENAME, STORE_VERSION, clamp, clampInt, cleanFormatMeta, cleanName, cleanSegmentMeta, now, restoreLibraries, restoreMountIds, restoreSettings, restoredDate, restoredLang, restoredText } from './store-utils.js'
|
|
7
|
-
|
|
8
|
-
export const persistenceMethods = {
|
|
9
|
-
load() {
|
|
10
|
-
let raw
|
|
11
|
-
try {
|
|
12
|
-
raw = readFileSync(this.storageFile, 'utf8')
|
|
13
|
-
} catch (error) {
|
|
14
|
-
if (error?.code === 'ENOENT') return
|
|
15
|
-
this.reportLoadFailure(error)
|
|
16
|
-
return
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
try {
|
|
20
|
-
if (Buffer.byteLength(raw, 'utf8') > MAX_STORE_BYTES) {
|
|
21
|
-
throw new Error(`数据文件超过 ${MAX_STORE_BYTES / 1024 / 1024} MB 上限`)
|
|
22
|
-
}
|
|
23
|
-
this.restore(JSON.parse(raw))
|
|
24
|
-
this.persistence.loaded = true
|
|
25
|
-
this.persistence.error = null
|
|
26
|
-
} catch (error) {
|
|
27
|
-
this.reportLoadFailure(error)
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
,
|
|
31
|
-
reportLoadFailure(error) {
|
|
32
|
-
const message = error instanceof Error ? error.message : String(error)
|
|
33
|
-
this.docs = new Map()
|
|
34
|
-
this.tm = []
|
|
35
|
-
this.glossary = []
|
|
36
|
-
this.activeDocId = null
|
|
37
|
-
this.openDocIds = []
|
|
38
|
-
this.resetLibraryState()
|
|
39
|
-
this.persistence.loaded = false
|
|
40
|
-
this.persistence.error = message
|
|
41
|
-
this.logger?.warn?.(
|
|
42
|
-
`CAIT: 无法加载持久化数据 (${this.storageFile}): ${message};已回退到空状态。`,
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
const backup = `${this.storageFile}.corrupt-${Date.now()}`
|
|
47
|
-
renameSync(this.storageFile, backup)
|
|
48
|
-
this.persistence.corruptBackup = backup
|
|
49
|
-
this.logger?.warn?.(`CAIT: 已将无法读取的数据文件备份到 ${backup}`)
|
|
50
|
-
} catch (backupError) {
|
|
51
|
-
if (backupError?.code !== 'ENOENT') {
|
|
52
|
-
this.logger?.warn?.(`CAIT: 备份损坏数据文件失败: ${backupError.message}`)
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
,
|
|
57
|
-
restore(payload) {
|
|
58
|
-
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
59
|
-
throw new Error('数据文件根节点不是对象')
|
|
60
|
-
}
|
|
61
|
-
if (payload.version !== 1 && payload.version !== STORE_VERSION) {
|
|
62
|
-
throw new Error(`不兼容的数据版本: ${String(payload.version ?? 'missing')}`)
|
|
63
|
-
}
|
|
64
|
-
if (!Array.isArray(payload.documents) || !Array.isArray(payload.tm) || !Array.isArray(payload.glossary)) {
|
|
65
|
-
throw new Error('数据文件缺少 documents/tm/glossary 数组')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const docs = new Map()
|
|
69
|
-
for (const rawDoc of payload.documents) {
|
|
70
|
-
if (!rawDoc || typeof rawDoc !== 'object') throw new Error('文档记录不是对象')
|
|
71
|
-
if (typeof rawDoc.id !== 'string' || !rawDoc.id) throw new Error('文档缺少 id')
|
|
72
|
-
if (!Array.isArray(rawDoc.segments) || !rawDoc.segments.length) {
|
|
73
|
-
throw new Error(`文档 ${rawDoc.id} 没有句段`)
|
|
74
|
-
}
|
|
75
|
-
if (rawDoc.segments.length > MAX_SEGMENTS) throw new Error(`文档 ${rawDoc.id} 句段数量超限`)
|
|
76
|
-
|
|
77
|
-
const sourceLang = restoredLang(rawDoc.sourceLang, 'en')
|
|
78
|
-
const targetLang = restoredLang(rawDoc.targetLang, 'zh-CN')
|
|
79
|
-
const segments = rawDoc.segments.map((rawSegment, index) => {
|
|
80
|
-
if (!rawSegment || typeof rawSegment !== 'object') {
|
|
81
|
-
throw new Error(`文档 ${rawDoc.id} 的第 ${index + 1} 个句段不是对象`)
|
|
82
|
-
}
|
|
83
|
-
const source = restoredText(rawSegment.source, '原文', { required: true })
|
|
84
|
-
const target = restoredText(rawSegment.target ?? '', '译文')
|
|
85
|
-
const status = rawSegment.status === 'confirmed' && target ? 'confirmed' : 'pending'
|
|
86
|
-
const meta = cleanSegmentMeta(rawSegment.meta)
|
|
87
|
-
return {
|
|
88
|
-
id:
|
|
89
|
-
typeof rawSegment.id === 'string' && rawSegment.id
|
|
90
|
-
? rawSegment.id
|
|
91
|
-
: `s${index + 1}`,
|
|
92
|
-
source,
|
|
93
|
-
target,
|
|
94
|
-
status,
|
|
95
|
-
paragraph: Number.isInteger(rawSegment.paragraph) ? rawSegment.paragraph : index,
|
|
96
|
-
...(meta ? { meta } : {}),
|
|
97
|
-
confirmedAt: status === 'confirmed' ? restoredDate(rawSegment.confirmedAt) : null,
|
|
98
|
-
updatedAt: restoredDate(rawSegment.updatedAt),
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
const doc = {
|
|
103
|
-
id: rawDoc.id,
|
|
104
|
-
name: cleanName(rawDoc.name, '未命名文档'),
|
|
105
|
-
sourceLang,
|
|
106
|
-
targetLang,
|
|
107
|
-
segments,
|
|
108
|
-
cursor: clampInt(rawDoc.cursor, 0, segments.length - 1, 0),
|
|
109
|
-
createdAt: restoredDate(rawDoc.createdAt),
|
|
110
|
-
updatedAt: restoredDate(rawDoc.updatedAt),
|
|
111
|
-
format: typeof rawDoc.format === 'string' ? rawDoc.format.slice(0, 24) : '',
|
|
112
|
-
fingerprint:
|
|
113
|
-
typeof rawDoc.fingerprint === 'string' && rawDoc.fingerprint
|
|
114
|
-
? rawDoc.fingerprint
|
|
115
|
-
: documentFingerprint(sourceLang, targetLang, segments, rawDoc.format),
|
|
116
|
-
}
|
|
117
|
-
const formatMeta = cleanFormatMeta(rawDoc.formatMeta)
|
|
118
|
-
if (formatMeta) doc.formatMeta = formatMeta
|
|
119
|
-
const sourceDocx = rawDoc.sourceDocx
|
|
120
|
-
if (
|
|
121
|
-
sourceDocx &&
|
|
122
|
-
typeof sourceDocx === 'object' &&
|
|
123
|
-
sourceDocx.kind === 'file' &&
|
|
124
|
-
typeof sourceDocx.asset === 'string' &&
|
|
125
|
-
/^sources\/[A-Za-z0-9_.-]+\.docx$/.test(sourceDocx.asset)
|
|
126
|
-
) {
|
|
127
|
-
doc.sourceDocx = {
|
|
128
|
-
kind: 'file',
|
|
129
|
-
asset: sourceDocx.asset,
|
|
130
|
-
name: cleanName(sourceDocx.name, 'source.docx'),
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
const sourcePptx = rawDoc.sourcePptx
|
|
134
|
-
if (
|
|
135
|
-
sourcePptx &&
|
|
136
|
-
typeof sourcePptx === 'object' &&
|
|
137
|
-
sourcePptx.kind === 'file' &&
|
|
138
|
-
typeof sourcePptx.asset === 'string' &&
|
|
139
|
-
/^sources\/[A-Za-z0-9_.-]+\.pptx$/.test(sourcePptx.asset)
|
|
140
|
-
) {
|
|
141
|
-
doc.sourcePptx = {
|
|
142
|
-
kind: 'file',
|
|
143
|
-
asset: sourcePptx.asset,
|
|
144
|
-
name: cleanName(sourcePptx.name, 'source.pptx'),
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
if (docs.has(doc.id)) throw new Error(`文档 id 重复: ${doc.id}`)
|
|
148
|
-
docs.set(doc.id, doc)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const tm = []
|
|
152
|
-
const tmKeys = new Set()
|
|
153
|
-
for (const rawEntry of payload.tm) {
|
|
154
|
-
if (!rawEntry || typeof rawEntry !== 'object') throw new Error('记忆库记录不是对象')
|
|
155
|
-
const source = restoredText(rawEntry.source, '记忆库原文', { required: true })
|
|
156
|
-
const target = restoredText(rawEntry.target, '记忆库译文', { required: true })
|
|
157
|
-
const sourceLang = restoredLang(rawEntry.sourceLang, 'en')
|
|
158
|
-
const targetLang = restoredLang(rawEntry.targetLang, 'zh-CN')
|
|
159
|
-
const key = normalizeText(source)
|
|
160
|
-
const tmId =
|
|
161
|
-
typeof rawEntry.tmId === 'string' && rawEntry.tmId ? rawEntry.tmId : DEFAULT_TM_ID
|
|
162
|
-
const dedupeKey = `${tmId}\u0000${sourceLang}\u0000${targetLang}\u0000${key}`
|
|
163
|
-
if (tmKeys.has(dedupeKey)) continue
|
|
164
|
-
tmKeys.add(dedupeKey)
|
|
165
|
-
tm.push({
|
|
166
|
-
id: typeof rawEntry.id === 'string' && rawEntry.id ? rawEntry.id : makeId('tm'),
|
|
167
|
-
tmId,
|
|
168
|
-
key,
|
|
169
|
-
source,
|
|
170
|
-
target,
|
|
171
|
-
sourceLang,
|
|
172
|
-
targetLang,
|
|
173
|
-
createdAt: restoredDate(rawEntry.createdAt),
|
|
174
|
-
updatedAt: restoredDate(rawEntry.updatedAt),
|
|
175
|
-
})
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const glossary = []
|
|
179
|
-
for (const rawEntry of payload.glossary) {
|
|
180
|
-
if (!rawEntry || typeof rawEntry !== 'object') throw new Error('术语库记录不是对象')
|
|
181
|
-
glossary.push({
|
|
182
|
-
id: typeof rawEntry.id === 'string' && rawEntry.id ? rawEntry.id : makeId('gl'),
|
|
183
|
-
glossaryId:
|
|
184
|
-
typeof rawEntry.glossaryId === 'string' && rawEntry.glossaryId
|
|
185
|
-
? rawEntry.glossaryId
|
|
186
|
-
: DEFAULT_GLOSSARY_ID,
|
|
187
|
-
source: restoredText(rawEntry.source, '术语原文', { required: true }),
|
|
188
|
-
target: restoredText(rawEntry.target, '术语译文', { required: true }),
|
|
189
|
-
note: typeof rawEntry.note === 'string' ? rawEntry.note.trim() : '',
|
|
190
|
-
sourceLang: String(rawEntry.sourceLang ?? '').trim()
|
|
191
|
-
? restoredLang(rawEntry.sourceLang, '')
|
|
192
|
-
: '',
|
|
193
|
-
caseSensitive: Boolean(rawEntry.caseSensitive),
|
|
194
|
-
createdAt: restoredDate(rawEntry.createdAt),
|
|
195
|
-
updatedAt: restoredDate(rawEntry.updatedAt),
|
|
196
|
-
})
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const tmLibraries = restoreLibraries(
|
|
200
|
-
payload.tmLibraries,
|
|
201
|
-
'tmId',
|
|
202
|
-
DEFAULT_TM_ID,
|
|
203
|
-
DEFAULT_TM_NAME,
|
|
204
|
-
tm,
|
|
205
|
-
)
|
|
206
|
-
const glossaryLibraries = restoreLibraries(
|
|
207
|
-
payload.glossaryLibraries,
|
|
208
|
-
'glossaryId',
|
|
209
|
-
DEFAULT_GLOSSARY_ID,
|
|
210
|
-
DEFAULT_GLOSSARY_NAME,
|
|
211
|
-
glossary,
|
|
212
|
-
)
|
|
213
|
-
const rawMounts = payload.mounts && typeof payload.mounts === 'object' ? payload.mounts : {}
|
|
214
|
-
const tmIds = restoreMountIds(rawMounts.tmIds, tmLibraries, 'tmId', DEFAULT_TM_ID)
|
|
215
|
-
const glossaryIds = restoreMountIds(
|
|
216
|
-
rawMounts.glossaryIds,
|
|
217
|
-
glossaryLibraries,
|
|
218
|
-
'glossaryId',
|
|
219
|
-
DEFAULT_GLOSSARY_ID,
|
|
220
|
-
)
|
|
221
|
-
const defaultTmId =
|
|
222
|
-
typeof rawMounts.defaultTmId === 'string' && tmIds.includes(rawMounts.defaultTmId)
|
|
223
|
-
? rawMounts.defaultTmId
|
|
224
|
-
: tmIds[0]
|
|
225
|
-
const defaultGlossaryId =
|
|
226
|
-
typeof rawMounts.defaultGlossaryId === 'string' &&
|
|
227
|
-
glossaryIds.includes(rawMounts.defaultGlossaryId)
|
|
228
|
-
? rawMounts.defaultGlossaryId
|
|
229
|
-
: glossaryIds[0]
|
|
230
|
-
|
|
231
|
-
this.docs = docs
|
|
232
|
-
this.tm = tm
|
|
233
|
-
this.glossary = glossary
|
|
234
|
-
this.tmLibraries = tmLibraries
|
|
235
|
-
this.glossaryLibraries = glossaryLibraries
|
|
236
|
-
this.mounts = { tmIds, glossaryIds, defaultTmId, defaultGlossaryId }
|
|
237
|
-
const hasOpenDocIds = Array.isArray(payload.openDocIds)
|
|
238
|
-
const openDocIds = []
|
|
239
|
-
for (const id of hasOpenDocIds ? payload.openDocIds : []) {
|
|
240
|
-
if (docs.has(id) && !openDocIds.includes(id)) openDocIds.push(id)
|
|
241
|
-
}
|
|
242
|
-
let activeDocId =
|
|
243
|
-
typeof payload.activeDocId === 'string' && docs.has(payload.activeDocId)
|
|
244
|
-
? payload.activeDocId
|
|
245
|
-
: hasOpenDocIds
|
|
246
|
-
? null
|
|
247
|
-
: docs.keys().next().value ?? null
|
|
248
|
-
if (!activeDocId && openDocIds.length) activeDocId = openDocIds[0]
|
|
249
|
-
if (activeDocId && !openDocIds.includes(activeDocId)) openDocIds.push(activeDocId)
|
|
250
|
-
this.activeDocId = activeDocId
|
|
251
|
-
this.openDocIds = openDocIds
|
|
252
|
-
this.settings = restoreSettings(payload.settings)
|
|
253
|
-
this.touch()
|
|
254
|
-
}
|
|
255
|
-
,
|
|
256
|
-
settingsForStorage() {
|
|
257
|
-
return {
|
|
258
|
-
sourceLang: this.settings.sourceLang,
|
|
259
|
-
targetLang: this.settings.targetLang,
|
|
260
|
-
fuzzyThreshold: this.settings.fuzzyThreshold,
|
|
261
|
-
tmLimit: this.settings.tmLimit,
|
|
262
|
-
mt: {
|
|
263
|
-
provider: this.settings.mt.provider,
|
|
264
|
-
model: this.settings.mt.model,
|
|
265
|
-
temperature: this.settings.mt.temperature,
|
|
266
|
-
},
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
,
|
|
270
|
-
persist({ strict = false } = {}) {
|
|
271
|
-
if (!this.storageFile) return { ok: false, skipped: true }
|
|
272
|
-
|
|
273
|
-
let temp = ''
|
|
274
|
-
try {
|
|
275
|
-
mkdirSync(this.dataDir, { recursive: true })
|
|
276
|
-
const savedAt = now()
|
|
277
|
-
const payload = {
|
|
278
|
-
version: STORE_VERSION,
|
|
279
|
-
savedAt,
|
|
280
|
-
activeDocId: this.activeDocId,
|
|
281
|
-
openDocIds: this.openDocIds,
|
|
282
|
-
documents: [...this.docs.values()],
|
|
283
|
-
tmLibraries: this.tmLibraries,
|
|
284
|
-
glossaryLibraries: this.glossaryLibraries,
|
|
285
|
-
mounts: this.mountsView(),
|
|
286
|
-
tm: this.tm,
|
|
287
|
-
glossary: this.glossary,
|
|
288
|
-
settings: this.settingsForStorage(),
|
|
289
|
-
}
|
|
290
|
-
const text = JSON.stringify(payload, null, 2)
|
|
291
|
-
temp = `${this.storageFile}.${process.pid}.${randomUUID()}.tmp`
|
|
292
|
-
writeFileSync(temp, text, 'utf8')
|
|
293
|
-
renameSync(temp, this.storageFile)
|
|
294
|
-
this.persistence.loaded = true
|
|
295
|
-
this.persistence.lastSavedAt = savedAt
|
|
296
|
-
this.persistence.error = null
|
|
297
|
-
return { ok: true, file: this.storageFile, bytes: Buffer.byteLength(text, 'utf8') }
|
|
298
|
-
} catch (error) {
|
|
299
|
-
if (temp) {
|
|
300
|
-
try {
|
|
301
|
-
unlinkSync(temp)
|
|
302
|
-
} catch {
|
|
303
|
-
// The temporary file may already have been renamed or removed.
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
this.persistence.error = error.message
|
|
307
|
-
this.logger?.error?.(`CAIT: 保存数据失败 (${this.storageFile}): ${error.message}`)
|
|
308
|
-
if (strict) {
|
|
309
|
-
const failure = new CatError(
|
|
310
|
-
'PERSISTENCE_FAILED',
|
|
311
|
-
'数据保存失败,本次操作已回滚;请检查 CAIT 数据目录后重试。',
|
|
312
|
-
503,
|
|
313
|
-
)
|
|
314
|
-
failure.storage = { ok: false, error: error.message }
|
|
315
|
-
throw failure
|
|
316
|
-
}
|
|
317
|
-
return { ok: false, error: error.message }
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
}
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { CatError } from './errors.js'
|
|
5
|
+
import { normalizeText } from './similarity.js'
|
|
6
|
+
import { documentFingerprint, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_SETTINGS, DEFAULT_TM_ID, DEFAULT_TM_NAME, MAX_SEGMENTS, MAX_STORE_BYTES, STORE_FILENAME, STORE_VERSION, clamp, clampInt, cleanFormatMeta, cleanName, cleanSegmentMeta, now, restoreLibraries, restoreMountIds, restoreSettings, restoredDate, restoredLang, restoredText } from './store-utils.js'
|
|
7
|
+
|
|
8
|
+
export const persistenceMethods = {
|
|
9
|
+
load() {
|
|
10
|
+
let raw
|
|
11
|
+
try {
|
|
12
|
+
raw = readFileSync(this.storageFile, 'utf8')
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (error?.code === 'ENOENT') return
|
|
15
|
+
this.reportLoadFailure(error)
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_STORE_BYTES) {
|
|
21
|
+
throw new Error(`数据文件超过 ${MAX_STORE_BYTES / 1024 / 1024} MB 上限`)
|
|
22
|
+
}
|
|
23
|
+
this.restore(JSON.parse(raw))
|
|
24
|
+
this.persistence.loaded = true
|
|
25
|
+
this.persistence.error = null
|
|
26
|
+
} catch (error) {
|
|
27
|
+
this.reportLoadFailure(error)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
,
|
|
31
|
+
reportLoadFailure(error) {
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
33
|
+
this.docs = new Map()
|
|
34
|
+
this.tm = []
|
|
35
|
+
this.glossary = []
|
|
36
|
+
this.activeDocId = null
|
|
37
|
+
this.openDocIds = []
|
|
38
|
+
this.resetLibraryState()
|
|
39
|
+
this.persistence.loaded = false
|
|
40
|
+
this.persistence.error = message
|
|
41
|
+
this.logger?.warn?.(
|
|
42
|
+
`CAIT: 无法加载持久化数据 (${this.storageFile}): ${message};已回退到空状态。`,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const backup = `${this.storageFile}.corrupt-${Date.now()}`
|
|
47
|
+
renameSync(this.storageFile, backup)
|
|
48
|
+
this.persistence.corruptBackup = backup
|
|
49
|
+
this.logger?.warn?.(`CAIT: 已将无法读取的数据文件备份到 ${backup}`)
|
|
50
|
+
} catch (backupError) {
|
|
51
|
+
if (backupError?.code !== 'ENOENT') {
|
|
52
|
+
this.logger?.warn?.(`CAIT: 备份损坏数据文件失败: ${backupError.message}`)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
,
|
|
57
|
+
restore(payload) {
|
|
58
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
59
|
+
throw new Error('数据文件根节点不是对象')
|
|
60
|
+
}
|
|
61
|
+
if (payload.version !== 1 && payload.version !== STORE_VERSION) {
|
|
62
|
+
throw new Error(`不兼容的数据版本: ${String(payload.version ?? 'missing')}`)
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(payload.documents) || !Array.isArray(payload.tm) || !Array.isArray(payload.glossary)) {
|
|
65
|
+
throw new Error('数据文件缺少 documents/tm/glossary 数组')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const docs = new Map()
|
|
69
|
+
for (const rawDoc of payload.documents) {
|
|
70
|
+
if (!rawDoc || typeof rawDoc !== 'object') throw new Error('文档记录不是对象')
|
|
71
|
+
if (typeof rawDoc.id !== 'string' || !rawDoc.id) throw new Error('文档缺少 id')
|
|
72
|
+
if (!Array.isArray(rawDoc.segments) || !rawDoc.segments.length) {
|
|
73
|
+
throw new Error(`文档 ${rawDoc.id} 没有句段`)
|
|
74
|
+
}
|
|
75
|
+
if (rawDoc.segments.length > MAX_SEGMENTS) throw new Error(`文档 ${rawDoc.id} 句段数量超限`)
|
|
76
|
+
|
|
77
|
+
const sourceLang = restoredLang(rawDoc.sourceLang, 'en')
|
|
78
|
+
const targetLang = restoredLang(rawDoc.targetLang, 'zh-CN')
|
|
79
|
+
const segments = rawDoc.segments.map((rawSegment, index) => {
|
|
80
|
+
if (!rawSegment || typeof rawSegment !== 'object') {
|
|
81
|
+
throw new Error(`文档 ${rawDoc.id} 的第 ${index + 1} 个句段不是对象`)
|
|
82
|
+
}
|
|
83
|
+
const source = restoredText(rawSegment.source, '原文', { required: true })
|
|
84
|
+
const target = restoredText(rawSegment.target ?? '', '译文')
|
|
85
|
+
const status = rawSegment.status === 'confirmed' && target ? 'confirmed' : 'pending'
|
|
86
|
+
const meta = cleanSegmentMeta(rawSegment.meta)
|
|
87
|
+
return {
|
|
88
|
+
id:
|
|
89
|
+
typeof rawSegment.id === 'string' && rawSegment.id
|
|
90
|
+
? rawSegment.id
|
|
91
|
+
: `s${index + 1}`,
|
|
92
|
+
source,
|
|
93
|
+
target,
|
|
94
|
+
status,
|
|
95
|
+
paragraph: Number.isInteger(rawSegment.paragraph) ? rawSegment.paragraph : index,
|
|
96
|
+
...(meta ? { meta } : {}),
|
|
97
|
+
confirmedAt: status === 'confirmed' ? restoredDate(rawSegment.confirmedAt) : null,
|
|
98
|
+
updatedAt: restoredDate(rawSegment.updatedAt),
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const doc = {
|
|
103
|
+
id: rawDoc.id,
|
|
104
|
+
name: cleanName(rawDoc.name, '未命名文档'),
|
|
105
|
+
sourceLang,
|
|
106
|
+
targetLang,
|
|
107
|
+
segments,
|
|
108
|
+
cursor: clampInt(rawDoc.cursor, 0, segments.length - 1, 0),
|
|
109
|
+
createdAt: restoredDate(rawDoc.createdAt),
|
|
110
|
+
updatedAt: restoredDate(rawDoc.updatedAt),
|
|
111
|
+
format: typeof rawDoc.format === 'string' ? rawDoc.format.slice(0, 24) : '',
|
|
112
|
+
fingerprint:
|
|
113
|
+
typeof rawDoc.fingerprint === 'string' && rawDoc.fingerprint
|
|
114
|
+
? rawDoc.fingerprint
|
|
115
|
+
: documentFingerprint(sourceLang, targetLang, segments, rawDoc.format),
|
|
116
|
+
}
|
|
117
|
+
const formatMeta = cleanFormatMeta(rawDoc.formatMeta)
|
|
118
|
+
if (formatMeta) doc.formatMeta = formatMeta
|
|
119
|
+
const sourceDocx = rawDoc.sourceDocx
|
|
120
|
+
if (
|
|
121
|
+
sourceDocx &&
|
|
122
|
+
typeof sourceDocx === 'object' &&
|
|
123
|
+
sourceDocx.kind === 'file' &&
|
|
124
|
+
typeof sourceDocx.asset === 'string' &&
|
|
125
|
+
/^sources\/[A-Za-z0-9_.-]+\.docx$/.test(sourceDocx.asset)
|
|
126
|
+
) {
|
|
127
|
+
doc.sourceDocx = {
|
|
128
|
+
kind: 'file',
|
|
129
|
+
asset: sourceDocx.asset,
|
|
130
|
+
name: cleanName(sourceDocx.name, 'source.docx'),
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const sourcePptx = rawDoc.sourcePptx
|
|
134
|
+
if (
|
|
135
|
+
sourcePptx &&
|
|
136
|
+
typeof sourcePptx === 'object' &&
|
|
137
|
+
sourcePptx.kind === 'file' &&
|
|
138
|
+
typeof sourcePptx.asset === 'string' &&
|
|
139
|
+
/^sources\/[A-Za-z0-9_.-]+\.pptx$/.test(sourcePptx.asset)
|
|
140
|
+
) {
|
|
141
|
+
doc.sourcePptx = {
|
|
142
|
+
kind: 'file',
|
|
143
|
+
asset: sourcePptx.asset,
|
|
144
|
+
name: cleanName(sourcePptx.name, 'source.pptx'),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (docs.has(doc.id)) throw new Error(`文档 id 重复: ${doc.id}`)
|
|
148
|
+
docs.set(doc.id, doc)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const tm = []
|
|
152
|
+
const tmKeys = new Set()
|
|
153
|
+
for (const rawEntry of payload.tm) {
|
|
154
|
+
if (!rawEntry || typeof rawEntry !== 'object') throw new Error('记忆库记录不是对象')
|
|
155
|
+
const source = restoredText(rawEntry.source, '记忆库原文', { required: true })
|
|
156
|
+
const target = restoredText(rawEntry.target, '记忆库译文', { required: true })
|
|
157
|
+
const sourceLang = restoredLang(rawEntry.sourceLang, 'en')
|
|
158
|
+
const targetLang = restoredLang(rawEntry.targetLang, 'zh-CN')
|
|
159
|
+
const key = normalizeText(source)
|
|
160
|
+
const tmId =
|
|
161
|
+
typeof rawEntry.tmId === 'string' && rawEntry.tmId ? rawEntry.tmId : DEFAULT_TM_ID
|
|
162
|
+
const dedupeKey = `${tmId}\u0000${sourceLang}\u0000${targetLang}\u0000${key}`
|
|
163
|
+
if (tmKeys.has(dedupeKey)) continue
|
|
164
|
+
tmKeys.add(dedupeKey)
|
|
165
|
+
tm.push({
|
|
166
|
+
id: typeof rawEntry.id === 'string' && rawEntry.id ? rawEntry.id : makeId('tm'),
|
|
167
|
+
tmId,
|
|
168
|
+
key,
|
|
169
|
+
source,
|
|
170
|
+
target,
|
|
171
|
+
sourceLang,
|
|
172
|
+
targetLang,
|
|
173
|
+
createdAt: restoredDate(rawEntry.createdAt),
|
|
174
|
+
updatedAt: restoredDate(rawEntry.updatedAt),
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const glossary = []
|
|
179
|
+
for (const rawEntry of payload.glossary) {
|
|
180
|
+
if (!rawEntry || typeof rawEntry !== 'object') throw new Error('术语库记录不是对象')
|
|
181
|
+
glossary.push({
|
|
182
|
+
id: typeof rawEntry.id === 'string' && rawEntry.id ? rawEntry.id : makeId('gl'),
|
|
183
|
+
glossaryId:
|
|
184
|
+
typeof rawEntry.glossaryId === 'string' && rawEntry.glossaryId
|
|
185
|
+
? rawEntry.glossaryId
|
|
186
|
+
: DEFAULT_GLOSSARY_ID,
|
|
187
|
+
source: restoredText(rawEntry.source, '术语原文', { required: true }),
|
|
188
|
+
target: restoredText(rawEntry.target, '术语译文', { required: true }),
|
|
189
|
+
note: typeof rawEntry.note === 'string' ? rawEntry.note.trim() : '',
|
|
190
|
+
sourceLang: String(rawEntry.sourceLang ?? '').trim()
|
|
191
|
+
? restoredLang(rawEntry.sourceLang, '')
|
|
192
|
+
: '',
|
|
193
|
+
caseSensitive: Boolean(rawEntry.caseSensitive),
|
|
194
|
+
createdAt: restoredDate(rawEntry.createdAt),
|
|
195
|
+
updatedAt: restoredDate(rawEntry.updatedAt),
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const tmLibraries = restoreLibraries(
|
|
200
|
+
payload.tmLibraries,
|
|
201
|
+
'tmId',
|
|
202
|
+
DEFAULT_TM_ID,
|
|
203
|
+
DEFAULT_TM_NAME,
|
|
204
|
+
tm,
|
|
205
|
+
)
|
|
206
|
+
const glossaryLibraries = restoreLibraries(
|
|
207
|
+
payload.glossaryLibraries,
|
|
208
|
+
'glossaryId',
|
|
209
|
+
DEFAULT_GLOSSARY_ID,
|
|
210
|
+
DEFAULT_GLOSSARY_NAME,
|
|
211
|
+
glossary,
|
|
212
|
+
)
|
|
213
|
+
const rawMounts = payload.mounts && typeof payload.mounts === 'object' ? payload.mounts : {}
|
|
214
|
+
const tmIds = restoreMountIds(rawMounts.tmIds, tmLibraries, 'tmId', DEFAULT_TM_ID)
|
|
215
|
+
const glossaryIds = restoreMountIds(
|
|
216
|
+
rawMounts.glossaryIds,
|
|
217
|
+
glossaryLibraries,
|
|
218
|
+
'glossaryId',
|
|
219
|
+
DEFAULT_GLOSSARY_ID,
|
|
220
|
+
)
|
|
221
|
+
const defaultTmId =
|
|
222
|
+
typeof rawMounts.defaultTmId === 'string' && tmIds.includes(rawMounts.defaultTmId)
|
|
223
|
+
? rawMounts.defaultTmId
|
|
224
|
+
: tmIds[0]
|
|
225
|
+
const defaultGlossaryId =
|
|
226
|
+
typeof rawMounts.defaultGlossaryId === 'string' &&
|
|
227
|
+
glossaryIds.includes(rawMounts.defaultGlossaryId)
|
|
228
|
+
? rawMounts.defaultGlossaryId
|
|
229
|
+
: glossaryIds[0]
|
|
230
|
+
|
|
231
|
+
this.docs = docs
|
|
232
|
+
this.tm = tm
|
|
233
|
+
this.glossary = glossary
|
|
234
|
+
this.tmLibraries = tmLibraries
|
|
235
|
+
this.glossaryLibraries = glossaryLibraries
|
|
236
|
+
this.mounts = { tmIds, glossaryIds, defaultTmId, defaultGlossaryId }
|
|
237
|
+
const hasOpenDocIds = Array.isArray(payload.openDocIds)
|
|
238
|
+
const openDocIds = []
|
|
239
|
+
for (const id of hasOpenDocIds ? payload.openDocIds : []) {
|
|
240
|
+
if (docs.has(id) && !openDocIds.includes(id)) openDocIds.push(id)
|
|
241
|
+
}
|
|
242
|
+
let activeDocId =
|
|
243
|
+
typeof payload.activeDocId === 'string' && docs.has(payload.activeDocId)
|
|
244
|
+
? payload.activeDocId
|
|
245
|
+
: hasOpenDocIds
|
|
246
|
+
? null
|
|
247
|
+
: docs.keys().next().value ?? null
|
|
248
|
+
if (!activeDocId && openDocIds.length) activeDocId = openDocIds[0]
|
|
249
|
+
if (activeDocId && !openDocIds.includes(activeDocId)) openDocIds.push(activeDocId)
|
|
250
|
+
this.activeDocId = activeDocId
|
|
251
|
+
this.openDocIds = openDocIds
|
|
252
|
+
this.settings = restoreSettings(payload.settings)
|
|
253
|
+
this.touch()
|
|
254
|
+
}
|
|
255
|
+
,
|
|
256
|
+
settingsForStorage() {
|
|
257
|
+
return {
|
|
258
|
+
sourceLang: this.settings.sourceLang,
|
|
259
|
+
targetLang: this.settings.targetLang,
|
|
260
|
+
fuzzyThreshold: this.settings.fuzzyThreshold,
|
|
261
|
+
tmLimit: this.settings.tmLimit,
|
|
262
|
+
mt: {
|
|
263
|
+
provider: this.settings.mt.provider,
|
|
264
|
+
model: this.settings.mt.model,
|
|
265
|
+
temperature: this.settings.mt.temperature,
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
,
|
|
270
|
+
persist({ strict = false } = {}) {
|
|
271
|
+
if (!this.storageFile) return { ok: false, skipped: true }
|
|
272
|
+
|
|
273
|
+
let temp = ''
|
|
274
|
+
try {
|
|
275
|
+
mkdirSync(this.dataDir, { recursive: true })
|
|
276
|
+
const savedAt = now()
|
|
277
|
+
const payload = {
|
|
278
|
+
version: STORE_VERSION,
|
|
279
|
+
savedAt,
|
|
280
|
+
activeDocId: this.activeDocId,
|
|
281
|
+
openDocIds: this.openDocIds,
|
|
282
|
+
documents: [...this.docs.values()],
|
|
283
|
+
tmLibraries: this.tmLibraries,
|
|
284
|
+
glossaryLibraries: this.glossaryLibraries,
|
|
285
|
+
mounts: this.mountsView(),
|
|
286
|
+
tm: this.tm,
|
|
287
|
+
glossary: this.glossary,
|
|
288
|
+
settings: this.settingsForStorage(),
|
|
289
|
+
}
|
|
290
|
+
const text = JSON.stringify(payload, null, 2)
|
|
291
|
+
temp = `${this.storageFile}.${process.pid}.${randomUUID()}.tmp`
|
|
292
|
+
writeFileSync(temp, text, 'utf8')
|
|
293
|
+
renameSync(temp, this.storageFile)
|
|
294
|
+
this.persistence.loaded = true
|
|
295
|
+
this.persistence.lastSavedAt = savedAt
|
|
296
|
+
this.persistence.error = null
|
|
297
|
+
return { ok: true, file: this.storageFile, bytes: Buffer.byteLength(text, 'utf8') }
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (temp) {
|
|
300
|
+
try {
|
|
301
|
+
unlinkSync(temp)
|
|
302
|
+
} catch {
|
|
303
|
+
// The temporary file may already have been renamed or removed.
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
this.persistence.error = error.message
|
|
307
|
+
this.logger?.error?.(`CAIT: 保存数据失败 (${this.storageFile}): ${error.message}`)
|
|
308
|
+
if (strict) {
|
|
309
|
+
const failure = new CatError(
|
|
310
|
+
'PERSISTENCE_FAILED',
|
|
311
|
+
'数据保存失败,本次操作已回滚;请检查 CAIT 数据目录后重试。',
|
|
312
|
+
503,
|
|
313
|
+
)
|
|
314
|
+
failure.storage = { ok: false, error: error.message }
|
|
315
|
+
throw failure
|
|
316
|
+
}
|
|
317
|
+
return { ok: false, error: error.message }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
}
|