dsh-config-manager 0.2.4 → 0.3.0

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.
Files changed (129) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/lib/adapters/self.d.ts +44 -4
  4. package/lib/adapters/self.js +63 -3
  5. package/lib/client.d.ts +11 -6
  6. package/lib/client.js +1263 -1192
  7. package/lib/core/analyzer.d.ts +3 -0
  8. package/lib/core/analyzer.js +32 -56
  9. package/lib/core/backup.d.ts +36 -0
  10. package/lib/core/backup.js +125 -3
  11. package/lib/core/import-defaults.d.ts +13 -0
  12. package/lib/core/import-defaults.js +14 -0
  13. package/lib/core/importer.d.ts +10 -1
  14. package/lib/core/importer.js +7 -1
  15. package/lib/core/messages.d.ts +4 -0
  16. package/lib/core/messages.js +8 -0
  17. package/lib/core/model-tools.js +19 -3
  18. package/lib/index.d.ts +1 -1
  19. package/lib/index.js +12 -22
  20. package/lib/routes/import.js +3 -1
  21. package/lib/routes/recovery.js +226 -2
  22. package/lib/routes/sync.js +26 -7
  23. package/lib/security/integrity.d.ts +24 -0
  24. package/lib/security/integrity.js +30 -0
  25. package/lib/security/unverified-file-blocks.d.ts +73 -0
  26. package/lib/security/unverified-file-blocks.js +151 -0
  27. package/lib/sync/autosync-config.d.ts +8 -4
  28. package/lib/sync/autosync-config.js +2 -2
  29. package/lib/sync/autosync-scheduler.d.ts +61 -3
  30. package/lib/sync/autosync-scheduler.js +87 -10
  31. package/lib/sync/config-issue-reporting.d.ts +32 -0
  32. package/lib/sync/config-issue-reporting.js +63 -0
  33. package/lib/sync/gc-skip.d.ts +42 -0
  34. package/lib/sync/gc-skip.js +57 -0
  35. package/lib/sync/git/git-transport.d.ts +10 -0
  36. package/lib/sync/git/git-transport.js +72 -14
  37. package/lib/sync/merge-preview-text.d.ts +11 -0
  38. package/lib/sync/merge-preview-text.js +38 -0
  39. package/lib/sync/s3/s3-transport.d.ts +8 -1
  40. package/lib/sync/s3/s3-transport.js +19 -5
  41. package/lib/sync/session-tombstones.d.ts +53 -0
  42. package/lib/sync/session-tombstones.js +52 -0
  43. package/lib/sync/sync-config.d.ts +55 -5
  44. package/lib/sync/sync-config.js +188 -15
  45. package/lib/sync/sync-engine.d.ts +21 -7
  46. package/lib/sync/sync-engine.js +63 -85
  47. package/lib/sync/webdav/webdav-transport.d.ts +11 -1
  48. package/lib/sync/webdav/webdav-transport.js +22 -6
  49. package/lib/ui/i18n.js +56 -36
  50. package/lib/ui/import-wizard.d.ts +1 -1
  51. package/lib/ui/import-wizard.js +2 -1
  52. package/lib/ui/overview-view.d.ts +6 -2
  53. package/lib/ui/overview-view.js +8 -1
  54. package/lib/ui/report.js +2 -1
  55. package/package.json +2 -2
  56. package/src/adapters/self.test.ts +126 -85
  57. package/src/adapters/self.ts +73 -6
  58. package/src/client/ConfigManagerSection.tsx +0 -3
  59. package/src/client/common/Icon.tsx +3 -0
  60. package/src/client/common/SectionComposition.tsx +6 -1
  61. package/src/client/common/info-hint-guard.test.ts +7 -5
  62. package/src/client/config-manager.module.css +254 -13
  63. package/src/client/environment/EnvironmentPanel.tsx +57 -47
  64. package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
  65. package/src/client/export/ExportView.tsx +3 -1
  66. package/src/client/home/HomePanel.tsx +25 -5
  67. package/src/client/import/ImportWizardView.tsx +1 -3
  68. package/src/client/import/import-wizard-steps.tsx +1 -17
  69. package/src/client/library/ArtifactRow.tsx +13 -2
  70. package/src/client/library/LibraryPanel.tsx +12 -2
  71. package/src/client/locales.ts +53 -34
  72. package/src/client/lucide-icons.d.ts +2 -0
  73. package/src/client/market/MarketPanel.tsx +12 -11
  74. package/src/client/market/market-locales.ts +16 -16
  75. package/src/client/recovery/RollbackCard.tsx +4 -2
  76. package/src/client/recovery/recovery-locales.test.ts +4 -2
  77. package/src/client/recovery/recovery-locales.ts +23 -23
  78. package/src/client/sync/AutosyncCard.tsx +36 -10
  79. package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
  80. package/src/client/sync/SyncPanel.tsx +32 -33
  81. package/src/client/sync/SyncProbeRow.tsx +23 -6
  82. package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
  83. package/src/client/sync/sync-locales.ts +24 -24
  84. package/src/core/analyzer.ts +37 -56
  85. package/src/core/backup.test.ts +139 -1
  86. package/src/core/backup.ts +145 -4
  87. package/src/core/import-defaults.ts +13 -0
  88. package/src/core/importer.ts +12 -2
  89. package/src/core/integrity-extra-entries.test.ts +205 -0
  90. package/src/core/messages.test.ts +3 -1
  91. package/src/core/messages.ts +8 -0
  92. package/src/core/model-tools.test.ts +33 -0
  93. package/src/core/model-tools.ts +20 -4
  94. package/src/index.ts +12 -21
  95. package/src/routes/import.ts +4 -1
  96. package/src/routes/recovery.test.ts +198 -1
  97. package/src/routes/recovery.ts +194 -1
  98. package/src/routes/sync.ts +27 -8
  99. package/src/security/integrity.ts +37 -0
  100. package/src/security/unverified-file-blocks.ts +192 -0
  101. package/src/sync/autosync-config.ts +9 -5
  102. package/src/sync/autosync-scheduler.test.ts +133 -6
  103. package/src/sync/autosync-scheduler.ts +118 -14
  104. package/src/sync/config-issue-reporting.test.ts +148 -0
  105. package/src/sync/config-issue-reporting.ts +75 -0
  106. package/src/sync/gc-skip.test.ts +66 -0
  107. package/src/sync/gc-skip.ts +64 -0
  108. package/src/sync/git/git-probe.test.ts +3 -2
  109. package/src/sync/git/git-transport.test.ts +168 -7
  110. package/src/sync/git/git-transport.ts +70 -14
  111. package/src/sync/merge-preview-text.ts +37 -0
  112. package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
  113. package/src/sync/s3/s3-transport.ts +23 -4
  114. package/src/sync/session-tombstones.ts +90 -0
  115. package/src/sync/sync-config.test.ts +170 -1
  116. package/src/sync/sync-config.ts +184 -14
  117. package/src/sync/sync-engine.test.ts +238 -0
  118. package/src/sync/sync-engine.ts +79 -83
  119. package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
  120. package/src/sync/webdav/webdav-transport.ts +26 -5
  121. package/src/ui/disk-usage-view.test.ts +2 -1
  122. package/src/ui/i18n.test.ts +47 -0
  123. package/src/ui/i18n.ts +58 -38
  124. package/src/ui/import-wizard.test.ts +18 -1
  125. package/src/ui/import-wizard.ts +3 -2
  126. package/src/ui/overview-view.test.ts +9 -0
  127. package/src/ui/overview-view.ts +9 -2
  128. package/src/ui/report.test.ts +4 -3
  129. package/src/ui/report.ts +2 -1
@@ -1,3 +1,4 @@
1
+ import type { GcSkipCode } from '../gc-skip.ts';
1
2
  import '../messages.ts';
2
3
  import type { MsgFunc } from '../../core/messages.ts';
3
4
  import { SyncTransportError } from '../transport.ts';
@@ -50,6 +51,10 @@ export declare class WebDavTransportError extends SyncTransportError {
50
51
  export declare class WebDavTransport implements SyncTransport {
51
52
  readonly type = "webdav";
52
53
  private readonly o;
54
+ /** 最近一轮 blob GC 的放弃码(`null` = 跑完或未触发;SYN-04)。 */
55
+ private gcSkip;
56
+ /** 最近一轮 blob GC 是否因「无法证明引用集完整」而整体放弃(`null` = 正常跑完),供上层给用户提示。 */
57
+ get lastGcSkip(): GcSkipCode | null;
53
58
  constructor(options: WebDavTransportOptions);
54
59
  /**
55
60
  * 最近一次 list() 里读不出来的远端快照(issue #59;index.json 损坏时带上原因)。
@@ -108,7 +113,12 @@ export declare class WebDavTransport implements SyncTransport {
108
113
  private rehydrate;
109
114
  /**
110
115
  * blob 仓 GC(P1-4):逐份读取**仍存在**的快照 JSON,收集被引用的哈希,删除无人引用且
111
- * 超过保护窗口的 blob。读坏任一份快照 → 本轮直接放弃(宁可留垃圾,不可删在用的)。
116
+ * 超过保护窗口的 blob。读不坏任何一份快照 → 正常回收;否则本轮整体放弃(宁可留垃圾,不可删在用的)。
117
+ *
118
+ * SYN-04(P1):**404 也放弃本轮**。旧实现把「索引有条目但快照文件 404」当作「文件不存在 ⇒ 不可能
119
+ * 引用任何 blob」直接 continue,于是引用集只剩「读得出来的那几份」,仍被该快照(假 404 / 孤儿条目)
120
+ * 使用的 blob 就被判成无人引用而删除 —— 不可恢复。拿不到内容就无法证明「没人引用」,与「读不出来」
121
+ * 是同一件事 ⇒ 一律放弃并记码(sync-N1 同族口径)。
112
122
  */
113
123
  private gcBlobStore;
114
124
  private validateOptions;
@@ -28,6 +28,7 @@
28
28
  import { requestOnce } from '../../utils/proxy.js';
29
29
  import { zhMsg } from '../../core/messages.js';
30
30
  import { composeMsg } from '../s3/messages.js';
31
+ import { GC_SKIP_REFERENCE_MISSING, GC_SKIP_REFERENCE_UNREADABLE, GC_SKIP_SNAPSHOT_UNPARSABLE } from '../gc-skip.js';
31
32
  // t6:「英文界面零中文」——注册同步域追加目录(this.o.msg 缺省 = zhMsg,其查找链会查注册目录;
32
33
  // sync.webdav.blobHashInvalid 这类新键必须能解析,绝不露裸键名)。
33
34
  import '../messages.js';
@@ -224,6 +225,12 @@ function sameOrigin(a, b) {
224
225
  export class WebDavTransport {
225
226
  type = 'webdav';
226
227
  o;
228
+ /** 最近一轮 blob GC 的放弃码(`null` = 跑完或未触发;SYN-04)。 */
229
+ gcSkip = null;
230
+ /** 最近一轮 blob GC 是否因「无法证明引用集完整」而整体放弃(`null` = 正常跑完),供上层给用户提示。 */
231
+ get lastGcSkip() {
232
+ return this.gcSkip;
233
+ }
227
234
  constructor(options) {
228
235
  this.o = {
229
236
  baseUrl: '',
@@ -582,26 +589,35 @@ export class WebDavTransport {
582
589
  }
583
590
  /**
584
591
  * blob 仓 GC(P1-4):逐份读取**仍存在**的快照 JSON,收集被引用的哈希,删除无人引用且
585
- * 超过保护窗口的 blob。读坏任一份快照 → 本轮直接放弃(宁可留垃圾,不可删在用的)。
592
+ * 超过保护窗口的 blob。读不坏任何一份快照 → 正常回收;否则本轮整体放弃(宁可留垃圾,不可删在用的)。
593
+ *
594
+ * SYN-04(P1):**404 也放弃本轮**。旧实现把「索引有条目但快照文件 404」当作「文件不存在 ⇒ 不可能
595
+ * 引用任何 blob」直接 continue,于是引用集只剩「读得出来的那几份」,仍被该快照(假 404 / 孤儿条目)
596
+ * 使用的 blob 就被判成无人引用而删除 —— 不可恢复。拿不到内容就无法证明「没人引用」,与「读不出来」
597
+ * 是同一件事 ⇒ 一律放弃并记码(sync-N1 同族口径)。
586
598
  */
587
599
  async gcBlobStore(pwd, remaining) {
600
+ this.gcSkip = null;
588
601
  const index = await this.readBlobIndex(pwd);
589
602
  if (Object.keys(index).length === 0)
590
603
  return;
591
604
  const referenced = new Set();
592
605
  for (const meta of remaining) {
593
606
  const res = await this.send('GET', this.snapshotUrl(meta.id), pwd);
594
- // 索引有条目但快照文件 404 ⇒ 该文件不存在,不可能引用任何 blob(安全跳过)。
595
- // 其它失败 ⇒ **读不出来 ≠ 没引用**,本轮直接放弃(与「宁可留垃圾,不可删在用的」同口径;sync-N1 同族)。
596
- if (res.status === 404)
597
- continue;
598
- if (!res.ok)
607
+ if (res.status === 404) {
608
+ this.gcSkip = GC_SKIP_REFERENCE_MISSING;
599
609
  return;
610
+ }
611
+ if (!res.ok) {
612
+ this.gcSkip = GC_SKIP_REFERENCE_UNREADABLE;
613
+ return;
614
+ }
600
615
  let snap;
601
616
  try {
602
617
  snap = deserializeSnapshot(await res.text());
603
618
  }
604
619
  catch {
620
+ this.gcSkip = GC_SKIP_SNAPSHOT_UNPARSABLE;
605
621
  return;
606
622
  }
607
623
  const plain = snap.sections;
package/lib/ui/i18n.js CHANGED
@@ -524,14 +524,14 @@ export const uiEn = {
524
524
  'report.backupCreated': 'Backup Created',
525
525
  'report.importComplete': 'Import Complete',
526
526
  'report.importFailed': 'Import Failed',
527
- 'report.tombstonedSkipped': '{count} item(s) skipped by deletion records',
527
+ 'report.tombstonedSkipped': '{count} {count, plural, item|items} skipped by deletion records',
528
528
  'report.included': 'Included:',
529
529
  'report.excluded': 'Excluded:',
530
530
  'report.security': 'Security:',
531
531
  'report.apiKeysExcluded': 'API Keys excluded:',
532
532
  'report.containsSecrets': 'Contains secrets:',
533
533
  'report.encrypted': 'Encrypted:',
534
- 'report.redacted': '{count} sensitive field(s) redacted',
534
+ 'report.redacted': '{count} sensitive {count, plural, field|fields} redacted',
535
535
  'report.file': 'File:',
536
536
  'report.yes': 'yes',
537
537
  'report.yesEncrypted': 'yes (encrypted)',
@@ -549,12 +549,12 @@ export const uiEn = {
549
549
  'report.other': 'Other',
550
550
  'report.fullText': 'Full text report (with per-item reasons)',
551
551
  'report.secrets': 'Secrets:',
552
- 'report.credentialsNeedEntry': '{count} credential(s) need to be entered',
552
+ 'report.credentialsNeedEntry': '{count} {count, plural, credential needs|credentials need} to be entered',
553
553
  'report.restartPluginsMcp': 'Plugin / MCP changes take effect after restarting DSH',
554
554
  'report.rollbackFull': 'Rollback: fully restored the pre-import configuration.',
555
555
  'report.rollbackPartial': 'Rollback partially completed.',
556
556
  'report.rollbackManual': 'These items may require manual recovery:',
557
- 'report.restored': 'Restored: {count} item(s)',
557
+ 'report.restored': 'Restored: {count} {count, plural, item|items}',
558
558
  'report.includedCount': '{count} included',
559
559
  'report.excludedCount': '{count} excluded',
560
560
  'report.badgeEncrypted': 'encrypted',
@@ -565,18 +565,18 @@ export const uiEn = {
565
565
  'report.includedSections': 'Included sections',
566
566
  'report.excludedSections': 'Excluded sections',
567
567
  'report.warnings': 'Warnings',
568
- 'report.unit.namespaces': 'namespace(s)',
569
- 'report.unit.files': 'file(s)',
570
- 'report.unit.servers': 'server(s)',
571
- 'report.unit.providers': 'provider(s)',
572
- 'report.unit.plugins': 'plugin(s)',
573
- 'report.unit.patchLines': 'patch line(s)',
574
- 'report.unit.patchFiles': 'patch file(s)',
575
- 'report.unit.localTarballs': 'local package(s)',
576
- 'report.unit.prompts': 'prompt(s)',
577
- 'report.unit.workspaces': 'workspace(s)',
578
- 'report.unit.credentials': 'credential status item(s)',
579
- 'report.unit.notes': 'note(s)',
568
+ 'report.unit.namespaces': '{n, plural, namespace|namespaces}',
569
+ 'report.unit.files': '{n, plural, file|files}',
570
+ 'report.unit.servers': '{n, plural, server|servers}',
571
+ 'report.unit.providers': '{n, plural, provider|providers}',
572
+ 'report.unit.plugins': '{n, plural, plugin|plugins}',
573
+ 'report.unit.patchLines': '{n, plural, patch line|patch lines}',
574
+ 'report.unit.patchFiles': '{n, plural, patch file|patch files}',
575
+ 'report.unit.localTarballs': '{n, plural, local package|local packages}',
576
+ 'report.unit.prompts': '{n, plural, prompt|prompts}',
577
+ 'report.unit.workspaces': '{n, plural, workspace|workspaces}',
578
+ 'report.unit.credentials': '{n, plural, credential status item|credential status items}',
579
+ 'report.unit.notes': '{n, plural, note|notes}',
580
580
  'error.notMounted': 'config-manager service is not mounted (plugin not loaded): make sure dsh-config-manager is installed in the profile and restart DSH',
581
581
  'error.apiPathMissed': 'API path not matched (HTTP {status}): the request prefix is {base} but that path did not return JSON — if you serve DSH under a reverse-proxy sub-path, check that this prefix is proxied (issue #108)',
582
582
  'error.httpInvalidJson': 'HTTP {status}: invalid JSON response',
@@ -667,16 +667,16 @@ export const uiEn = {
667
667
  'sync.pushFailed': 'Push failed',
668
668
  'sync.pushOk': 'Push succeeded (snapshot {id})',
669
669
  'sync.conflictUnresolved': 'Conflict item {id} must be resolved first (keep current / use imported)',
670
- 'sync.pushPreviewHeadline': 'Will push {total} section(s) ({changed} changed)',
670
+ 'sync.pushPreviewHeadline': 'Will push {total} {total, plural, section|sections} ({changed} changed)',
671
671
  'sync.pushPreviewEncrypted': 'Encrypted snapshot: the payload is encrypted as a whole; per-section changes vs the baseline cannot be compared.',
672
672
  'sync.pushPreviewCredentials': 'This push includes real credential values (encrypted with the payload; the remote only ever sees ciphertext).',
673
673
  'sync.pullFailed': 'Pull failed',
674
- 'sync.pullOk': 'Remote snapshot {id} diff preview: {count} change(s)',
674
+ 'sync.pullOk': 'Remote snapshot {id} diff preview: {count} {count, plural, change|changes}',
675
675
  'sync.pullEmpty': 'Remote snapshot matches local (no changes)',
676
676
  'sync.previewHint': 'Read-only diff preview above; nothing is imported. One-click import is not supported in this version — use the Import wizard to apply a downloaded backup if needed.',
677
677
  'sync.syncing': 'Syncing…',
678
- 'sync.importDone': 'Imported {n} section(s)',
679
- 'sync.importPartial': 'Partly successful: {n} item(s) did not take effect (the rest were written; nothing rolled back)',
678
+ 'sync.importDone': 'Imported {n} {n, plural, section|sections}',
679
+ 'sync.importPartial': 'Partly successful: {n} {n, plural, item did|items did} not take effect (the rest were written; nothing rolled back)',
680
680
  'sync.importFailed': 'Import failed (rolled back)',
681
681
  'sync.autosyncNever': 'Never run',
682
682
  'sync.autosyncLastRun': 'Last run: {time}',
@@ -684,7 +684,7 @@ export const uiEn = {
684
684
  'sync.autosyncSkipped': 'Skipped',
685
685
  'sync.autosyncFailed': 'Failed',
686
686
  'sync.autosyncPartial': 'Partial',
687
- 'sync.autosyncFailCount': '{n} consecutive failure(s)',
687
+ 'sync.autosyncFailCount': '{n} consecutive {n, plural, failure|failures}',
688
688
  'sync.duration.min': '{n} min',
689
689
  'sync.duration.hour': '{n} hr',
690
690
  'sync.duration.day': '{n} day',
@@ -703,7 +703,7 @@ export const uiEn = {
703
703
  'sync.github.pollError': 'GitHub OAuth error: {detail}',
704
704
  'sync.github.unknownError': 'Unknown error',
705
705
  'market.statusUnconfigured': 'No markets added (enter a public Git repository URL)',
706
- 'market.statusConfigured': '{count} market(s) added',
706
+ 'market.statusConfigured': '{count} {count, plural, market|markets} added',
707
707
  'market.supplyUnofficial': 'From the public network, not officially reviewed',
708
708
  'market.supplySource': 'Source repo: {url}',
709
709
  'market.supplyDownloadedAt': 'Downloaded at: {time}',
@@ -735,18 +735,18 @@ export const uiEn = {
735
735
  'consult.title': 'Migration Pre-flight Consultation',
736
736
  'consult.healthScore': 'Health score {score}',
737
737
  'consult.dryRun': 'Read-only analysis',
738
- 'consult.blockers': '{count} hard blocker(s) (cannot import safely until resolved)',
739
- 'consult.attention': '{count} item(s) need attention',
738
+ 'consult.blockers': '{count} hard {count, plural, blocker|blockers} (cannot import safely until resolved)',
739
+ 'consult.attention': '{count} {count, plural, item needs|items need} attention',
740
740
  'consult.reasons': 'Recommendation basis',
741
741
  'consult.willApply': 'Will apply',
742
- 'consult.sections': '{count} section(s)',
743
- 'consult.items': '{count} item(s)',
744
- 'consult.conflicts': '{count} conflict(s)',
745
- 'consult.risks': '{count} risk(s)',
742
+ 'consult.sections': '{count} {count, plural, section|sections}',
743
+ 'consult.items': '{count} {count, plural, item|items}',
744
+ 'consult.conflicts': '{count} {count, plural, conflict|conflicts}',
745
+ 'consult.risks': '{count} {count, plural, risk|risks}',
746
746
  'consult.dimensions': 'Score dimensions',
747
747
  'consult.loading': 'Generating consultation report…',
748
748
  'diskUsage.title': 'Disk Usage',
749
- 'diskUsage.files': '{count} file(s)',
749
+ 'diskUsage.files': '{count} {count, plural, file|files}',
750
750
  'diskUsage.refresh': 'Re-scan',
751
751
  'diskUsage.loading': 'Measuring disk usage…',
752
752
  'diskUsage.unreadable': 'not measured',
@@ -780,9 +780,9 @@ export const uiEn = {
780
780
  'diskUsage.area.vault': 'Sensitive file mirror',
781
781
  'diskUsage.area.vaultDesc': 'Mirror for the local editor write channel (enabled on demand)',
782
782
  'diskUsage.retention.none': 'Retention disabled (never auto-removed)',
783
- 'diskUsage.retention.days': '{count} day(s)',
784
- 'diskUsage.retention.hours': '{count} hour(s)',
785
- 'diskUsage.retention.minutes': '{count} minute(s)',
783
+ 'diskUsage.retention.days': '{count} {count, plural, day|days}',
784
+ 'diskUsage.retention.hours': '{count} {count, plural, hour|hours}',
785
+ 'diskUsage.retention.minutes': '{count} {count, plural, minute|minutes}',
786
786
  'diskUsage.backupRetention': 'Scheduled backups keep the latest {count}; exports are auto-removed after {days} days.',
787
787
  'diskUsage.clean.title': 'Reclaim space',
788
788
  'diskUsage.clean.hint': 'Only regenerable caches and staging are cleaned; snapshots and sync data are never removed here.',
@@ -792,10 +792,10 @@ export const uiEn = {
792
792
  'diskUsage.clean.tmpMarketDesc': 'Clears regardless of retention — all of it regenerates (avoid while an import is in flight).',
793
793
  'diskUsage.clean.expiredExports': 'Reclaim expired backups',
794
794
  'diskUsage.clean.expiredExportsDesc': 'Only exports past their retention window (scheduled backups are excluded).',
795
- 'diskUsage.clean.done': 'Freed {size}, removed {count} item(s)',
795
+ 'diskUsage.clean.done': 'Freed {size}, removed {count} {count, plural, item|items}',
796
796
  'diskUsage.clean.nothing': 'Nothing to reclaim',
797
797
  'diskUsage.clean.nothingAbsent': 'Nothing to clean (the target directories are missing or already empty)',
798
- 'diskUsage.clean.failed': 'Cleanup finished with {count} failure(s) (skipped)',
798
+ 'diskUsage.clean.failed': 'Cleanup finished with {count} {count, plural, failure|failures} (skipped)',
799
799
  'diskUsage.clean.confirmTitle': 'Clean disk usage',
800
800
  'diskUsage.clean.confirmMessage': 'This deletes regenerable caches/staging plus expired backup files; snapshots and sync data are untouched. It cannot be undone, but everything removed can be regenerated.',
801
801
  'diskUsage.clean.confirm': 'Clean',
@@ -900,7 +900,7 @@ export const uiEn = {
900
900
  'foreign.found.no': 'Not detected',
901
901
  'foreign.paths': 'Matched paths: {paths}',
902
902
  'foreign.pathsNone': 'No truth-table path matched',
903
- 'foreign.credentialRefs': '{count} credential(s) to enter manually: {refs}',
903
+ 'foreign.credentialRefs': '{count} {count, plural, credential|credentials} to enter manually: {refs}',
904
904
  'foreign.skip.unsupported-hooks': 'settings hooks: DSH has no equivalent, not migrated',
905
905
  'foreign.skip.unsupported-commands': 'Slash commands: not the same thing as DSH prompts, not migrated this round',
906
906
  'foreign.skip.credentials-not-migrated': 'Credential fields: only the names are recorded; the values are never read or migrated',
@@ -980,6 +980,17 @@ export const uiEn = {
980
980
  'run.progressLost': 'The task finished or exceeded its retention window; progress cannot be recovered',
981
981
  'run.restoreProgressLost': 'The restore task finished or exceeded its retention window; progress cannot be recovered',
982
982
  };
983
+ /**
984
+ * 单复数段:`{count, plural, one-form|other-form}`(ICU 的极小子集,2026-11-01)。
985
+ *
986
+ * 为什么要它:英文旧写法把复数塞进括号 —— `{count} item(s)`、`{count} copy(ies)`。
987
+ * 界面读不出来:用户看到 "3 item(s)" 会先怀疑渲染漏了,单数时也照样写 "1 item(s)"。
988
+ * 真复数得按数量选形式,而模板是死字符串,只能把两种形式都放进值里、由插值层挑一个。
989
+ *
990
+ * 规则:`Number(v)` 的绝对值 === 1 取 one,其余(含 0、小数、非数字)取 other —— 英语常规
991
+ * ("0 items")。中文值不写这段(中文没有单复数),同一键的中文照旧。
992
+ */
993
+ const PLURAL_SEGMENT = /\{(\w+),\s*plural,\s*([^{}|]*)\|([^{}]*)\}/g;
983
994
  /** 构造 翻译函数(zh 源 / en 镜像;缺 key 回退)。 */
984
995
  export function makeUiT(lang) {
985
996
  const dict = lang === 'en' ? uiEn : uiZh;
@@ -989,7 +1000,16 @@ export function makeUiT(lang) {
989
1000
  const template = dict[key] ?? fallback?.[key] ?? key;
990
1001
  if (params === undefined)
991
1002
  return template;
992
- return String(template).replace(/\{(\w+)\}/g, (match, k) => params[k] === undefined ? match : String(params[k]));
1003
+ return String(template)
1004
+ .replace(PLURAL_SEGMENT, (match, k, one, other) => {
1005
+ const value = params[k];
1006
+ if (value === undefined)
1007
+ return match;
1008
+ const n = Number(value);
1009
+ const form = Number.isFinite(n) && Math.abs(n) === 1 ? one : other;
1010
+ return form.trim();
1011
+ })
1012
+ .replace(/\{(\w+)\}/g, (match, k) => params[k] === undefined ? match : String(params[k]));
993
1013
  };
994
1014
  }
995
1015
  /**
@@ -20,7 +20,7 @@ import { type FlowPhase } from './flow.ts';
20
20
  export interface ImportWizardOptions {
21
21
  port: ImportPort;
22
22
  onProgress?: ProgressListener;
23
- /** 默认回滚策略(场景 E:true=整体回滚;UI 可在执行前覆盖) */
23
+ /** 默认回滚策略(场景 E:true=整体回滚;UI 可在执行前覆盖;缺省取 `DEFAULT_ROLLBACK_ON_ERROR`) */
24
24
  defaultRollbackOnError?: boolean;
25
25
  /**
26
26
  * 渲染层翻译器(UiT)。缺省 = **活**翻译器 `liveUiT`(随 DSH 语言变化)——
@@ -3,6 +3,7 @@ import { EXECUTING_STAGE, IMPORT_STAGES, ProgressTracker } from './progress.js';
3
3
  import { formatActionableError, toActionableError } from './errors.js';
4
4
  import { liveUiT } from './i18n.js';
5
5
  import { nextFlowPhase } from './flow.js';
6
+ import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.js';
6
7
  /**
7
8
  * 凭据补录:把**单个 ref 的输入**合并进当前提交集合(UI-06)。
8
9
  *
@@ -179,7 +180,7 @@ export class ImportWizard {
179
180
  this.port = opts.port;
180
181
  this.onProgress = opts.onProgress;
181
182
  this.tracker = new ProgressTracker(IMPORT_STAGES, this.onProgress);
182
- this.rollbackOnError = opts.defaultRollbackOnError ?? true;
183
+ this.rollbackOnError = opts.defaultRollbackOnError ?? DEFAULT_ROLLBACK_ON_ERROR;
183
184
  this.t = opts.t ?? liveUiT;
184
185
  }
185
186
  /** 当前状态快照(React 绑定 / 测试断言用) */
@@ -101,14 +101,18 @@ export type OverviewHealthKind = 'ok' | 'warn' | 'error';
101
101
  export interface OverviewHealth {
102
102
  kind: OverviewHealthKind;
103
103
  /** UI 渲染 key(`overview.health.*`)。 */
104
- textKey: 'health.ok' | 'health.noBackup' | 'health.scheduleFailed' | 'health.recovery';
104
+ textKey: 'health.ok' | 'health.noBackup' | 'health.scheduleFailed' | 'health.autoBackupOff' | 'health.recovery';
105
105
  }
106
106
  /**
107
107
  * 配置健康判定(保守优先级):
108
108
  * 1. SAFE MODE 激活(有未解决恢复事项)→ error(最高优先,需用户处理);
109
109
  * 2. 已加载且备份与快照均为空 → warn「尚无任何备份」(新装机引导场景);
110
110
  * 3. 定时备份开启但上次运行失败 → warn;
111
- * 4. 其余 → ok。
111
+ * 4. 手上已有备份、但定时备份是关的 → warn「自动备份已关闭」(2026-11-01 视觉 pass 新增:
112
+ * 此前关闭档不判定,于是「All healthy」与「Auto backup Off / Backups 1」同屏并列,
113
+ * 状态条给出的结论与它脚下的指标自相矛盾 —— 一页只在回答「现在安全吗」,
114
+ * 在没有任何后续保护时报健康等于放弃这个结论);
115
+ * 5. 其余 → ok。
112
116
  * 数据未加载(null)不降级健康状态(避免加载闪红)。
113
117
  */
114
118
  export declare function overviewHealth(inputs: OverviewInputs): OverviewHealth;
@@ -149,7 +149,11 @@ export function buildOverviewMetrics(inputs) {
149
149
  * 1. SAFE MODE 激活(有未解决恢复事项)→ error(最高优先,需用户处理);
150
150
  * 2. 已加载且备份与快照均为空 → warn「尚无任何备份」(新装机引导场景);
151
151
  * 3. 定时备份开启但上次运行失败 → warn;
152
- * 4. 其余 → ok。
152
+ * 4. 手上已有备份、但定时备份是关的 → warn「自动备份已关闭」(2026-11-01 视觉 pass 新增:
153
+ * 此前关闭档不判定,于是「All healthy」与「Auto backup Off / Backups 1」同屏并列,
154
+ * 状态条给出的结论与它脚下的指标自相矛盾 —— 一页只在回答「现在安全吗」,
155
+ * 在没有任何后续保护时报健康等于放弃这个结论);
156
+ * 5. 其余 → ok。
153
157
  * 数据未加载(null)不降级健康状态(避免加载闪红)。
154
158
  */
155
159
  export function overviewHealth(inputs) {
@@ -163,6 +167,9 @@ export function overviewHealth(inputs) {
163
167
  if (inputs.schedule !== null && inputs.schedule.enabled && inputs.schedule.lastRunStatus === 'failed') {
164
168
  return { kind: 'warn', textKey: 'health.scheduleFailed' };
165
169
  }
170
+ if (inputs.schedule !== null && !inputs.schedule.enabled) {
171
+ return { kind: 'warn', textKey: 'health.autoBackupOff' };
172
+ }
166
173
  return { kind: 'ok', textKey: 'health.ok' };
167
174
  }
168
175
  /* ---------------- 最近活动 ---------------- */
package/lib/ui/report.js CHANGED
@@ -31,7 +31,8 @@ export function exportCountsText(counts, t) {
31
31
  if (typeof value !== 'number' || !Number.isFinite(value))
32
32
  continue;
33
33
  const unitKey = COUNT_UNIT_KEY[key];
34
- parts.push(unitKey === undefined ? String(value) + ' ' + key : String(value) + ' ' + t(unitKey));
34
+ // 单位词按数量选单复数(en 的 `{n, plural, file|files}`);中文单位不受影响
35
+ parts.push(unitKey === undefined ? String(value) + ' ' + key : String(value) + ' ' + t(unitKey, { n: value }));
35
36
  }
36
37
  // 分隔符也走字典:英文用半角逗号(全角标点在英文界面同样是缺陷)
37
38
  return parts.join(t('report.unitSeparator'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-config-manager",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "description": "Backup, restore, export, import, migrate and sync your DeepSeek Harness (DSH) configuration: settings, plugins, MCP servers, skills, agent presets and workspaces. One-click migration to another machine. DSH 配置一键备份、恢复、导出、导入、迁移与同步插件。",
6
6
  "icon": "./icon.svg",
@@ -203,7 +203,7 @@
203
203
  "react": "^18.3.1",
204
204
  "react-dom": "^18.3.1",
205
205
  "schemastery": "^3.18.0",
206
- "tsdown": "0.23.1",
206
+ "tsdown": "0.23.4",
207
207
  "typescript": "^7.0.2"
208
208
  }
209
209
  }