dsh-cait 1.0.3 → 1.0.4

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.
@@ -1,605 +1,605 @@
1
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { CatError } from './errors.js'
4
- import { MAX_DOCX_BASE64, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, canonicalLanguage, cleanLang, cleanName, cleanText, csvCell, makeId, mdCell, now, safeBaseName } from './store-utils.js'
5
- import { stripMarkdown } from './markdown.js'
6
- import { DOCX_MIME, buildDocx, buildDocxFromText, parseDocxDetailed } from './docx.js'
7
- import { PPTX_MIME, buildPptx, parsePptxDetailed } from './pptx.js'
8
- import { XLSX_MIME, buildXlsx, parseXlsx } from './xlsx.js'
9
- import { buildTmx, parseTmx } from './tmx.js'
10
- import { buildHtml, buildJson, buildPo, buildRtf, buildSrt, buildVtt, buildXliff, buildXliff2, parseCsv, parseHtml, parseJson, parsePo, parseRtf, parseSrt, parseVtt, parseXliff } from './formats.js'
11
- import { decodeXml } from './xml.js'
12
-
13
- export const importExportMethods = {
14
- /**
15
- * Keep the original DOCX package so exporting can patch translated text into
16
- * the source layout. Persisted stores use a small sidecar file; transient
17
- * in-memory stores keep the bytes in the document object only.
18
- */
19
- attachDocxSource(doc, buffer, name = 'source.docx') {
20
- if (!Buffer.isBuffer(buffer) || !buffer.length) return
21
- const fileName = cleanName(name, 'source.docx')
22
- if (this.dataDir) {
23
- const asset = `sources/${doc.id}.docx`
24
- try {
25
- mkdirSync(join(this.dataDir, 'sources'), { recursive: true })
26
- writeFileSync(join(this.dataDir, asset), buffer)
27
- } catch (error) {
28
- throw new CatError(
29
- 'DOCX_SOURCE_WRITE_FAILED',
30
- `无法保存 DOCX 源文件,无法保留源格式: ${error.message}`,
31
- 500,
32
- )
33
- }
34
- doc.sourceDocx = { kind: 'file', asset, name: fileName }
35
- return
36
- }
37
- doc.sourceDocx = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
38
- },
39
-
40
- removeDocxSource(doc) {
41
- const source = doc?.sourceDocx
42
- if (!source) return
43
- if (source.kind === 'file' && this.dataDir) {
44
- try {
45
- unlinkSync(join(this.dataDir, source.asset))
46
- } catch (error) {
47
- if (error?.code !== 'ENOENT') {
48
- this.logger?.warn?.(`CAIT: 删除 DOCX 源文件失败: ${error.message}`)
49
- }
50
- }
51
- }
52
- delete doc.sourceDocx
53
- },
54
-
55
- readDocxSource(doc) {
56
- const source = doc?.sourceDocx
57
- if (!source) return null
58
- try {
59
- if (source.kind === 'base64' && source.data) {
60
- return Buffer.from(source.data, 'base64')
61
- }
62
- if (source.kind === 'file' && this.dataDir && source.asset) {
63
- return readFileSync(join(this.dataDir, source.asset))
64
- }
65
- } catch (error) {
66
- this.logger?.warn?.(`CAIT: 读取 DOCX 源文件失败: ${error.message}`)
67
- }
68
- return null
69
- },
70
-
71
- attachPptxSource(doc, buffer, name = 'source.pptx') {
72
- if (!Buffer.isBuffer(buffer) || !buffer.length) return
73
- const fileName = cleanName(name, 'source.pptx')
74
- if (this.dataDir) {
75
- const asset = `sources/${doc.id}.pptx`
76
- try {
77
- mkdirSync(join(this.dataDir, 'sources'), { recursive: true })
78
- writeFileSync(join(this.dataDir, asset), buffer)
79
- } catch (error) {
80
- throw new CatError(
81
- 'PPTX_SOURCE_WRITE_FAILED',
82
- `无法保存 PPTX 源文件,无法保留源格式: ${error.message}`,
83
- 500,
84
- )
85
- }
86
- doc.sourcePptx = { kind: 'file', asset, name: fileName }
87
- return
88
- }
89
- doc.sourcePptx = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
90
- },
91
-
92
- removePptxSource(doc) {
93
- const source = doc?.sourcePptx
94
- if (!source) return
95
- if (source.kind === 'file' && this.dataDir) {
96
- try {
97
- unlinkSync(join(this.dataDir, source.asset))
98
- } catch (error) {
99
- if (error?.code !== 'ENOENT') {
100
- this.logger?.warn?.(`CAIT: 删除 PPTX 源文件失败: ${error.message}`)
101
- }
102
- }
103
- }
104
- delete doc.sourcePptx
105
- },
106
-
107
- readPptxSource(doc) {
108
- const source = doc?.sourcePptx
109
- if (!source) return null
110
- try {
111
- if (source.kind === 'base64' && source.data) {
112
- return Buffer.from(source.data, 'base64')
113
- }
114
- if (source.kind === 'file' && this.dataDir && source.asset) {
115
- return readFileSync(join(this.dataDir, source.asset))
116
- }
117
- } catch (error) {
118
- this.logger?.warn?.(`CAIT: 读取 PPTX 源文件失败: ${error.message}`)
119
- }
120
- return null
121
- },
122
-
123
- importTmx({ text, name, sourceLang, targetLang, importAs = 'tm', tmId } = {}) {
124
- if (!String(text ?? '').trim()) throw new CatError('EMPTY_FILE', 'TMX 内容为空。')
125
- const parsed = parseTmx(text)
126
- const src = cleanLang(
127
- sourceLang || parsed.sourceLang || this.settings.sourceLang,
128
- this.settings.sourceLang,
129
- )
130
- const detectedTarget = parsed.units
131
- .flat()
132
- .map((variant) => variant.lang)
133
- .find((lang) => lang && lang !== src)
134
- const tgt = cleanLang(
135
- targetLang || detectedTarget || this.settings.targetLang,
136
- this.settings.targetLang,
137
- )
138
- const pairs = []
139
- const segments = []
140
-
141
- for (const variants of parsed.units) {
142
- const source =
143
- variants.find((variant) => variant.lang === src) ??
144
- variants.find((variant) => variant.lang !== tgt) ??
145
- variants[0]
146
- if (!source?.text) continue
147
- const target = variants.find((variant) => variant.lang === tgt)
148
- pairs.push({ source: source.text, target: target?.text || '', sourceLang: src, targetLang: tgt })
149
- segments.push({ source: source.text, target: target?.text || '', paragraph: segments.length })
150
- }
151
-
152
- if (!pairs.length) throw new CatError('TMX_NO_UNITS', 'TMX 中没有可用的翻译单元。')
153
-
154
- if (importAs === 'document') {
155
- const created = this.createDocument({
156
- name: cleanName(name, 'TMX 导入'),
157
- sourceLang: src,
158
- targetLang: tgt,
159
- segments,
160
- format: 'tmx',
161
- })
162
- return {
163
- ...created,
164
- sourceLang: src,
165
- targetLang: tgt,
166
- units: pairs.length,
167
- warnings: parsed.warnings,
168
- }
169
- }
170
-
171
- const imported = this.importTM({ pairs, sourceLang: src, targetLang: tgt, tmId })
172
- return {
173
- ...imported,
174
- sourceLang: src,
175
- targetLang: tgt,
176
- units: pairs.length,
177
- warnings: parsed.warnings,
178
- }
179
- }
180
- ,
181
- importFile({ name, text, base64, sourceLang, targetLang, importAs, csvSourceColumn, csvTargetColumn } = {}) {
182
- const fileName = cleanName(name, '导入文档')
183
- const extension = (fileName.match(/\.([a-z0-9]+)$/i)?.[1] ?? '').toLowerCase()
184
-
185
- if (extension === 'docx') {
186
- if (!base64) throw new CatError('EMPTY_FILE', 'DOCX 文件内容为空。')
187
- if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', 'DOCX 文件过大。')
188
- const buffer = Buffer.from(base64, 'base64')
189
- let content
190
- try {
191
- content = parseDocxDetailed(buffer)
192
- } catch (error) {
193
- throw new CatError('DOCX_PARSE', `DOCX 解析失败: ${error?.message ?? error}`)
194
- }
195
- return this.createDocument({
196
- name: fileName,
197
- text: content.text,
198
- paragraphMap: content.paragraphs.map((paragraph) => paragraph.index),
199
- docxSource: buffer,
200
- docxName: fileName,
201
- sourceLang,
202
- targetLang,
203
- format: 'docx',
204
- })
205
- }
206
-
207
- if (extension === 'xlsx' || extension === 'pptx') {
208
- if (!base64) throw new CatError('EMPTY_FILE', `${extension.toUpperCase()} 文件内容为空。`)
209
- if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', `${extension.toUpperCase()} 文件过大。`)
210
- const buffer = Buffer.from(base64, 'base64')
211
- let parsed
212
- try {
213
- parsed = extension === 'xlsx' ? parseXlsx(buffer) : parsePptxDetailed(buffer)
214
- } catch (error) {
215
- if (error instanceof CatError) throw error
216
- throw new CatError(
217
- `${extension.toUpperCase()}_PARSE`,
218
- `${extension.toUpperCase()} 解析失败: ${error?.message ?? error}`,
219
- )
220
- }
221
- const created = this.createDocument({
222
- name: fileName,
223
- segments: parsed.segments,
224
- sourceLang,
225
- targetLang,
226
- format: extension,
227
- ...(extension === 'pptx' ? { pptxSource: buffer, pptxName: fileName } : {}),
228
- })
229
- return {
230
- ...created,
231
- sourceLang: created.document.sourceLang,
232
- targetLang: created.document.targetLang,
233
- units: parsed.segments.length,
234
- warnings: parsed.warnings ?? [],
235
- }
236
- }
237
-
238
- if (extension === 'tmx') {
239
- return this.importTmx({
240
- text,
241
- name: fileName,
242
- sourceLang,
243
- targetLang,
244
- importAs: importAs || 'document',
245
- })
246
- }
247
-
248
- const structured = {
249
- csv: { parser: parseCsv, format: 'csv' },
250
- srt: { parser: parseSrt, format: 'srt' },
251
- vtt: { parser: parseVtt, format: 'vtt' },
252
- po: { parser: parsePo, format: 'po' },
253
- pot: { parser: parsePo, format: 'po' },
254
- html: { parser: parseHtml, format: 'html' },
255
- htm: { parser: parseHtml, format: 'html' },
256
- json: { parser: parseJson, format: 'json' },
257
- rtf: { parser: parseRtf, format: 'rtf' },
258
- xliff: { parser: parseXliff, format: 'xliff' },
259
- xlf: { parser: parseXliff, format: 'xliff' },
260
- sdlxliff: { parser: parseXliff, format: 'xliff' },
261
- }[extension]
262
- if (structured) {
263
- const sourceText = String(text ?? '')
264
- if (!sourceText.trim()) throw new CatError('EMPTY_FILE', '文件内容为空。')
265
- if (sourceText.length > MAX_IMPORT_CHARS) throw new CatError('FILE_TOO_LARGE', '文件过大。')
266
- let parsed
267
- try {
268
- parsed = extension === 'csv'
269
- ? parseCsv(sourceText, { sourceColumn: csvSourceColumn, targetColumn: csvTargetColumn })
270
- : structured.parser(sourceText)
271
- } catch (error) {
272
- if (error instanceof CatError) throw error
273
- throw new CatError(
274
- `${structured.format.toUpperCase()}_PARSE`,
275
- `${structured.format.toUpperCase()} 解析失败: ${error?.message ?? error}`,
276
- )
277
- }
278
- const src = cleanLang(
279
- sourceLang || parsed.sourceLang || this.settings.sourceLang,
280
- this.settings.sourceLang,
281
- )
282
- const tgt = cleanLang(
283
- targetLang || parsed.targetLang || this.settings.targetLang,
284
- this.settings.targetLang,
285
- )
286
- const formatMeta = {
287
- ...(parsed.formatMeta || {}),
288
- ...(parsed.obsolete?.length ? { poObsolete: parsed.obsolete } : {}),
289
- }
290
- const created = this.createDocument({
291
- name: fileName,
292
- segments: parsed.segments,
293
- sourceLang: src,
294
- targetLang: tgt,
295
- format: parsed.format || structured.format,
296
- formatMeta: Object.keys(formatMeta).length ? formatMeta : undefined,
297
- })
298
- return {
299
- ...created,
300
- sourceLang: src,
301
- targetLang: tgt,
302
- units: parsed.segments.length,
303
- warnings: parsed.warnings,
304
- }
305
- }
306
-
307
- let content = String(text ?? '')
308
- if (!content.trim()) throw new CatError('EMPTY_FILE', '文件内容为空。')
309
- if (content.length > MAX_IMPORT_CHARS) throw new CatError('FILE_TOO_LARGE', '文件过大。')
310
- if (extension === 'md' || extension === 'markdown') content = stripMarkdown(content)
311
- else if (extension === 'html' || extension === 'htm') {
312
- content = content
313
- .replace(/<script[\s\S]*?<\/script>/gi, '')
314
- .replace(/<style[\s\S]*?<\/style>/gi, '')
315
- .replace(/<br\s*\/?>/gi, '\n')
316
- .replace(/<\/p>/gi, '\n\n')
317
- .replace(/<[^>]+>/g, '')
318
- content = decodeXml(content)
319
- }
320
-
321
- return this.createDocument({ name: fileName, text: content, sourceLang, targetLang })
322
- }
323
- ,
324
- exportDocument({ format = 'bilingual-md', documentId } = {}) {
325
- const doc = this.requireDocument(documentId)
326
- const base = safeBaseName(doc.name)
327
- const targetJoiner = /^(zh|ja|ko)/i.test(doc.targetLang) ? '' : ' '
328
-
329
- const paragraphs = (field, joiner) => {
330
- const groups = new Map()
331
- for (const segment of doc.segments) {
332
- const key = segment.paragraph ?? 0
333
- if (!groups.has(key)) groups.set(key, [])
334
- if (segment[field]) groups.get(key).push(segment[field])
335
- }
336
- return [...groups.values()]
337
- .map((parts) => parts.join(joiner).trim())
338
- .filter(Boolean)
339
- .join('\n\n')
340
- }
341
-
342
- if (format === 'docx') {
343
- const grouped = new Map()
344
- for (const segment of doc.segments) {
345
- const key = Number.isInteger(segment.paragraph) ? segment.paragraph : 0
346
- if (!grouped.has(key)) grouped.set(key, [])
347
- grouped.get(key).push(segment)
348
- }
349
- const replacements = new Map()
350
- for (const [key, segments] of grouped) {
351
- const translated = segments
352
- .map((segment) => segment.target)
353
- .filter(Boolean)
354
- .join(targetJoiner)
355
- .trim()
356
- const fallback = segments
357
- .map((segment) => segment.source)
358
- .filter(Boolean)
359
- .join(' ')
360
- .trim()
361
- if (translated || fallback) replacements.set(key, translated || fallback)
362
- }
363
-
364
- let content = null
365
- const template = this.readDocxSource(doc)
366
- if (template) {
367
- try {
368
- content = buildDocx(template, replacements)
369
- } catch (error) {
370
- this.logger?.warn?.(`CAIT: DOCX 模板套用失败,改用纯文本 DOCX: ${error.message}`)
371
- }
372
- }
373
- if (!content) {
374
- const paragraphs = [...grouped.keys()]
375
- .sort((left, right) => left - right)
376
- .map((key) => replacements.get(key))
377
- .filter(Boolean)
378
- content = buildDocxFromText(paragraphs)
379
- }
380
- return {
381
- filename: `${base}.${doc.targetLang}.docx`,
382
- mime: DOCX_MIME,
383
- content,
384
- count: doc.segments.length,
385
- }
386
- }
387
-
388
- if (format === 'srt') {
389
- return {
390
- filename: `${base}.${doc.targetLang}.srt`,
391
- mime: 'application/x-subrip;charset=utf-8',
392
- content: buildSrt(doc),
393
- count: doc.segments.length,
394
- }
395
- }
396
-
397
- if (format === 'vtt') {
398
- return {
399
- filename: `${base}.${doc.targetLang}.vtt`,
400
- mime: 'text/vtt;charset=utf-8',
401
- content: buildVtt(doc),
402
- count: doc.segments.length,
403
- }
404
- }
405
-
406
- if (format === 'po') {
407
- return {
408
- filename: `${base}.${doc.targetLang}.po`,
409
- mime: 'text/x-gettext-translation;charset=utf-8',
410
- content: buildPo(doc),
411
- count: doc.segments.length,
412
- }
413
- }
414
-
415
- if (format === 'xliff' || format === 'xlf' || format === 'sdlxliff') {
416
- return {
417
- filename: `${base}.${doc.targetLang}.${format === 'sdlxliff' ? 'sdlxliff' : 'xliff'}`,
418
- mime: 'application/x-xliff+xml;charset=utf-8',
419
- content: buildXliff(doc),
420
- count: doc.segments.length,
421
- }
422
- }
423
-
424
- if (format === 'xliff2') {
425
- return {
426
- filename: `${base}.${doc.targetLang}.xliff`,
427
- mime: 'application/x-xliff+xml;charset=utf-8',
428
- content: buildXliff2(doc),
429
- count: doc.segments.length,
430
- }
431
- }
432
-
433
- if (format === 'html') {
434
- return {
435
- filename: `${base}.${doc.targetLang}.html`,
436
- mime: 'text/html;charset=utf-8',
437
- content: buildHtml(doc),
438
- count: doc.segments.length,
439
- }
440
- }
441
-
442
- if (format === 'json-tree' || format === 'json-export') {
443
- if (!doc.formatMeta?.jsonTemplate) {
444
- throw new CatError('JSON_TEMPLATE_REQUIRED', '该文档没有 JSON 模板,无法保持 JSON 结构导出。')
445
- }
446
- return {
447
- filename: `${base}.${doc.targetLang}.json`,
448
- mime: 'application/json;charset=utf-8',
449
- content: buildJson(doc),
450
- count: doc.segments.length,
451
- }
452
- }
453
-
454
- if (format === 'rtf') {
455
- return {
456
- filename: `${base}.${doc.targetLang}.rtf`,
457
- mime: 'application/rtf;charset=utf-8',
458
- content: buildRtf(doc),
459
- count: doc.segments.length,
460
- }
461
- }
462
-
463
- if (format === 'xlsx') {
464
- return {
465
- filename: `${base}.${doc.targetLang}.xlsx`,
466
- mime: XLSX_MIME,
467
- content: buildXlsx(doc),
468
- count: doc.segments.length,
469
- }
470
- }
471
-
472
- if (format === 'pptx') {
473
- const template = this.readPptxSource(doc)
474
- if (!template) {
475
- throw new CatError(
476
- 'PPTX_SOURCE_REQUIRED',
477
- '该文档没有保留 PPTX 源文件,无法按原版式导出;请重新导入 PPTX 后再导出。',
478
- 409,
479
- )
480
- }
481
- const replacements = new Map()
482
- for (const segment of doc.segments) {
483
- const slide = segment.meta?.pptxSlide
484
- const paragraph = Number(segment.meta?.pptxParagraph)
485
- if (!slide || !Number.isInteger(paragraph)) continue
486
- if (!replacements.has(slide)) replacements.set(slide, new Map())
487
- const byParagraph = replacements.get(slide)
488
- const text = String(segment.target || segment.source || '').trim()
489
- byParagraph.set(paragraph, text)
490
- }
491
- return {
492
- filename: `${base}.${doc.targetLang}.pptx`,
493
- mime: PPTX_MIME,
494
- content: buildPptx(template, replacements),
495
- count: doc.segments.length,
496
- }
497
- }
498
-
499
- if (format === 'target-txt') {
500
- return {
501
- filename: `${base}.${doc.targetLang}.txt`,
502
- mime: 'text/plain;charset=utf-8',
503
- content: `${paragraphs('target', targetJoiner)}\n`,
504
- count: doc.segments.length,
505
- }
506
- }
507
-
508
- if (format === 'source-txt') {
509
- return {
510
- filename: `${base}.${doc.sourceLang}.txt`,
511
- mime: 'text/plain;charset=utf-8',
512
- content: `${paragraphs('source', ' ')}\n`,
513
- count: doc.segments.length,
514
- }
515
- }
516
-
517
- if (format === 'target-md') {
518
- const content = `# ${doc.name}\n\n> ${doc.targetLang}\n\n${paragraphs('target', targetJoiner)}\n`
519
- return {
520
- filename: `${base}.${doc.targetLang}.md`,
521
- mime: 'text/markdown;charset=utf-8',
522
- content,
523
- count: doc.segments.length,
524
- }
525
- }
526
-
527
- if (format === 'bilingual-md') {
528
- const rows = doc.segments.map(
529
- (segment, index) =>
530
- `| ${index + 1} | ${mdCell(segment.source)} | ${mdCell(segment.target)} | ${segment.status} |`,
531
- )
532
- const content = [
533
- `# ${doc.name}`,
534
- '',
535
- `> ${doc.sourceLang} → ${doc.targetLang} · ${doc.segments.length} segments · ${this.progress(doc).percent}% confirmed`,
536
- '',
537
- '| # | Source | Target | Status |',
538
- '|---:|---|---|---|',
539
- ...rows,
540
- '',
541
- ].join('\n')
542
- return {
543
- filename: `${base}.bilingual.md`,
544
- mime: 'text/markdown;charset=utf-8',
545
- content,
546
- count: doc.segments.length,
547
- }
548
- }
549
-
550
- if (format === 'bilingual-csv') {
551
- const rows = doc.segments.map((segment, index) =>
552
- [index + 1, segment.source, segment.target, segment.status].map(csvCell).join(','),
553
- )
554
- return {
555
- filename: `${base}.bilingual.csv`,
556
- mime: 'text/csv;charset=utf-8',
557
- content: `\ufeffIndex,Source,Target,Status\n${rows.join('\n')}\n`,
558
- count: doc.segments.length,
559
- }
560
- }
561
-
562
- if (format === 'tmx') {
563
- return {
564
- filename: `${base}.tmx`,
565
- mime: 'application/xml;charset=utf-8',
566
- content: buildTmx(doc),
567
- count: doc.segments.length,
568
- }
569
- }
570
-
571
- // Document JSON, not a project backup: only this document's segments are
572
- // included. Use the snapshot file for libraries/TM/glossary/mounts.
573
- if (format === 'document-json' || format === 'json') {
574
- return {
575
- filename: `${base}.document.json`,
576
- mime: 'application/json;charset=utf-8',
577
- content: JSON.stringify(
578
- {
579
- format: 'cait-document',
580
- version: 1,
581
- exportedAt: now(),
582
- document: {
583
- name: doc.name,
584
- sourceLang: doc.sourceLang,
585
- targetLang: doc.targetLang,
586
- segments: doc.segments.map((segment, index) => ({
587
- index: index + 1,
588
- source: segment.source,
589
- target: segment.target,
590
- status: segment.status,
591
- paragraph: segment.paragraph,
592
- })),
593
- },
594
- },
595
- null,
596
- 2,
597
- ),
598
- count: doc.segments.length,
599
- }
600
- }
601
-
602
- throw new CatError('INVALID_EXPORT_FORMAT', `不支持的导出格式: ${format}`)
603
- }
604
-
605
- }
1
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { CatError } from './errors.js'
4
+ import { MAX_DOCX_BASE64, MAX_IMPORT_CHARS, MAX_SEGMENTS, MAX_SEGMENT_CHARS, canonicalLanguage, cleanLang, cleanName, cleanText, csvCell, makeId, mdCell, now, safeBaseName } from './store-utils.js'
5
+ import { stripMarkdown } from './markdown.js'
6
+ import { DOCX_MIME, buildDocx, buildDocxFromText, parseDocxDetailed } from './docx.js'
7
+ import { PPTX_MIME, buildPptx, parsePptxDetailed } from './pptx.js'
8
+ import { XLSX_MIME, buildXlsx, parseXlsx } from './xlsx.js'
9
+ import { buildTmx, parseTmx } from './tmx.js'
10
+ import { buildHtml, buildJson, buildPo, buildRtf, buildSrt, buildVtt, buildXliff, buildXliff2, parseCsv, parseHtml, parseJson, parsePo, parseRtf, parseSrt, parseVtt, parseXliff } from './formats.js'
11
+ import { decodeXml } from './xml.js'
12
+
13
+ export const importExportMethods = {
14
+ /**
15
+ * Keep the original DOCX package so exporting can patch translated text into
16
+ * the source layout. Persisted stores use a small sidecar file; transient
17
+ * in-memory stores keep the bytes in the document object only.
18
+ */
19
+ attachDocxSource(doc, buffer, name = 'source.docx') {
20
+ if (!Buffer.isBuffer(buffer) || !buffer.length) return
21
+ const fileName = cleanName(name, 'source.docx')
22
+ if (this.dataDir) {
23
+ const asset = `sources/${doc.id}.docx`
24
+ try {
25
+ mkdirSync(join(this.dataDir, 'sources'), { recursive: true })
26
+ writeFileSync(join(this.dataDir, asset), buffer)
27
+ } catch (error) {
28
+ throw new CatError(
29
+ 'DOCX_SOURCE_WRITE_FAILED',
30
+ `无法保存 DOCX 源文件,无法保留源格式: ${error.message}`,
31
+ 500,
32
+ )
33
+ }
34
+ doc.sourceDocx = { kind: 'file', asset, name: fileName }
35
+ return
36
+ }
37
+ doc.sourceDocx = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
38
+ },
39
+
40
+ removeDocxSource(doc) {
41
+ const source = doc?.sourceDocx
42
+ if (!source) return
43
+ if (source.kind === 'file' && this.dataDir) {
44
+ try {
45
+ unlinkSync(join(this.dataDir, source.asset))
46
+ } catch (error) {
47
+ if (error?.code !== 'ENOENT') {
48
+ this.logger?.warn?.(`CAIT: 删除 DOCX 源文件失败: ${error.message}`)
49
+ }
50
+ }
51
+ }
52
+ delete doc.sourceDocx
53
+ },
54
+
55
+ readDocxSource(doc) {
56
+ const source = doc?.sourceDocx
57
+ if (!source) return null
58
+ try {
59
+ if (source.kind === 'base64' && source.data) {
60
+ return Buffer.from(source.data, 'base64')
61
+ }
62
+ if (source.kind === 'file' && this.dataDir && source.asset) {
63
+ return readFileSync(join(this.dataDir, source.asset))
64
+ }
65
+ } catch (error) {
66
+ this.logger?.warn?.(`CAIT: 读取 DOCX 源文件失败: ${error.message}`)
67
+ }
68
+ return null
69
+ },
70
+
71
+ attachPptxSource(doc, buffer, name = 'source.pptx') {
72
+ if (!Buffer.isBuffer(buffer) || !buffer.length) return
73
+ const fileName = cleanName(name, 'source.pptx')
74
+ if (this.dataDir) {
75
+ const asset = `sources/${doc.id}.pptx`
76
+ try {
77
+ mkdirSync(join(this.dataDir, 'sources'), { recursive: true })
78
+ writeFileSync(join(this.dataDir, asset), buffer)
79
+ } catch (error) {
80
+ throw new CatError(
81
+ 'PPTX_SOURCE_WRITE_FAILED',
82
+ `无法保存 PPTX 源文件,无法保留源格式: ${error.message}`,
83
+ 500,
84
+ )
85
+ }
86
+ doc.sourcePptx = { kind: 'file', asset, name: fileName }
87
+ return
88
+ }
89
+ doc.sourcePptx = { kind: 'base64', data: buffer.toString('base64'), name: fileName }
90
+ },
91
+
92
+ removePptxSource(doc) {
93
+ const source = doc?.sourcePptx
94
+ if (!source) return
95
+ if (source.kind === 'file' && this.dataDir) {
96
+ try {
97
+ unlinkSync(join(this.dataDir, source.asset))
98
+ } catch (error) {
99
+ if (error?.code !== 'ENOENT') {
100
+ this.logger?.warn?.(`CAIT: 删除 PPTX 源文件失败: ${error.message}`)
101
+ }
102
+ }
103
+ }
104
+ delete doc.sourcePptx
105
+ },
106
+
107
+ readPptxSource(doc) {
108
+ const source = doc?.sourcePptx
109
+ if (!source) return null
110
+ try {
111
+ if (source.kind === 'base64' && source.data) {
112
+ return Buffer.from(source.data, 'base64')
113
+ }
114
+ if (source.kind === 'file' && this.dataDir && source.asset) {
115
+ return readFileSync(join(this.dataDir, source.asset))
116
+ }
117
+ } catch (error) {
118
+ this.logger?.warn?.(`CAIT: 读取 PPTX 源文件失败: ${error.message}`)
119
+ }
120
+ return null
121
+ },
122
+
123
+ importTmx({ text, name, sourceLang, targetLang, importAs = 'tm', tmId } = {}) {
124
+ if (!String(text ?? '').trim()) throw new CatError('EMPTY_FILE', 'TMX 内容为空。')
125
+ const parsed = parseTmx(text)
126
+ const src = cleanLang(
127
+ sourceLang || parsed.sourceLang || this.settings.sourceLang,
128
+ this.settings.sourceLang,
129
+ )
130
+ const detectedTarget = parsed.units
131
+ .flat()
132
+ .map((variant) => variant.lang)
133
+ .find((lang) => lang && lang !== src)
134
+ const tgt = cleanLang(
135
+ targetLang || detectedTarget || this.settings.targetLang,
136
+ this.settings.targetLang,
137
+ )
138
+ const pairs = []
139
+ const segments = []
140
+
141
+ for (const variants of parsed.units) {
142
+ const source =
143
+ variants.find((variant) => variant.lang === src) ??
144
+ variants.find((variant) => variant.lang !== tgt) ??
145
+ variants[0]
146
+ if (!source?.text) continue
147
+ const target = variants.find((variant) => variant.lang === tgt)
148
+ pairs.push({ source: source.text, target: target?.text || '', sourceLang: src, targetLang: tgt })
149
+ segments.push({ source: source.text, target: target?.text || '', paragraph: segments.length })
150
+ }
151
+
152
+ if (!pairs.length) throw new CatError('TMX_NO_UNITS', 'TMX 中没有可用的翻译单元。')
153
+
154
+ if (importAs === 'document') {
155
+ const created = this.createDocument({
156
+ name: cleanName(name, 'TMX 导入'),
157
+ sourceLang: src,
158
+ targetLang: tgt,
159
+ segments,
160
+ format: 'tmx',
161
+ })
162
+ return {
163
+ ...created,
164
+ sourceLang: src,
165
+ targetLang: tgt,
166
+ units: pairs.length,
167
+ warnings: parsed.warnings,
168
+ }
169
+ }
170
+
171
+ const imported = this.importTM({ pairs, sourceLang: src, targetLang: tgt, tmId })
172
+ return {
173
+ ...imported,
174
+ sourceLang: src,
175
+ targetLang: tgt,
176
+ units: pairs.length,
177
+ warnings: parsed.warnings,
178
+ }
179
+ }
180
+ ,
181
+ importFile({ name, text, base64, sourceLang, targetLang, importAs, csvSourceColumn, csvTargetColumn } = {}) {
182
+ const fileName = cleanName(name, '导入文档')
183
+ const extension = (fileName.match(/\.([a-z0-9]+)$/i)?.[1] ?? '').toLowerCase()
184
+
185
+ if (extension === 'docx') {
186
+ if (!base64) throw new CatError('EMPTY_FILE', 'DOCX 文件内容为空。')
187
+ if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', 'DOCX 文件过大。')
188
+ const buffer = Buffer.from(base64, 'base64')
189
+ let content
190
+ try {
191
+ content = parseDocxDetailed(buffer)
192
+ } catch (error) {
193
+ throw new CatError('DOCX_PARSE', `DOCX 解析失败: ${error?.message ?? error}`)
194
+ }
195
+ return this.createDocument({
196
+ name: fileName,
197
+ text: content.text,
198
+ paragraphMap: content.paragraphs.map((paragraph) => paragraph.index),
199
+ docxSource: buffer,
200
+ docxName: fileName,
201
+ sourceLang,
202
+ targetLang,
203
+ format: 'docx',
204
+ })
205
+ }
206
+
207
+ if (extension === 'xlsx' || extension === 'pptx') {
208
+ if (!base64) throw new CatError('EMPTY_FILE', `${extension.toUpperCase()} 文件内容为空。`)
209
+ if (base64.length > MAX_DOCX_BASE64) throw new CatError('FILE_TOO_LARGE', `${extension.toUpperCase()} 文件过大。`)
210
+ const buffer = Buffer.from(base64, 'base64')
211
+ let parsed
212
+ try {
213
+ parsed = extension === 'xlsx' ? parseXlsx(buffer) : parsePptxDetailed(buffer)
214
+ } catch (error) {
215
+ if (error instanceof CatError) throw error
216
+ throw new CatError(
217
+ `${extension.toUpperCase()}_PARSE`,
218
+ `${extension.toUpperCase()} 解析失败: ${error?.message ?? error}`,
219
+ )
220
+ }
221
+ const created = this.createDocument({
222
+ name: fileName,
223
+ segments: parsed.segments,
224
+ sourceLang,
225
+ targetLang,
226
+ format: extension,
227
+ ...(extension === 'pptx' ? { pptxSource: buffer, pptxName: fileName } : {}),
228
+ })
229
+ return {
230
+ ...created,
231
+ sourceLang: created.document.sourceLang,
232
+ targetLang: created.document.targetLang,
233
+ units: parsed.segments.length,
234
+ warnings: parsed.warnings ?? [],
235
+ }
236
+ }
237
+
238
+ if (extension === 'tmx') {
239
+ return this.importTmx({
240
+ text,
241
+ name: fileName,
242
+ sourceLang,
243
+ targetLang,
244
+ importAs: importAs || 'document',
245
+ })
246
+ }
247
+
248
+ const structured = {
249
+ csv: { parser: parseCsv, format: 'csv' },
250
+ srt: { parser: parseSrt, format: 'srt' },
251
+ vtt: { parser: parseVtt, format: 'vtt' },
252
+ po: { parser: parsePo, format: 'po' },
253
+ pot: { parser: parsePo, format: 'po' },
254
+ html: { parser: parseHtml, format: 'html' },
255
+ htm: { parser: parseHtml, format: 'html' },
256
+ json: { parser: parseJson, format: 'json' },
257
+ rtf: { parser: parseRtf, format: 'rtf' },
258
+ xliff: { parser: parseXliff, format: 'xliff' },
259
+ xlf: { parser: parseXliff, format: 'xliff' },
260
+ sdlxliff: { parser: parseXliff, format: 'xliff' },
261
+ }[extension]
262
+ if (structured) {
263
+ const sourceText = String(text ?? '')
264
+ if (!sourceText.trim()) throw new CatError('EMPTY_FILE', '文件内容为空。')
265
+ if (sourceText.length > MAX_IMPORT_CHARS) throw new CatError('FILE_TOO_LARGE', '文件过大。')
266
+ let parsed
267
+ try {
268
+ parsed = extension === 'csv'
269
+ ? parseCsv(sourceText, { sourceColumn: csvSourceColumn, targetColumn: csvTargetColumn })
270
+ : structured.parser(sourceText)
271
+ } catch (error) {
272
+ if (error instanceof CatError) throw error
273
+ throw new CatError(
274
+ `${structured.format.toUpperCase()}_PARSE`,
275
+ `${structured.format.toUpperCase()} 解析失败: ${error?.message ?? error}`,
276
+ )
277
+ }
278
+ const src = cleanLang(
279
+ sourceLang || parsed.sourceLang || this.settings.sourceLang,
280
+ this.settings.sourceLang,
281
+ )
282
+ const tgt = cleanLang(
283
+ targetLang || parsed.targetLang || this.settings.targetLang,
284
+ this.settings.targetLang,
285
+ )
286
+ const formatMeta = {
287
+ ...(parsed.formatMeta || {}),
288
+ ...(parsed.obsolete?.length ? { poObsolete: parsed.obsolete } : {}),
289
+ }
290
+ const created = this.createDocument({
291
+ name: fileName,
292
+ segments: parsed.segments,
293
+ sourceLang: src,
294
+ targetLang: tgt,
295
+ format: parsed.format || structured.format,
296
+ formatMeta: Object.keys(formatMeta).length ? formatMeta : undefined,
297
+ })
298
+ return {
299
+ ...created,
300
+ sourceLang: src,
301
+ targetLang: tgt,
302
+ units: parsed.segments.length,
303
+ warnings: parsed.warnings,
304
+ }
305
+ }
306
+
307
+ let content = String(text ?? '')
308
+ if (!content.trim()) throw new CatError('EMPTY_FILE', '文件内容为空。')
309
+ if (content.length > MAX_IMPORT_CHARS) throw new CatError('FILE_TOO_LARGE', '文件过大。')
310
+ if (extension === 'md' || extension === 'markdown') content = stripMarkdown(content)
311
+ else if (extension === 'html' || extension === 'htm') {
312
+ content = content
313
+ .replace(/<script[\s\S]*?<\/script>/gi, '')
314
+ .replace(/<style[\s\S]*?<\/style>/gi, '')
315
+ .replace(/<br\s*\/?>/gi, '\n')
316
+ .replace(/<\/p>/gi, '\n\n')
317
+ .replace(/<[^>]+>/g, '')
318
+ content = decodeXml(content)
319
+ }
320
+
321
+ return this.createDocument({ name: fileName, text: content, sourceLang, targetLang })
322
+ }
323
+ ,
324
+ exportDocument({ format = 'bilingual-md', documentId } = {}) {
325
+ const doc = this.requireDocument(documentId)
326
+ const base = safeBaseName(doc.name)
327
+ const targetJoiner = /^(zh|ja|ko)/i.test(doc.targetLang) ? '' : ' '
328
+
329
+ const paragraphs = (field, joiner) => {
330
+ const groups = new Map()
331
+ for (const segment of doc.segments) {
332
+ const key = segment.paragraph ?? 0
333
+ if (!groups.has(key)) groups.set(key, [])
334
+ if (segment[field]) groups.get(key).push(segment[field])
335
+ }
336
+ return [...groups.values()]
337
+ .map((parts) => parts.join(joiner).trim())
338
+ .filter(Boolean)
339
+ .join('\n\n')
340
+ }
341
+
342
+ if (format === 'docx') {
343
+ const grouped = new Map()
344
+ for (const segment of doc.segments) {
345
+ const key = Number.isInteger(segment.paragraph) ? segment.paragraph : 0
346
+ if (!grouped.has(key)) grouped.set(key, [])
347
+ grouped.get(key).push(segment)
348
+ }
349
+ const replacements = new Map()
350
+ for (const [key, segments] of grouped) {
351
+ const translated = segments
352
+ .map((segment) => segment.target)
353
+ .filter(Boolean)
354
+ .join(targetJoiner)
355
+ .trim()
356
+ const fallback = segments
357
+ .map((segment) => segment.source)
358
+ .filter(Boolean)
359
+ .join(' ')
360
+ .trim()
361
+ if (translated || fallback) replacements.set(key, translated || fallback)
362
+ }
363
+
364
+ let content = null
365
+ const template = this.readDocxSource(doc)
366
+ if (template) {
367
+ try {
368
+ content = buildDocx(template, replacements)
369
+ } catch (error) {
370
+ this.logger?.warn?.(`CAIT: DOCX 模板套用失败,改用纯文本 DOCX: ${error.message}`)
371
+ }
372
+ }
373
+ if (!content) {
374
+ const paragraphs = [...grouped.keys()]
375
+ .sort((left, right) => left - right)
376
+ .map((key) => replacements.get(key))
377
+ .filter(Boolean)
378
+ content = buildDocxFromText(paragraphs)
379
+ }
380
+ return {
381
+ filename: `${base}.${doc.targetLang}.docx`,
382
+ mime: DOCX_MIME,
383
+ content,
384
+ count: doc.segments.length,
385
+ }
386
+ }
387
+
388
+ if (format === 'srt') {
389
+ return {
390
+ filename: `${base}.${doc.targetLang}.srt`,
391
+ mime: 'application/x-subrip;charset=utf-8',
392
+ content: buildSrt(doc),
393
+ count: doc.segments.length,
394
+ }
395
+ }
396
+
397
+ if (format === 'vtt') {
398
+ return {
399
+ filename: `${base}.${doc.targetLang}.vtt`,
400
+ mime: 'text/vtt;charset=utf-8',
401
+ content: buildVtt(doc),
402
+ count: doc.segments.length,
403
+ }
404
+ }
405
+
406
+ if (format === 'po') {
407
+ return {
408
+ filename: `${base}.${doc.targetLang}.po`,
409
+ mime: 'text/x-gettext-translation;charset=utf-8',
410
+ content: buildPo(doc),
411
+ count: doc.segments.length,
412
+ }
413
+ }
414
+
415
+ if (format === 'xliff' || format === 'xlf' || format === 'sdlxliff') {
416
+ return {
417
+ filename: `${base}.${doc.targetLang}.${format === 'sdlxliff' ? 'sdlxliff' : 'xliff'}`,
418
+ mime: 'application/x-xliff+xml;charset=utf-8',
419
+ content: buildXliff(doc),
420
+ count: doc.segments.length,
421
+ }
422
+ }
423
+
424
+ if (format === 'xliff2') {
425
+ return {
426
+ filename: `${base}.${doc.targetLang}.xliff`,
427
+ mime: 'application/x-xliff+xml;charset=utf-8',
428
+ content: buildXliff2(doc),
429
+ count: doc.segments.length,
430
+ }
431
+ }
432
+
433
+ if (format === 'html') {
434
+ return {
435
+ filename: `${base}.${doc.targetLang}.html`,
436
+ mime: 'text/html;charset=utf-8',
437
+ content: buildHtml(doc),
438
+ count: doc.segments.length,
439
+ }
440
+ }
441
+
442
+ if (format === 'json-tree' || format === 'json-export') {
443
+ if (!doc.formatMeta?.jsonTemplate) {
444
+ throw new CatError('JSON_TEMPLATE_REQUIRED', '该文档没有 JSON 模板,无法保持 JSON 结构导出。')
445
+ }
446
+ return {
447
+ filename: `${base}.${doc.targetLang}.json`,
448
+ mime: 'application/json;charset=utf-8',
449
+ content: buildJson(doc),
450
+ count: doc.segments.length,
451
+ }
452
+ }
453
+
454
+ if (format === 'rtf') {
455
+ return {
456
+ filename: `${base}.${doc.targetLang}.rtf`,
457
+ mime: 'application/rtf;charset=utf-8',
458
+ content: buildRtf(doc),
459
+ count: doc.segments.length,
460
+ }
461
+ }
462
+
463
+ if (format === 'xlsx') {
464
+ return {
465
+ filename: `${base}.${doc.targetLang}.xlsx`,
466
+ mime: XLSX_MIME,
467
+ content: buildXlsx(doc),
468
+ count: doc.segments.length,
469
+ }
470
+ }
471
+
472
+ if (format === 'pptx') {
473
+ const template = this.readPptxSource(doc)
474
+ if (!template) {
475
+ throw new CatError(
476
+ 'PPTX_SOURCE_REQUIRED',
477
+ '该文档没有保留 PPTX 源文件,无法按原版式导出;请重新导入 PPTX 后再导出。',
478
+ 409,
479
+ )
480
+ }
481
+ const replacements = new Map()
482
+ for (const segment of doc.segments) {
483
+ const slide = segment.meta?.pptxSlide
484
+ const paragraph = Number(segment.meta?.pptxParagraph)
485
+ if (!slide || !Number.isInteger(paragraph)) continue
486
+ if (!replacements.has(slide)) replacements.set(slide, new Map())
487
+ const byParagraph = replacements.get(slide)
488
+ const text = String(segment.target || segment.source || '').trim()
489
+ byParagraph.set(paragraph, text)
490
+ }
491
+ return {
492
+ filename: `${base}.${doc.targetLang}.pptx`,
493
+ mime: PPTX_MIME,
494
+ content: buildPptx(template, replacements),
495
+ count: doc.segments.length,
496
+ }
497
+ }
498
+
499
+ if (format === 'target-txt') {
500
+ return {
501
+ filename: `${base}.${doc.targetLang}.txt`,
502
+ mime: 'text/plain;charset=utf-8',
503
+ content: `${paragraphs('target', targetJoiner)}\n`,
504
+ count: doc.segments.length,
505
+ }
506
+ }
507
+
508
+ if (format === 'source-txt') {
509
+ return {
510
+ filename: `${base}.${doc.sourceLang}.txt`,
511
+ mime: 'text/plain;charset=utf-8',
512
+ content: `${paragraphs('source', ' ')}\n`,
513
+ count: doc.segments.length,
514
+ }
515
+ }
516
+
517
+ if (format === 'target-md') {
518
+ const content = `# ${doc.name}\n\n> ${doc.targetLang}\n\n${paragraphs('target', targetJoiner)}\n`
519
+ return {
520
+ filename: `${base}.${doc.targetLang}.md`,
521
+ mime: 'text/markdown;charset=utf-8',
522
+ content,
523
+ count: doc.segments.length,
524
+ }
525
+ }
526
+
527
+ if (format === 'bilingual-md') {
528
+ const rows = doc.segments.map(
529
+ (segment, index) =>
530
+ `| ${index + 1} | ${mdCell(segment.source)} | ${mdCell(segment.target)} | ${segment.status} |`,
531
+ )
532
+ const content = [
533
+ `# ${doc.name}`,
534
+ '',
535
+ `> ${doc.sourceLang} → ${doc.targetLang} · ${doc.segments.length} segments · ${this.progress(doc).percent}% confirmed`,
536
+ '',
537
+ '| # | Source | Target | Status |',
538
+ '|---:|---|---|---|',
539
+ ...rows,
540
+ '',
541
+ ].join('\n')
542
+ return {
543
+ filename: `${base}.bilingual.md`,
544
+ mime: 'text/markdown;charset=utf-8',
545
+ content,
546
+ count: doc.segments.length,
547
+ }
548
+ }
549
+
550
+ if (format === 'bilingual-csv') {
551
+ const rows = doc.segments.map((segment, index) =>
552
+ [index + 1, segment.source, segment.target, segment.status].map(csvCell).join(','),
553
+ )
554
+ return {
555
+ filename: `${base}.bilingual.csv`,
556
+ mime: 'text/csv;charset=utf-8',
557
+ content: `\ufeffIndex,Source,Target,Status\n${rows.join('\n')}\n`,
558
+ count: doc.segments.length,
559
+ }
560
+ }
561
+
562
+ if (format === 'tmx') {
563
+ return {
564
+ filename: `${base}.tmx`,
565
+ mime: 'application/xml;charset=utf-8',
566
+ content: buildTmx(doc),
567
+ count: doc.segments.length,
568
+ }
569
+ }
570
+
571
+ // Document JSON, not a project backup: only this document's segments are
572
+ // included. Use the snapshot file for libraries/TM/glossary/mounts.
573
+ if (format === 'document-json' || format === 'json') {
574
+ return {
575
+ filename: `${base}.document.json`,
576
+ mime: 'application/json;charset=utf-8',
577
+ content: JSON.stringify(
578
+ {
579
+ format: 'cait-document',
580
+ version: 1,
581
+ exportedAt: now(),
582
+ document: {
583
+ name: doc.name,
584
+ sourceLang: doc.sourceLang,
585
+ targetLang: doc.targetLang,
586
+ segments: doc.segments.map((segment, index) => ({
587
+ index: index + 1,
588
+ source: segment.source,
589
+ target: segment.target,
590
+ status: segment.status,
591
+ paragraph: segment.paragraph,
592
+ })),
593
+ },
594
+ },
595
+ null,
596
+ 2,
597
+ ),
598
+ count: doc.segments.length,
599
+ }
600
+ }
601
+
602
+ throw new CatError('INVALID_EXPORT_FORMAT', `不支持的导出格式: ${format}`)
603
+ }
604
+
605
+ }