dsh-cait 1.0.4 → 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' },
@@ -1064,7 +1430,7 @@
1064
1430
  h('input', {
1065
1431
  ref: inputRef,
1066
1432
  type: 'file',
1067
- accept: '.txt,.md,.markdown,.tmx,.docx,.csv,.srt,.vtt,.po,.pot,.xliff,.xlf,.sdlxliff,.html,.htm,.json,.rtf,.xlsx,.pptx',
1433
+ accept: '.txt,.md,.markdown,.tmx,.docx,.epub,.csv,.srt,.vtt,.po,.pot,.xliff,.xlf,.sdlxliff,.html,.htm,.json,.rtf,.xlsx,.pptx',
1068
1434
  hidden: true,
1069
1435
  onChange: (event) => pickFile(event.target.files?.[0]),
1070
1436
  }),
@@ -1086,21 +1452,1185 @@
1086
1452
  )
1087
1453
  }
1088
1454
 
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 }) {
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
+ ),
2493
+ h(
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')),
2580
+ ),
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,
2629
+ )
2630
+ }
2631
+
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'],
@@ -1108,6 +2638,7 @@
1108
2638
  ['target-txt', t('modal.export.targetTxt'), t('modal.export.target'), 'download'],
1109
2639
  ['target-md', t('modal.export.targetMd'), t('modal.export.target'), 'download'],
1110
2640
  ['docx', t('modal.export.docx'), t('modal.export.docxGroup'), 'file'],
2641
+ ['epub', t('modal.export.epub'), t('modal.export.epubGroup'), 'file'],
1111
2642
  ['srt', t('modal.export.srt'), t('modal.export.subtitleGroup'), 'file'],
1112
2643
  ['vtt', t('modal.export.vtt'), t('modal.export.subtitleGroup'), 'file'],
1113
2644
  ['po', t('modal.export.po'), t('modal.export.localizationGroup'), 'file'],
@@ -1125,6 +2656,23 @@
1125
2656
  Modal,
1126
2657
  { title: t('modal.export.title'), onClose, wide: true },
1127
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,
1128
2676
  h(
1129
2677
  'div',
1130
2678
  { className: 'cait-export-grid' },
@@ -1147,6 +2695,11 @@
1147
2695
  fuzzyThreshold: settings.fuzzyThreshold,
1148
2696
  tmLimit: settings.tmLimit,
1149
2697
  uiLang: settings.uiLang || 'auto',
2698
+ preprocess: {
2699
+ normalizeWhitespace: settings.preprocess?.normalizeWhitespace !== false,
2700
+ stripInvisible: settings.preprocess?.stripInvisible !== false,
2701
+ collapseBlankLines: settings.preprocess?.collapseBlankLines !== false,
2702
+ },
1150
2703
  mt: {
1151
2704
  provider: settings.mt.provider,
1152
2705
  model: settings.mt.model,
@@ -1159,6 +2712,8 @@
1159
2712
  const routeMissing = form.mt.provider !== 'mock' && !route
1160
2713
 
1161
2714
  const setMt = (patch) => setForm((current) => ({ ...current, mt: { ...current.mt, ...patch } }))
2715
+ const setPre = (patch) =>
2716
+ setForm((current) => ({ ...current, preprocess: { ...current.preprocess, ...patch } }))
1162
2717
  const selectProvider = (provider) => {
1163
2718
  const nextRoute = routes.find((item) => item.id === provider)
1164
2719
  setForm((current) => ({
@@ -1182,6 +2737,11 @@
1182
2737
  fuzzyThreshold: Number(form.fuzzyThreshold),
1183
2738
  tmLimit: Number(form.tmLimit),
1184
2739
  uiLang: form.uiLang,
2740
+ preprocess: {
2741
+ normalizeWhitespace: Boolean(form.preprocess.normalizeWhitespace),
2742
+ stripInvisible: Boolean(form.preprocess.stripInvisible),
2743
+ collapseBlankLines: Boolean(form.preprocess.collapseBlankLines),
2744
+ },
1185
2745
  mt: {
1186
2746
  provider: form.mt.provider,
1187
2747
  model: form.mt.model,
@@ -1248,6 +2808,24 @@
1248
2808
  ),
1249
2809
  ),
1250
2810
  ),
2811
+ h(
2812
+ 'div',
2813
+ { className: 'cait-settings-section' },
2814
+ h('h4', { className: 'cait-settings-title' }, t('modal.settings.preprocess')),
2815
+ h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('modal.settings.preprocessHint')),
2816
+ ['normalizeWhitespace', 'stripInvisible', 'collapseBlankLines'].map((key) =>
2817
+ h(
2818
+ 'label',
2819
+ { key, className: 'cait-checkline' },
2820
+ h('input', {
2821
+ type: 'checkbox',
2822
+ checked: Boolean(form.preprocess[key]),
2823
+ onChange: (event) => setPre({ [key]: event.target.checked }),
2824
+ }),
2825
+ t(`modal.settings.pre.${key}`),
2826
+ ),
2827
+ ),
2828
+ ),
1251
2829
  h(
1252
2830
  'div',
1253
2831
  { className: 'cait-settings-section' },
@@ -1297,7 +2875,13 @@
1297
2875
  },
1298
2876
  h('option', { value: 'mock' }, t('mt.mock')),
1299
2877
  routes.map((item) =>
1300
- 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
+ ),
1301
2885
  ),
1302
2886
  routeMissing
1303
2887
  ? h('option', { value: form.mt.provider }, t('modal.settings.routeMissing'))
@@ -1362,139 +2946,6 @@
1362
2946
  )
1363
2947
  }
1364
2948
 
1365
- function LibrariesModal({ t, data, busy, onClose, onSetMounts, onCreate }) {
1366
- const libraries = data.libraries || { tm: [], glossary: [] }
1367
- const mounts = data.mounts || {
1368
- tmIds: [],
1369
- glossaryIds: [],
1370
- defaultTmId: '',
1371
- defaultGlossaryId: '',
1372
- }
1373
- const [tmName, setTmName] = useState('')
1374
- const [glossaryName, setGlossaryName] = useState('')
1375
-
1376
- const toggle = (kind, id, checked) => {
1377
- const current = kind === 'tm' ? mounts.tmIds : mounts.glossaryIds
1378
- const next = computeNextMountIds(current, id, checked)
1379
- if (!next) return
1380
- onSetMounts(kind === 'tm' ? { tmIds: next } : { glossaryIds: next })
1381
- }
1382
-
1383
- const create = async (kind, name, reset) => {
1384
- if (!name.trim()) return
1385
- const ok = await onCreate({ kind, name: name.trim() })
1386
- if (ok) reset('')
1387
- }
1388
-
1389
- const section = (kind, title, list, defaultId, ids) =>
1390
- h(
1391
- 'div',
1392
- { className: 'cait-libsection' },
1393
- h(
1394
- 'div',
1395
- { className: 'cait-libhead' },
1396
- h('h4', { className: 'cait-settings-title' }, title),
1397
- h('span', { className: 'cait-count' }, `${ids.length}/${list.length}`),
1398
- ),
1399
- list.length
1400
- ? list.map((library) => {
1401
- const id = kind === 'tm' ? library.tmId : library.glossaryId
1402
- const mounted = ids.includes(id)
1403
- return h(
1404
- 'div',
1405
- { className: 'cait-librow', key: id },
1406
- h(
1407
- 'label',
1408
- { className: 'cait-libcheck' },
1409
- h('input', {
1410
- type: 'checkbox',
1411
- checked: mounted,
1412
- disabled: isMountToggleDisabled({
1413
- mounted,
1414
- mountedCount: ids.length,
1415
- busy: Boolean(busy),
1416
- }),
1417
- onChange: (event) => toggle(kind, id, event.target.checked),
1418
- }),
1419
- h(
1420
- 'span',
1421
- { className: 'cait-libmeta' },
1422
- h('strong', null, library.name),
1423
- h(
1424
- 'small',
1425
- null,
1426
- `${library.entryCount || 0} ${t('libraries.entries')}${
1427
- library.mounted ? '' : ` · ${t('libraries.notMounted')}`
1428
- }`,
1429
- ),
1430
- ),
1431
- ),
1432
- h(
1433
- 'label',
1434
- { className: 'cait-libdefault' },
1435
- h('input', {
1436
- type: 'radio',
1437
- name: `cait-default-${kind}`,
1438
- checked: defaultId === id,
1439
- disabled: Boolean(busy) || !mounted,
1440
- onChange: () =>
1441
- onSetMounts(
1442
- kind === 'tm'
1443
- ? { defaultTmId: id }
1444
- : { defaultGlossaryId: id },
1445
- ),
1446
- }),
1447
- t(kind === 'tm' ? 'libraries.defaultTm' : 'libraries.defaultGlossary'),
1448
- ),
1449
- )
1450
- })
1451
- : h('p', { className: 'cait-empty-note' }, t('libraries.empty')),
1452
- h(
1453
- 'form',
1454
- {
1455
- className: 'cait-libcreate',
1456
- onSubmit: (event) => {
1457
- event.preventDefault()
1458
- create(kind, kind === 'tm' ? tmName : glossaryName, kind === 'tm' ? setTmName : setGlossaryName)
1459
- },
1460
- },
1461
- h('input', {
1462
- className: 'cait-input',
1463
- value: kind === 'tm' ? tmName : glossaryName,
1464
- onChange: (event) =>
1465
- kind === 'tm' ? setTmName(event.target.value) : setGlossaryName(event.target.value),
1466
- placeholder: t('libraries.namePlaceholder'),
1467
- disabled: Boolean(busy),
1468
- }),
1469
- h(
1470
- 'button',
1471
- { className: 'cait-btn', type: 'submit', disabled: Boolean(busy) },
1472
- h(Icon, { name: 'plus', size: 12 }),
1473
- t('libraries.create'),
1474
- ),
1475
- ),
1476
- )
1477
-
1478
- return h(
1479
- Modal,
1480
- { title: t('libraries.title'), onClose, wide: true },
1481
- h('p', { className: 'cait-mt-hint', style: { marginTop: 0 } }, t('libraries.desc')),
1482
- section('tm', t('libraries.tm'), libraries.tm, mounts.defaultTmId, mounts.tmIds),
1483
- section(
1484
- 'glossary',
1485
- t('libraries.glossary'),
1486
- libraries.glossary,
1487
- mounts.defaultGlossaryId,
1488
- mounts.glossaryIds,
1489
- ),
1490
- h(
1491
- 'div',
1492
- { className: 'cait-modal-actions' },
1493
- h('button', { className: 'cait-btn cait-btn-primary', onClick: onClose }, t('libraries.done')),
1494
- ),
1495
- )
1496
- }
1497
-
1498
2949
  function ConfirmModal({ t, state, busy, onClose, onConfirm }) {
1499
2950
  return h(
1500
2951
  Modal,
@@ -1547,8 +2998,22 @@
1547
2998
  const [dirty, setDirty] = useState(false)
1548
2999
  const [tab, setTab] = useState('tm')
1549
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)
1550
3014
  const [tmQuery, setTmQuery] = useState('')
1551
3015
  const [tmResults, setTmResults] = useState(null)
3016
+ const [tmRefresh, setTmRefresh] = useState(0)
1552
3017
  const [tmError, setTmError] = useState('')
1553
3018
  const [mt, setMt] = useState(null)
1554
3019
  const [mtError, setMtError] = useState('')
@@ -1663,6 +3128,61 @@
1663
3128
  }
1664
3129
  }
1665
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
+
1666
3186
  useEffect(() => {
1667
3187
  let alive = true
1668
3188
  setLoading(true)
@@ -1719,6 +3239,26 @@
1719
3239
  }
1720
3240
  }, [data?.revision])
1721
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
+
1722
3262
  useEffect(() => {
1723
3263
  let alive = true
1724
3264
  rpc('listMtRoutes')
@@ -1776,7 +3316,7 @@
1776
3316
  cancelled = true
1777
3317
  clearTimeout(timer)
1778
3318
  }
1779
- }, [tmQuery, doc?.id, doc?.sourceLang, doc?.targetLang, settings.fuzzyThreshold, settings.tmLimit])
3319
+ }, [tmQuery, tmRefresh, doc?.id, doc?.sourceLang, doc?.targetLang, settings.fuzzyThreshold, settings.tmLimit])
1780
3320
 
1781
3321
  const saveDraft = async (silent = true) => {
1782
3322
  if (!doc || !dirty) return true
@@ -1833,6 +3373,24 @@
1833
3373
  }
1834
3374
  }
1835
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
+
1836
3394
  const moveSegment = async (delta) => {
1837
3395
  if (!doc || busy) return
1838
3396
  setBusy('move')
@@ -1953,8 +3511,72 @@
1953
3511
  setTimeout(() => textareaRef.current?.focus(), 0)
1954
3512
  }
1955
3513
 
1956
- const addTerm = async (form) => {
1957
- 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')
1958
3580
  try {
1959
3581
  const result = await rpc('addGlossary', {
1960
3582
  ...form,
@@ -2094,6 +3716,37 @@
2094
3716
  }
2095
3717
  }
2096
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
+
2097
3750
  const exportAs = async (format) => {
2098
3751
  if (!doc) return
2099
3752
  setBusy('export')
@@ -2209,13 +3862,40 @@
2209
3862
  element.style.setProperty(CAIT_UNIT_VAR, String(unit))
2210
3863
  }
2211
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
+
2212
3886
  if (typeof ResizeObserver === 'undefined') {
2213
3887
  window.addEventListener('resize', measure)
2214
- return () => window.removeEventListener('resize', measure)
3888
+ return () => {
3889
+ window.removeEventListener('resize', measure)
3890
+ stopTheme()
3891
+ }
2215
3892
  }
2216
3893
  const observer = new ResizeObserver(measure)
2217
3894
  observer.observe(element)
2218
- return () => observer.disconnect()
3895
+ return () => {
3896
+ observer.disconnect()
3897
+ stopTheme()
3898
+ }
2219
3899
  }, [])
2220
3900
 
2221
3901
  if (loading) {
@@ -2262,12 +3942,45 @@
2262
3942
  onCloseDoc: closeDocumentTab,
2263
3943
  onNewTab: () => setModal({ type: 'new', defaultMode: 'file' }),
2264
3944
  onCleanClosed: () => setModal({ type: 'recent' }),
3945
+ pre,
3946
+ onPreTranslate: runPreTranslate,
3947
+ onCancelPreTranslate: cancelPreTranslate,
3948
+ view,
3949
+ onView: setView,
2265
3950
  }),
2266
3951
  h(SaveBanner, { t, storage: data?.storage, stale, onRefresh: refreshState }),
2267
- 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(
2268
3981
  'div',
2269
3982
  { className: `cait-body${doc ? '' : ' cait-no-doc'}` },
2270
- 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,
2271
3984
  h(
2272
3985
  'div',
2273
3986
  { className: `cait-main${doc ? '' : ' cait-main-empty'}` },
@@ -2289,6 +4002,7 @@
2289
4002
  onNext: () => moveSegment(1),
2290
4003
  onPrev: () => moveSegment(-1),
2291
4004
  onSave: () => saveDraft(false),
4005
+ onAdopt: adoptSuggestion,
2292
4006
  onApplyTerm: applyTerm,
2293
4007
  textareaRef,
2294
4008
  })
@@ -2307,6 +4021,8 @@
2307
4021
  tmResults,
2308
4022
  tmError,
2309
4023
  onApplyTm: applyTm,
4024
+ onEditTm: editTm,
4025
+ onDeleteTm: deleteTm,
2310
4026
  onImportTmx: importTmxFile,
2311
4027
  onClearTm: clearTm,
2312
4028
  onAddTerm: addTerm,
@@ -2327,12 +4043,13 @@
2327
4043
  },
2328
4044
  onCopy: copy,
2329
4045
  onOpenSettings: () => setModal({ type: 'settings' }),
2330
- onOpenLibraries: () => setModal({ type: 'libraries' }),
4046
+ onSearchLibrary: openLibrary,
2331
4047
  busy,
2332
4048
  })
2333
4049
  : null,
2334
4050
  ),
2335
- ),
4051
+ )
4052
+ : null,
2336
4053
  modal?.type === 'new'
2337
4054
  ? h(NewDocModal, {
2338
4055
  t,
@@ -2344,21 +4061,11 @@
2344
4061
  })
2345
4062
  : null,
2346
4063
  modal?.type === 'export'
2347
- ? h(ExportModal, { t, busy, onClose: () => setModal(null), onExport: exportAs })
4064
+ ? h(ExportModal, { t, busy, qa, onClose: () => setModal(null), onExport: exportAs })
2348
4065
  : null,
2349
4066
  modal?.type === 'settings'
2350
4067
  ? h(SettingsModal, { t, settings, mtRoutes, busy, onClose: () => setModal(null), onSave: saveSettings })
2351
4068
  : null,
2352
- modal?.type === 'libraries'
2353
- ? h(LibrariesModal, {
2354
- t,
2355
- data,
2356
- busy,
2357
- onClose: () => setModal(null),
2358
- onSetMounts: setMounts,
2359
- onCreate: createLibrary,
2360
- })
2361
- : null,
2362
4069
  modal?.type === 'recent'
2363
4070
  ? h(RecentDocsModal, {
2364
4071
  t,