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.
@@ -37,6 +37,10 @@
37
37
  check: ['M3 8.5l3.2 3.2L13 4.3'],
38
38
  align: ['M2 5h8', 'M2 11h5', 'M12 3v10', 'M10 11l2 2 2-2'],
39
39
  folder: ['M2 4h4l1.5 2H14v6.5H2z'],
40
+ chart: ['M3 13V8', 'M8 13V3', 'M13 13V6', 'M2 14h12'],
41
+ message: ['M2 3h12v8H8l-3 3v-3H2z'],
42
+ lock: ['M4 7h8v6H4z', 'M6 7V5a2 2 0 0 1 4 0v2'],
43
+ unlock: ['M4 7h8v6H4z', 'M6 7V5a2 2 0 0 1 3.8-1'],
40
44
  }
41
45
  const shape = paths[name] || paths.file
42
46
  return h(
@@ -302,6 +306,7 @@
302
306
  pre,
303
307
  onPreTranslate,
304
308
  onCancelPreTranslate,
309
+ onReplace,
305
310
  view,
306
311
  onView,
307
312
  }) {
@@ -407,6 +412,19 @@
407
412
  h('span', { className: 'cait-btn-label' }, t('pretranslate.start')),
408
413
  )
409
414
  : null,
415
+ documentsView
416
+ ? h(
417
+ 'button',
418
+ {
419
+ className: 'cait-btn',
420
+ onClick: onReplace,
421
+ disabled: Boolean(busy) || !doc,
422
+ title: t('replace.open'),
423
+ },
424
+ h(Icon, { name: 'search', size: 14 }),
425
+ h('span', { className: 'cait-btn-label' }, t('replace.open')),
426
+ )
427
+ : null,
410
428
  documentsView
411
429
  ? h(
412
430
  'button',
@@ -419,7 +437,7 @@
419
437
  ? h(
420
438
  'button',
421
439
  { className: 'cait-btn', onClick: onImport, disabled: Boolean(busy), title: t('nav.import') },
422
- h(Icon, { name: 'upload', size: 14 }),
440
+ h(Icon, { name: 'download', size: 14 }),
423
441
  h('span', { className: 'cait-btn-label' }, t('nav.import')),
424
442
  )
425
443
  : null,
@@ -427,7 +445,7 @@
427
445
  ? h(
428
446
  'button',
429
447
  { className: 'cait-btn', onClick: onExport, disabled: Boolean(busy) || !doc, title: t('nav.export') },
430
- h(Icon, { name: 'download', size: 14 }),
448
+ h(Icon, { name: 'upload', size: 14 }),
431
449
  h('span', { className: 'cait-btn-label' }, t('nav.export')),
432
450
  )
433
451
  : null,
@@ -436,9 +454,9 @@
436
454
  )
437
455
  }
438
456
 
439
- const SEGMENT_FILTERS = ['all', 'pending', 'translated', 'confirmed']
457
+ const SEGMENT_FILTERS = ['all', 'pending', 'translated', 'confirmed', 'risk']
440
458
 
441
- function SegmentRail({ t, doc, selected, query, setQuery, filter, setFilter, onSelect, onConfirmAll, onPropagate, busy, qa }) {
459
+ function SegmentRail({ t, doc, selected, query, setQuery, filter, setFilter, onSelect, onConfirmAll, onPropagate, onClearPretranslated, busy, qa }) {
442
460
  const activeRef = useRef(null)
443
461
  const storage = typeof localStorage !== 'undefined' ? localStorage : null
444
462
  const [pageSize, setPageSize] = useState(() => readPageSize(storage))
@@ -458,12 +476,15 @@
458
476
  list.push(issue.code)
459
477
  warns.set(issue.index, list)
460
478
  }
479
+ const riskByIndex = new Map()
480
+ for (const item of qa?.risks || []) riskByIndex.set(item.index, item)
461
481
 
462
482
  const counts = {
463
483
  all: doc.progress.total,
464
484
  pending: doc.progress.pending,
465
485
  translated: doc.progress.translated,
466
486
  confirmed: doc.progress.confirmed,
487
+ risk: riskByIndex.size,
467
488
  }
468
489
 
469
490
  // How many empty segments could be filled from a sibling with the same
@@ -482,10 +503,17 @@
482
503
  if (list.length < 2 || !list.some((segment) => segment.target)) continue
483
504
  duplicated += list.filter((segment) => !segment.target).length
484
505
  }
506
+ const pretranslated = doc.segments.filter((segment) => (
507
+ segment.target &&
508
+ !segment.confirmedAt &&
509
+ ['tm', 'mt', 'auto'].includes(segment.origin)
510
+ )).length
485
511
  const needle = query.trim().toLowerCase()
486
512
  const filtered = doc.segments
487
513
  .map((segment, index) => ({ segment, index }))
488
- .filter(({ segment }) => filter === 'all' || segment.status === filter)
514
+ .filter(({ segment, index }) =>
515
+ filter === 'all' || (filter === 'risk' ? riskByIndex.has(index) : segment.status === filter),
516
+ )
489
517
  .filter(({ segment }) => {
490
518
  if (!needle) return true
491
519
  return (
@@ -602,6 +630,19 @@
602
630
  `${t('rail.propagate')} · ${duplicated}`,
603
631
  )
604
632
  : null,
633
+ pretranslated > 0
634
+ ? h(
635
+ 'button',
636
+ {
637
+ className: 'cait-batchbtn cait-batchbtn-danger',
638
+ onClick: onClearPretranslated,
639
+ disabled: Boolean(busy),
640
+ title: t('rail.clearPretranslatedHint'),
641
+ },
642
+ h(Icon, { name: 'trash', size: 11 }),
643
+ `${t('rail.clearPretranslated')} · ${pretranslated}`,
644
+ )
645
+ : null,
605
646
  h(
606
647
  'div',
607
648
  { className: 'cait-rail-goto' },
@@ -664,7 +705,7 @@
664
705
  h('span', { className: `cait-dot ${segment.status}` }),
665
706
  warns.has(index)
666
707
  ? h('span', {
667
- className: 'cait-seg-warn',
708
+ className: `cait-seg-warn ${riskByIndex.get(index)?.risk || 'low'}`,
668
709
  title: (warns.get(index) || []).map((code) => t(`qa.code.${code}`)).join(' · '),
669
710
  'aria-label': t('qa.warn'),
670
711
  })
@@ -720,7 +761,7 @@
720
761
  )
721
762
  }
722
763
 
723
- function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onNext, onPrev, onSave, onAdopt, onApplyTerm, textareaRef }) {
764
+ function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onUnconfirm, onNext, onPrev, onSave, onUndo, onRedo, canUndo, canRedo, onToggleLock, onMerge, onSplit, onAdopt, onApplyTerm, onOpenBrief, onOpenResources, onOpenAnalysis, onOpenNotes, noteCount, qaRisk, textareaRef }) {
724
765
  const hits = detail?.index === index ? detail.glossaryHits : []
725
766
  const charCount = Array.from(draft || '').length
726
767
  const progress = doc.progress
@@ -737,9 +778,31 @@
737
778
  // against. It is stored in `suggestion`, which adoption already preserves.
738
779
  const baselineText = segment.suggestion || ''
739
780
  const baselineOrigin = segment.origin || 'agent'
781
+ const canMerge = !segment.locked && index < doc.segments.length - 1
782
+ const canSplit = !segment.locked && String(segment.source || '').length > 1
783
+ const splitAtCursor = () => {
784
+ const sourceLength = String(segment.source || '').length
785
+ const targetLength = String(draft || '').length
786
+ const cursor = textareaRef?.current?.selectionStart
787
+ const ratio = targetLength > 0 && Number.isInteger(cursor) ? cursor / targetLength : 0.5
788
+ const at = Math.max(1, Math.min(sourceLength - 1, Math.round(sourceLength * ratio)))
789
+ onSplit(at)
790
+ }
740
791
 
741
- const onKeyDown = (event) => { const mod = event.metaKey || event.ctrlKey
742
- if (mod && event.key === 'Enter') {
792
+ const onKeyDown = (event) => {
793
+ const mod = event.metaKey || event.ctrlKey
794
+ if (mod && event.key.toLowerCase() === 'z') {
795
+ if (canUndo || canRedo) {
796
+ event.preventDefault()
797
+ if (event.shiftKey) onRedo()
798
+ else onUndo()
799
+ }
800
+ } else if (mod && event.key.toLowerCase() === 'y') {
801
+ if (canRedo) {
802
+ event.preventDefault()
803
+ onRedo()
804
+ }
805
+ } else if (mod && event.key === 'Enter') {
743
806
  event.preventDefault()
744
807
  onConfirm()
745
808
  } else if (mod && event.key.toLowerCase() === 's') {
@@ -771,6 +834,37 @@
771
834
  h(Icon, { name: 'arrowRight', size: 11 }),
772
835
  h('span', { className: 'cait-langchip' }, doc.targetLang),
773
836
  h('span', null, `· ${doc.segments.length} ${t('doc.segments')}`),
837
+ h(
838
+ 'button',
839
+ {
840
+ className: 'cait-linkbtn cait-brieflink',
841
+ onClick: onOpenBrief,
842
+ title: doc.brief ? t('panel.briefHint') : t('brief.empty'),
843
+ },
844
+ h(Icon, { name: 'file', size: 11 }),
845
+ t('editor.brief'),
846
+ doc.brief ? h('span', { className: 'cait-briefdot' }) : null,
847
+ ),
848
+ h(
849
+ 'button',
850
+ {
851
+ className: 'cait-linkbtn',
852
+ onClick: onOpenResources,
853
+ title: t('resources.hint'),
854
+ },
855
+ h(Icon, { name: 'database', size: 11 }),
856
+ t('editor.resources'),
857
+ ),
858
+ h(
859
+ 'button',
860
+ {
861
+ className: 'cait-linkbtn',
862
+ onClick: onOpenAnalysis,
863
+ title: t('analysis.hint'),
864
+ },
865
+ h(Icon, { name: 'chart', size: 11 }),
866
+ t('editor.analysis'),
867
+ ),
774
868
  ),
775
869
  ),
776
870
  h(
@@ -785,7 +879,40 @@
785
879
  'div',
786
880
  { className: 'cait-seg-head' },
787
881
  h('span', { className: 'cait-seg-label' }, `${t('editor.segment')} ${index + 1} / ${doc.segments.length}`),
788
- h('span', { className: `cait-badge ${segment.status}` }, statusLabel),
882
+ h(
883
+ 'span',
884
+ { className: 'cait-seg-flags' },
885
+ h(
886
+ 'button',
887
+ {
888
+ className: `cait-linkbtn ${segment.locked ? 'active' : ''}`,
889
+ onClick: onToggleLock,
890
+ title: segment.locked ? t('editor.unlock') : t('editor.lock'),
891
+ 'aria-pressed': Boolean(segment.locked),
892
+ },
893
+ h(Icon, { name: segment.locked ? 'unlock' : 'lock', size: 11 }),
894
+ segment.locked ? t('editor.locked') : t('editor.lock'),
895
+ ),
896
+ h(
897
+ 'button',
898
+ {
899
+ className: 'cait-linkbtn cait-notelink',
900
+ onClick: onOpenNotes,
901
+ title: t('notes.hint'),
902
+ },
903
+ h(Icon, { name: 'message', size: 11 }),
904
+ t('editor.notes'),
905
+ noteCount ? h('span', { className: 'cait-notecount' }, String(noteCount)) : null,
906
+ ),
907
+ qaRisk
908
+ ? h(
909
+ 'span',
910
+ { className: `cait-riskchip ${qaRisk.risk}`, title: qaRisk.codes.map((code) => t(`qa.code.${code}`)).join(' · ') },
911
+ `${t('qa.risk')} · ${t(`qa.${qaRisk.risk}`)}`,
912
+ )
913
+ : null,
914
+ h('span', { className: `cait-badge ${segment.status}` }, statusLabel),
915
+ ),
789
916
  ),
790
917
  h(
791
918
  'div',
@@ -868,6 +995,7 @@
868
995
  value: draft,
869
996
  onChange: (event) => setDraft(event.target.value),
870
997
  onKeyDown,
998
+ readOnly: Boolean(segment.locked),
871
999
  placeholder: t('editor.targetPlaceholder'),
872
1000
  spellCheck: false,
873
1001
  rows: 8,
@@ -879,10 +1007,18 @@
879
1007
  { className: 'cait-actionbar' },
880
1008
  h(
881
1009
  'button',
882
- { className: 'cait-btn cait-btn-primary', onClick: onConfirm, disabled: Boolean(busy) || !draft.trim() },
1010
+ { className: 'cait-btn cait-btn-primary', onClick: onConfirm, disabled: Boolean(busy) || Boolean(segment.locked) || !draft.trim() },
883
1011
  busy === 'confirm' ? h(Spinner) : h(Icon, { name: 'check', size: 14 }),
884
1012
  segment.status === 'confirmed' ? t('editor.update') : t('editor.confirm'),
885
1013
  ),
1014
+ segment.status === 'confirmed'
1015
+ ? h(
1016
+ 'button',
1017
+ { className: 'cait-btn cait-btn-ghost', onClick: onUnconfirm, disabled: Boolean(busy) || Boolean(segment.locked), title: t('editor.unconfirmHint') },
1018
+ h(Icon, { name: 'refresh', size: 13 }),
1019
+ t('editor.unconfirm'),
1020
+ )
1021
+ : null,
886
1022
  h(
887
1023
  'button',
888
1024
  { className: 'cait-btn', onClick: onNext, disabled: Boolean(busy) },
@@ -897,7 +1033,27 @@
897
1033
  ),
898
1034
  h(
899
1035
  'button',
900
- { className: 'cait-iconbtn cait-savebtn', onClick: onSave, disabled: Boolean(busy) || !dirty, title: t('editor.save') },
1036
+ { className: 'cait-btn cait-btn-ghost', onClick: onMerge, disabled: Boolean(busy) || !canMerge, title: t('editor.mergeHint') },
1037
+ t('editor.merge'),
1038
+ ),
1039
+ h(
1040
+ 'button',
1041
+ { className: 'cait-btn cait-btn-ghost', onClick: splitAtCursor, disabled: Boolean(busy) || !canSplit, title: t('editor.splitHint') },
1042
+ t('editor.split'),
1043
+ ),
1044
+ h(
1045
+ 'button',
1046
+ { className: 'cait-iconbtn', onClick: onUndo, disabled: Boolean(busy) || !canUndo, title: t('editor.undo'), 'aria-label': t('editor.undo') },
1047
+ '↶',
1048
+ ),
1049
+ h(
1050
+ 'button',
1051
+ { className: 'cait-iconbtn', onClick: onRedo, disabled: Boolean(busy) || !canRedo, title: t('editor.redo'), 'aria-label': t('editor.redo') },
1052
+ '↷',
1053
+ ),
1054
+ h(
1055
+ 'button',
1056
+ { className: 'cait-iconbtn cait-savebtn', onClick: onSave, disabled: Boolean(busy) || Boolean(segment.locked) || !dirty, title: t('editor.save') },
901
1057
  h(Icon, { name: 'save', size: 14 }),
902
1058
  ),
903
1059
  h('span', { className: 'cait-hint' }, t('editor.hint')),
@@ -1020,6 +1176,19 @@
1020
1176
  onCopy,
1021
1177
  onOpenSettings,
1022
1178
  onSearchLibrary,
1179
+ qa,
1180
+ onRefreshQa,
1181
+ onJumpQa,
1182
+ onFixQa,
1183
+ review,
1184
+ reviewBusy,
1185
+ onReview,
1186
+ onApplyReview,
1187
+ concordance,
1188
+ concordanceQuery,
1189
+ setConcordanceQuery,
1190
+ onConcordance,
1191
+ onApplyConcordance,
1023
1192
  busy,
1024
1193
  }) {
1025
1194
  const [termQuery, setTermQuery] = useState('')
@@ -1099,6 +1268,19 @@
1099
1268
  h(Icon, { name: 'sparkles', size: 13 }),
1100
1269
  t('panel.mt'),
1101
1270
  ),
1271
+ h(
1272
+ 'button',
1273
+ { className: `cait-tab${tab === 'qa' ? ' active' : ''}`, onClick: () => setTab('qa') },
1274
+ h(Icon, { name: 'alert', size: 13 }),
1275
+ t('panel.qa'),
1276
+ qa?.issueCount ? h('span', { className: 'cait-count' }, qa.issueCount) : null,
1277
+ ),
1278
+ h(
1279
+ 'button',
1280
+ { className: `cait-tab${tab === 'concordance' ? ' active' : ''}`, onClick: () => setTab('concordance') },
1281
+ h(Icon, { name: 'search', size: 13 }),
1282
+ t('panel.concordance'),
1283
+ ),
1102
1284
  ),
1103
1285
  h(
1104
1286
  'div',
@@ -1176,7 +1358,7 @@
1176
1358
  event.target.value = ''
1177
1359
  },
1178
1360
  }),
1179
- h('button', { className: 'cait-btn', onClick: () => tmxInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'upload', size: 12 }), t('panel.importTmx')),
1361
+ h('button', { className: 'cait-btn', onClick: () => tmxInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'download', size: 12 }), t('panel.importTmx')),
1180
1362
  h('button', { className: 'cait-btn cait-btn-danger', onClick: onClearTm, disabled: Boolean(busy) || data.tm.count === 0 }, t('panel.clearTm')),
1181
1363
  ),
1182
1364
  )
@@ -1194,7 +1376,7 @@
1194
1376
  h(
1195
1377
  'button',
1196
1378
  { key: hit.id, className: 'cait-chip cait-chip-action', onClick: () => onApplyTerm(hit), title: hit.note || hit.target },
1197
- `${hit.term} → ${hit.target}`,
1379
+ `${hit.term} → ${hit.target}${hit.forbidden ? ` · ${t('panel.termForbidden')}` : hit.preferred ? ` · ${t('panel.termPreferred')}` : ''}`,
1198
1380
  ),
1199
1381
  ),
1200
1382
  )
@@ -1227,7 +1409,7 @@
1227
1409
  event.target.value = ''
1228
1410
  },
1229
1411
  }),
1230
- h('button', { className: 'cait-btn', onClick: () => glossaryInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'upload', size: 12 }), t('panel.importCsv')),
1412
+ h('button', { className: 'cait-btn', onClick: () => glossaryInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'download', size: 12 }), t('panel.importCsv')),
1231
1413
  h('button', { className: 'cait-btn cait-btn-danger', onClick: onClearGlossary, disabled: Boolean(busy) || data.glossary.length === 0 }, t('panel.clearGlossary')),
1232
1414
  ),
1233
1415
  )
@@ -1292,6 +1474,239 @@
1292
1474
  ),
1293
1475
  )
1294
1476
  : null,
1477
+ tab === 'qa'
1478
+ ? h(
1479
+ 'div',
1480
+ null,
1481
+ h(
1482
+ 'div',
1483
+ { className: 'cait-subhead' },
1484
+ h('span', null, t('qa.title')),
1485
+ h(
1486
+ 'div',
1487
+ { className: 'cait-subhead-actions' },
1488
+ h(
1489
+ 'button',
1490
+ {
1491
+ className: 'cait-linkbtn',
1492
+ onClick: onReview,
1493
+ disabled: Boolean(busy) || Boolean(reviewBusy),
1494
+ title: t('qa.review'),
1495
+ },
1496
+ h(Icon, { name: 'sparkles', size: 12 }),
1497
+ reviewBusy ? t('qa.reviewing') : t('qa.review'),
1498
+ ),
1499
+ h(
1500
+ 'button',
1501
+ { className: 'cait-linkbtn', onClick: onRefreshQa, disabled: Boolean(busy) },
1502
+ h(Icon, { name: 'refresh', size: 12 }),
1503
+ t('qa.refresh'),
1504
+ ),
1505
+ ),
1506
+ ),
1507
+ qa
1508
+ ? h(
1509
+ 'div',
1510
+ null,
1511
+ h(
1512
+ 'div',
1513
+ { className: 'cait-riskcounts' },
1514
+ ['high', 'medium', 'low'].map((level) =>
1515
+ h(
1516
+ 'span',
1517
+ { key: level, className: `cait-riskcount ${level}` },
1518
+ `${t(`qa.${level}`)} ${qa.riskCounts?.[level] || 0}`,
1519
+ ),
1520
+ ),
1521
+ ),
1522
+ (qa.risks || []).length
1523
+ ? qa.risks.map((item) => {
1524
+ const segment = doc.segments[item.index]
1525
+ const codes = (item.codes || []).map((code) => t(`qa.code.${code}`)).join(' · ')
1526
+ return h(
1527
+ 'div',
1528
+ {
1529
+ key: item.index,
1530
+ className: `cait-riskrow ${item.risk}`,
1531
+ role: 'button',
1532
+ tabIndex: 0,
1533
+ onClick: () => onJumpQa(item.index),
1534
+ onKeyDown: (event) => {
1535
+ if (event.key === 'Enter' || event.key === ' ') {
1536
+ event.preventDefault()
1537
+ onJumpQa(item.index)
1538
+ }
1539
+ },
1540
+ title: codes,
1541
+ },
1542
+ h('span', { className: 'cait-riskrow-index' }, `${item.index + 1}`),
1543
+ h(
1544
+ 'div',
1545
+ { className: 'cait-riskrow-body' },
1546
+ h('span', { className: 'cait-riskrow-source' }, segment?.source || ''),
1547
+ h('span', { className: 'cait-riskrow-codes' }, codes),
1548
+ onFixQa
1549
+ ? h(
1550
+ 'div',
1551
+ { className: 'cait-riskactions' },
1552
+ h(
1553
+ 'button',
1554
+ {
1555
+ className: 'cait-riskaction',
1556
+ onClick: (event) => {
1557
+ event.stopPropagation()
1558
+ onFixQa(item, 'copy-source')
1559
+ },
1560
+ disabled: Boolean(busy),
1561
+ },
1562
+ t('qa.fixCopy'),
1563
+ ),
1564
+ h(
1565
+ 'button',
1566
+ {
1567
+ className: 'cait-riskaction',
1568
+ onClick: (event) => {
1569
+ event.stopPropagation()
1570
+ onFixQa(item, 'clear')
1571
+ },
1572
+ disabled: Boolean(busy),
1573
+ },
1574
+ t('qa.fixClear'),
1575
+ ),
1576
+ item.termTarget
1577
+ ? h(
1578
+ 'button',
1579
+ {
1580
+ className: 'cait-riskaction',
1581
+ onClick: (event) => {
1582
+ event.stopPropagation()
1583
+ onFixQa(item, 'term')
1584
+ },
1585
+ disabled: Boolean(busy),
1586
+ },
1587
+ t('qa.fixTerm'),
1588
+ )
1589
+ : null,
1590
+ )
1591
+ : null,
1592
+ ),
1593
+ h(Icon, { name: 'chevronRight', size: 12 }),
1594
+ )
1595
+ })
1596
+ : h('p', { className: 'cait-empty-note' }, t('qa.empty')),
1597
+ )
1598
+ : h('p', { className: 'cait-empty-note' }, t('qa.loading')),
1599
+ review
1600
+ ? h(
1601
+ 'div',
1602
+ { className: 'cait-review' },
1603
+ h('div', { className: 'cait-listhead' }, t('qa.reviewTitle')),
1604
+ (review.issues || []).length
1605
+ ? review.issues.map((issue, issueIndex) => {
1606
+ const severity = ['high', 'medium', 'low'].includes(issue.severity)
1607
+ ? issue.severity
1608
+ : 'medium'
1609
+ return h(
1610
+ 'div',
1611
+ {
1612
+ key: `${issue.index}-${issueIndex}`,
1613
+ className: 'cait-reviewrow',
1614
+ role: 'button',
1615
+ tabIndex: 0,
1616
+ onClick: () => onJumpQa(issue.index),
1617
+ onKeyDown: (event) => {
1618
+ if (event.key === 'Enter' || event.key === ' ') {
1619
+ event.preventDefault()
1620
+ onJumpQa(issue.index)
1621
+ }
1622
+ },
1623
+ },
1624
+ h('span', { className: 'cait-reviewrow-index' }, `${issue.index + 1}`),
1625
+ h(
1626
+ 'div',
1627
+ { className: 'cait-reviewrow-body' },
1628
+ h(
1629
+ 'span',
1630
+ { className: `cait-reviewrow-msg ${severity}` },
1631
+ `[${t(`qa.${severity}`)}] ${issue.message}`,
1632
+ ),
1633
+ issue.suggestion
1634
+ ? h(
1635
+ 'button',
1636
+ {
1637
+ className: 'cait-riskaction',
1638
+ onClick: (event) => {
1639
+ event.stopPropagation()
1640
+ onApplyReview(issue)
1641
+ },
1642
+ },
1643
+ t('qa.reviewApply'),
1644
+ )
1645
+ : null,
1646
+ ),
1647
+ )
1648
+ })
1649
+ : h('p', { className: 'cait-empty-note' }, t('qa.reviewEmpty')),
1650
+ )
1651
+ : null,
1652
+ )
1653
+ : null,
1654
+ tab === 'concordance'
1655
+ ? h(
1656
+ 'div',
1657
+ null,
1658
+ h(
1659
+ 'form',
1660
+ {
1661
+ className: 'cait-search',
1662
+ onSubmit: (event) => {
1663
+ event.preventDefault()
1664
+ onConcordance()
1665
+ },
1666
+ },
1667
+ h(Icon, { name: 'search', size: 13 }),
1668
+ h('input', {
1669
+ value: concordanceQuery,
1670
+ onChange: (event) => setConcordanceQuery(event.target.value),
1671
+ placeholder: t('panel.concordancePlaceholder'),
1672
+ 'aria-label': t('panel.concordancePlaceholder'),
1673
+ }),
1674
+ h(
1675
+ 'button',
1676
+ { className: 'cait-iconbtn', type: 'submit', title: t('panel.searchConcordance') },
1677
+ h(Icon, { name: 'arrowRight', size: 13 }),
1678
+ ),
1679
+ ),
1680
+ h('p', { className: 'cait-mt-hint' }, t('panel.concordanceHint')),
1681
+ concordance
1682
+ ? h(
1683
+ 'div',
1684
+ null,
1685
+ h(
1686
+ 'div',
1687
+ { className: 'cait-listhead' },
1688
+ h('span', null, t('panel.concordance')),
1689
+ h('span', { className: 'cait-count' }, concordance.total ?? concordance.entries?.length ?? 0),
1690
+ ),
1691
+ (concordance.entries || []).length
1692
+ ? concordance.entries.map((entry, i) =>
1693
+ h(
1694
+ 'button',
1695
+ {
1696
+ key: `${entry.tmId || entry.library || 'c'}-${i}`,
1697
+ className: 'cait-concordance-row',
1698
+ onClick: () => onApplyConcordance(entry),
1699
+ },
1700
+ h('span', { className: 'cait-concordance-source' }, entry.source),
1701
+ h('span', { className: 'cait-concordance-target' }, entry.target),
1702
+ h('span', { className: 'cait-concordance-meta' }, entry.library || ''),
1703
+ ),
1704
+ )
1705
+ : h('p', { className: 'cait-empty-note' }, t('panel.noConcordance')),
1706
+ )
1707
+ : null,
1708
+ )
1709
+ : null,
1295
1710
  ),
1296
1711
  )
1297
1712
  }
@@ -1766,6 +2181,7 @@
1766
2181
  onCreateLibrary,
1767
2182
  onRenameLibrary,
1768
2183
  onDeleteLibrary,
2184
+ onSetMounts,
1769
2185
  }) {
1770
2186
  const storage = typeof localStorage !== 'undefined' ? localStorage : null
1771
2187
  const [kind, setKind] = useState(seed?.kind === 'glossary' ? 'glossary' : 'tm')
@@ -1793,7 +2209,23 @@
1793
2209
  const idKey = kind === 'tm' ? 'tmId' : 'glossaryId'
1794
2210
  const labelKey = 'name'
1795
2211
  const scopeKey = scopeIds.join(',')
1796
- const targetLibraryId = scopeIds[0] || (kind === 'tm' ? data?.mounts?.defaultTmId : data?.mounts?.defaultGlossaryId) || libraries[0]?.[idKey] || ''
2212
+ const defaultWriteId = kind === 'tm' ? data?.mounts?.defaultTmId : data?.mounts?.defaultGlossaryId
2213
+ const targetLibraryId = libraries.some((library) => library[idKey] === defaultWriteId)
2214
+ ? defaultWriteId
2215
+ : libraries[0]?.[idKey] || ''
2216
+
2217
+ const changeWriteLibrary = async (value) => {
2218
+ if (!value || value === targetLibraryId) return
2219
+ // A default must also be mounted; adding it here keeps the selector
2220
+ // from failing when the chosen library was only in the list view.
2221
+ if (kind === 'tm') {
2222
+ const tmIds = Array.from(new Set([...(data?.mounts?.tmIds || []), value]))
2223
+ await onSetMounts?.({ tmIds, defaultTmId: value })
2224
+ } else {
2225
+ const glossaryIds = Array.from(new Set([...(data?.mounts?.glossaryIds || []), value]))
2226
+ await onSetMounts?.({ glossaryIds, defaultGlossaryId: value })
2227
+ }
2228
+ }
1797
2229
 
1798
2230
  useEffect(() => {
1799
2231
  const timer = setTimeout(() => setDebouncedQuery(query), 250)
@@ -2034,6 +2466,35 @@
2034
2466
  })
2035
2467
  }
2036
2468
 
2469
+ const maintainTm = async (action) => {
2470
+ if (!targetLibraryId) return
2471
+ try {
2472
+ const preview = await rpc('tmMaintenancePreview', {
2473
+ action,
2474
+ tmId: targetLibraryId,
2475
+ emptyTarget: true,
2476
+ })
2477
+ const count = action === 'dedupe' ? preview.duplicates : preview.matched
2478
+ onConfirm({
2479
+ title: t(`tm.maintain.${action}`),
2480
+ message: t(`tm.maintain.${action}Hint`).replace('{count}', String(count)),
2481
+ confirmLabel: t(`tm.maintain.${action}`),
2482
+ action: async () => {
2483
+ const result = await rpc(action === 'dedupe' ? 'dedupeTmLibrary' : 'pruneTmLibrary', {
2484
+ tmId: targetLibraryId,
2485
+ emptyTarget: true,
2486
+ confirm: true,
2487
+ })
2488
+ if (result?.state) onApplyState(result.state)
2489
+ await loadRows()
2490
+ notify(t('tm.maintain.done').replace('{count}', String(count)))
2491
+ },
2492
+ })
2493
+ } catch (error) {
2494
+ report(error)
2495
+ }
2496
+ }
2497
+
2037
2498
  const exportLibrary = async () => {
2038
2499
  if (!targetLibraryId) return
2039
2500
  setWorking('export')
@@ -2306,6 +2767,28 @@
2306
2767
  ['tbx', 'csv', 'json'].map((format) => h('option', { key: format, value: format }, format.toUpperCase())),
2307
2768
  )
2308
2769
  : null,
2770
+ h(
2771
+ 'label',
2772
+ { className: 'cait-libwrite', title: t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary') },
2773
+ h(
2774
+ 'span',
2775
+ { className: 'cait-libwritelabel' },
2776
+ t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary'),
2777
+ ),
2778
+ h(
2779
+ 'select',
2780
+ {
2781
+ className: 'cait-liblang',
2782
+ value: targetLibraryId,
2783
+ onChange: (event) => changeWriteLibrary(event.target.value),
2784
+ disabled: Boolean(busy) || !libraries.length,
2785
+ 'aria-label': t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary'),
2786
+ },
2787
+ libraries.map((library) =>
2788
+ h('option', { key: library[idKey], value: library[idKey] }, library[labelKey]),
2789
+ ),
2790
+ ),
2791
+ ),
2309
2792
  kind === 'glossary'
2310
2793
  ? h(
2311
2794
  'button',
@@ -2335,7 +2818,7 @@
2335
2818
  disabled: Boolean(busy) || !targetLibraryId,
2336
2819
  title: t('library.import'),
2337
2820
  },
2338
- h(Icon, { name: 'upload', size: 13 }),
2821
+ h(Icon, { name: 'download', size: 13 }),
2339
2822
  h('span', { className: 'cait-btn-label' }, t('library.import')),
2340
2823
  ),
2341
2824
  h(
@@ -2347,9 +2830,37 @@
2347
2830
  disabled: Boolean(busy) || working === 'export' || !targetLibraryId,
2348
2831
  title: t('library.export'),
2349
2832
  },
2350
- h(Icon, { name: 'download', size: 13 }),
2833
+ h(Icon, { name: 'upload', size: 13 }),
2351
2834
  h('span', { className: 'cait-btn-label' }, kind === 'tm' ? 'TMX' : exportFormat.toUpperCase()),
2352
2835
  ),
2836
+ kind === 'tm'
2837
+ ? h(
2838
+ 'button',
2839
+ {
2840
+ type: 'button',
2841
+ className: 'cait-btn',
2842
+ onClick: () => maintainTm('dedupe'),
2843
+ disabled: Boolean(busy) || !targetLibraryId,
2844
+ title: t('tm.maintain.dedupe'),
2845
+ },
2846
+ h(Icon, { name: 'database', size: 13 }),
2847
+ h('span', { className: 'cait-btn-label' }, t('tm.maintain.dedupe')),
2848
+ )
2849
+ : null,
2850
+ kind === 'tm'
2851
+ ? h(
2852
+ 'button',
2853
+ {
2854
+ type: 'button',
2855
+ className: 'cait-btn',
2856
+ onClick: () => maintainTm('prune'),
2857
+ disabled: Boolean(busy) || !targetLibraryId,
2858
+ title: t('tm.maintain.prune'),
2859
+ },
2860
+ h(Icon, { name: 'trash', size: 13 }),
2861
+ h('span', { className: 'cait-btn-label' }, t('tm.maintain.prune')),
2862
+ )
2863
+ : null,
2353
2864
  h(
2354
2865
  'button',
2355
2866
  {
@@ -2360,6 +2871,7 @@
2360
2871
  title: t('library.clear'),
2361
2872
  },
2362
2873
  h(Icon, { name: 'trash', size: 13 }),
2874
+ h('span', { className: 'cait-btn-label' }, t('library.clear')),
2363
2875
  ),
2364
2876
  h('input', {
2365
2877
  ref: fileRef,
@@ -2612,6 +3124,7 @@
2612
3124
  onChange: () => toggleTerm(at),
2613
3125
  }),
2614
3126
  h('span', { className: 'cait-termname' }, candidate.term),
3127
+ candidate.proper ? h('span', { className: 'cait-originchip' }, t('term.proper')) : null,
2615
3128
  h('span', { className: 'cait-termcount' }, `×${candidate.count}`),
2616
3129
  candidate.inLibrary ? h('span', { className: 'cait-originchip' }, t('term.inLibrary')) : null,
2617
3130
  h('input', {
@@ -2629,7 +3142,7 @@
2629
3142
  )
2630
3143
  }
2631
3144
 
2632
- function ExportModal({ t, busy, onClose, onExport, qa }) {
3145
+ function ExportModal({ t, busy, onClose, onExport, onImportReview, qa }) {
2633
3146
  const issues = qa?.issueCount || 0
2634
3147
  const items = [
2635
3148
  ['bilingual-md', t('modal.export.bilingualMd'), t('modal.export.bilingual'), 'file'],
@@ -2638,11 +3151,13 @@
2638
3151
  ['target-txt', t('modal.export.targetTxt'), t('modal.export.target'), 'download'],
2639
3152
  ['target-md', t('modal.export.targetMd'), t('modal.export.target'), 'download'],
2640
3153
  ['docx', t('modal.export.docx'), t('modal.export.docxGroup'), 'file'],
3154
+ ['bilingual-docx', t('modal.export.bilingualDocx'), t('modal.export.bilingual'), 'file'],
2641
3155
  ['epub', t('modal.export.epub'), t('modal.export.epubGroup'), 'file'],
2642
3156
  ['srt', t('modal.export.srt'), t('modal.export.subtitleGroup'), 'file'],
2643
3157
  ['vtt', t('modal.export.vtt'), t('modal.export.subtitleGroup'), 'file'],
2644
3158
  ['po', t('modal.export.po'), t('modal.export.localizationGroup'), 'file'],
2645
3159
  ['xliff', t('modal.export.xliff'), t('modal.export.localizationGroup'), 'file'],
3160
+ ['xliff-review', t('modal.export.xliffReview'), t('modal.export.localizationGroup'), 'file'],
2646
3161
  ['xliff2', t('modal.export.xliff2'), t('modal.export.localizationGroup'), 'file'],
2647
3162
  ['sdlxliff', t('modal.export.sdlxliff'), t('modal.export.localizationGroup'), 'file'],
2648
3163
  ['html', t('modal.export.html'), t('modal.export.webGroup'), 'file'],
@@ -2685,6 +3200,30 @@
2685
3200
  ),
2686
3201
  ),
2687
3202
  ),
3203
+ onImportReview
3204
+ ? h(
3205
+ 'div',
3206
+ { className: 'cait-review-import' },
3207
+ h('p', { className: 'cait-libnote' }, t('modal.export.reviewImportHint')),
3208
+ h(
3209
+ 'label',
3210
+ { className: 'cait-btn' },
3211
+ h(Icon, { name: 'upload', size: 13 }),
3212
+ t('modal.export.reviewImport'),
3213
+ h('input', {
3214
+ type: 'file',
3215
+ accept: '.xliff,.xlf,application/x-xliff+xml',
3216
+ style: { display: 'none' },
3217
+ disabled: Boolean(busy),
3218
+ onChange: (event) => {
3219
+ const file = event.target.files?.[0]
3220
+ if (file) onImportReview(file)
3221
+ event.target.value = ''
3222
+ },
3223
+ }),
3224
+ ),
3225
+ )
3226
+ : null,
2688
3227
  )
2689
3228
  }
2690
3229
 
@@ -2705,13 +3244,23 @@
2705
3244
  model: settings.mt.model,
2706
3245
  temperature: settings.mt.temperature,
2707
3246
  },
3247
+ review: {
3248
+ provider: settings.review?.provider || '',
3249
+ model: settings.review?.model || '',
3250
+ temperature: settings.review?.temperature ?? 0,
3251
+ },
2708
3252
  })
2709
3253
  const routes = Array.isArray(mtRoutes?.providers) ? mtRoutes.providers : []
2710
3254
  const route = routes.find((item) => item.id === form.mt.provider)
2711
3255
  const models = Array.isArray(route?.models) ? route.models : []
2712
3256
  const routeMissing = form.mt.provider !== 'mock' && !route
3257
+ const reviewRoute = routes.find((item) => item.id === form.review.provider)
3258
+ const reviewModels = Array.isArray(reviewRoute?.models) ? reviewRoute.models : []
3259
+ const reviewInherited = !form.review.provider
2713
3260
 
2714
3261
  const setMt = (patch) => setForm((current) => ({ ...current, mt: { ...current.mt, ...patch } }))
3262
+ const setReview = (patch) =>
3263
+ setForm((current) => ({ ...current, review: { ...current.review, ...patch } }))
2715
3264
  const setPre = (patch) =>
2716
3265
  setForm((current) => ({ ...current, preprocess: { ...current.preprocess, ...patch } }))
2717
3266
  const selectProvider = (provider) => {
@@ -2729,6 +3278,18 @@
2729
3278
  }))
2730
3279
  }
2731
3280
 
3281
+ const selectReviewProvider = (provider) => {
3282
+ const nextRoute = routes.find((item) => item.id === provider)
3283
+ setForm((current) => ({
3284
+ ...current,
3285
+ review: {
3286
+ ...current.review,
3287
+ provider,
3288
+ model: nextRoute?.models?.[0]?.id || '',
3289
+ },
3290
+ }))
3291
+ }
3292
+
2732
3293
  const submit = (event) => {
2733
3294
  event.preventDefault()
2734
3295
  const patch = {
@@ -2747,6 +3308,11 @@
2747
3308
  model: form.mt.model,
2748
3309
  temperature: Number(form.mt.temperature),
2749
3310
  },
3311
+ review: {
3312
+ provider: form.review.provider,
3313
+ model: form.review.model,
3314
+ temperature: Number(form.review.temperature),
3315
+ },
2750
3316
  }
2751
3317
  onSave(patch)
2752
3318
  }
@@ -2831,9 +3397,26 @@
2831
3397
  { className: 'cait-settings-section' },
2832
3398
  h('h4', { className: 'cait-settings-title' }, t('modal.settings.tm')),
2833
3399
  h(
2834
- 'label',
3400
+ 'div',
2835
3401
  { className: 'cait-field' },
2836
- h('span', null, `${t('modal.settings.fuzzy')} · ${Math.round(Number(form.fuzzyThreshold) * 100)}%`),
3402
+ h(
3403
+ 'div',
3404
+ { className: 'cait-fieldhead' },
3405
+ h('span', null, `${t('modal.settings.fuzzy')} · ${fuzzyPercent(form.fuzzyThreshold)}%`),
3406
+ // Offered only while the slider is away from the default, so the
3407
+ // control never takes space in the usual case.
3408
+ fuzzyPercent(form.fuzzyThreshold) === fuzzyPercent(DEFAULT_FUZZY_THRESHOLD)
3409
+ ? null
3410
+ : h(
3411
+ 'button',
3412
+ {
3413
+ className: 'cait-linkbtn cait-fieldreset',
3414
+ type: 'button',
3415
+ onClick: () => setForm({ ...form, fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD }),
3416
+ },
3417
+ t('modal.settings.resetDefault'),
3418
+ ),
3419
+ ),
2837
3420
  h('input', {
2838
3421
  className: 'cait-range',
2839
3422
  type: 'range',
@@ -2843,6 +3426,7 @@
2843
3426
  value: form.fuzzyThreshold,
2844
3427
  onChange: (event) => setForm({ ...form, fuzzyThreshold: event.target.value }),
2845
3428
  }),
3429
+ h('p', { className: 'cait-fieldhint' }, t('modal.settings.fuzzyHint')),
2846
3430
  ),
2847
3431
  h(
2848
3432
  'label',
@@ -2938,38 +3522,130 @@
2938
3522
  ),
2939
3523
  h(
2940
3524
  'div',
2941
- { className: 'cait-modal-actions' },
2942
- h('button', { type: 'button', className: 'cait-btn cait-btn-ghost', onClick: onClose }, t('modal.settings.cancel')),
2943
- h('button', { type: 'submit', className: 'cait-btn cait-btn-primary', disabled: Boolean(busy) }, busy === 'settings' ? h(Spinner) : null, t('modal.settings.save')),
2944
- ),
2945
- ),
2946
- )
2947
- }
2948
-
2949
- function ConfirmModal({ t, state, busy, onClose, onConfirm }) {
2950
- return h(
2951
- Modal,
2952
- { title: state.title || t('modal.confirm.title'), onClose },
2953
- h('p', { className: 'cait-confirm-text' }, state.message),
2954
- h(
2955
- 'div',
2956
- { className: 'cait-modal-actions' },
2957
- h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
2958
- h('button', { className: 'cait-btn cait-btn-danger', onClick: onConfirm, disabled: Boolean(busy) }, busy ? h(Spinner) : null, state.confirmLabel || t('modal.confirm.ok')),
2959
- ),
2960
- )
2961
- }
2962
-
2963
- function EmptyState({ t, settings, busy, onCreate, onDemo }) {
2964
- return h(
2965
- 'div',
2966
- { className: 'cait-empty' },
2967
- h(
2968
- 'div',
2969
- { className: 'cait-empty-inner' },
2970
- h('span', { className: 'cait-kicker' }, 'CAIT · CAT WORKBENCH'),
2971
- h('h1', null, t('empty.title')),
2972
- h('p', { className: 'cait-lede' }, t('empty.desc')),
3525
+ { className: 'cait-settings-section' },
3526
+ h('h4', { className: 'cait-settings-title' }, t('modal.settings.review')),
3527
+ h(
3528
+ 'label',
3529
+ { className: 'cait-checkline' },
3530
+ h('input', {
3531
+ type: 'checkbox',
3532
+ checked: reviewInherited,
3533
+ onChange: (event) => {
3534
+ if (event.target.checked) {
3535
+ setForm((current) => ({
3536
+ ...current,
3537
+ review: { ...current.review, provider: '', model: '' },
3538
+ }))
3539
+ return
3540
+ }
3541
+ const first = routes.find((item) => item.id !== 'mock' && item.hasModels !== false) || routes[0]
3542
+ setForm((current) => ({
3543
+ ...current,
3544
+ review: {
3545
+ ...current.review,
3546
+ provider: first?.id || '',
3547
+ model: first?.models?.[0]?.id || '',
3548
+ },
3549
+ }))
3550
+ },
3551
+ }),
3552
+ t('modal.settings.reviewInherit'),
3553
+ ),
3554
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('modal.settings.reviewHint')),
3555
+ reviewInherited
3556
+ ? null
3557
+ : h(
3558
+ 'div',
3559
+ { className: 'cait-grid2' },
3560
+ h(
3561
+ 'label',
3562
+ { className: 'cait-field' },
3563
+ h('span', null, t('modal.settings.reviewProvider')),
3564
+ h(
3565
+ 'select',
3566
+ {
3567
+ className: 'cait-input',
3568
+ value: form.review.provider,
3569
+ onChange: (event) => selectReviewProvider(event.target.value),
3570
+ },
3571
+ routes.map((item) =>
3572
+ h(
3573
+ 'option',
3574
+ { key: item.id, value: item.id, disabled: item.hasModels === false },
3575
+ item.hasModels === false
3576
+ ? `${item.name} (${item.id}) - ${t('modal.settings.noModels')}`
3577
+ : `${item.name} (${item.id})`,
3578
+ ),
3579
+ ),
3580
+ form.review.provider && !reviewRoute
3581
+ ? h('option', { value: form.review.provider }, t('modal.settings.routeMissing'))
3582
+ : null,
3583
+ ),
3584
+ ),
3585
+ h(
3586
+ 'label',
3587
+ { className: 'cait-field' },
3588
+ h('span', null, t('modal.settings.model')),
3589
+ reviewModels.length
3590
+ ? h(
3591
+ 'select',
3592
+ {
3593
+ className: 'cait-input',
3594
+ value: form.review.model,
3595
+ onChange: (event) => setReview({ model: event.target.value }),
3596
+ },
3597
+ reviewModels.map((model) =>
3598
+ h('option', { key: model.id, value: model.id }, `${model.name} (${model.id})`),
3599
+ ),
3600
+ )
3601
+ : h('input', {
3602
+ className: 'cait-input',
3603
+ value: form.review.model,
3604
+ readOnly: true,
3605
+ placeholder: t('modal.settings.noModels'),
3606
+ }),
3607
+ ),
3608
+ ),
3609
+ h(
3610
+ 'p',
3611
+ { className: 'cait-mt-hint', style: { marginTop: 0 } },
3612
+ reviewInherited ? t('modal.settings.reviewInheritedHint') : t('modal.settings.reviewCustomHint'),
3613
+ ),
3614
+ ),
3615
+ h(
3616
+ 'div',
3617
+ { className: 'cait-modal-actions' },
3618
+ h('button', { type: 'button', className: 'cait-btn cait-btn-ghost', onClick: onClose }, t('modal.settings.cancel')),
3619
+ h('button', { type: 'submit', className: 'cait-btn cait-btn-primary', disabled: Boolean(busy) }, busy === 'settings' ? h(Spinner) : null, t('modal.settings.save')),
3620
+ ),
3621
+ ),
3622
+ )
3623
+ }
3624
+
3625
+ function ConfirmModal({ t, state, busy, onClose, onConfirm }) {
3626
+ return h(
3627
+ Modal,
3628
+ { title: state.title || t('modal.confirm.title'), onClose },
3629
+ h('p', { className: 'cait-confirm-text' }, state.message),
3630
+ h(
3631
+ 'div',
3632
+ { className: 'cait-modal-actions' },
3633
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
3634
+ h('button', { className: 'cait-btn cait-btn-danger', onClick: onConfirm, disabled: Boolean(busy) }, busy ? h(Spinner) : null, state.confirmLabel || t('modal.confirm.ok')),
3635
+ ),
3636
+ )
3637
+ }
3638
+
3639
+ function EmptyState({ t, settings, busy, onCreate, onDemo }) {
3640
+ return h(
3641
+ 'div',
3642
+ { className: 'cait-empty' },
3643
+ h(
3644
+ 'div',
3645
+ { className: 'cait-empty-inner' },
3646
+ h('span', { className: 'cait-kicker' }, 'CAIT · CAT WORKBENCH'),
3647
+ h('h1', null, t('empty.title')),
3648
+ h('p', { className: 'cait-lede' }, t('empty.desc')),
2973
3649
  h(
2974
3650
  'div',
2975
3651
  { className: 'cait-features' },
@@ -2988,6 +3664,614 @@
2988
3664
  )
2989
3665
  }
2990
3666
 
3667
+ function BriefModal({ t, doc, busy, onClose, onSave }) {
3668
+ const initial = doc.brief || {}
3669
+ const [form, setForm] = useState({
3670
+ domain: initial.domain || '',
3671
+ audience: initial.audience || '',
3672
+ tone: initial.tone || '',
3673
+ notes: initial.notes || '',
3674
+ doNotTranslate: initial.doNotTranslate || '',
3675
+ styleSample: initial.styleSample || '',
3676
+ })
3677
+ const field = (key, label, placeholder, rows) =>
3678
+ h(
3679
+ 'label',
3680
+ { className: 'cait-field' },
3681
+ h('span', null, t(label)),
3682
+ rows
3683
+ ? h('textarea', {
3684
+ className: 'cait-textarea',
3685
+ rows,
3686
+ value: form[key],
3687
+ placeholder: t(placeholder),
3688
+ onChange: (event) => setForm({ ...form, [key]: event.target.value }),
3689
+ })
3690
+ : h('input', {
3691
+ className: 'cait-input',
3692
+ value: form[key],
3693
+ placeholder: t(placeholder),
3694
+ onChange: (event) => setForm({ ...form, [key]: event.target.value }),
3695
+ }),
3696
+ )
3697
+ return h(
3698
+ Modal,
3699
+ { title: t('brief.title'), onClose, wide: true },
3700
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('brief.hint')),
3701
+ h(
3702
+ 'div',
3703
+ { className: 'cait-briefform' },
3704
+ h('div', { className: 'cait-grid2' }, field('domain', 'brief.domain', 'brief.placeholder.domain'), field('audience', 'brief.audience', 'brief.placeholder.audience')),
3705
+ field('tone', 'brief.tone', 'brief.placeholder.tone'),
3706
+ field('notes', 'brief.notes', 'brief.placeholder.notes', 3),
3707
+ field('doNotTranslate', 'brief.doNotTranslate', 'brief.placeholder.doNotTranslate', 3),
3708
+ field('styleSample', 'brief.styleSample', 'brief.placeholder.styleSample', 4),
3709
+ ),
3710
+ h(
3711
+ 'div',
3712
+ { className: 'cait-modal-actions' },
3713
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
3714
+ h(
3715
+ 'button',
3716
+ { className: 'cait-btn cait-btn-primary', onClick: () => onSave(form), disabled: Boolean(busy) },
3717
+ busy ? h(Spinner) : null,
3718
+ t('brief.save'),
3719
+ ),
3720
+ ),
3721
+ )
3722
+ }
3723
+
3724
+ function NoteModal({ t, doc, index, segment, notes, busy, onAdd, onUpdate, onDelete, onClose }) {
3725
+ const [text, setText] = useState('')
3726
+ const [editingId, setEditingId] = useState('')
3727
+ const [editingText, setEditingText] = useState('')
3728
+ const list = Array.isArray(notes) ? notes : []
3729
+ const submit = () => {
3730
+ const value = text.trim()
3731
+ if (!value || busy) return
3732
+ onAdd(value)
3733
+ setText('')
3734
+ }
3735
+ return h(
3736
+ Modal,
3737
+ { title: `${t('notes.title')} · #${index + 1}`, onClose, wide: true },
3738
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('notes.hint')),
3739
+ h('div', { className: 'cait-notelist' }, segment?.source || ''),
3740
+ list.length
3741
+ ? h(
3742
+ 'div',
3743
+ { className: 'cait-notelist' },
3744
+ list.map((note) =>
3745
+ h(
3746
+ 'div',
3747
+ { className: 'cait-noteitem', key: note.id },
3748
+ h(
3749
+ 'div',
3750
+ { className: 'cait-notehead' },
3751
+ h('span', { className: 'cait-noteauthor' }, note.author === 'agent' ? 'Agent' : t('notes.user')),
3752
+ h('span', { className: 'cait-notetime' }, String(note.createdAt || '').replace('T', ' ').slice(0, 16)),
3753
+ ),
3754
+ editingId === note.id
3755
+ ? h(
3756
+ 'div',
3757
+ null,
3758
+ h('textarea', {
3759
+ className: 'cait-input cait-textarea',
3760
+ value: editingText,
3761
+ rows: 3,
3762
+ onChange: (event) => setEditingText(event.target.value),
3763
+ }),
3764
+ h(
3765
+ 'div',
3766
+ { className: 'cait-noteactions' },
3767
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: () => setEditingId('') }, t('notes.cancel')),
3768
+ h(
3769
+ 'button',
3770
+ {
3771
+ className: 'cait-btn cait-btn-primary',
3772
+ disabled: Boolean(busy) || !editingText.trim(),
3773
+ onClick: () => {
3774
+ onUpdate(note.id, editingText)
3775
+ setEditingId('')
3776
+ },
3777
+ },
3778
+ t('notes.save'),
3779
+ ),
3780
+ ),
3781
+ )
3782
+ : h(
3783
+ 'div',
3784
+ null,
3785
+ h('div', { className: 'cait-notetext' }, note.text),
3786
+ h(
3787
+ 'div',
3788
+ { className: 'cait-noteactions' },
3789
+ h(
3790
+ 'button',
3791
+ {
3792
+ className: 'cait-minibtn',
3793
+ onClick: () => {
3794
+ setEditingId(note.id)
3795
+ setEditingText(note.text)
3796
+ },
3797
+ },
3798
+ t('notes.edit'),
3799
+ ),
3800
+ h(
3801
+ 'button',
3802
+ { className: 'cait-minibtn cait-danger', onClick: () => onDelete(note.id) },
3803
+ t('notes.delete'),
3804
+ ),
3805
+ ),
3806
+ ),
3807
+ ),
3808
+ ),
3809
+ )
3810
+ : h('p', { className: 'cait-mt-hint' }, t('notes.empty')),
3811
+ h(
3812
+ 'div',
3813
+ { className: 'cait-noteadd' },
3814
+ h('textarea', {
3815
+ className: 'cait-input cait-textarea',
3816
+ value: text,
3817
+ rows: 3,
3818
+ placeholder: t('notes.placeholder'),
3819
+ onChange: (event) => setText(event.target.value),
3820
+ }),
3821
+ h(
3822
+ 'div',
3823
+ { className: 'cait-noteactions' },
3824
+ h('button', { className: 'cait-btn cait-btn-primary', disabled: Boolean(busy) || !text.trim(), onClick: submit }, busy ? h(Spinner) : null, t('notes.add')),
3825
+ ),
3826
+ ),
3827
+ )
3828
+ }
3829
+
3830
+ function ReplaceModal({ t, doc, busy, preview, undoCount, onPreview, onApply, onUndo, onClose }) {
3831
+ const queryRef = useRef(null)
3832
+ const [form, setForm] = useState({
3833
+ query: '',
3834
+ replacement: '',
3835
+ scope: 'target',
3836
+ matchCase: false,
3837
+ regex: false,
3838
+ })
3839
+ useEffect(() => {
3840
+ if (queryRef.current) queryRef.current.focus()
3841
+ }, [])
3842
+ const set = (key, value) => setForm((current) => ({ ...current, [key]: value }))
3843
+ return h(
3844
+ Modal,
3845
+ { title: t('replace.title'), onClose, wide: true },
3846
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('replace.hint')),
3847
+ h(
3848
+ 'div',
3849
+ { className: 'cait-replaceform' },
3850
+ h(
3851
+ 'label',
3852
+ { className: 'cait-field' },
3853
+ h('span', null, t('replace.query')),
3854
+ h('input', {
3855
+ ref: queryRef,
3856
+ className: 'cait-input',
3857
+ value: form.query,
3858
+ placeholder: t('replace.queryPlaceholder'),
3859
+ onChange: (event) => set('query', event.target.value),
3860
+ }),
3861
+ ),
3862
+ h(
3863
+ 'label',
3864
+ { className: 'cait-field' },
3865
+ h('span', null, t('replace.replacement')),
3866
+ h('input', {
3867
+ className: 'cait-input',
3868
+ value: form.replacement,
3869
+ placeholder: t('replace.replacementPlaceholder'),
3870
+ onChange: (event) => set('replacement', event.target.value),
3871
+ }),
3872
+ ),
3873
+ h(
3874
+ 'div',
3875
+ { className: 'cait-replaceoptions' },
3876
+ h(
3877
+ 'label',
3878
+ { className: 'cait-field' },
3879
+ h('span', null, t('replace.scope')),
3880
+ h(
3881
+ 'select',
3882
+ {
3883
+ className: 'cait-input',
3884
+ value: form.scope,
3885
+ onChange: (event) => set('scope', event.target.value),
3886
+ },
3887
+ h('option', { value: 'target' }, t('replace.scope.target')),
3888
+ h('option', { value: 'source' }, t('replace.scope.source')),
3889
+ h('option', { value: 'both' }, t('replace.scope.both')),
3890
+ ),
3891
+ ),
3892
+ h(
3893
+ 'label',
3894
+ { className: 'cait-check' },
3895
+ h('input', {
3896
+ type: 'checkbox',
3897
+ checked: form.matchCase,
3898
+ onChange: (event) => set('matchCase', event.target.checked),
3899
+ }),
3900
+ t('replace.matchCase'),
3901
+ ),
3902
+ h(
3903
+ 'label',
3904
+ { className: 'cait-check' },
3905
+ h('input', {
3906
+ type: 'checkbox',
3907
+ checked: form.regex,
3908
+ onChange: (event) => set('regex', event.target.checked),
3909
+ }),
3910
+ t('replace.regex'),
3911
+ ),
3912
+ ),
3913
+ ),
3914
+ preview
3915
+ ? h(
3916
+ 'div',
3917
+ { className: 'cait-replaceresults' },
3918
+ h(
3919
+ 'div',
3920
+ { className: 'cait-replacecounts' },
3921
+ t('replace.results')
3922
+ .replace('{count}', String(preview.total))
3923
+ .replace('{segments}', String(preview.matchedSegments || 0)),
3924
+ ),
3925
+ preview.truncated ? h('p', { className: 'cait-mt-hint' }, t('replace.truncated')) : null,
3926
+ preview.matches?.length
3927
+ ? h(
3928
+ 'div',
3929
+ { className: 'cait-replacelist' },
3930
+ preview.matches.slice(0, 50).map((match, index) =>
3931
+ h(
3932
+ 'div',
3933
+ { className: 'cait-replaceitem', key: `${match.uid}-${match.field}-${index}` },
3934
+ h(
3935
+ 'div',
3936
+ { className: 'cait-replacehead' },
3937
+ h('span', null, `#${match.index + 1}`),
3938
+ h('span', { className: 'cait-replacefield' }, t(`replace.field.${match.field}`)),
3939
+ h('span', { className: 'cait-replacecount' }, `×${match.count}`),
3940
+ ),
3941
+ h('div', { className: 'cait-replacebefore' }, match.before),
3942
+ h('div', { className: 'cait-replaceafter' }, match.after),
3943
+ ),
3944
+ ),
3945
+ )
3946
+ : h('p', { className: 'cait-mt-hint' }, t('replace.noResults')),
3947
+ )
3948
+ : null,
3949
+ h(
3950
+ 'div',
3951
+ { className: 'cait-modal-actions' },
3952
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
3953
+ undoCount
3954
+ ? h('button', { className: 'cait-btn', onClick: onUndo, disabled: Boolean(busy) }, t('replace.undo'))
3955
+ : null,
3956
+ h('button', { className: 'cait-btn', onClick: () => onPreview(form), disabled: Boolean(busy) || !form.query }, t('replace.preview')),
3957
+ h(
3958
+ 'button',
3959
+ { className: 'cait-btn cait-btn-primary', onClick: () => onApply(form), disabled: Boolean(busy) || !form.query || !preview?.total },
3960
+ busy ? h(Spinner) : null,
3961
+ t('replace.apply'),
3962
+ ),
3963
+ ),
3964
+ )
3965
+ }
3966
+
3967
+ function CommandPalette({ t, items, onClose, onRun }) {
3968
+ const [query, setQuery] = useState('')
3969
+ const [active, setActive] = useState(0)
3970
+ const inputRef = useRef(null)
3971
+ useEffect(() => {
3972
+ if (inputRef.current) inputRef.current.focus()
3973
+ }, [])
3974
+ const needle = query.trim().toLowerCase()
3975
+ const filtered = items.filter((item) => {
3976
+ if (!needle) return true
3977
+ return `${item.title} ${item.hint || ''}`.toLowerCase().includes(needle)
3978
+ })
3979
+ useEffect(() => {
3980
+ setActive(0)
3981
+ }, [query])
3982
+ const choose = (item) => {
3983
+ if (item) onRun(item)
3984
+ }
3985
+ return h(
3986
+ Modal,
3987
+ { title: t('palette.title'), onClose, wide: true },
3988
+ h('input', {
3989
+ ref: inputRef,
3990
+ className: 'cait-input cait-palette-input',
3991
+ value: query,
3992
+ placeholder: t('palette.placeholder'),
3993
+ onChange: (event) => setQuery(event.target.value),
3994
+ onKeyDown: (event) => {
3995
+ if (event.key === 'ArrowDown') {
3996
+ event.preventDefault()
3997
+ setActive((value) => Math.min(value + 1, Math.max(0, filtered.length - 1)))
3998
+ } else if (event.key === 'ArrowUp') {
3999
+ event.preventDefault()
4000
+ setActive((value) => Math.max(value - 1, 0))
4001
+ } else if (event.key === 'Enter') {
4002
+ event.preventDefault()
4003
+ choose(filtered[active])
4004
+ }
4005
+ },
4006
+ }),
4007
+ h(
4008
+ 'div',
4009
+ { className: 'cait-palette-list' },
4010
+ filtered.length
4011
+ ? filtered.map((item, index) =>
4012
+ h(
4013
+ 'button',
4014
+ {
4015
+ type: 'button',
4016
+ key: item.id,
4017
+ className: `cait-palette-item${index === active ? ' active' : ''}`,
4018
+ onMouseEnter: () => setActive(index),
4019
+ onClick: () => choose(item),
4020
+ },
4021
+ h('span', { className: 'cait-palette-label' }, item.title),
4022
+ item.hint ? h('span', { className: 'cait-palette-hint' }, item.hint) : null,
4023
+ ),
4024
+ )
4025
+ : h('p', { className: 'cait-mt-hint' }, t('palette.empty')),
4026
+ ),
4027
+ h('p', { className: 'cait-palette-foot' }, t('palette.footer')),
4028
+ )
4029
+ }
4030
+
4031
+ function ResourceModal({ t, doc, libraries, mounts, busy, onClose, onSave }) {
4032
+ const resources = doc.resources || {}
4033
+ const [inheritTm, setInheritTm] = useState(resources.inheritsTm !== false)
4034
+ const [inheritGlossary, setInheritGlossary] = useState(resources.inheritsGlossary !== false)
4035
+ const [tmIds, setTmIds] = useState(
4036
+ () => new Set(resources.tmIds ?? resources.effectiveTmIds ?? []),
4037
+ )
4038
+ const [glossaryIds, setGlossaryIds] = useState(
4039
+ () => new Set(resources.glossaryIds ?? resources.effectiveGlossaryIds ?? []),
4040
+ )
4041
+ const [defaultWriteTmId, setDefaultWriteTmId] = useState(resources.defaultWriteTmId || '')
4042
+
4043
+ const allTm = libraries?.tm || []
4044
+ const allGlossary = libraries?.glossary || []
4045
+ const selectedTm = inheritTm ? mounts?.tmIds || [] : Array.from(tmIds)
4046
+ const selectedGlossary = inheritGlossary ? mounts?.glossaryIds || [] : Array.from(glossaryIds)
4047
+ const tmChoices = allTm.filter((library) => selectedTm.includes(library.tmId))
4048
+ const checkedTmIds = new Set(selectedTm)
4049
+ const checkedGlossaryIds = new Set(selectedGlossary)
4050
+ const defaultTm = tmChoices.some((library) => library.tmId === defaultWriteTmId)
4051
+ ? defaultWriteTmId
4052
+ : ''
4053
+
4054
+ const toggleId = (setter, id) => {
4055
+ setter((current) => {
4056
+ const next = new Set(current)
4057
+ if (next.has(id)) next.delete(id)
4058
+ else next.add(id)
4059
+ return next
4060
+ })
4061
+ }
4062
+
4063
+ const librarySection = (label, all, idKey, checkedIds, inherit, setInherit, toggle, inheritLabel) =>
4064
+ h(
4065
+ 'div',
4066
+ { className: 'cait-resource-section' },
4067
+ h(
4068
+ 'div',
4069
+ { className: 'cait-resource-head' },
4070
+ h('strong', null, t(label)),
4071
+ h(
4072
+ 'label',
4073
+ { className: 'cait-checkline' },
4074
+ h('input', {
4075
+ type: 'checkbox',
4076
+ checked: inherit,
4077
+ onChange: (event) => setInherit(event.target.checked),
4078
+ }),
4079
+ t(inheritLabel),
4080
+ ),
4081
+ ),
4082
+ h(
4083
+ 'div',
4084
+ { className: 'cait-resource-list' },
4085
+ all.length === 0
4086
+ ? h('p', { className: 'cait-meta' }, t('resources.empty'))
4087
+ : all.map((library) =>
4088
+ h(
4089
+ 'label',
4090
+ { key: library[idKey], className: 'cait-resource-row' },
4091
+ h('input', {
4092
+ type: 'checkbox',
4093
+ checked: checkedIds.has(library[idKey]),
4094
+ disabled: inherit,
4095
+ onChange: () => toggle(library[idKey]),
4096
+ }),
4097
+ h('span', { className: 'cait-resource-name' }, library.name),
4098
+ h('span', { className: 'cait-meta' }, library[idKey]),
4099
+ ),
4100
+ ),
4101
+ ),
4102
+ )
4103
+
4104
+ return h(
4105
+ Modal,
4106
+ { title: t('resources.title'), onClose, wide: true },
4107
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('resources.hint')),
4108
+ h(
4109
+ 'div',
4110
+ { className: 'cait-briefform' },
4111
+ librarySection(
4112
+ 'resources.tm',
4113
+ allTm,
4114
+ 'tmId',
4115
+ checkedTmIds,
4116
+ inheritTm,
4117
+ setInheritTm,
4118
+ (id) => toggleId(setTmIds, id),
4119
+ 'resources.inheritTm',
4120
+ ),
4121
+ librarySection(
4122
+ 'resources.glossary',
4123
+ allGlossary,
4124
+ 'glossaryId',
4125
+ checkedGlossaryIds,
4126
+ inheritGlossary,
4127
+ setInheritGlossary,
4128
+ (id) => toggleId(setGlossaryIds, id),
4129
+ 'resources.inheritGlossary',
4130
+ ),
4131
+ h(
4132
+ 'label',
4133
+ { className: 'cait-field' },
4134
+ h('span', null, t('resources.defaultWrite')),
4135
+ h(
4136
+ 'select',
4137
+ {
4138
+ className: 'cait-input',
4139
+ value: defaultTm,
4140
+ onChange: (event) => setDefaultWriteTmId(event.target.value),
4141
+ },
4142
+ h('option', { value: '' }, t(inheritTm ? 'resources.followDefault' : 'resources.firstBound')),
4143
+ tmChoices.map((library) => h('option', { key: library.tmId, value: library.tmId }, library.name)),
4144
+ ),
4145
+ ),
4146
+ ),
4147
+ h(
4148
+ 'div',
4149
+ { className: 'cait-modal-actions' },
4150
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
4151
+ h(
4152
+ 'button',
4153
+ {
4154
+ className: 'cait-btn cait-btn-primary',
4155
+ disabled: Boolean(busy),
4156
+ onClick: () =>
4157
+ onSave({
4158
+ tmIds: inheritTm ? null : Array.from(tmIds),
4159
+ glossaryIds: inheritGlossary ? null : Array.from(glossaryIds),
4160
+ defaultWriteTmId: defaultTm || null,
4161
+ }),
4162
+ },
4163
+ busy ? h(Spinner) : null,
4164
+ t('resources.save'),
4165
+ ),
4166
+ ),
4167
+ )
4168
+ }
4169
+
4170
+ function AnalysisModal({ t, doc, analysis, busy, onClose }) {
4171
+ const download = (format) => {
4172
+ if (!analysis) return
4173
+ const pairs = [
4174
+ ['segments', analysis.segments],
4175
+ ['words', analysis.words],
4176
+ ['characters', analysis.characters],
4177
+ ['duplicates.groups', analysis.duplicates.groups],
4178
+ ['duplicates.segments', analysis.duplicates.segments],
4179
+ ['translated', analysis.translated],
4180
+ ['confirmed', analysis.confirmed],
4181
+ ['ice', analysis.buckets.ice],
4182
+ ['exact', analysis.buckets.exact],
4183
+ ['fuzzy85', analysis.buckets.fuzzy85],
4184
+ ['fuzzy70', analysis.buckets.fuzzy70],
4185
+ ['new', analysis.buckets.new],
4186
+ ['matched.segments', analysis.matched.segments],
4187
+ ['matched.percent', analysis.matched.percent],
4188
+ ['leverage.percent', analysis.leverage.percent],
4189
+ ['generatedAt', analysis.generatedAt],
4190
+ ]
4191
+ const content =
4192
+ format === 'json'
4193
+ ? JSON.stringify(analysis, null, 2)
4194
+ : ['metric,value', ...pairs.map(([key, value]) => `${key},${value}`)].join('\n')
4195
+ const type = format === 'json' ? 'application/json' : 'text/csv'
4196
+ const blob = new Blob([content], { type })
4197
+ const url = URL.createObjectURL(blob)
4198
+ const anchor = document.createElement('a')
4199
+ anchor.href = url
4200
+ anchor.download = `${analysis.name || 'document'}-analysis.${format}`
4201
+ anchor.click()
4202
+ URL.revokeObjectURL(url)
4203
+ }
4204
+
4205
+ const rows = analysis
4206
+ ? [
4207
+ ['analysis.segments', analysis.segments],
4208
+ ['analysis.words', analysis.words],
4209
+ ['analysis.characters', analysis.characters],
4210
+ ['analysis.duplicates', `${analysis.duplicates.segments} / ${analysis.duplicates.groups}`],
4211
+ ['analysis.translated', analysis.translated],
4212
+ ['analysis.confirmed', analysis.confirmed],
4213
+ ]
4214
+ : []
4215
+ const buckets = analysis
4216
+ ? [
4217
+ ['analysis.ice', analysis.buckets.ice],
4218
+ ['analysis.exact', analysis.buckets.exact],
4219
+ ['analysis.fuzzy85', analysis.buckets.fuzzy85],
4220
+ ['analysis.fuzzy70', analysis.buckets.fuzzy70],
4221
+ ['analysis.new', analysis.buckets.new],
4222
+ ]
4223
+ : []
4224
+
4225
+ return h(
4226
+ Modal,
4227
+ { title: t('analysis.title'), onClose, wide: true },
4228
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('analysis.hint')),
4229
+ busy || !analysis
4230
+ ? h('div', { className: 'cait-empty' }, h(Spinner))
4231
+ : h(
4232
+ 'div',
4233
+ { className: 'cait-analysis' },
4234
+ h(
4235
+ 'div',
4236
+ { className: 'cait-analysis-grid' },
4237
+ rows.map(([label, value]) =>
4238
+ h(
4239
+ 'div',
4240
+ { key: label, className: 'cait-analysis-stat' },
4241
+ h('span', null, t(label)),
4242
+ h('strong', null, String(value)),
4243
+ ),
4244
+ ),
4245
+ ),
4246
+ h(
4247
+ 'div',
4248
+ { className: 'cait-analysis-grid' },
4249
+ buckets.map(([label, value]) =>
4250
+ h(
4251
+ 'div',
4252
+ { key: label, className: 'cait-analysis-stat' },
4253
+ h('span', null, t(label)),
4254
+ h('strong', null, String(value)),
4255
+ ),
4256
+ ),
4257
+ ),
4258
+ h(
4259
+ 'div',
4260
+ { className: 'cait-analysis-total' },
4261
+ h('span', null, `${t('analysis.matchRate')} ${analysis.matched.percent}%`),
4262
+ h('span', null, `${t('analysis.leverage')} ${analysis.leverage.percent}%`),
4263
+ ),
4264
+ ),
4265
+ h(
4266
+ 'div',
4267
+ { className: 'cait-modal-actions' },
4268
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('analysis.close')),
4269
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: () => download('csv'), disabled: Boolean(busy) || !analysis }, t('analysis.exportCsv')),
4270
+ h('button', { className: 'cait-btn cait-btn-primary', onClick: () => download('json'), disabled: Boolean(busy) || !analysis }, t('analysis.exportJson')),
4271
+ ),
4272
+ )
4273
+ }
4274
+
2991
4275
  function Workbench({ i18n, translate }) {
2992
4276
  const [data, setData] = useState(null)
2993
4277
  const [loading, setLoading] = useState(true)
@@ -3011,6 +4295,18 @@
3011
4295
  }
3012
4296
  const [pre, setPre] = useState({ running: false, done: 0, total: 0, filled: 0, failed: 0 })
3013
4297
  const [qa, setQa] = useState(null)
4298
+ const [review, setReview] = useState(null)
4299
+ const [reviewBusy, setReviewBusy] = useState(false)
4300
+ const [analysis, setAnalysis] = useState(null)
4301
+ const [analysisBusy, setAnalysisBusy] = useState(false)
4302
+ const [replacePreview, setReplacePreview] = useState(null)
4303
+ const [replaceUndo, setReplaceUndo] = useState(null)
4304
+ const [replaceBusy, setReplaceBusy] = useState(false)
4305
+ const [notes, setNotes] = useState(null)
4306
+ const [notesBusy, setNotesBusy] = useState(false)
4307
+ const [concordance, setConcordance] = useState(null)
4308
+ const [concordanceQuery, setConcordanceQuery] = useState('')
4309
+ const [concordanceBusy, setConcordanceBusy] = useState(false)
3014
4310
  const [tmQuery, setTmQuery] = useState('')
3015
4311
  const [tmResults, setTmResults] = useState(null)
3016
4312
  const [tmRefresh, setTmRefresh] = useState(0)
@@ -3027,16 +4323,20 @@
3027
4323
  const busyRef = useRef(false)
3028
4324
  const rootRef = useRef(null)
3029
4325
  const unitRef = useRef(0)
4326
+ const draftHistoryRef = useRef({ index: -1, stack: [], cursor: 0, lastAt: 0 })
3030
4327
 
3031
4328
  const doc = data?.activeDocument || null
3032
4329
  const segments = doc?.segments || []
3033
4330
  const index = doc ? Math.min(selected, Math.max(0, segments.length - 1)) : 0
3034
4331
  const segment = segments[index] || null
3035
4332
  const detail = doc?.current?.index === index ? doc.current : null
4333
+ useEffect(() => {
4334
+ draftHistoryRef.current = { index, stack: [draft], cursor: 0, lastAt: 0 }
4335
+ }, [doc?.id, index])
3036
4336
  const settings = data?.settings || {
3037
4337
  sourceLang: 'en',
3038
4338
  targetLang: 'zh-CN',
3039
- fuzzyThreshold: 0.55,
4339
+ fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD,
3040
4340
  tmLimit: 5,
3041
4341
  uiLang: 'auto',
3042
4342
  mt: { provider: 'mock', model: 'cait-mock', temperature: 0.2 },
@@ -3259,6 +4559,10 @@
3259
4559
  }
3260
4560
  }, [data?.activeDocument?.id, data?.revision])
3261
4561
 
4562
+ useEffect(() => {
4563
+ setReview(null)
4564
+ }, [data?.activeDocument?.id])
4565
+
3262
4566
  useEffect(() => {
3263
4567
  let alive = true
3264
4568
  rpc('listMtRoutes')
@@ -3284,6 +4588,11 @@
3284
4588
  setTmQuery('')
3285
4589
  setTmResults(null)
3286
4590
  setTmError('')
4591
+ setConcordance(null)
4592
+ setConcordanceQuery('')
4593
+ setReplacePreview(null)
4594
+ setReplaceUndo(null)
4595
+ setNotes(null)
3287
4596
  }, [doc?.id])
3288
4597
 
3289
4598
  useEffect(() => {
@@ -3321,7 +4630,8 @@
3321
4630
  const saveDraft = async (silent = true) => {
3322
4631
  if (!doc || !dirty) return true
3323
4632
  try {
3324
- await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
4633
+ const result = await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
4634
+ if (result.state) adoptState(result.state)
3325
4635
  setDirty(false)
3326
4636
  if (!silent) notify(t('toast.draftSaved'))
3327
4637
  return true
@@ -3348,6 +4658,49 @@
3348
4658
  }
3349
4659
  }
3350
4660
 
4661
+ const changeDraft = (value) => {
4662
+ const history = draftHistoryRef.current
4663
+ if (history.index !== index) {
4664
+ history.index = index
4665
+ history.stack = [draft]
4666
+ history.cursor = 0
4667
+ }
4668
+ if (history.stack[history.cursor] !== value) {
4669
+ const now = Date.now()
4670
+ if (now - history.lastAt < 650 && history.cursor > 0) {
4671
+ history.stack[history.cursor] = value
4672
+ } else {
4673
+ history.stack = history.stack.slice(0, history.cursor + 1)
4674
+ history.stack.push(value)
4675
+ if (history.stack.length > 200) history.stack.shift()
4676
+ history.cursor = history.stack.length - 1
4677
+ }
4678
+ history.lastAt = now
4679
+ }
4680
+ setDraft(value)
4681
+ setDirty(true)
4682
+ }
4683
+
4684
+ const undoDraft = () => {
4685
+ const history = draftHistoryRef.current
4686
+ if (history.cursor <= 0) return
4687
+ history.cursor -= 1
4688
+ history.lastAt = 0
4689
+ const value = history.stack[history.cursor]
4690
+ setDraft(value)
4691
+ setDirty(value !== (segment?.target || ''))
4692
+ }
4693
+
4694
+ const redoDraft = () => {
4695
+ const history = draftHistoryRef.current
4696
+ if (history.cursor >= history.stack.length - 1) return
4697
+ history.cursor += 1
4698
+ history.lastAt = 0
4699
+ const value = history.stack[history.cursor]
4700
+ setDraft(value)
4701
+ setDirty(value !== (segment?.target || ''))
4702
+ }
4703
+
3351
4704
  const confirmSegment = async () => {
3352
4705
  if (!doc || busy) return
3353
4706
  if (!draft.trim()) {
@@ -3502,6 +4855,10 @@
3502
4855
  }
3503
4856
 
3504
4857
  const applyTerm = (term) => {
4858
+ if (term?.forbidden) {
4859
+ notify(t('toast.termForbidden'), 'error')
4860
+ return
4861
+ }
3505
4862
  const separator = /^(zh|ja|ko)/i.test(doc?.targetLang || '') ? '' : ' '
3506
4863
  setDraft((current) => {
3507
4864
  const value = current.replace(/\s+$/, '')
@@ -3511,6 +4868,326 @@
3511
4868
  setTimeout(() => textareaRef.current?.focus(), 0)
3512
4869
  }
3513
4870
 
4871
+ const unconfirmCurrent = async () => {
4872
+ if (!doc || busy) return
4873
+ setBusy('unconfirm')
4874
+ try {
4875
+ if (dirty) {
4876
+ const saved = await saveDraft(true)
4877
+ if (!saved) return
4878
+ }
4879
+ const result = await rpc('unconfirmSegment', { documentId: doc.id, index: selected })
4880
+ adoptState(result.state)
4881
+ notify(t('toast.unconfirmed'))
4882
+ } catch (error) {
4883
+ // Saving a dirty confirmed segment already revokes the confirmation.
4884
+ // Treat the second call as idempotent instead of showing an error.
4885
+ if (error?.code === 'NOT_CONFIRMED') notify(t('toast.unconfirmed'))
4886
+ else report(error)
4887
+ } finally {
4888
+ setBusy('')
4889
+ }
4890
+ }
4891
+
4892
+ const toggleLock = async () => {
4893
+ if (!doc || busy || !segment) return
4894
+ setBusy('lock')
4895
+ try {
4896
+ await rpc('setSegmentLocked', { documentId: doc.id, index: selected, locked: !segment.locked })
4897
+ const next = await rpc('bootstrap')
4898
+ adoptState(next, selected)
4899
+ notify(t(segment.locked ? 'toast.unlocked' : 'toast.locked'))
4900
+ } catch (error) {
4901
+ report(error)
4902
+ } finally {
4903
+ setBusy('')
4904
+ }
4905
+ }
4906
+
4907
+ const mergeCurrent = () => {
4908
+ if (!doc || !segment || busy) return
4909
+ openConfirm({
4910
+ title: t('modal.confirm.mergeTitle'),
4911
+ message: t('modal.confirm.merge'),
4912
+ confirmLabel: t('editor.merge'),
4913
+ action: async () => {
4914
+ const result = await rpc('mergeSegments', { documentId: doc.id, index: selected, count: 2 })
4915
+ adoptState(result.state, result.index)
4916
+ notify(t('toast.merged'))
4917
+ },
4918
+ })
4919
+ }
4920
+
4921
+ const splitCurrent = (at) => {
4922
+ if (!doc || !segment || busy) return
4923
+ openConfirm({
4924
+ title: t('modal.confirm.splitTitle'),
4925
+ message: t('modal.confirm.split'),
4926
+ confirmLabel: t('editor.split'),
4927
+ action: async () => {
4928
+ const result = await rpc('splitSegment', { documentId: doc.id, index: selected, at })
4929
+ adoptState(result.state, result.index)
4930
+ notify(t('toast.split'))
4931
+ },
4932
+ })
4933
+ }
4934
+
4935
+ const saveBrief = async (brief) => {
4936
+ if (!doc || busy) return false
4937
+ setBusy('brief')
4938
+ try {
4939
+ const result = await rpc('setBrief', { documentId: doc.id, brief })
4940
+ adoptState(result.state)
4941
+ setModal(null)
4942
+ notify(t('toast.briefSaved'))
4943
+ return true
4944
+ } catch (error) {
4945
+ report(error)
4946
+ return false
4947
+ } finally {
4948
+ setBusy('')
4949
+ }
4950
+ }
4951
+
4952
+ const saveResources = async (resources) => {
4953
+ if (!doc || busy) return false
4954
+ setBusy('resources')
4955
+ try {
4956
+ const result = await rpc('setDocumentResources', { documentId: doc.id, ...resources })
4957
+ adoptState(result.state)
4958
+ setModal(null)
4959
+ notify(t('toast.resourcesSaved'))
4960
+ return true
4961
+ } catch (error) {
4962
+ report(error)
4963
+ return false
4964
+ } finally {
4965
+ setBusy('')
4966
+ }
4967
+ }
4968
+
4969
+ const openAnalysis = async () => {
4970
+ if (!doc) return
4971
+ setAnalysis(null)
4972
+ setModal({ type: 'analysis' })
4973
+ setAnalysisBusy(true)
4974
+ try {
4975
+ setAnalysis(await rpc('analyzeDocument', { documentId: doc.id }))
4976
+ } catch (error) {
4977
+ report(error)
4978
+ } finally {
4979
+ setAnalysisBusy(false)
4980
+ }
4981
+ }
4982
+
4983
+ const openReplace = () => {
4984
+ if (!doc) return
4985
+ setReplacePreview(null)
4986
+ setModal({ type: 'replace' })
4987
+ }
4988
+
4989
+ const previewReplace = async (form) => {
4990
+ if (!doc || replaceBusy) return
4991
+ setReplaceBusy(true)
4992
+ try {
4993
+ setReplacePreview(await rpc('replaceInDocument', { documentId: doc.id, ...form }))
4994
+ } catch (error) {
4995
+ report(error)
4996
+ } finally {
4997
+ setReplaceBusy(false)
4998
+ }
4999
+ }
5000
+
5001
+ const applyReplace = async (form) => {
5002
+ if (!doc || replaceBusy) return
5003
+ setReplaceBusy(true)
5004
+ try {
5005
+ const result = await rpc('replaceInDocument', { documentId: doc.id, ...form, apply: true })
5006
+ setReplaceUndo(Array.isArray(result.undo) && result.undo.length ? result.undo : null)
5007
+ setReplacePreview(null)
5008
+ adoptState(result.state, result.undo?.[0]?.index)
5009
+ notify(t('toast.replaced').replace('{count}', String(result.applied ?? 0)))
5010
+ } catch (error) {
5011
+ report(error)
5012
+ } finally {
5013
+ setReplaceBusy(false)
5014
+ }
5015
+ }
5016
+
5017
+ const undoReplace = async () => {
5018
+ if (!doc || !replaceUndo || replaceBusy) return
5019
+ setReplaceBusy(true)
5020
+ try {
5021
+ const result = await rpc('undoReplace', { documentId: doc.id, undo: replaceUndo })
5022
+ adoptState(result.state, replaceUndo[0]?.index)
5023
+ setReplaceUndo(null)
5024
+ setReplacePreview(null)
5025
+ notify(t('toast.replaceUndone'))
5026
+ } catch (error) {
5027
+ report(error)
5028
+ } finally {
5029
+ setReplaceBusy(false)
5030
+ }
5031
+ }
5032
+
5033
+ const openNotes = async () => {
5034
+ if (!doc) return
5035
+ setNotes(null)
5036
+ setNotesBusy(true)
5037
+ setModal({ type: 'notes' })
5038
+ try {
5039
+ const result = await rpc('notesForSegment', { documentId: doc.id, index })
5040
+ setNotes(result.notes)
5041
+ } catch (error) {
5042
+ report(error)
5043
+ } finally {
5044
+ setNotesBusy(false)
5045
+ }
5046
+ }
5047
+
5048
+ const addNote = async (text) => {
5049
+ if (!doc || notesBusy) return
5050
+ setNotesBusy(true)
5051
+ try {
5052
+ const result = await rpc('addSegmentNote', { documentId: doc.id, index, text })
5053
+ setNotes(result.notes)
5054
+ adoptState(result.state, index)
5055
+ notify(t('toast.noteSaved'))
5056
+ } catch (error) {
5057
+ report(error)
5058
+ } finally {
5059
+ setNotesBusy(false)
5060
+ }
5061
+ }
5062
+
5063
+ const updateNote = async (noteId, text) => {
5064
+ if (!doc || notesBusy) return
5065
+ setNotesBusy(true)
5066
+ try {
5067
+ const result = await rpc('updateSegmentNote', { documentId: doc.id, index, noteId, text })
5068
+ setNotes(result.notes)
5069
+ adoptState(result.state, index)
5070
+ notify(t('toast.noteSaved'))
5071
+ } catch (error) {
5072
+ report(error)
5073
+ } finally {
5074
+ setNotesBusy(false)
5075
+ }
5076
+ }
5077
+
5078
+ const deleteNote = async (noteId) => {
5079
+ if (!doc || notesBusy) return
5080
+ setNotesBusy(true)
5081
+ try {
5082
+ const result = await rpc('deleteSegmentNote', { documentId: doc.id, index, noteId })
5083
+ setNotes(result.notes)
5084
+ adoptState(result.state, index)
5085
+ notify(t('toast.noteDeleted'))
5086
+ } catch (error) {
5087
+ report(error)
5088
+ } finally {
5089
+ setNotesBusy(false)
5090
+ }
5091
+ }
5092
+
5093
+ const refreshQa = async () => { if (!doc) return
5094
+ setQa(null)
5095
+ try {
5096
+ setQa(await rpc('getQaReport', { documentId: doc.id }))
5097
+ } catch {
5098
+ // QA is advisory; a failure must not disturb the editor.
5099
+ }
5100
+ }
5101
+
5102
+ const jumpQa = (nextIndex) => {
5103
+ if (Number.isInteger(nextIndex)) chooseSegment(nextIndex)
5104
+ }
5105
+
5106
+ const runReview = async () => {
5107
+ if (!doc || reviewBusy) return
5108
+ setReviewBusy(true)
5109
+ try {
5110
+ const result = await rpc('reviewDocument', {
5111
+ documentId: doc.id,
5112
+ from: 0,
5113
+ count: 20,
5114
+ })
5115
+ setReview(result || { issues: [] })
5116
+ } catch (error) {
5117
+ setReview(null)
5118
+ report(error)
5119
+ } finally {
5120
+ setReviewBusy(false)
5121
+ }
5122
+ }
5123
+
5124
+ const applyReview = (issue) => {
5125
+ if (!doc || !issue || !Number.isInteger(issue.index)) return
5126
+ chooseSegment(issue.index)
5127
+ if (issue.suggestion) {
5128
+ setDraft(issue.suggestion)
5129
+ setDirty(true)
5130
+ }
5131
+ }
5132
+
5133
+ const applyQaFix = async (item, action) => {
5134
+ if (!doc || busy || !item) return
5135
+ const segment = doc.segments[item.index]
5136
+ if (!segment) return
5137
+ let target = segment.target || ''
5138
+ if (action === 'copy-source') {
5139
+ target = segment.source
5140
+ } else if (action === 'clear') {
5141
+ target = ''
5142
+ } else if (action === 'term') {
5143
+ const term = String(item.termTarget || '')
5144
+ if (!term || target.includes(term)) return
5145
+ const separator = /[A-Za-z0-9]$/.test(target) && /^[A-Za-z0-9]/.test(term) ? ' ' : ''
5146
+ target = `${target}${separator}${term}`
5147
+ } else {
5148
+ return
5149
+ }
5150
+ setBusy('qa-fix')
5151
+ try {
5152
+ const result = await rpc('saveDraft', {
5153
+ documentId: doc.id,
5154
+ index: item.index,
5155
+ target,
5156
+ })
5157
+ if (result.state) adoptState(result.state, item.index)
5158
+ setQa(await rpc('getQaReport', { documentId: doc.id }))
5159
+ notify(t('toast.qaFixed'))
5160
+ } catch (error) {
5161
+ report(error)
5162
+ } finally {
5163
+ setBusy('')
5164
+ }
5165
+ }
5166
+
5167
+ const runConcordance = async () => {
5168
+ const query = concordanceQuery.trim()
5169
+ if (!doc || !query) {
5170
+ setConcordance(null)
5171
+ return
5172
+ }
5173
+ setConcordanceBusy(true)
5174
+ try {
5175
+ setConcordance(await rpc('concordance', { documentId: doc.id, query, limit: 50 }))
5176
+ } catch (error) {
5177
+ report(error)
5178
+ } finally {
5179
+ setConcordanceBusy(false)
5180
+ }
5181
+ }
5182
+
5183
+ const applyConcordance = (entry) => {
5184
+ if (!entry?.target) return
5185
+ setDraft(entry.target)
5186
+ setDirty(true)
5187
+ setMt(null)
5188
+ setTimeout(() => textareaRef.current?.focus(), 0)
5189
+ }
5190
+
3514
5191
  const editTm = async ({ id, target }) => {
3515
5192
  setBusy('tm-edit')
3516
5193
  try {
@@ -3576,6 +5253,40 @@
3576
5253
  })
3577
5254
  }
3578
5255
 
5256
+ const clearPretranslated = async () => {
5257
+ const active = data?.activeDocument
5258
+ if (!active) return
5259
+ try {
5260
+ const preview = await rpc('getClearUnconfirmedPreview', { documentId: active.id })
5261
+ const counts = preview?.counts || {}
5262
+ const total = counts.total || 0
5263
+ if (!total) {
5264
+ notify(t('toast.nothingToClear'), 'info')
5265
+ return
5266
+ }
5267
+ setModal({
5268
+ type: 'confirm',
5269
+ title: t('modal.confirm.clearPretranslatedTitle'),
5270
+ message: t('modal.confirm.clearPretranslatedMessage')
5271
+ .replace('{count}', String(total))
5272
+ .replace('{mt}', String(counts.mt || 0))
5273
+ .replace('{tm}', String(counts.tm || 0))
5274
+ .replace('{auto}', String(counts.auto || 0)),
5275
+ confirmLabel: t('modal.confirm.clearPretranslatedConfirm').replace('{count}', String(total)),
5276
+ action: async () => {
5277
+ const result = await rpc('clearUnconfirmedTranslations', {
5278
+ documentId: active.id,
5279
+ confirm: true,
5280
+ })
5281
+ adoptState(result.state, result.cursor)
5282
+ notify(t('toast.pretranslatedCleared').replace('{count}', String(result.cleared)))
5283
+ },
5284
+ })
5285
+ } catch (error) {
5286
+ report(error)
5287
+ }
5288
+ }
5289
+
3579
5290
  const addTerm = async (form) => { setBusy('term')
3580
5291
  try {
3581
5292
  const result = await rpc('addGlossary', {
@@ -3765,6 +5476,32 @@
3765
5476
  }
3766
5477
  }
3767
5478
 
5479
+ const importReview = (file) => {
5480
+ if (!doc || !file) return
5481
+ const reader = new FileReader()
5482
+ reader.onload = async () => {
5483
+ setBusy('review')
5484
+ try {
5485
+ const result = await rpc('applyXliffReview', {
5486
+ documentId: doc.id,
5487
+ text: String(reader.result || ''),
5488
+ })
5489
+ adoptState(result.state)
5490
+ notify(
5491
+ t('toast.reviewApplied')
5492
+ .replace('{applied}', String(result.applied))
5493
+ .replace('{unmatched}', String(result.unmatched + result.sourceMismatch)),
5494
+ )
5495
+ setModal(null)
5496
+ } catch (error) {
5497
+ report(error)
5498
+ } finally {
5499
+ setBusy('')
5500
+ }
5501
+ }
5502
+ reader.readAsText(file, 'utf-8')
5503
+ }
5504
+
3768
5505
  const openConfirm = (state) => setModal({ type: 'confirm', ...state })
3769
5506
 
3770
5507
  const clearTm = () =>
@@ -3819,6 +5556,7 @@
3819
5556
 
3820
5557
  const openIds = new Set((data?.openDocuments || []).map((item) => item.id))
3821
5558
  const closedDocuments = (data?.documents || []).filter((item) => !openIds.has(item.id))
5559
+ const qaRisk = (qa?.risks || []).find((item) => item.index === index) || null
3822
5560
 
3823
5561
  const deleteClosedDocument = (document) =>
3824
5562
  openConfirm({
@@ -3898,6 +5636,23 @@
3898
5636
  }
3899
5637
  }, [])
3900
5638
 
5639
+ useEffect(() => {
5640
+ const onKey = (event) => {
5641
+ const mod = event.metaKey || event.ctrlKey
5642
+ if (!mod) return
5643
+ const key = event.key.toLowerCase()
5644
+ if (key === 'k') {
5645
+ event.preventDefault()
5646
+ setModal((current) => (current?.type === 'palette' ? null : { type: 'palette' }))
5647
+ } else if (key === 'f' && doc && !modal) {
5648
+ event.preventDefault()
5649
+ openReplace()
5650
+ }
5651
+ }
5652
+ window.addEventListener('keydown', onKey)
5653
+ return () => window.removeEventListener('keydown', onKey)
5654
+ }, [doc?.id, modal])
5655
+
3901
5656
  if (loading) {
3902
5657
  return h(
3903
5658
  'div',
@@ -3925,6 +5680,44 @@
3925
5680
  )
3926
5681
  }
3927
5682
 
5683
+ const commands = [
5684
+ { id: 'new', title: t('cmd.new'), hint: t('cmd.newHint'), run: () => setModal({ type: 'new', defaultMode: 'text' }) },
5685
+ { id: 'import', title: t('cmd.import'), hint: '', run: () => setModal({ type: 'new', defaultMode: 'file' }) },
5686
+ { id: 'export', title: t('cmd.export'), hint: '', docOnly: true, run: () => setModal({ type: 'export' }) },
5687
+ { id: 'pretranslate', title: t('cmd.pretranslate'), hint: '', docOnly: true, run: () => runPreTranslate() },
5688
+ { id: 'replace', title: t('cmd.replace'), hint: 'Ctrl+F', docOnly: true, run: () => openReplace() },
5689
+ { id: 'brief', title: t('cmd.brief'), hint: '', docOnly: true, run: () => setModal({ type: 'brief' }) },
5690
+ { id: 'resources', title: t('cmd.resources'), hint: '', docOnly: true, run: () => setModal({ type: 'resources' }) },
5691
+ { id: 'analysis', title: t('cmd.analysis'), hint: '', docOnly: true, run: () => openAnalysis() },
5692
+ { id: 'notes', title: t('cmd.notes'), hint: '', docOnly: true, run: () => openNotes() },
5693
+ {
5694
+ id: 'review',
5695
+ title: t('cmd.review'),
5696
+ hint: '',
5697
+ docOnly: true,
5698
+ run: () => {
5699
+ setTab('qa')
5700
+ runReview()
5701
+ },
5702
+ },
5703
+ {
5704
+ id: 'clear-pretranslated',
5705
+ title: t('cmd.clearPretranslated'),
5706
+ hint: '',
5707
+ docOnly: true,
5708
+ run: () => clearPretranslated(),
5709
+ },
5710
+ { id: 'documents', title: t('cmd.documents'), hint: '', run: () => setView('documents') },
5711
+ { id: 'libraries', title: t('cmd.libraries'), hint: '', run: () => setView('libraries') },
5712
+ { id: 'align', title: t('cmd.align'), hint: '', run: () => setView('align') },
5713
+ { id: 'settings', title: t('cmd.settings'), hint: '', run: () => setModal({ type: 'settings' }) },
5714
+ ].filter((item) => !item.docOnly || doc)
5715
+
5716
+ const runCommand = (item) => {
5717
+ setModal(null)
5718
+ setTimeout(() => item.run(), 0)
5719
+ }
5720
+
3928
5721
  return h(
3929
5722
  'div',
3930
5723
  { className: 'cait-root', ref: rootRef },
@@ -3945,6 +5738,7 @@
3945
5738
  pre,
3946
5739
  onPreTranslate: runPreTranslate,
3947
5740
  onCancelPreTranslate: cancelPreTranslate,
5741
+ onReplace: openReplace,
3948
5742
  view,
3949
5743
  onView: setView,
3950
5744
  }),
@@ -3963,6 +5757,7 @@
3963
5757
  onCreateLibrary: createLibrary,
3964
5758
  onRenameLibrary: renameLibrary,
3965
5759
  onDeleteLibrary: deleteLibrary,
5760
+ onSetMounts: setMounts,
3966
5761
  })
3967
5762
  : null,
3968
5763
  view === 'align'
@@ -3980,7 +5775,7 @@
3980
5775
  ? h(
3981
5776
  'div',
3982
5777
  { className: `cait-body${doc ? '' : ' cait-no-doc'}` },
3983
- doc ? h(SegmentRail, { t, doc, selected: index, query: railQuery, setQuery: setRailQuery, filter: railFilter, setFilter: setRailFilter, onSelect: chooseSegment, onConfirmAll: confirmAllTranslated, onPropagate: propagateRepeats, busy, qa }) : null,
5778
+ doc ? h(SegmentRail, { t, doc, selected: index, query: railQuery, setQuery: setRailQuery, filter: railFilter, setFilter: setRailFilter, onSelect: chooseSegment, onConfirmAll: confirmAllTranslated, onPropagate: propagateRepeats, onClearPretranslated: clearPretranslated, busy, qa }) : null,
3984
5779
  h(
3985
5780
  'div',
3986
5781
  { className: `cait-main${doc ? '' : ' cait-main-empty'}` },
@@ -3992,18 +5787,29 @@
3992
5787
  segment,
3993
5788
  detail,
3994
5789
  draft,
3995
- setDraft: (value) => {
3996
- setDraft(value)
3997
- setDirty(true)
3998
- },
5790
+ setDraft: changeDraft,
3999
5791
  dirty,
4000
5792
  busy,
5793
+ canUndo: draftHistoryRef.current.cursor > 0,
5794
+ canRedo: draftHistoryRef.current.cursor < draftHistoryRef.current.stack.length - 1,
5795
+ onUndo: undoDraft,
5796
+ onRedo: redoDraft,
4001
5797
  onConfirm: confirmSegment,
5798
+ onUnconfirm: unconfirmCurrent,
5799
+ onToggleLock: toggleLock,
5800
+ onMerge: mergeCurrent,
5801
+ onSplit: splitCurrent,
4002
5802
  onNext: () => moveSegment(1),
4003
5803
  onPrev: () => moveSegment(-1),
4004
5804
  onSave: () => saveDraft(false),
4005
5805
  onAdopt: adoptSuggestion,
4006
5806
  onApplyTerm: applyTerm,
5807
+ onOpenBrief: () => setModal({ type: 'brief' }),
5808
+ onOpenResources: () => setModal({ type: 'resources' }),
5809
+ onOpenAnalysis: openAnalysis,
5810
+ onOpenNotes: openNotes,
5811
+ noteCount: segment?.notes?.length || 0,
5812
+ qaRisk,
4007
5813
  textareaRef,
4008
5814
  })
4009
5815
  : h(EmptyState, { t, settings, busy, onCreate: createFromForm, onDemo: loadDemo }),
@@ -4044,7 +5850,20 @@
4044
5850
  onCopy: copy,
4045
5851
  onOpenSettings: () => setModal({ type: 'settings' }),
4046
5852
  onSearchLibrary: openLibrary,
4047
- busy,
5853
+ qa,
5854
+ onRefreshQa: refreshQa,
5855
+ onJumpQa: jumpQa,
5856
+ onFixQa: applyQaFix,
5857
+ review,
5858
+ reviewBusy,
5859
+ onReview: runReview,
5860
+ onApplyReview: applyReview,
5861
+ concordance,
5862
+ concordanceQuery,
5863
+ setConcordanceQuery,
5864
+ onConcordance: runConcordance,
5865
+ onApplyConcordance: applyConcordance,
5866
+ busy: busy || (concordanceBusy ? 'concordance' : ''),
4048
5867
  })
4049
5868
  : null,
4050
5869
  ),
@@ -4061,7 +5880,60 @@
4061
5880
  })
4062
5881
  : null,
4063
5882
  modal?.type === 'export'
4064
- ? h(ExportModal, { t, busy, qa, onClose: () => setModal(null), onExport: exportAs })
5883
+ ? h(ExportModal, { t, busy, qa, onClose: () => setModal(null), onExport: exportAs, onImportReview: importReview })
5884
+ : null,
5885
+ modal?.type === 'brief'
5886
+ ? h(BriefModal, { t, doc, busy, onClose: () => setModal(null), onSave: saveBrief })
5887
+ : null,
5888
+ modal?.type === 'resources'
5889
+ ? h(ResourceModal, {
5890
+ t,
5891
+ doc,
5892
+ libraries: data?.libraries,
5893
+ mounts: data?.mounts,
5894
+ busy,
5895
+ onClose: () => setModal(null),
5896
+ onSave: saveResources,
5897
+ })
5898
+ : null,
5899
+ modal?.type === 'analysis'
5900
+ ? h(AnalysisModal, {
5901
+ t,
5902
+ doc,
5903
+ analysis,
5904
+ busy: analysisBusy,
5905
+ onClose: () => setModal(null),
5906
+ })
5907
+ : null,
5908
+ modal?.type === 'replace'
5909
+ ? h(ReplaceModal, {
5910
+ t,
5911
+ doc,
5912
+ busy: replaceBusy,
5913
+ preview: replacePreview,
5914
+ undoCount: replaceUndo ? replaceUndo.length : 0,
5915
+ onPreview: previewReplace,
5916
+ onApply: applyReplace,
5917
+ onUndo: undoReplace,
5918
+ onClose: () => setModal(null),
5919
+ })
5920
+ : null,
5921
+ modal?.type === 'notes'
5922
+ ? h(NoteModal, {
5923
+ t,
5924
+ doc,
5925
+ index,
5926
+ segment,
5927
+ notes,
5928
+ busy: notesBusy,
5929
+ onAdd: addNote,
5930
+ onUpdate: updateNote,
5931
+ onDelete: deleteNote,
5932
+ onClose: () => setModal(null),
5933
+ })
5934
+ : null,
5935
+ modal?.type === 'palette'
5936
+ ? h(CommandPalette, { t, items: commands, onClose: () => setModal(null), onRun: runCommand })
4065
5937
  : null,
4066
5938
  modal?.type === 'settings'
4067
5939
  ? h(SettingsModal, { t, settings, mtRoutes, busy, onClose: () => setModal(null), onSave: saveSettings })