Remember pending Simple Fetch choices

Simple Fetch asked the same mode and deletion choices again after a failed or interrupted pending Fetch All run.

This stores the selected quick-flow choices in local small config until the operation completes, is cancelled, or is finalised.
This commit is contained in:
Ouyang Xingyuan
2026-06-24 10:12:51 +08:00
parent 2d5b5da904
commit bf8da52348
2 changed files with 114 additions and 20 deletions
+73 -20
View File
@@ -24,9 +24,73 @@ export const SIMPLE_FETCH_STAGE2_NEWER_CLEANUP = "Delete local files if deleted
export const SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL = "Keep local files even if deleted on remote"; export const SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL = "Keep local files even if deleted on remote";
export const STAGE2_ABORT = "Cancel all and reboot"; export const STAGE2_ABORT = "Cancel all and reboot";
const SIMPLE_FETCH_MODE_KEY = "simple-fetch-mode";
function buildSimpleFetchResult(stage1: string, stage2?: string) {
if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) {
return { mode: "legacy", options: {} };
}
if (stage1 === SIMPLE_FETCH_STAGE1_REMOTE_WINS && stage2) {
if (![SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL, SIMPLE_FETCH_STAGE2_REMOTE_DELETE_NONE].includes(stage2)) {
return undefined;
}
return {
mode: "remote-only",
options: {
mode: FullScanModes.DB_APPLY,
extraOnRemote:
stage2 === SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL ? ExtraOnRemote.DELETE_LOCAL_MISSING : undefined,
},
};
}
if (stage1 === SIMPLE_FETCH_STAGE1_NEWER_WINS && stage2) {
if (![SIMPLE_FETCH_STAGE2_NEWER_CLEANUP, SIMPLE_FETCH_STAGE2_NEWER_SYNC_ALL].includes(stage2)) {
return undefined;
}
return {
mode: "newer-wins",
options: {
mode: FullScanModes.NEWER_WINS,
extraOnLocal:
stage2 === SIMPLE_FETCH_STAGE2_NEWER_CLEANUP
? ExtraOnLocal.DELETE_DB_DELETED
: ExtraOnLocal.APPEND_STORAGE_ONLY,
},
};
}
return undefined;
}
function rememberSimpleFetchMode(host: NecessaryServices<"setting", never>, stage1: string, stage2?: string) {
host.services.setting.setSmallConfig(SIMPLE_FETCH_MODE_KEY, JSON.stringify({ stage1, stage2 }));
}
function getRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
const saved = host.services.setting.getSmallConfig(SIMPLE_FETCH_MODE_KEY);
if (!saved) return undefined;
try {
const { stage1, stage2 } = JSON.parse(saved) as { stage1?: string; stage2?: string };
if (stage1) {
const remembered = buildSimpleFetchResult(stage1, stage2);
if (remembered) return remembered;
}
} catch {
// Clear below; the saved choice is optional and can be rebuilt by asking again.
}
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
return undefined;
}
function clearRememberedSimpleFetchMode(host: NecessaryServices<"setting", never>) {
host.services.setting.deleteSmallConfig(SIMPLE_FETCH_MODE_KEY);
}
export async function askSimpleFetchMode( export async function askSimpleFetchMode(
host: NecessaryServices<"UI" | "vault", "storageAccess"> host: NecessaryServices<"UI" | "setting", never>
): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> { ): Promise<{ mode: string; options: Partial<FullScanOptions> } | "cancelled" | "aborted"> {
const remembered = getRememberedSimpleFetchMode(host);
if (remembered) return remembered;
const msg = `We are about to retrieve the remote data. const msg = `We are about to retrieve the remote data.
Firstly, how shall we handle the data retrieved from this remote server? Firstly, how shall we handle the data retrieved from this remote server?
@@ -55,7 +119,7 @@ Firstly, how shall we handle the data retrieved from this remote server?
if (!stage1 || stage1 === SIMPLE_FETCH_STAGE1_CANCEL) return "cancelled"; if (!stage1 || stage1 === SIMPLE_FETCH_STAGE1_CANCEL) return "cancelled";
if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) { if (stage1 === SIMPLE_FETCH_STAGE1_LEGACY) {
return { mode: "legacy", options: {} }; return buildSimpleFetchResult(stage1)!;
} }
if (stage1 === SIMPLE_FETCH_STAGE1_REMOTE_WINS) { if (stage1 === SIMPLE_FETCH_STAGE1_REMOTE_WINS) {
@@ -77,14 +141,8 @@ Firstly, how shall we handle the data retrieved from this remote server?
if (stage2 === STAGE2_ABORT) { if (stage2 === STAGE2_ABORT) {
return "aborted"; return "aborted";
} }
return { rememberSimpleFetchMode(host, stage1, stage2);
mode: "remote-only", return buildSimpleFetchResult(stage1, stage2)!;
options: {
mode: FullScanModes.DB_APPLY,
extraOnRemote:
stage2 === SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL ? ExtraOnRemote.DELETE_LOCAL_MISSING : undefined,
},
};
} }
if (stage1 === SIMPLE_FETCH_STAGE1_NEWER_WINS) { if (stage1 === SIMPLE_FETCH_STAGE1_NEWER_WINS) {
@@ -107,16 +165,8 @@ Firstly, how shall we handle the data retrieved from this remote server?
if (stage2 === STAGE2_ABORT) { if (stage2 === STAGE2_ABORT) {
return "aborted"; return "aborted";
} }
return { rememberSimpleFetchMode(host, stage1, stage2);
mode: "newer-wins", return buildSimpleFetchResult(stage1, stage2)!;
options: {
mode: FullScanModes.NEWER_WINS,
extraOnLocal:
stage2 === SIMPLE_FETCH_STAGE2_NEWER_CLEANUP
? ExtraOnLocal.DELETE_DB_DELETED
: ExtraOnLocal.APPEND_STORAGE_ONLY,
},
};
} }
return "cancelled"; return "cancelled";
@@ -143,12 +193,14 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
const result = await askSimpleFetchMode(host); const result = await askSimpleFetchMode(host);
if (result === "cancelled") { if (result === "cancelled") {
log("Fetch cancelled by user.", LOG_LEVEL_NOTICE); log("Fetch cancelled by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host);
await cleanupFlag(); await cleanupFlag();
host.services.appLifecycle.performRestart(); host.services.appLifecycle.performRestart();
return false; return false;
} }
if (result === "aborted") { if (result === "aborted") {
log("Fetch exited by user.", LOG_LEVEL_NOTICE); log("Fetch exited by user.", LOG_LEVEL_NOTICE);
clearRememberedSimpleFetchMode(host);
host.services.appLifecycle.performRestart(); host.services.appLifecycle.performRestart();
return false; return false;
} }
@@ -191,6 +243,7 @@ export async function askAndPerformFastSetupOnScheduledFetchAll(
} }
await host.serviceModules.rebuilder.finishRebuild(); await host.serviceModules.rebuilder.finishRebuild();
await cleanupFlag(); await cleanupFlag();
clearRememberedSimpleFetchMode(host);
log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE); log("Simple fetch and scan operation completed.", LOG_LEVEL_NOTICE);
return true; return true;
}); });
+41
View File
@@ -85,6 +85,7 @@ const createSettingServiceMock = () => {
writeLogToTheFile: false, writeLogToTheFile: false,
remoteType: "CouchDB", remoteType: "CouchDB",
}; };
const smallConfig = new Map<string, string>();
return { return {
settings, settings,
currentSettings: vi.fn(() => settings), currentSettings: vi.fn(() => settings),
@@ -98,6 +99,13 @@ const createSettingServiceMock = () => {
}), }),
suspendAllSync: vi.fn(() => Promise.resolve()), suspendAllSync: vi.fn(() => Promise.resolve()),
suspendExtraSync: vi.fn(() => Promise.resolve()), suspendExtraSync: vi.fn(() => Promise.resolve()),
getSmallConfig: vi.fn((key: string) => smallConfig.get(key) ?? ""),
setSmallConfig: vi.fn((key: string, value: string) => {
smallConfig.set(key, value);
}),
deleteSmallConfig: vi.fn((key: string) => {
smallConfig.delete(key);
}),
}; };
}; };
@@ -739,6 +747,39 @@ describe("Red Flag Feature", () => {
}); });
describe("askAndPerformFastSetupOnScheduledFetchAll", () => { describe("askAndPerformFastSetupOnScheduledFetchAll", () => {
it("should remember quick flow choices while the scheduled fetch is pending", async () => {
const host = createHostMock();
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_NEWER_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_NEWER_CLEANUP);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ batchSave: false } as any);
host.mocks.rebuilder.$fetchLocalDBFast.mockRejectedValueOnce(new Error("offline"));
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
expect(host.mocks.ui.confirm.confirmWithMessage).toHaveBeenCalledTimes(2);
expect(host.mocks.rebuilder.$fetchLocalDBFast).toHaveBeenCalledTimes(2);
});
it("should clear remembered quick flow choices after completion", async () => {
const host = createHostMock();
const log = createLoggerMock();
const cleanupFlag = vi.fn().mockResolvedValue(undefined);
host.mocks.ui.confirm.confirmWithMessage
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE1_REMOTE_WINS)
.mockResolvedValueOnce(SIMPLE_FETCH_STAGE2_REMOTE_DELETE_ALL);
host.mocks.tweakValue.fetchRemotePreferred.mockResolvedValue({ batchSave: false } as any);
await askAndPerformFastSetupOnScheduledFetchAll(host as any, log, cleanupFlag);
expect(host.mocks.setting.deleteSmallConfig).toHaveBeenCalledWith("simple-fetch-mode");
});
it("should return false and cleanup when quick flow is cancelled", async () => { it("should return false and cleanup when quick flow is cancelled", async () => {
const host = createHostMock(); const host = createHostMock();
const log = createLoggerMock(); const log = createLoggerMock();