dsh-cait 1.0.5 → 1.1.3

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.
@@ -32,7 +32,11 @@
32
32
  globe: ['M8 14A6 6 0 1 0 8 2a6 6 0 0 0 0 12Z', 'M2.5 8h11', 'M8 2c2 2 2 10 0 12', 'M8 2c-2 2-2 10 0 12'],
33
33
  alert: ['M8 2L14 13H2L8 2Z', 'M8 6v3.5', 'M8 11.5v.2'],
34
34
  save: ['M3 2h8l2 2v10H3z', 'M5 2v4h6V2', 'M5 14v-4h6v4'],
35
+ pencil: ['M11 2.5l2.5 2.5-8 8L2 14l1-3.5 8-8Z', 'M9.5 4l2.5 2.5'],
35
36
  languages: ['M2 4h7', 'M5.5 4v2c0 2-1.5 4-3.5 5', 'M4 8c0 1.5 2 3 4.5 3.5', 'M9 13l3-7 3 7', 'M10 11h4'],
37
+ check: ['M3 8.5l3.2 3.2L13 4.3'],
38
+ align: ['M2 5h8', 'M2 11h5', 'M12 3v10', 'M10 11l2 2 2-2'],
39
+ folder: ['M2 4h4l1.5 2H14v6.5H2z'],
36
40
  }
37
41
  const shape = paths[name] || paths.file
38
42
  return h(
@@ -295,10 +299,21 @@
295
299
  onCloseDoc,
296
300
  onNewTab,
297
301
  onCleanClosed,
302
+ pre,
303
+ onPreTranslate,
304
+ onCancelPreTranslate,
305
+ view,
306
+ onView,
298
307
  }) {
299
308
  const progress = doc?.progress || { percent: 0, confirmed: 0, total: 0 }
300
309
  const openIds = new Set((data.openDocuments || []).map((item) => item.id))
301
310
  const closedDocuments = (data.documents || []).filter((item) => !openIds.has(item.id))
311
+ const documentsView = view === 'documents'
312
+ const viewTabs = [
313
+ { id: 'documents', label: t('nav.documents') },
314
+ { id: 'libraries', label: t('nav.libraries') },
315
+ { id: 'align', label: t('nav.align') },
316
+ ]
302
317
  return h(
303
318
  'header',
304
319
  { className: 'cait-topbar' },
@@ -310,83 +325,314 @@
310
325
  ),
311
326
  h(
312
327
  'div',
313
- { className: 'cait-docbar' },
314
- h(DocTabs, {
315
- t,
316
- documents: data.openDocuments || data.documents || [],
317
- activeId: doc?.id,
318
- busy,
319
- onOpen: onOpenDoc,
320
- onClose: onCloseDoc,
321
- onNew: onNewTab,
322
- }),
323
- h(ClosedDocPicker, { t, documents: closedDocuments, busy, onOpen: onOpenDoc }),
324
- closedDocuments.length
328
+ { className: 'cait-viewtabs' },
329
+ viewTabs.map((tab) =>
330
+ h(
331
+ 'button',
332
+ {
333
+ key: tab.id,
334
+ type: 'button',
335
+ className: `cait-viewtab${view === tab.id ? ' active' : ''}`,
336
+ onClick: () => onView(tab.id),
337
+ },
338
+ tab.label,
339
+ ),
340
+ ),
341
+ ),
342
+ documentsView
343
+ ? h(
344
+ 'div',
345
+ { className: 'cait-docbar' },
346
+ h(DocTabs, {
347
+ t,
348
+ documents: data.openDocuments || data.documents || [],
349
+ activeId: doc?.id,
350
+ busy,
351
+ onOpen: onOpenDoc,
352
+ onClose: onCloseDoc,
353
+ onNew: onNewTab,
354
+ }),
355
+ h(ClosedDocPicker, { t, documents: closedDocuments, busy, onOpen: onOpenDoc }),
356
+ closedDocuments.length
357
+ ? h(
358
+ 'button',
359
+ {
360
+ type: 'button',
361
+ className: 'cait-doc-clean',
362
+ title: t('doc.clean'),
363
+ 'aria-label': t('doc.clean'),
364
+ disabled: Boolean(busy),
365
+ onClick: onCleanClosed,
366
+ },
367
+ h(Icon, { name: 'trash', size: 13 }),
368
+ )
369
+ : null,
370
+ doc
371
+ ? h('span', { className: 'cait-doc-progress' }, `${progress.confirmed}/${progress.total} · ${progress.percent}%`)
372
+ : null,
373
+ )
374
+ : h('div', { className: 'cait-docbar' }),
375
+ h(
376
+ 'div',
377
+ { className: 'cait-actions' },
378
+ documentsView && pre && pre.running
379
+ ? h(
380
+ 'span',
381
+ { className: 'cait-pre-status', title: t('pretranslate.running') },
382
+ h(Spinner),
383
+ h('span', null, `${t('pretranslate.running')} ${pre.done}/${pre.total}`),
384
+ h(
385
+ 'button',
386
+ {
387
+ type: 'button',
388
+ className: 'cait-iconbtn',
389
+ title: t('pretranslate.cancel'),
390
+ 'aria-label': t('pretranslate.cancel'),
391
+ onClick: onCancelPreTranslate,
392
+ },
393
+ h(Icon, { name: 'close', size: 13 }),
394
+ ),
395
+ )
396
+ : null,
397
+ documentsView && !(pre && pre.running)
325
398
  ? h(
326
399
  'button',
327
400
  {
328
- type: 'button',
329
- className: 'cait-doc-clean',
330
- title: t('doc.clean'),
331
- 'aria-label': t('doc.clean'),
332
- disabled: Boolean(busy),
333
- onClick: onCleanClosed,
401
+ className: 'cait-btn',
402
+ onClick: onPreTranslate,
403
+ disabled: Boolean(busy) || !doc || !(doc.progress?.total > 0),
404
+ title: t('pretranslate.title'),
334
405
  },
335
- h(Icon, { name: 'trash', size: 13 }),
406
+ h(Icon, { name: 'sparkles', size: 14 }),
407
+ h('span', { className: 'cait-btn-label' }, t('pretranslate.start')),
336
408
  )
337
409
  : null,
338
- doc
339
- ? h('span', { className: 'cait-doc-progress' }, `${progress.confirmed}/${progress.total} · ${progress.percent}%`)
410
+ documentsView
411
+ ? h(
412
+ 'button',
413
+ { className: 'cait-btn', onClick: onNew, disabled: Boolean(busy), title: t('nav.new') },
414
+ h(Icon, { name: 'plus', size: 14 }),
415
+ h('span', { className: 'cait-btn-label' }, t('nav.new')),
416
+ )
417
+ : null,
418
+ documentsView
419
+ ? h(
420
+ 'button',
421
+ { className: 'cait-btn', onClick: onImport, disabled: Boolean(busy), title: t('nav.import') },
422
+ h(Icon, { name: 'upload', size: 14 }),
423
+ h('span', { className: 'cait-btn-label' }, t('nav.import')),
424
+ )
425
+ : null,
426
+ documentsView
427
+ ? h(
428
+ 'button',
429
+ { className: 'cait-btn', onClick: onExport, disabled: Boolean(busy) || !doc, title: t('nav.export') },
430
+ h(Icon, { name: 'download', size: 14 }),
431
+ h('span', { className: 'cait-btn-label' }, t('nav.export')),
432
+ )
340
433
  : null,
341
- ),
342
- h(
343
- 'div',
344
- { className: 'cait-actions' },
345
- h(
346
- 'button',
347
- { className: 'cait-btn', onClick: onNew, disabled: Boolean(busy), title: t('nav.new') },
348
- h(Icon, { name: 'plus', size: 14 }),
349
- h('span', { className: 'cait-btn-label' }, t('nav.new')),
350
- ),
351
- h(
352
- 'button',
353
- { className: 'cait-btn', onClick: onImport, disabled: Boolean(busy), title: t('nav.import') },
354
- h(Icon, { name: 'upload', size: 14 }),
355
- h('span', { className: 'cait-btn-label' }, t('nav.import')),
356
- ),
357
- h(
358
- 'button',
359
- { className: 'cait-btn', onClick: onExport, disabled: Boolean(busy) || !doc, title: t('nav.export') },
360
- h(Icon, { name: 'download', size: 14 }),
361
- h('span', { className: 'cait-btn-label' }, t('nav.export')),
362
- ),
363
434
  h('button', { className: 'cait-iconbtn', onClick: onSettings, disabled: Boolean(busy), title: t('nav.settings') }, h(Icon, { name: 'settings', size: 15 })),
364
435
  ),
365
436
  )
366
437
  }
367
438
 
368
- function SegmentRail({ t, doc, selected, query, setQuery, onSelect }) {
439
+ const SEGMENT_FILTERS = ['all', 'pending', 'translated', 'confirmed']
440
+
441
+ function SegmentRail({ t, doc, selected, query, setQuery, filter, setFilter, onSelect, onConfirmAll, onPropagate, busy, qa }) {
369
442
  const activeRef = useRef(null)
443
+ const storage = typeof localStorage !== 'undefined' ? localStorage : null
444
+ const [pageSize, setPageSize] = useState(() => readPageSize(storage))
445
+ const [page, setPage] = useState(1)
446
+ const [gotoText, setGotoText] = useState('')
447
+ const [gotoBad, setGotoBad] = useState(false)
448
+ const lastSelected = useRef(selected)
370
449
  useEffect(() => {
371
450
  if (activeRef.current) activeRef.current.scrollIntoView({ block: 'nearest' })
372
- }, [selected])
451
+ }, [selected, filter])
452
+
453
+ // Advisory QA marks: the check never blocks editing or exporting, it only
454
+ // flags the segment in the rail so review can start where it matters.
455
+ const warns = new Map()
456
+ for (const issue of qa?.issues || []) {
457
+ const list = warns.get(issue.index) || []
458
+ list.push(issue.code)
459
+ warns.set(issue.index, list)
460
+ }
373
461
 
462
+ const counts = {
463
+ all: doc.progress.total,
464
+ pending: doc.progress.pending,
465
+ translated: doc.progress.translated,
466
+ confirmed: doc.progress.confirmed,
467
+ }
468
+
469
+ // How many empty segments could be filled from a sibling with the same
470
+ // source. Shown on the rail so propagation is a visible, one-click choice
471
+ // rather than something happening behind the user's back.
472
+ const duplicateGroups = new Map()
473
+ for (const segment of doc.segments) {
474
+ const key = String(segment.source || '').replace(/\s+/g, ' ').trim().toLowerCase()
475
+ if (!key) continue
476
+ const list = duplicateGroups.get(key) || []
477
+ list.push(segment)
478
+ duplicateGroups.set(key, list)
479
+ }
480
+ let duplicated = 0
481
+ for (const list of duplicateGroups.values()) {
482
+ if (list.length < 2 || !list.some((segment) => segment.target)) continue
483
+ duplicated += list.filter((segment) => !segment.target).length
484
+ }
374
485
  const needle = query.trim().toLowerCase()
375
- const items = doc.segments
486
+ const filtered = doc.segments
376
487
  .map((segment, index) => ({ segment, index }))
488
+ .filter(({ segment }) => filter === 'all' || segment.status === filter)
377
489
  .filter(({ segment }) => {
378
490
  if (!needle) return true
379
- return segment.source.toLowerCase().includes(needle) || (segment.target || '').toLowerCase().includes(needle)
491
+ return (
492
+ segment.source.toLowerCase().includes(needle) ||
493
+ (segment.target || '').toLowerCase().includes(needle) ||
494
+ (segment.suggestion || '').toLowerCase().includes(needle)
495
+ )
380
496
  })
381
497
 
498
+ // A rail window is a view over the filtered list: the page is clamped to
499
+ // the result count instead of being reset, so narrowing a filter keeps the
500
+ // reader near where they were.
501
+ const bounds = pageBounds(page, filtered.length, pageSize)
502
+ const firstRow = bounds.start === 0 ? 0 : bounds.start - 1
503
+ const visible = filtered.slice(firstRow, bounds.end)
504
+
505
+ useEffect(() => {
506
+ if (bounds.page !== page) setPage(bounds.page)
507
+ }, [bounds.page, page])
508
+
509
+ // Selection can move from outside the rail (next-unconfirmed, batch
510
+ // confirm), so follow it onto the page that holds it.
511
+ useEffect(() => {
512
+ if (lastSelected.current === selected) return
513
+ lastSelected.current = selected
514
+ const position = filtered.findIndex((item) => item.index === selected)
515
+ if (position < 0) return
516
+ const target = Math.floor(position / pageSize) + 1
517
+ if (target !== bounds.page) setPage(target)
518
+ }, [selected])
519
+
520
+ const changePageSize = (value) => {
521
+ const size = writePageSize(storage, value)
522
+ setPageSize(size)
523
+ setPage(1)
524
+ }
525
+
526
+ const submitGoto = () => {
527
+ const index = segmentNumberToIndex(gotoText, doc.progress.total)
528
+ if (index === null) {
529
+ setGotoBad(true)
530
+ return
531
+ }
532
+ setGotoBad(false)
533
+ setGotoText('')
534
+ // A number is useless while a filter or a search hides it, so jumping is
535
+ // an explicit "show me this segment" and clears both.
536
+ if (needle || filter !== 'all') {
537
+ setQuery('')
538
+ setFilter('all')
539
+ }
540
+ lastSelected.current = index
541
+ setPage(Math.floor(index / pageSize) + 1)
542
+ onSelect(index)
543
+ }
544
+
382
545
  return h(
383
546
  'aside',
384
547
  { className: 'cait-rail' },
385
548
  h(
386
549
  'div',
387
550
  { className: 'cait-rail-head' },
388
- h('div', { className: 'cait-rail-title' }, h('span', null, t('rail.title')), h('span', { className: 'cait-count' }, doc.progress.total)),
551
+ h(
552
+ 'div',
553
+ { className: 'cait-rail-title' },
554
+ h('span', null, t('rail.title')),
555
+ h(
556
+ 'span',
557
+ { className: 'cait-count', title: `${filtered.length} / ${doc.progress.total}` },
558
+ needle || filter !== 'all' ? `${filtered.length}/${doc.progress.total}` : doc.progress.total,
559
+ ),
560
+ ),
389
561
  h('div', { className: 'cait-mini-progress' }, h('i', { style: { width: `${doc.progress.percent}%` } })),
562
+ h(
563
+ 'div',
564
+ { className: 'cait-rail-filters' },
565
+ SEGMENT_FILTERS.map((key) =>
566
+ h(
567
+ 'button',
568
+ {
569
+ key,
570
+ className: `cait-filterchip${filter === key ? ' active' : ''}`,
571
+ onClick: () => setFilter(key),
572
+ title: t(`rail.filter.${key}`),
573
+ },
574
+ h('span', null, t(`rail.filter.${key}`)),
575
+ h('span', { className: `cait-filtercount ${key}` }, counts[key]),
576
+ ),
577
+ ),
578
+ ),
579
+ doc.progress.translated > 0
580
+ ? h(
581
+ 'button',
582
+ {
583
+ className: 'cait-batchbtn',
584
+ onClick: onConfirmAll,
585
+ disabled: Boolean(busy),
586
+ title: t('rail.confirmAllHint'),
587
+ },
588
+ h(Icon, { name: 'check', size: 11 }),
589
+ `${t('rail.confirmAll')} · ${doc.progress.translated}`,
590
+ )
591
+ : null,
592
+ duplicated > 0
593
+ ? h(
594
+ 'button',
595
+ {
596
+ className: 'cait-batchbtn cait-batchbtn-plain',
597
+ onClick: onPropagate,
598
+ disabled: Boolean(busy),
599
+ title: t('rail.propagateMessage'),
600
+ },
601
+ h(Icon, { name: 'copy', size: 11 }),
602
+ `${t('rail.propagate')} · ${duplicated}`,
603
+ )
604
+ : null,
605
+ h(
606
+ 'div',
607
+ { className: 'cait-rail-goto' },
608
+ h('input', {
609
+ className: `cait-goto-input${gotoBad ? ' bad' : ''}`,
610
+ value: gotoText,
611
+ onChange: (event) => {
612
+ setGotoText(event.target.value)
613
+ setGotoBad(false)
614
+ },
615
+ onKeyDown: (event) => {
616
+ if (event.key === 'Enter') submitGoto()
617
+ },
618
+ placeholder: t('rail.goto'),
619
+ 'aria-label': t('rail.goto'),
620
+ title: gotoBad ? t('rail.gotoInvalid') : t('rail.goto'),
621
+ inputMode: 'numeric',
622
+ }),
623
+ h(
624
+ 'button',
625
+ {
626
+ type: 'button',
627
+ className: 'cait-gotobtn',
628
+ onClick: submitGoto,
629
+ disabled: Boolean(busy),
630
+ title: t('rail.goto'),
631
+ 'aria-label': t('rail.goto'),
632
+ },
633
+ h(Icon, { name: 'arrowRight', size: 12 }),
634
+ ),
635
+ ),
390
636
  ),
391
637
  h(
392
638
  'div',
@@ -397,8 +643,8 @@
397
643
  h(
398
644
  'div',
399
645
  { className: 'cait-rail-list' },
400
- items.length
401
- ? items.map(({ segment, index }) =>
646
+ visible.length
647
+ ? visible.map(({ segment, index }) =>
402
648
  h(
403
649
  'button',
404
650
  {
@@ -416,6 +662,13 @@
416
662
  { className: 'cait-seg-line' },
417
663
  h('span', { className: 'cait-seg-index' }, String(index + 1).padStart(2, '0')),
418
664
  h('span', { className: `cait-dot ${segment.status}` }),
665
+ warns.has(index)
666
+ ? h('span', {
667
+ className: 'cait-seg-warn',
668
+ title: (warns.get(index) || []).map((code) => t(`qa.code.${code}`)).join(' · '),
669
+ 'aria-label': t('qa.warn'),
670
+ })
671
+ : null,
419
672
  ),
420
673
  h('span', { className: 'cait-seg-text' }, segment.source),
421
674
  segment.target ? h('span', { className: 'cait-seg-target' }, segment.target) : null,
@@ -424,16 +677,68 @@
424
677
  )
425
678
  : h('div', { className: 'cait-empty-note' }, t('rail.empty')),
426
679
  ),
680
+ h(
681
+ 'div',
682
+ { className: 'cait-rail-page' },
683
+ h(
684
+ 'button',
685
+ {
686
+ type: 'button',
687
+ className: 'cait-pagebtn',
688
+ onClick: () => setPage(bounds.page - 1),
689
+ disabled: bounds.page <= 1,
690
+ title: t('library.prev'),
691
+ 'aria-label': t('library.prev'),
692
+ },
693
+ h(Icon, { name: 'chevronLeft', size: 12 }),
694
+ ),
695
+ h('span', { className: 'cait-pageinfo' }, t('rail.pageInfo', { start: bounds.start, end: bounds.end, total: bounds.total })),
696
+ h(
697
+ 'button',
698
+ {
699
+ type: 'button',
700
+ className: 'cait-pagebtn',
701
+ onClick: () => setPage(bounds.page + 1),
702
+ disabled: bounds.page >= bounds.pages,
703
+ title: t('library.next'),
704
+ 'aria-label': t('library.next'),
705
+ },
706
+ h(Icon, { name: 'chevronRight', size: 12 }),
707
+ ),
708
+ h(
709
+ 'select',
710
+ {
711
+ className: 'cait-pagesize',
712
+ value: String(pageSize),
713
+ onChange: (event) => changePageSize(Number(event.target.value)),
714
+ title: t('library.pageSize'),
715
+ 'aria-label': t('library.pageSize'),
716
+ },
717
+ PAGE_SIZE_OPTIONS.map((size) => h('option', { key: size, value: String(size) }, String(size))),
718
+ ),
719
+ ),
427
720
  )
428
721
  }
429
722
 
430
- function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onNext, onPrev, onSave, onApplyTerm, textareaRef }) {
723
+ function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onNext, onPrev, onSave, onAdopt, onApplyTerm, textareaRef }) {
431
724
  const hits = detail?.index === index ? detail.glossaryHits : []
432
725
  const charCount = Array.from(draft || '').length
433
726
  const progress = doc.progress
434
-
435
- const onKeyDown = (event) => {
436
- const mod = event.metaKey || event.ctrlKey
727
+ const remaining = progress.total - progress.confirmed
728
+ const statusLabel =
729
+ segment.status === 'confirmed'
730
+ ? t('status.confirmed')
731
+ : segment.status === 'translated'
732
+ ? t('status.translated')
733
+ : t('status.pending')
734
+
735
+ // The middle column keeps the machine/agent baseline that produced the
736
+ // current text, so editing in the right column has something to compare
737
+ // against. It is stored in `suggestion`, which adoption already preserves.
738
+ const baselineText = segment.suggestion || ''
739
+ const baselineOrigin = segment.origin || 'agent'
740
+
741
+ const onKeyDown = (event) => { const mod = event.metaKey || event.ctrlKey
437
742
  if (mod && event.key === 'Enter') {
438
743
  event.preventDefault()
439
744
  onConfirm()
@@ -480,61 +785,94 @@
480
785
  'div',
481
786
  { className: 'cait-seg-head' },
482
787
  h('span', { className: 'cait-seg-label' }, `${t('editor.segment')} ${index + 1} / ${doc.segments.length}`),
483
- h('span', { className: `cait-badge ${segment.status}` }, segment.status === 'confirmed' ? t('status.confirmed') : t('status.pending')),
788
+ h('span', { className: `cait-badge ${segment.status}` }, statusLabel),
484
789
  ),
485
790
  h(
486
791
  'div',
487
- { className: 'cait-source-card' },
792
+ { className: 'cait-review' },
488
793
  h(
489
- 'div',
490
- { className: 'cait-labelrow' },
491
- h('span', { className: 'cait-label' }, t('editor.source')),
492
- h('span', { className: 'cait-langchip' }, doc.sourceLang),
794
+ 'section',
795
+ { className: 'cait-revcol cait-rev-src' },
796
+ h(
797
+ 'div',
798
+ { className: 'cait-labelrow' },
799
+ h('span', { className: 'cait-label' }, t('editor.source')),
800
+ h('span', { className: 'cait-langchip' }, doc.sourceLang),
801
+ ),
802
+ h('div', { className: 'cait-source-text' }, highlightSource(segment.source, hits)),
803
+ hits.length
804
+ ? h(
805
+ 'div',
806
+ { className: 'cait-hitrow' },
807
+ hits.map((hit) =>
808
+ h(
809
+ 'button',
810
+ {
811
+ key: hit.id,
812
+ className: 'cait-chip cait-chip-action',
813
+ onClick: () => onApplyTerm(hit),
814
+ title: hit.note || `${hit.term} → ${hit.target}`,
815
+ },
816
+ h(Icon, { name: 'book', size: 11 }),
817
+ `${hit.term} → ${hit.target}`,
818
+ ),
819
+ ),
820
+ )
821
+ : null,
493
822
  ),
494
- h('div', { className: 'cait-source-text' }, highlightSource(segment.source, hits)),
495
- hits.length
496
- ? h(
497
- 'div',
498
- { className: 'cait-hitrow' },
499
- hits.map((hit) =>
500
- h(
823
+ h(
824
+ 'section',
825
+ { className: 'cait-revcol cait-rev-sugg' },
826
+ h(
827
+ 'div',
828
+ { className: 'cait-labelrow' },
829
+ h('span', { className: 'cait-label' }, t('editor.suggestion')),
830
+ baselineText
831
+ ? h('span', { className: `cait-originchip ${baselineOrigin}` }, t(`origin.${baselineOrigin}`))
832
+ : null,
833
+ segment.suggestion
834
+ ? h(
501
835
  'button',
502
836
  {
503
- key: hit.id,
504
837
  className: 'cait-chip cait-chip-action',
505
- onClick: () => onApplyTerm(hit),
506
- title: hit.note || `${hit.term} → ${hit.target}`,
838
+ onClick: onAdopt,
839
+ disabled: Boolean(busy) || draft.trim() === segment.suggestion,
840
+ title: t('editor.adoptHint'),
507
841
  },
508
- h(Icon, { name: 'book', size: 11 }),
509
- `${hit.term} → ${hit.target}`,
510
- ),
511
- ),
512
- )
513
- : null,
514
- ),
515
- h(
516
- 'div',
517
- { className: 'cait-target-area' },
842
+ h(Icon, { name: 'download', size: 11 }),
843
+ t('editor.adopt'),
844
+ )
845
+ : null,
846
+ ),
847
+ baselineText
848
+ ? h('div', { className: 'cait-suggest-text' }, baselineText)
849
+ : h('div', { className: 'cait-suggest-empty' }, t('editor.noSuggestion')),
850
+ h('div', { className: 'cait-rev-hint' }, segment.suggestion ? t('editor.adoptHint') : t('editor.baselineHint')),
851
+ ),
518
852
  h(
519
- 'div',
520
- { className: 'cait-labelrow' },
521
- h('span', { className: 'cait-label' }, t('editor.target')),
853
+ 'section',
854
+ { className: 'cait-revcol cait-rev-target' },
522
855
  h(
523
- 'span',
524
- { className: 'cait-meta' },
525
- `${charCount} ${t('editor.chars')}${dirty ? ` · ${t('editor.unsaved')}` : ''}`,
856
+ 'div',
857
+ { className: 'cait-labelrow' },
858
+ h('span', { className: 'cait-label' }, t('editor.target')),
859
+ h(
860
+ 'span',
861
+ { className: 'cait-meta' },
862
+ `${charCount} ${t('editor.chars')}${dirty ? ` · ${t('editor.unsaved')}` : ''}`,
863
+ ),
526
864
  ),
865
+ h('textarea', {
866
+ ref: textareaRef,
867
+ className: 'cait-textarea',
868
+ value: draft,
869
+ onChange: (event) => setDraft(event.target.value),
870
+ onKeyDown,
871
+ placeholder: t('editor.targetPlaceholder'),
872
+ spellCheck: false,
873
+ rows: 8,
874
+ }),
527
875
  ),
528
- h('textarea', {
529
- ref: textareaRef,
530
- className: 'cait-textarea',
531
- value: draft,
532
- onChange: (event) => setDraft(event.target.value),
533
- onKeyDown,
534
- placeholder: t('editor.targetPlaceholder'),
535
- spellCheck: false,
536
- rows: 8,
537
- }),
538
876
  ),
539
877
  h(
540
878
  'div',
@@ -567,16 +905,30 @@
567
905
  h(
568
906
  'div',
569
907
  { className: 'cait-editor-foot' },
570
- progress.pending === 0
908
+ remaining === 0
571
909
  ? h('span', { className: 'cait-donechip' }, h(Icon, { name: 'check', size: 11 }), t('editor.done'))
572
- : h('span', null, `${progress.pending} ${t('status.pending')}`),
910
+ : h('span', null, `${remaining} ${t('editor.remaining')}`),
573
911
  segment.confirmedAt ? h('span', null, `${t('editor.confirmedAt')} ${new Date(segment.confirmedAt).toLocaleString()}`) : null,
574
912
  ),
575
913
  )
576
914
  }
577
915
 
578
- function TmMatchCard({ t, match, onApply }) {
916
+ function TmMatchCard({ t, match, onApply, onEdit, onDelete, busy }) {
579
917
  const matchType = match.matchType || (match.exact ? 'exact' : 'low_fuzzy')
918
+ const [editing, setEditing] = useState(false)
919
+ const [value, setValue] = useState(match.target)
920
+
921
+ useEffect(() => {
922
+ setEditing(false)
923
+ setValue(match.target)
924
+ }, [match.id, match.target])
925
+
926
+ const save = async () => {
927
+ if (!value.trim()) return
928
+ const ok = await onEdit({ id: match.id, target: value })
929
+ if (ok) setEditing(false)
930
+ }
931
+
580
932
  return h(
581
933
  'article',
582
934
  { className: 'cait-card' },
@@ -585,10 +937,56 @@
585
937
  { className: 'cait-card-head' },
586
938
  h('span', { className: `cait-score ${matchType}` }, `${match.percent}%`),
587
939
  h('span', { className: 'cait-score-label' }, t(`match.${matchType}`)),
588
- h('button', { className: 'cait-minibtn', onClick: () => onApply(match) }, t('panel.apply')),
940
+ h(
941
+ 'div',
942
+ { className: 'cait-term-actions' },
943
+ h('button', { className: 'cait-minibtn', onClick: () => onApply(match) }, t('panel.apply')),
944
+ h(
945
+ 'button',
946
+ { className: 'cait-minibtn', onClick: () => setEditing((current) => !current), disabled: Boolean(busy), title: t('panel.edit') },
947
+ h(Icon, { name: 'pencil', size: 11 }),
948
+ ),
949
+ h(
950
+ 'button',
951
+ { className: 'cait-deletebtn', onClick: () => onDelete(match), disabled: Boolean(busy), title: t('panel.delete') },
952
+ h(Icon, { name: 'trash', size: 11 }),
953
+ ),
954
+ ),
589
955
  ),
590
956
  h('p', { className: 'cait-card-source' }, match.source),
591
- h('p', { className: 'cait-card-target' }, match.target),
957
+ match.context
958
+ ? h(
959
+ 'p',
960
+ { className: 'cait-card-context' },
961
+ `${t('panel.contextPrev')} ${match.context.prev || '—'} · ${t('panel.contextNext')} ${match.context.next || '—'}`,
962
+ )
963
+ : null,
964
+ editing
965
+ ? h(
966
+ 'div',
967
+ { className: 'cait-tmedit' },
968
+ h('textarea', {
969
+ className: 'cait-textarea cait-tmedit-input',
970
+ value,
971
+ rows: 3,
972
+ autoFocus: true,
973
+ onChange: (event) => setValue(event.target.value),
974
+ onKeyDown: (event) => {
975
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
976
+ event.preventDefault()
977
+ save()
978
+ }
979
+ if (event.key === 'Escape') setEditing(false)
980
+ },
981
+ }),
982
+ h(
983
+ 'div',
984
+ { className: 'cait-formrow' },
985
+ h('button', { className: 'cait-btn cait-btn-primary', onClick: save, disabled: Boolean(busy) || !value.trim() }, t('panel.saveEntry')),
986
+ h('button', { className: 'cait-btn cait-btn-ghost', onClick: () => setEditing(false) }, t('panel.cancel')),
987
+ ),
988
+ )
989
+ : h('p', { className: 'cait-card-target' }, match.target),
592
990
  )
593
991
  }
594
992
 
@@ -605,6 +1003,8 @@
605
1003
  tmResults,
606
1004
  tmError,
607
1005
  onApplyTm,
1006
+ onEditTm,
1007
+ onDeleteTm,
608
1008
  onImportTmx,
609
1009
  onClearTm,
610
1010
  onAddTerm,
@@ -619,7 +1019,7 @@
619
1019
  onApplyMt,
620
1020
  onCopy,
621
1021
  onOpenSettings,
622
- onOpenLibraries,
1022
+ onSearchLibrary,
623
1023
  busy,
624
1024
  }) {
625
1025
  const [termQuery, setTermQuery] = useState('')
@@ -666,7 +1066,7 @@
666
1066
  'button',
667
1067
  {
668
1068
  className: 'cait-btn cait-libopen',
669
- onClick: onOpenLibraries,
1069
+ onClick: () => onSearchLibrary(tab === 'glossary' ? 'glossary' : 'tm', tab === 'tm' ? tmQuery : ''),
670
1070
  disabled: Boolean(busy),
671
1071
  title: t('libraries.open'),
672
1072
  },
@@ -730,6 +1130,17 @@
730
1130
  }),
731
1131
  h('button', { className: 'cait-iconbtn', type: 'submit', title: t('panel.search') }, h(Icon, { name: 'arrowRight', size: 13 })),
732
1132
  ),
1133
+ // The panel searches what is mounted; the library workspace
1134
+ // searches everything. This carries the typed query across.
1135
+ h(
1136
+ 'div',
1137
+ { className: 'cait-paneljump' },
1138
+ h(
1139
+ 'button',
1140
+ { className: 'cait-linkbtn', type: 'button', onClick: () => onSearchLibrary('tm', tmQuery) },
1141
+ t('panel.searchInLibrary'),
1142
+ ),
1143
+ ),
733
1144
  h(
734
1145
  'div',
735
1146
  { className: 'cait-listhead' },
@@ -738,7 +1149,17 @@
738
1149
  ),
739
1150
  tmError ? h('div', { className: 'cait-mt-error' }, tmError) : null,
740
1151
  tmMatches.length
741
- ? tmMatches.map((match) => h(TmMatchCard, { key: match.id, t, match, onApply: onApplyTm }))
1152
+ ? tmMatches.map((match) =>
1153
+ h(TmMatchCard, {
1154
+ key: match.id,
1155
+ t,
1156
+ match,
1157
+ busy,
1158
+ onApply: onApplyTm,
1159
+ onEdit: onEditTm,
1160
+ onDelete: onDeleteTm,
1161
+ }),
1162
+ )
742
1163
  : h('p', { className: 'cait-empty-note' }, tmResults ? t('panel.noMatches') : t('panel.noCurrentMatches')),
743
1164
  h('p', { className: 'cait-mt-hint' }, t('panel.tmHint')),
744
1165
  h(
@@ -778,75 +1199,20 @@
778
1199
  ),
779
1200
  )
780
1201
  : h('p', { className: 'cait-empty-note' }, t('panel.noHits')),
1202
+ // The panel is a per-segment view: whole-library browsing,
1203
+ // adding and editing live on the library page, so the panel
1204
+ // stays short instead of dumping the mounted glossary.
1205
+ h('p', { className: 'cait-mt-hint' }, t('panel.glossaryMatchesOnly')),
781
1206
  h(
782
1207
  'div',
783
- { className: 'cait-subhead' },
784
- h('span', null, t('panel.allTerms')),
785
- h('button', { className: 'cait-linkbtn', onClick: () => setShowTermForm((value) => !value) }, `+ ${t('panel.addTerm')}`),
786
- ),
787
- showTermForm
788
- ? h(
789
- 'form',
790
- { className: 'cait-termform', onSubmit: submitTerm },
791
- h('input', {
792
- className: 'cait-input',
793
- value: termForm.source,
794
- onChange: (event) => setTermForm({ ...termForm, source: event.target.value }),
795
- placeholder: t('panel.termSource'),
796
- autoFocus: true,
797
- }),
798
- h(
799
- 'div',
800
- { className: 'cait-formrow' },
801
- h('input', {
802
- className: 'cait-input',
803
- value: termForm.target,
804
- onChange: (event) => setTermForm({ ...termForm, target: event.target.value }),
805
- placeholder: t('panel.termTarget'),
806
- }),
807
- h('input', {
808
- className: 'cait-input',
809
- value: termForm.note,
810
- onChange: (event) => setTermForm({ ...termForm, note: event.target.value }),
811
- placeholder: t('panel.termNote'),
812
- }),
813
- ),
814
- h(
815
- 'div',
816
- { className: 'cait-formrow' },
817
- h('button', { className: 'cait-btn cait-btn-primary', type: 'submit', disabled: Boolean(busy) || !termForm.source.trim() || !termForm.target.trim() }, t('panel.saveTerm')),
818
- h('button', { className: 'cait-btn cait-btn-ghost', type: 'button', onClick: () => setShowTermForm(false) }, t('panel.cancel')),
819
- ),
820
- )
821
- : null,
822
- h(
823
- 'div',
824
- { className: 'cait-search' },
825
- h(Icon, { name: 'search', size: 13 }),
826
- h('input', { value: termQuery, onChange: (event) => setTermQuery(event.target.value), placeholder: t('panel.termSearch'), 'aria-label': t('panel.termSearch') }),
1208
+ { className: 'cait-panel-foot' },
1209
+ h(
1210
+ 'button',
1211
+ { className: 'cait-btn', onClick: () => onSearchLibrary('glossary', ''), disabled: Boolean(busy) },
1212
+ h(Icon, { name: 'book', size: 12 }),
1213
+ t('panel.viewAll'),
1214
+ ),
827
1215
  ),
828
- terms.length
829
- ? terms.map((term) =>
830
- h(
831
- 'div',
832
- { key: term.id, className: 'cait-term' },
833
- h(
834
- 'div',
835
- { className: 'cait-term-main' },
836
- h('strong', null, term.source),
837
- h(Icon, { name: 'arrowRight', size: 11, className: 'cait-term-arrow' }),
838
- h('span', null, term.target),
839
- ),
840
- term.note ? h('div', { className: 'cait-muted cait-tiny', style: { marginTop: 2 } }, term.note) : null,
841
- h(
842
- 'div',
843
- { className: 'cait-term-actions' },
844
- h('button', { className: 'cait-minibtn', onClick: () => onApplyTerm(term) }, t('panel.apply')),
845
- h('button', { className: 'cait-deletebtn', onClick: () => onDeleteTerm(term), title: t('panel.delete') }, h(Icon, { name: 'trash', size: 11 })),
846
- ),
847
- ),
848
- )
849
- : h('p', { className: 'cait-empty-note' }, t('panel.noTerms')),
850
1216
  h(
851
1217
  'div',
852
1218
  { className: 'cait-panel-foot' },
@@ -1072,35 +1438,1199 @@
1072
1438
  h('strong', null, file ? file.name : t('empty.drop')),
1073
1439
  h('span', null, file ? formatBytes(file.size) : t('modal.new.dropHint')),
1074
1440
  ),
1075
- h(
1076
- 'div',
1077
- { className: 'cait-formfoot' },
1078
- h('span', { className: 'cait-muted' }, t('empty.formats')),
1441
+ h(
1442
+ 'div',
1443
+ { className: 'cait-formfoot' },
1444
+ h('span', { className: 'cait-muted' }, t('empty.formats')),
1445
+ h(
1446
+ 'button',
1447
+ { className: 'cait-btn cait-btn-primary', type: 'submit', disabled: Boolean(busy) || !canSubmit },
1448
+ busy === 'create' ? h(Spinner) : h(Icon, { name: 'plus', size: 13 }),
1449
+ busy === 'create' ? t('modal.new.importing') : submitLabel,
1450
+ ),
1451
+ ),
1452
+ )
1453
+ }
1454
+
1455
+ function NewDocModal({ t, settings, busy, defaultMode, onClose, onCreate }) {
1456
+ return h(
1457
+ Modal,
1458
+ { title: t('modal.new.title'), onClose },
1459
+ h(DocumentSourceForm, { t, settings, busy, submitLabel: t('modal.new.create'), onSubmit: onCreate, defaultMode }),
1460
+ h(
1461
+ 'div',
1462
+ { className: 'cait-demo-row' },
1463
+ h('span', null, t('modal.new.sample')),
1464
+ h('button', { className: 'cait-linkbtn', onClick: () => onCreate({ demo: true }), disabled: Boolean(busy) }, t('modal.new.demo')),
1465
+ ),
1466
+ )
1467
+ }
1468
+
1469
+ /**
1470
+ * The library workspace: browse, search, page through, edit and export the
1471
+ * translation memories and glossaries themselves. It deliberately does not
1472
+ * need an open document — the rail and the right panel stay document-scoped
1473
+ * and this page is the only place that manages whole libraries.
1474
+ */
1475
+ function AlignPage({ t, data, busy, notify, report, onApplyState, onConfirm }) {
1476
+ const [sourceFile, setSourceFile] = useState(null)
1477
+ const [targetFile, setTargetFile] = useState(null)
1478
+ const [sourceLang, setSourceLang] = useState('')
1479
+ const [targetLang, setTargetLang] = useState('')
1480
+ const [result, setResult] = useState(null)
1481
+ const [rows, setRows] = useState([])
1482
+ const [working, setWorking] = useState(false)
1483
+ const [tmId, setTmId] = useState('')
1484
+ const sourceRef = useRef(null)
1485
+ const targetRef = useRef(null)
1486
+ const libraries = data?.tmLibraries || []
1487
+
1488
+ useEffect(() => {
1489
+ if (!tmId && libraries.length) setTmId(libraries[0].id)
1490
+ }, [libraries.length])
1491
+
1492
+ const readBase64 = (file) =>
1493
+ new Promise((resolve, reject) => {
1494
+ const reader = new FileReader()
1495
+ reader.onerror = () => reject(new Error(t('align.readError')))
1496
+ reader.onload = () => {
1497
+ const value = String(reader.result || '')
1498
+ const comma = value.indexOf(',')
1499
+ resolve(comma >= 0 ? value.slice(comma + 1) : '')
1500
+ }
1501
+ reader.readAsDataURL(file)
1502
+ })
1503
+
1504
+ const pick = async (which, file) => {
1505
+ if (!file) return
1506
+ setWorking(true)
1507
+ try {
1508
+ const uploaded = await rpc('readUploadText', {
1509
+ name: file.name,
1510
+ base64: await readBase64(file),
1511
+ })
1512
+ const picked = { name: uploaded.name, text: uploaded.text, chars: uploaded.chars }
1513
+ if (which === 'source') setSourceFile(picked)
1514
+ else setTargetFile(picked)
1515
+ } catch (error) {
1516
+ report(error)
1517
+ } finally {
1518
+ setWorking(false)
1519
+ }
1520
+ }
1521
+
1522
+ const run = async () => {
1523
+ if (!sourceFile || !targetFile) {
1524
+ notify(t('align.needFiles'), true)
1525
+ return
1526
+ }
1527
+ setWorking(true)
1528
+ try {
1529
+ const aligned = await rpc('alignUploads', {
1530
+ sourceFile: { name: sourceFile.name, text: sourceFile.text },
1531
+ targetFile: { name: targetFile.name, text: targetFile.text },
1532
+ sourceLang,
1533
+ targetLang,
1534
+ })
1535
+ setResult(aligned)
1536
+ setRows(aligned.rows.map((row) => ({ ...row, include: row.confidence === 'high' })))
1537
+ } catch (error) {
1538
+ report(error)
1539
+ } finally {
1540
+ setWorking(false)
1541
+ }
1542
+ }
1543
+
1544
+ const updateRow = (at, patch) =>
1545
+ setRows((current) => current.map((row, position) => (position === at ? { ...row, ...patch } : row)))
1546
+
1547
+ const removeRow = (at) => setRows((current) => current.filter((_row, position) => position !== at))
1548
+
1549
+ const mergeRow = (at) =>
1550
+ setRows((current) => {
1551
+ const head = current[at]
1552
+ const next = current[at + 1]
1553
+ if (!head || !next) return current
1554
+ const merged = {
1555
+ ...head,
1556
+ target: [head.target, next.target].filter(Boolean).join(' '),
1557
+ include: head.include,
1558
+ merged: true,
1559
+ }
1560
+ return [...current.slice(0, at), merged, ...current.slice(at + 2)]
1561
+ })
1562
+
1563
+ const splitRow = (at) =>
1564
+ setRows((current) => {
1565
+ const row = current[at]
1566
+ if (!row || !row.target) return current
1567
+ const parts = String(row.target).split(/\n+/).filter(Boolean)
1568
+ if (parts.length < 2) return current
1569
+ const replacement = parts.map((part) => ({ ...row, target: part }))
1570
+ return [...current.slice(0, at), ...replacement, ...current.slice(at + 1)]
1571
+ })
1572
+
1573
+ const write = async () => {
1574
+ const pairs = rows
1575
+ .filter((row) => row.include && row.source && row.target)
1576
+ .map((row) => ({ source: row.source, target: row.target, sourceLang, targetLang }))
1577
+ if (!pairs.length) {
1578
+ notify(t('align.nothing'), true)
1579
+ return
1580
+ }
1581
+ onConfirm({
1582
+ title: t('align.write'),
1583
+ message: t('align.writeMessage', { count: pairs.length }),
1584
+ action: async () => {
1585
+ setWorking(true)
1586
+ try {
1587
+ const written = await rpc('writeAlignment', { pairs, tmId: tmId || undefined })
1588
+ notify(t('align.written', { written: written.written, skipped: written.skipped }))
1589
+ } catch (error) {
1590
+ report(error)
1591
+ } finally {
1592
+ setWorking(false)
1593
+ }
1594
+ },
1595
+ })
1596
+ }
1597
+
1598
+ const included = rows.filter((row) => row.include && row.source && row.target).length
1599
+ const fileCard = (which, file, ref) =>
1600
+ h(
1601
+ 'div',
1602
+ { className: 'cait-alignfile' },
1603
+ h('span', { className: 'cait-alignlabel' }, t(which === 'source' ? 'align.sourceFile' : 'align.targetFile')),
1604
+ h('span', { className: `cait-alignname${file ? '' : ' empty'}` }, file ? file.name : t('align.pick')),
1605
+ h('span', { className: 'cait-alignmeta' }, file ? t('align.chars', { count: file.chars }) : ''),
1606
+ h('input', {
1607
+ ref,
1608
+ type: 'file',
1609
+ accept: '.txt,.md,.docx,.epub,.html,.htm,.xlsx,.pptx,.csv,.srt,.vtt,.po,.json,.rtf,.xliff',
1610
+ style: { display: 'none' },
1611
+ onChange: (event) => {
1612
+ const chosen = event.target.files && event.target.files[0]
1613
+ pick(which, chosen)
1614
+ event.target.value = ''
1615
+ },
1616
+ }),
1617
+ h(
1618
+ 'button',
1619
+ { type: 'button', className: 'cait-btn', onClick: () => ref.current && ref.current.click(), disabled: working },
1620
+ h(Icon, { name: 'folder', size: 13 }),
1621
+ h('span', { className: 'cait-btn-label' }, t('align.choose')),
1622
+ ),
1623
+ )
1624
+
1625
+ return h(
1626
+ 'div',
1627
+ { className: 'cait-alignpage' },
1628
+ h(
1629
+ 'div',
1630
+ { className: 'cait-alignhead' },
1631
+ h('div', null, h('h2', null, t('align.title')), h('p', { className: 'cait-mt-hint' }, t('align.hint'))),
1632
+ h(
1633
+ 'div',
1634
+ { className: 'cait-alignactions' },
1635
+ h('input', {
1636
+ className: 'cait-libinput',
1637
+ placeholder: t('align.sourceLang'),
1638
+ value: sourceLang,
1639
+ onChange: (event) => setSourceLang(event.target.value),
1640
+ }),
1641
+ h('input', {
1642
+ className: 'cait-libinput',
1643
+ placeholder: t('align.targetLang'),
1644
+ value: targetLang,
1645
+ onChange: (event) => setTargetLang(event.target.value),
1646
+ }),
1647
+ h(
1648
+ 'select',
1649
+ {
1650
+ className: 'cait-select cait-liblang',
1651
+ value: tmId,
1652
+ onChange: (event) => setTmId(event.target.value),
1653
+ },
1654
+ libraries.map((library) => h('option', { key: library.id, value: library.id }, library.name)),
1655
+ ),
1656
+ h(
1657
+ 'button',
1658
+ { type: 'button', className: 'cait-btn cait-btn-primary', onClick: run, disabled: working || !sourceFile || !targetFile },
1659
+ h(Icon, { name: 'align', size: 13 }),
1660
+ h('span', { className: 'cait-btn-label' }, working ? t('align.running') : t('align.run')),
1661
+ ),
1662
+ ),
1663
+ ),
1664
+ h('div', { className: 'cait-alignfiles' }, fileCard('source', sourceFile, sourceRef), fileCard('target', targetFile, targetRef)),
1665
+ result
1666
+ ? h(
1667
+ 'div',
1668
+ { className: 'cait-alignstats' },
1669
+ h('span', null, t('align.stats', {
1670
+ high: result.high,
1671
+ medium: result.medium,
1672
+ low: result.low,
1673
+ })),
1674
+ h('span', null, `${t('align.unpairedSource')}: ${result.unpairedSource}`),
1675
+ h('span', null, `${t('align.unpairedTarget')}: ${result.unpairedTarget}`),
1676
+ result.blocked ? h('span', { className: 'cait-alignwarn' }, t('align.blocked')) : null,
1677
+ h(
1678
+ 'button',
1679
+ { type: 'button', className: 'cait-btn', onClick: write, disabled: working || !included },
1680
+ h(Icon, { name: 'check', size: 13 }),
1681
+ h('span', { className: 'cait-btn-label' }, t('align.writeCount', { count: included })),
1682
+ ),
1683
+ )
1684
+ : null,
1685
+ rows.length
1686
+ ? h(
1687
+ 'div',
1688
+ { className: 'cait-libtable-wrap cait-aligntable-wrap' },
1689
+ h(
1690
+ 'table',
1691
+ { className: 'cait-libtable cait-aligntable' },
1692
+ h(
1693
+ 'thead',
1694
+ null,
1695
+ h(
1696
+ 'tr',
1697
+ null,
1698
+ h('th', null, ''),
1699
+ h('th', null, t('align.sourceFile')),
1700
+ h('th', null, t('align.targetFile')),
1701
+ h('th', null, t('align.confidence')),
1702
+ h('th', null, ''),
1703
+ ),
1704
+ ),
1705
+ h(
1706
+ 'tbody',
1707
+ null,
1708
+ rows.map((row, at) =>
1709
+ h(
1710
+ 'tr',
1711
+ { key: `${row.sourceIndex}-${row.targetIndex}-${at}`, className: row.include ? 'included' : '' },
1712
+ h(
1713
+ 'td',
1714
+ null,
1715
+ h('input', {
1716
+ type: 'checkbox',
1717
+ checked: Boolean(row.include),
1718
+ onChange: (event) => updateRow(at, { include: event.target.checked }),
1719
+ }),
1720
+ ),
1721
+ h('td', null, row.source),
1722
+ h('td', null, row.target || h('span', { className: 'cait-alignmissing' }, '—')),
1723
+ h(
1724
+ 'td',
1725
+ null,
1726
+ h('span', { className: `cait-confidence ${row.confidence}` }, `${Math.round(row.score * 100)}%`),
1727
+ ),
1728
+ h(
1729
+ 'td',
1730
+ { className: 'cait-alignrow-actions' },
1731
+ h(
1732
+ 'button',
1733
+ { type: 'button', className: 'cait-minibtn', title: t('align.merge'), onClick: () => mergeRow(at) },
1734
+ h(Icon, { name: 'chevronDown', size: 12 }),
1735
+ ),
1736
+ h(
1737
+ 'button',
1738
+ { type: 'button', className: 'cait-minibtn', title: t('align.split'), onClick: () => splitRow(at) },
1739
+ h(Icon, { name: 'chevronRight', size: 12 }),
1740
+ ),
1741
+ h(
1742
+ 'button',
1743
+ { type: 'button', className: 'cait-minibtn cait-deletebtn', title: t('align.remove'), onClick: () => removeRow(at) },
1744
+ h(Icon, { name: 'trash', size: 12 }),
1745
+ ),
1746
+ ),
1747
+ ),
1748
+ ),
1749
+ ),
1750
+ ),
1751
+ )
1752
+ : null,
1753
+ )
1754
+ }
1755
+
1756
+ function LibraryPage({
1757
+ t,
1758
+ data,
1759
+ settings,
1760
+ busy,
1761
+ notify,
1762
+ report,
1763
+ seed,
1764
+ onApplyState,
1765
+ onConfirm,
1766
+ onCreateLibrary,
1767
+ onRenameLibrary,
1768
+ onDeleteLibrary,
1769
+ }) {
1770
+ const storage = typeof localStorage !== 'undefined' ? localStorage : null
1771
+ const [kind, setKind] = useState(seed?.kind === 'glossary' ? 'glossary' : 'tm')
1772
+ const [scopeIds, setScopeIds] = useState([])
1773
+ const [query, setQuery] = useState(seed?.query || '')
1774
+ const [debouncedQuery, setDebouncedQuery] = useState('')
1775
+ const [lang, setLang] = useState('')
1776
+ const [page, setPage] = useState(1)
1777
+ const [pageSize, setPageSize] = useState(() => readPageSize(storage))
1778
+ const [rows, setRows] = useState(null)
1779
+ const [loading, setLoading] = useState(false)
1780
+ const [working, setWorking] = useState('')
1781
+ const [selectedId, setSelectedId] = useState('')
1782
+ const [draft, setDraft] = useState(null)
1783
+ const [renamingId, setRenamingId] = useState('')
1784
+ const [renameText, setRenameText] = useState('')
1785
+ const [newLibraryName, setNewLibraryName] = useState('')
1786
+ const [adding, setAdding] = useState(false)
1787
+ const [exportFormat, setExportFormat] = useState('tbx')
1788
+ const [terms, setTerms] = useState(null)
1789
+ const fileRef = useRef(null)
1790
+ const importMode = useRef('')
1791
+
1792
+ const libraries = (kind === 'tm' ? data?.libraries?.tm : data?.libraries?.glossary) || []
1793
+ const idKey = kind === 'tm' ? 'tmId' : 'glossaryId'
1794
+ const labelKey = 'name'
1795
+ const scopeKey = scopeIds.join(',')
1796
+ const targetLibraryId = scopeIds[0] || (kind === 'tm' ? data?.mounts?.defaultTmId : data?.mounts?.defaultGlossaryId) || libraries[0]?.[idKey] || ''
1797
+
1798
+ useEffect(() => {
1799
+ const timer = setTimeout(() => setDebouncedQuery(query), 250)
1800
+ return () => clearTimeout(timer)
1801
+ }, [query])
1802
+
1803
+ // The panel can jump into the library with its current search text; a
1804
+ // fresh nonce means "apply this seed now" even when the values repeat.
1805
+ useEffect(() => {
1806
+ if (!seed) return
1807
+ setKind(seed.kind === 'glossary' ? 'glossary' : 'tm')
1808
+ setQuery(seed.query || '')
1809
+ setPage(1)
1810
+ }, [seed?.nonce])
1811
+
1812
+ // Switching kind resets the scope: library ids are not interchangeable.
1813
+ useEffect(() => {
1814
+ setScopeIds([])
1815
+ setPage(1)
1816
+ setSelectedId('')
1817
+ setLang('')
1818
+ }, [kind])
1819
+
1820
+ const loadRows = async () => {
1821
+ setLoading(true)
1822
+ try {
1823
+ const params =
1824
+ kind === 'tm'
1825
+ ? { tmIds: scopeIds, query: debouncedQuery, page, pageSize }
1826
+ : { glossaryIds: scopeIds, query: debouncedQuery, page, pageSize }
1827
+ if (lang) params.sourceLang = lang
1828
+ const result = await rpc(kind === 'tm' ? 'listTmEntries' : 'listGlossaryEntries', params)
1829
+ setRows(result)
1830
+ if (result.page !== page) setPage(result.page)
1831
+ } catch (error) {
1832
+ report(error)
1833
+ setRows(null)
1834
+ } finally {
1835
+ setLoading(false)
1836
+ }
1837
+ }
1838
+
1839
+ useEffect(() => {
1840
+ let alive = true
1841
+ const run = async () => {
1842
+ if (!alive) return
1843
+ await loadRows()
1844
+ }
1845
+ run()
1846
+ return () => {
1847
+ alive = false
1848
+ }
1849
+ }, [kind, scopeKey, debouncedQuery, lang, page, pageSize, data?.revision])
1850
+
1851
+ useEffect(() => {
1852
+ const entry = (rows?.entries || []).find((item) => item.id === selectedId)
1853
+ setDraft(entry ? { ...entry } : null)
1854
+ }, [selectedId, rows])
1855
+
1856
+ const dirty =
1857
+ draft &&
1858
+ rows?.entries?.some(
1859
+ (item) =>
1860
+ item.id === draft.id &&
1861
+ (item.source !== draft.source ||
1862
+ item.target !== draft.target ||
1863
+ (item.note || '') !== (draft.note || '') ||
1864
+ Boolean(item.caseSensitive) !== Boolean(draft.caseSensitive)),
1865
+ )
1866
+
1867
+ const selectKind = (next) => setKind(next)
1868
+
1869
+ const mineTerms = async () => {
1870
+ const active = data?.activeDocument
1871
+ if (!active) {
1872
+ notify(t('term.noDoc'), true)
1873
+ return
1874
+ }
1875
+ setWorking('terms')
1876
+ try {
1877
+ const mined = await rpc('termCandidates', { documentId: active.id, limit: 60 })
1878
+ setTerms(mined.candidates.map((candidate) => ({
1879
+ ...candidate,
1880
+ picked: false,
1881
+ target: candidate.evidence[0] || '',
1882
+ })))
1883
+ } catch (error) {
1884
+ report(error)
1885
+ } finally {
1886
+ setWorking('')
1887
+ }
1888
+ }
1889
+
1890
+ const toggleTerm = (index) =>
1891
+ setTerms((current) =>
1892
+ current.map((item, position) => (position === index ? { ...item, picked: !item.picked } : item)),
1893
+ )
1894
+
1895
+ const setTermTarget = (index, value) =>
1896
+ setTerms((current) =>
1897
+ current.map((item, position) => (position === index ? { ...item, target: value, picked: true } : item)),
1898
+ )
1899
+
1900
+ const addMinedTerms = async () => {
1901
+ const picked = (terms || []).filter((item) => item.picked && item.target.trim())
1902
+ if (!picked.length) return
1903
+ setWorking('terms')
1904
+ try {
1905
+ const result = await rpc('addGlossaryEntries', {
1906
+ glossaryId: targetLibraryId || undefined,
1907
+ entries: picked.map((item) => ({
1908
+ source: item.term,
1909
+ target: item.target.trim(),
1910
+ sourceLang: settings.sourceLang,
1911
+ })),
1912
+ })
1913
+ if (result?.state) onApplyState(result.state)
1914
+ notify(t('term.added').replace('{added}', String(result.added)))
1915
+ setTerms(null)
1916
+ await loadRows()
1917
+ } catch (error) {
1918
+ report(error)
1919
+ } finally {
1920
+ setWorking('')
1921
+ }
1922
+ }
1923
+
1924
+ const toggleScope = (id) => {
1925
+ setPage(1)
1926
+ setScopeIds((current) => (current.includes(id) ? current.filter((item) => item !== id) : [...current, id]))
1927
+ }
1928
+
1929
+ const allLanguages = rows?.languages
1930
+ ? [...new Set([...(rows.languages.source || []), ...(rows.languages.target || [])])].filter(Boolean)
1931
+ : []
1932
+
1933
+ const changePageSize = (value) => {
1934
+ const size = writePageSize(storage, value)
1935
+ setPageSize(size)
1936
+ setPage(1)
1937
+ }
1938
+
1939
+ const saveEntry = async () => {
1940
+ if (!draft) return
1941
+ setWorking('save')
1942
+ try {
1943
+ const result =
1944
+ kind === 'tm'
1945
+ ? await rpc('updateTM', {
1946
+ id: draft.id,
1947
+ source: draft.source,
1948
+ target: draft.target,
1949
+ note: draft.note,
1950
+ })
1951
+ : await rpc('updateGlossary', {
1952
+ id: draft.id,
1953
+ source: draft.source,
1954
+ target: draft.target,
1955
+ note: draft.note,
1956
+ sourceLang: draft.sourceLang,
1957
+ caseSensitive: Boolean(draft.caseSensitive),
1958
+ })
1959
+ if (result?.state) onApplyState(result.state)
1960
+ notify(t('library.entrySaved'))
1961
+ setAdding(false)
1962
+ await loadRows()
1963
+ } catch (error) {
1964
+ report(error)
1965
+ } finally {
1966
+ setWorking('')
1967
+ }
1968
+ }
1969
+
1970
+ const addEntry = async () => {
1971
+ if (!draft) return
1972
+ setWorking('add')
1973
+ try {
1974
+ const result =
1975
+ kind === 'tm'
1976
+ ? await rpc('addTM', {
1977
+ source: draft.source,
1978
+ target: draft.target,
1979
+ tmId: targetLibraryId,
1980
+ sourceLang: draft.sourceLang || settings.sourceLang,
1981
+ targetLang: draft.targetLang || settings.targetLang,
1982
+ })
1983
+ : await rpc('addGlossary', {
1984
+ source: draft.source,
1985
+ target: draft.target,
1986
+ note: draft.note,
1987
+ glossaryId: targetLibraryId,
1988
+ sourceLang: draft.sourceLang || '',
1989
+ caseSensitive: Boolean(draft.caseSensitive),
1990
+ })
1991
+ if (result?.state) onApplyState(result.state)
1992
+ notify(t('library.entryCreated'))
1993
+ setAdding(false)
1994
+ setSelectedId('')
1995
+ await loadRows()
1996
+ } catch (error) {
1997
+ report(error)
1998
+ } finally {
1999
+ setWorking('')
2000
+ }
2001
+ }
2002
+
2003
+ const deleteEntry = () => {
2004
+ if (!draft || adding) return
2005
+ onConfirm({
2006
+ title: t('library.deleteEntry'),
2007
+ message: kind === 'tm' ? t('modal.confirm.deleteTm') : t('library.deleteEntry'),
2008
+ confirmLabel: t('library.deleteEntry'),
2009
+ action: async () => {
2010
+ const result = await rpc(kind === 'tm' ? 'deleteTM' : 'deleteGlossary', { id: draft.id })
2011
+ if (result?.state) onApplyState(result.state)
2012
+ setSelectedId('')
2013
+ notify(t('library.entryDeleted'))
2014
+ await loadRows()
2015
+ },
2016
+ })
2017
+ }
2018
+
2019
+ const clearLibrary = () => {
2020
+ if (!targetLibraryId) return
2021
+ onConfirm({
2022
+ title: t('library.clear'),
2023
+ message: t('modal.confirm.clearTm'),
2024
+ confirmLabel: t('library.clear'),
2025
+ action: async () => {
2026
+ const result =
2027
+ kind === 'tm'
2028
+ ? await rpc('clearTM', { tmIds: [targetLibraryId], confirm: true })
2029
+ : await rpc('clearGlossary', { glossaryIds: [targetLibraryId], confirm: true })
2030
+ if (result?.state) onApplyState(result.state)
2031
+ setSelectedId('')
2032
+ await loadRows()
2033
+ },
2034
+ })
2035
+ }
2036
+
2037
+ const exportLibrary = async () => {
2038
+ if (!targetLibraryId) return
2039
+ setWorking('export')
2040
+ try {
2041
+ const result =
2042
+ kind === 'tm'
2043
+ ? await rpc('exportTM', { tmId: targetLibraryId, format: 'tmx' })
2044
+ : await rpc('exportGlossary', { glossaryId: targetLibraryId, format: exportFormat })
2045
+ downloadFile(result.filename, result.content, result.mime)
2046
+ notify(t('library.exported'))
2047
+ } catch (error) {
2048
+ report(error)
2049
+ } finally {
2050
+ setWorking('')
2051
+ }
2052
+ }
2053
+
2054
+ const pickImport = (mode) => {
2055
+ importMode.current = mode
2056
+ if (fileRef.current) {
2057
+ fileRef.current.value = ''
2058
+ fileRef.current.click()
2059
+ }
2060
+ }
2061
+
2062
+ const runImport = async (file) => {
2063
+ if (!file || !targetLibraryId) return
2064
+ setWorking('import')
2065
+ try {
2066
+ const text = await file.text()
2067
+ if (importMode.current === 'glossary') {
2068
+ const result = await rpc('importGlossary', { text, glossaryId: targetLibraryId })
2069
+ if (result?.state) onApplyState(result.state)
2070
+ notify(`${t('toast.glossaryImported')} · +${result.added} / ~${result.updated}`)
2071
+ } else {
2072
+ const result = await rpc('importTmx', {
2073
+ text,
2074
+ importAs: 'tm',
2075
+ tmId: targetLibraryId,
2076
+ sourceLang: kind === 'tm' ? undefined : settings.sourceLang,
2077
+ targetLang: settings.targetLang,
2078
+ })
2079
+ if (result?.state) onApplyState(result.state)
2080
+ notify(`${t('toast.tmImported')} · +${result.added} / ~${result.updated}`)
2081
+ }
2082
+ await loadRows()
2083
+ } catch (error) {
2084
+ report(error)
2085
+ } finally {
2086
+ setWorking('')
2087
+ }
2088
+ }
2089
+
2090
+ const submitRename = async (library) => {
2091
+ const name = renameText.trim()
2092
+ setRenamingId('')
2093
+ if (!name || name === library[labelKey]) return
2094
+ await onRenameLibrary({ kind, id: library[idKey], name })
2095
+ }
2096
+
2097
+ const submitNewLibrary = async () => {
2098
+ const name = newLibraryName.trim()
2099
+ if (!name) return
2100
+ const ok = await onCreateLibrary({ kind, name })
2101
+ if (ok) setNewLibraryName('')
2102
+ }
2103
+
2104
+ const confirmDeleteLibrary = (library) => {
2105
+ if (libraries.length <= 1) return
2106
+ onConfirm({
2107
+ title: t('library.deleteLibrary'),
2108
+ message: t('library.deleteLibraryMessage', { name: library[labelKey] }),
2109
+ confirmLabel: t('library.deleteLibrary'),
2110
+ action: async () => {
2111
+ await onDeleteLibrary({ kind, id: library[idKey], name: library[labelKey] })
2112
+ if (scopeIds.includes(library[idKey])) {
2113
+ setScopeIds((current) => current.filter((item) => item !== library[idKey]))
2114
+ }
2115
+ },
2116
+ })
2117
+ }
2118
+
2119
+ const startAdding = () => {
2120
+ setAdding(true)
2121
+ setSelectedId('')
2122
+ setDraft(
2123
+ kind === 'tm'
2124
+ ? {
2125
+ id: '',
2126
+ source: '',
2127
+ target: '',
2128
+ note: '',
2129
+ sourceLang: settings.sourceLang,
2130
+ targetLang: settings.targetLang,
2131
+ }
2132
+ : { id: '', source: '', target: '', note: '', sourceLang: settings.sourceLang, caseSensitive: false },
2133
+ )
2134
+ }
2135
+
2136
+ const bounds = pageBounds(rows?.page || 1, rows?.total || 0, rows?.pageSize || pageSize)
2137
+ const contextLabel = (entry) =>
2138
+ entry.context ? `${entry.context.prev || '—'} · ${entry.context.next || '—'}` : '—'
2139
+ const columns = kind === 'tm'
2140
+ ? [t('library.colSource'), t('library.colTarget'), t('library.colNote'), t('library.colContext'), t('library.colLang'), t('library.colUpdated')]
2141
+ : [t('library.colSource'), t('library.colTarget'), t('library.colNote'), t('library.colLang'), t('library.colCase'), t('library.colUpdated')]
2142
+
2143
+ return h(
2144
+ 'div',
2145
+ { className: 'cait-libpage' },
2146
+ h(
2147
+ 'div',
2148
+ { className: 'cait-libpage-head' },
2149
+ h(
2150
+ 'div',
2151
+ { className: 'cait-libtabs' },
2152
+ h(
2153
+ 'button',
2154
+ { className: `cait-libtabs-item${kind === 'tm' ? ' active' : ''}`, onClick: () => selectKind('tm') },
2155
+ t('library.tmTab'),
2156
+ h('span', { className: 'cait-libtabs-count' }, data?.libraries?.tm?.length || 0),
2157
+ ),
2158
+ h(
2159
+ 'button',
2160
+ { className: `cait-libtabs-item${kind === 'glossary' ? ' active' : ''}`, onClick: () => selectKind('glossary') },
2161
+ t('library.glossaryTab'),
2162
+ h('span', { className: 'cait-libtabs-count' }, data?.libraries?.glossary?.length || 0),
2163
+ ),
2164
+ ),
2165
+ h('span', { className: 'cait-libhint' }, t('library.noDocHint')),
2166
+ ),
2167
+ h(
2168
+ 'div',
2169
+ { className: 'cait-libpage-body' },
2170
+ h(
2171
+ 'div',
2172
+ { className: 'cait-liblist' },
2173
+ h(
2174
+ 'div',
2175
+ { className: 'cait-liblist-head' },
2176
+ t('library.title'),
2177
+ ),
2178
+ libraries.map((library) =>
2179
+ h(
2180
+ 'div',
2181
+ { key: library[idKey], className: `cait-librow${scopeIds.includes(library[idKey]) ? ' scoped' : ''}` },
2182
+ h('input', {
2183
+ type: 'checkbox',
2184
+ checked: scopeIds.includes(library[idKey]),
2185
+ onChange: () => toggleScope(library[idKey]),
2186
+ title: t('library.scope'),
2187
+ 'aria-label': t('library.scope'),
2188
+ }),
2189
+ renamingId === library[idKey]
2190
+ ? h('input', {
2191
+ className: 'cait-librename',
2192
+ value: renameText,
2193
+ autoFocus: true,
2194
+ onChange: (event) => setRenameText(event.target.value),
2195
+ onKeyDown: (event) => {
2196
+ if (event.key === 'Enter') submitRename(library)
2197
+ if (event.key === 'Escape') setRenamingId('')
2198
+ },
2199
+ onBlur: () => submitRename(library),
2200
+ })
2201
+ : h(
2202
+ 'button',
2203
+ {
2204
+ type: 'button',
2205
+ className: 'cait-libname',
2206
+ onClick: () => toggleScope(library[idKey]),
2207
+ title: library[labelKey],
2208
+ },
2209
+ h('span', { className: 'cait-libname-text' }, library[labelKey]),
2210
+ h('span', { className: 'cait-libcount' }, library.entryCount),
2211
+ library.mounted ? h('span', { className: 'cait-libmounted' }, '●') : null,
2212
+ ),
2213
+ h(
2214
+ 'span',
2215
+ { className: 'cait-libactions' },
2216
+ h(
2217
+ 'button',
2218
+ {
2219
+ type: 'button',
2220
+ className: 'cait-minibtn',
2221
+ title: t('library.rename'),
2222
+ onClick: () => {
2223
+ setRenamingId(library[idKey])
2224
+ setRenameText(library[labelKey])
2225
+ },
2226
+ },
2227
+ h(Icon, { name: 'pencil', size: 12 }),
2228
+ ),
2229
+ h(
2230
+ 'button',
2231
+ {
2232
+ type: 'button',
2233
+ className: 'cait-deletebtn',
2234
+ title: t('library.deleteLibrary'),
2235
+ disabled: libraries.length <= 1,
2236
+ onClick: () => confirmDeleteLibrary(library),
2237
+ },
2238
+ h(Icon, { name: 'trash', size: 12 }),
2239
+ ),
2240
+ ),
2241
+ ),
2242
+ ),
2243
+ h(
2244
+ 'div',
2245
+ { className: 'cait-libnew' },
2246
+ h('input', {
2247
+ value: newLibraryName,
2248
+ placeholder: t('library.newLibrary'),
2249
+ onChange: (event) => setNewLibraryName(event.target.value),
2250
+ onKeyDown: (event) => {
2251
+ if (event.key === 'Enter') submitNewLibrary()
2252
+ },
2253
+ }),
2254
+ h(
2255
+ 'button',
2256
+ { type: 'button', className: 'cait-minibtn', onClick: submitNewLibrary, disabled: !newLibraryName.trim() },
2257
+ h(Icon, { name: 'plus', size: 12 }),
2258
+ ),
2259
+ ),
2260
+ ),
2261
+ h(
2262
+ 'div',
2263
+ { className: 'cait-libmain' },
2264
+ h(
2265
+ 'div',
2266
+ { className: 'cait-libtoolbar' },
2267
+ h(
2268
+ 'div',
2269
+ { className: 'cait-libsearch' },
2270
+ h(Icon, { name: 'search', size: 13 }),
2271
+ h('input', {
2272
+ value: query,
2273
+ onChange: (event) => {
2274
+ setQuery(event.target.value)
2275
+ setPage(1)
2276
+ },
2277
+ placeholder: t('library.search'),
2278
+ 'aria-label': t('library.search'),
2279
+ }),
2280
+ ),
2281
+ h(
2282
+ 'select',
2283
+ {
2284
+ className: 'cait-liblang',
2285
+ value: lang,
2286
+ onChange: (event) => {
2287
+ setLang(event.target.value)
2288
+ setPage(1)
2289
+ },
2290
+ title: t('library.language'),
2291
+ 'aria-label': t('library.language'),
2292
+ },
2293
+ h('option', { value: '' }, t('library.language')),
2294
+ allLanguages.map((code) => h('option', { key: code, value: code }, code)),
2295
+ ),
2296
+ kind === 'glossary'
2297
+ ? h(
2298
+ 'select',
2299
+ {
2300
+ className: 'cait-liblang',
2301
+ value: exportFormat,
2302
+ onChange: (event) => setExportFormat(event.target.value),
2303
+ title: t('library.exportFormat'),
2304
+ 'aria-label': t('library.exportFormat'),
2305
+ },
2306
+ ['tbx', 'csv', 'json'].map((format) => h('option', { key: format, value: format }, format.toUpperCase())),
2307
+ )
2308
+ : null,
2309
+ kind === 'glossary'
2310
+ ? h(
2311
+ 'button',
2312
+ {
2313
+ type: 'button',
2314
+ className: 'cait-btn',
2315
+ onClick: mineTerms,
2316
+ disabled: Boolean(busy) || working === 'terms' || !data?.activeDocument,
2317
+ title: t('term.extract'),
2318
+ },
2319
+ h(Icon, { name: 'sparkles', size: 13 }),
2320
+ h('span', { className: 'cait-btn-label' }, t('term.extract')),
2321
+ )
2322
+ : null,
2323
+ h(
2324
+ 'button',
2325
+ { type: 'button', className: 'cait-btn', onClick: startAdding, disabled: Boolean(busy) },
2326
+ h(Icon, { name: 'plus', size: 13 }),
2327
+ h('span', { className: 'cait-btn-label' }, t('library.add')),
2328
+ ),
2329
+ h(
2330
+ 'button',
2331
+ {
2332
+ type: 'button',
2333
+ className: 'cait-btn',
2334
+ onClick: () => pickImport(kind),
2335
+ disabled: Boolean(busy) || !targetLibraryId,
2336
+ title: t('library.import'),
2337
+ },
2338
+ h(Icon, { name: 'upload', size: 13 }),
2339
+ h('span', { className: 'cait-btn-label' }, t('library.import')),
2340
+ ),
2341
+ h(
2342
+ 'button',
2343
+ {
2344
+ type: 'button',
2345
+ className: 'cait-btn',
2346
+ onClick: exportLibrary,
2347
+ disabled: Boolean(busy) || working === 'export' || !targetLibraryId,
2348
+ title: t('library.export'),
2349
+ },
2350
+ h(Icon, { name: 'download', size: 13 }),
2351
+ h('span', { className: 'cait-btn-label' }, kind === 'tm' ? 'TMX' : exportFormat.toUpperCase()),
2352
+ ),
2353
+ h(
2354
+ 'button',
2355
+ {
2356
+ type: 'button',
2357
+ className: 'cait-btn',
2358
+ onClick: clearLibrary,
2359
+ disabled: Boolean(busy) || !targetLibraryId,
2360
+ title: t('library.clear'),
2361
+ },
2362
+ h(Icon, { name: 'trash', size: 13 }),
2363
+ ),
2364
+ h('input', {
2365
+ ref: fileRef,
2366
+ type: 'file',
2367
+ accept: kind === 'tm' ? '.tmx' : '.tbx,.csv,.json',
2368
+ style: { display: 'none' },
2369
+ onChange: (event) => {
2370
+ const file = event.target.files && event.target.files[0]
2371
+ if (file) runImport(file)
2372
+ },
2373
+ }),
2374
+ ),
2375
+ h(
2376
+ 'div',
2377
+ { className: 'cait-libscope' },
2378
+ h(
2379
+ 'button',
2380
+ {
2381
+ type: 'button',
2382
+ className: `cait-filterchip${scopeIds.length === 0 ? ' active' : ''}`,
2383
+ onClick: () => {
2384
+ setScopeIds([])
2385
+ setPage(1)
2386
+ },
2387
+ },
2388
+ t('library.scopeAll'),
2389
+ ),
2390
+ libraries.map((library) =>
2391
+ h(
2392
+ 'button',
2393
+ {
2394
+ key: library[idKey],
2395
+ type: 'button',
2396
+ className: `cait-filterchip${scopeIds.includes(library[idKey]) ? ' active' : ''}`,
2397
+ onClick: () => toggleScope(library[idKey]),
2398
+ },
2399
+ library[labelKey],
2400
+ ),
2401
+ ),
2402
+ ),
2403
+ h(
2404
+ 'div',
2405
+ { className: 'cait-libtable-wrap' },
2406
+ loading && !rows?.entries?.length
2407
+ ? h('div', { className: 'cait-center' }, h(Spinner), t('common.loading'))
2408
+ : rows?.entries?.length
2409
+ ? h(
2410
+ 'table',
2411
+ { className: 'cait-libtable' },
2412
+ h('thead', null, h('tr', null, columns.map((column) => h('th', { key: column }, column)))),
2413
+ h(
2414
+ 'tbody',
2415
+ null,
2416
+ rows.entries.map((entry) =>
2417
+ h(
2418
+ 'tr',
2419
+ {
2420
+ key: entry.id,
2421
+ className: entry.id === selectedId ? 'active' : '',
2422
+ onClick: () => {
2423
+ setAdding(false)
2424
+ setSelectedId(entry.id)
2425
+ },
2426
+ },
2427
+ h('td', { className: 'src', title: entry.source }, entry.source),
2428
+ h('td', { className: 'tgt', title: entry.target }, entry.target),
2429
+ h('td', { className: 'note' }, entry.note || ''),
2430
+ kind === 'tm'
2431
+ ? h(
2432
+ 'td',
2433
+ { className: 'ctx', title: contextLabel(entry) },
2434
+ contextLabel(entry),
2435
+ )
2436
+ : null,
2437
+ h(
2438
+ 'td',
2439
+ { className: 'lang' },
2440
+ kind === 'tm' ? `${entry.sourceLang} → ${entry.targetLang}` : entry.sourceLang || '—',
2441
+ ),
2442
+ kind === 'glossary'
2443
+ ? h('td', { className: 'case' }, entry.caseSensitive ? '✓' : '')
2444
+ : null,
2445
+ h('td', { className: 'upd' }, String(entry.updatedAt || '').slice(0, 10)),
2446
+ ),
2447
+ ),
2448
+ ),
2449
+ )
2450
+ : h('div', { className: 'cait-empty-note' }, query || lang ? t('library.noMatch') : t('library.empty')),
2451
+ ),
2452
+ h(
2453
+ 'div',
2454
+ { className: 'cait-rail-page cait-libpage-bar' },
2455
+ h(
2456
+ 'button',
2457
+ {
2458
+ type: 'button',
2459
+ className: 'cait-pagebtn',
2460
+ onClick: () => setPage(Math.max(1, (rows?.page || 1) - 1)),
2461
+ disabled: (rows?.page || 1) <= 1,
2462
+ title: t('library.prev'),
2463
+ 'aria-label': t('library.prev'),
2464
+ },
2465
+ h(Icon, { name: 'chevronLeft', size: 12 }),
2466
+ ),
2467
+ h('span', { className: 'cait-pageinfo' }, t('library.pageInfo', { start: bounds.start, end: bounds.end, total: bounds.total })),
2468
+ h(
2469
+ 'button',
2470
+ {
2471
+ type: 'button',
2472
+ className: 'cait-pagebtn',
2473
+ onClick: () => setPage((rows?.page || 1) + 1),
2474
+ disabled: (rows?.page || 1) >= (rows?.totalPages || 1),
2475
+ title: t('library.next'),
2476
+ 'aria-label': t('library.next'),
2477
+ },
2478
+ h(Icon, { name: 'chevronRight', size: 12 }),
2479
+ ),
2480
+ h(
2481
+ 'select',
2482
+ {
2483
+ className: 'cait-pagesize',
2484
+ value: String(pageSize),
2485
+ onChange: (event) => changePageSize(Number(event.target.value)),
2486
+ title: t('library.pageSize'),
2487
+ 'aria-label': t('library.pageSize'),
2488
+ },
2489
+ PAGE_SIZE_OPTIONS.map((size) => h('option', { key: size, value: String(size) }, String(size))),
2490
+ ),
2491
+ ),
2492
+ ),
1079
2493
  h(
1080
- 'button',
1081
- { className: 'cait-btn cait-btn-primary', type: 'submit', disabled: Boolean(busy) || !canSubmit },
1082
- busy === 'create' ? h(Spinner) : h(Icon, { name: 'plus', size: 13 }),
1083
- busy === 'create' ? t('modal.new.importing') : submitLabel,
2494
+ 'aside',
2495
+ { className: 'cait-libdetail' },
2496
+ h('div', { className: 'cait-libdetail-title' }, t('library.detail')),
2497
+ draft
2498
+ ? h(
2499
+ 'div',
2500
+ { className: 'cait-libform' },
2501
+ h('label', null, t('library.colSource')),
2502
+ h('textarea', {
2503
+ className: 'cait-libinput',
2504
+ value: draft.source,
2505
+ onChange: (event) => setDraft({ ...draft, source: event.target.value }),
2506
+ }),
2507
+ h('label', null, t('library.colTarget')),
2508
+ h('textarea', {
2509
+ className: 'cait-libinput',
2510
+ value: draft.target,
2511
+ onChange: (event) => setDraft({ ...draft, target: event.target.value }),
2512
+ }),
2513
+ h('label', null, t('library.colNote')),
2514
+ h('input', {
2515
+ className: 'cait-libinput',
2516
+ value: draft.note || '',
2517
+ onChange: (event) => setDraft({ ...draft, note: event.target.value }),
2518
+ }),
2519
+ h('label', null, t('library.colLang')),
2520
+ h('input', {
2521
+ className: 'cait-libinput',
2522
+ value: draft.sourceLang || '',
2523
+ disabled: adding,
2524
+ onChange: (event) => setDraft({ ...draft, sourceLang: event.target.value }),
2525
+ }),
2526
+ kind === 'glossary'
2527
+ ? h(
2528
+ 'label',
2529
+ { className: 'cait-libcheck' },
2530
+ h('input', {
2531
+ type: 'checkbox',
2532
+ checked: Boolean(draft.caseSensitive),
2533
+ onChange: (event) => setDraft({ ...draft, caseSensitive: event.target.checked }),
2534
+ }),
2535
+ h('span', null, t('library.colCase')),
2536
+ )
2537
+ : null,
2538
+ kind === 'glossary' ? h('p', { className: 'cait-libnote' }, t('library.caseSensitiveHint')) : null,
2539
+ h(
2540
+ 'div',
2541
+ { className: 'cait-libform-actions' },
2542
+ h(
2543
+ 'button',
2544
+ {
2545
+ type: 'button',
2546
+ className: 'cait-btn cait-btn-primary',
2547
+ onClick: adding ? addEntry : saveEntry,
2548
+ disabled: Boolean(busy) || working !== '' || !draft.source.trim() || !draft.target.trim(),
2549
+ },
2550
+ adding ? t('library.add') : t('library.save'),
2551
+ ),
2552
+ adding
2553
+ ? h(
2554
+ 'button',
2555
+ {
2556
+ type: 'button',
2557
+ className: 'cait-btn',
2558
+ onClick: () => {
2559
+ setAdding(false)
2560
+ setDraft(null)
2561
+ },
2562
+ },
2563
+ t('panel.cancel'),
2564
+ )
2565
+ : h(
2566
+ 'button',
2567
+ {
2568
+ type: 'button',
2569
+ className: 'cait-btn',
2570
+ onClick: deleteEntry,
2571
+ disabled: Boolean(busy) || working !== '',
2572
+ },
2573
+ h(Icon, { name: 'trash', size: 13 }),
2574
+ t('library.deleteEntry'),
2575
+ ),
2576
+ ),
2577
+ dirty ? h('p', { className: 'cait-libnote' }, t('editor.unsaved')) : null,
2578
+ )
2579
+ : h('div', { className: 'cait-empty-note' }, t('library.detailEmpty')),
1084
2580
  ),
1085
2581
  ),
2582
+ terms
2583
+ ? h(
2584
+ 'div',
2585
+ { className: 'cait-termpanel' },
2586
+ h(
2587
+ 'div',
2588
+ { className: 'cait-termpanel-head' },
2589
+ h('strong', null, t('term.title')),
2590
+ h('span', { className: 'cait-mt-hint' }, t('term.hint')),
2591
+ h('span', { className: 'cait-termpanel-actions' },
2592
+ h(
2593
+ 'button',
2594
+ { type: 'button', className: 'cait-btn cait-btn-primary', onClick: addMinedTerms, disabled: working === 'terms' },
2595
+ h(Icon, { name: 'check', size: 13 }),
2596
+ t('term.add'),
2597
+ ),
2598
+ h('button', { type: 'button', className: 'cait-btn', onClick: () => setTerms(null) }, t('panel.cancel')),
2599
+ ),
2600
+ ),
2601
+ terms.length
2602
+ ? h(
2603
+ 'div',
2604
+ { className: 'cait-termlist' },
2605
+ terms.map((candidate, at) =>
2606
+ h(
2607
+ 'div',
2608
+ { key: `${candidate.term}-${at}`, className: `cait-termrow${candidate.picked ? ' picked' : ''}` },
2609
+ h('input', {
2610
+ type: 'checkbox',
2611
+ checked: Boolean(candidate.picked),
2612
+ onChange: () => toggleTerm(at),
2613
+ }),
2614
+ h('span', { className: 'cait-termname' }, candidate.term),
2615
+ h('span', { className: 'cait-termcount' }, `×${candidate.count}`),
2616
+ candidate.inLibrary ? h('span', { className: 'cait-originchip' }, t('term.inLibrary')) : null,
2617
+ h('input', {
2618
+ className: 'cait-libinput cait-termtarget',
2619
+ placeholder: t('library.colTarget'),
2620
+ value: candidate.target,
2621
+ onChange: (event) => setTermTarget(at, event.target.value),
2622
+ }),
2623
+ ),
2624
+ ),
2625
+ )
2626
+ : h('div', { className: 'cait-empty-note' }, t('term.empty')),
2627
+ )
2628
+ : null,
1086
2629
  )
1087
2630
  }
1088
2631
 
1089
- function NewDocModal({ t, settings, busy, defaultMode, onClose, onCreate }) {
1090
- return h(
1091
- Modal,
1092
- { title: t('modal.new.title'), onClose },
1093
- h(DocumentSourceForm, { t, settings, busy, submitLabel: t('modal.new.create'), onSubmit: onCreate, defaultMode }),
1094
- h(
1095
- 'div',
1096
- { className: 'cait-demo-row' },
1097
- h('span', null, t('modal.new.sample')),
1098
- h('button', { className: 'cait-linkbtn', onClick: () => onCreate({ demo: true }), disabled: Boolean(busy) }, t('modal.new.demo')),
1099
- ),
1100
- )
1101
- }
1102
-
1103
- function ExportModal({ t, busy, onClose, onExport }) {
2632
+ function ExportModal({ t, busy, onClose, onExport, qa }) {
2633
+ const issues = qa?.issueCount || 0
1104
2634
  const items = [
1105
2635
  ['bilingual-md', t('modal.export.bilingualMd'), t('modal.export.bilingual'), 'file'],
1106
2636
  ['bilingual-csv', t('modal.export.bilingualCsv'), t('modal.export.bilingual'), 'file'],
@@ -1126,6 +2656,23 @@
1126
2656
  Modal,
1127
2657
  { title: t('modal.export.title'), onClose, wide: true },
1128
2658
  h('p', { className: 'cait-confirm-text' }, t('modal.export.desc')),
2659
+ issues
2660
+ ? h(
2661
+ 'div',
2662
+ { className: 'cait-qa-banner' },
2663
+ h(Icon, { name: 'alert', size: 12 }),
2664
+ h(
2665
+ 'span',
2666
+ null,
2667
+ `${t('qa.exportBanner').replace('{count}', String(issues))} `,
2668
+ t('qa.counts')
2669
+ .replace('{placeholder}', String(qa.counts.PLACEHOLDER_MISSING))
2670
+ .replace('{number}', String(qa.counts.NUMBER_MISMATCH))
2671
+ .replace('{term}', String(qa.counts.TERM_NOT_USED))
2672
+ .replace('{empty}', String(qa.counts.EMPTY_TARGET)),
2673
+ ),
2674
+ )
2675
+ : null,
1129
2676
  h(
1130
2677
  'div',
1131
2678
  { className: 'cait-export-grid' },
@@ -1328,7 +2875,13 @@
1328
2875
  },
1329
2876
  h('option', { value: 'mock' }, t('mt.mock')),
1330
2877
  routes.map((item) =>
1331
- h('option', { key: item.id, value: item.id }, `${item.name} (${item.id})`),
2878
+ h(
2879
+ 'option',
2880
+ { key: item.id, value: item.id, disabled: item.hasModels === false },
2881
+ item.hasModels === false
2882
+ ? `${item.name} (${item.id}) · ${t('modal.settings.noModels')}`
2883
+ : `${item.name} (${item.id})`,
2884
+ ),
1332
2885
  ),
1333
2886
  routeMissing
1334
2887
  ? h('option', { value: form.mt.provider }, t('modal.settings.routeMissing'))
@@ -1393,139 +2946,6 @@
1393
2946
  )
1394
2947
  }
1395
2948
 
1396
- function LibrariesModal({ t, data, busy, onClose, onSetMounts, onCreate }) {
1397
- const libraries = data.libraries || { tm: [], glossary: [] }
1398
- const mounts = data.mounts || {
1399
- tmIds: [],
1400
- glossaryIds: [],
1401
- defaultTmId: '',
1402
- defaultGlossaryId: '',
1403
- }
1404
- const [tmName, setTmName] = useState('')
1405
- const [glossaryName, setGlossaryName] = useState('')
1406
-
1407
- const toggle = (kind, id, checked) => {
1408
- const current = kind === 'tm' ? mounts.tmIds : mounts.glossaryIds
1409
- const next = computeNextMountIds(current, id, checked)
1410
- if (!next) return
1411
- onSetMounts(kind === 'tm' ? { tmIds: next } : { glossaryIds: next })
1412
- }
1413
-
1414
- const create = async (kind, name, reset) => {
1415
- if (!name.trim()) return
1416
- const ok = await onCreate({ kind, name: name.trim() })
1417
- if (ok) reset('')
1418
- }
1419
-
1420
- const section = (kind, title, list, defaultId, ids) =>
1421
- h(
1422
- 'div',
1423
- { className: 'cait-libsection' },
1424
- h(
1425
- 'div',
1426
- { className: 'cait-libhead' },
1427
- h('h4', { className: 'cait-settings-title' }, title),
1428
- h('span', { className: 'cait-count' }, `${ids.length}/${list.length}`),
1429
- ),
1430
- list.length
1431
- ? list.map((library) => {
1432
- const id = kind === 'tm' ? library.tmId : library.glossaryId
1433
- const mounted = ids.includes(id)
1434
- return h(
1435
- 'div',
1436
- { className: 'cait-librow', key: id },
1437
- h(
1438
- 'label',
1439
- { className: 'cait-libcheck' },
1440
- h('input', {
1441
- type: 'checkbox',
1442
- checked: mounted,
1443
- disabled: isMountToggleDisabled({
1444
- mounted,
1445
- mountedCount: ids.length,
1446
- busy: Boolean(busy),
1447
- }),
1448
- onChange: (event) => toggle(kind, id, event.target.checked),
1449
- }),
1450
- h(
1451
- 'span',
1452
- { className: 'cait-libmeta' },
1453
- h('strong', null, library.name),
1454
- h(
1455
- 'small',
1456
- null,
1457
- `${library.entryCount || 0} ${t('libraries.entries')}${
1458
- library.mounted ? '' : ` · ${t('libraries.notMounted')}`
1459
- }`,
1460
- ),
1461
- ),
1462
- ),
1463
- h(
1464
- 'label',
1465
- { className: 'cait-libdefault' },
1466
- h('input', {
1467
- type: 'radio',
1468
- name: `cait-default-${kind}`,
1469
- checked: defaultId === id,
1470
- disabled: Boolean(busy) || !mounted,
1471
- onChange: () =>
1472
- onSetMounts(
1473
- kind === 'tm'
1474
- ? { defaultTmId: id }
1475
- : { defaultGlossaryId: id },
1476
- ),
1477
- }),
1478
- t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary'),
1479
- ),
1480
- )
1481
- })
1482
- : h('p', { className: 'cait-empty-note' }, t('libraries.empty')),
1483
- h(
1484
- 'form',
1485
- {
1486
- className: 'cait-libcreate',
1487
- onSubmit: (event) => {
1488
- event.preventDefault()
1489
- create(kind, kind === 'tm' ? tmName : glossaryName, kind === 'tm' ? setTmName : setGlossaryName)
1490
- },
1491
- },
1492
- h('input', {
1493
- className: 'cait-input',
1494
- value: kind === 'tm' ? tmName : glossaryName,
1495
- onChange: (event) =>
1496
- kind === 'tm' ? setTmName(event.target.value) : setGlossaryName(event.target.value),
1497
- placeholder: t('libraries.namePlaceholder'),
1498
- disabled: Boolean(busy),
1499
- }),
1500
- h(
1501
- 'button',
1502
- { className: 'cait-btn', type: 'submit', disabled: Boolean(busy) },
1503
- h(Icon, { name: 'plus', size: 12 }),
1504
- t('libraries.create'),
1505
- ),
1506
- ),
1507
- )
1508
-
1509
- return h(
1510
- Modal,
1511
- { title: t('libraries.title'), onClose, wide: true },
1512
- h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('libraries.desc')),
1513
- section('tm', t('libraries.tm'), libraries.tm, mounts.defaultTmId, mounts.tmIds),
1514
- section(
1515
- 'glossary',
1516
- t('libraries.glossary'),
1517
- libraries.glossary,
1518
- mounts.defaultGlossaryId,
1519
- mounts.glossaryIds,
1520
- ),
1521
- h(
1522
- 'div',
1523
- { className: 'cait-modal-actions' },
1524
- h('button', { className: 'cait-btn cait-btn-primary', onClick: onClose }, t('libraries.done')),
1525
- ),
1526
- )
1527
- }
1528
-
1529
2949
  function ConfirmModal({ t, state, busy, onClose, onConfirm }) {
1530
2950
  return h(
1531
2951
  Modal,
@@ -1578,8 +2998,22 @@
1578
2998
  const [dirty, setDirty] = useState(false)
1579
2999
  const [tab, setTab] = useState('tm')
1580
3000
  const [railQuery, setRailQuery] = useState('')
3001
+ const [railFilter, setRailFilter] = useState('all')
3002
+ // Two top-level workspaces: the translator's document and the library
3003
+ // manager. The latter needs no document, so it must not be a modal.
3004
+ const [view, setView] = useState('documents')
3005
+ // A seed carries the panel's search text into the library workspace.
3006
+ const [libSeed, setLibSeed] = useState(null)
3007
+
3008
+ const openLibrary = (kind, query) => {
3009
+ setLibSeed({ kind: kind === 'glossary' ? 'glossary' : 'tm', query: query || '', nonce: Date.now() })
3010
+ setView('libraries')
3011
+ }
3012
+ const [pre, setPre] = useState({ running: false, done: 0, total: 0, filled: 0, failed: 0 })
3013
+ const [qa, setQa] = useState(null)
1581
3014
  const [tmQuery, setTmQuery] = useState('')
1582
3015
  const [tmResults, setTmResults] = useState(null)
3016
+ const [tmRefresh, setTmRefresh] = useState(0)
1583
3017
  const [tmError, setTmError] = useState('')
1584
3018
  const [mt, setMt] = useState(null)
1585
3019
  const [mtError, setMtError] = useState('')
@@ -1694,6 +3128,61 @@
1694
3128
  }
1695
3129
  }
1696
3130
 
3131
+ const preCancelRef = useRef(false)
3132
+
3133
+ // The client drives pre-translation block by block instead of asking the
3134
+ // host for a background job: a block is one RPC, cancellation is simply
3135
+ // "stop looping", and every finished block is already persisted.
3136
+ const runPreTranslate = async () => {
3137
+ const active = data?.activeDocument
3138
+ if (!active || pre.running) return
3139
+ preCancelRef.current = false
3140
+ const initial = active.progress || { confirmed: 0, translated: 0, total: 0 }
3141
+ setPre({
3142
+ running: true,
3143
+ done: initial.confirmed + initial.translated,
3144
+ total: initial.total,
3145
+ filled: 0,
3146
+ failed: 0,
3147
+ })
3148
+ let from = 0
3149
+ let filled = 0
3150
+ let failed = 0
3151
+ try {
3152
+ while (from !== null) {
3153
+ if (preCancelRef.current) break
3154
+ const result = await rpc('preTranslate', {
3155
+ documentId: active.id,
3156
+ from,
3157
+ count: PRETRANSLATE_BLOCK_SIZE,
3158
+ })
3159
+ filled += result.filled
3160
+ failed += result.failed.length
3161
+ from = result.next
3162
+ const progress = result.progress
3163
+ setPre((current) => ({
3164
+ ...current,
3165
+ done: progress.confirmed + progress.translated,
3166
+ total: progress.total,
3167
+ filled,
3168
+ failed,
3169
+ }))
3170
+ await refreshState()
3171
+ }
3172
+ if (preCancelRef.current) notify(t('toast.preCancelled'))
3173
+ else if (failed) notify(t('toast.preFailed').replace('{count}', String(failed)))
3174
+ else notify(t('toast.preDone').replace('{count}', String(filled)))
3175
+ } catch (error) {
3176
+ report(error)
3177
+ } finally {
3178
+ setPre((current) => ({ ...current, running: false }))
3179
+ }
3180
+ }
3181
+
3182
+ const cancelPreTranslate = () => {
3183
+ preCancelRef.current = true
3184
+ }
3185
+
1697
3186
  useEffect(() => {
1698
3187
  let alive = true
1699
3188
  setLoading(true)
@@ -1750,6 +3239,26 @@
1750
3239
  }
1751
3240
  }, [data?.revision])
1752
3241
 
3242
+ useEffect(() => {
3243
+ const id = data?.activeDocument?.id
3244
+ if (!id) {
3245
+ setQa(null)
3246
+ return
3247
+ }
3248
+ let alive = true
3249
+ rpc('getQaReport', { documentId: id })
3250
+ .then((report) => {
3251
+ if (alive) setQa(report)
3252
+ })
3253
+ .catch(() => {
3254
+ // QA is advisory: a failure must never disturb the workbench.
3255
+ if (alive) setQa(null)
3256
+ })
3257
+ return () => {
3258
+ alive = false
3259
+ }
3260
+ }, [data?.activeDocument?.id, data?.revision])
3261
+
1753
3262
  useEffect(() => {
1754
3263
  let alive = true
1755
3264
  rpc('listMtRoutes')
@@ -1807,7 +3316,7 @@
1807
3316
  cancelled = true
1808
3317
  clearTimeout(timer)
1809
3318
  }
1810
- }, [tmQuery, doc?.id, doc?.sourceLang, doc?.targetLang, settings.fuzzyThreshold, settings.tmLimit])
3319
+ }, [tmQuery, tmRefresh, doc?.id, doc?.sourceLang, doc?.targetLang, settings.fuzzyThreshold, settings.tmLimit])
1811
3320
 
1812
3321
  const saveDraft = async (silent = true) => {
1813
3322
  if (!doc || !dirty) return true
@@ -1864,6 +3373,24 @@
1864
3373
  }
1865
3374
  }
1866
3375
 
3376
+ const adoptSuggestion = async () => {
3377
+ if (!doc || busy || !segment?.suggestion) return
3378
+ if (dirty) {
3379
+ const saved = await saveDraft(true)
3380
+ if (!saved) return
3381
+ }
3382
+ setBusy('adopt')
3383
+ try {
3384
+ const result = await rpc('adoptSuggestion', { documentId: doc.id, index: selected })
3385
+ adoptState(result.state)
3386
+ notify(t('toast.suggestionAdopted'))
3387
+ } catch (error) {
3388
+ report(error)
3389
+ } finally {
3390
+ setBusy('')
3391
+ }
3392
+ }
3393
+
1867
3394
  const moveSegment = async (delta) => {
1868
3395
  if (!doc || busy) return
1869
3396
  setBusy('move')
@@ -1984,8 +3511,72 @@
1984
3511
  setTimeout(() => textareaRef.current?.focus(), 0)
1985
3512
  }
1986
3513
 
1987
- const addTerm = async (form) => {
1988
- setBusy('term')
3514
+ const editTm = async ({ id, target }) => {
3515
+ setBusy('tm-edit')
3516
+ try {
3517
+ const result = await rpc('updateTM', { id, target })
3518
+ setData(result.state)
3519
+ setTmRefresh((value) => value + 1)
3520
+ notify(t('toast.tmUpdated'))
3521
+ return true
3522
+ } catch (error) {
3523
+ report(error)
3524
+ return false
3525
+ } finally {
3526
+ setBusy('')
3527
+ }
3528
+ }
3529
+
3530
+ const deleteTm = (match) => {
3531
+ setModal({
3532
+ type: 'confirm',
3533
+ title: t('modal.confirm.title'),
3534
+ message: `${t('modal.confirm.deleteTm')} “${match.source} → ${match.target}”?`,
3535
+ confirmLabel: t('panel.delete'),
3536
+ action: async () => {
3537
+ const result = await rpc('deleteTM', { id: match.id })
3538
+ setData(result.state)
3539
+ setTmRefresh((value) => value + 1)
3540
+ notify(t('toast.tmDeleted'))
3541
+ },
3542
+ })
3543
+ }
3544
+
3545
+ const confirmAllTranslated = () => {
3546
+ const active = data?.activeDocument
3547
+ const awaiting = active?.progress?.translated || 0
3548
+ if (!active || !awaiting) return
3549
+ setModal({
3550
+ type: 'confirm',
3551
+ title: t('rail.confirmAll'),
3552
+ message: t('rail.confirmAllMessage').replace('{count}', String(awaiting)),
3553
+ confirmLabel: t('rail.confirmAll'),
3554
+ action: async () => {
3555
+ const result = await rpc('confirmSegments', { documentId: active.id, status: 'translated' })
3556
+ await refreshState()
3557
+ setTmRefresh((value) => value + 1)
3558
+ notify(t('toast.confirmedMany').replace('{count}', String(result.confirmed.length)))
3559
+ },
3560
+ })
3561
+ }
3562
+
3563
+ const propagateRepeats = () => {
3564
+ const active = data?.activeDocument
3565
+ if (!active) return
3566
+ setModal({
3567
+ type: 'confirm',
3568
+ title: t('rail.propagate'),
3569
+ message: t('rail.propagateMessage'),
3570
+ confirmLabel: t('rail.propagate'),
3571
+ action: async () => {
3572
+ const result = await rpc('propagateDocument', { documentId: active.id })
3573
+ await refreshState()
3574
+ notify(t('toast.propagated').replace('{count}', String(result.filled)))
3575
+ },
3576
+ })
3577
+ }
3578
+
3579
+ const addTerm = async (form) => { setBusy('term')
1989
3580
  try {
1990
3581
  const result = await rpc('addGlossary', {
1991
3582
  ...form,
@@ -2125,6 +3716,37 @@
2125
3716
  }
2126
3717
  }
2127
3718
 
3719
+ const renameLibrary = async ({ kind, id, name }) => {
3720
+ setBusy('library')
3721
+ try {
3722
+ const result = await rpc('renameLibrary', { kind, id, name })
3723
+ setData(result.state)
3724
+ notify(t('libraries.renamed'))
3725
+ return true
3726
+ } catch (error) {
3727
+ report(error)
3728
+ return false
3729
+ } finally {
3730
+ setBusy('')
3731
+ }
3732
+ }
3733
+
3734
+ const deleteLibrary = ({ kind, id, name }) => {
3735
+ const entries = kind === 'tm' ? t('libraries.deleteTmWarn') : t('libraries.deleteGlossaryWarn')
3736
+ setModal({
3737
+ type: 'confirm',
3738
+ title: t('modal.confirm.title'),
3739
+ message: `${t('libraries.delete')} “${name}”?${entries}`,
3740
+ confirmLabel: t('libraries.delete'),
3741
+ action: async () => {
3742
+ const result = await rpc('deleteLibrary', { kind, id })
3743
+ setData(result.state)
3744
+ setTmResults(null)
3745
+ notify(t('libraries.deleted'))
3746
+ },
3747
+ })
3748
+ }
3749
+
2128
3750
  const exportAs = async (format) => {
2129
3751
  if (!doc) return
2130
3752
  setBusy('export')
@@ -2240,13 +3862,40 @@
2240
3862
  element.style.setProperty(CAIT_UNIT_VAR, String(unit))
2241
3863
  }
2242
3864
  measure()
3865
+
3866
+ // Mirror the host theme so native widgets follow `color-scheme`. DSH
3867
+ // toggles the marker on <body>, so observe that instead of guessing
3868
+ // from colours.
3869
+ const syncTheme = () => {
3870
+ const kind = detectThemeKind(document)
3871
+ if (element.getAttribute(CAIT_THEME_ATTR) !== kind) element.setAttribute(CAIT_THEME_ATTR, kind)
3872
+ }
3873
+ syncTheme()
3874
+ let themeObserver = null
3875
+ if (typeof MutationObserver !== 'undefined' && document.body) {
3876
+ themeObserver = new MutationObserver(syncTheme)
3877
+ themeObserver.observe(document.body, {
3878
+ attributes: true,
3879
+ attributeFilter: ['data-ds-dark-theme', 'data-theme', 'class'],
3880
+ })
3881
+ }
3882
+ const stopTheme = () => {
3883
+ if (themeObserver) themeObserver.disconnect()
3884
+ }
3885
+
2243
3886
  if (typeof ResizeObserver === 'undefined') {
2244
3887
  window.addEventListener('resize', measure)
2245
- return () => window.removeEventListener('resize', measure)
3888
+ return () => {
3889
+ window.removeEventListener('resize', measure)
3890
+ stopTheme()
3891
+ }
2246
3892
  }
2247
3893
  const observer = new ResizeObserver(measure)
2248
3894
  observer.observe(element)
2249
- return () => observer.disconnect()
3895
+ return () => {
3896
+ observer.disconnect()
3897
+ stopTheme()
3898
+ }
2250
3899
  }, [])
2251
3900
 
2252
3901
  if (loading) {
@@ -2293,12 +3942,45 @@
2293
3942
  onCloseDoc: closeDocumentTab,
2294
3943
  onNewTab: () => setModal({ type: 'new', defaultMode: 'file' }),
2295
3944
  onCleanClosed: () => setModal({ type: 'recent' }),
3945
+ pre,
3946
+ onPreTranslate: runPreTranslate,
3947
+ onCancelPreTranslate: cancelPreTranslate,
3948
+ view,
3949
+ onView: setView,
2296
3950
  }),
2297
3951
  h(SaveBanner, { t, storage: data?.storage, stale, onRefresh: refreshState }),
2298
- h(
3952
+ view === 'libraries'
3953
+ ? h(LibraryPage, {
3954
+ t,
3955
+ data,
3956
+ settings,
3957
+ busy,
3958
+ notify,
3959
+ report,
3960
+ seed: libSeed,
3961
+ onApplyState: (state) => setData(state),
3962
+ onConfirm: openConfirm,
3963
+ onCreateLibrary: createLibrary,
3964
+ onRenameLibrary: renameLibrary,
3965
+ onDeleteLibrary: deleteLibrary,
3966
+ })
3967
+ : null,
3968
+ view === 'align'
3969
+ ? h(AlignPage, {
3970
+ t,
3971
+ data,
3972
+ busy,
3973
+ notify,
3974
+ report,
3975
+ onApplyState: (state) => setData(state),
3976
+ onConfirm: openConfirm,
3977
+ })
3978
+ : null,
3979
+ view === 'documents'
3980
+ ? h(
2299
3981
  'div',
2300
3982
  { className: `cait-body${doc ? '' : ' cait-no-doc'}` },
2301
- doc ? h(SegmentRail, { t, doc, selected: index, query: railQuery, setQuery: setRailQuery, onSelect: chooseSegment }) : null,
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,
2302
3984
  h(
2303
3985
  'div',
2304
3986
  { className: `cait-main${doc ? '' : ' cait-main-empty'}` },
@@ -2320,6 +4002,7 @@
2320
4002
  onNext: () => moveSegment(1),
2321
4003
  onPrev: () => moveSegment(-1),
2322
4004
  onSave: () => saveDraft(false),
4005
+ onAdopt: adoptSuggestion,
2323
4006
  onApplyTerm: applyTerm,
2324
4007
  textareaRef,
2325
4008
  })
@@ -2338,6 +4021,8 @@
2338
4021
  tmResults,
2339
4022
  tmError,
2340
4023
  onApplyTm: applyTm,
4024
+ onEditTm: editTm,
4025
+ onDeleteTm: deleteTm,
2341
4026
  onImportTmx: importTmxFile,
2342
4027
  onClearTm: clearTm,
2343
4028
  onAddTerm: addTerm,
@@ -2358,12 +4043,13 @@
2358
4043
  },
2359
4044
  onCopy: copy,
2360
4045
  onOpenSettings: () => setModal({ type: 'settings' }),
2361
- onOpenLibraries: () => setModal({ type: 'libraries' }),
4046
+ onSearchLibrary: openLibrary,
2362
4047
  busy,
2363
4048
  })
2364
4049
  : null,
2365
4050
  ),
2366
- ),
4051
+ )
4052
+ : null,
2367
4053
  modal?.type === 'new'
2368
4054
  ? h(NewDocModal, {
2369
4055
  t,
@@ -2375,21 +4061,11 @@
2375
4061
  })
2376
4062
  : null,
2377
4063
  modal?.type === 'export'
2378
- ? h(ExportModal, { t, busy, onClose: () => setModal(null), onExport: exportAs })
4064
+ ? h(ExportModal, { t, busy, qa, onClose: () => setModal(null), onExport: exportAs })
2379
4065
  : null,
2380
4066
  modal?.type === 'settings'
2381
4067
  ? h(SettingsModal, { t, settings, mtRoutes, busy, onClose: () => setModal(null), onSave: saveSettings })
2382
4068
  : null,
2383
- modal?.type === 'libraries'
2384
- ? h(LibrariesModal, {
2385
- t,
2386
- data,
2387
- busy,
2388
- onClose: () => setModal(null),
2389
- onSetMounts: setMounts,
2390
- onCreate: createLibrary,
2391
- })
2392
- : null,
2393
4069
  modal?.type === 'recent'
2394
4070
  ? h(RecentDocsModal, {
2395
4071
  t,