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/store-utils.js
CHANGED
|
@@ -1,22 +1,56 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
2
|
import { CatError } from './errors.js'
|
|
3
3
|
import { clamp, clampInt } from './similarity.js'
|
|
4
|
+
import { DEFAULT_PREPROCESS, normalizePreprocessOptions } from './preprocess.js'
|
|
4
5
|
|
|
5
6
|
export { clamp, clampInt }
|
|
6
7
|
|
|
7
8
|
export const MAX_SEGMENTS = 20000
|
|
8
|
-
export const CAIT_VERSION = '1.
|
|
9
|
+
export const CAIT_VERSION = '1.1.3'
|
|
9
10
|
export const MAX_SEGMENT_CHARS = 20000
|
|
10
11
|
export const MAX_IMPORT_CHARS = 8_000_000
|
|
11
12
|
export const MAX_DOCX_BASE64 = 28_000_000
|
|
12
13
|
export const MAX_STORE_BYTES = 64 * 1024 * 1024
|
|
13
|
-
export const STORE_VERSION =
|
|
14
|
+
export const STORE_VERSION = 4
|
|
14
15
|
export const STORE_FILENAME = 'store.json'
|
|
15
16
|
export const DEFAULT_TM_ID = 'tm_default'
|
|
16
17
|
export const DEFAULT_GLOSSARY_ID = 'glossary_default'
|
|
17
18
|
export const DEFAULT_TM_NAME = 'Default TM'
|
|
18
19
|
export const DEFAULT_GLOSSARY_NAME = 'Default glossary'
|
|
19
20
|
|
|
21
|
+
// Segment lifecycle: nothing translated yet → a draft or a proposal exists →
|
|
22
|
+
// a human confirmed it. `origin` records where the current target/suggestion
|
|
23
|
+
// came from, which is what makes "MT filled it, I only reviewed" auditable.
|
|
24
|
+
export const SEGMENT_STATUSES = ['pending', 'translated', 'confirmed']
|
|
25
|
+
export const SEGMENT_ORIGINS = ['tm', 'mt', 'agent', 'manual', 'auto']
|
|
26
|
+
|
|
27
|
+
export function segmentStatus(segment) {
|
|
28
|
+
const target = String(segment?.target ?? '')
|
|
29
|
+
const suggestion = String(segment?.suggestion ?? '')
|
|
30
|
+
if (target && segment?.confirmedAt) return 'confirmed'
|
|
31
|
+
if (target || suggestion) return 'translated'
|
|
32
|
+
return 'pending'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Re-derive the stored fields from the facts (target/suggestion/confirmedAt)
|
|
37
|
+
* so the three-state model cannot drift out of sync with the text.
|
|
38
|
+
*/
|
|
39
|
+
export function normalizeSegmentState(segment) {
|
|
40
|
+
if (!String(segment.target ?? '')) segment.confirmedAt = null
|
|
41
|
+
if (!String(segment.suggestion ?? '')) {
|
|
42
|
+
segment.suggestion = ''
|
|
43
|
+
segment.suggestionReason = ''
|
|
44
|
+
}
|
|
45
|
+
segment.status = segmentStatus(segment)
|
|
46
|
+
if (segment.status === 'pending') {
|
|
47
|
+
segment.origin = null
|
|
48
|
+
} else if (!SEGMENT_ORIGINS.includes(segment.origin)) {
|
|
49
|
+
segment.origin = 'manual'
|
|
50
|
+
}
|
|
51
|
+
return segment
|
|
52
|
+
}
|
|
53
|
+
|
|
20
54
|
// UI languages the workbench can switch between; 'auto' follows the DSH locale.
|
|
21
55
|
export const UI_LANG_CODES = ['auto', 'zh', 'en', 'ja']
|
|
22
56
|
|
|
@@ -26,6 +60,7 @@ export const DEFAULT_SETTINGS = {
|
|
|
26
60
|
fuzzyThreshold: 0.55,
|
|
27
61
|
tmLimit: 5,
|
|
28
62
|
uiLang: 'auto',
|
|
63
|
+
preprocess: { ...DEFAULT_PREPROCESS },
|
|
29
64
|
mt: {
|
|
30
65
|
provider: 'mock',
|
|
31
66
|
model: 'cait-mock',
|
|
@@ -161,11 +196,11 @@ function cleanInlineTokens(value) {
|
|
|
161
196
|
export function cleanSegmentMeta(value) {
|
|
162
197
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
163
198
|
const meta = {}
|
|
164
|
-
for (const key of ['start', 'end', 'xlsxRow', 'pptxParagraph']) {
|
|
199
|
+
for (const key of ['start', 'end', 'xlsxRow', 'pptxParagraph', 'epubBlock']) {
|
|
165
200
|
const number = Number(value[key])
|
|
166
201
|
if (Number.isFinite(number) && number >= 0) meta[key] = Math.round(number)
|
|
167
202
|
}
|
|
168
|
-
for (const key of ['settings', 'identifier', 'context', 'msgidPlural', 'xliffId', 'format', 'jsonPath', 'pptxSlide']) {
|
|
203
|
+
for (const key of ['settings', 'identifier', 'context', 'msgidPlural', 'xliffId', 'format', 'jsonPath', 'pptxSlide', 'epubHref']) {
|
|
169
204
|
if (typeof value[key] === 'string' && value[key].trim()) {
|
|
170
205
|
meta[key] = value[key].slice(0, 500)
|
|
171
206
|
}
|
|
@@ -212,6 +247,7 @@ export function restoreSettings(raw) {
|
|
|
212
247
|
if (Number.isFinite(limit)) settings.tmLimit = clampInt(limit, 1, 20, settings.tmLimit)
|
|
213
248
|
const uiLang = String(raw.uiLang ?? '').trim().toLowerCase()
|
|
214
249
|
if (UI_LANG_CODES.includes(uiLang)) settings.uiLang = uiLang
|
|
250
|
+
settings.preprocess = normalizePreprocessOptions(raw.preprocess)
|
|
215
251
|
|
|
216
252
|
if (raw.mt && typeof raw.mt === 'object' && !Array.isArray(raw.mt)) {
|
|
217
253
|
const provider = String(raw.mt.provider ?? '').trim()
|
|
@@ -330,10 +366,16 @@ export function parseGlossaryCsv(text) {
|
|
|
330
366
|
|
|
331
367
|
const entries = []
|
|
332
368
|
for (const cells of rows) {
|
|
333
|
-
const [source, target, note, lang] = cells.map((cell) => cell.trim())
|
|
369
|
+
const [source, target, note, lang, caseFlag] = cells.map((cell) => cell.trim())
|
|
334
370
|
if (!source || !target) continue
|
|
335
371
|
if (/^(source|term|原文|术语)$/i.test(source) && /^(target|translation|译文)$/i.test(target)) continue
|
|
336
|
-
entries.push({
|
|
372
|
+
entries.push({
|
|
373
|
+
source,
|
|
374
|
+
target,
|
|
375
|
+
note: note || '',
|
|
376
|
+
sourceLang: lang || '',
|
|
377
|
+
caseSensitive: /^(true|1|yes|y)$/i.test(caseFlag || ''),
|
|
378
|
+
})
|
|
337
379
|
}
|
|
338
380
|
return entries
|
|
339
381
|
}
|
package/lib/store.js
CHANGED
|
@@ -2,16 +2,24 @@ import { CatError } from './errors.js'
|
|
|
2
2
|
import { createRequire } from 'node:module'
|
|
3
3
|
import { join, resolve } from 'node:path'
|
|
4
4
|
import { splitIntoSegments } from './segmenter.js'
|
|
5
|
-
import { canonicalLanguage, cleanFormatMeta, cleanLang, cleanName, cleanSegmentMeta, cleanText, clamp, clampInt, countWords, documentFingerprint, DEFAULT_SETTINGS, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_TM_ID, DEFAULT_TM_NAME, escapeRegExp, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, now, makeId, parseGlossaryCsv, restoredDate, restoredLang, restoredText, restoreSettings, safeBaseName, STORE_FILENAME, UI_LANG_CODES } from './store-utils.js'
|
|
5
|
+
import { canonicalLanguage, cleanFormatMeta, cleanLang, cleanName, cleanSegmentMeta, cleanText, clamp, clampInt, countWords, documentFingerprint, DEFAULT_SETTINGS, DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, DEFAULT_TM_ID, DEFAULT_TM_NAME, escapeRegExp, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, normalizeSegmentState, now, makeId, parseGlossaryCsv, restoredDate, restoredLang, restoredText, restoreSettings, safeBaseName, SEGMENT_ORIGINS, STORE_FILENAME, UI_LANG_CODES } from './store-utils.js'
|
|
6
6
|
import { libraryMethods } from './libraries.js'
|
|
7
7
|
import { tmMethods } from './tm.js'
|
|
8
8
|
import { glossaryMethods } from './glossary.js'
|
|
9
9
|
import { persistenceMethods } from './persistence.js'
|
|
10
10
|
import { importExportMethods } from './import-export.js'
|
|
11
|
+
import { preTranslateMethods } from './pre-translate.js'
|
|
12
|
+
import { qaMethods } from './qa.js'
|
|
13
|
+
import { libraryViewMethods } from './library-view.js'
|
|
14
|
+
import { propagateMethods } from './propagate.js'
|
|
15
|
+
import { alignMethods } from './aligner.js'
|
|
16
|
+
import { termMethods } from './terms.js'
|
|
11
17
|
import { classifyMatchType, normalizeSource, normalizeText, similarity } from './similarity.js'
|
|
18
|
+
import { contextOf } from './context.js'
|
|
12
19
|
import { MOCK_MODEL, MOCK_PROVIDER, NO_MODEL_MESSAGE, NO_ROUTE_MESSAGE, collectStreamText } from './mt.js'
|
|
13
20
|
import { buildTmx } from './tmx.js'
|
|
14
21
|
import { missingInlineTokens } from './formats.js'
|
|
22
|
+
import { normalizePreprocessOptions, preprocessSegments } from './preprocess.js'
|
|
15
23
|
|
|
16
24
|
export { CatError } from './errors.js'
|
|
17
25
|
export { canonicalLanguage } from './store-utils.js'
|
|
@@ -31,6 +39,7 @@ export class CatStore {
|
|
|
31
39
|
this.resetLibraryState()
|
|
32
40
|
this.dataDir = String(dataDir ?? '').trim() ? resolve(String(dataDir)) : ''
|
|
33
41
|
this.storageFile = this.dataDir ? join(this.dataDir, STORE_FILENAME) : ''
|
|
42
|
+
this.saveTimer = null
|
|
34
43
|
this.persistence = {
|
|
35
44
|
enabled: Boolean(this.storageFile),
|
|
36
45
|
file: this.storageFile || null,
|
|
@@ -84,10 +93,14 @@ export class CatStore {
|
|
|
84
93
|
progress(doc) {
|
|
85
94
|
const total = doc.segments.length
|
|
86
95
|
const confirmed = doc.segments.filter((segment) => segment.status === 'confirmed').length
|
|
96
|
+
const translated = doc.segments.filter((segment) => segment.status === 'translated').length
|
|
87
97
|
return {
|
|
88
98
|
total,
|
|
89
99
|
confirmed,
|
|
90
|
-
|
|
100
|
+
// "translated" means a draft or a proposal exists and only review is left;
|
|
101
|
+
// "pending" is everything with no text at all.
|
|
102
|
+
translated,
|
|
103
|
+
pending: total - confirmed - translated,
|
|
91
104
|
percent: total ? Math.round((confirmed / total) * 100) : 0,
|
|
92
105
|
}
|
|
93
106
|
}
|
|
@@ -112,6 +125,9 @@ export class CatStore {
|
|
|
112
125
|
source: segment.source,
|
|
113
126
|
target: segment.target,
|
|
114
127
|
status: segment.status,
|
|
128
|
+
origin: segment.origin ?? null,
|
|
129
|
+
suggestion: segment.suggestion ?? '',
|
|
130
|
+
suggestionReason: segment.suggestionReason ?? '',
|
|
115
131
|
paragraph: segment.paragraph,
|
|
116
132
|
confirmedAt: segment.confirmedAt,
|
|
117
133
|
}
|
|
@@ -127,6 +143,8 @@ export class CatStore {
|
|
|
127
143
|
query: segment.source,
|
|
128
144
|
sourceLang: doc.sourceLang,
|
|
129
145
|
targetLang: doc.targetLang,
|
|
146
|
+
documentId: doc.id,
|
|
147
|
+
index,
|
|
130
148
|
}).matches,
|
|
131
149
|
glossaryHits: this.findGlossaryHits(segment.source, doc.sourceLang),
|
|
132
150
|
}
|
|
@@ -177,6 +195,10 @@ export class CatStore {
|
|
|
177
195
|
(sum, item) => sum + item.segments.filter((segment) => segment.status === 'confirmed').length,
|
|
178
196
|
0,
|
|
179
197
|
),
|
|
198
|
+
translated: [...this.docs.values()].reduce(
|
|
199
|
+
(sum, item) => sum + item.segments.filter((segment) => segment.status === 'translated').length,
|
|
200
|
+
0,
|
|
201
|
+
),
|
|
180
202
|
tm: this.tm.length,
|
|
181
203
|
glossary: this.glossary.length,
|
|
182
204
|
},
|
|
@@ -216,6 +238,7 @@ export class CatStore {
|
|
|
216
238
|
}
|
|
217
239
|
|
|
218
240
|
if (!rawSegments.length) throw new CatError('EMPTY_SOURCE', '没有可翻译的句段。')
|
|
241
|
+
rawSegments = preprocessSegments(rawSegments, this.settings.preprocess)
|
|
219
242
|
if (rawSegments.length > MAX_SEGMENTS) {
|
|
220
243
|
throw new CatError('TOO_MANY_SEGMENTS', `句段数量超过 ${MAX_SEGMENTS} 上限`)
|
|
221
244
|
}
|
|
@@ -232,16 +255,19 @@ export class CatStore {
|
|
|
232
255
|
? raw.paragraph
|
|
233
256
|
: index
|
|
234
257
|
const meta = cleanSegmentMeta(raw.meta)
|
|
235
|
-
|
|
258
|
+
const segment = {
|
|
236
259
|
id: `s${index + 1}`,
|
|
237
260
|
source,
|
|
238
261
|
target,
|
|
239
262
|
status: target ? 'confirmed' : 'pending',
|
|
263
|
+
origin: target ? 'manual' : null,
|
|
264
|
+
suggestion: '',
|
|
240
265
|
paragraph,
|
|
241
266
|
...(meta ? { meta } : {}),
|
|
242
267
|
confirmedAt: target ? createdAt : null,
|
|
243
268
|
updatedAt: createdAt,
|
|
244
269
|
}
|
|
270
|
+
return normalizeSegmentState(segment)
|
|
245
271
|
})
|
|
246
272
|
|
|
247
273
|
const format = typeof input.format === 'string' ? input.format.slice(0, 24) : ''
|
|
@@ -251,6 +277,7 @@ export class CatStore {
|
|
|
251
277
|
if (existing) {
|
|
252
278
|
if (input.docxSource) this.attachDocxSource(existing, input.docxSource, input.docxName)
|
|
253
279
|
if (input.pptxSource) this.attachPptxSource(existing, input.pptxSource, input.pptxName)
|
|
280
|
+
if (input.epubSource) this.attachEpubSource(existing, input.epubSource, input.epubName)
|
|
254
281
|
if (input.format) existing.format = String(input.format).slice(0, 24)
|
|
255
282
|
if (formatMeta) existing.formatMeta = cleanFormatMeta({ ...(existing.formatMeta || {}), ...formatMeta })
|
|
256
283
|
this.activeDocId = existing.id
|
|
@@ -278,6 +305,7 @@ export class CatStore {
|
|
|
278
305
|
if (doc.cursor < 0) doc.cursor = 0
|
|
279
306
|
if (input.docxSource) this.attachDocxSource(doc, input.docxSource, input.docxName)
|
|
280
307
|
if (input.pptxSource) this.attachPptxSource(doc, input.pptxSource, input.pptxName)
|
|
308
|
+
if (input.epubSource) this.attachEpubSource(doc, input.epubSource, input.epubName)
|
|
281
309
|
|
|
282
310
|
this.docs.set(doc.id, doc)
|
|
283
311
|
this.activeDocId = doc.id
|
|
@@ -299,6 +327,7 @@ export class CatStore {
|
|
|
299
327
|
const tabIndex = this.openDocIds.indexOf(doc.id)
|
|
300
328
|
this.removeDocxSource(doc)
|
|
301
329
|
this.removePptxSource(doc)
|
|
330
|
+
this.removeEpubSource(doc)
|
|
302
331
|
this.docs.delete(doc.id)
|
|
303
332
|
this.openDocIds = this.openDocIds.filter((id) => id !== doc.id)
|
|
304
333
|
if (this.activeDocId === doc.id) {
|
|
@@ -341,20 +370,98 @@ export class CatStore {
|
|
|
341
370
|
doc.cursor = target
|
|
342
371
|
doc.updatedAt = now()
|
|
343
372
|
this.touch()
|
|
373
|
+
this.schedulePersist()
|
|
344
374
|
return { cursor: doc.cursor, current: this.segmentDetail(doc, doc.cursor) }
|
|
345
375
|
}
|
|
346
376
|
|
|
347
377
|
saveDraft({ documentId, index, target } = {}) {
|
|
348
378
|
const doc = this.requireDocument(documentId)
|
|
349
|
-
const
|
|
379
|
+
const segmentIndex = Number(index ?? doc.cursor)
|
|
380
|
+
const segment = this.findSegment(doc, segmentIndex)
|
|
350
381
|
const value = cleanText(target, '译文')
|
|
351
|
-
segment.target
|
|
382
|
+
if (value !== segment.target) {
|
|
383
|
+
segment.target = value
|
|
384
|
+
segment.updatedAt = now()
|
|
385
|
+
doc.updatedAt = segment.updatedAt
|
|
386
|
+
// Typed by a human. A model proposal stays visible next to it for
|
|
387
|
+
// reference until it is adopted or replaced.
|
|
388
|
+
if (value) segment.origin = 'manual'
|
|
389
|
+
// Duplicates of this source follow the human's wording instead of
|
|
390
|
+
// keeping a stale copy. Confirmed siblings are never touched.
|
|
391
|
+
if (value) this.propagateEdit(doc, segmentIndex)
|
|
392
|
+
}
|
|
393
|
+
normalizeSegmentState(segment)
|
|
394
|
+
this.touch()
|
|
395
|
+
this.schedulePersist()
|
|
396
|
+
return {
|
|
397
|
+
segment: this.segmentView(segment, segmentIndex),
|
|
398
|
+
cursor: doc.cursor,
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
adoptSuggestion({ documentId, index } = {}) {
|
|
403
|
+
const doc = this.requireDocument(documentId)
|
|
404
|
+
const segmentIndex = Number(index ?? doc.cursor)
|
|
405
|
+
const segment = this.findSegment(doc, segmentIndex)
|
|
406
|
+
if (!segment.suggestion) {
|
|
407
|
+
throw new CatError('NO_SUGGESTION', '当前句段没有可供采纳的建议。')
|
|
408
|
+
}
|
|
409
|
+
if (segment.status === 'confirmed') {
|
|
410
|
+
throw new CatError('ALREADY_CONFIRMED', '已确认的句段不能采纳建议,请先撤销确认。')
|
|
411
|
+
}
|
|
412
|
+
segment.target = segment.suggestion
|
|
352
413
|
segment.updatedAt = now()
|
|
353
414
|
doc.updatedAt = segment.updatedAt
|
|
415
|
+
// Adoption must keep the proposal's provenance (mt/agent/tm), not mark it
|
|
416
|
+
// as human-authored.
|
|
417
|
+
normalizeSegmentState(segment)
|
|
354
418
|
this.touch()
|
|
419
|
+
this.persist()
|
|
355
420
|
return {
|
|
356
|
-
segment: this.segmentView(segment,
|
|
421
|
+
segment: this.segmentView(segment, segmentIndex),
|
|
357
422
|
cursor: doc.cursor,
|
|
423
|
+
progress: this.progress(doc),
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
proposeTranslation({ documentId, index, target, reason = '' } = {}) {
|
|
428
|
+
const doc = this.requireDocument(documentId)
|
|
429
|
+
const segmentIndex = Number(index ?? doc.cursor)
|
|
430
|
+
const segment = this.findSegment(doc, segmentIndex)
|
|
431
|
+
if (segment.status === 'confirmed') {
|
|
432
|
+
throw new CatError('ALREADY_CONFIRMED', '已确认的句段不能提建议,请先撤销确认。')
|
|
433
|
+
}
|
|
434
|
+
const text = cleanText(target, '建议译文')
|
|
435
|
+
if (!text) throw new CatError('EMPTY_TARGET', '建议译文为空。')
|
|
436
|
+
const before = {
|
|
437
|
+
revision: this.revision,
|
|
438
|
+
updatedAt: doc.updatedAt,
|
|
439
|
+
segment: {
|
|
440
|
+
origin: segment.origin,
|
|
441
|
+
suggestion: segment.suggestion,
|
|
442
|
+
suggestionReason: segment.suggestionReason,
|
|
443
|
+
updatedAt: segment.updatedAt,
|
|
444
|
+
},
|
|
445
|
+
}
|
|
446
|
+
segment.suggestion = text
|
|
447
|
+
segment.suggestionReason = String(reason ?? '').slice(0, 500)
|
|
448
|
+
// A proposal never touches `target`: the human keeps the decision.
|
|
449
|
+
if (!segment.target) segment.origin = 'agent'
|
|
450
|
+
segment.updatedAt = now()
|
|
451
|
+
doc.updatedAt = segment.updatedAt
|
|
452
|
+
normalizeSegmentState(segment)
|
|
453
|
+
this.touch()
|
|
454
|
+
try {
|
|
455
|
+
this.persist({ strict: true })
|
|
456
|
+
} catch (error) {
|
|
457
|
+
this.revision = before.revision
|
|
458
|
+
doc.updatedAt = before.updatedAt
|
|
459
|
+
Object.assign(segment, before.segment)
|
|
460
|
+
throw error
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
segment: this.segmentView(segment, segmentIndex),
|
|
464
|
+
progress: this.progress(doc),
|
|
358
465
|
}
|
|
359
466
|
}
|
|
360
467
|
|
|
@@ -370,6 +477,8 @@ export class CatStore {
|
|
|
370
477
|
segment: {
|
|
371
478
|
target: segment.target,
|
|
372
479
|
status: segment.status,
|
|
480
|
+
origin: segment.origin,
|
|
481
|
+
suggestion: segment.suggestion,
|
|
373
482
|
confirmedAt: segment.confirmedAt,
|
|
374
483
|
updatedAt: segment.updatedAt,
|
|
375
484
|
},
|
|
@@ -377,7 +486,11 @@ export class CatStore {
|
|
|
377
486
|
}
|
|
378
487
|
|
|
379
488
|
if (target !== undefined) {
|
|
380
|
-
|
|
489
|
+
const value = cleanText(target, '译文')
|
|
490
|
+
// Confirming with the proposal's own text is adoption: keep where it came
|
|
491
|
+
// from. Any other text is authored right here.
|
|
492
|
+
if (value !== segment.target && value !== segment.suggestion) segment.origin = 'manual'
|
|
493
|
+
segment.target = value
|
|
381
494
|
}
|
|
382
495
|
if (!segment.target) {
|
|
383
496
|
throw new CatError('EMPTY_TARGET', '译文为空,无法确认句段。')
|
|
@@ -391,17 +504,25 @@ export class CatStore {
|
|
|
391
504
|
)
|
|
392
505
|
}
|
|
393
506
|
|
|
394
|
-
segment.status = 'confirmed'
|
|
395
507
|
segment.confirmedAt = now()
|
|
396
508
|
segment.updatedAt = segment.confirmedAt
|
|
509
|
+
normalizeSegmentState(segment)
|
|
397
510
|
doc.updatedAt = segment.confirmedAt
|
|
398
511
|
|
|
399
512
|
const tmResult = addToTm
|
|
400
|
-
? this.upsertTM(
|
|
513
|
+
? this.upsertTM(
|
|
514
|
+
segment.source,
|
|
515
|
+
segment.target,
|
|
516
|
+
doc.sourceLang,
|
|
517
|
+
doc.targetLang,
|
|
518
|
+
writeTmId,
|
|
519
|
+
contextOf(doc, segmentIndex),
|
|
520
|
+
)
|
|
401
521
|
: null
|
|
402
522
|
|
|
403
523
|
const nextIndex = this.findNextUnconfirmed(doc, segmentIndex)
|
|
404
524
|
doc.cursor = nextIndex
|
|
525
|
+
this.propagateFromSegment(doc, segmentIndex)
|
|
405
526
|
this.touch()
|
|
406
527
|
try {
|
|
407
528
|
this.persist({ strict: true })
|
|
@@ -415,12 +536,15 @@ export class CatStore {
|
|
|
415
536
|
}
|
|
416
537
|
|
|
417
538
|
const progress = this.progress(doc)
|
|
539
|
+
// Translated-but-unreviewed segments still have to be visited, so the
|
|
540
|
+
// remaining count is driven by confirmed only.
|
|
541
|
+
const remaining = progress.total - progress.confirmed
|
|
418
542
|
return {
|
|
419
543
|
confirmed: this.segmentView(segment, segmentIndex),
|
|
420
|
-
next:
|
|
544
|
+
next: remaining ? this.segmentDetail(doc, doc.cursor) : null,
|
|
421
545
|
cursor: doc.cursor,
|
|
422
546
|
progress,
|
|
423
|
-
done:
|
|
547
|
+
done: remaining === 0,
|
|
424
548
|
tm: tmResult
|
|
425
549
|
? {
|
|
426
550
|
added: tmResult.created ? 1 : 0,
|
|
@@ -473,6 +597,13 @@ export class CatStore {
|
|
|
473
597
|
this.settings.uiLang = uiLang
|
|
474
598
|
}
|
|
475
599
|
|
|
600
|
+
if (patch.preprocess && typeof patch.preprocess === 'object') {
|
|
601
|
+
this.settings.preprocess = normalizePreprocessOptions({
|
|
602
|
+
...this.settings.preprocess,
|
|
603
|
+
...patch.preprocess,
|
|
604
|
+
})
|
|
605
|
+
}
|
|
606
|
+
|
|
476
607
|
if (patch.mt && typeof patch.mt === 'object') {
|
|
477
608
|
const mt = patch.mt
|
|
478
609
|
if (mt.provider !== undefined) {
|
|
@@ -731,4 +862,4 @@ export class CatStore {
|
|
|
731
862
|
}
|
|
732
863
|
}
|
|
733
864
|
|
|
734
|
-
Object.assign(CatStore.prototype, libraryMethods, tmMethods, glossaryMethods, persistenceMethods, importExportMethods)
|
|
865
|
+
Object.assign(CatStore.prototype, libraryMethods, tmMethods, glossaryMethods, persistenceMethods, importExportMethods, preTranslateMethods, qaMethods, libraryViewMethods, propagateMethods, alignMethods, termMethods)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminology candidates, found by counting rather than by asking a model to
|
|
3
|
+
* read a document. Models are good at judging a short list ("is this a domain
|
|
4
|
+
* term? what does it translate to?") and bad at enumerating one: without
|
|
5
|
+
* frequency evidence a long list cannot be reviewed and gets abandoned, which
|
|
6
|
+
* is exactly what happened the first time.
|
|
7
|
+
*
|
|
8
|
+
* So this module only proposes candidates with evidence — occurrences, length,
|
|
9
|
+
* boundary entropy, containment — and the model (or the human) picks.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const STOPWORDS = new Set([
|
|
13
|
+
'a', 'an', 'the', 'and', 'or', 'but', 'if', 'then', 'than', 'that', 'this', 'these', 'those',
|
|
14
|
+
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'to', 'of', 'in', 'on', 'at', 'by', 'for',
|
|
15
|
+
'with', 'from', 'as', 'it', 'its', 'we', 'you', 'they', 'he', 'she', 'i', 'not', 'no', 'do',
|
|
16
|
+
'does', 'did', 'can', 'could', 'will', 'would', 'should', 'may', 'might', 'must', 'have', 'has',
|
|
17
|
+
'had', 'there', 'here', 'when', 'where', 'which', 'who', 'what', 'how', 'all', 'any', 'some',
|
|
18
|
+
'more', 'most', 'other', 'into', 'over', 'under', 'about', 'after', 'before', 'also', 'just',
|
|
19
|
+
'only', 'very', 'so', 'such', 'use', 'used', 'using', 'one', 'two', 'new', 'get', 'set', 'see',
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
const CJK = /[\u3400-\u9fff\u3040-\u30ff\uac00-\ud7af]/
|
|
23
|
+
|
|
24
|
+
const WORD = /[A-Za-z][A-Za-z0-9'’.-]*/g
|
|
25
|
+
|
|
26
|
+
export function splitSentencesForTerms(text) {
|
|
27
|
+
return String(text ?? '')
|
|
28
|
+
.split(/(?<=[.!?。!?])\s+|\n+/)
|
|
29
|
+
.map((line) => line.trim())
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function wordsOf(sentence) {
|
|
34
|
+
return (sentence.match(WORD) || []).map((word) => word.replace(/[.'’-]+$/, ''))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizedTerm(value) {
|
|
38
|
+
return String(value ?? '')
|
|
39
|
+
.replace(/\s+/g, ' ')
|
|
40
|
+
.trim()
|
|
41
|
+
.toLocaleLowerCase()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Latin candidates are word n-grams (1–4) without a stopword at either edge. */
|
|
45
|
+
export function latinCandidates(sentences, options = {}) {
|
|
46
|
+
const maxN = options.maxN ?? 4
|
|
47
|
+
const counts = new Map()
|
|
48
|
+
const examples = new Map()
|
|
49
|
+
const display = new Map()
|
|
50
|
+
for (const sentence of sentences) {
|
|
51
|
+
const words = wordsOf(sentence)
|
|
52
|
+
for (let n = 1; n <= maxN; n += 1) {
|
|
53
|
+
for (let start = 0; start + n <= words.length; start += 1) {
|
|
54
|
+
const slice = words.slice(start, start + n)
|
|
55
|
+
if (STOPWORDS.has(slice[0].toLocaleLowerCase())) continue
|
|
56
|
+
if (STOPWORDS.has(slice[n - 1].toLocaleLowerCase())) continue
|
|
57
|
+
const term = slice.join(' ')
|
|
58
|
+
// A single token has to look like a name or a compound; plain everyday
|
|
59
|
+
// words are noise.
|
|
60
|
+
if (n === 1 && !/^[A-Z][A-Za-z0-9]*$|[-_]|[a-z][A-Z]/.test(slice[0])) continue
|
|
61
|
+
const key = normalizedTerm(term)
|
|
62
|
+
counts.set(key, (counts.get(key) || 0) + 1)
|
|
63
|
+
if (!display.has(key)) display.set(key, term)
|
|
64
|
+
if (!examples.has(key)) examples.set(key, [])
|
|
65
|
+
const bucket = examples.get(key)
|
|
66
|
+
if (bucket.length < 2 && !bucket.includes(sentence)) bucket.push(sentence)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...counts.entries()].map(([key, count]) => ({
|
|
71
|
+
term: display.get(key) ?? key,
|
|
72
|
+
key,
|
|
73
|
+
count,
|
|
74
|
+
kind: 'latin',
|
|
75
|
+
examples: examples.get(key) ?? [],
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function entropy(values) {
|
|
80
|
+
if (!values.length) return 0
|
|
81
|
+
const counts = new Map()
|
|
82
|
+
for (const value of values) counts.set(value, (counts.get(value) || 0) + 1)
|
|
83
|
+
let value = 0
|
|
84
|
+
for (const amount of counts.values()) {
|
|
85
|
+
const p = amount / values.length
|
|
86
|
+
value -= p * Math.log2(p)
|
|
87
|
+
}
|
|
88
|
+
return value
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Chinese, Japanese and Korean have no spaces, so candidates are repeated
|
|
93
|
+
* substrings. Left/right neighbour entropy filters out fragments that are
|
|
94
|
+
* merely the middle of a longer word: a real term appears after many different
|
|
95
|
+
* characters, a fragment appears after the same one.
|
|
96
|
+
*/
|
|
97
|
+
export function cjkCandidates(text, options = {}) {
|
|
98
|
+
const min = options.min ?? 2
|
|
99
|
+
const max = options.max ?? 6
|
|
100
|
+
const limit = options.limit ?? 400
|
|
101
|
+
const body = String(text ?? '').replace(/\s+/g, ' ')
|
|
102
|
+
const counts = new Map()
|
|
103
|
+
const left = new Map()
|
|
104
|
+
const right = new Map()
|
|
105
|
+
for (let start = 0; start < body.length; start += 1) {
|
|
106
|
+
if (!CJK.test(body[start])) continue
|
|
107
|
+
for (let size = min; size <= max; size += 1) {
|
|
108
|
+
const end = start + size
|
|
109
|
+
if (end > body.length) break
|
|
110
|
+
const piece = body.slice(start, end)
|
|
111
|
+
if (!CJK.test(piece[0]) || !CJK.test(piece[size - 1])) continue
|
|
112
|
+
counts.set(piece, (counts.get(piece) || 0) + 1)
|
|
113
|
+
if (!left.has(piece)) left.set(piece, [])
|
|
114
|
+
if (!right.has(piece)) right.set(piece, [])
|
|
115
|
+
left.get(piece).push(body[start - 1] ?? '')
|
|
116
|
+
right.get(piece).push(body[end] ?? '')
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const candidates = []
|
|
120
|
+
for (const [term, count] of counts) {
|
|
121
|
+
if (count < 2) continue
|
|
122
|
+
const boundary = Math.min(entropy(left.get(term) || []), entropy(right.get(term) || []))
|
|
123
|
+
if (boundary < 0.5) continue
|
|
124
|
+
candidates.push({ term, count, kind: 'cjk', boundary, examples: [] })
|
|
125
|
+
}
|
|
126
|
+
return candidates.sort((a, b) => b.count - a.count).slice(0, limit)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* C-value in spirit: frequent, long candidates win, and a string that is only
|
|
131
|
+
* ever contained in a longer candidate loses to that longer one.
|
|
132
|
+
*/
|
|
133
|
+
export function rankCandidates(candidates) {
|
|
134
|
+
for (const item of candidates) {
|
|
135
|
+
let contained = 0
|
|
136
|
+
let longerCount = 0
|
|
137
|
+
for (const other of candidates) {
|
|
138
|
+
if (other === item) continue
|
|
139
|
+
if (other.term.length > item.term.length && other.term.includes(item.term)) {
|
|
140
|
+
contained += 1
|
|
141
|
+
longerCount += other.count
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const lengthBonus = Math.log2(2 + Array.from(item.term).length)
|
|
145
|
+
const cvalue = item.count * lengthBonus - (contained ? longerCount / contained : 0)
|
|
146
|
+
item.score = Math.round(Math.max(0, cvalue) * 1000) / 1000
|
|
147
|
+
}
|
|
148
|
+
// A fragment that only ever shows up inside a longer term is part of that
|
|
149
|
+
// term, not a term of its own ("design" next to "Design System").
|
|
150
|
+
const covered = new Set()
|
|
151
|
+
for (const item of candidates) {
|
|
152
|
+
for (const other of candidates) {
|
|
153
|
+
if (other === item) continue
|
|
154
|
+
if (other.term.length > item.term.length && other.term.includes(item.term) && other.count >= item.count) {
|
|
155
|
+
covered.add(item)
|
|
156
|
+
break
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const seen = new Set()
|
|
161
|
+
const unique = []
|
|
162
|
+
for (const item of candidates.filter((candidate) => !covered.has(candidate)).sort((a, b) => b.score - a.score || b.count - a.count)) {
|
|
163
|
+
// Fold case and a trailing plural: "Mode"/"modes" are one concept.
|
|
164
|
+
const fold = normalizedTerm(item.term).replace(/(\w)s$/, '$1')
|
|
165
|
+
if (seen.has(fold)) continue
|
|
166
|
+
seen.add(fold)
|
|
167
|
+
unique.push(item)
|
|
168
|
+
}
|
|
169
|
+
return unique
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Candidates for one text, annotated against the terms already in memory.
|
|
174
|
+
*/
|
|
175
|
+
export function extractTerms(text, options = {}) {
|
|
176
|
+
const sentences = splitSentencesForTerms(text)
|
|
177
|
+
const latin = latinCandidates(sentences, options)
|
|
178
|
+
const cjk = cjkCandidates(text, options)
|
|
179
|
+
const ranked = rankCandidates([...latin, ...cjk])
|
|
180
|
+
const known = new Set((options.knownTerms ?? []).map(normalizedTerm))
|
|
181
|
+
const limit = options.limit ?? 60
|
|
182
|
+
const minimumCount = options.minCount ?? 2
|
|
183
|
+
return ranked
|
|
184
|
+
.filter((item) => item.count >= minimumCount)
|
|
185
|
+
.map((item) => ({
|
|
186
|
+
...item,
|
|
187
|
+
inLibrary: known.has(normalizedTerm(item.term)),
|
|
188
|
+
}))
|
|
189
|
+
.slice(0, limit)
|
|
190
|
+
}
|