mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-11-02 14:28:18 +00:00
Merge pull request #981 from apple-ouyang/codex/maintenance-prereq-dialog
Ask before applying maintenance prerequisite settings
This commit is contained in:
3 files changed
+163
-18
No files matched your search
@@ -18,6 +18,7 @@ import { EVENT_ANALYSE_DB_USAGE, EVENT_REQUEST_PERFORM_GC_V3, eventHub } from "@
|
|||||||
import type { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator";
|
import type { LiveSyncCouchDBReplicator } from "@lib/replication/couchdb/LiveSyncReplicator";
|
||||||
import { delay } from "@lib/common/utils";
|
import { delay } from "@lib/common/utils";
|
||||||
import { isNotFoundError } from "@lib/common/utils.doc";
|
import { isNotFoundError } from "@lib/common/utils.doc";
|
||||||
|
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
|
||||||
// import { _requestToCouchDB } from "@/common/utils";
|
// import { _requestToCouchDB } from "@/common/utils";
|
||||||
const DB_KEY_SEQ = "gc-seq";
|
const DB_KEY_SEQ = "gc-seq";
|
||||||
const DB_KEY_CHUNK_SET = "chunk-set";
|
const DB_KEY_CHUNK_SET = "chunk-set";
|
||||||
@@ -77,22 +78,22 @@ export class LocalDatabaseMaintenance extends LiveSyncCommands {
|
|||||||
})) === affirmative
|
})) === affirmative
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
isAvailable() {
|
async ensureAvailable(operationName: string) {
|
||||||
if (!this.settings.doNotUseFixedRevisionForChunks) {
|
return await ensureLocalDatabaseMaintenancePrerequisites({
|
||||||
this._notice("Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.");
|
operationName,
|
||||||
return false;
|
settings: this.settings,
|
||||||
}
|
askSelectStringDialogue: this.core.confirm.askSelectStringDialogue.bind(this.core.confirm),
|
||||||
if (this.settings.readChunksOnline) {
|
applyPartial: async (settings, saveImmediately) => {
|
||||||
this._notice("Please disable 'Read chunks online' in settings to use Garbage Collection.");
|
await this.core.services.setting.applyPartial(settings, saveImmediately);
|
||||||
return false;
|
Object.assign(this.core.settings, settings);
|
||||||
}
|
},
|
||||||
return true;
|
});
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Resurrect deleted chunks that are still used in the database.
|
* Resurrect deleted chunks that are still used in the database.
|
||||||
*/
|
*/
|
||||||
async resurrectChunks() {
|
async resurrectChunks() {
|
||||||
if (!this.isAvailable()) return;
|
if (!(await this.ensureAvailable("Resurrect Chunks"))) return;
|
||||||
const { used, existing } = await this.allChunks(true);
|
const { used, existing } = await this.allChunks(true);
|
||||||
const excessiveDeletions = [...existing]
|
const excessiveDeletions = [...existing]
|
||||||
.filter(([key, e]) => e._deleted)
|
.filter(([key, e]) => e._deleted)
|
||||||
@@ -157,7 +158,7 @@ Do you want to resurrect these chunks?`;
|
|||||||
* After this, chunks that are used in the deleted files become ready for compaction.
|
* After this, chunks that are used in the deleted files become ready for compaction.
|
||||||
*/
|
*/
|
||||||
async commitFileDeletion() {
|
async commitFileDeletion() {
|
||||||
if (!this.isAvailable()) return;
|
if (!(await this.ensureAvailable("Delete Files"))) return;
|
||||||
const p = this._progress("", LOG_LEVEL_NOTICE);
|
const p = this._progress("", LOG_LEVEL_NOTICE);
|
||||||
p.log("Searching for deleted files..");
|
p.log("Searching for deleted files..");
|
||||||
const docs = await this.database.allDocs<MetaEntry>({ include_docs: true });
|
const docs = await this.database.allDocs<MetaEntry>({ include_docs: true });
|
||||||
@@ -199,7 +200,7 @@ Note: **Make sure to synchronise all devices before deletion.**
|
|||||||
* It is recommended to compact the database after this operation (History should be kept once before compaction).
|
* It is recommended to compact the database after this operation (History should be kept once before compaction).
|
||||||
*/
|
*/
|
||||||
async commitChunkDeletion() {
|
async commitChunkDeletion() {
|
||||||
if (!this.isAvailable()) return;
|
if (!(await this.ensureAvailable("Delete Chunks"))) return;
|
||||||
const { existing } = await this.allChunks(true);
|
const { existing } = await this.allChunks(true);
|
||||||
const deletedChunks = [...existing].filter(([key, e]) => e._deleted && e.data !== "").map(([key, e]) => e);
|
const deletedChunks = [...existing].filter(([key, e]) => e._deleted && e.data !== "").map(([key, e]) => e);
|
||||||
const deletedNotVacantChunks = deletedChunks.map((e) => ({ ...e, data: "", _deleted: true }));
|
const deletedNotVacantChunks = deletedChunks.map((e) => ({ ...e, data: "", _deleted: true }));
|
||||||
@@ -236,7 +237,7 @@ Note: **Make sure to synchronise all devices before deletion.**
|
|||||||
* Make sure all devices are synchronized before running this method.
|
* Make sure all devices are synchronized before running this method.
|
||||||
*/
|
*/
|
||||||
async markUnusedChunks() {
|
async markUnusedChunks() {
|
||||||
if (!this.isAvailable()) return;
|
if (!(await this.ensureAvailable("Mark unused chunks"))) return;
|
||||||
const { used, existing } = await this.allChunks();
|
const { used, existing } = await this.allChunks();
|
||||||
const existChunks = [...existing];
|
const existChunks = [...existing];
|
||||||
const unusedChunks = existChunks.filter(([key, e]) => !used.has(e._id)).map(([key, e]) => e);
|
const unusedChunks = existChunks.filter(([key, e]) => !used.has(e._id)).map(([key, e]) => e);
|
||||||
@@ -269,6 +270,7 @@ Note: **Make sure to synchronise all devices before deletion.**
|
|||||||
}
|
}
|
||||||
|
|
||||||
async removeUnusedChunks() {
|
async removeUnusedChunks() {
|
||||||
|
if (!(await this.ensureAvailable("Delete unused chunks"))) return;
|
||||||
const { used, existing } = await this.allChunks();
|
const { used, existing } = await this.allChunks();
|
||||||
const existChunks = [...existing];
|
const existChunks = [...existing];
|
||||||
const unusedChunks = existChunks.filter(([key, e]) => !used.has(e._id)).map(([key, e]) => e);
|
const unusedChunks = existChunks.filter(([key, e]) => !used.has(e._id)).map(([key, e]) => e);
|
||||||
@@ -326,7 +328,7 @@ Note: **Make sure to synchronise all devices before deletion.**
|
|||||||
* Note that this only able to perform without Fetch chunks on demand.
|
* Note that this only able to perform without Fetch chunks on demand.
|
||||||
*/
|
*/
|
||||||
async trackChanges(fromStart: boolean = false, showNotice: boolean = false) {
|
async trackChanges(fromStart: boolean = false, showNotice: boolean = false) {
|
||||||
if (!this.isAvailable()) return;
|
if (!(await this.ensureAvailable("Track chunk usage"))) return;
|
||||||
const logLevel = showNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
const logLevel = showNotice ? LOG_LEVEL_NOTICE : LOG_LEVEL_INFO;
|
||||||
const kvDB = this.core.kvDB;
|
const kvDB = this.core.kvDB;
|
||||||
|
|
||||||
@@ -442,7 +444,7 @@ Note: **Make sure to synchronise all devices before deletion.**
|
|||||||
this._log(message, logLevel);
|
this._log(message, logLevel);
|
||||||
}
|
}
|
||||||
async performGC(showingNotice = false) {
|
async performGC(showingNotice = false) {
|
||||||
if (!this.isAvailable()) return;
|
if (!(await this.ensureAvailable("Garbage Collection"))) return;
|
||||||
await this.trackChanges(false, showingNotice);
|
await this.trackChanges(false, showingNotice);
|
||||||
const title = "Are all devices synchronised?";
|
const title = "Are all devices synchronised?";
|
||||||
const confirmMessage = `This function deletes unused chunks from the device. If there are differences between devices, some chunks may be missing when resolving conflicts.
|
const confirmMessage = `This function deletes unused chunks from the device. If there are differences between devices, some chunks may be missing when resolving conflicts.
|
||||||
@@ -512,7 +514,7 @@ Success: ${successCount}, Errored: ${errored}`;
|
|||||||
|
|
||||||
// Analyse the database and report chunk usage.
|
// Analyse the database and report chunk usage.
|
||||||
async analyseDatabase() {
|
async analyseDatabase() {
|
||||||
if (!this.isAvailable()) return;
|
if (!(await this.ensureAvailable("Analyse Database Usage"))) return;
|
||||||
const db = this.localDatabase.localDatabase;
|
const db = this.localDatabase.localDatabase;
|
||||||
// Map of chunk ID to its info
|
// Map of chunk ID to its info
|
||||||
type ChunkInfo = {
|
type ChunkInfo = {
|
||||||
@@ -822,7 +824,7 @@ Success: ${successCount}, Errored: ${errored}`;
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
async gcv3() {
|
async gcv3() {
|
||||||
if (!this.isAvailable()) return;
|
if (!(await this.ensureAvailable("Garbage Collection"))) return;
|
||||||
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
const replicator = this.core.replicator as LiveSyncCouchDBReplicator;
|
||||||
// Start one-shot replication to ensure all changes are synced before GC.
|
// Start one-shot replication to ensure all changes are synced before GC.
|
||||||
const r0 = await replicator.openOneShotReplication(this.settings, false, false, "sync");
|
const r0 = await replicator.openOneShotReplication(this.settings, false, false, "sync");
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { DEFAULT_SETTINGS } from "@lib/common/types";
|
||||||
|
import { ensureLocalDatabaseMaintenancePrerequisites } from "./maintenancePrerequisites";
|
||||||
|
|
||||||
|
function createPrerequisites(settingsOverride: Partial<typeof DEFAULT_SETTINGS> = {}) {
|
||||||
|
const askSelectStringDialogue = vi.fn<() => Promise<"Apply and continue" | "Cancel" | false | undefined>>(
|
||||||
|
async () => "Apply and continue"
|
||||||
|
);
|
||||||
|
const applyPartial = vi.fn(async () => undefined);
|
||||||
|
const settings = {
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
doNotUseFixedRevisionForChunks: false,
|
||||||
|
readChunksOnline: true,
|
||||||
|
...settingsOverride,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { settings, askSelectStringDialogue, applyPartial };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LocalDatabaseMaintenance prerequisites", () => {
|
||||||
|
it("asks to apply missing prerequisite settings before maintenance actions", async () => {
|
||||||
|
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
|
||||||
|
|
||||||
|
const result = await ensureLocalDatabaseMaintenancePrerequisites({
|
||||||
|
operationName: "Garbage Collection",
|
||||||
|
settings: {
|
||||||
|
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
|
||||||
|
readChunksOnline: settings.readChunksOnline,
|
||||||
|
},
|
||||||
|
askSelectStringDialogue,
|
||||||
|
applyPartial,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(askSelectStringDialogue).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("Garbage Collection requires the following settings"),
|
||||||
|
["Apply and continue", "Cancel"],
|
||||||
|
{
|
||||||
|
title: "Garbage Collection prerequisites",
|
||||||
|
defaultAction: "Cancel",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(applyPartial).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
doNotUseFixedRevisionForChunks: true,
|
||||||
|
readChunksOnline: false,
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels maintenance actions when prerequisite changes are rejected", async () => {
|
||||||
|
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites();
|
||||||
|
askSelectStringDialogue.mockResolvedValueOnce("Cancel");
|
||||||
|
|
||||||
|
const result = await ensureLocalDatabaseMaintenancePrerequisites({
|
||||||
|
operationName: "Garbage Collection",
|
||||||
|
settings: {
|
||||||
|
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
|
||||||
|
readChunksOnline: settings.readChunksOnline,
|
||||||
|
},
|
||||||
|
askSelectStringDialogue,
|
||||||
|
applyPartial,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(applyPartial).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues without asking when prerequisite settings already match", async () => {
|
||||||
|
const { settings, askSelectStringDialogue, applyPartial } = createPrerequisites({
|
||||||
|
doNotUseFixedRevisionForChunks: true,
|
||||||
|
readChunksOnline: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await ensureLocalDatabaseMaintenancePrerequisites({
|
||||||
|
operationName: "Garbage Collection",
|
||||||
|
settings: {
|
||||||
|
doNotUseFixedRevisionForChunks: settings.doNotUseFixedRevisionForChunks,
|
||||||
|
readChunksOnline: settings.readChunksOnline,
|
||||||
|
},
|
||||||
|
askSelectStringDialogue,
|
||||||
|
applyPartial,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(askSelectStringDialogue).not.toHaveBeenCalled();
|
||||||
|
expect(applyPartial).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ObsidianLiveSyncSettings } from "@lib/common/types";
|
||||||
|
|
||||||
|
type MaintenancePrerequisiteSettings = Pick<
|
||||||
|
ObsidianLiveSyncSettings,
|
||||||
|
"doNotUseFixedRevisionForChunks" | "readChunksOnline"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type MaintenancePrerequisiteOptions = {
|
||||||
|
operationName: string;
|
||||||
|
settings: MaintenancePrerequisiteSettings;
|
||||||
|
askSelectStringDialogue: (
|
||||||
|
message: string,
|
||||||
|
buttons: readonly ["Apply and continue", "Cancel"],
|
||||||
|
options: { title: string; defaultAction: "Cancel" }
|
||||||
|
) => Promise<"Apply and continue" | "Cancel" | false | undefined>;
|
||||||
|
applyPartial: (settings: Partial<ObsidianLiveSyncSettings>, saveImmediately?: boolean) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function ensureLocalDatabaseMaintenancePrerequisites({
|
||||||
|
operationName,
|
||||||
|
settings,
|
||||||
|
askSelectStringDialogue,
|
||||||
|
applyPartial,
|
||||||
|
}: MaintenancePrerequisiteOptions): Promise<boolean> {
|
||||||
|
const requiredSettings = {
|
||||||
|
doNotUseFixedRevisionForChunks: true,
|
||||||
|
readChunksOnline: false,
|
||||||
|
} satisfies MaintenancePrerequisiteSettings;
|
||||||
|
|
||||||
|
const missing = [
|
||||||
|
...(settings.doNotUseFixedRevisionForChunks ? [] : ["- Compute revisions for chunks: On (currently Off)"]),
|
||||||
|
...(settings.readChunksOnline ? ["- Fetch chunks on demand: Off (currently On)"] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (missing.length == 0) return true;
|
||||||
|
|
||||||
|
const APPLY = "Apply and continue";
|
||||||
|
const CANCEL = "Cancel";
|
||||||
|
const result = await askSelectStringDialogue(
|
||||||
|
`${operationName} requires the following settings:\n\n${missing.join(
|
||||||
|
"\n"
|
||||||
|
)}\n\nApply these settings and continue?`,
|
||||||
|
[APPLY, CANCEL],
|
||||||
|
{
|
||||||
|
title: `${operationName} prerequisites`,
|
||||||
|
defaultAction: CANCEL,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result !== APPLY) return false;
|
||||||
|
|
||||||
|
await applyPartial(requiredSettings, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user