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.
- 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 +1263 -1192
- 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 +10 -0
- package/lib/sync/git/git-transport.js +72 -14
- 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/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 +3 -2
- package/src/sync/git/git-transport.test.ts +168 -7
- package/src/sync/git/git-transport.ts +70 -14
- 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
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
import { requestOnce, type RawResponse } from '../../utils/proxy.ts';
|
|
29
29
|
import { zhMsg } from '../../core/messages.ts';
|
|
30
30
|
import { composeMsg } from '../s3/messages.ts';
|
|
31
|
+
import { GC_SKIP_REFERENCE_MISSING, GC_SKIP_REFERENCE_UNREADABLE, GC_SKIP_SNAPSHOT_UNPARSABLE } from '../gc-skip.ts';
|
|
32
|
+
import type { GcSkipCode } from '../gc-skip.ts';
|
|
31
33
|
// t6:「英文界面零中文」——注册同步域追加目录(this.o.msg 缺省 = zhMsg,其查找链会查注册目录;
|
|
32
34
|
// sync.webdav.blobHashInvalid 这类新键必须能解析,绝不露裸键名)。
|
|
33
35
|
import '../messages.ts';
|
|
@@ -302,6 +304,14 @@ export class WebDavTransport implements SyncTransport {
|
|
|
302
304
|
msg: MsgFunc;
|
|
303
305
|
};
|
|
304
306
|
|
|
307
|
+
/** 最近一轮 blob GC 的放弃码(`null` = 跑完或未触发;SYN-04)。 */
|
|
308
|
+
private gcSkip: GcSkipCode | null = null;
|
|
309
|
+
|
|
310
|
+
/** 最近一轮 blob GC 是否因「无法证明引用集完整」而整体放弃(`null` = 正常跑完),供上层给用户提示。 */
|
|
311
|
+
get lastGcSkip(): GcSkipCode | null {
|
|
312
|
+
return this.gcSkip;
|
|
313
|
+
}
|
|
314
|
+
|
|
305
315
|
constructor(options: WebDavTransportOptions) {
|
|
306
316
|
this.o = {
|
|
307
317
|
baseUrl: '',
|
|
@@ -662,22 +672,33 @@ export class WebDavTransport implements SyncTransport {
|
|
|
662
672
|
|
|
663
673
|
/**
|
|
664
674
|
* blob 仓 GC(P1-4):逐份读取**仍存在**的快照 JSON,收集被引用的哈希,删除无人引用且
|
|
665
|
-
* 超过保护窗口的 blob
|
|
675
|
+
* 超过保护窗口的 blob。读不坏任何一份快照 → 正常回收;否则本轮整体放弃(宁可留垃圾,不可删在用的)。
|
|
676
|
+
*
|
|
677
|
+
* SYN-04(P1):**404 也放弃本轮**。旧实现把「索引有条目但快照文件 404」当作「文件不存在 ⇒ 不可能
|
|
678
|
+
* 引用任何 blob」直接 continue,于是引用集只剩「读得出来的那几份」,仍被该快照(假 404 / 孤儿条目)
|
|
679
|
+
* 使用的 blob 就被判成无人引用而删除 —— 不可恢复。拿不到内容就无法证明「没人引用」,与「读不出来」
|
|
680
|
+
* 是同一件事 ⇒ 一律放弃并记码(sync-N1 同族口径)。
|
|
666
681
|
*/
|
|
667
682
|
private async gcBlobStore(pwd: string, remaining: SyncSnapshotMeta[]): Promise<void> {
|
|
683
|
+
this.gcSkip = null;
|
|
668
684
|
const index = await this.readBlobIndex(pwd);
|
|
669
685
|
if (Object.keys(index).length === 0) return;
|
|
670
686
|
const referenced = new Set<string>();
|
|
671
687
|
for (const meta of remaining) {
|
|
672
688
|
const res = await this.send('GET', this.snapshotUrl(meta.id), pwd);
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
689
|
+
if (res.status === 404) {
|
|
690
|
+
this.gcSkip = GC_SKIP_REFERENCE_MISSING;
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
if (!res.ok) {
|
|
694
|
+
this.gcSkip = GC_SKIP_REFERENCE_UNREADABLE;
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
677
697
|
let snap: SyncSnapshot;
|
|
678
698
|
try {
|
|
679
699
|
snap = deserializeSnapshot(await res.text());
|
|
680
700
|
} catch {
|
|
701
|
+
this.gcSkip = GC_SKIP_SNAPSHOT_UNPARSABLE;
|
|
681
702
|
return;
|
|
682
703
|
}
|
|
683
704
|
const plain = snap.sections as Partial<Record<SectionId, unknown>>;
|
|
@@ -134,7 +134,8 @@ test('formatRetention:天 / 小时 / 缺失三档(en 与 zh 都走字典)'
|
|
|
134
134
|
assert.equal(formatRetention(6 * 60 * 60 * 1000, zh), '6 小时');
|
|
135
135
|
assert.equal(formatRetention(30 * 60 * 1000, zh), '30 分钟');
|
|
136
136
|
assert.equal(formatRetention(0, zh), zh('diskUsage.retention.none'));
|
|
137
|
-
assert.equal(formatRetention(2 * DAY, en), '2
|
|
137
|
+
assert.equal(formatRetention(2 * DAY, en), '2 days');
|
|
138
|
+
assert.equal(formatRetention(DAY, en), '1 day', '单数(今日起由 plural 段选形)');
|
|
138
139
|
});
|
|
139
140
|
|
|
140
141
|
test('diskUsageViewModel:英文界面下所有标签来自 en 字典(不回落中文)', () => {
|
package/src/ui/i18n.test.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import test from 'node:test';
|
|
21
21
|
import assert from 'node:assert/strict';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
22
23
|
|
|
23
24
|
import { makeUiT, uiEn, uiZh, zhUiT } from './i18n.ts';
|
|
24
25
|
import {
|
|
@@ -145,3 +146,49 @@ test('i18n-05 默认超时路径走 error.requestTimeout(真实请求:getJso
|
|
|
145
146
|
return true;
|
|
146
147
|
});
|
|
147
148
|
});
|
|
149
|
+
|
|
150
|
+
// 复数语法(2026-11-01):`{n, plural, one|other}`。英文 (s)/(ies) 括号式复数全站清零后,
|
|
151
|
+
// 单复数由这条替换链决定 —— 它错了整站英文都会退回 "1 files"。
|
|
152
|
+
test('i18n-06 plural 段按数量选形:1/-1 取 one,0/2/非数取 other', () => {
|
|
153
|
+
assert.equal(enT('report.unit.files', { n: 1 }), 'file');
|
|
154
|
+
assert.equal(enT('report.unit.files', { n: 2 }), 'files');
|
|
155
|
+
assert.equal(enT('report.unit.files', { n: 0 }), 'files');
|
|
156
|
+
assert.equal(enT('report.unit.files', { n: -1 }), 'file', '负数是绝对值为 1,不当成复数');
|
|
157
|
+
assert.equal(enT('report.unit.files', { n: '1' }), 'file', '占位符值是字符串也要认');
|
|
158
|
+
assert.equal(enT('report.unit.files', { n: 'n/a' }), 'files', '无法解析成数字时按复数兜底,不抛错');
|
|
159
|
+
// 缺参时与普通占位符同口径:原样保留模板(调用点漏传会显形,而不是静默选错形)
|
|
160
|
+
assert.equal(enT('report.unit.files'), '{n, plural, file|files}');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('i18n-07 plural 段与数字并存:数字仍由普通占位符渲染,且只出现一次', () => {
|
|
164
|
+
const en = enT('consult.attention', { count: 1 });
|
|
165
|
+
assert.equal(en, '1 item needs attention');
|
|
166
|
+
assert.equal(enT('consult.attention', { count: 3 }), '3 items need attention');
|
|
167
|
+
// 动词单复数必须跟着变:`3 item need` / `1 items did` 这类残留是这条断言的主要目标
|
|
168
|
+
assert.equal(
|
|
169
|
+
enT('sync.importPartial', { n: 1 }),
|
|
170
|
+
'Partly successful: 1 item did not take effect (the rest were written; nothing rolled back)',
|
|
171
|
+
);
|
|
172
|
+
assert.equal(
|
|
173
|
+
enT('sync.importPartial', { n: 4 }),
|
|
174
|
+
'Partly successful: 4 items did not take effect (the rest were written; nothing rolled back)',
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('i18n-08 英文字典不留 (s)/(ies) 括号式复数(今日起由 plural 段承担)', () => {
|
|
179
|
+
const offenders: string[] = [];
|
|
180
|
+
const collect = (dict: Record<string, string>, label: string) => {
|
|
181
|
+
for (const [key, value] of Object.entries(dict)) {
|
|
182
|
+
if (/\(s\)|\(ies\)/.test(value) && !/http\(s\)/.test(value)) offenders.push(label + ':' + key + ' = ' + value);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
collect(uiEn, 'uiEn');
|
|
186
|
+
// 客户端四本字典同查:改的是源码文本,不 import(client 侧不保证可被 node 直接加载)
|
|
187
|
+
for (const rel of ['../client/locales.ts', '../client/sync/sync-locales.ts', '../client/market/market-locales.ts', '../client/recovery/recovery-locales.ts']) {
|
|
188
|
+
const text = readFileSync(new URL(rel, import.meta.url), 'utf8');
|
|
189
|
+
for (const line of text.split('\n')) {
|
|
190
|
+
if (/\((s|ies)\)/.test(line) && !/http\(s\)/.test(line) && /:\s*'/.test(line)) offenders.push(rel + ' :: ' + line.trim());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
assert.deepEqual(offenders, [], '括号式复数会让 1 渲染成 "1 file(s)";改用 {n, plural, file|files}');
|
|
194
|
+
});
|
package/src/ui/i18n.ts
CHANGED
|
@@ -528,14 +528,14 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
528
528
|
'report.backupCreated': 'Backup Created',
|
|
529
529
|
'report.importComplete': 'Import Complete',
|
|
530
530
|
'report.importFailed': 'Import Failed',
|
|
531
|
-
'report.tombstonedSkipped': '{count} item
|
|
531
|
+
'report.tombstonedSkipped': '{count} {count, plural, item|items} skipped by deletion records',
|
|
532
532
|
'report.included': 'Included:',
|
|
533
533
|
'report.excluded': 'Excluded:',
|
|
534
534
|
'report.security': 'Security:',
|
|
535
535
|
'report.apiKeysExcluded': 'API Keys excluded:',
|
|
536
536
|
'report.containsSecrets': 'Contains secrets:',
|
|
537
537
|
'report.encrypted': 'Encrypted:',
|
|
538
|
-
'report.redacted': '{count} sensitive field
|
|
538
|
+
'report.redacted': '{count} sensitive {count, plural, field|fields} redacted',
|
|
539
539
|
'report.file': 'File:',
|
|
540
540
|
'report.yes': 'yes',
|
|
541
541
|
'report.yesEncrypted': 'yes (encrypted)',
|
|
@@ -553,12 +553,12 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
553
553
|
'report.other': 'Other',
|
|
554
554
|
'report.fullText': 'Full text report (with per-item reasons)',
|
|
555
555
|
'report.secrets': 'Secrets:',
|
|
556
|
-
'report.credentialsNeedEntry': '{count} credential
|
|
556
|
+
'report.credentialsNeedEntry': '{count} {count, plural, credential needs|credentials need} to be entered',
|
|
557
557
|
'report.restartPluginsMcp': 'Plugin / MCP changes take effect after restarting DSH',
|
|
558
558
|
'report.rollbackFull': 'Rollback: fully restored the pre-import configuration.',
|
|
559
559
|
'report.rollbackPartial': 'Rollback partially completed.',
|
|
560
560
|
'report.rollbackManual': 'These items may require manual recovery:',
|
|
561
|
-
'report.restored': 'Restored: {count} item
|
|
561
|
+
'report.restored': 'Restored: {count} {count, plural, item|items}',
|
|
562
562
|
'report.includedCount': '{count} included',
|
|
563
563
|
'report.excludedCount': '{count} excluded',
|
|
564
564
|
'report.badgeEncrypted': 'encrypted',
|
|
@@ -569,18 +569,18 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
569
569
|
'report.includedSections': 'Included sections',
|
|
570
570
|
'report.excludedSections': 'Excluded sections',
|
|
571
571
|
'report.warnings': 'Warnings',
|
|
572
|
-
'report.unit.namespaces': 'namespace
|
|
573
|
-
'report.unit.files': 'file
|
|
574
|
-
'report.unit.servers': 'server
|
|
575
|
-
'report.unit.providers': 'provider
|
|
576
|
-
'report.unit.plugins': 'plugin
|
|
577
|
-
'report.unit.patchLines': 'patch line
|
|
578
|
-
'report.unit.patchFiles': 'patch file
|
|
579
|
-
'report.unit.localTarballs': 'local package
|
|
580
|
-
'report.unit.prompts': 'prompt
|
|
581
|
-
'report.unit.workspaces': 'workspace
|
|
582
|
-
'report.unit.credentials': 'credential status item
|
|
583
|
-
'report.unit.notes': 'note
|
|
572
|
+
'report.unit.namespaces': '{n, plural, namespace|namespaces}',
|
|
573
|
+
'report.unit.files': '{n, plural, file|files}',
|
|
574
|
+
'report.unit.servers': '{n, plural, server|servers}',
|
|
575
|
+
'report.unit.providers': '{n, plural, provider|providers}',
|
|
576
|
+
'report.unit.plugins': '{n, plural, plugin|plugins}',
|
|
577
|
+
'report.unit.patchLines': '{n, plural, patch line|patch lines}',
|
|
578
|
+
'report.unit.patchFiles': '{n, plural, patch file|patch files}',
|
|
579
|
+
'report.unit.localTarballs': '{n, plural, local package|local packages}',
|
|
580
|
+
'report.unit.prompts': '{n, plural, prompt|prompts}',
|
|
581
|
+
'report.unit.workspaces': '{n, plural, workspace|workspaces}',
|
|
582
|
+
'report.unit.credentials': '{n, plural, credential status item|credential status items}',
|
|
583
|
+
'report.unit.notes': '{n, plural, note|notes}',
|
|
584
584
|
'error.notMounted': 'config-manager service is not mounted (plugin not loaded): make sure dsh-config-manager is installed in the profile and restart DSH',
|
|
585
585
|
'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)',
|
|
586
586
|
'error.httpInvalidJson': 'HTTP {status}: invalid JSON response',
|
|
@@ -671,16 +671,16 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
671
671
|
'sync.pushFailed': 'Push failed',
|
|
672
672
|
'sync.pushOk': 'Push succeeded (snapshot {id})',
|
|
673
673
|
'sync.conflictUnresolved': 'Conflict item {id} must be resolved first (keep current / use imported)',
|
|
674
|
-
'sync.pushPreviewHeadline': 'Will push {total} section
|
|
674
|
+
'sync.pushPreviewHeadline': 'Will push {total} {total, plural, section|sections} ({changed} changed)',
|
|
675
675
|
'sync.pushPreviewEncrypted': 'Encrypted snapshot: the payload is encrypted as a whole; per-section changes vs the baseline cannot be compared.',
|
|
676
676
|
'sync.pushPreviewCredentials': 'This push includes real credential values (encrypted with the payload; the remote only ever sees ciphertext).',
|
|
677
677
|
'sync.pullFailed': 'Pull failed',
|
|
678
|
-
'sync.pullOk': 'Remote snapshot {id} diff preview: {count} change
|
|
678
|
+
'sync.pullOk': 'Remote snapshot {id} diff preview: {count} {count, plural, change|changes}',
|
|
679
679
|
'sync.pullEmpty': 'Remote snapshot matches local (no changes)',
|
|
680
680
|
'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.',
|
|
681
681
|
'sync.syncing': 'Syncing…',
|
|
682
|
-
'sync.importDone': 'Imported {n} section
|
|
683
|
-
'sync.importPartial': 'Partly successful: {n} item
|
|
682
|
+
'sync.importDone': 'Imported {n} {n, plural, section|sections}',
|
|
683
|
+
'sync.importPartial': 'Partly successful: {n} {n, plural, item did|items did} not take effect (the rest were written; nothing rolled back)',
|
|
684
684
|
'sync.importFailed': 'Import failed (rolled back)',
|
|
685
685
|
'sync.autosyncNever': 'Never run',
|
|
686
686
|
'sync.autosyncLastRun': 'Last run: {time}',
|
|
@@ -688,7 +688,7 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
688
688
|
'sync.autosyncSkipped': 'Skipped',
|
|
689
689
|
'sync.autosyncFailed': 'Failed',
|
|
690
690
|
'sync.autosyncPartial': 'Partial',
|
|
691
|
-
'sync.autosyncFailCount': '{n} consecutive failure
|
|
691
|
+
'sync.autosyncFailCount': '{n} consecutive {n, plural, failure|failures}',
|
|
692
692
|
'sync.duration.min': '{n} min',
|
|
693
693
|
'sync.duration.hour': '{n} hr',
|
|
694
694
|
'sync.duration.day': '{n} day',
|
|
@@ -707,7 +707,7 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
707
707
|
'sync.github.pollError': 'GitHub OAuth error: {detail}',
|
|
708
708
|
'sync.github.unknownError': 'Unknown error',
|
|
709
709
|
'market.statusUnconfigured': 'No markets added (enter a public Git repository URL)',
|
|
710
|
-
'market.statusConfigured': '{count} market
|
|
710
|
+
'market.statusConfigured': '{count} {count, plural, market|markets} added',
|
|
711
711
|
'market.supplyUnofficial': 'From the public network, not officially reviewed',
|
|
712
712
|
'market.supplySource': 'Source repo: {url}',
|
|
713
713
|
'market.supplyDownloadedAt': 'Downloaded at: {time}',
|
|
@@ -739,18 +739,18 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
739
739
|
'consult.title': 'Migration Pre-flight Consultation',
|
|
740
740
|
'consult.healthScore': 'Health score {score}',
|
|
741
741
|
'consult.dryRun': 'Read-only analysis',
|
|
742
|
-
'consult.blockers': '{count} hard blocker
|
|
743
|
-
'consult.attention': '{count} item
|
|
742
|
+
'consult.blockers': '{count} hard {count, plural, blocker|blockers} (cannot import safely until resolved)',
|
|
743
|
+
'consult.attention': '{count} {count, plural, item needs|items need} attention',
|
|
744
744
|
'consult.reasons': 'Recommendation basis',
|
|
745
745
|
'consult.willApply': 'Will apply',
|
|
746
|
-
'consult.sections': '{count} section
|
|
747
|
-
'consult.items': '{count} item
|
|
748
|
-
'consult.conflicts': '{count} conflict
|
|
749
|
-
'consult.risks': '{count} risk
|
|
746
|
+
'consult.sections': '{count} {count, plural, section|sections}',
|
|
747
|
+
'consult.items': '{count} {count, plural, item|items}',
|
|
748
|
+
'consult.conflicts': '{count} {count, plural, conflict|conflicts}',
|
|
749
|
+
'consult.risks': '{count} {count, plural, risk|risks}',
|
|
750
750
|
'consult.dimensions': 'Score dimensions',
|
|
751
751
|
'consult.loading': 'Generating consultation report…',
|
|
752
752
|
'diskUsage.title': 'Disk Usage',
|
|
753
|
-
'diskUsage.files': '{count} file
|
|
753
|
+
'diskUsage.files': '{count} {count, plural, file|files}',
|
|
754
754
|
'diskUsage.refresh': 'Re-scan',
|
|
755
755
|
'diskUsage.loading': 'Measuring disk usage…',
|
|
756
756
|
'diskUsage.unreadable': 'not measured',
|
|
@@ -784,9 +784,9 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
784
784
|
'diskUsage.area.vault': 'Sensitive file mirror',
|
|
785
785
|
'diskUsage.area.vaultDesc': 'Mirror for the local editor write channel (enabled on demand)',
|
|
786
786
|
'diskUsage.retention.none': 'Retention disabled (never auto-removed)',
|
|
787
|
-
'diskUsage.retention.days': '{count} day
|
|
788
|
-
'diskUsage.retention.hours': '{count} hour
|
|
789
|
-
'diskUsage.retention.minutes': '{count} minute
|
|
787
|
+
'diskUsage.retention.days': '{count} {count, plural, day|days}',
|
|
788
|
+
'diskUsage.retention.hours': '{count} {count, plural, hour|hours}',
|
|
789
|
+
'diskUsage.retention.minutes': '{count} {count, plural, minute|minutes}',
|
|
790
790
|
'diskUsage.backupRetention': 'Scheduled backups keep the latest {count}; exports are auto-removed after {days} days.',
|
|
791
791
|
'diskUsage.clean.title': 'Reclaim space',
|
|
792
792
|
'diskUsage.clean.hint': 'Only regenerable caches and staging are cleaned; snapshots and sync data are never removed here.',
|
|
@@ -796,10 +796,10 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
796
796
|
'diskUsage.clean.tmpMarketDesc': 'Clears regardless of retention — all of it regenerates (avoid while an import is in flight).',
|
|
797
797
|
'diskUsage.clean.expiredExports': 'Reclaim expired backups',
|
|
798
798
|
'diskUsage.clean.expiredExportsDesc': 'Only exports past their retention window (scheduled backups are excluded).',
|
|
799
|
-
'diskUsage.clean.done': 'Freed {size}, removed {count} item
|
|
799
|
+
'diskUsage.clean.done': 'Freed {size}, removed {count} {count, plural, item|items}',
|
|
800
800
|
'diskUsage.clean.nothing': 'Nothing to reclaim',
|
|
801
801
|
'diskUsage.clean.nothingAbsent': 'Nothing to clean (the target directories are missing or already empty)',
|
|
802
|
-
'diskUsage.clean.failed': 'Cleanup finished with {count} failure
|
|
802
|
+
'diskUsage.clean.failed': 'Cleanup finished with {count} {count, plural, failure|failures} (skipped)',
|
|
803
803
|
'diskUsage.clean.confirmTitle': 'Clean disk usage',
|
|
804
804
|
'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.',
|
|
805
805
|
'diskUsage.clean.confirm': 'Clean',
|
|
@@ -904,7 +904,7 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
904
904
|
'foreign.found.no': 'Not detected',
|
|
905
905
|
'foreign.paths': 'Matched paths: {paths}',
|
|
906
906
|
'foreign.pathsNone': 'No truth-table path matched',
|
|
907
|
-
'foreign.credentialRefs': '{count} credential
|
|
907
|
+
'foreign.credentialRefs': '{count} {count, plural, credential|credentials} to enter manually: {refs}',
|
|
908
908
|
'foreign.skip.unsupported-hooks': 'settings hooks: DSH has no equivalent, not migrated',
|
|
909
909
|
'foreign.skip.unsupported-commands': 'Slash commands: not the same thing as DSH prompts, not migrated this round',
|
|
910
910
|
'foreign.skip.credentials-not-migrated': 'Credential fields: only the names are recorded; the values are never read or migrated',
|
|
@@ -988,6 +988,18 @@ export const uiEn: Record<UiTextKey, string> = {
|
|
|
988
988
|
/** UI 翻译函数:key → 当前语言文案({param} 插值;未知键回退 zh/键名)。 */
|
|
989
989
|
export type UiT = (key: UiTextKey, params?: Record<string, string | number>) => string;
|
|
990
990
|
|
|
991
|
+
/**
|
|
992
|
+
* 单复数段:`{count, plural, one-form|other-form}`(ICU 的极小子集,2026-11-01)。
|
|
993
|
+
*
|
|
994
|
+
* 为什么要它:英文旧写法把复数塞进括号 —— `{count} item(s)`、`{count} copy(ies)`。
|
|
995
|
+
* 界面读不出来:用户看到 "3 item(s)" 会先怀疑渲染漏了,单数时也照样写 "1 item(s)"。
|
|
996
|
+
* 真复数得按数量选形式,而模板是死字符串,只能把两种形式都放进值里、由插值层挑一个。
|
|
997
|
+
*
|
|
998
|
+
* 规则:`Number(v)` 的绝对值 === 1 取 one,其余(含 0、小数、非数字)取 other —— 英语常规
|
|
999
|
+
* ("0 items")。中文值不写这段(中文没有单复数),同一键的中文照旧。
|
|
1000
|
+
*/
|
|
1001
|
+
const PLURAL_SEGMENT = /\{(\w+),\s*plural,\s*([^{}|]*)\|([^{}]*)\}/g;
|
|
1002
|
+
|
|
991
1003
|
/** 构造 翻译函数(zh 源 / en 镜像;缺 key 回退)。 */
|
|
992
1004
|
export function makeUiT(lang: 'zh' | 'en'): UiT {
|
|
993
1005
|
const dict = lang === 'en' ? uiEn : uiZh;
|
|
@@ -996,9 +1008,17 @@ export function makeUiT(lang: 'zh' | 'en'): UiT {
|
|
|
996
1008
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
997
1009
|
const template = (dict as any)[key] ?? (fallback as any)?.[key] ?? key;
|
|
998
1010
|
if (params === undefined) return template;
|
|
999
|
-
return String(template)
|
|
1000
|
-
|
|
1001
|
-
|
|
1011
|
+
return String(template)
|
|
1012
|
+
.replace(PLURAL_SEGMENT, (match, k: string, one: string, other: string) => {
|
|
1013
|
+
const value = params[k];
|
|
1014
|
+
if (value === undefined) return match;
|
|
1015
|
+
const n = Number(value);
|
|
1016
|
+
const form = Number.isFinite(n) && Math.abs(n) === 1 ? one : other;
|
|
1017
|
+
return form.trim();
|
|
1018
|
+
})
|
|
1019
|
+
.replace(/\{(\w+)\}/g, (match, k: string) =>
|
|
1020
|
+
params[k] === undefined ? match : String(params[k]),
|
|
1021
|
+
);
|
|
1002
1022
|
};
|
|
1003
1023
|
}
|
|
1004
1024
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
importPreviewStageAfter, isSkippablePluginInstall, mergeSecretInput, nextImportPhase, pendingSecretRequests,
|
|
14
14
|
} from './import-wizard.ts';
|
|
15
15
|
import { MockImportPort, makeAnalysis, makeImportResult, makePlan, makePlanItem } from './test-helpers.ts';
|
|
16
|
+
import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/importer.ts';
|
|
16
17
|
import type { ImportPlan, PathIssue } from '../core/types.ts';
|
|
17
18
|
import type { SectionId } from '../schema/types.ts';
|
|
18
19
|
import { ConfigManagerApiError } from '../client/common/http.ts';
|
|
@@ -101,7 +102,23 @@ test('import-wizard: execute 默认显式传 rollbackOnError=true(场景 E)'
|
|
|
101
102
|
assert.equal(wiz.currentStep, 'result');
|
|
102
103
|
assert.equal(port.executeCalls.length, 1);
|
|
103
104
|
assert.equal(port.executeCalls[0]!.confirm, true);
|
|
104
|
-
assert.equal(port.executeCalls[0]!.rollbackOnError,
|
|
105
|
+
assert.equal(port.executeCalls[0]!.rollbackOnError, DEFAULT_ROLLBACK_ON_ERROR, '默认整体回滚(与引擎同一缺省符号)');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('IE-02: 向导缺省取引擎同一符号(IE-02 四入口同缺省)', async () => {
|
|
109
|
+
const port = new MockImportPort();
|
|
110
|
+
const wiz = new ImportWizard({ port });
|
|
111
|
+
assert.equal(wiz.snapshot().rollbackOnError, DEFAULT_ROLLBACK_ON_ERROR, '未传 defaultRollbackOnError → 引擎同一缺省');
|
|
112
|
+
|
|
113
|
+
await wiz.selectZip('x.zip');
|
|
114
|
+
await wiz.confirmCompatibility();
|
|
115
|
+
await wiz.execute({ confirm: true });
|
|
116
|
+
assert.equal(port.executeCalls[0]!.rollbackOnError, DEFAULT_ROLLBACK_ON_ERROR, '交给 /execute 的值也是同一缺省');
|
|
117
|
+
|
|
118
|
+
// 显式缺省仍以调用方为准(UI 可选项不被缺省吃掉)
|
|
119
|
+
const offPort = new MockImportPort();
|
|
120
|
+
const off = new ImportWizard({ port: offPort, defaultRollbackOnError: false });
|
|
121
|
+
assert.equal(off.snapshot().rollbackOnError, false);
|
|
105
122
|
});
|
|
106
123
|
|
|
107
124
|
test('import-wizard: 用户可选 rollbackOnError=false(单项失败继续 §34.17)', async () => {
|
package/src/ui/import-wizard.ts
CHANGED
|
@@ -25,11 +25,12 @@ import { EXECUTING_STAGE, IMPORT_STAGES, ProgressTracker } from './progress.ts';
|
|
|
25
25
|
import { formatActionableError, toActionableError } from './errors.ts';
|
|
26
26
|
import { liveUiT, type UiT } from './i18n.ts';
|
|
27
27
|
import { nextFlowPhase, type FlowPhase } from './flow.ts';
|
|
28
|
+
import { DEFAULT_ROLLBACK_ON_ERROR } from '../core/import-defaults.ts';
|
|
28
29
|
|
|
29
30
|
export interface ImportWizardOptions {
|
|
30
31
|
port: ImportPort;
|
|
31
32
|
onProgress?: ProgressListener;
|
|
32
|
-
/** 默认回滚策略(场景 E:true=整体回滚;UI
|
|
33
|
+
/** 默认回滚策略(场景 E:true=整体回滚;UI 可在执行前覆盖;缺省取 `DEFAULT_ROLLBACK_ON_ERROR`) */
|
|
33
34
|
defaultRollbackOnError?: boolean;
|
|
34
35
|
/**
|
|
35
36
|
* 渲染层翻译器(UiT)。缺省 = **活**翻译器 `liveUiT`(随 DSH 语言变化)——
|
|
@@ -282,7 +283,7 @@ export class ImportWizard {
|
|
|
282
283
|
this.port = opts.port;
|
|
283
284
|
this.onProgress = opts.onProgress;
|
|
284
285
|
this.tracker = new ProgressTracker(IMPORT_STAGES, this.onProgress);
|
|
285
|
-
this.rollbackOnError = opts.defaultRollbackOnError ??
|
|
286
|
+
this.rollbackOnError = opts.defaultRollbackOnError ?? DEFAULT_ROLLBACK_ON_ERROR;
|
|
286
287
|
this.t = opts.t ?? liveUiT;
|
|
287
288
|
}
|
|
288
289
|
|
|
@@ -155,6 +155,15 @@ test('overviewHealth: SAFE MODE 优先 error;全空 warn;正常 ok', () => {
|
|
|
155
155
|
assert.equal(scheduleFail.kind, 'warn')
|
|
156
156
|
assert.equal(scheduleFail.textKey, 'health.scheduleFailed')
|
|
157
157
|
|
|
158
|
+
/* 有备份、但自动备份是关的 → 仍要 warn:状态条不能与脚下「Auto backup Off」并列报健康 */
|
|
159
|
+
const autoOff = overviewHealth(baseInputs({
|
|
160
|
+
backups: [{ name: 'a.zip', sizeBytes: 1, mtimeMs: NOW, source: 'manual' }],
|
|
161
|
+
snapshots: [{ createdAt: '2026-09-10T00:00:00Z', entryCount: 1 }],
|
|
162
|
+
schedule: { enabled: false, lastRunStatus: 'ok' },
|
|
163
|
+
}))
|
|
164
|
+
assert.equal(autoOff.kind, 'warn')
|
|
165
|
+
assert.equal(autoOff.textKey, 'health.autoBackupOff')
|
|
166
|
+
|
|
158
167
|
assert.equal(overviewHealth(baseInputs()).kind, 'ok', '数据未加载不降级')
|
|
159
168
|
})
|
|
160
169
|
|
package/src/ui/overview-view.ts
CHANGED
|
@@ -237,7 +237,7 @@ export type OverviewHealthKind = 'ok' | 'warn' | 'error'
|
|
|
237
237
|
export interface OverviewHealth {
|
|
238
238
|
kind: OverviewHealthKind
|
|
239
239
|
/** UI 渲染 key(`overview.health.*`)。 */
|
|
240
|
-
textKey: 'health.ok' | 'health.noBackup' | 'health.scheduleFailed' | 'health.recovery'
|
|
240
|
+
textKey: 'health.ok' | 'health.noBackup' | 'health.scheduleFailed' | 'health.autoBackupOff' | 'health.recovery'
|
|
241
241
|
}
|
|
242
242
|
|
|
243
243
|
/**
|
|
@@ -245,7 +245,11 @@ export interface OverviewHealth {
|
|
|
245
245
|
* 1. SAFE MODE 激活(有未解决恢复事项)→ error(最高优先,需用户处理);
|
|
246
246
|
* 2. 已加载且备份与快照均为空 → warn「尚无任何备份」(新装机引导场景);
|
|
247
247
|
* 3. 定时备份开启但上次运行失败 → warn;
|
|
248
|
-
* 4.
|
|
248
|
+
* 4. 手上已有备份、但定时备份是关的 → warn「自动备份已关闭」(2026-11-01 视觉 pass 新增:
|
|
249
|
+
* 此前关闭档不判定,于是「All healthy」与「Auto backup Off / Backups 1」同屏并列,
|
|
250
|
+
* 状态条给出的结论与它脚下的指标自相矛盾 —— 一页只在回答「现在安全吗」,
|
|
251
|
+
* 在没有任何后续保护时报健康等于放弃这个结论);
|
|
252
|
+
* 5. 其余 → ok。
|
|
249
253
|
* 数据未加载(null)不降级健康状态(避免加载闪红)。
|
|
250
254
|
*/
|
|
251
255
|
export function overviewHealth(inputs: OverviewInputs): OverviewHealth {
|
|
@@ -259,6 +263,9 @@ export function overviewHealth(inputs: OverviewInputs): OverviewHealth {
|
|
|
259
263
|
if (inputs.schedule !== null && inputs.schedule.enabled && inputs.schedule.lastRunStatus === 'failed') {
|
|
260
264
|
return { kind: 'warn', textKey: 'health.scheduleFailed' }
|
|
261
265
|
}
|
|
266
|
+
if (inputs.schedule !== null && !inputs.schedule.enabled) {
|
|
267
|
+
return { kind: 'warn', textKey: 'health.autoBackupOff' }
|
|
268
|
+
}
|
|
262
269
|
return { kind: 'ok', textKey: 'health.ok' }
|
|
263
270
|
}
|
|
264
271
|
|
package/src/ui/report.test.ts
CHANGED
|
@@ -36,7 +36,7 @@ test('report: 导出报告含 Included/Excluded/Security/File(en / zh)', ()
|
|
|
36
36
|
assert.ok(enText.includes('○ sessions'));
|
|
37
37
|
assert.ok(enText.includes('API Keys excluded: yes'));
|
|
38
38
|
assert.ok(enText.includes('File: dsh-config-2026-08-14.zip'));
|
|
39
|
-
assert.ok(enText.includes('2 sensitive
|
|
39
|
+
assert.ok(enText.includes('2 sensitive fields redacted'));
|
|
40
40
|
const zhText = renderExportReport(makeExportReport(), zhT);
|
|
41
41
|
assert.ok(zhText.includes('备份已创建'));
|
|
42
42
|
});
|
|
@@ -44,7 +44,8 @@ test('report: 导出报告含 Included/Excluded/Security/File(en / zh)', ()
|
|
|
44
44
|
test('report: exportCountsText —— 计数单位中文化(zh/en),未知单位键原样回退', () => {
|
|
45
45
|
assert.equal(exportCountsText({ namespaces: 18 }, zhT), '18 个命名空间');
|
|
46
46
|
assert.equal(exportCountsText({ plugins: 10, patchLines: 11 }, zhT), '10 个插件,11 行补丁');
|
|
47
|
-
assert.equal(exportCountsText({ namespaces: 18 }, enT), '18 namespace
|
|
47
|
+
assert.equal(exportCountsText({ namespaces: 18 }, enT), '18 namespaces', '单位词按数量选形(1 → namespace)');
|
|
48
|
+
assert.equal(exportCountsText({ namespaces: 1 }, enT), '1 namespace', '单数不再写成 1 namespace(s)');
|
|
48
49
|
assert.equal(exportCountsText({ servers: 0 }, zhT), '0 个服务器', '0 是有效值,不能当空');
|
|
49
50
|
assert.equal(exportCountsText({ customThing: 2 }, zhT), '2 customThing', '未知单位键原样显示,不吞信息');
|
|
50
51
|
assert.equal(exportCountsText({}, zhT), '', '空 counts → 空串(由调用方渲染占位符)');
|
|
@@ -66,7 +67,7 @@ test('report: 导入报告渲染含分节/重启/缺失 secret(en)', () => {
|
|
|
66
67
|
const text = renderImportReport(makeImportResult(), enT);
|
|
67
68
|
assert.ok(text.includes('Import Complete'));
|
|
68
69
|
assert.ok(text.includes('settings: ✓ 2 imported/restored'));
|
|
69
|
-
assert.ok(text.includes('Secrets: ⚠ 1 credential
|
|
70
|
+
assert.ok(text.includes('Secrets: ⚠ 1 credential needs to be entered'), '动词单复数跟着数量走');
|
|
70
71
|
assert.ok(text.includes('Plugin / MCP changes take effect'));
|
|
71
72
|
});
|
|
72
73
|
|
package/src/ui/report.ts
CHANGED
|
@@ -45,7 +45,8 @@ export function exportCountsText(counts: Record<string, number>, t: UiT): string
|
|
|
45
45
|
for (const [key, value] of Object.entries(counts)) {
|
|
46
46
|
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
|
|
47
47
|
const unitKey = COUNT_UNIT_KEY[key];
|
|
48
|
-
|
|
48
|
+
// 单位词按数量选单复数(en 的 `{n, plural, file|files}`);中文单位不受影响
|
|
49
|
+
parts.push(unitKey === undefined ? String(value) + ' ' + key : String(value) + ' ' + t(unitKey, { n: value }));
|
|
49
50
|
}
|
|
50
51
|
// 分隔符也走字典:英文用半角逗号(全角标点在英文界面同样是缺陷)
|
|
51
52
|
return parts.join(t('report.unitSeparator'));
|