dsh-cait 1.1.3 → 1.1.7
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 +186 -0
- package/README.ja.md +51 -14
- package/README.md +137 -30
- package/README.zh-CN.md +51 -14
- package/client/components.js +2033 -48
- package/client/entry.js +2 -0
- package/client/helpers.js +12 -0
- package/client/locales.js +658 -8
- package/client/styles.js +111 -4
- package/client.js +2814 -58
- package/lib/aligner.js +6 -6
- package/lib/analysis.js +88 -0
- package/lib/api.js +46 -0
- package/lib/brief.js +275 -0
- package/lib/docx.js +155 -26
- package/lib/formats.js +37 -0
- package/lib/glossary.js +20 -4
- package/lib/import-export.js +212 -13
- package/lib/libraries.js +156 -0
- package/lib/library-export.js +7 -1
- package/lib/localization.js +1098 -0
- package/lib/lock.js +30 -0
- package/lib/notes.js +144 -0
- package/lib/persistence.js +54 -7
- package/lib/pptx.js +33 -1
- package/lib/pre-translate.js +62 -45
- package/lib/propagate.js +3 -0
- package/lib/qa.js +94 -4
- package/lib/replace.js +243 -0
- package/lib/review.js +131 -0
- package/lib/segment-edit.js +199 -0
- package/lib/store-utils.js +33 -6
- package/lib/store.js +507 -47
- package/lib/subtitles.js +213 -0
- package/lib/term-extract.js +105 -3
- package/lib/terms.js +4 -2
- package/lib/tm-maintenance.js +157 -0
- package/lib/tm.js +4 -1
- package/lib/tools.js +294 -0
- package/lib/xlsx.js +176 -66
- package/package.json +1 -1
package/lib/aligner.js
CHANGED
|
@@ -362,12 +362,12 @@ export const alignMethods = {
|
|
|
362
362
|
* stored are skipped, and nothing else in the store is touched: no document,
|
|
363
363
|
* no segment status.
|
|
364
364
|
*/
|
|
365
|
-
writeAlignment({ pairs, tmId } = {}) {
|
|
365
|
+
writeAlignment({ pairs, tmId, sourceLang, targetLang } = {}) {
|
|
366
366
|
const list = Array.isArray(pairs) ? pairs : []
|
|
367
367
|
if (!list.length) throw new CatError('ALIGN_EMPTY', '没有可写入的配对。')
|
|
368
368
|
const writeTmId = this.requireMountedTmLibrary(tmId)
|
|
369
|
-
const
|
|
370
|
-
const
|
|
369
|
+
const writeSourceLang = cleanLang(sourceLang || this.settings.sourceLang, this.settings.sourceLang)
|
|
370
|
+
const writeTargetLang = cleanLang(targetLang || this.settings.targetLang, this.settings.targetLang)
|
|
371
371
|
let written = 0
|
|
372
372
|
let updated = 0
|
|
373
373
|
let skipped = 0
|
|
@@ -382,8 +382,8 @@ export const alignMethods = {
|
|
|
382
382
|
const existing = this.tm.find(
|
|
383
383
|
(entry) =>
|
|
384
384
|
entry.tmId === writeTmId &&
|
|
385
|
-
entry.sourceLang ===
|
|
386
|
-
entry.targetLang ===
|
|
385
|
+
entry.sourceLang === writeSourceLang &&
|
|
386
|
+
entry.targetLang === writeTargetLang &&
|
|
387
387
|
(entry.contextHash ?? null) === null &&
|
|
388
388
|
normalizeText(entry.source) === key,
|
|
389
389
|
)
|
|
@@ -391,7 +391,7 @@ export const alignMethods = {
|
|
|
391
391
|
skipped += 1
|
|
392
392
|
continue
|
|
393
393
|
}
|
|
394
|
-
this.upsertTM(sourceText, targetText,
|
|
394
|
+
this.upsertTM(sourceText, targetText, writeSourceLang, writeTargetLang, writeTmId, null)
|
|
395
395
|
if (existing) updated += 1
|
|
396
396
|
else written += 1
|
|
397
397
|
}
|
package/lib/analysis.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { countWords } from './store-utils.js'
|
|
2
|
+
import { normalizeText } from './similarity.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Translation leverage is a quoting aid, not an accounting system. We keep the
|
|
6
|
+
* buckets aligned with the TM match bands the workbench already shows so a
|
|
7
|
+
* project manager can explain every number from the same lookup result.
|
|
8
|
+
*/
|
|
9
|
+
export const MATCH_BUCKETS = ['ice', 'exact', 'fuzzy85', 'fuzzy70', 'new']
|
|
10
|
+
|
|
11
|
+
export function classifyLeverage(match) {
|
|
12
|
+
if (!match) return 'new'
|
|
13
|
+
if (match.contextMatch === 'ice' || match.percent >= 101) return 'ice'
|
|
14
|
+
if (match.exact) return 'exact'
|
|
15
|
+
if (match.percent >= 85) return 'fuzzy85'
|
|
16
|
+
if (match.percent >= 70) return 'fuzzy70'
|
|
17
|
+
return 'new'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const analysisMethods = {
|
|
21
|
+
analyzeDocument({ documentId } = {}) {
|
|
22
|
+
const doc = this.requireDocument(documentId)
|
|
23
|
+
const buckets = { ice: 0, exact: 0, fuzzy85: 0, fuzzy70: 0, new: 0 }
|
|
24
|
+
const duplicateMap = new Map()
|
|
25
|
+
let words = 0
|
|
26
|
+
let characters = 0
|
|
27
|
+
let translated = 0
|
|
28
|
+
let confirmed = 0
|
|
29
|
+
|
|
30
|
+
for (let index = 0; index < doc.segments.length; index += 1) {
|
|
31
|
+
const segment = doc.segments[index]
|
|
32
|
+
const source = String(segment.source ?? '')
|
|
33
|
+
words += countWords(source)
|
|
34
|
+
characters += Array.from(source).length
|
|
35
|
+
if (String(segment.target ?? '').trim()) translated += 1
|
|
36
|
+
if (segment.confirmedAt) confirmed += 1
|
|
37
|
+
const key = normalizeText(source)
|
|
38
|
+
if (key) duplicateMap.set(key, (duplicateMap.get(key) || 0) + 1)
|
|
39
|
+
|
|
40
|
+
const result = this.lookupTM({
|
|
41
|
+
query: source,
|
|
42
|
+
threshold: 0.7,
|
|
43
|
+
limit: 1,
|
|
44
|
+
documentId: doc.id,
|
|
45
|
+
index,
|
|
46
|
+
})
|
|
47
|
+
const bucket = classifyLeverage(result.matches[0])
|
|
48
|
+
buckets[bucket] += 1
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const total = doc.segments.length
|
|
52
|
+
const duplicateGroups = Array.from(duplicateMap.values()).filter((count) => count > 1)
|
|
53
|
+
const duplicateSegments = duplicateGroups.reduce((sum, count) => sum + count, 0)
|
|
54
|
+
const matched = buckets.ice + buckets.exact + buckets.fuzzy85 + buckets.fuzzy70
|
|
55
|
+
const weighted =
|
|
56
|
+
buckets.ice * 1.01 +
|
|
57
|
+
buckets.exact +
|
|
58
|
+
buckets.fuzzy85 * 0.9 +
|
|
59
|
+
buckets.fuzzy70 * 0.75
|
|
60
|
+
const percent = (value) => (total ? Math.round((value / total) * 1000) / 10 : 0)
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
documentId: doc.id,
|
|
64
|
+
name: doc.name,
|
|
65
|
+
sourceLang: doc.sourceLang,
|
|
66
|
+
targetLang: doc.targetLang,
|
|
67
|
+
segments: total,
|
|
68
|
+
words,
|
|
69
|
+
characters,
|
|
70
|
+
translated,
|
|
71
|
+
confirmed,
|
|
72
|
+
duplicates: {
|
|
73
|
+
groups: duplicateGroups.length,
|
|
74
|
+
segments: duplicateSegments,
|
|
75
|
+
},
|
|
76
|
+
buckets,
|
|
77
|
+
matched: {
|
|
78
|
+
segments: matched,
|
|
79
|
+
percent: percent(matched),
|
|
80
|
+
},
|
|
81
|
+
leverage: {
|
|
82
|
+
weighted: total ? Math.round((weighted / total) * 1000) / 10 : 0,
|
|
83
|
+
percent: percent(weighted),
|
|
84
|
+
},
|
|
85
|
+
generatedAt: new Date().toISOString(),
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -10,16 +10,35 @@ const MUTATIONS = new Set([
|
|
|
10
10
|
'openDocument',
|
|
11
11
|
'closeDocument',
|
|
12
12
|
'importFile',
|
|
13
|
+
'applyXliffReview',
|
|
13
14
|
'importTmx',
|
|
14
15
|
'loadDemo',
|
|
15
16
|
'selectSegment',
|
|
16
17
|
'saveDraft',
|
|
18
|
+
'replaceInDocument',
|
|
19
|
+
'undoReplace',
|
|
20
|
+
'addSegmentNote',
|
|
21
|
+
'updateSegmentNote',
|
|
22
|
+
'deleteSegmentNote',
|
|
23
|
+
'setSegmentLocked',
|
|
24
|
+
'mergeSegments',
|
|
25
|
+
'splitSegment',
|
|
26
|
+
'mergeCues',
|
|
27
|
+
'splitCue',
|
|
28
|
+
'shiftSubtitles',
|
|
29
|
+
'clearUnconfirmedTranslations',
|
|
17
30
|
'adoptSuggestion',
|
|
18
31
|
'proposeTranslation',
|
|
19
32
|
'writeAlignment',
|
|
20
33
|
'propagateDocument',
|
|
21
34
|
'addGlossaryEntries',
|
|
22
35
|
'preTranslate',
|
|
36
|
+
'setBrief',
|
|
37
|
+
'setDocumentResources',
|
|
38
|
+
'mergeTmLibraries',
|
|
39
|
+
'dedupeTmLibrary',
|
|
40
|
+
'pruneTmLibrary',
|
|
41
|
+
'unconfirmSegment',
|
|
23
42
|
'confirmSegments',
|
|
24
43
|
'confirmSegment',
|
|
25
44
|
'moveSegment',
|
|
@@ -111,10 +130,25 @@ function createHandlers(store) {
|
|
|
111
130
|
openDocument: (params) => store.openDocument(params),
|
|
112
131
|
closeDocument: (params) => store.closeDocument(params),
|
|
113
132
|
importFile: (params) => store.importFile(params),
|
|
133
|
+
applyXliffReview: (params) => store.applyXliffReview(params),
|
|
114
134
|
importTmx: (params) => store.importTmx(params),
|
|
115
135
|
loadDemo: () => store.loadDemo(),
|
|
116
136
|
selectSegment: (params) => store.selectSegment(params),
|
|
117
137
|
saveDraft: (params) => store.saveDraft(params),
|
|
138
|
+
replaceInDocument: (params) => store.replaceInDocument(params),
|
|
139
|
+
undoReplace: (params) => store.undoReplace(params),
|
|
140
|
+
notesForSegment: (params) => store.notesForSegment(params),
|
|
141
|
+
addSegmentNote: (params) => store.addSegmentNote(params),
|
|
142
|
+
updateSegmentNote: (params) => store.updateSegmentNote(params),
|
|
143
|
+
deleteSegmentNote: (params) => store.deleteSegmentNote(params),
|
|
144
|
+
setSegmentLocked: (params) => store.setSegmentLocked(params),
|
|
145
|
+
mergeSegments: (params) => store.mergeSegments(params),
|
|
146
|
+
splitSegment: (params) => store.splitSegment(params),
|
|
147
|
+
mergeCues: (params) => store.mergeCues(params),
|
|
148
|
+
splitCue: (params) => store.splitCue(params),
|
|
149
|
+
shiftSubtitles: (params) => store.shiftSubtitles(params),
|
|
150
|
+
getClearUnconfirmedPreview: (params) => store.clearUnconfirmedTranslations({ ...params, confirm: false }),
|
|
151
|
+
clearUnconfirmedTranslations: confirmDangerous((params) => store.clearUnconfirmedTranslations({ ...params, confirm: true })),
|
|
118
152
|
adoptSuggestion: (params) => store.adoptSuggestion(params),
|
|
119
153
|
proposeTranslation: (params) => store.proposeTranslation(params),
|
|
120
154
|
writeAlignment: (params) => store.writeAlignment(params),
|
|
@@ -127,6 +161,18 @@ function createHandlers(store) {
|
|
|
127
161
|
preTranslate: (params) => store.preTranslate(params),
|
|
128
162
|
confirmSegments: (params) => store.confirmSegmentsForTool(params),
|
|
129
163
|
getQaReport: (params) => store.getQaReport(params),
|
|
164
|
+
reviewDocument: (params) => store.reviewDocument(params),
|
|
165
|
+
analyzeDocument: (params) => store.analyzeDocument(params),
|
|
166
|
+
tmMaintenancePreview: (params) => store.tmMaintenancePreview(params),
|
|
167
|
+
mergeTmLibraries: confirmDangerous((params) => store.mergeTmLibraries({ ...params, confirm: true })),
|
|
168
|
+
dedupeTmLibrary: confirmDangerous((params) => store.dedupeTmLibrary({ ...params, confirm: true })),
|
|
169
|
+
pruneTmLibrary: confirmDangerous((params) => store.pruneTmLibrary({ ...params, confirm: true })),
|
|
170
|
+
getBrief: (params) => store.getBrief(params),
|
|
171
|
+
setBrief: (params) => store.setBrief(params),
|
|
172
|
+
setDocumentResources: (params) => store.setDocumentResources(params),
|
|
173
|
+
contextForTool: (params) => store.contextForTool(params),
|
|
174
|
+
concordance: (params) => store.concordance(params),
|
|
175
|
+
unconfirmSegment: (params) => store.unconfirmSegment(params),
|
|
130
176
|
confirmSegment: (params) => store.confirmSegment(params),
|
|
131
177
|
moveSegment: (params) => store.moveSegment(params),
|
|
132
178
|
|
package/lib/brief.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { CatError } from './errors.js'
|
|
2
|
+
import { canonicalLanguage, cleanText, clampInt, normalizeSegmentState, now } from './store-utils.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A document brief is deliberately small: the facts a translator would ask
|
|
6
|
+
* before starting. Project-level metadata (client, deadline, billing) belongs
|
|
7
|
+
* to the project layer in 1.1.5, not to every document.
|
|
8
|
+
*/
|
|
9
|
+
export const BRIEF_FIELDS = ['domain', 'audience', 'tone', 'notes', 'doNotTranslate', 'styleSample']
|
|
10
|
+
|
|
11
|
+
const BRIEF_LIMITS = {
|
|
12
|
+
domain: 120,
|
|
13
|
+
audience: 200,
|
|
14
|
+
tone: 200,
|
|
15
|
+
notes: 2000,
|
|
16
|
+
doNotTranslate: 2000,
|
|
17
|
+
styleSample: 4000,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BRIEF_LABELS = {
|
|
21
|
+
domain: 'Domain',
|
|
22
|
+
audience: 'Audience',
|
|
23
|
+
tone: 'Tone',
|
|
24
|
+
notes: 'Notes',
|
|
25
|
+
doNotTranslate: 'Do not translate',
|
|
26
|
+
styleSample: 'Style sample',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cleanBriefValue(field, value) {
|
|
30
|
+
const limit = BRIEF_LIMITS[field]
|
|
31
|
+
if (!limit) return ''
|
|
32
|
+
return String(value ?? '')
|
|
33
|
+
.replace(/\r\n?/g, '\n')
|
|
34
|
+
.trim()
|
|
35
|
+
.slice(0, limit)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Keep only known, non-empty fields. Used for new and restored documents. */
|
|
39
|
+
export function normalizeBrief(value) {
|
|
40
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
|
41
|
+
const brief = {}
|
|
42
|
+
for (const field of BRIEF_FIELDS) {
|
|
43
|
+
const text = cleanBriefValue(field, value[field])
|
|
44
|
+
if (text) brief[field] = text
|
|
45
|
+
}
|
|
46
|
+
if (!Object.keys(brief).length) return null
|
|
47
|
+
if (typeof value.updatedAt === 'string' && value.updatedAt) brief.updatedAt = value.updatedAt
|
|
48
|
+
return brief
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function clip(text, max) {
|
|
52
|
+
const value = String(text ?? '')
|
|
53
|
+
return value.length > max ? `${value.slice(0, max)}…` : value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The prompt block shared by single-segment MT and batch MT. */
|
|
57
|
+
export function briefPromptBlock(brief) {
|
|
58
|
+
if (!brief) return ''
|
|
59
|
+
const rows = []
|
|
60
|
+
for (const field of BRIEF_FIELDS) {
|
|
61
|
+
if (!brief[field]) continue
|
|
62
|
+
rows.push(`- ${BRIEF_LABELS[field]}: ${clip(brief[field], field === 'styleSample' ? 800 : 400)}`)
|
|
63
|
+
}
|
|
64
|
+
return rows.length ? `Document brief:\n${rows.join('\n')}\n` : ''
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Bounded neighbours for prompts and the context tool. */
|
|
68
|
+
export function neighborContext(doc, index, { before = 2, after = 2 } = {}) {
|
|
69
|
+
const total = doc.segments.length
|
|
70
|
+
const safeIndex = clampInt(index, 0, total - 1, 0)
|
|
71
|
+
const beforeCount = clampInt(before, 0, 5, 2)
|
|
72
|
+
const afterCount = clampInt(after, 0, 5, 2)
|
|
73
|
+
const start = Math.max(0, safeIndex - beforeCount)
|
|
74
|
+
const end = Math.min(total, safeIndex + afterCount + 1)
|
|
75
|
+
const view = (segment, at) => ({
|
|
76
|
+
index: at,
|
|
77
|
+
source: segment.source,
|
|
78
|
+
target: segment.target || '',
|
|
79
|
+
status: segment.status,
|
|
80
|
+
})
|
|
81
|
+
return {
|
|
82
|
+
before: doc.segments.slice(start, safeIndex).map((segment, offset) => view(segment, start + offset)),
|
|
83
|
+
after: doc.segments.slice(safeIndex + 1, end).map((segment, offset) => view(segment, safeIndex + 1 + offset)),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function contextPromptBlock(doc, index) {
|
|
88
|
+
const { before, after } = neighborContext(doc, index)
|
|
89
|
+
if (!before.length && !after.length) return ''
|
|
90
|
+
const lines = ['Surrounding segments (for context only, do not translate them):']
|
|
91
|
+
for (const item of before) lines.push(`[previous] ${clip(item.source, 500)}`)
|
|
92
|
+
for (const item of after) lines.push(`[next] ${clip(item.source, 500)}`)
|
|
93
|
+
return `${lines.join('\n')}\n`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const briefMethods = {
|
|
97
|
+
getBrief({ documentId } = {}) {
|
|
98
|
+
const doc = this.requireDocument(documentId)
|
|
99
|
+
return {
|
|
100
|
+
documentId: doc.id,
|
|
101
|
+
brief: doc.brief ? { ...doc.brief } : null,
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Merge a patch into the document brief. Empty strings clear a field; an
|
|
107
|
+
* explicit null or `clear: true` removes the whole brief. A failed strict
|
|
108
|
+
* persist rolls the previous brief back.
|
|
109
|
+
*/
|
|
110
|
+
setBrief({ documentId, brief, clear = false } = {}) {
|
|
111
|
+
const doc = this.requireDocument(documentId)
|
|
112
|
+
const previous = doc.brief
|
|
113
|
+
const previousUpdatedAt = doc.updatedAt
|
|
114
|
+
if (clear === true || brief === null) {
|
|
115
|
+
delete doc.brief
|
|
116
|
+
} else {
|
|
117
|
+
if (!brief || typeof brief !== 'object' || Array.isArray(brief)) {
|
|
118
|
+
throw new CatError('INVALID_BRIEF', 'brief 必须是对象。')
|
|
119
|
+
}
|
|
120
|
+
const next = { ...(doc.brief || {}) }
|
|
121
|
+
for (const field of BRIEF_FIELDS) {
|
|
122
|
+
if (!Object.prototype.hasOwnProperty.call(brief, field)) continue
|
|
123
|
+
const text = cleanBriefValue(field, brief[field])
|
|
124
|
+
if (text) next[field] = text
|
|
125
|
+
else delete next[field]
|
|
126
|
+
}
|
|
127
|
+
delete next.updatedAt
|
|
128
|
+
if (Object.keys(next).length) {
|
|
129
|
+
next.updatedAt = now()
|
|
130
|
+
doc.brief = next
|
|
131
|
+
} else {
|
|
132
|
+
delete doc.brief
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
doc.updatedAt = now()
|
|
136
|
+
this.touch()
|
|
137
|
+
try {
|
|
138
|
+
this.persist({ strict: true })
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (previous === undefined) delete doc.brief
|
|
141
|
+
else doc.brief = previous
|
|
142
|
+
doc.updatedAt = previousUpdatedAt
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
return { documentId: doc.id, brief: doc.brief ? { ...doc.brief } : null }
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Everything an agent needs before translating or reviewing one segment:
|
|
150
|
+
* the brief, the segment, and a bounded window of its neighbours.
|
|
151
|
+
*/
|
|
152
|
+
contextForTool({ documentId, index, before = 2, after = 2 } = {}) {
|
|
153
|
+
const doc = this.requireDocument(documentId)
|
|
154
|
+
const at = index === undefined || index === null ? doc.cursor : index
|
|
155
|
+
if (!Number.isInteger(at)) throw new CatError('SEGMENT_OUT_OF_RANGE', `句段索引不是整数: ${at}`)
|
|
156
|
+
const segment = this.findSegment(doc, at)
|
|
157
|
+
const window = neighborContext(doc, at, { before, after })
|
|
158
|
+
const paragraphCount =
|
|
159
|
+
doc.segments.reduce(
|
|
160
|
+
(max, item) => Math.max(max, Number.isInteger(item.paragraph) ? item.paragraph : 0),
|
|
161
|
+
0,
|
|
162
|
+
) + 1
|
|
163
|
+
return {
|
|
164
|
+
document: {
|
|
165
|
+
id: doc.id,
|
|
166
|
+
name: doc.name,
|
|
167
|
+
sourceLang: doc.sourceLang,
|
|
168
|
+
targetLang: doc.targetLang,
|
|
169
|
+
total: doc.segments.length,
|
|
170
|
+
cursor: doc.cursor,
|
|
171
|
+
},
|
|
172
|
+
paragraph: {
|
|
173
|
+
index: Number.isInteger(segment.paragraph) ? segment.paragraph : 0,
|
|
174
|
+
count: paragraphCount,
|
|
175
|
+
// Paragraph headings are ordinary segments in CAIT; showing the
|
|
176
|
+
// document name is the one stable title every format provides.
|
|
177
|
+
title: doc.name,
|
|
178
|
+
},
|
|
179
|
+
brief: doc.brief ? { ...doc.brief } : null,
|
|
180
|
+
segment: {
|
|
181
|
+
index: at,
|
|
182
|
+
uid: segment.uid,
|
|
183
|
+
id: segment.id,
|
|
184
|
+
source: segment.source,
|
|
185
|
+
target: segment.target || '',
|
|
186
|
+
status: segment.status,
|
|
187
|
+
origin: segment.origin,
|
|
188
|
+
paragraph: segment.paragraph,
|
|
189
|
+
},
|
|
190
|
+
before: window.before,
|
|
191
|
+
after: window.after,
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/** Substring search over every mounted TM entry, not just the active pair. */
|
|
196
|
+
concordance({ query, sourceLang, targetLang, limit = 20, documentId } = {}) {
|
|
197
|
+
const text = String(query ?? '').trim()
|
|
198
|
+
if (!text) throw new CatError('EMPTY_QUERY', '请输入要检索的词或短语。')
|
|
199
|
+
if (text.length > 200) throw new CatError('QUERY_TOO_LONG', '检索词最多 200 个字符。')
|
|
200
|
+
const doc = documentId ? this.requireDocument(documentId) : null
|
|
201
|
+
const src = sourceLang ? canonicalLanguage(sourceLang) : doc?.sourceLang || ''
|
|
202
|
+
const tgt = targetLang ? canonicalLanguage(targetLang) : doc?.targetLang || ''
|
|
203
|
+
const mounted = new Set(this.mounts.tmIds)
|
|
204
|
+
const needle = text.toLowerCase()
|
|
205
|
+
const max = clampInt(limit, 1, 50, 20)
|
|
206
|
+
const matches = []
|
|
207
|
+
for (const entry of this.tm) {
|
|
208
|
+
if (!mounted.has(entry.tmId)) continue
|
|
209
|
+
if (src && entry.sourceLang !== src) continue
|
|
210
|
+
if (tgt && entry.targetLang !== tgt) continue
|
|
211
|
+
if (!entry.source.toLowerCase().includes(needle) && !entry.target.toLowerCase().includes(needle)) {
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
matches.push(entry)
|
|
215
|
+
}
|
|
216
|
+
matches.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
|
217
|
+
const libraries = new Map(this.tmLibraries.map((library) => [library.tmId, library.name]))
|
|
218
|
+
return {
|
|
219
|
+
query: text,
|
|
220
|
+
total: matches.length,
|
|
221
|
+
entries: matches.slice(0, max).map((entry) => ({
|
|
222
|
+
source: entry.source,
|
|
223
|
+
target: entry.target,
|
|
224
|
+
sourceLang: entry.sourceLang,
|
|
225
|
+
targetLang: entry.targetLang,
|
|
226
|
+
context: entry.context || null,
|
|
227
|
+
tmId: entry.tmId,
|
|
228
|
+
library: libraries.get(entry.tmId) || entry.tmId,
|
|
229
|
+
updatedAt: entry.updatedAt,
|
|
230
|
+
})),
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Revoke a confirmation without touching TM. TM is a reusable asset, and
|
|
236
|
+
* mainstream CAT tools also keep it when a segment is unconfirmed; a later
|
|
237
|
+
* confirmation updates the same entry.
|
|
238
|
+
*/
|
|
239
|
+
unconfirmSegment({ documentId, index } = {}) {
|
|
240
|
+
const doc = this.requireDocument(documentId)
|
|
241
|
+
const at = index === undefined || index === null ? doc.cursor : index
|
|
242
|
+
if (!Number.isInteger(at)) throw new CatError('SEGMENT_OUT_OF_RANGE', `句段索引不是整数: ${at}`)
|
|
243
|
+
const segment = this.findSegment(doc, at)
|
|
244
|
+
if (segment.locked) throw new CatError('SEGMENT_LOCKED', '该句段已锁定,请先解锁再撤销确认。')
|
|
245
|
+
// Older stores may carry status=confirmed without a confirmedAt stamp.
|
|
246
|
+
if (segment.status !== 'confirmed' && !segment.confirmedAt) {
|
|
247
|
+
throw new CatError('NOT_CONFIRMED', '该句段还没有确认,不能撤销确认。', 409)
|
|
248
|
+
}
|
|
249
|
+
const previous = {
|
|
250
|
+
confirmedAt: segment.confirmedAt,
|
|
251
|
+
origin: segment.origin,
|
|
252
|
+
status: segment.status,
|
|
253
|
+
updatedAt: segment.updatedAt,
|
|
254
|
+
}
|
|
255
|
+
segment.confirmedAt = null
|
|
256
|
+
if (!segment.origin) segment.origin = 'manual'
|
|
257
|
+
segment.updatedAt = now()
|
|
258
|
+
normalizeSegmentState(segment)
|
|
259
|
+
doc.cursor = at
|
|
260
|
+
doc.updatedAt = now()
|
|
261
|
+
this.touch()
|
|
262
|
+
try {
|
|
263
|
+
this.persist({ strict: true })
|
|
264
|
+
} catch (error) {
|
|
265
|
+
Object.assign(segment, previous)
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
segment: this.segmentView(segment, at),
|
|
270
|
+
progress: this.progress(doc),
|
|
271
|
+
documentId: doc.id,
|
|
272
|
+
index: at,
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
}
|