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/CHANGELOG.md +202 -0
- package/README.ja.md +147 -25
- package/README.md +176 -27
- package/README.zh-CN.md +137 -26
- package/client/components.js +2047 -340
- package/client/entry.js +13 -0
- package/client/helpers.js +110 -7
- package/client/locales.js +435 -6
- package/client/rpc.js +1 -1
- package/client/styles.js +137 -3
- package/client.js +2742 -356
- package/index.js +3 -0
- package/lib/aligner.js +411 -0
- package/lib/api.js +31 -0
- package/lib/context.js +71 -0
- package/lib/docx.js +12 -9
- package/lib/epub.js +355 -0
- package/lib/formats.js +88 -33
- package/lib/glossary.js +4 -4
- package/lib/html.js +271 -0
- package/lib/import-export.js +208 -95
- package/lib/libraries.js +55 -0
- package/lib/library-export.js +75 -0
- package/lib/library-view.js +215 -0
- package/lib/mt.js +17 -7
- package/lib/persistence.js +92 -7
- package/lib/pre-translate.js +263 -0
- package/lib/preprocess.js +65 -0
- package/lib/propagate.js +133 -0
- package/lib/qa.js +167 -0
- package/lib/store-utils.js +48 -6
- package/lib/store.js +143 -12
- package/lib/term-extract.js +190 -0
- package/lib/terms.js +102 -0
- package/lib/tm.js +123 -11
- package/lib/tmx.js +47 -2
- package/lib/tools.js +472 -0
- package/package.json +2 -2
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:
|
|
86
|
-
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
|
|
package/lib/persistence.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|