dsh-cait 1.1.3 → 1.1.6
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 +143 -0
- package/README.ja.md +34 -7
- package/README.md +169 -81
- package/README.zh-CN.md +34 -7
- package/client/components.js +1934 -62
- package/client/entry.js +2 -0
- package/client/helpers.js +12 -0
- package/client/locales.js +584 -6
- package/client/styles.js +105 -2
- package/client.js +2635 -68
- package/lib/analysis.js +88 -0
- package/lib/api.js +40 -0
- package/lib/brief.js +275 -0
- package/lib/docx.js +31 -0
- package/lib/formats.js +37 -0
- package/lib/glossary.js +20 -4
- package/lib/import-export.js +97 -3
- package/lib/libraries.js +156 -0
- package/lib/library-export.js +7 -1
- package/lib/lock.js +30 -0
- package/lib/notes.js +144 -0
- package/lib/persistence.js +40 -7
- package/lib/pre-translate.js +62 -45
- package/lib/propagate.js +3 -0
- package/lib/qa.js +76 -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 +26 -4
- package/lib/store.js +494 -46
- 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/package.json +1 -1
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,32 @@ 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
|
+
'clearUnconfirmedTranslations',
|
|
17
27
|
'adoptSuggestion',
|
|
18
28
|
'proposeTranslation',
|
|
19
29
|
'writeAlignment',
|
|
20
30
|
'propagateDocument',
|
|
21
31
|
'addGlossaryEntries',
|
|
22
32
|
'preTranslate',
|
|
33
|
+
'setBrief',
|
|
34
|
+
'setDocumentResources',
|
|
35
|
+
'mergeTmLibraries',
|
|
36
|
+
'dedupeTmLibrary',
|
|
37
|
+
'pruneTmLibrary',
|
|
38
|
+
'unconfirmSegment',
|
|
23
39
|
'confirmSegments',
|
|
24
40
|
'confirmSegment',
|
|
25
41
|
'moveSegment',
|
|
@@ -111,10 +127,22 @@ function createHandlers(store) {
|
|
|
111
127
|
openDocument: (params) => store.openDocument(params),
|
|
112
128
|
closeDocument: (params) => store.closeDocument(params),
|
|
113
129
|
importFile: (params) => store.importFile(params),
|
|
130
|
+
applyXliffReview: (params) => store.applyXliffReview(params),
|
|
114
131
|
importTmx: (params) => store.importTmx(params),
|
|
115
132
|
loadDemo: () => store.loadDemo(),
|
|
116
133
|
selectSegment: (params) => store.selectSegment(params),
|
|
117
134
|
saveDraft: (params) => store.saveDraft(params),
|
|
135
|
+
replaceInDocument: (params) => store.replaceInDocument(params),
|
|
136
|
+
undoReplace: (params) => store.undoReplace(params),
|
|
137
|
+
notesForSegment: (params) => store.notesForSegment(params),
|
|
138
|
+
addSegmentNote: (params) => store.addSegmentNote(params),
|
|
139
|
+
updateSegmentNote: (params) => store.updateSegmentNote(params),
|
|
140
|
+
deleteSegmentNote: (params) => store.deleteSegmentNote(params),
|
|
141
|
+
setSegmentLocked: (params) => store.setSegmentLocked(params),
|
|
142
|
+
mergeSegments: (params) => store.mergeSegments(params),
|
|
143
|
+
splitSegment: (params) => store.splitSegment(params),
|
|
144
|
+
getClearUnconfirmedPreview: (params) => store.clearUnconfirmedTranslations({ ...params, confirm: false }),
|
|
145
|
+
clearUnconfirmedTranslations: confirmDangerous((params) => store.clearUnconfirmedTranslations({ ...params, confirm: true })),
|
|
118
146
|
adoptSuggestion: (params) => store.adoptSuggestion(params),
|
|
119
147
|
proposeTranslation: (params) => store.proposeTranslation(params),
|
|
120
148
|
writeAlignment: (params) => store.writeAlignment(params),
|
|
@@ -127,6 +155,18 @@ function createHandlers(store) {
|
|
|
127
155
|
preTranslate: (params) => store.preTranslate(params),
|
|
128
156
|
confirmSegments: (params) => store.confirmSegmentsForTool(params),
|
|
129
157
|
getQaReport: (params) => store.getQaReport(params),
|
|
158
|
+
reviewDocument: (params) => store.reviewDocument(params),
|
|
159
|
+
analyzeDocument: (params) => store.analyzeDocument(params),
|
|
160
|
+
tmMaintenancePreview: (params) => store.tmMaintenancePreview(params),
|
|
161
|
+
mergeTmLibraries: confirmDangerous((params) => store.mergeTmLibraries({ ...params, confirm: true })),
|
|
162
|
+
dedupeTmLibrary: confirmDangerous((params) => store.dedupeTmLibrary({ ...params, confirm: true })),
|
|
163
|
+
pruneTmLibrary: confirmDangerous((params) => store.pruneTmLibrary({ ...params, confirm: true })),
|
|
164
|
+
getBrief: (params) => store.getBrief(params),
|
|
165
|
+
setBrief: (params) => store.setBrief(params),
|
|
166
|
+
setDocumentResources: (params) => store.setDocumentResources(params),
|
|
167
|
+
contextForTool: (params) => store.contextForTool(params),
|
|
168
|
+
concordance: (params) => store.concordance(params),
|
|
169
|
+
unconfirmSegment: (params) => store.unconfirmSegment(params),
|
|
130
170
|
confirmSegment: (params) => store.confirmSegment(params),
|
|
131
171
|
moveSegment: (params) => store.moveSegment(params),
|
|
132
172
|
|
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
|
+
}
|
package/lib/docx.js
CHANGED
|
@@ -389,4 +389,35 @@ export function buildDocxFromText(paragraphs) {
|
|
|
389
389
|
])
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
export function buildBilingualDocx(segments, { includeStatus = false, title = '' } = {}) {
|
|
393
|
+
const columns = includeStatus ? 3 : 2
|
|
394
|
+
const width = Math.floor(9000 / columns)
|
|
395
|
+
const cell = (text, shaded = false) => {
|
|
396
|
+
const paragraphs = String(text ?? '')
|
|
397
|
+
.split(/\r?\n/)
|
|
398
|
+
.map((line) => `<w:p><w:r><w:t xml:space="preserve">${escapeXml(line)}</w:t></w:r></w:p>`)
|
|
399
|
+
.join('')
|
|
400
|
+
const shade = shaded ? '<w:shd w:val="clear" w:color="auto" w:fill="F2F2F2"/>' : ''
|
|
401
|
+
return `<w:tc><w:tcPr><w:tcW w:w="${width}" w:type="dxa"/>${shade}</w:tcPr>${paragraphs}</w:tc>`
|
|
402
|
+
}
|
|
403
|
+
const header = `<w:tr><w:trPr><w:tblHeader/></w:trPr>${cell('Source', true)}${cell('Target', true)}${includeStatus ? cell('Status', true) : ''}</w:tr>`
|
|
404
|
+
const body = segments
|
|
405
|
+
.map(
|
|
406
|
+
(segment) =>
|
|
407
|
+
`<w:tr>${cell(segment.source)}${cell(segment.target || '')}${includeStatus ? cell(segment.status || '') : ''}</w:tr>`,
|
|
408
|
+
)
|
|
409
|
+
.join('')
|
|
410
|
+
const titleXml = title
|
|
411
|
+
? `<w:p><w:r><w:rPr><w:b/><w:sz w:val="28"/></w:rPr><w:t xml:space="preserve">${escapeXml(title)}</w:t></w:r></w:p>`
|
|
412
|
+
: ''
|
|
413
|
+
const document = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${titleXml}<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4"/><w:left w:val="single" w:sz="4"/><w:bottom w:val="single" w:sz="4"/><w:right w:val="single" w:sz="4"/><w:insideH w:val="single" w:sz="4"/><w:insideV w:val="single" w:sz="4"/></w:tblBorders></w:tblPr><w:tblGrid>${Array.from({ length: columns }, () => `<w:gridCol w:w="${width}"/>`).join('')}</w:tblGrid>${header}${body}</w:tbl><w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
|
|
414
|
+
const contentTypes = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`
|
|
415
|
+
const rels = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`
|
|
416
|
+
return writeZip([
|
|
417
|
+
{ name: '[Content_Types].xml', data: Buffer.from(contentTypes, 'utf8') },
|
|
418
|
+
{ name: '_rels/.rels', data: Buffer.from(rels, 'utf8') },
|
|
419
|
+
{ name: 'word/document.xml', data: Buffer.from(document, 'utf8') },
|
|
420
|
+
])
|
|
421
|
+
}
|
|
422
|
+
|
|
392
423
|
export { readZipEntries, writeZip }
|
package/lib/formats.js
CHANGED
|
@@ -577,6 +577,9 @@ function parseXliffUnit(body, attrs, format, tokens) {
|
|
|
577
577
|
const targetMatch = body.match(/<(?:[\w.-]+:)?target\b[^>]*>([\s\S]*?)<\/(?:[\w.-]+:)?target>/i)
|
|
578
578
|
?? body.match(/<(?:[\w.-]+:)?target\b[^>]*\/>/i)
|
|
579
579
|
const target = targetMatch ? extractInline(targetMatch[1] ?? '').text : ''
|
|
580
|
+
const targetTag = body.match(/<(?:[\w.-]+:)?target\b([^>]*)>/i)
|
|
581
|
+
const targetState = targetTag?.[1] ? readXmlAttr(targetTag[1], 'state') : ''
|
|
582
|
+
const caitUid = readXmlAttr(attrs, 'x-cait-uid')
|
|
580
583
|
tokens.push(...source.tokens)
|
|
581
584
|
return {
|
|
582
585
|
source: source.text,
|
|
@@ -585,6 +588,8 @@ function parseXliffUnit(body, attrs, format, tokens) {
|
|
|
585
588
|
meta: {
|
|
586
589
|
format,
|
|
587
590
|
xliffId: readXmlAttr(attrs, 'id'),
|
|
591
|
+
...(caitUid ? { caitUid } : {}),
|
|
592
|
+
...(targetState ? { targetState } : {}),
|
|
588
593
|
...(source.tokens.length ? { inline: source.tokens } : {}),
|
|
589
594
|
},
|
|
590
595
|
}
|
|
@@ -661,6 +666,38 @@ export function buildXliff(doc) {
|
|
|
661
666
|
].join('\n')
|
|
662
667
|
}
|
|
663
668
|
|
|
669
|
+
export function buildXliffReview(doc) {
|
|
670
|
+
const units = doc.segments.map((segment, index) => {
|
|
671
|
+
const id = String(segment.uid || segment.meta?.xliffId || index + 1)
|
|
672
|
+
const tokens = Array.isArray(segment.meta?.inline) ? segment.meta.inline : []
|
|
673
|
+
const target = completeInline(segment.target || '', tokens)
|
|
674
|
+
const state = segment.confirmedAt ? 'final' : segment.target ? 'translated' : 'new'
|
|
675
|
+
const note = segment.confirmedAt
|
|
676
|
+
? 'confirmed'
|
|
677
|
+
: segment.origin
|
|
678
|
+
? `origin: ${segment.origin}`
|
|
679
|
+
: ''
|
|
680
|
+
return [
|
|
681
|
+
` <trans-unit id="${escapeXml(id)}" x-cait-uid="${escapeXml(segment.uid || id)}">`,
|
|
682
|
+
` <source xml:lang="${escapeXml(doc.sourceLang)}">${renderInline(segment.source, tokens)}</source>`,
|
|
683
|
+
` <target xml:lang="${escapeXml(doc.targetLang)}" state="${state}">${renderInline(target, tokens)}</target>`,
|
|
684
|
+
...(note ? [` <note from="cait">${escapeXml(note)}</note>`] : []),
|
|
685
|
+
' </trans-unit>',
|
|
686
|
+
].join('\n')
|
|
687
|
+
})
|
|
688
|
+
return [
|
|
689
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
690
|
+
'<xliff version="1.2">',
|
|
691
|
+
` <file source-language="${escapeXml(doc.sourceLang)}" target-language="${escapeXml(doc.targetLang)}" datatype="plaintext" original="${escapeXml(doc.name)}">`,
|
|
692
|
+
' <body>',
|
|
693
|
+
...units,
|
|
694
|
+
' </body>',
|
|
695
|
+
' </file>',
|
|
696
|
+
'</xliff>',
|
|
697
|
+
'',
|
|
698
|
+
].join('\n')
|
|
699
|
+
}
|
|
700
|
+
|
|
664
701
|
export function buildXliff2(doc) {
|
|
665
702
|
const units = doc.segments.map((segment, index) => {
|
|
666
703
|
const id = String(segment.meta?.xliffId || index + 1)
|
package/lib/glossary.js
CHANGED
|
@@ -3,13 +3,16 @@ import { DEFAULT_GLOSSARY_ID, DEFAULT_GLOSSARY_NAME, MAX_SEGMENTS, canonicalLang
|
|
|
3
3
|
import { normalizeText } from './similarity.js'
|
|
4
4
|
|
|
5
5
|
export const glossaryMethods = {
|
|
6
|
-
findGlossaryHits(text, sourceLang) {
|
|
6
|
+
findGlossaryHits(text, sourceLang, documentId) {
|
|
7
7
|
const haystack = String(text ?? '')
|
|
8
8
|
if (!haystack) return []
|
|
9
9
|
const lower = haystack.toLowerCase()
|
|
10
10
|
const lang = sourceLang ? canonicalLanguage(sourceLang) : ''
|
|
11
11
|
const hits = []
|
|
12
|
-
const
|
|
12
|
+
const scopedDoc = documentId ? this.requireDocument(documentId) : null
|
|
13
|
+
const mountedGlossary = new Set(
|
|
14
|
+
scopedDoc ? this.effectiveGlossaryIds(scopedDoc) : this.mounts.glossaryIds,
|
|
15
|
+
)
|
|
13
16
|
|
|
14
17
|
for (const entry of this.glossary) {
|
|
15
18
|
if (!mountedGlossary.has(entry.glossaryId || DEFAULT_GLOSSARY_ID)) continue
|
|
@@ -29,6 +32,8 @@ export const glossaryMethods = {
|
|
|
29
32
|
term: entry.source,
|
|
30
33
|
target: entry.target,
|
|
31
34
|
note: entry.note || '',
|
|
35
|
+
forbidden: Boolean(entry.forbidden),
|
|
36
|
+
preferred: Boolean(entry.preferred),
|
|
32
37
|
count,
|
|
33
38
|
})
|
|
34
39
|
}
|
|
@@ -36,7 +41,7 @@ export const glossaryMethods = {
|
|
|
36
41
|
return hits.sort((a, b) => b.term.length - a.term.length)
|
|
37
42
|
}
|
|
38
43
|
,
|
|
39
|
-
addGlossary({ source, target, note, sourceLang, caseSensitive, glossaryId } = {}) {
|
|
44
|
+
addGlossary({ source, target, note, sourceLang, caseSensitive, forbidden, preferred, glossaryId } = {}) {
|
|
40
45
|
const term = cleanText(source, '术语')
|
|
41
46
|
const translation = cleanText(target, '术语译文')
|
|
42
47
|
if (!term || !translation) throw new CatError('EMPTY_TERM', '术语和译文都不能为空。')
|
|
@@ -53,6 +58,8 @@ export const glossaryMethods = {
|
|
|
53
58
|
existing.target = translation
|
|
54
59
|
existing.note = String(note ?? '').trim()
|
|
55
60
|
existing.caseSensitive = Boolean(caseSensitive)
|
|
61
|
+
existing.forbidden = Boolean(forbidden)
|
|
62
|
+
existing.preferred = Boolean(preferred)
|
|
56
63
|
existing.updatedAt = now()
|
|
57
64
|
this.touch()
|
|
58
65
|
this.persist()
|
|
@@ -66,6 +73,8 @@ export const glossaryMethods = {
|
|
|
66
73
|
note: String(note ?? '').trim(),
|
|
67
74
|
sourceLang: lang,
|
|
68
75
|
caseSensitive: Boolean(caseSensitive),
|
|
76
|
+
forbidden: Boolean(forbidden),
|
|
77
|
+
preferred: Boolean(preferred),
|
|
69
78
|
createdAt: now(),
|
|
70
79
|
updatedAt: now(),
|
|
71
80
|
}
|
|
@@ -75,7 +84,7 @@ export const glossaryMethods = {
|
|
|
75
84
|
return { entry: { ...entry }, created: true }
|
|
76
85
|
}
|
|
77
86
|
,
|
|
78
|
-
updateGlossary({ id, source, target, note, caseSensitive } = {}) {
|
|
87
|
+
updateGlossary({ id, source, target, note, caseSensitive, forbidden, preferred } = {}) {
|
|
79
88
|
const entry = this.glossary.find((item) => item.id === id)
|
|
80
89
|
if (!entry) throw new CatError('TERM_NOT_FOUND', '术语不存在。', 404)
|
|
81
90
|
// The library page manages every library, mounted or not; only the target
|
|
@@ -93,6 +102,9 @@ export const glossaryMethods = {
|
|
|
93
102
|
}
|
|
94
103
|
if (note !== undefined) entry.note = String(note ?? '').trim()
|
|
95
104
|
if (caseSensitive !== undefined) entry.caseSensitive = Boolean(caseSensitive)
|
|
105
|
+
if (forbidden !== undefined) entry.forbidden = Boolean(forbidden)
|
|
106
|
+
if (preferred !== undefined) entry.preferred = Boolean(preferred)
|
|
107
|
+
if (entry.forbidden) entry.preferred = false
|
|
96
108
|
entry.updatedAt = now()
|
|
97
109
|
this.touch()
|
|
98
110
|
this.persist()
|
|
@@ -129,6 +141,8 @@ export const glossaryMethods = {
|
|
|
129
141
|
if (existing) {
|
|
130
142
|
existing.target = String(item.target).trim()
|
|
131
143
|
existing.note = String(item.note ?? '').trim()
|
|
144
|
+
existing.forbidden = Boolean(item.forbidden)
|
|
145
|
+
existing.preferred = Boolean(item.preferred) && !existing.forbidden
|
|
132
146
|
existing.updatedAt = now()
|
|
133
147
|
updated += 1
|
|
134
148
|
} else {
|
|
@@ -140,6 +154,8 @@ export const glossaryMethods = {
|
|
|
140
154
|
note: String(item.note ?? '').trim(),
|
|
141
155
|
sourceLang: lang,
|
|
142
156
|
caseSensitive: Boolean(item.caseSensitive),
|
|
157
|
+
forbidden: Boolean(item.forbidden),
|
|
158
|
+
preferred: Boolean(item.preferred) && !item.forbidden,
|
|
143
159
|
createdAt: now(),
|
|
144
160
|
updatedAt: now(),
|
|
145
161
|
})
|