dsh-cait 1.1.6 → 1.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -447,6 +447,9 @@ window.__ModuleLoader__.load({
447
447
  .cait-textarea::placeholder{color:var(--dsw-alias-label-secondary)}
448
448
  .cait-meta{font-size:10.5px;color:var(--dsw-alias-label-secondary);font-variant-numeric:tabular-nums}
449
449
  .cait-actionbar{display:flex;align-items:center;gap:7px;flex-wrap:wrap;margin-top:13px}
450
+ .cait-subbar{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap;padding:3px 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-2)}
451
+ .cait-subtime{font-size:10.5px;font-variant-numeric:tabular-nums;color:var(--dsw-alias-label-secondary);white-space:nowrap}
452
+ .cait-suboffset{width:54px;height:24px;padding:0 5px;border:1px solid var(--dsw-alias-border-l1);border-radius:5px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:10.5px}
450
453
  .cait-hint{margin-left:auto;font-size:10px;color:var(--dsw-alias-label-secondary)}
451
454
  .cait-savebtn{width:31px;height:31px;border-color:var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1)}
452
455
  .cait-editor-foot{display:flex;align-items:center;gap:8px;margin-top:11px;font-size:10.5px;color:var(--dsw-alias-label-secondary)}
@@ -648,7 +651,8 @@ window.__ModuleLoader__.load({
648
651
  .cait-libtoolbar{display:flex;align-items:center;gap:6px;padding:9px 12px 6px;flex-wrap:wrap;flex:0 0 auto}
649
652
  .cait-libsearch{display:flex;align-items:center;gap:6px;min-width:150px;flex:1;height:29px;padding:0 9px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary)}
650
653
  .cait-libsearch input{min-width:0;flex:1;border:0;outline:0;background:transparent;font-size:11.5px;color:var(--dsw-alias-label-primary)}
651
- .cait-liblang{height:29px;padding:0 5px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-1);font-size:11px;flex:0 0 auto}
654
+ .cait-liblang{height:29px;padding:0 7px;border:1px solid var(--dsw-alias-border-l1);border-radius:7px;background:var(--dsw-alias-bg-layer-1);font-size:11px;flex:0 0 auto;min-width:132px;max-width:280px}
655
+ .cait-liblang:disabled{opacity:.65}
652
656
  .cait-libwrite{display:inline-flex;align-items:center;gap:5px;flex:0 0 auto;font-size:10.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap}
653
657
  .cait-libwritelabel{font-size:10.5px;color:var(--dsw-alias-label-secondary)}
654
658
  .cait-libscope{display:flex;align-items:center;gap:4px;padding:0 12px 8px;flex-wrap:wrap;flex:0 0 auto}
@@ -681,7 +685,7 @@ window.__ModuleLoader__.load({
681
685
  .cait-alignhead{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;flex-wrap:wrap}
682
686
  .cait-alignhead h2{margin:0 0 3px;font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary)}
683
687
  .cait-alignactions{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
684
- .cait-alignactions .cait-libinput{width:118px;min-height:26px;height:26px}
688
+ .cait-alignactions .cait-libinput{width:170px;min-height:28px;height:28px;flex:0 0 auto}
685
689
  .cait-alignfiles{display:flex;gap:8px;flex-wrap:wrap}
686
690
  .cait-alignfile{display:flex;align-items:center;gap:8px;padding:7px 9px;border:1px dashed var(--dsw-alias-border-l1);border-radius:8px;background:var(--dsw-alias-bg-layer-1);min-width:0;flex:1 1 300px}
687
691
  .cait-alignlabel{font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--dsw-alias-label-secondary);flex:0 0 auto}
@@ -1577,9 +1581,21 @@ window.__ModuleLoader__.load({
1577
1581
  )
1578
1582
  }
1579
1583
 
1580
- function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onUnconfirm, onNext, onPrev, onSave, onUndo, onRedo, canUndo, canRedo, onToggleLock, onMerge, onSplit, onAdopt, onApplyTerm, onOpenBrief, onOpenResources, onOpenAnalysis, onOpenNotes, noteCount, qaRisk, textareaRef }) {
1584
+ function EditorPane({ t, doc, index, segment, detail, draft, setDraft, dirty, busy, onConfirm, onUnconfirm, onNext, onPrev, onSave, onUndo, onRedo, canUndo, canRedo, onToggleLock, onMerge, onSplit, onMergeCue, onSplitCue, onShiftSubtitles, onAdopt, onApplyTerm, onOpenBrief, onOpenResources, onOpenAnalysis, onOpenNotes, noteCount, qaRisk, textareaRef }) {
1581
1585
  const hits = detail?.index === index ? detail.glossaryHits : []
1582
1586
  const charCount = Array.from(draft || '').length
1587
+ const isSubtitle = doc.format === 'srt' || doc.format === 'vtt'
1588
+ const [shiftSeconds, setShiftSeconds] = useState('1')
1589
+ const cueStart = Number(segment.meta?.start)
1590
+ const cueEnd = Number(segment.meta?.end)
1591
+ const formatCue = (value) => {
1592
+ if (!Number.isFinite(value)) return '--:--.-'
1593
+ const total = Math.max(0, Math.round(value))
1594
+ const minutes = Math.floor(total / 60000)
1595
+ const seconds = Math.floor((total % 60000) / 1000)
1596
+ const millis = total % 1000
1597
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`
1598
+ }
1583
1599
  const progress = doc.progress
1584
1600
  const remaining = progress.total - progress.confirmed
1585
1601
  const statusLabel =
@@ -1847,16 +1863,47 @@ window.__ModuleLoader__.load({
1847
1863
  h(Icon, { name: 'chevronLeft', size: 13 }),
1848
1864
  t('editor.prev'),
1849
1865
  ),
1850
- h(
1851
- 'button',
1852
- { className: 'cait-btn cait-btn-ghost', onClick: onMerge, disabled: Boolean(busy) || !canMerge, title: t('editor.mergeHint') },
1853
- t('editor.merge'),
1854
- ),
1855
- h(
1856
- 'button',
1857
- { className: 'cait-btn cait-btn-ghost', onClick: splitAtCursor, disabled: Boolean(busy) || !canSplit, title: t('editor.splitHint') },
1858
- t('editor.split'),
1859
- ),
1866
+ isSubtitle
1867
+ ? h(
1868
+ 'div',
1869
+ { className: 'cait-subbar' },
1870
+ h('span', { className: 'cait-subtime' }, `${formatCue(cueStart)} → ${formatCue(cueEnd)}`),
1871
+ h('input', {
1872
+ className: 'cait-suboffset',
1873
+ type: 'number',
1874
+ step: '0.1',
1875
+ value: shiftSeconds,
1876
+ onChange: (event) => setShiftSeconds(event.target.value),
1877
+ title: t('editor.shiftHint'),
1878
+ }),
1879
+ h(
1880
+ 'button',
1881
+ { className: 'cait-btn cait-btn-ghost', onClick: () => onShiftSubtitles(Math.round(Number(shiftSeconds) * 1000)), disabled: Boolean(busy) || Boolean(segment.locked) || !Number.isFinite(Number(shiftSeconds)) },
1882
+ t('editor.shift'),
1883
+ ),
1884
+ h(
1885
+ 'button',
1886
+ { className: 'cait-btn cait-btn-ghost', onClick: onMergeCue, disabled: Boolean(busy) || Boolean(segment.locked) || index >= doc.segments.length - 1 },
1887
+ t('editor.mergeCue'),
1888
+ ),
1889
+ h(
1890
+ 'button',
1891
+ { className: 'cait-btn cait-btn-ghost', onClick: () => onSplitCue(Math.round((cueStart + cueEnd) / 2)), disabled: Boolean(busy) || Boolean(segment.locked) || !(cueEnd > cueStart) },
1892
+ t('editor.splitCue'),
1893
+ ),
1894
+ )
1895
+ : [
1896
+ h(
1897
+ 'button',
1898
+ { key: 'merge', className: 'cait-btn cait-btn-ghost', onClick: onMerge, disabled: Boolean(busy) || !canMerge, title: t('editor.mergeHint') },
1899
+ t('editor.merge'),
1900
+ ),
1901
+ h(
1902
+ 'button',
1903
+ { key: 'split', className: 'cait-btn cait-btn-ghost', onClick: splitAtCursor, disabled: Boolean(busy) || !canSplit, title: t('editor.splitHint') },
1904
+ t('editor.split'),
1905
+ ),
1906
+ ],
1860
1907
  h(
1861
1908
  'button',
1862
1909
  { className: 'cait-iconbtn', onClick: onUndo, disabled: Boolean(busy) || !canUndo, title: t('editor.undo'), 'aria-label': t('editor.undo') },
@@ -2715,6 +2762,7 @@ window.__ModuleLoader__.load({
2715
2762
  const sourceRef = useRef(null)
2716
2763
  const targetRef = useRef(null)
2717
2764
  const libraries = data?.tmLibraries || []
2765
+ const languageOptions = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko', 'fr', 'de', 'es', 'pt-BR', 'ru', 'it', 'ar']
2718
2766
 
2719
2767
  useEffect(() => {
2720
2768
  if (!tmId && libraries.length) setTmId(libraries[0].id)
@@ -2815,7 +2863,12 @@ window.__ModuleLoader__.load({
2815
2863
  action: async () => {
2816
2864
  setWorking(true)
2817
2865
  try {
2818
- const written = await rpc('writeAlignment', { pairs, tmId: tmId || undefined })
2866
+ const written = await rpc('writeAlignment', {
2867
+ pairs,
2868
+ tmId: tmId || undefined,
2869
+ sourceLang: sourceLang || undefined,
2870
+ targetLang: targetLang || undefined,
2871
+ })
2819
2872
  notify(t('align.written', { written: written.written, skipped: written.skipped }))
2820
2873
  } catch (error) {
2821
2874
  report(error)
@@ -2863,15 +2916,20 @@ window.__ModuleLoader__.load({
2863
2916
  h(
2864
2917
  'div',
2865
2918
  { className: 'cait-alignactions' },
2919
+ h('datalist', { id: 'cait-align-languages' }, languageOptions.map((code) => h('option', { key: code, value: code }))),
2866
2920
  h('input', {
2867
2921
  className: 'cait-libinput',
2868
2922
  placeholder: t('align.sourceLang'),
2923
+ title: t('align.langHint'),
2924
+ list: 'cait-align-languages',
2869
2925
  value: sourceLang,
2870
2926
  onChange: (event) => setSourceLang(event.target.value),
2871
2927
  }),
2872
2928
  h('input', {
2873
2929
  className: 'cait-libinput',
2874
2930
  placeholder: t('align.targetLang'),
2931
+ title: t('align.langHint'),
2932
+ list: 'cait-align-languages',
2875
2933
  value: targetLang,
2876
2934
  onChange: (event) => setTargetLang(event.target.value),
2877
2935
  }),
@@ -2881,8 +2939,15 @@ window.__ModuleLoader__.load({
2881
2939
  className: 'cait-select cait-liblang',
2882
2940
  value: tmId,
2883
2941
  onChange: (event) => setTmId(event.target.value),
2942
+ title: t('align.tmHint'),
2943
+ 'aria-label': t('align.tm'),
2944
+ disabled: !libraries.length,
2884
2945
  },
2885
- libraries.map((library) => h('option', { key: library.id, value: library.id }, library.name)),
2946
+ libraries.length
2947
+ ? libraries.map((library) =>
2948
+ h('option', { key: library.id, value: library.id }, t('align.tmOption', { name: library.name })),
2949
+ )
2950
+ : h('option', { value: '' }, t('align.noTm')),
2886
2951
  ),
2887
2952
  h(
2888
2953
  'button',
@@ -3972,6 +4037,13 @@ window.__ModuleLoader__.load({
3972
4037
  ['srt', t('modal.export.srt'), t('modal.export.subtitleGroup'), 'file'],
3973
4038
  ['vtt', t('modal.export.vtt'), t('modal.export.subtitleGroup'), 'file'],
3974
4039
  ['po', t('modal.export.po'), t('modal.export.localizationGroup'), 'file'],
4040
+ ['apple-strings', t('modal.export.appleStrings'), t('modal.export.localizationGroup'), 'file'],
4041
+ ['apple-stringsdict', t('modal.export.appleStringsdict'), t('modal.export.localizationGroup'), 'file'],
4042
+ ['android', t('modal.export.android'), t('modal.export.localizationGroup'), 'file'],
4043
+ ['resx', t('modal.export.resx'), t('modal.export.localizationGroup'), 'file'],
4044
+ ['arb', t('modal.export.arb'), t('modal.export.localizationGroup'), 'file'],
4045
+ ['yaml', t('modal.export.yaml'), t('modal.export.localizationGroup'), 'file'],
4046
+ ['properties', t('modal.export.properties'), t('modal.export.localizationGroup'), 'file'],
3975
4047
  ['xliff', t('modal.export.xliff'), t('modal.export.localizationGroup'), 'file'],
3976
4048
  ['xliff-review', t('modal.export.xliffReview'), t('modal.export.localizationGroup'), 'file'],
3977
4049
  ['xliff2', t('modal.export.xliff2'), t('modal.export.localizationGroup'), 'file'],
@@ -5748,6 +5820,48 @@ window.__ModuleLoader__.load({
5748
5820
  })
5749
5821
  }
5750
5822
 
5823
+ const mergeCue = () => {
5824
+ if (!doc || !segment || busy) return
5825
+ openConfirm({
5826
+ title: t('modal.confirm.mergeTitle'),
5827
+ message: t('modal.confirm.mergeCue'),
5828
+ confirmLabel: t('editor.mergeCue'),
5829
+ action: async () => {
5830
+ const result = await rpc('mergeCues', { documentId: doc.id, index: selected, count: 2 })
5831
+ adoptState(result.state, result.index)
5832
+ notify(t('toast.cuesMerged'))
5833
+ },
5834
+ })
5835
+ }
5836
+
5837
+ const splitCueAt = (at) => {
5838
+ if (!doc || !segment || busy) return
5839
+ openConfirm({
5840
+ title: t('modal.confirm.splitTitle'),
5841
+ message: t('modal.confirm.splitCue'),
5842
+ confirmLabel: t('editor.splitCue'),
5843
+ action: async () => {
5844
+ const result = await rpc('splitCue', { documentId: doc.id, index: selected, at })
5845
+ adoptState(result.state, result.index)
5846
+ notify(t('toast.cueSplit'))
5847
+ },
5848
+ })
5849
+ }
5850
+
5851
+ const shiftSubtitles = (offsetMs) => {
5852
+ if (!doc || !offsetMs || busy) return
5853
+ openConfirm({
5854
+ title: t('modal.confirm.shiftTitle'),
5855
+ message: t('modal.confirm.shiftCue'),
5856
+ confirmLabel: t('editor.shift'),
5857
+ action: async () => {
5858
+ const result = await rpc('shiftSubtitles', { documentId: doc.id, offsetMs })
5859
+ adoptState(result.state)
5860
+ notify(t('toast.subtitlesShifted'))
5861
+ },
5862
+ })
5863
+ }
5864
+
5751
5865
  const saveBrief = async (brief) => {
5752
5866
  if (!doc || busy) return false
5753
5867
  setBusy('brief')
@@ -6615,6 +6729,9 @@ window.__ModuleLoader__.load({
6615
6729
  onToggleLock: toggleLock,
6616
6730
  onMerge: mergeCurrent,
6617
6731
  onSplit: splitCurrent,
6732
+ onMergeCue: mergeCue,
6733
+ onSplitCue: splitCueAt,
6734
+ onShiftSubtitles: shiftSubtitles,
6618
6735
  onNext: () => moveSegment(1),
6619
6736
  onPrev: () => moveSegment(-1),
6620
6737
  onSave: () => saveDraft(false),
@@ -6992,6 +7109,13 @@ window.__ModuleLoader__.load({
6992
7109
  'modal.export.srt': 'SRT 字幕',
6993
7110
  'modal.export.vtt': 'WebVTT 字幕',
6994
7111
  'modal.export.po': 'PO 本地化',
7112
+ 'modal.export.appleStrings': '.strings 字符串',
7113
+ 'modal.export.appleStringsdict': '.stringsdict 复数',
7114
+ 'modal.export.android': 'Android strings.xml',
7115
+ 'modal.export.resx': '.resx 资源',
7116
+ 'modal.export.arb': 'ARB 应用资源',
7117
+ 'modal.export.yaml': 'YAML 本地化',
7118
+ 'modal.export.properties': '.properties 属性',
6995
7119
  'modal.export.xliff': 'XLIFF 1.2',
6996
7120
  'modal.export.xliffReview': 'XLIFF 审校包',
6997
7121
  'modal.export.reviewImport': '回填审校包',
@@ -7137,6 +7261,11 @@ window.__ModuleLoader__.load({
7137
7261
  'align.readError': '读取文件失败。',
7138
7262
  'align.sourceLang': '源语言(可选)',
7139
7263
  'align.targetLang': '目标语言(可选)',
7264
+ 'align.langHint': '可留空;例如 zh-CN、en',
7265
+ 'align.tm': '写入记忆库',
7266
+ 'align.tmHint': '对齐结果写入这个记忆库',
7267
+ 'align.tmOption': '记忆库:{name}',
7268
+ 'align.noTm': '暂无已挂载的记忆库',
7140
7269
  'align.stats': '高置信 {high} · 待确认 {medium} · 可疑 {low}',
7141
7270
  'align.unpairedSource': '未配对原文',
7142
7271
  'align.unpairedTarget': '未配对译文',
@@ -7386,6 +7515,13 @@ window.__ModuleLoader__.load({
7386
7515
  'modal.export.srt': 'SRT subtitles',
7387
7516
  'modal.export.vtt': 'WebVTT subtitles',
7388
7517
  'modal.export.po': 'PO localization',
7518
+ 'modal.export.appleStrings': '.strings strings',
7519
+ 'modal.export.appleStringsdict': '.stringsdict plurals',
7520
+ 'modal.export.android': 'Android strings.xml',
7521
+ 'modal.export.resx': '.resx resources',
7522
+ 'modal.export.arb': 'ARB app resources',
7523
+ 'modal.export.yaml': 'YAML localization',
7524
+ 'modal.export.properties': '.properties',
7389
7525
  'modal.export.xliff': 'XLIFF 1.2',
7390
7526
  'modal.export.xliffReview': 'XLIFF review package',
7391
7527
  'modal.export.reviewImport': 'Apply review package',
@@ -7530,8 +7666,13 @@ window.__ModuleLoader__.load({
7530
7666
  'align.running': 'Aligning…',
7531
7667
  'align.needFiles': 'Choose both files first.',
7532
7668
  'align.readError': 'Could not read that file.',
7533
- 'align.sourceLang': 'Source language (optional)',
7534
- 'align.targetLang': 'Target language (optional)',
7669
+ 'align.sourceLang': 'Source language',
7670
+ 'align.targetLang': 'Target language',
7671
+ 'align.langHint': 'Optional; for example zh-CN, en',
7672
+ 'align.tm': 'Write to memory',
7673
+ 'align.tmHint': 'Aligned pairs are written to this memory',
7674
+ 'align.tmOption': 'Memory: {name}',
7675
+ 'align.noTm': 'No mounted memory yet',
7535
7676
  'align.stats': 'high {high} · to review {medium} · doubtful {low}',
7536
7677
  'align.unpairedSource': 'Unpaired source',
7537
7678
  'align.unpairedTarget': 'Unpaired target',
@@ -7781,6 +7922,13 @@ window.__ModuleLoader__.load({
7781
7922
  'modal.export.srt': 'SRT 字幕',
7782
7923
  'modal.export.vtt': 'WebVTT 字幕',
7783
7924
  'modal.export.po': 'PO ローカライズ',
7925
+ 'modal.export.appleStrings': '.strings 文字列',
7926
+ 'modal.export.appleStringsdict': '.stringsdict 複数形',
7927
+ 'modal.export.android': 'Android strings.xml',
7928
+ 'modal.export.resx': '.resx リソース',
7929
+ 'modal.export.arb': 'ARB アプリリソース',
7930
+ 'modal.export.yaml': 'YAML ローカライズ',
7931
+ 'modal.export.properties': '.properties',
7784
7932
  'modal.export.xliff': 'XLIFF 1.2',
7785
7933
  'modal.export.xliffReview': 'XLIFF 校閲パッケージ',
7786
7934
  'modal.export.reviewImport': '校閲パッケージを反映',
@@ -7927,6 +8075,11 @@ window.__ModuleLoader__.load({
7927
8075
  'align.readError': 'ファイルを読み込めませんでした。',
7928
8076
  'align.sourceLang': '原文言語(任意)',
7929
8077
  'align.targetLang': '訳文言語(任意)',
8078
+ 'align.langHint': '任意;例: zh-CN、en',
8079
+ 'align.tm': '保存先メモリ',
8080
+ 'align.tmHint': '整列結果をこのメモリに保存します',
8081
+ 'align.tmOption': 'メモリ: {name}',
8082
+ 'align.noTm': 'メモリ未設定',
7930
8083
  'align.stats': '高 {high} · 要確認 {medium} · 要注意 {low}',
7931
8084
  'align.unpairedSource': '未対応の原文',
7932
8085
  'align.unpairedTarget': '未対応の訳文',
@@ -8012,6 +8165,7 @@ window.__ModuleLoader__.load({
8012
8165
  'qa.risk': '风险',
8013
8166
  'qa.code.TERM_FORBIDDEN': '禁用术语',
8014
8167
  'qa.code.LENGTH_DRIFT': '长度异常',
8168
+ 'qa.code.SUBTITLE_OVERLAP': '字幕重叠',
8015
8169
  'qa.fixCopy': '复制源文',
8016
8170
  'qa.fixClear': '清空译文',
8017
8171
  'qa.fixTerm': '插入术语',
@@ -8164,6 +8318,7 @@ window.__ModuleLoader__.load({
8164
8318
  'qa.risk': 'Risk',
8165
8319
  'qa.code.TERM_FORBIDDEN': 'Forbidden term',
8166
8320
  'qa.code.LENGTH_DRIFT': 'Length drift',
8321
+ 'qa.code.SUBTITLE_OVERLAP': 'Subtitle overlap',
8167
8322
  'qa.fixCopy': 'Copy source',
8168
8323
  'qa.fixClear': 'Clear target',
8169
8324
  'qa.fixTerm': 'Insert term',
@@ -8316,6 +8471,7 @@ window.__ModuleLoader__.load({
8316
8471
  'qa.risk': 'リスク',
8317
8472
  'qa.code.TERM_FORBIDDEN': '禁止用語',
8318
8473
  'qa.code.LENGTH_DRIFT': '長さの不一致',
8474
+ 'qa.code.SUBTITLE_OVERLAP': '字幕の重なり',
8319
8475
  'qa.fixCopy': '原文をコピー',
8320
8476
  'qa.fixClear': '訳文を空にする',
8321
8477
  'qa.fixTerm': '用語を挿入',
@@ -8428,6 +8584,17 @@ window.__ModuleLoader__.load({
8428
8584
  'modal.confirm.merge': '合并会新建一个句段,并丢失两条原文各自的确认状态。确定继续?',
8429
8585
  'modal.confirm.splitTitle': '拆分句段',
8430
8586
  'modal.confirm.split': '拆分后两侧都会变成未确认状态,译文按比例切开。确定继续?',
8587
+ 'editor.shift': '平移',
8588
+ 'editor.shiftHint': '按秒平移当前文档所有字幕时间码;不能产生负时间码。',
8589
+ 'editor.mergeCue': '合并下一条',
8590
+ 'editor.splitCue': '按中点拆分',
8591
+ 'toast.cuesMerged': '已合并字幕',
8592
+ 'toast.cueSplit': '已拆分字幕',
8593
+ 'toast.subtitlesShifted': '已平移字幕时间轴',
8594
+ 'modal.confirm.shiftTitle': '平移字幕',
8595
+ 'modal.confirm.shiftCue': '会平移当前文档所有字幕的时间码,且不修改译文。确定继续?',
8596
+ 'modal.confirm.mergeCue': '合并当前字幕和下一条,时间范围取两者首尾;确定继续?',
8597
+ 'modal.confirm.splitCue': '会在当前字幕时间范围的中点拆成两条,译文按比例切开;确定继续?',
8431
8598
  })
8432
8599
  Object.assign(EN, {
8433
8600
  'editor.merge': 'Merge next',
@@ -8440,6 +8607,17 @@ window.__ModuleLoader__.load({
8440
8607
  'modal.confirm.merge': 'Merging creates a new segment and discards the confirmation state of both originals. Continue?',
8441
8608
  'modal.confirm.splitTitle': 'Split segment',
8442
8609
  'modal.confirm.split': 'Both halves become unconfirmed and the target is split proportionally. Continue?',
8610
+ 'editor.shift': 'Shift',
8611
+ 'editor.shiftHint': 'Shift every subtitle cue in this document by seconds; negative timecodes are rejected.',
8612
+ 'editor.mergeCue': 'Merge next cue',
8613
+ 'editor.splitCue': 'Split at midpoint',
8614
+ 'toast.cuesMerged': 'Cues merged',
8615
+ 'toast.cueSplit': 'Cue split',
8616
+ 'toast.subtitlesShifted': 'Subtitle timeline shifted',
8617
+ 'modal.confirm.shiftTitle': 'Shift subtitles',
8618
+ 'modal.confirm.shiftCue': 'Every cue in this document moves by the same offset. Translations are unchanged. Continue?',
8619
+ 'modal.confirm.mergeCue': 'Merge this cue with the next one, using the outer time range. Continue?',
8620
+ 'modal.confirm.splitCue': 'Split this cue at its midpoint; the translation is split proportionally. Continue?',
8443
8621
  })
8444
8622
  Object.assign(JA, {
8445
8623
  'editor.merge': '次を結合',
@@ -8452,6 +8630,17 @@ window.__ModuleLoader__.load({
8452
8630
  'modal.confirm.merge': '結合すると新しいセグメントになり、元の確認状態は失われます。続行しますか?',
8453
8631
  'modal.confirm.splitTitle': 'セグメントを分割',
8454
8632
  'modal.confirm.split': '分割後は両側とも未確認になり、訳文は比率で分割されます。続行しますか?',
8633
+ 'editor.shift': 'シフト',
8634
+ 'editor.shiftHint': '文書内の全字幕を秒単位でシフトします。負のタイムコードは拒否されます。',
8635
+ 'editor.mergeCue': '次のキューを結合',
8636
+ 'editor.splitCue': '中点で分割',
8637
+ 'toast.cuesMerged': '字幕を結合しました',
8638
+ 'toast.cueSplit': '字幕を分割しました',
8639
+ 'toast.subtitlesShifted': '字幕タイムラインをシフトしました',
8640
+ 'modal.confirm.shiftTitle': '字幕をシフト',
8641
+ 'modal.confirm.shiftCue': '文書内の全キューを同じ量だけ移動します。訳文は変更しません。続行しますか?',
8642
+ 'modal.confirm.mergeCue': '現在のキューと次を結合し、外側の時間範囲を使います。続行しますか?',
8643
+ 'modal.confirm.splitCue': '現在のキューの中央で二分割し、訳文を比率で分けます。続行しますか?',
8455
8644
  })
8456
8645
  const UI_LANGS = [
8457
8646
  ['auto', ''],
package/lib/aligner.js CHANGED
@@ -362,12 +362,12 @@ export const alignMethods = {
362
362
  * stored are skipped, and nothing else in the store is touched: no document,
363
363
  * no segment status.
364
364
  */
365
- writeAlignment({ pairs, tmId } = {}) {
365
+ writeAlignment({ pairs, tmId, sourceLang, targetLang } = {}) {
366
366
  const list = Array.isArray(pairs) ? pairs : []
367
367
  if (!list.length) throw new CatError('ALIGN_EMPTY', '没有可写入的配对。')
368
368
  const writeTmId = this.requireMountedTmLibrary(tmId)
369
- const sourceLang = cleanLang(this.settings.sourceLang, this.settings.sourceLang)
370
- const targetLang = cleanLang(this.settings.targetLang, this.settings.targetLang)
369
+ const writeSourceLang = cleanLang(sourceLang || this.settings.sourceLang, this.settings.sourceLang)
370
+ const writeTargetLang = cleanLang(targetLang || this.settings.targetLang, this.settings.targetLang)
371
371
  let written = 0
372
372
  let updated = 0
373
373
  let skipped = 0
@@ -382,8 +382,8 @@ export const alignMethods = {
382
382
  const existing = this.tm.find(
383
383
  (entry) =>
384
384
  entry.tmId === writeTmId &&
385
- entry.sourceLang === sourceLang &&
386
- entry.targetLang === targetLang &&
385
+ entry.sourceLang === writeSourceLang &&
386
+ entry.targetLang === writeTargetLang &&
387
387
  (entry.contextHash ?? null) === null &&
388
388
  normalizeText(entry.source) === key,
389
389
  )
@@ -391,7 +391,7 @@ export const alignMethods = {
391
391
  skipped += 1
392
392
  continue
393
393
  }
394
- this.upsertTM(sourceText, targetText, sourceLang, targetLang, writeTmId, null)
394
+ this.upsertTM(sourceText, targetText, writeSourceLang, writeTargetLang, writeTmId, null)
395
395
  if (existing) updated += 1
396
396
  else written += 1
397
397
  }
package/lib/api.js CHANGED
@@ -23,6 +23,9 @@ const MUTATIONS = new Set([
23
23
  'setSegmentLocked',
24
24
  'mergeSegments',
25
25
  'splitSegment',
26
+ 'mergeCues',
27
+ 'splitCue',
28
+ 'shiftSubtitles',
26
29
  'clearUnconfirmedTranslations',
27
30
  'adoptSuggestion',
28
31
  'proposeTranslation',
@@ -141,6 +144,9 @@ function createHandlers(store) {
141
144
  setSegmentLocked: (params) => store.setSegmentLocked(params),
142
145
  mergeSegments: (params) => store.mergeSegments(params),
143
146
  splitSegment: (params) => store.splitSegment(params),
147
+ mergeCues: (params) => store.mergeCues(params),
148
+ splitCue: (params) => store.splitCue(params),
149
+ shiftSubtitles: (params) => store.shiftSubtitles(params),
144
150
  getClearUnconfirmedPreview: (params) => store.clearUnconfirmedTranslations({ ...params, confirm: false }),
145
151
  clearUnconfirmedTranslations: confirmDangerous((params) => store.clearUnconfirmedTranslations({ ...params, confirm: true })),
146
152
  adoptSuggestion: (params) => store.adoptSuggestion(params),
package/lib/docx.js CHANGED
@@ -239,6 +239,88 @@ function paragraphText(paragraph) {
239
239
  return out.replace(/\n{3,}/g, '\n\n').trim()
240
240
  }
241
241
 
242
+ function contentPartRank(name) {
243
+ if (name === 'word/document.xml') return 0
244
+ if (/^word\/header\d*\.xml$/i.test(name)) return 1
245
+ if (/^word\/footer\d*\.xml$/i.test(name)) return 2
246
+ if (name === 'word/footnotes.xml') return 3
247
+ if (name === 'word/endnotes.xml') return 4
248
+ return 9
249
+ }
250
+
251
+ function contentPartNames(entries) {
252
+ return entries
253
+ .map((entry) => entry.name)
254
+ .filter((name) => contentPartRank(name) < 9)
255
+ .sort((left, right) => {
256
+ const rank = contentPartRank(left) - contentPartRank(right)
257
+ return rank || left.localeCompare(right)
258
+ })
259
+ }
260
+
261
+ /**
262
+ * Collect every paragraph in one document part, including table cells, in
263
+ * document order. `docxParagraph` is the ordinal within that part, which is
264
+ * what the exporter patches; table/row/cell are informative metadata.
265
+ */
266
+ function collectPartParagraphs(xml, partName) {
267
+ const pattern = /<w:tbl\b[^>]*>|<\/w:tbl>|<w:tr\b[^>]*>|<\/w:tr>|<w:tc\b[^>]*>|<\/w:tc>|<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi
268
+ const paragraphs = []
269
+ let tableDepth = 0
270
+ let tableIndex = -1
271
+ let tableCount = 0
272
+ let rowIndex = -1
273
+ let cellIndex = -1
274
+ let ordinal = 0
275
+ let match
276
+
277
+ while ((match = pattern.exec(xml)) !== null) {
278
+ const tag = match[0]
279
+ if (/^<w:tbl\b/i.test(tag)) {
280
+ if (tableDepth === 0) {
281
+ tableIndex = tableCount
282
+ tableCount += 1
283
+ rowIndex = -1
284
+ cellIndex = -1
285
+ }
286
+ tableDepth += 1
287
+ continue
288
+ }
289
+ if (/^<\/w:tbl>/i.test(tag)) {
290
+ tableDepth = Math.max(0, tableDepth - 1)
291
+ continue
292
+ }
293
+ if (/^<w:tr\b/i.test(tag)) {
294
+ if (tableDepth > 0) {
295
+ rowIndex += 1
296
+ cellIndex = -1
297
+ }
298
+ continue
299
+ }
300
+ if (/^<\/w:tr>/i.test(tag)) continue
301
+ if (/^<w:tc\b/i.test(tag)) {
302
+ if (tableDepth > 0) cellIndex += 1
303
+ continue
304
+ }
305
+ if (/^<\/w:tc>/i.test(tag)) continue
306
+
307
+ const inner = tag.replace(/^<w:p\b[^>]*>/i, '').replace(/<\/w:p>\s*$/i, '')
308
+ const text = paragraphText(inner)
309
+ if (text) {
310
+ const meta = { docxPart: partName, docxParagraph: ordinal }
311
+ if (tableDepth > 0) {
312
+ meta.table = tableIndex
313
+ meta.row = Math.max(0, rowIndex)
314
+ meta.cell = Math.max(0, cellIndex)
315
+ }
316
+ paragraphs.push({ text, meta })
317
+ }
318
+ ordinal += 1
319
+ }
320
+
321
+ return paragraphs
322
+ }
323
+
242
324
  /**
243
325
  * Like parseDocx, but also returns the zero-based body paragraph ordinal for
244
326
  * every non-empty paragraph. The ordinal counts all `<w:p>` elements outside
@@ -248,33 +330,35 @@ export function parseDocxDetailed(buffer) {
248
330
  if (!Buffer.isBuffer(buffer) || buffer.length < 4) throw new Error('DOCX 内容为空')
249
331
  if (buffer[0] !== 0x50 || buffer[1] !== 0x4b) throw new Error('DOCX 文件头不是 ZIP')
250
332
 
251
- const xml = readZipEntry(buffer, 'word/document.xml')
252
- if (!xml) throw new Error('DOCX 中缺少 word/document.xml')
333
+ const entries = readZipEntries(buffer)
334
+ const documentEntry = entries.find((entry) => entry.name === 'word/document.xml')
335
+ if (!documentEntry) throw new Error('DOCX 中缺少 word/document.xml')
253
336
 
254
- const document = xml.toString('utf8')
255
- const body = document.match(/<w:body\b[^>]*>([\s\S]*?)<\/w:body>/i)?.[1] ?? document
256
- const content = withoutTables(body)
337
+ const parts = contentPartNames(entries)
257
338
  const paragraphs = []
258
- const pattern = /<w:p\b[^>]*>([\s\S]*?)<\/w:p>/gi
259
- let match
260
- let paragraphIndex = 0
261
-
262
- while ((match = pattern.exec(content)) !== null) {
263
- const text = paragraphText(match[1])
264
- if (text) paragraphs.push({ text, index: paragraphIndex })
265
- paragraphIndex += 1
339
+ for (const name of parts) {
340
+ const entry = entries.find((item) => item.name === name)
341
+ if (!entry) continue
342
+ const found = collectPartParagraphs(entry.data.toString('utf8'), name)
343
+ for (const paragraph of found) {
344
+ paragraphs.push({ ...paragraph, index: paragraphs.length })
345
+ }
266
346
  }
267
347
 
268
348
  if (!paragraphs.length) {
269
- const fallback = content.replace(/<w:tab\b[^>]*\/>/g, '\t').replace(/<w:br\b[^>]*\/>/g, '\n')
349
+ const fallback = documentEntry.data
350
+ .toString('utf8')
351
+ .replace(/<w:tab\b[^>]*\/>/g, '\t')
352
+ .replace(/<w:br\b[^>]*\/>/g, '\n')
270
353
  const text = decodeXml(fallback.replace(/<[^>]*>/g, '')).replace(/\n{3,}/g, '\n\n').trim()
271
- if (text) paragraphs.push({ text, index: 0 })
354
+ if (text) paragraphs.push({ text, index: 0, meta: { docxPart: 'word/document.xml', docxParagraph: 0 } })
272
355
  }
273
356
 
274
357
  if (!paragraphs.length) throw new Error('DOCX 中未找到可翻译的文本')
275
358
  return {
276
359
  text: paragraphs.map((paragraph) => paragraph.text).join('\n\n'),
277
360
  paragraphs,
361
+ formatMeta: { docxParts: parts },
278
362
  }
279
363
  }
280
364
 
@@ -282,9 +366,9 @@ export function parseDocx(buffer) {
282
366
  return parseDocxDetailed(buffer).text
283
367
  }
284
368
 
285
- /** Read `word/document.xml` as text; used by the export tests. */
286
- export function readDocxXml(buffer) {
287
- const xml = readZipEntry(buffer, 'word/document.xml')
369
+ /** Read one DOCX part as text; used by the export tests. */
370
+ export function readDocxXml(buffer, partName = 'word/document.xml') {
371
+ const xml = readZipEntry(buffer, partName)
288
372
  return xml ? xml.toString('utf8') : null
289
373
  }
290
374
 
@@ -355,19 +439,33 @@ function patchDocumentXml(xml, replacements) {
355
439
  return `${xml.slice(0, start)}${patchBody(body, replacements)}${xml.slice(start + body.length)}`
356
440
  }
357
441
 
442
+ function patchPartXml(xml, replacements) {
443
+ let ordinal = 0
444
+ return xml.replace(/<w:p\b[^>]*>[\s\S]*?<\/w:p>/gi, (paragraph) => {
445
+ const current = ordinal
446
+ ordinal += 1
447
+ if (!replacements.has(current)) return paragraph
448
+ return patchParagraph(paragraph, replacements.get(current))
449
+ })
450
+ }
451
+
358
452
  /** Patch translated text into the source package, preserving its formatting. */
359
- export function buildDocx(template, replacements) {
453
+ export function buildDocx(template, replacements, partReplacements = null) {
360
454
  const entries = readZipEntries(template)
361
455
  const documentEntry = entries.find((entry) => entry.name === 'word/document.xml')
362
456
  if (!documentEntry) throw new Error('DOCX 中缺少 word/document.xml')
363
- const xml = documentEntry.data.toString('utf8')
364
- const patched = patchDocumentXml(xml, replacements)
457
+ const byPart = partReplacements instanceof Map
458
+ ? partReplacements
459
+ : new Map([['word/document.xml', replacements]])
365
460
  return writeZip(
366
- entries.map((entry) =>
367
- entry.name === 'word/document.xml'
368
- ? { name: entry.name, data: Buffer.from(patched, 'utf8') }
369
- : entry,
370
- ),
461
+ entries.map((entry) => {
462
+ if (!byPart.has(entry.name)) return entry
463
+ const xml = entry.data.toString('utf8')
464
+ const patched = partReplacements instanceof Map
465
+ ? patchPartXml(xml, byPart.get(entry.name))
466
+ : patchDocumentXml(xml, replacements)
467
+ return { name: entry.name, data: Buffer.from(patched, 'utf8') }
468
+ }),
371
469
  )
372
470
  }
373
471