dsh-cait 1.0.4 → 1.1.3

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/lib/libraries.js CHANGED
@@ -135,6 +135,61 @@ export const libraryMethods = {
135
135
  }
136
136
  throw new CatError('INVALID_LIBRARY_KIND', '库类型必须是 tm 或 glossary')
137
137
  }
138
+ ,
139
+ renameLibrary({ kind, id, name } = {}) {
140
+ const { idKey, list, notFoundCode } = this.libraryTarget(kind)
141
+ const library = list.find((item) => item[idKey] === String(id ?? '').trim())
142
+ if (!library) throw new CatError(notFoundCode, `库不存在: ${String(id ?? '')}`, 404)
143
+ library.name = cleanLibraryName(name, library.name)
144
+ library.updatedAt = now()
145
+ this.touch()
146
+ this.persist()
147
+ return { libraries: this.librariesView() }
148
+ }
149
+ ,
150
+ deleteLibrary({ kind, id } = {}) {
151
+ const isTm = String(kind ?? '').trim() === 'tm'
152
+ const { idKey, list, notFoundCode } = this.libraryTarget(kind)
153
+ const libraryId = String(id ?? '').trim()
154
+ const index = list.findIndex((item) => item[idKey] === libraryId)
155
+ if (index < 0) throw new CatError(notFoundCode, `库不存在: ${libraryId}`, 404)
156
+ if (list.length <= 1) {
157
+ throw new CatError(
158
+ isTm ? 'LAST_TM_LIBRARY' : 'LAST_GLOSSARY_LIBRARY',
159
+ '至少要保留一个库,无法删除最后一个。',
160
+ 409,
161
+ )
162
+ }
163
+
164
+ list.splice(index, 1)
165
+ // Entries belong to the library; dropping it drops them with it.
166
+ if (isTm) {
167
+ this.tm = this.tm.filter((entry) => (entry.tmId || DEFAULT_TM_ID) !== libraryId)
168
+ } else {
169
+ this.glossary = this.glossary.filter((entry) => (entry.glossaryId || DEFAULT_GLOSSARY_ID) !== libraryId)
170
+ }
171
+
172
+ const idsKey = isTm ? 'tmIds' : 'glossaryIds'
173
+ const defaultKey = isTm ? 'defaultTmId' : 'defaultGlossaryId'
174
+ const remaining = this.mounts[idsKey].filter((item) => item !== libraryId)
175
+ this.mounts[idsKey] = remaining.length ? remaining : [list[0][idKey]]
176
+ if (this.mounts[defaultKey] === libraryId) this.mounts[defaultKey] = this.mounts[idsKey][0]
177
+
178
+ this.touch()
179
+ this.persist()
180
+ return { deleted: libraryId, mounts: this.mountsView(), libraries: this.librariesView() }
181
+ }
182
+ ,
183
+ libraryTarget(kind) {
184
+ const libraryKind = String(kind ?? '').trim()
185
+ if (libraryKind === 'tm') {
186
+ return { idKey: 'tmId', list: this.tmLibraries, notFoundCode: 'TM_NOT_FOUND' }
187
+ }
188
+ if (libraryKind === 'glossary') {
189
+ return { idKey: 'glossaryId', list: this.glossaryLibraries, notFoundCode: 'GLOSSARY_NOT_FOUND' }
190
+ }
191
+ throw new CatError('INVALID_LIBRARY_KIND', '库类型必须是 tm 或 glossary')
192
+ }
138
193
  ,
139
194
  setMounts({ tmIds, glossaryIds, defaultTmId, defaultGlossaryId } = {}) {
140
195
  if (tmIds !== undefined && !Array.isArray(tmIds)) {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Serialisers for the library page: a TM as TMX and a glossary as TBX, CSV
3
+ * or JSON. They are deliberately dependency-free and mirror what the
4
+ * importers already accept, so a CSV round-trip never loses notes.
5
+ */
6
+
7
+ import { escapeXml } from './xml.js'
8
+
9
+ const CSV_COLUMNS = ['source', 'target', 'note', 'sourceLang', 'caseSensitive']
10
+
11
+ function csvCell(value) {
12
+ const text = String(value ?? '')
13
+ return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
14
+ }
15
+
16
+ export function glossaryToCsv(entries) {
17
+ const lines = [CSV_COLUMNS.join(',')]
18
+ for (const entry of entries) {
19
+ lines.push(
20
+ [
21
+ csvCell(entry.source),
22
+ csvCell(entry.target),
23
+ csvCell(entry.note || ''),
24
+ csvCell(entry.sourceLang || ''),
25
+ entry.caseSensitive ? 'true' : 'false',
26
+ ].join(','),
27
+ )
28
+ }
29
+ return `${lines.join('\n')}\n`
30
+ }
31
+
32
+ export function glossaryToJson(entries) {
33
+ return `${JSON.stringify(
34
+ entries.map((entry) => ({
35
+ source: entry.source,
36
+ target: entry.target,
37
+ note: entry.note || '',
38
+ sourceLang: entry.sourceLang || '',
39
+ caseSensitive: Boolean(entry.caseSensitive),
40
+ })),
41
+ null,
42
+ 2,
43
+ )}\n`
44
+ }
45
+
46
+ /**
47
+ * TBX-Basic, minimal shape: one termEntry per glossary entry, a langSet per
48
+ * language with a single tig/term, and the note as a descrip. Not every TBX
49
+ * consumer likes the minimal header, but this is what the round-trip test
50
+ * and the requirements doc agreed on (export only, no TBX import).
51
+ */
52
+ export function glossaryToTbx(entries, { sourceLang = 'en', targetLang = 'zh-CN' } = {}) {
53
+ const lines = [
54
+ '<?xml version="1.0" encoding="UTF-8"?>',
55
+ `<tbx style="dca" type="TBX-Basic" xml:lang="${escapeXml(sourceLang)}" version="3">`,
56
+ ' <header>',
57
+ ` <file original="cait" sourceLang="${escapeXml(sourceLang)}" targetLang="${escapeXml(targetLang)}"/>`,
58
+ ' </header>',
59
+ ' <body>',
60
+ ]
61
+ for (const entry of entries) {
62
+ const entryLang = entry.sourceLang || sourceLang
63
+ lines.push(` <termEntry id="${escapeXml(entry.id)}">`)
64
+ lines.push(` <langSet xml:lang="${escapeXml(entryLang)}">`)
65
+ lines.push(` <tig><term>${escapeXml(entry.source)}</term></tig>`)
66
+ lines.push(' </langSet>')
67
+ lines.push(` <langSet xml:lang="${escapeXml(targetLang)}">`)
68
+ lines.push(` <tig><term>${escapeXml(entry.target)}</term></tig>`)
69
+ lines.push(' </langSet>')
70
+ if (entry.note) lines.push(` <descrip type="note">${escapeXml(entry.note)}</descrip>`)
71
+ lines.push(' </termEntry>')
72
+ }
73
+ lines.push(' </body>', '</tbx>', '')
74
+ return lines.join('\n')
75
+ }
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Library browsing and export. `lookupTM` answers "what matches this segment",
3
+ * which is a query, not a catalogue: it only looks at mounted libraries, scores
4
+ * fuzzily and caps at 30 rows. The library page needs the opposite shape —
5
+ * every entry of a chosen set of libraries, filtered by substring and paged.
6
+ * These methods are read-only, so `lib/api.js` must not list them in MUTATIONS.
7
+ */
8
+
9
+ import { CatError } from './errors.js'
10
+ import { DEFAULT_GLOSSARY_ID, DEFAULT_TM_ID, canonicalLanguage, clampInt } from './store-utils.js'
11
+ import { normalizeText } from './similarity.js'
12
+ import { buildTmxForEntries } from './tmx.js'
13
+ import { glossaryToCsv, glossaryToJson, glossaryToTbx } from './library-export.js'
14
+
15
+ export const PAGE_SIZE_OPTIONS = [20, 50, 100, 200]
16
+ export const PAGE_SIZE_DEFAULT = 50
17
+
18
+ const EXPORT_FORMATS = {
19
+ tm: ['tmx'],
20
+ glossary: ['tbx', 'csv', 'json'],
21
+ }
22
+
23
+ function dateStamp(date = new Date()) {
24
+ const pad = (value) => String(value).padStart(2, '0')
25
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`
26
+ }
27
+
28
+ function fileStem(name, fallback) {
29
+ const stem = String(name ?? '')
30
+ .trim()
31
+ .replace(/[\\/:*?"<>|]+/g, '-')
32
+ .replace(/\s+/g, '-')
33
+ .replace(/^-+|-+$/g, '')
34
+ return stem || fallback
35
+ }
36
+
37
+ function pageSizeOf(value) {
38
+ const size = Number(value)
39
+ if (!Number.isFinite(size) || size <= 0) return PAGE_SIZE_DEFAULT
40
+ // The UI only offers four sizes; snapping keeps the total page count honest
41
+ // if a stale localStorage value ever reaches the server.
42
+ return PAGE_SIZE_OPTIONS.reduce(
43
+ (best, option) => (Math.abs(option - size) < Math.abs(best - size) ? option : best),
44
+ PAGE_SIZE_OPTIONS[0],
45
+ )
46
+ }
47
+
48
+ function pageOf(page, pageSize, total) {
49
+ const size = pageSizeOf(pageSize)
50
+ const totalPages = Math.max(1, Math.ceil(total / size))
51
+ const current = clampInt(page ?? 1, 1, totalPages, 1)
52
+ return { page: current, pageSize: size, totalPages, offset: (current - 1) * size }
53
+ }
54
+
55
+ function matchesQuery(entry, query) {
56
+ if (!query) return true
57
+ const needle = normalizeText(query)
58
+ if (!needle) return true
59
+ return normalizeText(entry.source).includes(needle) || normalizeText(entry.target).includes(needle)
60
+ }
61
+
62
+ function languageFacet(entries) {
63
+ const source = new Set()
64
+ const target = new Set()
65
+ for (const entry of entries) {
66
+ if (entry.sourceLang) source.add(entry.sourceLang)
67
+ if (entry.targetLang) target.add(entry.targetLang)
68
+ }
69
+ return { source: [...source].sort(), target: [...target].sort() }
70
+ }
71
+
72
+ function wantedIds(kind, ids, store) {
73
+ const clean = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id) : []
74
+ if (!clean.length) {
75
+ return new Set(
76
+ kind === 'tm'
77
+ ? store.tmLibraries.map((library) => library.tmId)
78
+ : store.glossaryLibraries.map((library) => library.glossaryId),
79
+ )
80
+ }
81
+ const scoped = new Set()
82
+ for (const id of clean) {
83
+ scoped.add(kind === 'tm' ? store.requireTmLibrary(id) : store.requireGlossaryLibrary(id))
84
+ }
85
+ return scoped
86
+ }
87
+
88
+ export const libraryViewMethods = {
89
+ listTmEntries({ tmIds, query, page, pageSize, sourceLang, targetLang } = {}) {
90
+ const scoped = wantedIds('tm', tmIds, this)
91
+ const src = sourceLang ? canonicalLanguage(sourceLang) : ''
92
+ const tgt = targetLang ? canonicalLanguage(targetLang) : ''
93
+
94
+ const inScope = this.tm.filter((entry) => scoped.has(entry.tmId || DEFAULT_TM_ID))
95
+ const languages = languageFacet(inScope)
96
+ const filtered = inScope
97
+ .filter((entry) => !src || entry.sourceLang === src)
98
+ .filter((entry) => !tgt || entry.targetLang === tgt)
99
+ .filter((entry) => matchesQuery(entry, query))
100
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
101
+
102
+ const paging = pageOf(page, pageSize, filtered.length)
103
+ return {
104
+ kind: 'tm',
105
+ scopeIds: [...scoped],
106
+ query: String(query ?? ''),
107
+ sourceLang: src,
108
+ targetLang: tgt,
109
+ total: filtered.length,
110
+ languages,
111
+ ...paging,
112
+ entries: filtered.slice(paging.offset, paging.offset + paging.pageSize).map((entry) => ({
113
+ id: entry.id,
114
+ tmId: entry.tmId || DEFAULT_TM_ID,
115
+ source: entry.source,
116
+ target: entry.target,
117
+ note: entry.note || '',
118
+ sourceLang: entry.sourceLang,
119
+ targetLang: entry.targetLang,
120
+ context: entry.context ?? null,
121
+ contextHash: entry.contextHash ?? null,
122
+ createdAt: entry.createdAt,
123
+ updatedAt: entry.updatedAt,
124
+ })),
125
+ }
126
+ }
127
+ ,
128
+ listGlossaryEntries({ glossaryIds, query, page, pageSize, sourceLang } = {}) {
129
+ const scoped = wantedIds('glossary', glossaryIds, this)
130
+ const src = sourceLang ? canonicalLanguage(sourceLang) : ''
131
+
132
+ const inScope = this.glossary.filter((entry) =>
133
+ scoped.has(entry.glossaryId || DEFAULT_GLOSSARY_ID),
134
+ )
135
+ const languages = languageFacet(inScope)
136
+ const filtered = inScope
137
+ .filter((entry) => !src || entry.sourceLang === src)
138
+ .filter((entry) => matchesQuery(entry, query))
139
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
140
+
141
+ const paging = pageOf(page, pageSize, filtered.length)
142
+ return {
143
+ kind: 'glossary',
144
+ scopeIds: [...scoped],
145
+ query: String(query ?? ''),
146
+ sourceLang: src,
147
+ total: filtered.length,
148
+ languages,
149
+ ...paging,
150
+ entries: filtered.slice(paging.offset, paging.offset + paging.pageSize).map((entry) => ({
151
+ id: entry.id,
152
+ glossaryId: entry.glossaryId || DEFAULT_GLOSSARY_ID,
153
+ source: entry.source,
154
+ target: entry.target,
155
+ note: entry.note || '',
156
+ sourceLang: entry.sourceLang,
157
+ caseSensitive: Boolean(entry.caseSensitive),
158
+ createdAt: entry.createdAt,
159
+ updatedAt: entry.updatedAt,
160
+ })),
161
+ }
162
+ }
163
+ ,
164
+ exportTM({ tmId, format = 'tmx' } = {}) {
165
+ const libraryId = this.requireTmLibrary(tmId)
166
+ const cleanFormat = String(format ?? 'tmx').trim().toLowerCase()
167
+ if (!EXPORT_FORMATS.tm.includes(cleanFormat)) {
168
+ throw new CatError('INVALID_EXPORT_FORMAT', `记忆库不支持导出为 ${cleanFormat || '(empty)'}。`)
169
+ }
170
+ const library = this.tmLibraries.find((item) => item.tmId === libraryId)
171
+ const entries = this.tm
172
+ .filter((entry) => (entry.tmId || DEFAULT_TM_ID) === libraryId)
173
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
174
+ const sourceLang = entries[0]?.sourceLang || this.settings.sourceLang
175
+ const targetLang = entries[0]?.targetLang || this.settings.targetLang
176
+ return {
177
+ filename: `cait-tm-${fileStem(library?.name, libraryId)}-${dateStamp()}.${cleanFormat}`,
178
+ mime: 'application/xml; charset=utf-8',
179
+ format: cleanFormat,
180
+ count: entries.length,
181
+ content: buildTmxForEntries(entries, { sourceLang, targetLang }),
182
+ }
183
+ }
184
+ ,
185
+ exportGlossary({ glossaryId, format = 'tbx' } = {}) {
186
+ const libraryId = this.requireGlossaryLibrary(glossaryId)
187
+ const cleanFormat = String(format ?? 'tbx').trim().toLowerCase()
188
+ if (!EXPORT_FORMATS.glossary.includes(cleanFormat)) {
189
+ throw new CatError('INVALID_EXPORT_FORMAT', `术语库不支持导出为 ${cleanFormat || '(empty)'}。`)
190
+ }
191
+ const library = this.glossaryLibraries.find((item) => item.glossaryId === libraryId)
192
+ const entries = this.glossary
193
+ .filter((entry) => (entry.glossaryId || DEFAULT_GLOSSARY_ID) === libraryId)
194
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
195
+ const sourceLang = entries[0]?.sourceLang || this.settings.sourceLang
196
+ const targetLang = this.settings.targetLang
197
+ const content = cleanFormat === 'csv'
198
+ ? glossaryToCsv(entries)
199
+ : cleanFormat === 'json'
200
+ ? glossaryToJson(entries)
201
+ : glossaryToTbx(entries, { sourceLang, targetLang })
202
+ const mime = cleanFormat === 'csv'
203
+ ? 'text/csv; charset=utf-8'
204
+ : cleanFormat === 'json'
205
+ ? 'application/json; charset=utf-8'
206
+ : 'application/xml; charset=utf-8'
207
+ return {
208
+ filename: `cait-glossary-${fileStem(library?.name, libraryId)}-${dateStamp()}.${cleanFormat}`,
209
+ mime,
210
+ format: cleanFormat,
211
+ count: entries.length,
212
+ content,
213
+ }
214
+ },
215
+ }
package/lib/mt.js CHANGED
@@ -52,17 +52,12 @@ async function discoverRoutes(llm, loader) {
52
52
  if (!Array.isArray(adapters)) adapters = []
53
53
 
54
54
  const configured = configuredProviderIds(loader)
55
- const hasLoaderRoutes = adapters.some((adapter) => configured.has(String(adapter?.id ?? '')))
56
55
  const providers = []
57
56
  const seen = new Set()
58
57
 
59
58
  for (const adapter of adapters) {
60
59
  const id = String(adapter?.id ?? '').trim()
61
60
  if (!id || id === MOCK_PROVIDER || seen.has(id)) continue
62
- // When the loader exposes provider configs, use it to avoid listing adapters
63
- // that have no configured route. If loader discovery found nothing, trust the
64
- // adapter catalog (some providers are registered outside loader entries).
65
- if (hasLoaderRoutes && !configured.has(id)) continue
66
61
  seen.add(id)
67
62
 
68
63
  let models = []
@@ -73,17 +68,32 @@ async function discoverRoutes(llm, loader) {
73
68
  models = []
74
69
  }
75
70
  }
71
+ // Loader entries only tell us which providers already have a route
72
+ // configured; they do not define the catalog. Providers may be registered
73
+ // outside loader entries (the built-in DeepSeek adapter is one), so the
74
+ // loader must never decide visibility: filtering on it made every such
75
+ // adapter vanish as soon as a single custom provider was added.
76
76
  providers.push({
77
77
  id,
78
78
  name: String(adapter?.name ?? id).trim() || id,
79
79
  models,
80
+ configured: configured.has(id),
81
+ hasModels: models.length > 0,
80
82
  })
81
83
  }
82
84
 
85
+ // Configured routes first, otherwise keep the adapter catalog order stable.
86
+ providers.sort((a, b) => Number(b.configured) - Number(a.configured))
87
+
88
+ const usable = providers.filter((provider) => provider.hasModels)
83
89
  return {
84
90
  providers,
85
- fallback: providers.length === 0,
86
- message: providers.length ? '' : NO_ROUTE_MESSAGE,
91
+ fallback: usable.length === 0,
92
+ message: usable.length
93
+ ? ''
94
+ : providers.length
95
+ ? NO_MODEL_MESSAGE
96
+ : NO_ROUTE_MESSAGE,
87
97
  }
88
98
  }
89
99
 
@@ -3,7 +3,8 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from '
3
3
  import { join, resolve } from 'node:path'
4
4
  import { CatError } from './errors.js'
5
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'
6
+ import { contextHashOf, normalizeContext } from './context.js'
7
+ import { documentFingerprint, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_SETTINGS, DEFAULT_TM_ID, DEFAULT_TM_NAME, MAX_SEGMENTS, MAX_STORE_BYTES, SEGMENT_ORIGINS, SEGMENT_STATUSES, STORE_FILENAME, STORE_VERSION, clamp, clampInt, cleanFormatMeta, cleanName, cleanSegmentMeta, normalizeSegmentState, now, restoreLibraries, restoreMountIds, restoreSettings, restoredDate, restoredLang, restoredText } from './store-utils.js'
7
8
 
8
9
  export const persistenceMethods = {
9
10
  load() {
@@ -58,7 +59,14 @@ export const persistenceMethods = {
58
59
  if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
59
60
  throw new Error('数据文件根节点不是对象')
60
61
  }
61
- if (payload.version !== 1 && payload.version !== STORE_VERSION) {
62
+ // v1 and v2 predate the three-state model: their segments only carried
63
+ // pending/confirmed, so their target text maps onto `translated`.
64
+ if (
65
+ payload.version !== 1 &&
66
+ payload.version !== 2 &&
67
+ payload.version !== 3 &&
68
+ payload.version !== STORE_VERSION
69
+ ) {
62
70
  throw new Error(`不兼容的数据版本: ${String(payload.version ?? 'missing')}`)
63
71
  }
64
72
  if (!Array.isArray(payload.documents) || !Array.isArray(payload.tm) || !Array.isArray(payload.glossary)) {
@@ -82,21 +90,37 @@ export const persistenceMethods = {
82
90
  }
83
91
  const source = restoredText(rawSegment.source, '原文', { required: true })
84
92
  const target = restoredText(rawSegment.target ?? '', '译文')
85
- const status = rawSegment.status === 'confirmed' && target ? 'confirmed' : 'pending'
93
+ const suggestion = restoredText(rawSegment.suggestion ?? '', '译文')
94
+ const confirmed = rawSegment.status === 'confirmed' && target
95
+ const origin = SEGMENT_ORIGINS.includes(rawSegment.origin)
96
+ ? rawSegment.origin
97
+ : target || suggestion
98
+ ? 'manual'
99
+ : null
86
100
  const meta = cleanSegmentMeta(rawSegment.meta)
87
- return {
101
+ const segment = {
88
102
  id:
89
103
  typeof rawSegment.id === 'string' && rawSegment.id
90
104
  ? rawSegment.id
91
105
  : `s${index + 1}`,
92
106
  source,
93
107
  target,
94
- status,
108
+ // normalizeSegmentState re-derives this from target/suggestion; the
109
+ // stored value is only a hint so a v3 file with a stale status still
110
+ // loads into a consistent model.
111
+ status: SEGMENT_STATUSES.includes(rawSegment.status) ? rawSegment.status : 'pending',
112
+ origin,
113
+ suggestion,
114
+ suggestionReason:
115
+ typeof rawSegment.suggestionReason === 'string'
116
+ ? rawSegment.suggestionReason.slice(0, 500)
117
+ : '',
95
118
  paragraph: Number.isInteger(rawSegment.paragraph) ? rawSegment.paragraph : index,
96
119
  ...(meta ? { meta } : {}),
97
- confirmedAt: status === 'confirmed' ? restoredDate(rawSegment.confirmedAt) : null,
120
+ confirmedAt: confirmed ? restoredDate(rawSegment.confirmedAt) : null,
98
121
  updatedAt: restoredDate(rawSegment.updatedAt),
99
122
  }
123
+ return normalizeSegmentState(segment)
100
124
  })
101
125
 
102
126
  const doc = {
@@ -144,6 +168,20 @@ export const persistenceMethods = {
144
168
  name: cleanName(sourcePptx.name, 'source.pptx'),
145
169
  }
146
170
  }
171
+ const sourceEpub = rawDoc.sourceEpub
172
+ if (
173
+ sourceEpub &&
174
+ typeof sourceEpub === 'object' &&
175
+ sourceEpub.kind === 'file' &&
176
+ typeof sourceEpub.asset === 'string' &&
177
+ /^sources\/[A-Za-z0-9_.-]+\.epub$/.test(sourceEpub.asset)
178
+ ) {
179
+ doc.sourceEpub = {
180
+ kind: 'file',
181
+ asset: sourceEpub.asset,
182
+ name: cleanName(sourceEpub.name, 'source.epub'),
183
+ }
184
+ }
147
185
  if (docs.has(doc.id)) throw new Error(`文档 id 重复: ${doc.id}`)
148
186
  docs.set(doc.id, doc)
149
187
  }
@@ -157,9 +195,13 @@ export const persistenceMethods = {
157
195
  const sourceLang = restoredLang(rawEntry.sourceLang, 'en')
158
196
  const targetLang = restoredLang(rawEntry.targetLang, 'zh-CN')
159
197
  const key = normalizeText(source)
198
+ // Context is recomputed from its text on load: a stored hash can never be
199
+ // trusted to agree with the pair it was derived from.
200
+ const context = normalizeContext(rawEntry.context)
201
+ const contextHash = contextHashOf(context)
160
202
  const tmId =
161
203
  typeof rawEntry.tmId === 'string' && rawEntry.tmId ? rawEntry.tmId : DEFAULT_TM_ID
162
- const dedupeKey = `${tmId}\u0000${sourceLang}\u0000${targetLang}\u0000${key}`
204
+ const dedupeKey = `${tmId}\u0000${sourceLang}\u0000${targetLang}\u0000${key}\u0000${contextHash ?? ''}`
163
205
  if (tmKeys.has(dedupeKey)) continue
164
206
  tmKeys.add(dedupeKey)
165
207
  tm.push({
@@ -170,6 +212,8 @@ export const persistenceMethods = {
170
212
  target,
171
213
  sourceLang,
172
214
  targetLang,
215
+ context,
216
+ contextHash,
173
217
  createdAt: restoredDate(rawEntry.createdAt),
174
218
  updatedAt: restoredDate(rawEntry.updatedAt),
175
219
  })
@@ -259,6 +303,12 @@ export const persistenceMethods = {
259
303
  targetLang: this.settings.targetLang,
260
304
  fuzzyThreshold: this.settings.fuzzyThreshold,
261
305
  tmLimit: this.settings.tmLimit,
306
+ uiLang: this.settings.uiLang,
307
+ preprocess: {
308
+ normalizeWhitespace: this.settings.preprocess.normalizeWhitespace,
309
+ stripInvisible: this.settings.preprocess.stripInvisible,
310
+ collapseBlankLines: this.settings.preprocess.collapseBlankLines,
311
+ },
262
312
  mt: {
263
313
  provider: this.settings.mt.provider,
264
314
  model: this.settings.mt.model,
@@ -266,9 +316,44 @@ export const persistenceMethods = {
266
316
  },
267
317
  }
268
318
  }
319
+ ,
320
+ /**
321
+ * Selecting a segment or typing a draft should survive a restart, but neither
322
+ * is worth a synchronous whole-file write per keystroke, so both schedule a
323
+ * trailing save instead.
324
+ */
325
+ schedulePersist(delay = 1200) {
326
+ if (!this.storageFile) return
327
+ if (this.saveTimer) clearTimeout(this.saveTimer)
328
+ this.saveTimer = setTimeout(() => {
329
+ this.saveTimer = null
330
+ try {
331
+ this.persist()
332
+ } catch {
333
+ // persist() already logged and recorded the failure.
334
+ }
335
+ }, delay)
336
+ this.saveTimer.unref?.()
337
+ }
338
+ ,
339
+ flushPersist() {
340
+ if (this.saveTimer) {
341
+ clearTimeout(this.saveTimer)
342
+ this.saveTimer = null
343
+ }
344
+ try {
345
+ return this.persist()
346
+ } catch {
347
+ return { ok: false }
348
+ }
349
+ }
269
350
  ,
270
351
  persist({ strict = false } = {}) {
271
352
  if (!this.storageFile) return { ok: false, skipped: true }
353
+ if (this.saveTimer) {
354
+ clearTimeout(this.saveTimer)
355
+ this.saveTimer = null
356
+ }
272
357
 
273
358
  let temp = ''
274
359
  try {