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.
@@ -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 mountedGlossary = new Set(this.mounts.glossaryIds)
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
  })