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,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
+ }