mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-28 14:27:08 +00:00
fix: persist document history restoration in the database
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { isErrorOfMissingDoc } from "@vrtmrz/livesync-commonlib/compat/pouchdb/utils_couchdb";
|
||||
import { fireAndForget, getDocData, readContent } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { fireAndForget, getDocData } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isPlainText, stripPrefix } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { scheduleOnceIfDuplicated } from "octagonal-wheels/concurrency/lock";
|
||||
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore.ts";
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
saveDocumentHistoryPreference,
|
||||
} from "./documentHistoryPreferences.ts";
|
||||
import type PouchDB from "pouchdb-core";
|
||||
import {
|
||||
restoreDocumentHistoryRevision,
|
||||
type DocumentHistoryRestorationResult,
|
||||
} from "@/serviceFeatures/documentHistoryRestoration";
|
||||
|
||||
function isImage(path: string) {
|
||||
const ext = path.split(".").splice(-1)[0].toLowerCase();
|
||||
@@ -277,6 +281,7 @@ export class DocumentHistoryModal extends Modal {
|
||||
|
||||
async showExactRev(rev: string) {
|
||||
const db = this.core.localDatabase;
|
||||
this.currentDoc = undefined;
|
||||
const w = await db.getDBEntry(this.file, { rev: rev }, false, false, true);
|
||||
this.currentText = "";
|
||||
this.currentDeleted = false;
|
||||
@@ -702,7 +707,6 @@ export class DocumentHistoryModal extends Modal {
|
||||
e.addClass("mod-cta");
|
||||
e.addEventListener("click", () => {
|
||||
fireAndForget(async () => {
|
||||
// const pathToWrite = this.plugin.id2path(this.id, true);
|
||||
const pathToWrite = stripPrefix(this.file);
|
||||
if (!isValidPath(pathToWrite)) {
|
||||
Logger("Path is not valid to write content.", LOG_LEVEL_INFO);
|
||||
@@ -712,9 +716,94 @@ export class DocumentHistoryModal extends Modal {
|
||||
Logger("No active file loaded.", LOG_LEVEL_INFO);
|
||||
return;
|
||||
}
|
||||
const d = readContent(this.currentDoc);
|
||||
await this.core.storageAccess.writeHiddenFileAuto(pathToWrite, d);
|
||||
await focusFile(pathToWrite);
|
||||
const sourceRevision = this.currentDoc._rev;
|
||||
if (!sourceRevision) {
|
||||
Logger("The selected revision does not have a revision identifier.", LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
|
||||
e.disabled = true;
|
||||
let result: DocumentHistoryRestorationResult;
|
||||
try {
|
||||
result = await restoreDocumentHistoryRevision(this.core, this.file, sourceRevision, {
|
||||
isPathValid: isValidPath,
|
||||
});
|
||||
} catch (ex) {
|
||||
Logger(
|
||||
"Restoring the selected revision failed before Vault reflection completed. Review the file with 'Inspect conflicts and file/database differences' before retrying.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
e.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "source-unavailable") {
|
||||
Logger(
|
||||
"The selected revision could not be restored because its content is no longer available.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
e.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (result.status === "current-unavailable") {
|
||||
Logger(
|
||||
"The current database revision could not be read. The Vault was not changed.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
e.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (result.status === "unsupported-path") {
|
||||
Logger(
|
||||
"Only an ordinary valid Vault path can be restored from Document History.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
e.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (result.status === "database-write-refused") {
|
||||
Logger(
|
||||
"The restored revision could not be created. The file may have changed during the operation, and the Vault was not changed.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
e.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (result.status === "stored-not-reflected") {
|
||||
Logger(
|
||||
"The restored revision was saved in the local database, but it could not be reflected to the Vault. Use 'Inspect conflicts and file/database differences' to review and apply it.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
if (result.cause) {
|
||||
Logger(result.cause, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.conflictCheckError) {
|
||||
Logger(result.conflictCheckError, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
if (result.conflictsRemain === true) {
|
||||
Logger(
|
||||
"The selected content was restored as a new revision. Other versions remain unresolved; use 'Inspect conflicts and file/database differences' to review them.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
} else if (result.conflictsRemain === false) {
|
||||
Logger("The selected content was restored as a new revision.", LOG_LEVEL_NOTICE);
|
||||
} else {
|
||||
Logger(
|
||||
"The selected content was restored as a new revision. LiveSync could not confirm whether other versions remain; use 'Inspect conflicts and file/database differences' to review the file.",
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
}
|
||||
try {
|
||||
await focusFile(result.path);
|
||||
} catch (ex) {
|
||||
Logger("The restored file could not be opened in the editor.", LOG_LEVEL_NOTICE);
|
||||
Logger(ex, LOG_LEVEL_VERBOSE);
|
||||
}
|
||||
this.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { FilePath, FilePathWithPrefix, UXFileInfo } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import type { DatabaseFileAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/DatabaseFileAccess";
|
||||
import type { IFileHandler } from "@vrtmrz/livesync-commonlib/compat/interfaces/FileHandler";
|
||||
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
|
||||
export type DocumentHistoryRestorationCore = {
|
||||
databaseFileAccess: Pick<
|
||||
DatabaseFileAccess,
|
||||
"fetchEntry" | "fetchEntryMeta" | "getConflictedRevs" | "storeWithLiveBaseRevision"
|
||||
>;
|
||||
fileHandler: Pick<IFileHandler, "dbToStorageWithSpecificRev">;
|
||||
};
|
||||
|
||||
export type DocumentHistoryRestorationResult =
|
||||
| {
|
||||
status: "restored";
|
||||
path: FilePath;
|
||||
revision: string;
|
||||
conflictsRemain: boolean | undefined;
|
||||
conflictCheckError?: unknown;
|
||||
}
|
||||
| { status: "source-unavailable" }
|
||||
| { status: "current-unavailable" }
|
||||
| { status: "unsupported-path" }
|
||||
| { status: "database-write-refused" }
|
||||
| { status: "stored-not-reflected"; path: FilePath; revision: string; cause?: unknown };
|
||||
|
||||
export type DocumentHistoryRestorationOptions = {
|
||||
now?: () => number;
|
||||
isPathValid?: (path: FilePath) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restore historical content as a new child of the current live database winner, then reflect
|
||||
* that exact new revision to the Vault. The historical revision supplies content, not ancestry.
|
||||
*/
|
||||
export async function restoreDocumentHistoryRevision(
|
||||
core: DocumentHistoryRestorationCore,
|
||||
path: FilePathWithPrefix,
|
||||
sourceRevision: string,
|
||||
options: DocumentHistoryRestorationOptions = {}
|
||||
): Promise<DocumentHistoryRestorationResult> {
|
||||
const now = options.now ?? Date.now;
|
||||
const isPathValid = options.isPathValid ?? (() => true);
|
||||
const source = await core.databaseFileAccess.fetchEntry(path, sourceRevision, true, true);
|
||||
if (source === false) {
|
||||
return { status: "source-unavailable" };
|
||||
}
|
||||
|
||||
const current = await core.databaseFileAccess.fetchEntryMeta(path, undefined, true);
|
||||
if (current === false || !current._rev) {
|
||||
return { status: "current-unavailable" };
|
||||
}
|
||||
|
||||
const body = readAsBlob(source);
|
||||
const storagePath = stripAllPrefixes(current.path);
|
||||
if (storagePath !== current.path || !isPathValid(storagePath)) {
|
||||
return { status: "unsupported-path" };
|
||||
}
|
||||
const file: UXFileInfo = {
|
||||
name: storagePath.split("/").pop() ?? storagePath,
|
||||
path: storagePath,
|
||||
stat: {
|
||||
ctime: current.ctime,
|
||||
mtime: now(),
|
||||
size: body.size,
|
||||
type: "file",
|
||||
},
|
||||
body,
|
||||
};
|
||||
const revision = await core.databaseFileAccess.storeWithLiveBaseRevision(file, current._rev, true);
|
||||
if (revision === false) {
|
||||
return { status: "database-write-refused" };
|
||||
}
|
||||
|
||||
try {
|
||||
const reflected = await core.fileHandler.dbToStorageWithSpecificRev(storagePath, revision, true);
|
||||
if (!reflected) {
|
||||
return { status: "stored-not-reflected", path: storagePath, revision };
|
||||
}
|
||||
} catch (cause) {
|
||||
return { status: "stored-not-reflected", path: storagePath, revision, cause };
|
||||
}
|
||||
|
||||
try {
|
||||
const conflictsRemain = (await core.databaseFileAccess.getConflictedRevs(storagePath)).length > 0;
|
||||
return { status: "restored", path: storagePath, revision, conflictsRemain };
|
||||
} catch (conflictCheckError) {
|
||||
return {
|
||||
status: "restored",
|
||||
path: storagePath,
|
||||
revision,
|
||||
conflictsRemain: undefined,
|
||||
conflictCheckError,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
FilePath,
|
||||
FilePathWithPrefix,
|
||||
LoadedEntry,
|
||||
MetaEntry,
|
||||
UXFileInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { restoreDocumentHistoryRevision, type DocumentHistoryRestorationCore } from "./documentHistoryRestoration";
|
||||
|
||||
const path = "history.md" as FilePathWithPrefix;
|
||||
const currentPath = "History.md" as FilePathWithPrefix;
|
||||
|
||||
function createSource(): LoadedEntry {
|
||||
return {
|
||||
_id: "f:history",
|
||||
_rev: "2-source",
|
||||
path,
|
||||
ctime: 10,
|
||||
mtime: 20,
|
||||
size: 18,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: ["h:source"],
|
||||
data: ["historical content"],
|
||||
eden: {},
|
||||
} as LoadedEntry;
|
||||
}
|
||||
|
||||
function createCurrent(): MetaEntry {
|
||||
return {
|
||||
_id: "f:history",
|
||||
_rev: "4-deleted",
|
||||
path: currentPath,
|
||||
ctime: 10,
|
||||
mtime: 40,
|
||||
size: 18,
|
||||
type: "plain",
|
||||
children: ["h:current"],
|
||||
deleted: true,
|
||||
eden: {},
|
||||
} as MetaEntry;
|
||||
}
|
||||
|
||||
function createCore(
|
||||
overrides: {
|
||||
source?: LoadedEntry | false;
|
||||
current?: MetaEntry | false;
|
||||
storedRevision?: string | false;
|
||||
reflected?: boolean;
|
||||
reflectionError?: unknown;
|
||||
conflicts?: string[];
|
||||
conflictCheckError?: unknown;
|
||||
} = {}
|
||||
) {
|
||||
const calls: string[] = [];
|
||||
const fetchEntry = vi.fn(async () => {
|
||||
calls.push("read-source");
|
||||
return overrides.source === undefined ? createSource() : overrides.source;
|
||||
});
|
||||
const fetchEntryMeta = vi.fn(async () => {
|
||||
calls.push("read-current");
|
||||
return overrides.current === undefined ? createCurrent() : overrides.current;
|
||||
});
|
||||
const storeWithLiveBaseRevision = vi.fn(async (_file: UXFileInfo) => {
|
||||
calls.push("store");
|
||||
return overrides.storedRevision === undefined ? "5-restored" : overrides.storedRevision;
|
||||
});
|
||||
const getConflictedRevs = vi.fn(async () => {
|
||||
calls.push("check-conflicts");
|
||||
if (overrides.conflictCheckError !== undefined) {
|
||||
throw overrides.conflictCheckError;
|
||||
}
|
||||
return overrides.conflicts ?? [];
|
||||
});
|
||||
const dbToStorageWithSpecificRev = vi.fn(async () => {
|
||||
calls.push("reflect");
|
||||
if (overrides.reflectionError !== undefined) {
|
||||
throw overrides.reflectionError;
|
||||
}
|
||||
return overrides.reflected ?? true;
|
||||
});
|
||||
const core: DocumentHistoryRestorationCore = {
|
||||
databaseFileAccess: {
|
||||
fetchEntry,
|
||||
fetchEntryMeta,
|
||||
getConflictedRevs,
|
||||
storeWithLiveBaseRevision,
|
||||
},
|
||||
fileHandler: {
|
||||
dbToStorageWithSpecificRev,
|
||||
},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
core,
|
||||
dbToStorageWithSpecificRev,
|
||||
fetchEntry,
|
||||
fetchEntryMeta,
|
||||
getConflictedRevs,
|
||||
storeWithLiveBaseRevision,
|
||||
};
|
||||
}
|
||||
|
||||
describe("restoreDocumentHistoryRevision", () => {
|
||||
it("stores historical bytes below the current deleted winner before reflecting the exact new revision", async () => {
|
||||
const { calls, core, dbToStorageWithSpecificRev, fetchEntry, fetchEntryMeta, storeWithLiveBaseRevision } =
|
||||
createCore();
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source", { now: () => 50 })).resolves.toEqual({
|
||||
status: "restored",
|
||||
path: "History.md" as FilePath,
|
||||
revision: "5-restored",
|
||||
conflictsRemain: false,
|
||||
});
|
||||
|
||||
expect(calls).toEqual(["read-source", "read-current", "store", "reflect", "check-conflicts"]);
|
||||
expect(fetchEntry).toHaveBeenCalledWith(path, "2-source", true, true);
|
||||
expect(fetchEntryMeta).toHaveBeenCalledWith(path, undefined, true);
|
||||
expect(storeWithLiveBaseRevision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: "History.md",
|
||||
path: "History.md",
|
||||
stat: {
|
||||
ctime: 10,
|
||||
mtime: 50,
|
||||
size: 18,
|
||||
type: "file",
|
||||
},
|
||||
body: expect.any(Blob),
|
||||
}),
|
||||
"4-deleted",
|
||||
true
|
||||
);
|
||||
const storedFile = storeWithLiveBaseRevision.mock.calls[0][0];
|
||||
await expect(storedFile.body.text()).resolves.toBe("historical content");
|
||||
expect(storedFile.deleted).toBeUndefined();
|
||||
expect(dbToStorageWithSpecificRev).toHaveBeenCalledWith("History.md", "5-restored", true);
|
||||
});
|
||||
|
||||
it("leaves the Vault unchanged when the conditional database write is refused", async () => {
|
||||
const { calls, core, dbToStorageWithSpecificRev } = createCore({ storedRevision: false });
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
||||
status: "database-write-refused",
|
||||
});
|
||||
|
||||
expect(calls).toEqual(["read-source", "read-current", "store"]);
|
||||
expect(dbToStorageWithSpecificRev).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports a stored revision when its subsequent Vault reflection fails", async () => {
|
||||
const { core, storeWithLiveBaseRevision } = createCore({ reflected: false });
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
||||
status: "stored-not-reflected",
|
||||
path: "History.md" as FilePath,
|
||||
revision: "5-restored",
|
||||
});
|
||||
|
||||
expect(storeWithLiveBaseRevision).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a stored revision when its subsequent Vault reflection throws", async () => {
|
||||
const reflectionError = new Error("adapter unavailable");
|
||||
const { core, getConflictedRevs } = createCore({ reflectionError });
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
||||
status: "stored-not-reflected",
|
||||
path: "History.md" as FilePath,
|
||||
revision: "5-restored",
|
||||
cause: reflectionError,
|
||||
});
|
||||
|
||||
expect(getConflictedRevs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports remaining live conflict leaves after restoration", async () => {
|
||||
const { core, getConflictedRevs } = createCore({ conflicts: ["3-other"] });
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
||||
status: "restored",
|
||||
path: "History.md" as FilePath,
|
||||
revision: "5-restored",
|
||||
conflictsRemain: true,
|
||||
});
|
||||
|
||||
expect(getConflictedRevs).toHaveBeenCalledWith("History.md");
|
||||
});
|
||||
|
||||
it("does not turn a completed restoration into a failure when conflict inspection fails", async () => {
|
||||
const conflictCheckError = new Error("inspection unavailable");
|
||||
const { core } = createCore({ conflictCheckError });
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
||||
status: "restored",
|
||||
path: "History.md" as FilePath,
|
||||
revision: "5-restored",
|
||||
conflictsRemain: undefined,
|
||||
conflictCheckError,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not read or mutate the current tree when the historical source is unavailable", async () => {
|
||||
const { core, fetchEntryMeta, storeWithLiveBaseRevision } = createCore({ source: false });
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
||||
status: "source-unavailable",
|
||||
});
|
||||
|
||||
expect(fetchEntryMeta).not.toHaveBeenCalled();
|
||||
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not write when the current winner cannot supply an exact base revision", async () => {
|
||||
const current = { ...createCurrent(), _rev: undefined } as MetaEntry;
|
||||
const { core, storeWithLiveBaseRevision } = createCore({ current });
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
||||
status: "current-unavailable",
|
||||
});
|
||||
|
||||
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses prefixed internal Metadata before creating a database revision", async () => {
|
||||
const current = { ...createCurrent(), path: "i:.obsidian/config.json" as FilePathWithPrefix } as MetaEntry;
|
||||
const { core, storeWithLiveBaseRevision } = createCore({ current });
|
||||
|
||||
await expect(restoreDocumentHistoryRevision(core, path, "2-source")).resolves.toEqual({
|
||||
status: "unsupported-path",
|
||||
});
|
||||
|
||||
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the host path validator before creating a database revision", async () => {
|
||||
const { core, storeWithLiveBaseRevision } = createCore();
|
||||
|
||||
await expect(
|
||||
restoreDocumentHistoryRevision(core, path, "2-source", { isPathValid: () => false })
|
||||
).resolves.toEqual({ status: "unsupported-path" });
|
||||
|
||||
expect(storeWithLiveBaseRevision).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user