dsh-config-manager 0.2.3 → 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.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/lib/adapters/self.d.ts +44 -4
- package/lib/adapters/self.js +63 -3
- package/lib/client.d.ts +11 -6
- package/lib/client.js +1256 -1185
- package/lib/core/analyzer.d.ts +3 -0
- package/lib/core/analyzer.js +32 -56
- package/lib/core/backup.d.ts +36 -0
- package/lib/core/backup.js +125 -3
- package/lib/core/import-defaults.d.ts +13 -0
- package/lib/core/import-defaults.js +14 -0
- package/lib/core/importer.d.ts +10 -1
- package/lib/core/importer.js +7 -1
- package/lib/core/messages.d.ts +4 -0
- package/lib/core/messages.js +8 -0
- package/lib/core/model-tools.js +19 -3
- package/lib/index.d.ts +1 -1
- package/lib/index.js +12 -22
- package/lib/routes/import.js +3 -1
- package/lib/routes/recovery.js +226 -2
- package/lib/routes/sync.js +26 -7
- package/lib/security/integrity.d.ts +24 -0
- package/lib/security/integrity.js +30 -0
- package/lib/security/unverified-file-blocks.d.ts +73 -0
- package/lib/security/unverified-file-blocks.js +151 -0
- package/lib/sync/autosync-config.d.ts +8 -4
- package/lib/sync/autosync-config.js +2 -2
- package/lib/sync/autosync-scheduler.d.ts +61 -3
- package/lib/sync/autosync-scheduler.js +87 -10
- package/lib/sync/config-issue-reporting.d.ts +32 -0
- package/lib/sync/config-issue-reporting.js +63 -0
- package/lib/sync/gc-skip.d.ts +42 -0
- package/lib/sync/gc-skip.js +57 -0
- package/lib/sync/git/git-transport.d.ts +56 -2
- package/lib/sync/git/git-transport.js +344 -73
- package/lib/sync/merge-preview-text.d.ts +11 -0
- package/lib/sync/merge-preview-text.js +38 -0
- package/lib/sync/s3/s3-transport.d.ts +8 -1
- package/lib/sync/s3/s3-transport.js +19 -5
- package/lib/sync/session-tombstones.d.ts +53 -0
- package/lib/sync/session-tombstones.js +52 -0
- package/lib/sync/sync-config.d.ts +55 -5
- package/lib/sync/sync-config.js +188 -15
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +63 -85
- package/lib/sync/webdav/webdav-transport.d.ts +11 -1
- package/lib/sync/webdav/webdav-transport.js +22 -6
- package/lib/ui/i18n.js +56 -36
- package/lib/ui/import-wizard.d.ts +1 -1
- package/lib/ui/import-wizard.js +2 -1
- package/lib/ui/overview-view.d.ts +6 -2
- package/lib/ui/overview-view.js +8 -1
- package/lib/ui/report.js +2 -1
- package/lib/utils/git-fast-forward.d.ts +7 -2
- package/lib/utils/git-fast-forward.js +8 -3
- package/package.json +2 -2
- package/src/adapters/self.test.ts +126 -85
- package/src/adapters/self.ts +73 -6
- package/src/client/ConfigManagerSection.tsx +0 -3
- package/src/client/common/Icon.tsx +3 -0
- package/src/client/common/SectionComposition.tsx +6 -1
- package/src/client/common/info-hint-guard.test.ts +7 -5
- package/src/client/config-manager.module.css +254 -13
- package/src/client/environment/EnvironmentPanel.tsx +57 -47
- package/src/client/environment/maintenance/DiskUsageCard.tsx +3 -1
- package/src/client/export/ExportView.tsx +3 -1
- package/src/client/home/HomePanel.tsx +25 -5
- package/src/client/import/ImportWizardView.tsx +1 -3
- package/src/client/import/import-wizard-steps.tsx +1 -17
- package/src/client/library/ArtifactRow.tsx +13 -2
- package/src/client/library/LibraryPanel.tsx +12 -2
- package/src/client/locales.ts +53 -34
- package/src/client/lucide-icons.d.ts +2 -0
- package/src/client/market/MarketPanel.tsx +12 -11
- package/src/client/market/market-locales.ts +16 -16
- package/src/client/recovery/RollbackCard.tsx +4 -2
- package/src/client/recovery/recovery-locales.test.ts +4 -2
- package/src/client/recovery/recovery-locales.ts +23 -23
- package/src/client/sync/AutosyncCard.tsx +36 -10
- package/src/client/sync/SyncChannelEntryCard.tsx +18 -19
- package/src/client/sync/SyncPanel.tsx +32 -33
- package/src/client/sync/SyncProbeRow.tsx +23 -6
- package/src/client/sync/SyncSectionPickerDialog.tsx +2 -2
- package/src/client/sync/sync-locales.ts +24 -24
- package/src/core/analyzer.ts +37 -56
- package/src/core/backup.test.ts +139 -1
- package/src/core/backup.ts +145 -4
- package/src/core/import-defaults.ts +13 -0
- package/src/core/importer.ts +12 -2
- package/src/core/integrity-extra-entries.test.ts +205 -0
- package/src/core/messages.test.ts +3 -1
- package/src/core/messages.ts +8 -0
- package/src/core/model-tools.test.ts +33 -0
- package/src/core/model-tools.ts +20 -4
- package/src/index.ts +12 -21
- package/src/routes/import.ts +4 -1
- package/src/routes/recovery.test.ts +198 -1
- package/src/routes/recovery.ts +194 -1
- package/src/routes/sync.ts +27 -8
- package/src/security/integrity.ts +37 -0
- package/src/security/unverified-file-blocks.ts +192 -0
- package/src/sync/autosync-config.ts +9 -5
- package/src/sync/autosync-scheduler.test.ts +133 -6
- package/src/sync/autosync-scheduler.ts +118 -14
- package/src/sync/config-issue-reporting.test.ts +148 -0
- package/src/sync/config-issue-reporting.ts +75 -0
- package/src/sync/gc-skip.test.ts +66 -0
- package/src/sync/gc-skip.ts +64 -0
- package/src/sync/git/git-probe.test.ts +6 -1
- package/src/sync/git/git-transport.test.ts +351 -14
- package/src/sync/git/git-transport.ts +343 -79
- package/src/sync/merge-preview-text.ts +37 -0
- package/src/sync/s3/s3-delete-index-404.test.ts +26 -1
- package/src/sync/s3/s3-transport.ts +23 -4
- package/src/sync/session-tombstones.ts +90 -0
- package/src/sync/sync-config.test.ts +170 -1
- package/src/sync/sync-config.ts +184 -14
- package/src/sync/sync-engine.test.ts +238 -0
- package/src/sync/sync-engine.ts +79 -83
- package/src/sync/webdav/webdav-delete-index-404.test.ts +29 -1
- package/src/sync/webdav/webdav-transport.ts +26 -5
- package/src/ui/disk-usage-view.test.ts +2 -1
- package/src/ui/i18n.test.ts +47 -0
- package/src/ui/i18n.ts +58 -38
- package/src/ui/import-wizard.test.ts +18 -1
- package/src/ui/import-wizard.ts +3 -2
- package/src/ui/overview-view.test.ts +9 -0
- package/src/ui/overview-view.ts +9 -2
- package/src/ui/report.test.ts +4 -3
- package/src/ui/report.ts +2 -1
- package/src/utils/git-fast-forward.ts +8 -3
package/lib/sync/sync-engine.js
CHANGED
|
@@ -22,6 +22,7 @@ import path from 'node:path';
|
|
|
22
22
|
import { createAdapters } from '../adapters/index.js';
|
|
23
23
|
import { defaultSecretScanner } from '../core/exporter.js';
|
|
24
24
|
import { Importer } from '../core/importer.js';
|
|
25
|
+
import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.js';
|
|
25
26
|
// issue #91:只读预览与基线 hash 的口径判定(本地源插件在预览里不会被 npm pack)
|
|
26
27
|
import { isLocalPluginSpec } from '../core/local-plugin-pack.js';
|
|
27
28
|
import { isFileSection, SECTION_FILE_PREFIXES, SECTION_JSON_PATHS } from '../schema/config.js';
|
|
@@ -34,7 +35,7 @@ import { createSnapshotFs, joinFs } from './fs.js';
|
|
|
34
35
|
import { writeSnapshotToDir } from './layout.js';
|
|
35
36
|
import { credentialsMapFromYaml, decryptCredentialsPayload, decryptSectionsPayload, encryptCredentialsPayload, encryptSectionsPayload, } from './snapshot-crypto.js';
|
|
36
37
|
import { OPT_IN_SYNC_SECTIONS } from './sync-selection.js';
|
|
37
|
-
import {
|
|
38
|
+
import { listRemoteSnapshotIndex, planSessionBookkeeping, remoteTombstonesOf, stripTombstonedSessionsInPlace, } from './session-tombstones.js';
|
|
38
39
|
import { DEFAULT_RETENTION_POLICY, selectPruneCandidatesByPolicy } from './retention-policy.js';
|
|
39
40
|
import { hashSection, loadSyncState, resolveDeviceIdentity, saveSyncState } from './sync-state.js';
|
|
40
41
|
import { isEncryptedSections, PROBE_UNSUPPORTED_CODE, SyncTransportError } from './transport.js';
|
|
@@ -42,8 +43,9 @@ import { msgOf, zhMsg } from '../core/messages.js';
|
|
|
42
43
|
import { engineMsg } from './messages.js';
|
|
43
44
|
import { DEFAULT_ANCESTOR_KEEP, pruneAncestors, tryLoadAncestor, writeAncestor, writeEncryptedAncestor } from './ancestor.js';
|
|
44
45
|
import { merge as mergeSections } from './merge.js';
|
|
46
|
+
import { previewTextOf } from './merge-preview-text.js';
|
|
47
|
+
import { gcSkipWarning, readGcSkipCode, reportGcSkipOnce } from './gc-skip.js';
|
|
45
48
|
import { classifyMergePlan } from './risk.js';
|
|
46
|
-
import { redact } from '../security/redaction.js';
|
|
47
49
|
import { registerMessageCatalog } from '../core/engine-locale.js';
|
|
48
50
|
import { createSnapshot, selectPruneCandidates } from '../core/backup.js';
|
|
49
51
|
import { rollback } from '../core/rollback.js';
|
|
@@ -58,6 +60,7 @@ import { FileSnapshotStore } from '../core/backup.js';
|
|
|
58
60
|
const zh = {
|
|
59
61
|
'sync.pushRemoteAdvanced': '远端最新快照 {remote} 与本地基线 {base} 不一致:本次推送未执行(远端未写入、基线未改动)。请先同步远端改动;确认要用本机覆盖远端时,再显式选择强制推送。',
|
|
60
62
|
'sync.pushAdvanceCheckFailed': '无法确认远端是否已被其它设备推进({reason}):本次推送按原样继续',
|
|
63
|
+
'sync.sessionTombstonesInherited': '已从远端快照继承 {count} 条会话删除墓碑:删除继续跨设备传播(本机数据不动)',
|
|
61
64
|
'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine 缺少 importer(需在 options 中注入)',
|
|
62
65
|
'sync.mergePendingConflicts': '仍有 {count} 个分区未裁决({sections}):已裁决的部分已写入本机,但基线不推进,冲突下一轮会重新出现',
|
|
63
66
|
'sync.baselineRexportFailed': '写回成功但重新导出本机分区失败({sections}):无法证明基线与本机一致,本次**不记基线**(远端指针保持原值,下一轮重新合并收敛)',
|
|
@@ -65,47 +68,12 @@ const zh = {
|
|
|
65
68
|
const en = {
|
|
66
69
|
'sync.pushRemoteAdvanced': 'The newest remote snapshot {remote} differs from the local baseline {base}: this push did not run (nothing written remotely, baseline untouched). Sync the remote changes first, or explicitly force the push if this machine should overwrite the remote.',
|
|
67
70
|
'sync.pushAdvanceCheckFailed': 'Cannot tell whether another device advanced the remote ({reason}): this push continues as-is',
|
|
71
|
+
'sync.sessionTombstonesInherited': 'Inherited {count} session deletion tombstone(s) from the remote snapshot: deletions keep propagating across devices (local data untouched)',
|
|
68
72
|
'sync.applyMergeResolutionsNoImporter': 'applyMergeResolutions: SyncEngine is missing the importer (must be injected via options)',
|
|
69
73
|
'sync.mergePendingConflicts': '{count} section(s) still have no decision ({sections}): the decided parts were written locally, but the baseline is not advanced, so the conflicts will reappear next round',
|
|
70
74
|
'sync.baselineRexportFailed': 'The write succeeded but re-exporting the local section(s) failed ({sections}): the baseline cannot be proven to match this machine, so no baseline was recorded (the remote pointer keeps its old value and the next merge converges again)',
|
|
71
75
|
};
|
|
72
76
|
registerMessageCatalog({ id: 'sync-engine-merge', zh, en });
|
|
73
|
-
/**
|
|
74
|
-
* 单侧预览文本上限(2 KiB **字符**预算):超出截断并追加省略号。
|
|
75
|
-
*
|
|
76
|
-
* 顺序恒为「先 redact 再截断」:反过来会把跨边界的敏感值切成半个形状,让脱敏匹配不到它
|
|
77
|
-
* (等于把秘密截一半发出去)。redact 之后的剩余文本已不含秘密形状,截断才安全。
|
|
78
|
-
*/
|
|
79
|
-
const MERGE_PREVIEW_TEXT_LIMIT = 2048;
|
|
80
|
-
/** 严格 UTF-8 解码器:非法字节序列直接抛错(用于区分文本与二进制文件冲突)。 */
|
|
81
|
-
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true });
|
|
82
|
-
/** 把任意冲突侧值转成**可读字符串**;二进制只回传形状与字节数,绝不回传原始值对象。 */
|
|
83
|
-
function previewTextOf(value) {
|
|
84
|
-
if (value === undefined)
|
|
85
|
-
return '';
|
|
86
|
-
if (typeof value === 'string')
|
|
87
|
-
return finalizePreviewText(value);
|
|
88
|
-
if (value instanceof Uint8Array) {
|
|
89
|
-
try {
|
|
90
|
-
return finalizePreviewText(STRICT_UTF8.decode(value));
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
// 二进制内容解码只会得到乱码(同样误导用户)→ 只回传形状;JSON 是数据形态,不是文案
|
|
94
|
-
return JSON.stringify({ binary: true, bytes: value.length });
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
try {
|
|
98
|
-
return finalizePreviewText(JSON.stringify(value) ?? '');
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
return finalizePreviewText(String(value));
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
/** 预览文本统一出口:先 redact(敏感值绝不回传浏览器),再截断到 2 KiB。 */
|
|
105
|
-
function finalizePreviewText(text) {
|
|
106
|
-
const masked = redact(text);
|
|
107
|
-
return masked.length > MERGE_PREVIEW_TEXT_LIMIT ? `${masked.slice(0, MERGE_PREVIEW_TEXT_LIMIT)}…` : masked;
|
|
108
|
-
}
|
|
109
77
|
/** plan 的单个分区结果 → 预览视图(三侧都只回传可读文本,绝不回传原始值)。 */
|
|
110
78
|
function mergePreviewSectionView(result) {
|
|
111
79
|
const conflicts = result.conflicts.map((c) => ({
|
|
@@ -519,10 +487,13 @@ export class SyncEngine {
|
|
|
519
487
|
if (encrypt && (opts.password === undefined || opts.password === '')) {
|
|
520
488
|
throw new Error(this.msg('sync.encryptRequiresPassword'));
|
|
521
489
|
}
|
|
490
|
+
// SYN-02:本轮只列一次远端 index(推进检测 + 继承会话删除墓碑共用;墓碑累积在最新快照里)
|
|
491
|
+
const remoteIndex = await listRemoteSnapshotIndex(this.transport);
|
|
492
|
+
const remoteTombstones = remoteIndex.reason === undefined ? remoteTombstonesOf(remoteIndex.metas) : [];
|
|
522
493
|
// 远端推进检测:不一致 = 远端被别的设备推过(或本机基线已被裁剪)。
|
|
523
494
|
// 此时推上去会覆盖/架空别人的改动 → 直接返回冲突(**不抛错**、不 upload、不记基线、不改 sync-state)。
|
|
524
495
|
if (opts.force !== true) {
|
|
525
|
-
const check = await this.remoteAdvancedCheck();
|
|
496
|
+
const check = await this.remoteAdvancedCheck(remoteIndex);
|
|
526
497
|
if (check.reason !== undefined) {
|
|
527
498
|
// 读不到远端列表 ⇒ 无法判定。按原样继续(上传本身会再暴露通道问题),但如实说明。
|
|
528
499
|
warnings.push(this.msg('sync.pushAdvanceCheckFailed', { reason: check.reason }));
|
|
@@ -575,11 +546,15 @@ export class SyncEngine {
|
|
|
575
546
|
const credentials = includeSecrets
|
|
576
547
|
? await this.buildCredentialsPayload(opts.password, warnings)
|
|
577
548
|
: undefined;
|
|
578
|
-
// P1-5
|
|
579
|
-
|
|
549
|
+
// P1-5 + SYN-02:本机墓碑(上次推过、本机已删)叠加上远端继承的墓碑 ——
|
|
550
|
+
// 不继承的话「A 删 → B 推」会漏掉墓碑,C 带旧快照就能把已删会话复活(删除只在本机传播)。
|
|
551
|
+
const bookkeeping = await this.computeSessionBookkeeping(targets, plainSections, remoteTombstones);
|
|
580
552
|
if (bookkeeping.deletedNow.length > 0) {
|
|
581
553
|
warnings.push(this.msg('sync.sessionDeletionsRecorded', { count: String(bookkeeping.deletedNow.length) }));
|
|
582
554
|
}
|
|
555
|
+
if (bookkeeping.inherited > 0) {
|
|
556
|
+
warnings.push(this.msg('sync.sessionTombstonesInherited', { count: String(bookkeeping.inherited) }));
|
|
557
|
+
}
|
|
583
558
|
// ⑥ 设备身份(cloud-sync 借鉴项 ⑥):首次生成后持久化在 sync-state.json(已有则纯读),
|
|
584
559
|
// 随 manifest 上行 → 别的设备在历史列表里能看到「这份快照是哪台机器推的」。
|
|
585
560
|
const device = await resolveDeviceIdentity(this.stateDir, os.hostname(), this.fsx, this.msg);
|
|
@@ -747,50 +722,33 @@ export class SyncEngine {
|
|
|
747
722
|
* `tracked=false` 的三种情形一律**不动既有记录**:没推 sessions / 适配器不支持全量枚举 /
|
|
748
723
|
* 本机枚举失败或为空。用「读不到」覆盖成「全删了」会一次性标错几百条墓碑,绝不猜。
|
|
749
724
|
*/
|
|
750
|
-
async computeSessionBookkeeping(targets, plainSections) {
|
|
725
|
+
async computeSessionBookkeeping(targets, plainSections, remoteTombstones = []) {
|
|
751
726
|
const sessions = targets.find((a) => a.id === 'sessions');
|
|
752
727
|
if (sessions === undefined || sessions.listAllUnitIds === undefined) {
|
|
753
|
-
return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
|
|
728
|
+
return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
|
|
754
729
|
}
|
|
755
730
|
let localUnits;
|
|
756
731
|
try {
|
|
757
732
|
localUnits = await sessions.listAllUnitIds(this.ctx);
|
|
758
733
|
}
|
|
759
734
|
catch {
|
|
760
|
-
return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
|
|
735
|
+
return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
|
|
761
736
|
}
|
|
762
737
|
if (localUnits.length === 0) {
|
|
763
|
-
return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [] };
|
|
738
|
+
return { tracked: false, sessionUnits: [], tombstones: [], deletedNow: [], inherited: 0 };
|
|
764
739
|
}
|
|
765
740
|
const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
|
|
766
|
-
const { tombstones, deletedNow } = nextSessionTombstones({
|
|
767
|
-
previousUnits: state.sessionUnits ?? [],
|
|
768
|
-
localUnits,
|
|
769
|
-
previousTombstones: state.deletedSessions ?? [],
|
|
770
|
-
});
|
|
771
741
|
return {
|
|
772
742
|
tracked: true,
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
743
|
+
...planSessionBookkeeping({
|
|
744
|
+
previousUnits: state.sessionUnits ?? [],
|
|
745
|
+
previousTombstones: state.deletedSessions ?? [],
|
|
746
|
+
localUnits,
|
|
747
|
+
exportedSessions: plainSections['sessions'],
|
|
748
|
+
remoteTombstones,
|
|
749
|
+
}),
|
|
776
750
|
};
|
|
777
751
|
}
|
|
778
|
-
/**
|
|
779
|
-
* P1-5:按远端墓碑剔除**将要导入**的会话(旧快照不得复活已删除的对话)。
|
|
780
|
-
* 只动载荷,不动本机已有数据 —— 墓碑不代用户删除本机文件(那是不可回滚的用户动作)。
|
|
781
|
-
* @returns 被剔除的会话单元 id(调用方据此出可见报告,绝不静默)
|
|
782
|
-
*/
|
|
783
|
-
stripTombstonedSessions(snapshot) {
|
|
784
|
-
const tombstones = snapshot.manifest.deletedSessions ?? [];
|
|
785
|
-
if (tombstones.length === 0 || isEncryptedSections(snapshot.sections))
|
|
786
|
-
return [];
|
|
787
|
-
const plain = snapshot.sections;
|
|
788
|
-
const stripped = stripTombstonedUnits(plain['sessions'], tombstones);
|
|
789
|
-
if (stripped.removed.length === 0)
|
|
790
|
-
return [];
|
|
791
|
-
plain['sessions'] = stripped.section;
|
|
792
|
-
return stripped.removed;
|
|
793
|
-
}
|
|
794
752
|
/**
|
|
795
753
|
* 裁剪远端快照:调用 transport.list() 获取全部快照(按 createdAt 升序),
|
|
796
754
|
* 按 retentionPolicyProvider() 给出的 GFS 策略(最近 N / 每月一份 / 每年一份)
|
|
@@ -822,6 +780,10 @@ export class SyncEngine {
|
|
|
822
780
|
const doomed = selectPruneCandidatesByPolicy(metas.map((m) => ({ id: m.id, createdAt: m.createdAt })), policy, selectPruneCandidates);
|
|
823
781
|
// 刚 push 的快照永不进删除集(防御性:策略三层全关时也保住它)
|
|
824
782
|
const keep = new Set([pushedId]);
|
|
783
|
+
// SYN-04b:本轮 GC 是否已放弃 —— 每次 transport.delete() 内部会重跑一轮 blob GC,
|
|
784
|
+
// 而放弃码是「本轮」的结论;同一次 push 里删 N 个快照会设置 N 次同一个原因,
|
|
785
|
+
// 逐条报会把「一样的事实」刷 N 遍,所以本轮只报一次。
|
|
786
|
+
const gcReported = { done: false };
|
|
825
787
|
for (const m of metas) {
|
|
826
788
|
if (doomed.includes(m.id) && !keep.has(m.id)) {
|
|
827
789
|
try {
|
|
@@ -831,6 +793,7 @@ export class SyncEngine {
|
|
|
831
793
|
const reason = err instanceof Error ? err.message : String(err);
|
|
832
794
|
warnings.push(this.msg('sync.pruneDeleteFailed', { id: m.id, reason }));
|
|
833
795
|
}
|
|
796
|
+
reportGcSkipOnce(this.transport, this.msg, warnings, gcReported);
|
|
834
797
|
}
|
|
835
798
|
}
|
|
836
799
|
}
|
|
@@ -852,19 +815,17 @@ export class SyncEngine {
|
|
|
852
815
|
* - 其余:远端最新快照 id !== lastSnapshotId ⇒ 远端被推进(或本机基线已被裁剪)→ 拦。
|
|
853
816
|
* 两种情况都**无法安全断言「远端没变」**,保守拒绝并给出 force 出口。
|
|
854
817
|
* - list() 抛错 → 返回 reason(调用方按原样继续并告警),绝不把「读不到」当成「没变」。
|
|
818
|
+
* - SYN-02:index 可由 push() 传入(同一轮只列一次远端),缺省自己 list(其它调用点行为不变)。
|
|
855
819
|
*/
|
|
856
|
-
async remoteAdvancedCheck() {
|
|
820
|
+
async remoteAdvancedCheck(index) {
|
|
857
821
|
const state = await loadSyncState(this.stateDir, this.fsx, this.msg);
|
|
858
822
|
const base = state.lastSnapshotId;
|
|
859
823
|
if (base === '')
|
|
860
824
|
return { blocked: false, base, latest: '' };
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
catch (err) {
|
|
866
|
-
return { blocked: false, base, latest: '', reason: err instanceof Error ? err.message : String(err) };
|
|
867
|
-
}
|
|
825
|
+
const listed = index ?? await listRemoteSnapshotIndex(this.transport);
|
|
826
|
+
if (listed.reason !== undefined)
|
|
827
|
+
return { blocked: false, base, latest: '', reason: listed.reason };
|
|
828
|
+
const metas = listed.metas;
|
|
868
829
|
if (metas.length === 0)
|
|
869
830
|
return { blocked: false, base, latest: '' };
|
|
870
831
|
const latest = metas[metas.length - 1].id; // list 按 createdAt 升序 → 最新在末
|
|
@@ -888,7 +849,7 @@ export class SyncEngine {
|
|
|
888
849
|
// 加密快照 → 用密码解密回明文(无密码明确报错);普通快照 → containsSecrets 拒绝
|
|
889
850
|
await this.prepareSnapshot(snapshot, opts.password);
|
|
890
851
|
// P1-5:按远端墓碑剔除已删除的会话(旧快照不复活)
|
|
891
|
-
const tombstoned =
|
|
852
|
+
const tombstoned = stripTombstonedSessionsInPlace(snapshot);
|
|
892
853
|
// issue #38:快照携带凭据载荷 → 解密出 ref→值(仅内存),供计划项与后续 apply 使用
|
|
893
854
|
const credentials = await this.decryptSnapshotCredentials(snapshot, opts.password);
|
|
894
855
|
const portableIds = this.pullSectionIds();
|
|
@@ -951,7 +912,7 @@ export class SyncEngine {
|
|
|
951
912
|
await this.prepareSnapshot(snapshot, opts.password);
|
|
952
913
|
// audit-sync sync-F6:剔除结果必须**可见**(与 pull/preview 同一口径)。
|
|
953
914
|
// 此前返回值丢弃了它 —— 产物里少了哪些会话,调用方与用户都无从得知(绝不静默少带)。
|
|
954
|
-
const tombstonedSessions =
|
|
915
|
+
const tombstonedSessions = stripTombstonedSessionsInPlace(snapshot).length;
|
|
955
916
|
const portableIds = this.pullSectionIds();
|
|
956
917
|
const zipPath = await this.snapshotToZip(snapshot, portableIds);
|
|
957
918
|
// snapshotToZip 产出在临时目录;搬到调用方指定目录(同盘 rename,跨盘退化为复制)
|
|
@@ -978,9 +939,26 @@ export class SyncEngine {
|
|
|
978
939
|
* - **与保留策略裁剪共用同一条 `transport.delete`**(两条通道的删除语义只有一份实现),
|
|
979
940
|
* 区别只在「谁点名」:这里是用户显式指定的 id,pruneRemoteSnapshots 由 RetentionPolicy 挑;
|
|
980
941
|
* - **不存在视为成功**(SyncTransport 契约),所以重复点删除 / 已被裁剪掉都不会报错。
|
|
942
|
+
*
|
|
943
|
+
* F3:通道侧的 blob GC 可能「放弃本轮」(SYN-04:引用快照 404 / 读不出 ⇒ 证明不了没人引用)。
|
|
944
|
+
* 放弃不是删除失败,但**必须可见**(否则孤儿 blob 无人知晓地一直堆)⇒ 放弃码用与 push 报告
|
|
945
|
+
* 同一条 `gcSkipWarning` 渲染进返回体的 warnings。
|
|
981
946
|
*/
|
|
982
947
|
async deleteSnapshot(id) {
|
|
983
948
|
await this.transport.delete(id);
|
|
949
|
+
// transport.delete 内部是 await 完 gcBlobStore 才返回的(两条通道同型),所以此时取到的是
|
|
950
|
+
// **本次删除那一轮**的放弃码,而不是上一轮的残留。
|
|
951
|
+
const code = this.lastGcSkipCode();
|
|
952
|
+
return code === null ? { warnings: [] } : { warnings: [gcSkipWarning(this.msg, code)] };
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* SYN-04b:通道最近一轮 GC 的放弃码(无则 null)。
|
|
956
|
+
*
|
|
957
|
+
* 保留为 public 只读出口(不删):属本类已发布的可见 API,路由/诊断不跑删除流程也能读到通道能力;
|
|
958
|
+
* 引擎内已由 deleteSnapshot 消费(放弃码 → warnings),不再是死方法。读不到(通道没这能力)= null。
|
|
959
|
+
*/
|
|
960
|
+
lastGcSkipCode() {
|
|
961
|
+
return readGcSkipCode(this.transport);
|
|
984
962
|
}
|
|
985
963
|
/** 列出远端已有快照(按 createdAt 升序)—— 供「选择历史快照」下拉。 */
|
|
986
964
|
async listSnapshots() {
|
|
@@ -1083,7 +1061,7 @@ export class SyncEngine {
|
|
|
1083
1061
|
// 加密快照 → 用密码解密回明文(无密码明确报错);普通快照 → containsSecrets 拒绝
|
|
1084
1062
|
await this.prepareSnapshot(snapshot, opts.password);
|
|
1085
1063
|
// P1-5:按远端墓碑剔除已删除的会话(旧快照不复活;一键同步复用本 plan)
|
|
1086
|
-
const tombstoned =
|
|
1064
|
+
const tombstoned = stripTombstonedSessionsInPlace(snapshot);
|
|
1087
1065
|
// issue #38:凭据载荷解密(仅内存;由调用方登记进同步会话,apply-items 时写回本机)
|
|
1088
1066
|
const credentials = await this.decryptSnapshotCredentials(snapshot, opts.password);
|
|
1089
1067
|
const portableIds = this.pullSectionIds();
|
|
@@ -1128,7 +1106,7 @@ export class SyncEngine {
|
|
|
1128
1106
|
// issue #107:记下远端快照是否加密 —— applyMergePlan 据此决定祖先副本也落密文
|
|
1129
1107
|
this.lastMergedRemoteEncrypted = remote.manifest.encrypted === true;
|
|
1130
1108
|
// P1-5:按远端墓碑剔除已删除的会话(三方合并的 remote 侧同样不许复活)
|
|
1131
|
-
|
|
1109
|
+
stripTombstonedSessionsInPlace(remote);
|
|
1132
1110
|
// 本次合并的远端基准(P0-7):紧随其后的 applyMergePlan() 用它写基线远端指针
|
|
1133
1111
|
this.lastMergedRemoteSnapshotId = targetId;
|
|
1134
1112
|
// issue #45:把远端快照的 sourceHome 与用户映射一并备忘 —— applyMergePlan 重建临时 ZIP 时
|
|
@@ -1571,12 +1549,12 @@ export class SyncEngine {
|
|
|
1571
1549
|
warnings: [this.msg('sync.preApplySnapshotFailed', { err: backupErr instanceof Error ? backupErr.message : String(backupErr) })],
|
|
1572
1550
|
};
|
|
1573
1551
|
}
|
|
1574
|
-
// 3) 真正执行:Importer.executeImportPlan(rollbackOnError
|
|
1552
|
+
// 3) 真正执行:Importer.executeImportPlan(rollbackOnError 取单一缺省 → 任一失败整体回滚)
|
|
1575
1553
|
// 自动同步路径没有密码(凭据载荷无法解密),因此恒不注入 decryptedCredentials;
|
|
1576
1554
|
// 加密快照本就不会进入自动同步(无密码无法 prepareSnapshot)。
|
|
1577
1555
|
const result = await this.importer.executeImportPlan(zipPath, plan, {
|
|
1578
1556
|
confirm: true,
|
|
1579
|
-
rollbackOnError:
|
|
1557
|
+
rollbackOnError: DEFAULT_ROLLBACK_ON_ERROR,
|
|
1580
1558
|
secretInputs: undefined,
|
|
1581
1559
|
decryptedCredentials: undefined,
|
|
1582
1560
|
// 审计 P0-23:把 journal 绑定面透传给引擎(自动同步路径由 autosync-scheduler 提供),
|
|
@@ -1680,10 +1658,10 @@ export class SyncEngine {
|
|
|
1680
1658
|
result: null,
|
|
1681
1659
|
};
|
|
1682
1660
|
}
|
|
1683
|
-
// 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError
|
|
1661
|
+
// 真正执行:Importer.executeImportPlan(confirm:true + rollbackOnError 取单一缺省 → 任一失败整体回滚)
|
|
1684
1662
|
const result = await this.importer.executeImportPlan(zipPath, subPlan, {
|
|
1685
1663
|
confirm: true,
|
|
1686
|
-
rollbackOnError:
|
|
1664
|
+
rollbackOnError: DEFAULT_ROLLBACK_ON_ERROR,
|
|
1687
1665
|
secretInputs: undefined,
|
|
1688
1666
|
// issue #38:快照凭据载荷解出的 ref→值(仅内存)→ credentials adapter 写回本机
|
|
1689
1667
|
decryptedCredentials: opts.credentials,
|
|
@@ -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
|
-
|
|
595
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
569
|
-
'report.unit.files': 'file
|
|
570
|
-
'report.unit.servers': 'server
|
|
571
|
-
'report.unit.providers': 'provider
|
|
572
|
-
'report.unit.plugins': 'plugin
|
|
573
|
-
'report.unit.patchLines': 'patch line
|
|
574
|
-
'report.unit.patchFiles': 'patch file
|
|
575
|
-
'report.unit.localTarballs': 'local package
|
|
576
|
-
'report.unit.prompts': 'prompt
|
|
577
|
-
'report.unit.workspaces': 'workspace
|
|
578
|
-
'report.unit.credentials': 'credential status item
|
|
579
|
-
'report.unit.notes': 'note
|
|
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
|
|
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
|
|
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
|
|
679
|
-
'sync.importPartial': 'Partly successful: {n} item
|
|
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
|
|
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
|
|
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
|
|
739
|
-
'consult.attention': '{count} item
|
|
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
|
|
743
|
-
'consult.items': '{count} item
|
|
744
|
-
'consult.conflicts': '{count} conflict
|
|
745
|
-
'consult.risks': '{count} risk
|
|
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
|
|
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
|
|
784
|
-
'diskUsage.retention.hours': '{count} hour
|
|
785
|
-
'diskUsage.retention.minutes': '{count} minute
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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 语言变化)——
|
package/lib/ui/import-wizard.js
CHANGED
|
@@ -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 ??
|
|
183
|
+
this.rollbackOnError = opts.defaultRollbackOnError ?? DEFAULT_ROLLBACK_ON_ERROR;
|
|
183
184
|
this.t = opts.t ?? liveUiT;
|
|
184
185
|
}
|
|
185
186
|
/** 当前状态快照(React 绑定 / 测试断言用) */
|