dsh-cait 1.1.3 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,9 +761,21 @@
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, onMergeCue, onSplitCue, onShiftSubtitles, 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
767
+ const isSubtitle = doc.format === 'srt' || doc.format === 'vtt'
768
+ const [shiftSeconds, setShiftSeconds] = useState('1')
769
+ const cueStart = Number(segment.meta?.start)
770
+ const cueEnd = Number(segment.meta?.end)
771
+ const formatCue = (value) => {
772
+ if (!Number.isFinite(value)) return '--:--.-'
773
+ const total = Math.max(0, Math.round(value))
774
+ const minutes = Math.floor(total / 60000)
775
+ const seconds = Math.floor((total % 60000) / 1000)
776
+ const millis = total % 1000
777
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`
778
+ }
726
779
  const progress = doc.progress
727
780
  const remaining = progress.total - progress.confirmed
728
781
  const statusLabel =
@@ -737,9 +790,31 @@
737
790
  // against. It is stored in `suggestion`, which adoption already preserves.
738
791
  const baselineText = segment.suggestion || ''
739
792
  const baselineOrigin = segment.origin || 'agent'
793
+ const canMerge = !segment.locked && index < doc.segments.length - 1
794
+ const canSplit = !segment.locked && String(segment.source || '').length > 1
795
+ const splitAtCursor = () => {
796
+ const sourceLength = String(segment.source || '').length
797
+ const targetLength = String(draft || '').length
798
+ const cursor = textareaRef?.current?.selectionStart
799
+ const ratio = targetLength > 0 && Number.isInteger(cursor) ? cursor / targetLength : 0.5
800
+ const at = Math.max(1, Math.min(sourceLength - 1, Math.round(sourceLength * ratio)))
801
+ onSplit(at)
802
+ }
740
803
 
741
- const onKeyDown = (event) => { const mod = event.metaKey || event.ctrlKey
742
- if (mod && event.key === 'Enter') {
804
+ const onKeyDown = (event) => {
805
+ const mod = event.metaKey || event.ctrlKey
806
+ if (mod && event.key.toLowerCase() === 'z') {
807
+ if (canUndo || canRedo) {
808
+ event.preventDefault()
809
+ if (event.shiftKey) onRedo()
810
+ else onUndo()
811
+ }
812
+ } else if (mod && event.key.toLowerCase() === 'y') {
813
+ if (canRedo) {
814
+ event.preventDefault()
815
+ onRedo()
816
+ }
817
+ } else if (mod && event.key === 'Enter') {
743
818
  event.preventDefault()
744
819
  onConfirm()
745
820
  } else if (mod && event.key.toLowerCase() === 's') {
@@ -771,6 +846,37 @@
771
846
  h(Icon, { name: 'arrowRight', size: 11 }),
772
847
  h('span', { className: 'cait-langchip' }, doc.targetLang),
773
848
  h('span', null, `· ${doc.segments.length} ${t('doc.segments')}`),
849
+ h(
850
+ 'button',
851
+ {
852
+ className: 'cait-linkbtn cait-brieflink',
853
+ onClick: onOpenBrief,
854
+ title: doc.brief ? t('panel.briefHint') : t('brief.empty'),
855
+ },
856
+ h(Icon, { name: 'file', size: 11 }),
857
+ t('editor.brief'),
858
+ doc.brief ? h('span', { className: 'cait-briefdot' }) : null,
859
+ ),
860
+ h(
861
+ 'button',
862
+ {
863
+ className: 'cait-linkbtn',
864
+ onClick: onOpenResources,
865
+ title: t('resources.hint'),
866
+ },
867
+ h(Icon, { name: 'database', size: 11 }),
868
+ t('editor.resources'),
869
+ ),
870
+ h(
871
+ 'button',
872
+ {
873
+ className: 'cait-linkbtn',
874
+ onClick: onOpenAnalysis,
875
+ title: t('analysis.hint'),
876
+ },
877
+ h(Icon, { name: 'chart', size: 11 }),
878
+ t('editor.analysis'),
879
+ ),
774
880
  ),
775
881
  ),
776
882
  h(
@@ -785,7 +891,40 @@
785
891
  'div',
786
892
  { className: 'cait-seg-head' },
787
893
  h('span', { className: 'cait-seg-label' }, `${t('editor.segment')} ${index + 1} / ${doc.segments.length}`),
788
- h('span', { className: `cait-badge ${segment.status}` }, statusLabel),
894
+ h(
895
+ 'span',
896
+ { className: 'cait-seg-flags' },
897
+ h(
898
+ 'button',
899
+ {
900
+ className: `cait-linkbtn ${segment.locked ? 'active' : ''}`,
901
+ onClick: onToggleLock,
902
+ title: segment.locked ? t('editor.unlock') : t('editor.lock'),
903
+ 'aria-pressed': Boolean(segment.locked),
904
+ },
905
+ h(Icon, { name: segment.locked ? 'unlock' : 'lock', size: 11 }),
906
+ segment.locked ? t('editor.locked') : t('editor.lock'),
907
+ ),
908
+ h(
909
+ 'button',
910
+ {
911
+ className: 'cait-linkbtn cait-notelink',
912
+ onClick: onOpenNotes,
913
+ title: t('notes.hint'),
914
+ },
915
+ h(Icon, { name: 'message', size: 11 }),
916
+ t('editor.notes'),
917
+ noteCount ? h('span', { className: 'cait-notecount' }, String(noteCount)) : null,
918
+ ),
919
+ qaRisk
920
+ ? h(
921
+ 'span',
922
+ { className: `cait-riskchip ${qaRisk.risk}`, title: qaRisk.codes.map((code) => t(`qa.code.${code}`)).join(' · ') },
923
+ `${t('qa.risk')} · ${t(`qa.${qaRisk.risk}`)}`,
924
+ )
925
+ : null,
926
+ h('span', { className: `cait-badge ${segment.status}` }, statusLabel),
927
+ ),
789
928
  ),
790
929
  h(
791
930
  'div',
@@ -868,6 +1007,7 @@
868
1007
  value: draft,
869
1008
  onChange: (event) => setDraft(event.target.value),
870
1009
  onKeyDown,
1010
+ readOnly: Boolean(segment.locked),
871
1011
  placeholder: t('editor.targetPlaceholder'),
872
1012
  spellCheck: false,
873
1013
  rows: 8,
@@ -879,10 +1019,18 @@
879
1019
  { className: 'cait-actionbar' },
880
1020
  h(
881
1021
  'button',
882
- { className: 'cait-btn cait-btn-primary', onClick: onConfirm, disabled: Boolean(busy) || !draft.trim() },
1022
+ { className: 'cait-btn cait-btn-primary', onClick: onConfirm, disabled: Boolean(busy) || Boolean(segment.locked) || !draft.trim() },
883
1023
  busy === 'confirm' ? h(Spinner) : h(Icon, { name: 'check', size: 14 }),
884
1024
  segment.status === 'confirmed' ? t('editor.update') : t('editor.confirm'),
885
1025
  ),
1026
+ segment.status === 'confirmed'
1027
+ ? h(
1028
+ 'button',
1029
+ { className: 'cait-btn cait-btn-ghost', onClick: onUnconfirm, disabled: Boolean(busy) || Boolean(segment.locked), title: t('editor.unconfirmHint') },
1030
+ h(Icon, { name: 'refresh', size: 13 }),
1031
+ t('editor.unconfirm'),
1032
+ )
1033
+ : null,
886
1034
  h(
887
1035
  'button',
888
1036
  { className: 'cait-btn', onClick: onNext, disabled: Boolean(busy) },
@@ -895,9 +1043,60 @@
895
1043
  h(Icon, { name: 'chevronLeft', size: 13 }),
896
1044
  t('editor.prev'),
897
1045
  ),
1046
+ isSubtitle
1047
+ ? h(
1048
+ 'div',
1049
+ { className: 'cait-subbar' },
1050
+ h('span', { className: 'cait-subtime' }, `${formatCue(cueStart)} → ${formatCue(cueEnd)}`),
1051
+ h('input', {
1052
+ className: 'cait-suboffset',
1053
+ type: 'number',
1054
+ step: '0.1',
1055
+ value: shiftSeconds,
1056
+ onChange: (event) => setShiftSeconds(event.target.value),
1057
+ title: t('editor.shiftHint'),
1058
+ }),
1059
+ h(
1060
+ 'button',
1061
+ { className: 'cait-btn cait-btn-ghost', onClick: () => onShiftSubtitles(Math.round(Number(shiftSeconds) * 1000)), disabled: Boolean(busy) || Boolean(segment.locked) || !Number.isFinite(Number(shiftSeconds)) },
1062
+ t('editor.shift'),
1063
+ ),
1064
+ h(
1065
+ 'button',
1066
+ { className: 'cait-btn cait-btn-ghost', onClick: onMergeCue, disabled: Boolean(busy) || Boolean(segment.locked) || index >= doc.segments.length - 1 },
1067
+ t('editor.mergeCue'),
1068
+ ),
1069
+ h(
1070
+ 'button',
1071
+ { className: 'cait-btn cait-btn-ghost', onClick: () => onSplitCue(Math.round((cueStart + cueEnd) / 2)), disabled: Boolean(busy) || Boolean(segment.locked) || !(cueEnd > cueStart) },
1072
+ t('editor.splitCue'),
1073
+ ),
1074
+ )
1075
+ : [
1076
+ h(
1077
+ 'button',
1078
+ { key: 'merge', className: 'cait-btn cait-btn-ghost', onClick: onMerge, disabled: Boolean(busy) || !canMerge, title: t('editor.mergeHint') },
1079
+ t('editor.merge'),
1080
+ ),
1081
+ h(
1082
+ 'button',
1083
+ { key: 'split', className: 'cait-btn cait-btn-ghost', onClick: splitAtCursor, disabled: Boolean(busy) || !canSplit, title: t('editor.splitHint') },
1084
+ t('editor.split'),
1085
+ ),
1086
+ ],
1087
+ h(
1088
+ 'button',
1089
+ { className: 'cait-iconbtn', onClick: onUndo, disabled: Boolean(busy) || !canUndo, title: t('editor.undo'), 'aria-label': t('editor.undo') },
1090
+ '↶',
1091
+ ),
1092
+ h(
1093
+ 'button',
1094
+ { className: 'cait-iconbtn', onClick: onRedo, disabled: Boolean(busy) || !canRedo, title: t('editor.redo'), 'aria-label': t('editor.redo') },
1095
+ '↷',
1096
+ ),
898
1097
  h(
899
1098
  'button',
900
- { className: 'cait-iconbtn cait-savebtn', onClick: onSave, disabled: Boolean(busy) || !dirty, title: t('editor.save') },
1099
+ { className: 'cait-iconbtn cait-savebtn', onClick: onSave, disabled: Boolean(busy) || Boolean(segment.locked) || !dirty, title: t('editor.save') },
901
1100
  h(Icon, { name: 'save', size: 14 }),
902
1101
  ),
903
1102
  h('span', { className: 'cait-hint' }, t('editor.hint')),
@@ -1020,6 +1219,19 @@
1020
1219
  onCopy,
1021
1220
  onOpenSettings,
1022
1221
  onSearchLibrary,
1222
+ qa,
1223
+ onRefreshQa,
1224
+ onJumpQa,
1225
+ onFixQa,
1226
+ review,
1227
+ reviewBusy,
1228
+ onReview,
1229
+ onApplyReview,
1230
+ concordance,
1231
+ concordanceQuery,
1232
+ setConcordanceQuery,
1233
+ onConcordance,
1234
+ onApplyConcordance,
1023
1235
  busy,
1024
1236
  }) {
1025
1237
  const [termQuery, setTermQuery] = useState('')
@@ -1099,6 +1311,19 @@
1099
1311
  h(Icon, { name: 'sparkles', size: 13 }),
1100
1312
  t('panel.mt'),
1101
1313
  ),
1314
+ h(
1315
+ 'button',
1316
+ { className: `cait-tab${tab === 'qa' ? ' active' : ''}`, onClick: () => setTab('qa') },
1317
+ h(Icon, { name: 'alert', size: 13 }),
1318
+ t('panel.qa'),
1319
+ qa?.issueCount ? h('span', { className: 'cait-count' }, qa.issueCount) : null,
1320
+ ),
1321
+ h(
1322
+ 'button',
1323
+ { className: `cait-tab${tab === 'concordance' ? ' active' : ''}`, onClick: () => setTab('concordance') },
1324
+ h(Icon, { name: 'search', size: 13 }),
1325
+ t('panel.concordance'),
1326
+ ),
1102
1327
  ),
1103
1328
  h(
1104
1329
  'div',
@@ -1176,7 +1401,7 @@
1176
1401
  event.target.value = ''
1177
1402
  },
1178
1403
  }),
1179
- h('button', { className: 'cait-btn', onClick: () => tmxInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'upload', size: 12 }), t('panel.importTmx')),
1404
+ h('button', { className: 'cait-btn', onClick: () => tmxInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'download', size: 12 }), t('panel.importTmx')),
1180
1405
  h('button', { className: 'cait-btn cait-btn-danger', onClick: onClearTm, disabled: Boolean(busy) || data.tm.count === 0 }, t('panel.clearTm')),
1181
1406
  ),
1182
1407
  )
@@ -1194,7 +1419,7 @@
1194
1419
  h(
1195
1420
  'button',
1196
1421
  { key: hit.id, className: 'cait-chip cait-chip-action', onClick: () => onApplyTerm(hit), title: hit.note || hit.target },
1197
- `${hit.term} → ${hit.target}`,
1422
+ `${hit.term} → ${hit.target}${hit.forbidden ? ` · ${t('panel.termForbidden')}` : hit.preferred ? ` · ${t('panel.termPreferred')}` : ''}`,
1198
1423
  ),
1199
1424
  ),
1200
1425
  )
@@ -1227,7 +1452,7 @@
1227
1452
  event.target.value = ''
1228
1453
  },
1229
1454
  }),
1230
- h('button', { className: 'cait-btn', onClick: () => glossaryInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'upload', size: 12 }), t('panel.importCsv')),
1455
+ h('button', { className: 'cait-btn', onClick: () => glossaryInput.current?.click(), disabled: Boolean(busy) }, h(Icon, { name: 'download', size: 12 }), t('panel.importCsv')),
1231
1456
  h('button', { className: 'cait-btn cait-btn-danger', onClick: onClearGlossary, disabled: Boolean(busy) || data.glossary.length === 0 }, t('panel.clearGlossary')),
1232
1457
  ),
1233
1458
  )
@@ -1292,6 +1517,239 @@
1292
1517
  ),
1293
1518
  )
1294
1519
  : null,
1520
+ tab === 'qa'
1521
+ ? h(
1522
+ 'div',
1523
+ null,
1524
+ h(
1525
+ 'div',
1526
+ { className: 'cait-subhead' },
1527
+ h('span', null, t('qa.title')),
1528
+ h(
1529
+ 'div',
1530
+ { className: 'cait-subhead-actions' },
1531
+ h(
1532
+ 'button',
1533
+ {
1534
+ className: 'cait-linkbtn',
1535
+ onClick: onReview,
1536
+ disabled: Boolean(busy) || Boolean(reviewBusy),
1537
+ title: t('qa.review'),
1538
+ },
1539
+ h(Icon, { name: 'sparkles', size: 12 }),
1540
+ reviewBusy ? t('qa.reviewing') : t('qa.review'),
1541
+ ),
1542
+ h(
1543
+ 'button',
1544
+ { className: 'cait-linkbtn', onClick: onRefreshQa, disabled: Boolean(busy) },
1545
+ h(Icon, { name: 'refresh', size: 12 }),
1546
+ t('qa.refresh'),
1547
+ ),
1548
+ ),
1549
+ ),
1550
+ qa
1551
+ ? h(
1552
+ 'div',
1553
+ null,
1554
+ h(
1555
+ 'div',
1556
+ { className: 'cait-riskcounts' },
1557
+ ['high', 'medium', 'low'].map((level) =>
1558
+ h(
1559
+ 'span',
1560
+ { key: level, className: `cait-riskcount ${level}` },
1561
+ `${t(`qa.${level}`)} ${qa.riskCounts?.[level] || 0}`,
1562
+ ),
1563
+ ),
1564
+ ),
1565
+ (qa.risks || []).length
1566
+ ? qa.risks.map((item) => {
1567
+ const segment = doc.segments[item.index]
1568
+ const codes = (item.codes || []).map((code) => t(`qa.code.${code}`)).join(' · ')
1569
+ return h(
1570
+ 'div',
1571
+ {
1572
+ key: item.index,
1573
+ className: `cait-riskrow ${item.risk}`,
1574
+ role: 'button',
1575
+ tabIndex: 0,
1576
+ onClick: () => onJumpQa(item.index),
1577
+ onKeyDown: (event) => {
1578
+ if (event.key === 'Enter' || event.key === ' ') {
1579
+ event.preventDefault()
1580
+ onJumpQa(item.index)
1581
+ }
1582
+ },
1583
+ title: codes,
1584
+ },
1585
+ h('span', { className: 'cait-riskrow-index' }, `${item.index + 1}`),
1586
+ h(
1587
+ 'div',
1588
+ { className: 'cait-riskrow-body' },
1589
+ h('span', { className: 'cait-riskrow-source' }, segment?.source || ''),
1590
+ h('span', { className: 'cait-riskrow-codes' }, codes),
1591
+ onFixQa
1592
+ ? h(
1593
+ 'div',
1594
+ { className: 'cait-riskactions' },
1595
+ h(
1596
+ 'button',
1597
+ {
1598
+ className: 'cait-riskaction',
1599
+ onClick: (event) => {
1600
+ event.stopPropagation()
1601
+ onFixQa(item, 'copy-source')
1602
+ },
1603
+ disabled: Boolean(busy),
1604
+ },
1605
+ t('qa.fixCopy'),
1606
+ ),
1607
+ h(
1608
+ 'button',
1609
+ {
1610
+ className: 'cait-riskaction',
1611
+ onClick: (event) => {
1612
+ event.stopPropagation()
1613
+ onFixQa(item, 'clear')
1614
+ },
1615
+ disabled: Boolean(busy),
1616
+ },
1617
+ t('qa.fixClear'),
1618
+ ),
1619
+ item.termTarget
1620
+ ? h(
1621
+ 'button',
1622
+ {
1623
+ className: 'cait-riskaction',
1624
+ onClick: (event) => {
1625
+ event.stopPropagation()
1626
+ onFixQa(item, 'term')
1627
+ },
1628
+ disabled: Boolean(busy),
1629
+ },
1630
+ t('qa.fixTerm'),
1631
+ )
1632
+ : null,
1633
+ )
1634
+ : null,
1635
+ ),
1636
+ h(Icon, { name: 'chevronRight', size: 12 }),
1637
+ )
1638
+ })
1639
+ : h('p', { className: 'cait-empty-note' }, t('qa.empty')),
1640
+ )
1641
+ : h('p', { className: 'cait-empty-note' }, t('qa.loading')),
1642
+ review
1643
+ ? h(
1644
+ 'div',
1645
+ { className: 'cait-review' },
1646
+ h('div', { className: 'cait-listhead' }, t('qa.reviewTitle')),
1647
+ (review.issues || []).length
1648
+ ? review.issues.map((issue, issueIndex) => {
1649
+ const severity = ['high', 'medium', 'low'].includes(issue.severity)
1650
+ ? issue.severity
1651
+ : 'medium'
1652
+ return h(
1653
+ 'div',
1654
+ {
1655
+ key: `${issue.index}-${issueIndex}`,
1656
+ className: 'cait-reviewrow',
1657
+ role: 'button',
1658
+ tabIndex: 0,
1659
+ onClick: () => onJumpQa(issue.index),
1660
+ onKeyDown: (event) => {
1661
+ if (event.key === 'Enter' || event.key === ' ') {
1662
+ event.preventDefault()
1663
+ onJumpQa(issue.index)
1664
+ }
1665
+ },
1666
+ },
1667
+ h('span', { className: 'cait-reviewrow-index' }, `${issue.index + 1}`),
1668
+ h(
1669
+ 'div',
1670
+ { className: 'cait-reviewrow-body' },
1671
+ h(
1672
+ 'span',
1673
+ { className: `cait-reviewrow-msg ${severity}` },
1674
+ `[${t(`qa.${severity}`)}] ${issue.message}`,
1675
+ ),
1676
+ issue.suggestion
1677
+ ? h(
1678
+ 'button',
1679
+ {
1680
+ className: 'cait-riskaction',
1681
+ onClick: (event) => {
1682
+ event.stopPropagation()
1683
+ onApplyReview(issue)
1684
+ },
1685
+ },
1686
+ t('qa.reviewApply'),
1687
+ )
1688
+ : null,
1689
+ ),
1690
+ )
1691
+ })
1692
+ : h('p', { className: 'cait-empty-note' }, t('qa.reviewEmpty')),
1693
+ )
1694
+ : null,
1695
+ )
1696
+ : null,
1697
+ tab === 'concordance'
1698
+ ? h(
1699
+ 'div',
1700
+ null,
1701
+ h(
1702
+ 'form',
1703
+ {
1704
+ className: 'cait-search',
1705
+ onSubmit: (event) => {
1706
+ event.preventDefault()
1707
+ onConcordance()
1708
+ },
1709
+ },
1710
+ h(Icon, { name: 'search', size: 13 }),
1711
+ h('input', {
1712
+ value: concordanceQuery,
1713
+ onChange: (event) => setConcordanceQuery(event.target.value),
1714
+ placeholder: t('panel.concordancePlaceholder'),
1715
+ 'aria-label': t('panel.concordancePlaceholder'),
1716
+ }),
1717
+ h(
1718
+ 'button',
1719
+ { className: 'cait-iconbtn', type: 'submit', title: t('panel.searchConcordance') },
1720
+ h(Icon, { name: 'arrowRight', size: 13 }),
1721
+ ),
1722
+ ),
1723
+ h('p', { className: 'cait-mt-hint' }, t('panel.concordanceHint')),
1724
+ concordance
1725
+ ? h(
1726
+ 'div',
1727
+ null,
1728
+ h(
1729
+ 'div',
1730
+ { className: 'cait-listhead' },
1731
+ h('span', null, t('panel.concordance')),
1732
+ h('span', { className: 'cait-count' }, concordance.total ?? concordance.entries?.length ?? 0),
1733
+ ),
1734
+ (concordance.entries || []).length
1735
+ ? concordance.entries.map((entry, i) =>
1736
+ h(
1737
+ 'button',
1738
+ {
1739
+ key: `${entry.tmId || entry.library || 'c'}-${i}`,
1740
+ className: 'cait-concordance-row',
1741
+ onClick: () => onApplyConcordance(entry),
1742
+ },
1743
+ h('span', { className: 'cait-concordance-source' }, entry.source),
1744
+ h('span', { className: 'cait-concordance-target' }, entry.target),
1745
+ h('span', { className: 'cait-concordance-meta' }, entry.library || ''),
1746
+ ),
1747
+ )
1748
+ : h('p', { className: 'cait-empty-note' }, t('panel.noConcordance')),
1749
+ )
1750
+ : null,
1751
+ )
1752
+ : null,
1295
1753
  ),
1296
1754
  )
1297
1755
  }
@@ -1484,6 +1942,7 @@
1484
1942
  const sourceRef = useRef(null)
1485
1943
  const targetRef = useRef(null)
1486
1944
  const libraries = data?.tmLibraries || []
1945
+ const languageOptions = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'fr', 'de', 'es', 'pt-BR', 'ru', 'it', 'ar']
1487
1946
 
1488
1947
  useEffect(() => {
1489
1948
  if (!tmId && libraries.length) setTmId(libraries[0].id)
@@ -1584,7 +2043,12 @@
1584
2043
  action: async () => {
1585
2044
  setWorking(true)
1586
2045
  try {
1587
- const written = await rpc('writeAlignment', { pairs, tmId: tmId || undefined })
2046
+ const written = await rpc('writeAlignment', {
2047
+ pairs,
2048
+ tmId: tmId || undefined,
2049
+ sourceLang: sourceLang || undefined,
2050
+ targetLang: targetLang || undefined,
2051
+ })
1588
2052
  notify(t('align.written', { written: written.written, skipped: written.skipped }))
1589
2053
  } catch (error) {
1590
2054
  report(error)
@@ -1632,15 +2096,20 @@
1632
2096
  h(
1633
2097
  'div',
1634
2098
  { className: 'cait-alignactions' },
2099
+ h('datalist', { id: 'cait-align-languages' }, languageOptions.map((code) => h('option', { key: code, value: code }))),
1635
2100
  h('input', {
1636
2101
  className: 'cait-libinput',
1637
2102
  placeholder: t('align.sourceLang'),
2103
+ title: t('align.langHint'),
2104
+ list: 'cait-align-languages',
1638
2105
  value: sourceLang,
1639
2106
  onChange: (event) => setSourceLang(event.target.value),
1640
2107
  }),
1641
2108
  h('input', {
1642
2109
  className: 'cait-libinput',
1643
2110
  placeholder: t('align.targetLang'),
2111
+ title: t('align.langHint'),
2112
+ list: 'cait-align-languages',
1644
2113
  value: targetLang,
1645
2114
  onChange: (event) => setTargetLang(event.target.value),
1646
2115
  }),
@@ -1650,8 +2119,15 @@
1650
2119
  className: 'cait-select cait-liblang',
1651
2120
  value: tmId,
1652
2121
  onChange: (event) => setTmId(event.target.value),
2122
+ title: t('align.tmHint'),
2123
+ 'aria-label': t('align.tm'),
2124
+ disabled: !libraries.length,
1653
2125
  },
1654
- libraries.map((library) => h('option', { key: library.id, value: library.id }, library.name)),
2126
+ libraries.length
2127
+ ? libraries.map((library) =>
2128
+ h('option', { key: library.id, value: library.id }, t('align.tmOption', { name: library.name })),
2129
+ )
2130
+ : h('option', { value: '' }, t('align.noTm')),
1655
2131
  ),
1656
2132
  h(
1657
2133
  'button',
@@ -1766,6 +2242,7 @@
1766
2242
  onCreateLibrary,
1767
2243
  onRenameLibrary,
1768
2244
  onDeleteLibrary,
2245
+ onSetMounts,
1769
2246
  }) {
1770
2247
  const storage = typeof localStorage !== 'undefined' ? localStorage : null
1771
2248
  const [kind, setKind] = useState(seed?.kind === 'glossary' ? 'glossary' : 'tm')
@@ -1793,7 +2270,23 @@
1793
2270
  const idKey = kind === 'tm' ? 'tmId' : 'glossaryId'
1794
2271
  const labelKey = 'name'
1795
2272
  const scopeKey = scopeIds.join(',')
1796
- const targetLibraryId = scopeIds[0] || (kind === 'tm' ? data?.mounts?.defaultTmId : data?.mounts?.defaultGlossaryId) || libraries[0]?.[idKey] || ''
2273
+ const defaultWriteId = kind === 'tm' ? data?.mounts?.defaultTmId : data?.mounts?.defaultGlossaryId
2274
+ const targetLibraryId = libraries.some((library) => library[idKey] === defaultWriteId)
2275
+ ? defaultWriteId
2276
+ : libraries[0]?.[idKey] || ''
2277
+
2278
+ const changeWriteLibrary = async (value) => {
2279
+ if (!value || value === targetLibraryId) return
2280
+ // A default must also be mounted; adding it here keeps the selector
2281
+ // from failing when the chosen library was only in the list view.
2282
+ if (kind === 'tm') {
2283
+ const tmIds = Array.from(new Set([...(data?.mounts?.tmIds || []), value]))
2284
+ await onSetMounts?.({ tmIds, defaultTmId: value })
2285
+ } else {
2286
+ const glossaryIds = Array.from(new Set([...(data?.mounts?.glossaryIds || []), value]))
2287
+ await onSetMounts?.({ glossaryIds, defaultGlossaryId: value })
2288
+ }
2289
+ }
1797
2290
 
1798
2291
  useEffect(() => {
1799
2292
  const timer = setTimeout(() => setDebouncedQuery(query), 250)
@@ -2034,6 +2527,35 @@
2034
2527
  })
2035
2528
  }
2036
2529
 
2530
+ const maintainTm = async (action) => {
2531
+ if (!targetLibraryId) return
2532
+ try {
2533
+ const preview = await rpc('tmMaintenancePreview', {
2534
+ action,
2535
+ tmId: targetLibraryId,
2536
+ emptyTarget: true,
2537
+ })
2538
+ const count = action === 'dedupe' ? preview.duplicates : preview.matched
2539
+ onConfirm({
2540
+ title: t(`tm.maintain.${action}`),
2541
+ message: t(`tm.maintain.${action}Hint`).replace('{count}', String(count)),
2542
+ confirmLabel: t(`tm.maintain.${action}`),
2543
+ action: async () => {
2544
+ const result = await rpc(action === 'dedupe' ? 'dedupeTmLibrary' : 'pruneTmLibrary', {
2545
+ tmId: targetLibraryId,
2546
+ emptyTarget: true,
2547
+ confirm: true,
2548
+ })
2549
+ if (result?.state) onApplyState(result.state)
2550
+ await loadRows()
2551
+ notify(t('tm.maintain.done').replace('{count}', String(count)))
2552
+ },
2553
+ })
2554
+ } catch (error) {
2555
+ report(error)
2556
+ }
2557
+ }
2558
+
2037
2559
  const exportLibrary = async () => {
2038
2560
  if (!targetLibraryId) return
2039
2561
  setWorking('export')
@@ -2306,6 +2828,28 @@
2306
2828
  ['tbx', 'csv', 'json'].map((format) => h('option', { key: format, value: format }, format.toUpperCase())),
2307
2829
  )
2308
2830
  : null,
2831
+ h(
2832
+ 'label',
2833
+ { className: 'cait-libwrite', title: t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary') },
2834
+ h(
2835
+ 'span',
2836
+ { className: 'cait-libwritelabel' },
2837
+ t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary'),
2838
+ ),
2839
+ h(
2840
+ 'select',
2841
+ {
2842
+ className: 'cait-liblang',
2843
+ value: targetLibraryId,
2844
+ onChange: (event) => changeWriteLibrary(event.target.value),
2845
+ disabled: Boolean(busy) || !libraries.length,
2846
+ 'aria-label': t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary'),
2847
+ },
2848
+ libraries.map((library) =>
2849
+ h('option', { key: library[idKey], value: library[idKey] }, library[labelKey]),
2850
+ ),
2851
+ ),
2852
+ ),
2309
2853
  kind === 'glossary'
2310
2854
  ? h(
2311
2855
  'button',
@@ -2335,7 +2879,7 @@
2335
2879
  disabled: Boolean(busy) || !targetLibraryId,
2336
2880
  title: t('library.import'),
2337
2881
  },
2338
- h(Icon, { name: 'upload', size: 13 }),
2882
+ h(Icon, { name: 'download', size: 13 }),
2339
2883
  h('span', { className: 'cait-btn-label' }, t('library.import')),
2340
2884
  ),
2341
2885
  h(
@@ -2347,9 +2891,37 @@
2347
2891
  disabled: Boolean(busy) || working === 'export' || !targetLibraryId,
2348
2892
  title: t('library.export'),
2349
2893
  },
2350
- h(Icon, { name: 'download', size: 13 }),
2894
+ h(Icon, { name: 'upload', size: 13 }),
2351
2895
  h('span', { className: 'cait-btn-label' }, kind === 'tm' ? 'TMX' : exportFormat.toUpperCase()),
2352
2896
  ),
2897
+ kind === 'tm'
2898
+ ? h(
2899
+ 'button',
2900
+ {
2901
+ type: 'button',
2902
+ className: 'cait-btn',
2903
+ onClick: () => maintainTm('dedupe'),
2904
+ disabled: Boolean(busy) || !targetLibraryId,
2905
+ title: t('tm.maintain.dedupe'),
2906
+ },
2907
+ h(Icon, { name: 'database', size: 13 }),
2908
+ h('span', { className: 'cait-btn-label' }, t('tm.maintain.dedupe')),
2909
+ )
2910
+ : null,
2911
+ kind === 'tm'
2912
+ ? h(
2913
+ 'button',
2914
+ {
2915
+ type: 'button',
2916
+ className: 'cait-btn',
2917
+ onClick: () => maintainTm('prune'),
2918
+ disabled: Boolean(busy) || !targetLibraryId,
2919
+ title: t('tm.maintain.prune'),
2920
+ },
2921
+ h(Icon, { name: 'trash', size: 13 }),
2922
+ h('span', { className: 'cait-btn-label' }, t('tm.maintain.prune')),
2923
+ )
2924
+ : null,
2353
2925
  h(
2354
2926
  'button',
2355
2927
  {
@@ -2360,6 +2932,7 @@
2360
2932
  title: t('library.clear'),
2361
2933
  },
2362
2934
  h(Icon, { name: 'trash', size: 13 }),
2935
+ h('span', { className: 'cait-btn-label' }, t('library.clear')),
2363
2936
  ),
2364
2937
  h('input', {
2365
2938
  ref: fileRef,
@@ -2612,6 +3185,7 @@
2612
3185
  onChange: () => toggleTerm(at),
2613
3186
  }),
2614
3187
  h('span', { className: 'cait-termname' }, candidate.term),
3188
+ candidate.proper ? h('span', { className: 'cait-originchip' }, t('term.proper')) : null,
2615
3189
  h('span', { className: 'cait-termcount' }, `×${candidate.count}`),
2616
3190
  candidate.inLibrary ? h('span', { className: 'cait-originchip' }, t('term.inLibrary')) : null,
2617
3191
  h('input', {
@@ -2629,7 +3203,7 @@
2629
3203
  )
2630
3204
  }
2631
3205
 
2632
- function ExportModal({ t, busy, onClose, onExport, qa }) {
3206
+ function ExportModal({ t, busy, onClose, onExport, onImportReview, qa }) {
2633
3207
  const issues = qa?.issueCount || 0
2634
3208
  const items = [
2635
3209
  ['bilingual-md', t('modal.export.bilingualMd'), t('modal.export.bilingual'), 'file'],
@@ -2638,11 +3212,20 @@
2638
3212
  ['target-txt', t('modal.export.targetTxt'), t('modal.export.target'), 'download'],
2639
3213
  ['target-md', t('modal.export.targetMd'), t('modal.export.target'), 'download'],
2640
3214
  ['docx', t('modal.export.docx'), t('modal.export.docxGroup'), 'file'],
3215
+ ['bilingual-docx', t('modal.export.bilingualDocx'), t('modal.export.bilingual'), 'file'],
2641
3216
  ['epub', t('modal.export.epub'), t('modal.export.epubGroup'), 'file'],
2642
3217
  ['srt', t('modal.export.srt'), t('modal.export.subtitleGroup'), 'file'],
2643
3218
  ['vtt', t('modal.export.vtt'), t('modal.export.subtitleGroup'), 'file'],
2644
3219
  ['po', t('modal.export.po'), t('modal.export.localizationGroup'), 'file'],
3220
+ ['apple-strings', t('modal.export.appleStrings'), t('modal.export.localizationGroup'), 'file'],
3221
+ ['apple-stringsdict', t('modal.export.appleStringsdict'), t('modal.export.localizationGroup'), 'file'],
3222
+ ['android', t('modal.export.android'), t('modal.export.localizationGroup'), 'file'],
3223
+ ['resx', t('modal.export.resx'), t('modal.export.localizationGroup'), 'file'],
3224
+ ['arb', t('modal.export.arb'), t('modal.export.localizationGroup'), 'file'],
3225
+ ['yaml', t('modal.export.yaml'), t('modal.export.localizationGroup'), 'file'],
3226
+ ['properties', t('modal.export.properties'), t('modal.export.localizationGroup'), 'file'],
2645
3227
  ['xliff', t('modal.export.xliff'), t('modal.export.localizationGroup'), 'file'],
3228
+ ['xliff-review', t('modal.export.xliffReview'), t('modal.export.localizationGroup'), 'file'],
2646
3229
  ['xliff2', t('modal.export.xliff2'), t('modal.export.localizationGroup'), 'file'],
2647
3230
  ['sdlxliff', t('modal.export.sdlxliff'), t('modal.export.localizationGroup'), 'file'],
2648
3231
  ['html', t('modal.export.html'), t('modal.export.webGroup'), 'file'],
@@ -2685,6 +3268,30 @@
2685
3268
  ),
2686
3269
  ),
2687
3270
  ),
3271
+ onImportReview
3272
+ ? h(
3273
+ 'div',
3274
+ { className: 'cait-review-import' },
3275
+ h('p', { className: 'cait-libnote' }, t('modal.export.reviewImportHint')),
3276
+ h(
3277
+ 'label',
3278
+ { className: 'cait-btn' },
3279
+ h(Icon, { name: 'upload', size: 13 }),
3280
+ t('modal.export.reviewImport'),
3281
+ h('input', {
3282
+ type: 'file',
3283
+ accept: '.xliff,.xlf,application/x-xliff+xml',
3284
+ style: { display: 'none' },
3285
+ disabled: Boolean(busy),
3286
+ onChange: (event) => {
3287
+ const file = event.target.files?.[0]
3288
+ if (file) onImportReview(file)
3289
+ event.target.value = ''
3290
+ },
3291
+ }),
3292
+ ),
3293
+ )
3294
+ : null,
2688
3295
  )
2689
3296
  }
2690
3297
 
@@ -2705,13 +3312,23 @@
2705
3312
  model: settings.mt.model,
2706
3313
  temperature: settings.mt.temperature,
2707
3314
  },
3315
+ review: {
3316
+ provider: settings.review?.provider || '',
3317
+ model: settings.review?.model || '',
3318
+ temperature: settings.review?.temperature ?? 0,
3319
+ },
2708
3320
  })
2709
3321
  const routes = Array.isArray(mtRoutes?.providers) ? mtRoutes.providers : []
2710
3322
  const route = routes.find((item) => item.id === form.mt.provider)
2711
3323
  const models = Array.isArray(route?.models) ? route.models : []
2712
3324
  const routeMissing = form.mt.provider !== 'mock' && !route
3325
+ const reviewRoute = routes.find((item) => item.id === form.review.provider)
3326
+ const reviewModels = Array.isArray(reviewRoute?.models) ? reviewRoute.models : []
3327
+ const reviewInherited = !form.review.provider
2713
3328
 
2714
3329
  const setMt = (patch) => setForm((current) => ({ ...current, mt: { ...current.mt, ...patch } }))
3330
+ const setReview = (patch) =>
3331
+ setForm((current) => ({ ...current, review: { ...current.review, ...patch } }))
2715
3332
  const setPre = (patch) =>
2716
3333
  setForm((current) => ({ ...current, preprocess: { ...current.preprocess, ...patch } }))
2717
3334
  const selectProvider = (provider) => {
@@ -2729,6 +3346,18 @@
2729
3346
  }))
2730
3347
  }
2731
3348
 
3349
+ const selectReviewProvider = (provider) => {
3350
+ const nextRoute = routes.find((item) => item.id === provider)
3351
+ setForm((current) => ({
3352
+ ...current,
3353
+ review: {
3354
+ ...current.review,
3355
+ provider,
3356
+ model: nextRoute?.models?.[0]?.id || '',
3357
+ },
3358
+ }))
3359
+ }
3360
+
2732
3361
  const submit = (event) => {
2733
3362
  event.preventDefault()
2734
3363
  const patch = {
@@ -2747,6 +3376,11 @@
2747
3376
  model: form.mt.model,
2748
3377
  temperature: Number(form.mt.temperature),
2749
3378
  },
3379
+ review: {
3380
+ provider: form.review.provider,
3381
+ model: form.review.model,
3382
+ temperature: Number(form.review.temperature),
3383
+ },
2750
3384
  }
2751
3385
  onSave(patch)
2752
3386
  }
@@ -2831,9 +3465,26 @@
2831
3465
  { className: 'cait-settings-section' },
2832
3466
  h('h4', { className: 'cait-settings-title' }, t('modal.settings.tm')),
2833
3467
  h(
2834
- 'label',
3468
+ 'div',
2835
3469
  { className: 'cait-field' },
2836
- h('span', null, `${t('modal.settings.fuzzy')} · ${Math.round(Number(form.fuzzyThreshold) * 100)}%`),
3470
+ h(
3471
+ 'div',
3472
+ { className: 'cait-fieldhead' },
3473
+ h('span', null, `${t('modal.settings.fuzzy')} · ${fuzzyPercent(form.fuzzyThreshold)}%`),
3474
+ // Offered only while the slider is away from the default, so the
3475
+ // control never takes space in the usual case.
3476
+ fuzzyPercent(form.fuzzyThreshold) === fuzzyPercent(DEFAULT_FUZZY_THRESHOLD)
3477
+ ? null
3478
+ : h(
3479
+ 'button',
3480
+ {
3481
+ className: 'cait-linkbtn cait-fieldreset',
3482
+ type: 'button',
3483
+ onClick: () => setForm({ ...form, fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD }),
3484
+ },
3485
+ t('modal.settings.resetDefault'),
3486
+ ),
3487
+ ),
2837
3488
  h('input', {
2838
3489
  className: 'cait-range',
2839
3490
  type: 'range',
@@ -2843,6 +3494,7 @@
2843
3494
  value: form.fuzzyThreshold,
2844
3495
  onChange: (event) => setForm({ ...form, fuzzyThreshold: event.target.value }),
2845
3496
  }),
3497
+ h('p', { className: 'cait-fieldhint' }, t('modal.settings.fuzzyHint')),
2846
3498
  ),
2847
3499
  h(
2848
3500
  'label',
@@ -2938,22 +3590,114 @@
2938
3590
  ),
2939
3591
  h(
2940
3592
  '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' },
3593
+ { className: 'cait-settings-section' },
3594
+ h('h4', { className: 'cait-settings-title' }, t('modal.settings.review')),
3595
+ h(
3596
+ 'label',
3597
+ { className: 'cait-checkline' },
3598
+ h('input', {
3599
+ type: 'checkbox',
3600
+ checked: reviewInherited,
3601
+ onChange: (event) => {
3602
+ if (event.target.checked) {
3603
+ setForm((current) => ({
3604
+ ...current,
3605
+ review: { ...current.review, provider: '', model: '' },
3606
+ }))
3607
+ return
3608
+ }
3609
+ const first = routes.find((item) => item.id !== 'mock' && item.hasModels !== false) || routes[0]
3610
+ setForm((current) => ({
3611
+ ...current,
3612
+ review: {
3613
+ ...current.review,
3614
+ provider: first?.id || '',
3615
+ model: first?.models?.[0]?.id || '',
3616
+ },
3617
+ }))
3618
+ },
3619
+ }),
3620
+ t('modal.settings.reviewInherit'),
3621
+ ),
3622
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('modal.settings.reviewHint')),
3623
+ reviewInherited
3624
+ ? null
3625
+ : h(
3626
+ 'div',
3627
+ { className: 'cait-grid2' },
3628
+ h(
3629
+ 'label',
3630
+ { className: 'cait-field' },
3631
+ h('span', null, t('modal.settings.reviewProvider')),
3632
+ h(
3633
+ 'select',
3634
+ {
3635
+ className: 'cait-input',
3636
+ value: form.review.provider,
3637
+ onChange: (event) => selectReviewProvider(event.target.value),
3638
+ },
3639
+ routes.map((item) =>
3640
+ h(
3641
+ 'option',
3642
+ { key: item.id, value: item.id, disabled: item.hasModels === false },
3643
+ item.hasModels === false
3644
+ ? `${item.name} (${item.id}) - ${t('modal.settings.noModels')}`
3645
+ : `${item.name} (${item.id})`,
3646
+ ),
3647
+ ),
3648
+ form.review.provider && !reviewRoute
3649
+ ? h('option', { value: form.review.provider }, t('modal.settings.routeMissing'))
3650
+ : null,
3651
+ ),
3652
+ ),
3653
+ h(
3654
+ 'label',
3655
+ { className: 'cait-field' },
3656
+ h('span', null, t('modal.settings.model')),
3657
+ reviewModels.length
3658
+ ? h(
3659
+ 'select',
3660
+ {
3661
+ className: 'cait-input',
3662
+ value: form.review.model,
3663
+ onChange: (event) => setReview({ model: event.target.value }),
3664
+ },
3665
+ reviewModels.map((model) =>
3666
+ h('option', { key: model.id, value: model.id }, `${model.name} (${model.id})`),
3667
+ ),
3668
+ )
3669
+ : h('input', {
3670
+ className: 'cait-input',
3671
+ value: form.review.model,
3672
+ readOnly: true,
3673
+ placeholder: t('modal.settings.noModels'),
3674
+ }),
3675
+ ),
3676
+ ),
3677
+ h(
3678
+ 'p',
3679
+ { className: 'cait-mt-hint', style: { marginTop: 0 } },
3680
+ reviewInherited ? t('modal.settings.reviewInheritedHint') : t('modal.settings.reviewCustomHint'),
3681
+ ),
3682
+ ),
3683
+ h(
3684
+ 'div',
3685
+ { className: 'cait-modal-actions' },
3686
+ h('button', { type: 'button', className: 'cait-btn cait-btn-ghost', onClick: onClose }, t('modal.settings.cancel')),
3687
+ h('button', { type: 'submit', className: 'cait-btn cait-btn-primary', disabled: Boolean(busy) }, busy === 'settings' ? h(Spinner) : null, t('modal.settings.save')),
3688
+ ),
3689
+ ),
3690
+ )
3691
+ }
3692
+
3693
+ function ConfirmModal({ t, state, busy, onClose, onConfirm }) {
3694
+ return h(
3695
+ Modal,
3696
+ { title: state.title || t('modal.confirm.title'), onClose },
3697
+ h('p', { className: 'cait-confirm-text' }, state.message),
3698
+ h(
3699
+ 'div',
3700
+ { className: 'cait-modal-actions' },
2957
3701
  h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
2958
3702
  h('button', { className: 'cait-btn cait-btn-danger', onClick: onConfirm, disabled: Boolean(busy) }, busy ? h(Spinner) : null, state.confirmLabel || t('modal.confirm.ok')),
2959
3703
  ),
@@ -2988,6 +3732,614 @@
2988
3732
  )
2989
3733
  }
2990
3734
 
3735
+ function BriefModal({ t, doc, busy, onClose, onSave }) {
3736
+ const initial = doc.brief || {}
3737
+ const [form, setForm] = useState({
3738
+ domain: initial.domain || '',
3739
+ audience: initial.audience || '',
3740
+ tone: initial.tone || '',
3741
+ notes: initial.notes || '',
3742
+ doNotTranslate: initial.doNotTranslate || '',
3743
+ styleSample: initial.styleSample || '',
3744
+ })
3745
+ const field = (key, label, placeholder, rows) =>
3746
+ h(
3747
+ 'label',
3748
+ { className: 'cait-field' },
3749
+ h('span', null, t(label)),
3750
+ rows
3751
+ ? h('textarea', {
3752
+ className: 'cait-textarea',
3753
+ rows,
3754
+ value: form[key],
3755
+ placeholder: t(placeholder),
3756
+ onChange: (event) => setForm({ ...form, [key]: event.target.value }),
3757
+ })
3758
+ : h('input', {
3759
+ className: 'cait-input',
3760
+ value: form[key],
3761
+ placeholder: t(placeholder),
3762
+ onChange: (event) => setForm({ ...form, [key]: event.target.value }),
3763
+ }),
3764
+ )
3765
+ return h(
3766
+ Modal,
3767
+ { title: t('brief.title'), onClose, wide: true },
3768
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('brief.hint')),
3769
+ h(
3770
+ 'div',
3771
+ { className: 'cait-briefform' },
3772
+ h('div', { className: 'cait-grid2' }, field('domain', 'brief.domain', 'brief.placeholder.domain'), field('audience', 'brief.audience', 'brief.placeholder.audience')),
3773
+ field('tone', 'brief.tone', 'brief.placeholder.tone'),
3774
+ field('notes', 'brief.notes', 'brief.placeholder.notes', 3),
3775
+ field('doNotTranslate', 'brief.doNotTranslate', 'brief.placeholder.doNotTranslate', 3),
3776
+ field('styleSample', 'brief.styleSample', 'brief.placeholder.styleSample', 4),
3777
+ ),
3778
+ h(
3779
+ 'div',
3780
+ { className: 'cait-modal-actions' },
3781
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
3782
+ h(
3783
+ 'button',
3784
+ { className: 'cait-btn cait-btn-primary', onClick: () => onSave(form), disabled: Boolean(busy) },
3785
+ busy ? h(Spinner) : null,
3786
+ t('brief.save'),
3787
+ ),
3788
+ ),
3789
+ )
3790
+ }
3791
+
3792
+ function NoteModal({ t, doc, index, segment, notes, busy, onAdd, onUpdate, onDelete, onClose }) {
3793
+ const [text, setText] = useState('')
3794
+ const [editingId, setEditingId] = useState('')
3795
+ const [editingText, setEditingText] = useState('')
3796
+ const list = Array.isArray(notes) ? notes : []
3797
+ const submit = () => {
3798
+ const value = text.trim()
3799
+ if (!value || busy) return
3800
+ onAdd(value)
3801
+ setText('')
3802
+ }
3803
+ return h(
3804
+ Modal,
3805
+ { title: `${t('notes.title')} · #${index + 1}`, onClose, wide: true },
3806
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('notes.hint')),
3807
+ h('div', { className: 'cait-notelist' }, segment?.source || ''),
3808
+ list.length
3809
+ ? h(
3810
+ 'div',
3811
+ { className: 'cait-notelist' },
3812
+ list.map((note) =>
3813
+ h(
3814
+ 'div',
3815
+ { className: 'cait-noteitem', key: note.id },
3816
+ h(
3817
+ 'div',
3818
+ { className: 'cait-notehead' },
3819
+ h('span', { className: 'cait-noteauthor' }, note.author === 'agent' ? 'Agent' : t('notes.user')),
3820
+ h('span', { className: 'cait-notetime' }, String(note.createdAt || '').replace('T', ' ').slice(0, 16)),
3821
+ ),
3822
+ editingId === note.id
3823
+ ? h(
3824
+ 'div',
3825
+ null,
3826
+ h('textarea', {
3827
+ className: 'cait-input cait-textarea',
3828
+ value: editingText,
3829
+ rows: 3,
3830
+ onChange: (event) => setEditingText(event.target.value),
3831
+ }),
3832
+ h(
3833
+ 'div',
3834
+ { className: 'cait-noteactions' },
3835
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: () => setEditingId('') }, t('notes.cancel')),
3836
+ h(
3837
+ 'button',
3838
+ {
3839
+ className: 'cait-btn cait-btn-primary',
3840
+ disabled: Boolean(busy) || !editingText.trim(),
3841
+ onClick: () => {
3842
+ onUpdate(note.id, editingText)
3843
+ setEditingId('')
3844
+ },
3845
+ },
3846
+ t('notes.save'),
3847
+ ),
3848
+ ),
3849
+ )
3850
+ : h(
3851
+ 'div',
3852
+ null,
3853
+ h('div', { className: 'cait-notetext' }, note.text),
3854
+ h(
3855
+ 'div',
3856
+ { className: 'cait-noteactions' },
3857
+ h(
3858
+ 'button',
3859
+ {
3860
+ className: 'cait-minibtn',
3861
+ onClick: () => {
3862
+ setEditingId(note.id)
3863
+ setEditingText(note.text)
3864
+ },
3865
+ },
3866
+ t('notes.edit'),
3867
+ ),
3868
+ h(
3869
+ 'button',
3870
+ { className: 'cait-minibtn cait-danger', onClick: () => onDelete(note.id) },
3871
+ t('notes.delete'),
3872
+ ),
3873
+ ),
3874
+ ),
3875
+ ),
3876
+ ),
3877
+ )
3878
+ : h('p', { className: 'cait-mt-hint' }, t('notes.empty')),
3879
+ h(
3880
+ 'div',
3881
+ { className: 'cait-noteadd' },
3882
+ h('textarea', {
3883
+ className: 'cait-input cait-textarea',
3884
+ value: text,
3885
+ rows: 3,
3886
+ placeholder: t('notes.placeholder'),
3887
+ onChange: (event) => setText(event.target.value),
3888
+ }),
3889
+ h(
3890
+ 'div',
3891
+ { className: 'cait-noteactions' },
3892
+ h('button', { className: 'cait-btn cait-btn-primary', disabled: Boolean(busy) || !text.trim(), onClick: submit }, busy ? h(Spinner) : null, t('notes.add')),
3893
+ ),
3894
+ ),
3895
+ )
3896
+ }
3897
+
3898
+ function ReplaceModal({ t, doc, busy, preview, undoCount, onPreview, onApply, onUndo, onClose }) {
3899
+ const queryRef = useRef(null)
3900
+ const [form, setForm] = useState({
3901
+ query: '',
3902
+ replacement: '',
3903
+ scope: 'target',
3904
+ matchCase: false,
3905
+ regex: false,
3906
+ })
3907
+ useEffect(() => {
3908
+ if (queryRef.current) queryRef.current.focus()
3909
+ }, [])
3910
+ const set = (key, value) => setForm((current) => ({ ...current, [key]: value }))
3911
+ return h(
3912
+ Modal,
3913
+ { title: t('replace.title'), onClose, wide: true },
3914
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('replace.hint')),
3915
+ h(
3916
+ 'div',
3917
+ { className: 'cait-replaceform' },
3918
+ h(
3919
+ 'label',
3920
+ { className: 'cait-field' },
3921
+ h('span', null, t('replace.query')),
3922
+ h('input', {
3923
+ ref: queryRef,
3924
+ className: 'cait-input',
3925
+ value: form.query,
3926
+ placeholder: t('replace.queryPlaceholder'),
3927
+ onChange: (event) => set('query', event.target.value),
3928
+ }),
3929
+ ),
3930
+ h(
3931
+ 'label',
3932
+ { className: 'cait-field' },
3933
+ h('span', null, t('replace.replacement')),
3934
+ h('input', {
3935
+ className: 'cait-input',
3936
+ value: form.replacement,
3937
+ placeholder: t('replace.replacementPlaceholder'),
3938
+ onChange: (event) => set('replacement', event.target.value),
3939
+ }),
3940
+ ),
3941
+ h(
3942
+ 'div',
3943
+ { className: 'cait-replaceoptions' },
3944
+ h(
3945
+ 'label',
3946
+ { className: 'cait-field' },
3947
+ h('span', null, t('replace.scope')),
3948
+ h(
3949
+ 'select',
3950
+ {
3951
+ className: 'cait-input',
3952
+ value: form.scope,
3953
+ onChange: (event) => set('scope', event.target.value),
3954
+ },
3955
+ h('option', { value: 'target' }, t('replace.scope.target')),
3956
+ h('option', { value: 'source' }, t('replace.scope.source')),
3957
+ h('option', { value: 'both' }, t('replace.scope.both')),
3958
+ ),
3959
+ ),
3960
+ h(
3961
+ 'label',
3962
+ { className: 'cait-check' },
3963
+ h('input', {
3964
+ type: 'checkbox',
3965
+ checked: form.matchCase,
3966
+ onChange: (event) => set('matchCase', event.target.checked),
3967
+ }),
3968
+ t('replace.matchCase'),
3969
+ ),
3970
+ h(
3971
+ 'label',
3972
+ { className: 'cait-check' },
3973
+ h('input', {
3974
+ type: 'checkbox',
3975
+ checked: form.regex,
3976
+ onChange: (event) => set('regex', event.target.checked),
3977
+ }),
3978
+ t('replace.regex'),
3979
+ ),
3980
+ ),
3981
+ ),
3982
+ preview
3983
+ ? h(
3984
+ 'div',
3985
+ { className: 'cait-replaceresults' },
3986
+ h(
3987
+ 'div',
3988
+ { className: 'cait-replacecounts' },
3989
+ t('replace.results')
3990
+ .replace('{count}', String(preview.total))
3991
+ .replace('{segments}', String(preview.matchedSegments || 0)),
3992
+ ),
3993
+ preview.truncated ? h('p', { className: 'cait-mt-hint' }, t('replace.truncated')) : null,
3994
+ preview.matches?.length
3995
+ ? h(
3996
+ 'div',
3997
+ { className: 'cait-replacelist' },
3998
+ preview.matches.slice(0, 50).map((match, index) =>
3999
+ h(
4000
+ 'div',
4001
+ { className: 'cait-replaceitem', key: `${match.uid}-${match.field}-${index}` },
4002
+ h(
4003
+ 'div',
4004
+ { className: 'cait-replacehead' },
4005
+ h('span', null, `#${match.index + 1}`),
4006
+ h('span', { className: 'cait-replacefield' }, t(`replace.field.${match.field}`)),
4007
+ h('span', { className: 'cait-replacecount' }, `×${match.count}`),
4008
+ ),
4009
+ h('div', { className: 'cait-replacebefore' }, match.before),
4010
+ h('div', { className: 'cait-replaceafter' }, match.after),
4011
+ ),
4012
+ ),
4013
+ )
4014
+ : h('p', { className: 'cait-mt-hint' }, t('replace.noResults')),
4015
+ )
4016
+ : null,
4017
+ h(
4018
+ 'div',
4019
+ { className: 'cait-modal-actions' },
4020
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
4021
+ undoCount
4022
+ ? h('button', { className: 'cait-btn', onClick: onUndo, disabled: Boolean(busy) }, t('replace.undo'))
4023
+ : null,
4024
+ h('button', { className: 'cait-btn', onClick: () => onPreview(form), disabled: Boolean(busy) || !form.query }, t('replace.preview')),
4025
+ h(
4026
+ 'button',
4027
+ { className: 'cait-btn cait-btn-primary', onClick: () => onApply(form), disabled: Boolean(busy) || !form.query || !preview?.total },
4028
+ busy ? h(Spinner) : null,
4029
+ t('replace.apply'),
4030
+ ),
4031
+ ),
4032
+ )
4033
+ }
4034
+
4035
+ function CommandPalette({ t, items, onClose, onRun }) {
4036
+ const [query, setQuery] = useState('')
4037
+ const [active, setActive] = useState(0)
4038
+ const inputRef = useRef(null)
4039
+ useEffect(() => {
4040
+ if (inputRef.current) inputRef.current.focus()
4041
+ }, [])
4042
+ const needle = query.trim().toLowerCase()
4043
+ const filtered = items.filter((item) => {
4044
+ if (!needle) return true
4045
+ return `${item.title} ${item.hint || ''}`.toLowerCase().includes(needle)
4046
+ })
4047
+ useEffect(() => {
4048
+ setActive(0)
4049
+ }, [query])
4050
+ const choose = (item) => {
4051
+ if (item) onRun(item)
4052
+ }
4053
+ return h(
4054
+ Modal,
4055
+ { title: t('palette.title'), onClose, wide: true },
4056
+ h('input', {
4057
+ ref: inputRef,
4058
+ className: 'cait-input cait-palette-input',
4059
+ value: query,
4060
+ placeholder: t('palette.placeholder'),
4061
+ onChange: (event) => setQuery(event.target.value),
4062
+ onKeyDown: (event) => {
4063
+ if (event.key === 'ArrowDown') {
4064
+ event.preventDefault()
4065
+ setActive((value) => Math.min(value + 1, Math.max(0, filtered.length - 1)))
4066
+ } else if (event.key === 'ArrowUp') {
4067
+ event.preventDefault()
4068
+ setActive((value) => Math.max(value - 1, 0))
4069
+ } else if (event.key === 'Enter') {
4070
+ event.preventDefault()
4071
+ choose(filtered[active])
4072
+ }
4073
+ },
4074
+ }),
4075
+ h(
4076
+ 'div',
4077
+ { className: 'cait-palette-list' },
4078
+ filtered.length
4079
+ ? filtered.map((item, index) =>
4080
+ h(
4081
+ 'button',
4082
+ {
4083
+ type: 'button',
4084
+ key: item.id,
4085
+ className: `cait-palette-item${index === active ? ' active' : ''}`,
4086
+ onMouseEnter: () => setActive(index),
4087
+ onClick: () => choose(item),
4088
+ },
4089
+ h('span', { className: 'cait-palette-label' }, item.title),
4090
+ item.hint ? h('span', { className: 'cait-palette-hint' }, item.hint) : null,
4091
+ ),
4092
+ )
4093
+ : h('p', { className: 'cait-mt-hint' }, t('palette.empty')),
4094
+ ),
4095
+ h('p', { className: 'cait-palette-foot' }, t('palette.footer')),
4096
+ )
4097
+ }
4098
+
4099
+ function ResourceModal({ t, doc, libraries, mounts, busy, onClose, onSave }) {
4100
+ const resources = doc.resources || {}
4101
+ const [inheritTm, setInheritTm] = useState(resources.inheritsTm !== false)
4102
+ const [inheritGlossary, setInheritGlossary] = useState(resources.inheritsGlossary !== false)
4103
+ const [tmIds, setTmIds] = useState(
4104
+ () => new Set(resources.tmIds ?? resources.effectiveTmIds ?? []),
4105
+ )
4106
+ const [glossaryIds, setGlossaryIds] = useState(
4107
+ () => new Set(resources.glossaryIds ?? resources.effectiveGlossaryIds ?? []),
4108
+ )
4109
+ const [defaultWriteTmId, setDefaultWriteTmId] = useState(resources.defaultWriteTmId || '')
4110
+
4111
+ const allTm = libraries?.tm || []
4112
+ const allGlossary = libraries?.glossary || []
4113
+ const selectedTm = inheritTm ? mounts?.tmIds || [] : Array.from(tmIds)
4114
+ const selectedGlossary = inheritGlossary ? mounts?.glossaryIds || [] : Array.from(glossaryIds)
4115
+ const tmChoices = allTm.filter((library) => selectedTm.includes(library.tmId))
4116
+ const checkedTmIds = new Set(selectedTm)
4117
+ const checkedGlossaryIds = new Set(selectedGlossary)
4118
+ const defaultTm = tmChoices.some((library) => library.tmId === defaultWriteTmId)
4119
+ ? defaultWriteTmId
4120
+ : ''
4121
+
4122
+ const toggleId = (setter, id) => {
4123
+ setter((current) => {
4124
+ const next = new Set(current)
4125
+ if (next.has(id)) next.delete(id)
4126
+ else next.add(id)
4127
+ return next
4128
+ })
4129
+ }
4130
+
4131
+ const librarySection = (label, all, idKey, checkedIds, inherit, setInherit, toggle, inheritLabel) =>
4132
+ h(
4133
+ 'div',
4134
+ { className: 'cait-resource-section' },
4135
+ h(
4136
+ 'div',
4137
+ { className: 'cait-resource-head' },
4138
+ h('strong', null, t(label)),
4139
+ h(
4140
+ 'label',
4141
+ { className: 'cait-checkline' },
4142
+ h('input', {
4143
+ type: 'checkbox',
4144
+ checked: inherit,
4145
+ onChange: (event) => setInherit(event.target.checked),
4146
+ }),
4147
+ t(inheritLabel),
4148
+ ),
4149
+ ),
4150
+ h(
4151
+ 'div',
4152
+ { className: 'cait-resource-list' },
4153
+ all.length === 0
4154
+ ? h('p', { className: 'cait-meta' }, t('resources.empty'))
4155
+ : all.map((library) =>
4156
+ h(
4157
+ 'label',
4158
+ { key: library[idKey], className: 'cait-resource-row' },
4159
+ h('input', {
4160
+ type: 'checkbox',
4161
+ checked: checkedIds.has(library[idKey]),
4162
+ disabled: inherit,
4163
+ onChange: () => toggle(library[idKey]),
4164
+ }),
4165
+ h('span', { className: 'cait-resource-name' }, library.name),
4166
+ h('span', { className: 'cait-meta' }, library[idKey]),
4167
+ ),
4168
+ ),
4169
+ ),
4170
+ )
4171
+
4172
+ return h(
4173
+ Modal,
4174
+ { title: t('resources.title'), onClose, wide: true },
4175
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('resources.hint')),
4176
+ h(
4177
+ 'div',
4178
+ { className: 'cait-briefform' },
4179
+ librarySection(
4180
+ 'resources.tm',
4181
+ allTm,
4182
+ 'tmId',
4183
+ checkedTmIds,
4184
+ inheritTm,
4185
+ setInheritTm,
4186
+ (id) => toggleId(setTmIds, id),
4187
+ 'resources.inheritTm',
4188
+ ),
4189
+ librarySection(
4190
+ 'resources.glossary',
4191
+ allGlossary,
4192
+ 'glossaryId',
4193
+ checkedGlossaryIds,
4194
+ inheritGlossary,
4195
+ setInheritGlossary,
4196
+ (id) => toggleId(setGlossaryIds, id),
4197
+ 'resources.inheritGlossary',
4198
+ ),
4199
+ h(
4200
+ 'label',
4201
+ { className: 'cait-field' },
4202
+ h('span', null, t('resources.defaultWrite')),
4203
+ h(
4204
+ 'select',
4205
+ {
4206
+ className: 'cait-input',
4207
+ value: defaultTm,
4208
+ onChange: (event) => setDefaultWriteTmId(event.target.value),
4209
+ },
4210
+ h('option', { value: '' }, t(inheritTm ? 'resources.followDefault' : 'resources.firstBound')),
4211
+ tmChoices.map((library) => h('option', { key: library.tmId, value: library.tmId }, library.name)),
4212
+ ),
4213
+ ),
4214
+ ),
4215
+ h(
4216
+ 'div',
4217
+ { className: 'cait-modal-actions' },
4218
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('modal.confirm.cancel')),
4219
+ h(
4220
+ 'button',
4221
+ {
4222
+ className: 'cait-btn cait-btn-primary',
4223
+ disabled: Boolean(busy),
4224
+ onClick: () =>
4225
+ onSave({
4226
+ tmIds: inheritTm ? null : Array.from(tmIds),
4227
+ glossaryIds: inheritGlossary ? null : Array.from(glossaryIds),
4228
+ defaultWriteTmId: defaultTm || null,
4229
+ }),
4230
+ },
4231
+ busy ? h(Spinner) : null,
4232
+ t('resources.save'),
4233
+ ),
4234
+ ),
4235
+ )
4236
+ }
4237
+
4238
+ function AnalysisModal({ t, doc, analysis, busy, onClose }) {
4239
+ const download = (format) => {
4240
+ if (!analysis) return
4241
+ const pairs = [
4242
+ ['segments', analysis.segments],
4243
+ ['words', analysis.words],
4244
+ ['characters', analysis.characters],
4245
+ ['duplicates.groups', analysis.duplicates.groups],
4246
+ ['duplicates.segments', analysis.duplicates.segments],
4247
+ ['translated', analysis.translated],
4248
+ ['confirmed', analysis.confirmed],
4249
+ ['ice', analysis.buckets.ice],
4250
+ ['exact', analysis.buckets.exact],
4251
+ ['fuzzy85', analysis.buckets.fuzzy85],
4252
+ ['fuzzy70', analysis.buckets.fuzzy70],
4253
+ ['new', analysis.buckets.new],
4254
+ ['matched.segments', analysis.matched.segments],
4255
+ ['matched.percent', analysis.matched.percent],
4256
+ ['leverage.percent', analysis.leverage.percent],
4257
+ ['generatedAt', analysis.generatedAt],
4258
+ ]
4259
+ const content =
4260
+ format === 'json'
4261
+ ? JSON.stringify(analysis, null, 2)
4262
+ : ['metric,value', ...pairs.map(([key, value]) => `${key},${value}`)].join('\n')
4263
+ const type = format === 'json' ? 'application/json' : 'text/csv'
4264
+ const blob = new Blob([content], { type })
4265
+ const url = URL.createObjectURL(blob)
4266
+ const anchor = document.createElement('a')
4267
+ anchor.href = url
4268
+ anchor.download = `${analysis.name || 'document'}-analysis.${format}`
4269
+ anchor.click()
4270
+ URL.revokeObjectURL(url)
4271
+ }
4272
+
4273
+ const rows = analysis
4274
+ ? [
4275
+ ['analysis.segments', analysis.segments],
4276
+ ['analysis.words', analysis.words],
4277
+ ['analysis.characters', analysis.characters],
4278
+ ['analysis.duplicates', `${analysis.duplicates.segments} / ${analysis.duplicates.groups}`],
4279
+ ['analysis.translated', analysis.translated],
4280
+ ['analysis.confirmed', analysis.confirmed],
4281
+ ]
4282
+ : []
4283
+ const buckets = analysis
4284
+ ? [
4285
+ ['analysis.ice', analysis.buckets.ice],
4286
+ ['analysis.exact', analysis.buckets.exact],
4287
+ ['analysis.fuzzy85', analysis.buckets.fuzzy85],
4288
+ ['analysis.fuzzy70', analysis.buckets.fuzzy70],
4289
+ ['analysis.new', analysis.buckets.new],
4290
+ ]
4291
+ : []
4292
+
4293
+ return h(
4294
+ Modal,
4295
+ { title: t('analysis.title'), onClose, wide: true },
4296
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('analysis.hint')),
4297
+ busy || !analysis
4298
+ ? h('div', { className: 'cait-empty' }, h(Spinner))
4299
+ : h(
4300
+ 'div',
4301
+ { className: 'cait-analysis' },
4302
+ h(
4303
+ 'div',
4304
+ { className: 'cait-analysis-grid' },
4305
+ rows.map(([label, value]) =>
4306
+ h(
4307
+ 'div',
4308
+ { key: label, className: 'cait-analysis-stat' },
4309
+ h('span', null, t(label)),
4310
+ h('strong', null, String(value)),
4311
+ ),
4312
+ ),
4313
+ ),
4314
+ h(
4315
+ 'div',
4316
+ { className: 'cait-analysis-grid' },
4317
+ buckets.map(([label, value]) =>
4318
+ h(
4319
+ 'div',
4320
+ { key: label, className: 'cait-analysis-stat' },
4321
+ h('span', null, t(label)),
4322
+ h('strong', null, String(value)),
4323
+ ),
4324
+ ),
4325
+ ),
4326
+ h(
4327
+ 'div',
4328
+ { className: 'cait-analysis-total' },
4329
+ h('span', null, `${t('analysis.matchRate')} ${analysis.matched.percent}%`),
4330
+ h('span', null, `${t('analysis.leverage')} ${analysis.leverage.percent}%`),
4331
+ ),
4332
+ ),
4333
+ h(
4334
+ 'div',
4335
+ { className: 'cait-modal-actions' },
4336
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: onClose, disabled: Boolean(busy) }, t('analysis.close')),
4337
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: () => download('csv'), disabled: Boolean(busy) || !analysis }, t('analysis.exportCsv')),
4338
+ h('button', { className: 'cait-btn cait-btn-primary', onClick: () => download('json'), disabled: Boolean(busy) || !analysis }, t('analysis.exportJson')),
4339
+ ),
4340
+ )
4341
+ }
4342
+
2991
4343
  function Workbench({ i18n, translate }) {
2992
4344
  const [data, setData] = useState(null)
2993
4345
  const [loading, setLoading] = useState(true)
@@ -3011,6 +4363,18 @@
3011
4363
  }
3012
4364
  const [pre, setPre] = useState({ running: false, done: 0, total: 0, filled: 0, failed: 0 })
3013
4365
  const [qa, setQa] = useState(null)
4366
+ const [review, setReview] = useState(null)
4367
+ const [reviewBusy, setReviewBusy] = useState(false)
4368
+ const [analysis, setAnalysis] = useState(null)
4369
+ const [analysisBusy, setAnalysisBusy] = useState(false)
4370
+ const [replacePreview, setReplacePreview] = useState(null)
4371
+ const [replaceUndo, setReplaceUndo] = useState(null)
4372
+ const [replaceBusy, setReplaceBusy] = useState(false)
4373
+ const [notes, setNotes] = useState(null)
4374
+ const [notesBusy, setNotesBusy] = useState(false)
4375
+ const [concordance, setConcordance] = useState(null)
4376
+ const [concordanceQuery, setConcordanceQuery] = useState('')
4377
+ const [concordanceBusy, setConcordanceBusy] = useState(false)
3014
4378
  const [tmQuery, setTmQuery] = useState('')
3015
4379
  const [tmResults, setTmResults] = useState(null)
3016
4380
  const [tmRefresh, setTmRefresh] = useState(0)
@@ -3027,16 +4391,20 @@
3027
4391
  const busyRef = useRef(false)
3028
4392
  const rootRef = useRef(null)
3029
4393
  const unitRef = useRef(0)
4394
+ const draftHistoryRef = useRef({ index: -1, stack: [], cursor: 0, lastAt: 0 })
3030
4395
 
3031
4396
  const doc = data?.activeDocument || null
3032
4397
  const segments = doc?.segments || []
3033
4398
  const index = doc ? Math.min(selected, Math.max(0, segments.length - 1)) : 0
3034
4399
  const segment = segments[index] || null
3035
4400
  const detail = doc?.current?.index === index ? doc.current : null
4401
+ useEffect(() => {
4402
+ draftHistoryRef.current = { index, stack: [draft], cursor: 0, lastAt: 0 }
4403
+ }, [doc?.id, index])
3036
4404
  const settings = data?.settings || {
3037
4405
  sourceLang: 'en',
3038
4406
  targetLang: 'zh-CN',
3039
- fuzzyThreshold: 0.55,
4407
+ fuzzyThreshold: DEFAULT_FUZZY_THRESHOLD,
3040
4408
  tmLimit: 5,
3041
4409
  uiLang: 'auto',
3042
4410
  mt: { provider: 'mock', model: 'cait-mock', temperature: 0.2 },
@@ -3259,6 +4627,10 @@
3259
4627
  }
3260
4628
  }, [data?.activeDocument?.id, data?.revision])
3261
4629
 
4630
+ useEffect(() => {
4631
+ setReview(null)
4632
+ }, [data?.activeDocument?.id])
4633
+
3262
4634
  useEffect(() => {
3263
4635
  let alive = true
3264
4636
  rpc('listMtRoutes')
@@ -3284,6 +4656,11 @@
3284
4656
  setTmQuery('')
3285
4657
  setTmResults(null)
3286
4658
  setTmError('')
4659
+ setConcordance(null)
4660
+ setConcordanceQuery('')
4661
+ setReplacePreview(null)
4662
+ setReplaceUndo(null)
4663
+ setNotes(null)
3287
4664
  }, [doc?.id])
3288
4665
 
3289
4666
  useEffect(() => {
@@ -3321,7 +4698,8 @@
3321
4698
  const saveDraft = async (silent = true) => {
3322
4699
  if (!doc || !dirty) return true
3323
4700
  try {
3324
- await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
4701
+ const result = await rpc('saveDraft', { documentId: doc.id, index: selected, target: draft })
4702
+ if (result.state) adoptState(result.state)
3325
4703
  setDirty(false)
3326
4704
  if (!silent) notify(t('toast.draftSaved'))
3327
4705
  return true
@@ -3348,6 +4726,49 @@
3348
4726
  }
3349
4727
  }
3350
4728
 
4729
+ const changeDraft = (value) => {
4730
+ const history = draftHistoryRef.current
4731
+ if (history.index !== index) {
4732
+ history.index = index
4733
+ history.stack = [draft]
4734
+ history.cursor = 0
4735
+ }
4736
+ if (history.stack[history.cursor] !== value) {
4737
+ const now = Date.now()
4738
+ if (now - history.lastAt < 650 && history.cursor > 0) {
4739
+ history.stack[history.cursor] = value
4740
+ } else {
4741
+ history.stack = history.stack.slice(0, history.cursor + 1)
4742
+ history.stack.push(value)
4743
+ if (history.stack.length > 200) history.stack.shift()
4744
+ history.cursor = history.stack.length - 1
4745
+ }
4746
+ history.lastAt = now
4747
+ }
4748
+ setDraft(value)
4749
+ setDirty(true)
4750
+ }
4751
+
4752
+ const undoDraft = () => {
4753
+ const history = draftHistoryRef.current
4754
+ if (history.cursor <= 0) return
4755
+ history.cursor -= 1
4756
+ history.lastAt = 0
4757
+ const value = history.stack[history.cursor]
4758
+ setDraft(value)
4759
+ setDirty(value !== (segment?.target || ''))
4760
+ }
4761
+
4762
+ const redoDraft = () => {
4763
+ const history = draftHistoryRef.current
4764
+ if (history.cursor >= history.stack.length - 1) return
4765
+ history.cursor += 1
4766
+ history.lastAt = 0
4767
+ const value = history.stack[history.cursor]
4768
+ setDraft(value)
4769
+ setDirty(value !== (segment?.target || ''))
4770
+ }
4771
+
3351
4772
  const confirmSegment = async () => {
3352
4773
  if (!doc || busy) return
3353
4774
  if (!draft.trim()) {
@@ -3502,6 +4923,10 @@
3502
4923
  }
3503
4924
 
3504
4925
  const applyTerm = (term) => {
4926
+ if (term?.forbidden) {
4927
+ notify(t('toast.termForbidden'), 'error')
4928
+ return
4929
+ }
3505
4930
  const separator = /^(zh|ja|ko)/i.test(doc?.targetLang || '') ? '' : ' '
3506
4931
  setDraft((current) => {
3507
4932
  const value = current.replace(/\s+$/, '')
@@ -3511,6 +4936,368 @@
3511
4936
  setTimeout(() => textareaRef.current?.focus(), 0)
3512
4937
  }
3513
4938
 
4939
+ const unconfirmCurrent = async () => {
4940
+ if (!doc || busy) return
4941
+ setBusy('unconfirm')
4942
+ try {
4943
+ if (dirty) {
4944
+ const saved = await saveDraft(true)
4945
+ if (!saved) return
4946
+ }
4947
+ const result = await rpc('unconfirmSegment', { documentId: doc.id, index: selected })
4948
+ adoptState(result.state)
4949
+ notify(t('toast.unconfirmed'))
4950
+ } catch (error) {
4951
+ // Saving a dirty confirmed segment already revokes the confirmation.
4952
+ // Treat the second call as idempotent instead of showing an error.
4953
+ if (error?.code === 'NOT_CONFIRMED') notify(t('toast.unconfirmed'))
4954
+ else report(error)
4955
+ } finally {
4956
+ setBusy('')
4957
+ }
4958
+ }
4959
+
4960
+ const toggleLock = async () => {
4961
+ if (!doc || busy || !segment) return
4962
+ setBusy('lock')
4963
+ try {
4964
+ await rpc('setSegmentLocked', { documentId: doc.id, index: selected, locked: !segment.locked })
4965
+ const next = await rpc('bootstrap')
4966
+ adoptState(next, selected)
4967
+ notify(t(segment.locked ? 'toast.unlocked' : 'toast.locked'))
4968
+ } catch (error) {
4969
+ report(error)
4970
+ } finally {
4971
+ setBusy('')
4972
+ }
4973
+ }
4974
+
4975
+ const mergeCurrent = () => {
4976
+ if (!doc || !segment || busy) return
4977
+ openConfirm({
4978
+ title: t('modal.confirm.mergeTitle'),
4979
+ message: t('modal.confirm.merge'),
4980
+ confirmLabel: t('editor.merge'),
4981
+ action: async () => {
4982
+ const result = await rpc('mergeSegments', { documentId: doc.id, index: selected, count: 2 })
4983
+ adoptState(result.state, result.index)
4984
+ notify(t('toast.merged'))
4985
+ },
4986
+ })
4987
+ }
4988
+
4989
+ const splitCurrent = (at) => {
4990
+ if (!doc || !segment || busy) return
4991
+ openConfirm({
4992
+ title: t('modal.confirm.splitTitle'),
4993
+ message: t('modal.confirm.split'),
4994
+ confirmLabel: t('editor.split'),
4995
+ action: async () => {
4996
+ const result = await rpc('splitSegment', { documentId: doc.id, index: selected, at })
4997
+ adoptState(result.state, result.index)
4998
+ notify(t('toast.split'))
4999
+ },
5000
+ })
5001
+ }
5002
+
5003
+ const mergeCue = () => {
5004
+ if (!doc || !segment || busy) return
5005
+ openConfirm({
5006
+ title: t('modal.confirm.mergeTitle'),
5007
+ message: t('modal.confirm.mergeCue'),
5008
+ confirmLabel: t('editor.mergeCue'),
5009
+ action: async () => {
5010
+ const result = await rpc('mergeCues', { documentId: doc.id, index: selected, count: 2 })
5011
+ adoptState(result.state, result.index)
5012
+ notify(t('toast.cuesMerged'))
5013
+ },
5014
+ })
5015
+ }
5016
+
5017
+ const splitCueAt = (at) => {
5018
+ if (!doc || !segment || busy) return
5019
+ openConfirm({
5020
+ title: t('modal.confirm.splitTitle'),
5021
+ message: t('modal.confirm.splitCue'),
5022
+ confirmLabel: t('editor.splitCue'),
5023
+ action: async () => {
5024
+ const result = await rpc('splitCue', { documentId: doc.id, index: selected, at })
5025
+ adoptState(result.state, result.index)
5026
+ notify(t('toast.cueSplit'))
5027
+ },
5028
+ })
5029
+ }
5030
+
5031
+ const shiftSubtitles = (offsetMs) => {
5032
+ if (!doc || !offsetMs || busy) return
5033
+ openConfirm({
5034
+ title: t('modal.confirm.shiftTitle'),
5035
+ message: t('modal.confirm.shiftCue'),
5036
+ confirmLabel: t('editor.shift'),
5037
+ action: async () => {
5038
+ const result = await rpc('shiftSubtitles', { documentId: doc.id, offsetMs })
5039
+ adoptState(result.state)
5040
+ notify(t('toast.subtitlesShifted'))
5041
+ },
5042
+ })
5043
+ }
5044
+
5045
+ const saveBrief = async (brief) => {
5046
+ if (!doc || busy) return false
5047
+ setBusy('brief')
5048
+ try {
5049
+ const result = await rpc('setBrief', { documentId: doc.id, brief })
5050
+ adoptState(result.state)
5051
+ setModal(null)
5052
+ notify(t('toast.briefSaved'))
5053
+ return true
5054
+ } catch (error) {
5055
+ report(error)
5056
+ return false
5057
+ } finally {
5058
+ setBusy('')
5059
+ }
5060
+ }
5061
+
5062
+ const saveResources = async (resources) => {
5063
+ if (!doc || busy) return false
5064
+ setBusy('resources')
5065
+ try {
5066
+ const result = await rpc('setDocumentResources', { documentId: doc.id, ...resources })
5067
+ adoptState(result.state)
5068
+ setModal(null)
5069
+ notify(t('toast.resourcesSaved'))
5070
+ return true
5071
+ } catch (error) {
5072
+ report(error)
5073
+ return false
5074
+ } finally {
5075
+ setBusy('')
5076
+ }
5077
+ }
5078
+
5079
+ const openAnalysis = async () => {
5080
+ if (!doc) return
5081
+ setAnalysis(null)
5082
+ setModal({ type: 'analysis' })
5083
+ setAnalysisBusy(true)
5084
+ try {
5085
+ setAnalysis(await rpc('analyzeDocument', { documentId: doc.id }))
5086
+ } catch (error) {
5087
+ report(error)
5088
+ } finally {
5089
+ setAnalysisBusy(false)
5090
+ }
5091
+ }
5092
+
5093
+ const openReplace = () => {
5094
+ if (!doc) return
5095
+ setReplacePreview(null)
5096
+ setModal({ type: 'replace' })
5097
+ }
5098
+
5099
+ const previewReplace = async (form) => {
5100
+ if (!doc || replaceBusy) return
5101
+ setReplaceBusy(true)
5102
+ try {
5103
+ setReplacePreview(await rpc('replaceInDocument', { documentId: doc.id, ...form }))
5104
+ } catch (error) {
5105
+ report(error)
5106
+ } finally {
5107
+ setReplaceBusy(false)
5108
+ }
5109
+ }
5110
+
5111
+ const applyReplace = async (form) => {
5112
+ if (!doc || replaceBusy) return
5113
+ setReplaceBusy(true)
5114
+ try {
5115
+ const result = await rpc('replaceInDocument', { documentId: doc.id, ...form, apply: true })
5116
+ setReplaceUndo(Array.isArray(result.undo) && result.undo.length ? result.undo : null)
5117
+ setReplacePreview(null)
5118
+ adoptState(result.state, result.undo?.[0]?.index)
5119
+ notify(t('toast.replaced').replace('{count}', String(result.applied ?? 0)))
5120
+ } catch (error) {
5121
+ report(error)
5122
+ } finally {
5123
+ setReplaceBusy(false)
5124
+ }
5125
+ }
5126
+
5127
+ const undoReplace = async () => {
5128
+ if (!doc || !replaceUndo || replaceBusy) return
5129
+ setReplaceBusy(true)
5130
+ try {
5131
+ const result = await rpc('undoReplace', { documentId: doc.id, undo: replaceUndo })
5132
+ adoptState(result.state, replaceUndo[0]?.index)
5133
+ setReplaceUndo(null)
5134
+ setReplacePreview(null)
5135
+ notify(t('toast.replaceUndone'))
5136
+ } catch (error) {
5137
+ report(error)
5138
+ } finally {
5139
+ setReplaceBusy(false)
5140
+ }
5141
+ }
5142
+
5143
+ const openNotes = async () => {
5144
+ if (!doc) return
5145
+ setNotes(null)
5146
+ setNotesBusy(true)
5147
+ setModal({ type: 'notes' })
5148
+ try {
5149
+ const result = await rpc('notesForSegment', { documentId: doc.id, index })
5150
+ setNotes(result.notes)
5151
+ } catch (error) {
5152
+ report(error)
5153
+ } finally {
5154
+ setNotesBusy(false)
5155
+ }
5156
+ }
5157
+
5158
+ const addNote = async (text) => {
5159
+ if (!doc || notesBusy) return
5160
+ setNotesBusy(true)
5161
+ try {
5162
+ const result = await rpc('addSegmentNote', { documentId: doc.id, index, text })
5163
+ setNotes(result.notes)
5164
+ adoptState(result.state, index)
5165
+ notify(t('toast.noteSaved'))
5166
+ } catch (error) {
5167
+ report(error)
5168
+ } finally {
5169
+ setNotesBusy(false)
5170
+ }
5171
+ }
5172
+
5173
+ const updateNote = async (noteId, text) => {
5174
+ if (!doc || notesBusy) return
5175
+ setNotesBusy(true)
5176
+ try {
5177
+ const result = await rpc('updateSegmentNote', { documentId: doc.id, index, noteId, text })
5178
+ setNotes(result.notes)
5179
+ adoptState(result.state, index)
5180
+ notify(t('toast.noteSaved'))
5181
+ } catch (error) {
5182
+ report(error)
5183
+ } finally {
5184
+ setNotesBusy(false)
5185
+ }
5186
+ }
5187
+
5188
+ const deleteNote = async (noteId) => {
5189
+ if (!doc || notesBusy) return
5190
+ setNotesBusy(true)
5191
+ try {
5192
+ const result = await rpc('deleteSegmentNote', { documentId: doc.id, index, noteId })
5193
+ setNotes(result.notes)
5194
+ adoptState(result.state, index)
5195
+ notify(t('toast.noteDeleted'))
5196
+ } catch (error) {
5197
+ report(error)
5198
+ } finally {
5199
+ setNotesBusy(false)
5200
+ }
5201
+ }
5202
+
5203
+ const refreshQa = async () => { if (!doc) return
5204
+ setQa(null)
5205
+ try {
5206
+ setQa(await rpc('getQaReport', { documentId: doc.id }))
5207
+ } catch {
5208
+ // QA is advisory; a failure must not disturb the editor.
5209
+ }
5210
+ }
5211
+
5212
+ const jumpQa = (nextIndex) => {
5213
+ if (Number.isInteger(nextIndex)) chooseSegment(nextIndex)
5214
+ }
5215
+
5216
+ const runReview = async () => {
5217
+ if (!doc || reviewBusy) return
5218
+ setReviewBusy(true)
5219
+ try {
5220
+ const result = await rpc('reviewDocument', {
5221
+ documentId: doc.id,
5222
+ from: 0,
5223
+ count: 20,
5224
+ })
5225
+ setReview(result || { issues: [] })
5226
+ } catch (error) {
5227
+ setReview(null)
5228
+ report(error)
5229
+ } finally {
5230
+ setReviewBusy(false)
5231
+ }
5232
+ }
5233
+
5234
+ const applyReview = (issue) => {
5235
+ if (!doc || !issue || !Number.isInteger(issue.index)) return
5236
+ chooseSegment(issue.index)
5237
+ if (issue.suggestion) {
5238
+ setDraft(issue.suggestion)
5239
+ setDirty(true)
5240
+ }
5241
+ }
5242
+
5243
+ const applyQaFix = async (item, action) => {
5244
+ if (!doc || busy || !item) return
5245
+ const segment = doc.segments[item.index]
5246
+ if (!segment) return
5247
+ let target = segment.target || ''
5248
+ if (action === 'copy-source') {
5249
+ target = segment.source
5250
+ } else if (action === 'clear') {
5251
+ target = ''
5252
+ } else if (action === 'term') {
5253
+ const term = String(item.termTarget || '')
5254
+ if (!term || target.includes(term)) return
5255
+ const separator = /[A-Za-z0-9]$/.test(target) && /^[A-Za-z0-9]/.test(term) ? ' ' : ''
5256
+ target = `${target}${separator}${term}`
5257
+ } else {
5258
+ return
5259
+ }
5260
+ setBusy('qa-fix')
5261
+ try {
5262
+ const result = await rpc('saveDraft', {
5263
+ documentId: doc.id,
5264
+ index: item.index,
5265
+ target,
5266
+ })
5267
+ if (result.state) adoptState(result.state, item.index)
5268
+ setQa(await rpc('getQaReport', { documentId: doc.id }))
5269
+ notify(t('toast.qaFixed'))
5270
+ } catch (error) {
5271
+ report(error)
5272
+ } finally {
5273
+ setBusy('')
5274
+ }
5275
+ }
5276
+
5277
+ const runConcordance = async () => {
5278
+ const query = concordanceQuery.trim()
5279
+ if (!doc || !query) {
5280
+ setConcordance(null)
5281
+ return
5282
+ }
5283
+ setConcordanceBusy(true)
5284
+ try {
5285
+ setConcordance(await rpc('concordance', { documentId: doc.id, query, limit: 50 }))
5286
+ } catch (error) {
5287
+ report(error)
5288
+ } finally {
5289
+ setConcordanceBusy(false)
5290
+ }
5291
+ }
5292
+
5293
+ const applyConcordance = (entry) => {
5294
+ if (!entry?.target) return
5295
+ setDraft(entry.target)
5296
+ setDirty(true)
5297
+ setMt(null)
5298
+ setTimeout(() => textareaRef.current?.focus(), 0)
5299
+ }
5300
+
3514
5301
  const editTm = async ({ id, target }) => {
3515
5302
  setBusy('tm-edit')
3516
5303
  try {
@@ -3576,6 +5363,40 @@
3576
5363
  })
3577
5364
  }
3578
5365
 
5366
+ const clearPretranslated = async () => {
5367
+ const active = data?.activeDocument
5368
+ if (!active) return
5369
+ try {
5370
+ const preview = await rpc('getClearUnconfirmedPreview', { documentId: active.id })
5371
+ const counts = preview?.counts || {}
5372
+ const total = counts.total || 0
5373
+ if (!total) {
5374
+ notify(t('toast.nothingToClear'), 'info')
5375
+ return
5376
+ }
5377
+ setModal({
5378
+ type: 'confirm',
5379
+ title: t('modal.confirm.clearPretranslatedTitle'),
5380
+ message: t('modal.confirm.clearPretranslatedMessage')
5381
+ .replace('{count}', String(total))
5382
+ .replace('{mt}', String(counts.mt || 0))
5383
+ .replace('{tm}', String(counts.tm || 0))
5384
+ .replace('{auto}', String(counts.auto || 0)),
5385
+ confirmLabel: t('modal.confirm.clearPretranslatedConfirm').replace('{count}', String(total)),
5386
+ action: async () => {
5387
+ const result = await rpc('clearUnconfirmedTranslations', {
5388
+ documentId: active.id,
5389
+ confirm: true,
5390
+ })
5391
+ adoptState(result.state, result.cursor)
5392
+ notify(t('toast.pretranslatedCleared').replace('{count}', String(result.cleared)))
5393
+ },
5394
+ })
5395
+ } catch (error) {
5396
+ report(error)
5397
+ }
5398
+ }
5399
+
3579
5400
  const addTerm = async (form) => { setBusy('term')
3580
5401
  try {
3581
5402
  const result = await rpc('addGlossary', {
@@ -3765,6 +5586,32 @@
3765
5586
  }
3766
5587
  }
3767
5588
 
5589
+ const importReview = (file) => {
5590
+ if (!doc || !file) return
5591
+ const reader = new FileReader()
5592
+ reader.onload = async () => {
5593
+ setBusy('review')
5594
+ try {
5595
+ const result = await rpc('applyXliffReview', {
5596
+ documentId: doc.id,
5597
+ text: String(reader.result || ''),
5598
+ })
5599
+ adoptState(result.state)
5600
+ notify(
5601
+ t('toast.reviewApplied')
5602
+ .replace('{applied}', String(result.applied))
5603
+ .replace('{unmatched}', String(result.unmatched + result.sourceMismatch)),
5604
+ )
5605
+ setModal(null)
5606
+ } catch (error) {
5607
+ report(error)
5608
+ } finally {
5609
+ setBusy('')
5610
+ }
5611
+ }
5612
+ reader.readAsText(file, 'utf-8')
5613
+ }
5614
+
3768
5615
  const openConfirm = (state) => setModal({ type: 'confirm', ...state })
3769
5616
 
3770
5617
  const clearTm = () =>
@@ -3819,6 +5666,7 @@
3819
5666
 
3820
5667
  const openIds = new Set((data?.openDocuments || []).map((item) => item.id))
3821
5668
  const closedDocuments = (data?.documents || []).filter((item) => !openIds.has(item.id))
5669
+ const qaRisk = (qa?.risks || []).find((item) => item.index === index) || null
3822
5670
 
3823
5671
  const deleteClosedDocument = (document) =>
3824
5672
  openConfirm({
@@ -3898,6 +5746,23 @@
3898
5746
  }
3899
5747
  }, [])
3900
5748
 
5749
+ useEffect(() => {
5750
+ const onKey = (event) => {
5751
+ const mod = event.metaKey || event.ctrlKey
5752
+ if (!mod) return
5753
+ const key = event.key.toLowerCase()
5754
+ if (key === 'k') {
5755
+ event.preventDefault()
5756
+ setModal((current) => (current?.type === 'palette' ? null : { type: 'palette' }))
5757
+ } else if (key === 'f' && doc && !modal) {
5758
+ event.preventDefault()
5759
+ openReplace()
5760
+ }
5761
+ }
5762
+ window.addEventListener('keydown', onKey)
5763
+ return () => window.removeEventListener('keydown', onKey)
5764
+ }, [doc?.id, modal])
5765
+
3901
5766
  if (loading) {
3902
5767
  return h(
3903
5768
  'div',
@@ -3925,6 +5790,44 @@
3925
5790
  )
3926
5791
  }
3927
5792
 
5793
+ const commands = [
5794
+ { id: 'new', title: t('cmd.new'), hint: t('cmd.newHint'), run: () => setModal({ type: 'new', defaultMode: 'text' }) },
5795
+ { id: 'import', title: t('cmd.import'), hint: '', run: () => setModal({ type: 'new', defaultMode: 'file' }) },
5796
+ { id: 'export', title: t('cmd.export'), hint: '', docOnly: true, run: () => setModal({ type: 'export' }) },
5797
+ { id: 'pretranslate', title: t('cmd.pretranslate'), hint: '', docOnly: true, run: () => runPreTranslate() },
5798
+ { id: 'replace', title: t('cmd.replace'), hint: 'Ctrl+F', docOnly: true, run: () => openReplace() },
5799
+ { id: 'brief', title: t('cmd.brief'), hint: '', docOnly: true, run: () => setModal({ type: 'brief' }) },
5800
+ { id: 'resources', title: t('cmd.resources'), hint: '', docOnly: true, run: () => setModal({ type: 'resources' }) },
5801
+ { id: 'analysis', title: t('cmd.analysis'), hint: '', docOnly: true, run: () => openAnalysis() },
5802
+ { id: 'notes', title: t('cmd.notes'), hint: '', docOnly: true, run: () => openNotes() },
5803
+ {
5804
+ id: 'review',
5805
+ title: t('cmd.review'),
5806
+ hint: '',
5807
+ docOnly: true,
5808
+ run: () => {
5809
+ setTab('qa')
5810
+ runReview()
5811
+ },
5812
+ },
5813
+ {
5814
+ id: 'clear-pretranslated',
5815
+ title: t('cmd.clearPretranslated'),
5816
+ hint: '',
5817
+ docOnly: true,
5818
+ run: () => clearPretranslated(),
5819
+ },
5820
+ { id: 'documents', title: t('cmd.documents'), hint: '', run: () => setView('documents') },
5821
+ { id: 'libraries', title: t('cmd.libraries'), hint: '', run: () => setView('libraries') },
5822
+ { id: 'align', title: t('cmd.align'), hint: '', run: () => setView('align') },
5823
+ { id: 'settings', title: t('cmd.settings'), hint: '', run: () => setModal({ type: 'settings' }) },
5824
+ ].filter((item) => !item.docOnly || doc)
5825
+
5826
+ const runCommand = (item) => {
5827
+ setModal(null)
5828
+ setTimeout(() => item.run(), 0)
5829
+ }
5830
+
3928
5831
  return h(
3929
5832
  'div',
3930
5833
  { className: 'cait-root', ref: rootRef },
@@ -3945,6 +5848,7 @@
3945
5848
  pre,
3946
5849
  onPreTranslate: runPreTranslate,
3947
5850
  onCancelPreTranslate: cancelPreTranslate,
5851
+ onReplace: openReplace,
3948
5852
  view,
3949
5853
  onView: setView,
3950
5854
  }),
@@ -3963,6 +5867,7 @@
3963
5867
  onCreateLibrary: createLibrary,
3964
5868
  onRenameLibrary: renameLibrary,
3965
5869
  onDeleteLibrary: deleteLibrary,
5870
+ onSetMounts: setMounts,
3966
5871
  })
3967
5872
  : null,
3968
5873
  view === 'align'
@@ -3980,7 +5885,7 @@
3980
5885
  ? h(
3981
5886
  'div',
3982
5887
  { 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,
5888
+ 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
5889
  h(
3985
5890
  'div',
3986
5891
  { className: `cait-main${doc ? '' : ' cait-main-empty'}` },
@@ -3992,18 +5897,32 @@
3992
5897
  segment,
3993
5898
  detail,
3994
5899
  draft,
3995
- setDraft: (value) => {
3996
- setDraft(value)
3997
- setDirty(true)
3998
- },
5900
+ setDraft: changeDraft,
3999
5901
  dirty,
4000
5902
  busy,
5903
+ canUndo: draftHistoryRef.current.cursor > 0,
5904
+ canRedo: draftHistoryRef.current.cursor < draftHistoryRef.current.stack.length - 1,
5905
+ onUndo: undoDraft,
5906
+ onRedo: redoDraft,
4001
5907
  onConfirm: confirmSegment,
5908
+ onUnconfirm: unconfirmCurrent,
5909
+ onToggleLock: toggleLock,
5910
+ onMerge: mergeCurrent,
5911
+ onSplit: splitCurrent,
5912
+ onMergeCue: mergeCue,
5913
+ onSplitCue: splitCueAt,
5914
+ onShiftSubtitles: shiftSubtitles,
4002
5915
  onNext: () => moveSegment(1),
4003
5916
  onPrev: () => moveSegment(-1),
4004
5917
  onSave: () => saveDraft(false),
4005
5918
  onAdopt: adoptSuggestion,
4006
5919
  onApplyTerm: applyTerm,
5920
+ onOpenBrief: () => setModal({ type: 'brief' }),
5921
+ onOpenResources: () => setModal({ type: 'resources' }),
5922
+ onOpenAnalysis: openAnalysis,
5923
+ onOpenNotes: openNotes,
5924
+ noteCount: segment?.notes?.length || 0,
5925
+ qaRisk,
4007
5926
  textareaRef,
4008
5927
  })
4009
5928
  : h(EmptyState, { t, settings, busy, onCreate: createFromForm, onDemo: loadDemo }),
@@ -4044,7 +5963,20 @@
4044
5963
  onCopy: copy,
4045
5964
  onOpenSettings: () => setModal({ type: 'settings' }),
4046
5965
  onSearchLibrary: openLibrary,
4047
- busy,
5966
+ qa,
5967
+ onRefreshQa: refreshQa,
5968
+ onJumpQa: jumpQa,
5969
+ onFixQa: applyQaFix,
5970
+ review,
5971
+ reviewBusy,
5972
+ onReview: runReview,
5973
+ onApplyReview: applyReview,
5974
+ concordance,
5975
+ concordanceQuery,
5976
+ setConcordanceQuery,
5977
+ onConcordance: runConcordance,
5978
+ onApplyConcordance: applyConcordance,
5979
+ busy: busy || (concordanceBusy ? 'concordance' : ''),
4048
5980
  })
4049
5981
  : null,
4050
5982
  ),
@@ -4061,7 +5993,60 @@
4061
5993
  })
4062
5994
  : null,
4063
5995
  modal?.type === 'export'
4064
- ? h(ExportModal, { t, busy, qa, onClose: () => setModal(null), onExport: exportAs })
5996
+ ? h(ExportModal, { t, busy, qa, onClose: () => setModal(null), onExport: exportAs, onImportReview: importReview })
5997
+ : null,
5998
+ modal?.type === 'brief'
5999
+ ? h(BriefModal, { t, doc, busy, onClose: () => setModal(null), onSave: saveBrief })
6000
+ : null,
6001
+ modal?.type === 'resources'
6002
+ ? h(ResourceModal, {
6003
+ t,
6004
+ doc,
6005
+ libraries: data?.libraries,
6006
+ mounts: data?.mounts,
6007
+ busy,
6008
+ onClose: () => setModal(null),
6009
+ onSave: saveResources,
6010
+ })
6011
+ : null,
6012
+ modal?.type === 'analysis'
6013
+ ? h(AnalysisModal, {
6014
+ t,
6015
+ doc,
6016
+ analysis,
6017
+ busy: analysisBusy,
6018
+ onClose: () => setModal(null),
6019
+ })
6020
+ : null,
6021
+ modal?.type === 'replace'
6022
+ ? h(ReplaceModal, {
6023
+ t,
6024
+ doc,
6025
+ busy: replaceBusy,
6026
+ preview: replacePreview,
6027
+ undoCount: replaceUndo ? replaceUndo.length : 0,
6028
+ onPreview: previewReplace,
6029
+ onApply: applyReplace,
6030
+ onUndo: undoReplace,
6031
+ onClose: () => setModal(null),
6032
+ })
6033
+ : null,
6034
+ modal?.type === 'notes'
6035
+ ? h(NoteModal, {
6036
+ t,
6037
+ doc,
6038
+ index,
6039
+ segment,
6040
+ notes,
6041
+ busy: notesBusy,
6042
+ onAdd: addNote,
6043
+ onUpdate: updateNote,
6044
+ onDelete: deleteNote,
6045
+ onClose: () => setModal(null),
6046
+ })
6047
+ : null,
6048
+ modal?.type === 'palette'
6049
+ ? h(CommandPalette, { t, items: commands, onClose: () => setModal(null), onRun: runCommand })
4065
6050
  : null,
4066
6051
  modal?.type === 'settings'
4067
6052
  ? h(SettingsModal, { t, settings, mtRoutes, busy, onClose: () => setModal(null), onSave: saveSettings })