mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-28 23:42:59 +00:00
Improve recovery diagnostics and actions
This commit is contained in:
@@ -74,21 +74,51 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Database information for ${FILE}": "Database information for ${FILE}",
|
||||
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.":
|
||||
"All revisions and chunk availability below are a snapshot of this device's local database; the remote is not queried. Review the Vault-relative path, document identifier, content-derived chunk identifiers, and metadata before sharing this report. File contents are omitted.",
|
||||
"Vault file: modified ${TIME}, size ${SIZE}": "Vault file: modified ${TIME}, size ${SIZE}",
|
||||
"Vault file: missing": "Vault file: missing",
|
||||
"Local database document: missing": "Local database document: missing",
|
||||
"📁 Vault: ${SIZE} B · ${TIME}": "📁 Vault: ${SIZE} B · ${TIME}",
|
||||
"📁 Vault: missing": "📁 Vault: missing",
|
||||
"🗄️ Local DB: missing": "🗄️ Local DB: missing",
|
||||
"Vault and database revision": "Vault and database revision",
|
||||
"Vault file": "Vault file",
|
||||
"Database revision": "Database revision",
|
||||
"Vault file is newer": "Vault file is newer",
|
||||
"Database revision is newer": "Database revision is newer",
|
||||
"Within the two-second comparison window": "Within the two-second comparison window",
|
||||
"Timestamp comparison unavailable": "Timestamp comparison unavailable",
|
||||
"${ROLE}: ${REVISION}": "${ROLE}: ${REVISION}",
|
||||
"Winner revision": "Winner revision",
|
||||
"Conflict revision": "Conflict revision",
|
||||
"Unknown revision": "Unknown revision",
|
||||
"Logical deletion": "Logical deletion",
|
||||
"🗑️ Logical deletion": "🗑️ Logical deletion",
|
||||
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}":
|
||||
"Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}",
|
||||
"Unreadable on this device; ${COUNT} referenced chunks are missing or deleted":
|
||||
"Unreadable on this device; ${COUNT} referenced chunks are missing or deleted",
|
||||
"Matches the current Vault file": "Matches the current Vault file",
|
||||
"Differs from the current Vault file": "Differs from the current Vault file",
|
||||
"🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable":
|
||||
"📦 DB: recorded ${RECORDED} B · decoded unavailable",
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B":
|
||||
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})":
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
|
||||
"✅ Matches Vault": "✅ Matches Vault",
|
||||
"⚠️ Differs from Vault": "⚠️ Differs from Vault",
|
||||
"✅ Vault matches winner": "✅ Vault matches winner",
|
||||
"⚠️ Conflicts: ${COUNT}": "⚠️ Conflicts: ${COUNT}",
|
||||
"Compare with Vault": "Compare with Vault",
|
||||
"Apply this revision to Vault": "Apply this revision to Vault",
|
||||
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.":
|
||||
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.",
|
||||
"Apply database revision to Vault": "Apply database revision to Vault",
|
||||
"Mark this revision as the Vault version": "Mark this revision as the Vault version",
|
||||
"Store Vault file as a child of this revision": "Store Vault file as a child of this revision",
|
||||
"Apply logical deletion to Vault": "Apply logical deletion to Vault",
|
||||
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.":
|
||||
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.",
|
||||
"Retry reading revision": "Retry reading revision",
|
||||
"Discard this branch": "Discard this branch",
|
||||
"Discard branch": "Discard branch",
|
||||
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.":
|
||||
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.",
|
||||
"Discard unreadable revision": "Discard unreadable revision",
|
||||
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.":
|
||||
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.",
|
||||
@@ -97,9 +127,11 @@ export const liveSyncProvisionalEnglishMessages = {
|
||||
"Shared ancestor ${REVISION} is not readable on this device. Automatic three-way merging may be unavailable, but the live revisions remain available for explicit review.",
|
||||
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.":
|
||||
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review.",
|
||||
"More actions for revision ${REVISION}": "More actions for revision ${REVISION}",
|
||||
"More actions for ${FILE}": "More actions for ${FILE}",
|
||||
"Show revision history": "Show revision history",
|
||||
"Use Vault file in local database": "Use Vault file in local database",
|
||||
"Restore database winner to Vault": "Restore database winner to Vault",
|
||||
"Store Vault file as a new local database document":
|
||||
"Store Vault file as a new local database document",
|
||||
"Copy database information": "Copy database information",
|
||||
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
|
||||
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
|
||||
|
||||
@@ -1530,6 +1530,29 @@ Offline Changed files: ${files.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
private async getLiveInternalRevision(
|
||||
prefixedFileName: FilePathWithPrefix,
|
||||
revision: string
|
||||
): Promise<MetaEntry | false> {
|
||||
const [selected, current, conflicts] = await Promise.all([
|
||||
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, revision, true),
|
||||
this.core.databaseFileAccess.fetchEntryMeta(prefixedFileName, undefined, true),
|
||||
this.core.databaseFileAccess.getConflictedRevs(prefixedFileName),
|
||||
]);
|
||||
const liveRevisions = new Set([
|
||||
...(current && current._rev ? [current._rev] : []),
|
||||
...conflicts,
|
||||
]);
|
||||
if (!selected || selected._rev !== revision || !liveRevisions.has(revision)) {
|
||||
this._log(
|
||||
`Could not use hidden-file revision ${revision} of ${stripAllPrefixes(prefixedFileName)}; the selected revision is no longer live`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabase(file: InternalFileInfo | UXFileInfo, forceWrite = false) {
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
@@ -1581,6 +1604,79 @@ Offline Changed files: ${files.length}`;
|
||||
});
|
||||
}
|
||||
|
||||
async storeInternalFileToDatabaseWithBaseRevision(
|
||||
file: InternalFileInfo | UXFileInfo,
|
||||
baseRevision: string,
|
||||
createIfDifferent = true
|
||||
): Promise<boolean> {
|
||||
const storeFilePath = stripAllPrefixes(file.path);
|
||||
const storageFilePath = file.path;
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
return false;
|
||||
}
|
||||
const prefixedFileName = addPrefix(storeFilePath, ICHeader);
|
||||
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
const baseData = await this.getLiveInternalRevision(prefixedFileName, baseRevision);
|
||||
if (baseData === false) {
|
||||
return false;
|
||||
}
|
||||
const fileInfo = "stat" in file && "body" in file ? file : await this.loadFileWithInfo(storeFilePath);
|
||||
if (fileInfo.deleted) {
|
||||
throw new Error(`Hidden file:${storeFilePath} is deleted. This should not be occurred.`);
|
||||
}
|
||||
if (!baseData.deleted && !baseData._deleted) {
|
||||
const loadedBase = await this.core.databaseFileAccess.fetchEntryFromMeta(baseData, true, true);
|
||||
if (loadedBase && (await isDocContentSame(readAsBlob(loadedBase), fileInfo.body))) {
|
||||
this.updateLastProcessed(storeFilePath, baseData, fileInfo.stat);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (!createIfDifferent) {
|
||||
this._log(
|
||||
`Could not mark hidden file ${storeFilePath} as revision ${baseRevision}; the storage content differs`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const storedRevision = await this.core.databaseFileAccess.storeWithBaseRevision(
|
||||
{
|
||||
...fileInfo,
|
||||
path: storeFilePath,
|
||||
name: fileInfo.name || storeFilePath.split("/").pop() || "",
|
||||
isInternal: true,
|
||||
},
|
||||
baseRevision,
|
||||
true
|
||||
);
|
||||
if (storedRevision === false) {
|
||||
return false;
|
||||
}
|
||||
this.updateLastProcessed(
|
||||
storeFilePath,
|
||||
{
|
||||
...baseData,
|
||||
_rev: storedRevision,
|
||||
path: prefixedFileName,
|
||||
ctime: fileInfo.stat.ctime,
|
||||
mtime: fileInfo.stat.mtime,
|
||||
size: fileInfo.stat.size,
|
||||
deleted: false,
|
||||
},
|
||||
fileInfo.stat
|
||||
);
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Done`);
|
||||
return true;
|
||||
} catch (ex) {
|
||||
this._log(`STORAGE --> DB:${storageFilePath}: (hidden, selected branch) Failed`);
|
||||
this._log(ex, LOG_LEVEL_VERBOSE);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteInternalFileOnDatabase(filenameSrc: FilePath, forceWrite = false) {
|
||||
const storeFilePath = filenameSrc;
|
||||
const storageFilePath = filenameSrc;
|
||||
@@ -1644,7 +1740,8 @@ Offline Changed files: ${files.length}`;
|
||||
metaEntry?: MetaEntry | LoadedEntry,
|
||||
preventDoubleProcess = true,
|
||||
onlyNew = false,
|
||||
includeDeletion = true
|
||||
includeDeletion = true,
|
||||
requiredLiveRevision?: string
|
||||
) {
|
||||
const prefixedFileName = addPrefix(storageFilePath, ICHeader);
|
||||
if (await this.services.vault.isIgnoredByIgnoreFile(storageFilePath)) {
|
||||
@@ -1653,9 +1750,11 @@ Offline Changed files: ${files.length}`;
|
||||
return await serialized("file-" + prefixedFileName, async () => {
|
||||
try {
|
||||
// Check conflicted status
|
||||
const metaOnDB = metaEntry
|
||||
? metaEntry
|
||||
: await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true);
|
||||
const metaOnDB = requiredLiveRevision
|
||||
? await this.getLiveInternalRevision(prefixedFileName, requiredLiveRevision)
|
||||
: metaEntry
|
||||
? metaEntry
|
||||
: await this.localDatabase.getDBEntryMeta(prefixedFileName, { conflicts: true }, true);
|
||||
if (metaOnDB === false) throw new Error(`File not found on database.:${storageFilePath}`);
|
||||
// Prevent overwrite for Prevent overwriting while some conflicted revision exists.
|
||||
if (metaOnDB?._conflicts?.length) {
|
||||
@@ -1729,6 +1828,24 @@ Offline Changed files: ${files.length}`;
|
||||
});
|
||||
}
|
||||
|
||||
async extractInternalFileRevisionFromDatabase(
|
||||
storageFilePath: FilePath,
|
||||
revision: string,
|
||||
force = false
|
||||
): Promise<boolean> {
|
||||
return Boolean(
|
||||
await this.extractInternalFileFromDatabase(
|
||||
storageFilePath,
|
||||
force,
|
||||
undefined,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
revision
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async __checkIsNeedToWriteFile(storageFilePath: FilePath, content: string | ArrayBuffer): Promise<boolean> {
|
||||
try {
|
||||
const storageContent = await this.core.storageAccess.readHiddenFileAuto(storageFilePath);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { LOG_LEVEL_NOTICE } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import {
|
||||
type DocumentID,
|
||||
LOG_LEVEL_NOTICE,
|
||||
type FilePath,
|
||||
type FilePathWithPrefix,
|
||||
type MetaEntry,
|
||||
type UXFileInfo,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
|
||||
vi.mock("@/deps.ts", () => ({}));
|
||||
vi.mock("@/features/HiddenFileCommon/JsonResolveModal.ts", () => ({
|
||||
@@ -27,6 +34,70 @@ vi.mock("./configureHiddenFileSyncMode.ts", () => ({
|
||||
import { HiddenFileSync } from "./CmdHiddenFileSync.ts";
|
||||
import { configureHiddenFileSyncMode } from "./configureHiddenFileSyncMode.ts";
|
||||
|
||||
function createHiddenRevisionOperation() {
|
||||
const path = ".obsidian/plugins/example/data.json" as FilePath;
|
||||
const file = {
|
||||
path,
|
||||
name: "data.json",
|
||||
isInternal: true,
|
||||
body: new Blob(["{\"value\":\"vault\"}"]),
|
||||
stat: {
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 17,
|
||||
type: "file",
|
||||
},
|
||||
} as UXFileInfo;
|
||||
const selected = {
|
||||
_id: "i:example" as DocumentID,
|
||||
_rev: "2-selected",
|
||||
path: `i:${path}` as FilePathWithPrefix,
|
||||
ctime: 1,
|
||||
mtime: 2,
|
||||
size: 17,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
deleted: false,
|
||||
} as MetaEntry;
|
||||
const winner = {
|
||||
...selected,
|
||||
_rev: "3-winner",
|
||||
} as MetaEntry;
|
||||
const databaseFileAccess = {
|
||||
fetchEntryMeta: vi.fn(
|
||||
async (_path: unknown, revision?: string) =>
|
||||
revision === selected._rev ? selected : winner
|
||||
),
|
||||
getConflictedRevs: vi.fn(async () => [selected._rev]),
|
||||
fetchEntryFromMeta: vi.fn(async () => ({ ...selected, data: "{\"value\":\"database\"}" })),
|
||||
storeWithBaseRevision: vi.fn(async () => "3-vault-child"),
|
||||
};
|
||||
const hiddenFileSync = Object.create(HiddenFileSync.prototype) as HiddenFileSync;
|
||||
Object.assign(hiddenFileSync, {
|
||||
core: {
|
||||
services: {
|
||||
vault: {
|
||||
isIgnoredByIgnoreFile: vi.fn(async () => false),
|
||||
},
|
||||
},
|
||||
databaseFileAccess,
|
||||
},
|
||||
loadFileWithInfo: vi.fn(async () => file),
|
||||
updateLastProcessed: vi.fn(),
|
||||
_log: vi.fn(),
|
||||
});
|
||||
return {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
file,
|
||||
selected,
|
||||
winner,
|
||||
databaseFileAccess,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HiddenFileSync configuration-change notices", () => {
|
||||
it("shows manual Hidden File Sync commands only when the feature, Advanced mode, and runtime are ready", () => {
|
||||
const commands: Array<{
|
||||
@@ -270,3 +341,130 @@ describe("HiddenFileSync configuration-change notices", () => {
|
||||
expect(progress.done).toHaveBeenCalledWith("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HiddenFileSync exact revision repair operations", () => {
|
||||
it("stores the current hidden Vault file as a child of the selected live revision", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: file.path,
|
||||
body: file.body,
|
||||
isInternal: true,
|
||||
}),
|
||||
selected._rev,
|
||||
true
|
||||
);
|
||||
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
|
||||
file.path,
|
||||
expect.objectContaining({ _rev: "3-vault-child" }),
|
||||
file.stat
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses to extend a hidden-file revision which is no longer live", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(file, selected._rev!)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not create a hidden-file child when asked only to mark a revision which differs from the Vault", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
|
||||
file,
|
||||
selected._rev!,
|
||||
false
|
||||
)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks a matching hidden-file revision without creating a child", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
file,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.fetchEntryFromMeta.mockResolvedValue({
|
||||
...selected,
|
||||
data: "{\"value\":\"vault\"}",
|
||||
});
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.storeInternalFileToDatabaseWithBaseRevision(
|
||||
file,
|
||||
selected._rev!,
|
||||
false
|
||||
)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(databaseFileAccess.storeWithBaseRevision).not.toHaveBeenCalled();
|
||||
expect(hiddenFileSync.updateLastProcessed).toHaveBeenCalledWith(
|
||||
file.path,
|
||||
selected,
|
||||
file.stat
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the selected live hidden-file revision through the existing extraction path", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
selected,
|
||||
} = createHiddenRevisionOperation();
|
||||
const extract = vi.fn(async () => true);
|
||||
hiddenFileSync.extractInternalFileFromDatabase = extract;
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(extract).toHaveBeenCalledWith(path, true, undefined, true, false, true, selected._rev);
|
||||
});
|
||||
|
||||
it("does not apply a hidden-file revision which ceased to be live", async () => {
|
||||
const {
|
||||
hiddenFileSync,
|
||||
path,
|
||||
selected,
|
||||
databaseFileAccess,
|
||||
} = createHiddenRevisionOperation();
|
||||
databaseFileAccess.getConflictedRevs.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
hiddenFileSync.extractInternalFileRevisionFromDatabase(path, selected._rev!, true)
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(databaseFileAccess.fetchEntryFromMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,13 @@ export const POSTPONED = Symbol("postponed");
|
||||
|
||||
export type MergeDialogResult = typeof CANCELLED | typeof POSTPONED | typeof LEAVE_TO_SUBSEQUENT | string;
|
||||
|
||||
export type ConflictResolveModalOptions = {
|
||||
readOnly?: boolean;
|
||||
title?: string;
|
||||
localName?: string;
|
||||
remoteName?: string;
|
||||
};
|
||||
|
||||
export class ConflictResolveModal extends Modal {
|
||||
result: diff_result;
|
||||
filename: FilePathWithPrefix;
|
||||
@@ -25,6 +32,7 @@ export class ConflictResolveModal extends Modal {
|
||||
title: string = "Conflicting changes";
|
||||
|
||||
pluginPickMode: boolean = false;
|
||||
readOnly: boolean = false;
|
||||
localName: string = "Base";
|
||||
remoteName: string = "Conflicted";
|
||||
offEvent?: ReturnType<typeof eventHub.onEvent>;
|
||||
@@ -37,16 +45,22 @@ export class ConflictResolveModal extends Modal {
|
||||
filename: FilePathWithPrefix,
|
||||
diff: diff_result,
|
||||
pluginPickMode?: boolean,
|
||||
remoteName?: string
|
||||
remoteName?: string,
|
||||
options?: ConflictResolveModalOptions
|
||||
) {
|
||||
super(app);
|
||||
this.result = diff;
|
||||
this.filename = filename;
|
||||
this.pluginPickMode = pluginPickMode || false;
|
||||
this.readOnly = options?.readOnly ?? false;
|
||||
if (this.pluginPickMode) {
|
||||
this.title = "Pick a version";
|
||||
this.remoteName = `${remoteName || "Remote"}`;
|
||||
this.localName = "Local";
|
||||
} else if (this.readOnly) {
|
||||
this.title = options?.title ?? "Vault and database revision";
|
||||
this.localName = options?.localName ?? "Vault file";
|
||||
this.remoteName = options?.remoteName ?? "Database revision";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,16 +115,18 @@ export class ConflictResolveModal extends Modal {
|
||||
if (this.offEvent) {
|
||||
this.offEvent();
|
||||
}
|
||||
// Cancel an older dialogue for this path before subscribing this
|
||||
// instance. Emitting after subscription would close the replacement
|
||||
// itself; the instance-owned result promise then completes the older
|
||||
// caller even when it only begins waiting after this event.
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
if (!this.readOnly) {
|
||||
// Cancel an older dialogue for this path before subscribing this
|
||||
// instance. Emitting after subscription would close the replacement
|
||||
// itself; the instance-owned result promise then completes the older
|
||||
// caller even when it only begins waiting after this event.
|
||||
eventHub.emitEvent(EVENT_CONFLICT_CANCELLED, this.filename);
|
||||
this.offEvent = eventHub.onEvent(EVENT_CONFLICT_CANCELLED, (path) => {
|
||||
if (path === this.filename) {
|
||||
this.sendResponse(CANCELLED);
|
||||
}
|
||||
});
|
||||
}
|
||||
this.titleEl.setText(this.title);
|
||||
contentEl.empty();
|
||||
const diffOptionsRow = contentEl.createDiv("");
|
||||
@@ -159,24 +175,31 @@ export class ConflictResolveModal extends Modal {
|
||||
this.appendVersionInfo(div2, "deleted", this.localName, date1);
|
||||
this.appendVersionInfo(div2, "added", this.remoteName, date2);
|
||||
const actionContainer = contentEl.createDiv("conflict-action-container");
|
||||
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
|
||||
});
|
||||
if (!this.pluginPickMode) {
|
||||
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
|
||||
if (this.readOnly) {
|
||||
actionContainer.createEl("button", { text: "Close" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
e.addEventListener("click", () => this.sendResponse(CANCELLED));
|
||||
});
|
||||
} else {
|
||||
actionContainer.createEl("button", { text: `Use ${this.localName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.right.rev));
|
||||
});
|
||||
actionContainer.createEl("button", { text: `Use ${this.remoteName}` }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.result.left.rev));
|
||||
});
|
||||
if (!this.pluginPickMode) {
|
||||
actionContainer.createEl("button", { text: "Concat both" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(LEAVE_TO_SUBSEQUENT));
|
||||
});
|
||||
}
|
||||
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
});
|
||||
}
|
||||
actionContainer.createEl("button", { text: !this.pluginPickMode ? "Not now" : "Cancel" }, (e) => {
|
||||
e.addClass("conflict-action-button");
|
||||
e.addEventListener("click", () => this.sendResponse(this.pluginPickMode ? CANCELLED : POSTPONED));
|
||||
});
|
||||
if (diffLength > 100 * 1024) {
|
||||
this.diffView.empty();
|
||||
this.diffView.setText("(Too large diff to display)");
|
||||
|
||||
@@ -5,6 +5,8 @@ import { CANCELLED, type diff_result, type FilePathWithPrefix } from "@vrtmrz/li
|
||||
vi.mock("@/deps.ts", () => ({
|
||||
App: class App {},
|
||||
Modal: class Modal {
|
||||
createdButtons: string[] = [];
|
||||
|
||||
private createElement(): Record<string, unknown> {
|
||||
const element: Record<string, unknown> = {
|
||||
addClass: vi.fn(),
|
||||
@@ -22,6 +24,14 @@ vi.mock("@/deps.ts", () => ({
|
||||
};
|
||||
element.createDiv = vi.fn(() => this.createElement());
|
||||
element.createEl = vi.fn((_tag: string, _options?: unknown, callback?: (child: unknown) => void) => {
|
||||
if (
|
||||
_tag === "button" &&
|
||||
typeof _options === "object" &&
|
||||
_options !== null &&
|
||||
"text" in _options
|
||||
) {
|
||||
this.createdButtons.push(String((_options as { text: unknown }).text));
|
||||
}
|
||||
const child = this.createElement();
|
||||
callback?.(child);
|
||||
return child;
|
||||
@@ -82,4 +92,55 @@ describe("ConflictResolveModal result lifecycle", () => {
|
||||
expect(previousResult).toBe(CANCELLED);
|
||||
expect(replacementState).toBe("still-open");
|
||||
});
|
||||
|
||||
it("renders a read-only comparison with no resolution actions", () => {
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal & { createdButtons: string[] };
|
||||
const modal = new ReadOnlyModal(
|
||||
{},
|
||||
"repair-preview.md",
|
||||
conflict,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
title: "Vault and database revision",
|
||||
localName: "Vault file",
|
||||
remoteName: "Database revision",
|
||||
}
|
||||
);
|
||||
|
||||
modal.onOpen();
|
||||
|
||||
expect(modal.createdButtons).toContain("Close");
|
||||
expect(modal.createdButtons).not.toContain("Use Vault file");
|
||||
expect(modal.createdButtons).not.toContain("Use Database revision");
|
||||
expect(modal.createdButtons).not.toContain("Concat both");
|
||||
expect(modal.createdButtons).not.toContain("Not now");
|
||||
modal.close();
|
||||
});
|
||||
|
||||
it("does not cancel an active conflict dialogue when a read-only comparison opens for the same file", async () => {
|
||||
const filename = "repair-alongside-conflict.md" as FilePathWithPrefix;
|
||||
const previous = new ConflictResolveModal({} as never, filename, conflict);
|
||||
const ReadOnlyModal = ConflictResolveModal as unknown as new (
|
||||
...args: unknown[]
|
||||
) => ConflictResolveModal;
|
||||
const comparison = new ReadOnlyModal({}, filename, conflict, false, undefined, {
|
||||
readOnly: true,
|
||||
});
|
||||
previous.onOpen();
|
||||
|
||||
comparison.onOpen();
|
||||
const previousState = await Promise.race([
|
||||
previous.waitForResult(),
|
||||
new Promise<"still-open">((resolve) => setTimeout(() => resolve("still-open"), 25)),
|
||||
]);
|
||||
|
||||
previous.sendResponse(CANCELLED);
|
||||
comparison.close();
|
||||
|
||||
expect(previousState).toBe("still-open");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
type DocumentID,
|
||||
LOG_LEVEL_NOTICE,
|
||||
LOG_LEVEL_VERBOSE,
|
||||
type MetaEntry,
|
||||
type FilePath,
|
||||
type EntryDoc,
|
||||
type diff_result,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import { readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { createBlob, readAsBlob } from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { Logger } from "@vrtmrz/livesync-commonlib/compat/common/logger";
|
||||
import { shouldBeIgnored } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import { Menu, diff_match_patch } from "@/deps.ts";
|
||||
import { $msg } from "@/common/translation";
|
||||
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
||||
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
|
||||
eventHub,
|
||||
} from "@/common/events.ts";
|
||||
import { ICHeader } from "@/common/types.ts";
|
||||
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
||||
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
|
||||
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
||||
@@ -33,11 +33,17 @@ import {
|
||||
retryReadFileDatabaseRevision,
|
||||
} from "@/serviceFeatures/fileDatabaseInfo.ts";
|
||||
import {
|
||||
discardLiveBranch,
|
||||
discardUnreadableLiveRevision,
|
||||
inspectFileRepair,
|
||||
type FileRepairInspection,
|
||||
type FileRepairRevision,
|
||||
} from "@/serviceFeatures/fileRepair.ts";
|
||||
import {
|
||||
getFileRepairRevisionActions,
|
||||
getFileRepairRevisionComparison,
|
||||
} from "@/serviceFeatures/fileRepairPresentation.ts";
|
||||
import { ConflictResolveModal } from "@/modules/features/InteractiveConflictResolving/ConflictResolveModal.ts";
|
||||
export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, { addPanel }: PageFunctions): void {
|
||||
// const hatchWarn = this.createEl(paneEl, "div", { text: `To stop the boot up sequence for fixing problems on databases, you can put redflag.md on top of your vault (Rebooting obsidian is required).` });
|
||||
// hatchWarn.addClass("op-warn-info");
|
||||
@@ -123,57 +129,195 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
|
||||
void addPanel(paneEl, "Recovery and Repair").then((paneEl) => {
|
||||
const resultArea = paneEl.createDiv({ text: "", cls: "sls-repair-results" });
|
||||
const addActionButton = (
|
||||
type RepairMenuAction = {
|
||||
title: string;
|
||||
run: () => Promise<void> | void;
|
||||
warning?: boolean;
|
||||
};
|
||||
const addActionMenu = (
|
||||
parent: HTMLElement,
|
||||
text: string,
|
||||
action: (button: HTMLButtonElement) => Promise<void> | void,
|
||||
warning = false
|
||||
label: string,
|
||||
actions: RepairMenuAction[]
|
||||
) => {
|
||||
this.createEl(parent, "button", { text }, (button) => {
|
||||
if (warning) {
|
||||
button.addClass("mod-warning");
|
||||
}
|
||||
button.onClickEvent(async () => {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await action(button);
|
||||
} finally {
|
||||
if (button.isConnected) {
|
||||
button.disabled = false;
|
||||
}
|
||||
if (actions.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.createEl(parent, "button", { text: "…", cls: "sls-repair-action-menu" }, (button) => {
|
||||
button.setAttr("aria-label", label);
|
||||
button.setAttr("title", label);
|
||||
button.onClickEvent(() => {
|
||||
const menu = new Menu();
|
||||
for (const action of actions) {
|
||||
menu.addItem((item) => {
|
||||
item.setTitle(action.title);
|
||||
if (action.warning) {
|
||||
item.setWarning(true);
|
||||
}
|
||||
item.onClick(() => {
|
||||
button.disabled = true;
|
||||
void Promise.resolve()
|
||||
.then(() => action.run())
|
||||
.catch((error) => {
|
||||
Logger(error, LOG_LEVEL_VERBOSE);
|
||||
Logger(
|
||||
`Repair action '${action.title}' failed`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (button.isConnected) {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
const rect = button.getBoundingClientRect();
|
||||
menu.showAtPosition({ x: rect.left, y: rect.bottom });
|
||||
});
|
||||
});
|
||||
};
|
||||
const findHiddenFile = async (path: string) => {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
if (!addOn) {
|
||||
return false;
|
||||
}
|
||||
const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path);
|
||||
if (!file) {
|
||||
Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return { addOn, file };
|
||||
};
|
||||
const storeStorageInDatabase = async (path: string): Promise<boolean> => {
|
||||
if (path.startsWith(".")) {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
if (!addOn) {
|
||||
return false;
|
||||
}
|
||||
const file = (await addOn.scanInternalFiles()).find((entry) => entry.path === path);
|
||||
if (!file) {
|
||||
Logger(`Failed to find the file in the internal files: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return false;
|
||||
}
|
||||
return Boolean(await addOn.storeInternalFileToDatabase(file, true));
|
||||
const hidden = await findHiddenFile(path);
|
||||
return hidden
|
||||
? Boolean(await hidden.addOn.storeInternalFileToDatabase(hidden.file, true))
|
||||
: false;
|
||||
}
|
||||
return Boolean(await this.core.fileHandler.storeFileToDB(path as FilePath, true));
|
||||
};
|
||||
const applyWinnerToStorage = async (
|
||||
const storeStorageOnRevision = async (
|
||||
path: string,
|
||||
revision: FileRepairRevision
|
||||
revision: string,
|
||||
createIfDifferent = true
|
||||
): Promise<boolean> => {
|
||||
if (revision.loadedEntry === false) {
|
||||
return false;
|
||||
}
|
||||
if (revision.loadedEntry.path.startsWith(ICHeader)) {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
return addOn
|
||||
? Boolean(await addOn.extractInternalFileFromDatabase(path as FilePath, true))
|
||||
if (path.startsWith(".")) {
|
||||
const hidden = await findHiddenFile(path);
|
||||
return hidden
|
||||
? Boolean(
|
||||
await hidden.addOn.storeInternalFileToDatabaseWithBaseRevision(
|
||||
hidden.file,
|
||||
revision,
|
||||
createIfDifferent
|
||||
)
|
||||
)
|
||||
: false;
|
||||
}
|
||||
return Boolean(await this.core.fileHandler.dbToStorage(revision.loadedEntry as MetaEntry, null, true));
|
||||
return Boolean(
|
||||
await this.core.fileHandler.storeFileToDBWithBaseRevision(
|
||||
path as FilePath,
|
||||
revision,
|
||||
createIfDifferent
|
||||
)
|
||||
);
|
||||
};
|
||||
const applyRevisionToStorage = async (
|
||||
path: string,
|
||||
revision: string,
|
||||
force: boolean
|
||||
): Promise<boolean> => {
|
||||
if (path.startsWith(".")) {
|
||||
const addOn = this.core.getAddOn<HiddenFileSync>(HiddenFileSync.name);
|
||||
return addOn
|
||||
? Boolean(
|
||||
await addOn.extractInternalFileRevisionFromDatabase(
|
||||
path as FilePath,
|
||||
revision,
|
||||
force
|
||||
)
|
||||
)
|
||||
: false;
|
||||
}
|
||||
return Boolean(
|
||||
await this.core.fileHandler.dbToStorageWithSpecificRev(
|
||||
path as FilePath,
|
||||
revision,
|
||||
force
|
||||
)
|
||||
);
|
||||
};
|
||||
const openRevisionComparison = async (
|
||||
path: string,
|
||||
selectedRevision: string
|
||||
): Promise<boolean> => {
|
||||
const latest = await inspectFileRepair(this.core, path);
|
||||
const revision = latest.revisions.find(
|
||||
({ metadata }) => metadata.revision === selectedRevision
|
||||
);
|
||||
if (
|
||||
!latest.information.storage.exists ||
|
||||
!revision ||
|
||||
revision.loadedEntry === false
|
||||
) {
|
||||
Logger(
|
||||
`Could not compare ${path} revision ${selectedRevision}; the Vault file or selected live revision is no longer readable`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const vaultText = await createBlob(
|
||||
await this.core.storageAccess.readHiddenFileBinary(path)
|
||||
).text();
|
||||
const databaseText = await readAsBlob(revision.loadedEntry).text();
|
||||
const dmp = new diff_match_patch();
|
||||
const diff = dmp.diff_main(vaultText, databaseText);
|
||||
dmp.diff_cleanupSemantic(diff);
|
||||
const result: diff_result = {
|
||||
left: {
|
||||
rev: "vault",
|
||||
data: vaultText,
|
||||
ctime: latest.information.storage.ctime ?? 0,
|
||||
mtime: latest.information.storage.mtime ?? 0,
|
||||
},
|
||||
right: {
|
||||
rev: selectedRevision,
|
||||
data: databaseText,
|
||||
ctime: revision.metadata.ctime,
|
||||
mtime: revision.metadata.mtime,
|
||||
},
|
||||
diff,
|
||||
};
|
||||
new ConflictResolveModal(
|
||||
this.app,
|
||||
path as FilePathWithPrefix,
|
||||
result,
|
||||
false,
|
||||
undefined,
|
||||
{
|
||||
readOnly: true,
|
||||
title: $msg("Vault and database revision"),
|
||||
localName: $msg("Vault file"),
|
||||
remoteName: $msg("Database revision"),
|
||||
}
|
||||
).open();
|
||||
return true;
|
||||
};
|
||||
const formatSigned = (value: number) => `${value >= 0 ? "+" : ""}${value}`;
|
||||
const timestampRelationLabel = (
|
||||
relation: ReturnType<typeof getFileRepairRevisionComparison>["timestampRelation"]
|
||||
) => {
|
||||
switch (relation) {
|
||||
case "vault-newer":
|
||||
return $msg("Vault file is newer");
|
||||
case "database-newer":
|
||||
return $msg("Database revision is newer");
|
||||
case "same-window":
|
||||
return $msg("Within the two-second comparison window");
|
||||
default:
|
||||
return $msg("Timestamp comparison unavailable");
|
||||
}
|
||||
};
|
||||
const addRepairResult = (inspection: FileRepairInspection) => {
|
||||
const { information, revisions } = inspection;
|
||||
@@ -188,40 +332,128 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
Logger(`Verification no longer reports a problem for ${path}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
};
|
||||
const runMutation = async (
|
||||
description: string,
|
||||
mutation: () => Promise<boolean>
|
||||
) => {
|
||||
try {
|
||||
const succeeded = await mutation();
|
||||
if (!succeeded) {
|
||||
Logger(`${description} failed: ${path}`, LOG_LEVEL_NOTICE);
|
||||
}
|
||||
} finally {
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
const discardLiveBranchAction = (revision: string): RepairMenuAction => ({
|
||||
title: $msg("Discard this branch"),
|
||||
warning: true,
|
||||
run: async () => {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Discard database branch ${REVISION} of ${FILE}? This creates a logical deletion for that exact live branch. The current Vault file will not be changed.",
|
||||
{
|
||||
REVISION: revision,
|
||||
FILE: path,
|
||||
}
|
||||
),
|
||||
{
|
||||
title: $msg("Discard branch"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const result = await discardLiveBranch(this.core, path, revision);
|
||||
Logger(
|
||||
`Discard database branch ${revision} of ${path}: ${result}`,
|
||||
result === "discarded" ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE
|
||||
);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
|
||||
this.createEl(card, "h6", { text: path });
|
||||
const fileHeader = this.createEl(card, "div", { cls: "sls-repair-header" });
|
||||
this.createEl(fileHeader, "h6", { text: path });
|
||||
const fileMenuHost = this.createEl(fileHeader, "div");
|
||||
if (information.storage.exists) {
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("Vault file: modified ${TIME}, size ${SIZE}", {
|
||||
text: $msg("📁 Vault: ${SIZE} B · ${TIME}", {
|
||||
TIME: new Date(information.storage.mtime ?? 0).toLocaleString(),
|
||||
SIZE: `${information.storage.size ?? 0}`,
|
||||
}),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
} else {
|
||||
this.createEl(card, "div", { text: $msg("Vault file: missing") });
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("📁 Vault: missing"),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
}
|
||||
if (!information.database.exists) {
|
||||
this.createEl(card, "div", { text: $msg("Local database document: missing") });
|
||||
this.createEl(card, "div", {
|
||||
text: $msg("🗄️ Local DB: missing"),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
}
|
||||
if (information.database.conflictCount > 0) {
|
||||
const winner = revisions.find(({ role }) => role === "winner");
|
||||
const vaultMatchesWinner =
|
||||
winner !== undefined &&
|
||||
(winner.metadata.deleted
|
||||
? !information.storage.exists
|
||||
: information.storage.exists &&
|
||||
winner.contentMatchesStorage === true);
|
||||
const status = this.createEl(card, "div", { cls: "sls-repair-status" });
|
||||
if (vaultMatchesWinner) {
|
||||
this.createEl(status, "span", {
|
||||
text: $msg("✅ Vault matches winner"),
|
||||
cls: "sls-repair-status-ok",
|
||||
});
|
||||
}
|
||||
this.createEl(status, "span", {
|
||||
text: $msg("⚠️ Conflicts: ${COUNT}", {
|
||||
COUNT: `${information.database.conflictCount}`,
|
||||
}),
|
||||
cls: "sls-repair-status-warning",
|
||||
});
|
||||
}
|
||||
|
||||
const addRevision = (revision: FileRepairRevision) => {
|
||||
const { metadata } = revision;
|
||||
const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" });
|
||||
this.createEl(revisionEl, "div", {
|
||||
const revisionHeader = this.createEl(revisionEl, "div", {
|
||||
cls: "sls-repair-header",
|
||||
});
|
||||
this.createEl(revisionHeader, "div", {
|
||||
text: $msg("${ROLE}: ${REVISION}", {
|
||||
ROLE: revision.role === "winner" ? $msg("Winner revision") : $msg("Conflict revision"),
|
||||
REVISION: metadata.revision ?? $msg("Unknown revision"),
|
||||
}),
|
||||
cls: "sls-repair-revision-title",
|
||||
});
|
||||
const revisionMenuHost = this.createEl(revisionHeader, "div");
|
||||
const comparison = getFileRepairRevisionComparison(inspection, revision);
|
||||
if (metadata.deleted) {
|
||||
this.createEl(revisionEl, "div", { text: $msg("Logical deletion") });
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("🗑️ Logical deletion"),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
} else if (revision.contentReadable) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("Readable on this device; recorded size ${RECORDED}, decoded size ${ACTUAL}", {
|
||||
RECORDED: `${metadata.recordedSize}`,
|
||||
ACTUAL: `${revision.loadedEntry === false ? 0 : readAsBlob(revision.loadedEntry).size}`,
|
||||
}),
|
||||
text: $msg(
|
||||
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
|
||||
{
|
||||
RECORDED: `${comparison.recordedSize}`,
|
||||
DECODED: `${comparison.decodedSize ?? 0}`,
|
||||
DIFFERENCE: formatSigned(
|
||||
comparison.recordedToDecodedSizeDifference ?? 0
|
||||
),
|
||||
}
|
||||
),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
} else {
|
||||
const missing = metadata.chunks.filter(
|
||||
@@ -229,10 +461,16 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
!embedded && localDatabaseState !== "available"
|
||||
);
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("Unreadable on this device; ${COUNT} referenced chunks are missing or deleted", {
|
||||
text: $msg("🧩 Missing chunks: ${COUNT}", {
|
||||
COUNT: `${missing.length}`,
|
||||
}),
|
||||
cls: "mod-warning",
|
||||
cls: "sls-repair-metric mod-warning",
|
||||
});
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("📦 DB: recorded ${RECORDED} B · decoded unavailable", {
|
||||
RECORDED: `${comparison.recordedSize}`,
|
||||
}),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
this.createEl(revisionEl, "code", {
|
||||
@@ -243,28 +481,196 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (
|
||||
comparison.vaultSize !== null &&
|
||||
comparison.databaseToVaultSizeDifference !== null
|
||||
) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B", {
|
||||
VAULT: `${comparison.vaultSize}`,
|
||||
DIFFERENCE: formatSigned(
|
||||
comparison.databaseToVaultSizeDifference
|
||||
),
|
||||
}),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
}
|
||||
if (
|
||||
comparison.vaultMtime !== null &&
|
||||
comparison.timestampDifferenceMs !== null
|
||||
) {
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg(
|
||||
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
|
||||
{
|
||||
DATABASE_TIME: new Date(
|
||||
comparison.databaseMtime
|
||||
).toLocaleString(),
|
||||
VAULT_TIME: new Date(
|
||||
comparison.vaultMtime
|
||||
).toLocaleString(),
|
||||
DIFFERENCE: formatSigned(
|
||||
comparison.timestampDifferenceMs
|
||||
),
|
||||
RELATION: timestampRelationLabel(
|
||||
comparison.timestampRelation
|
||||
),
|
||||
}
|
||||
),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
}
|
||||
if (revision.contentMatchesStorage === true) {
|
||||
this.createEl(revisionEl, "div", { text: $msg("Matches the current Vault file") });
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("✅ Matches Vault"),
|
||||
cls: "sls-repair-metric",
|
||||
});
|
||||
} else if (revision.contentMatchesStorage === false) {
|
||||
this.createEl(revisionEl, "div", { text: $msg("Differs from the current Vault file") });
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("⚠️ Differs from Vault"),
|
||||
cls: "sls-repair-metric mod-warning",
|
||||
});
|
||||
}
|
||||
|
||||
if (!metadata.deleted && !revision.contentReadable && metadata.revision) {
|
||||
const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" });
|
||||
addActionButton(actions, $msg("Retry reading revision"), async () => {
|
||||
const loaded = await retryReadFileDatabaseRevision(this.core, path, metadata.revision!);
|
||||
Logger(
|
||||
loaded
|
||||
? `Revision ${metadata.revision} of ${path} is readable after retry`
|
||||
: `Revision ${metadata.revision} of ${path} remains unreadable`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await refresh();
|
||||
const policy = getFileRepairRevisionActions(inspection, revision);
|
||||
const revisionActions: RepairMenuAction[] = [];
|
||||
if (metadata.revision && policy.compareWithVault) {
|
||||
revisionActions.push({
|
||||
title: $msg("Compare with Vault"),
|
||||
run: async () => {
|
||||
await openRevisionComparison(path, metadata.revision!);
|
||||
},
|
||||
});
|
||||
addActionButton(
|
||||
actions,
|
||||
$msg("Discard unreadable revision"),
|
||||
async () => {
|
||||
}
|
||||
if (metadata.revision && policy.applyRevisionToVault) {
|
||||
revisionActions.push({
|
||||
title: $msg("Apply this revision to Vault"),
|
||||
run: async () => {
|
||||
if (await this.core.storageAccess.isExistsIncludeHidden(path)) {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Apply database revision ${REVISION} to ${FILE}? The current Vault file will be overwritten.",
|
||||
{
|
||||
REVISION: metadata.revision!,
|
||||
FILE: path,
|
||||
}
|
||||
),
|
||||
{
|
||||
title: $msg("Apply database revision to Vault"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await runMutation(
|
||||
`Apply database revision ${metadata.revision} to the Vault`,
|
||||
() =>
|
||||
applyRevisionToStorage(
|
||||
path,
|
||||
metadata.revision!,
|
||||
true
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.markAsVaultRevision) {
|
||||
revisionActions.push({
|
||||
title: $msg("Mark this revision as the Vault version"),
|
||||
run: async () => {
|
||||
await runMutation(
|
||||
`Mark database revision ${metadata.revision} as the Vault version`,
|
||||
() =>
|
||||
storeStorageOnRevision(
|
||||
path,
|
||||
metadata.revision!,
|
||||
false
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.storeVaultOnBranch) {
|
||||
revisionActions.push({
|
||||
title: $msg("Store Vault file as a child of this revision"),
|
||||
run: async () => {
|
||||
await runMutation(
|
||||
`Store the Vault file on database revision ${metadata.revision}`,
|
||||
() =>
|
||||
storeStorageOnRevision(
|
||||
path,
|
||||
metadata.revision!
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.applyLogicalDeletionToVault) {
|
||||
revisionActions.push({
|
||||
title: $msg("Apply logical deletion to Vault"),
|
||||
warning: true,
|
||||
run: async () => {
|
||||
if (await this.core.storageAccess.isExistsIncludeHidden(path)) {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Apply logical deletion ${REVISION} to ${FILE}? The current Vault file will be removed.",
|
||||
{
|
||||
REVISION: metadata.revision!,
|
||||
FILE: path,
|
||||
}
|
||||
),
|
||||
{
|
||||
title: $msg("Apply logical deletion to Vault"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await runMutation(
|
||||
`Apply logical deletion ${metadata.revision} to the Vault`,
|
||||
() =>
|
||||
applyRevisionToStorage(
|
||||
path,
|
||||
metadata.revision!,
|
||||
true
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.retryRevision) {
|
||||
revisionActions.push({
|
||||
title: $msg("Retry reading revision"),
|
||||
run: async () => {
|
||||
const loaded = await retryReadFileDatabaseRevision(
|
||||
this.core,
|
||||
path,
|
||||
metadata.revision!
|
||||
);
|
||||
Logger(
|
||||
loaded
|
||||
? `Revision ${metadata.revision} of ${path} is readable after retry`
|
||||
: `Revision ${metadata.revision} of ${path} remains unreadable`,
|
||||
LOG_LEVEL_NOTICE
|
||||
);
|
||||
await refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
if (metadata.revision && policy.discardBranch) {
|
||||
revisionActions.push(discardLiveBranchAction(metadata.revision));
|
||||
}
|
||||
if (metadata.revision && policy.discardRevision) {
|
||||
revisionActions.push({
|
||||
title: $msg("Discard unreadable revision"),
|
||||
warning: true,
|
||||
run: async () => {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
@@ -293,55 +699,54 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
);
|
||||
await refresh();
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
addActionMenu(
|
||||
revisionMenuHost,
|
||||
$msg("More actions for revision ${REVISION}", {
|
||||
REVISION: metadata.revision ?? $msg("Unknown revision"),
|
||||
}),
|
||||
revisionActions
|
||||
);
|
||||
};
|
||||
revisions.forEach(addRevision);
|
||||
|
||||
for (const revision of information.database.unavailableConflictRevisions) {
|
||||
const revisionEl = this.createEl(card, "div", { cls: "sls-repair-revision" });
|
||||
this.createEl(revisionEl, "div", {
|
||||
const revisionHeader = this.createEl(revisionEl, "div", {
|
||||
cls: "sls-repair-header",
|
||||
});
|
||||
this.createEl(revisionHeader, "div", {
|
||||
text: $msg("${ROLE}: ${REVISION}", {
|
||||
ROLE: $msg("Conflict revision"),
|
||||
REVISION: revision,
|
||||
}),
|
||||
cls: "sls-repair-revision-title",
|
||||
});
|
||||
const revisionMenuHost = this.createEl(revisionHeader, "div");
|
||||
this.createEl(revisionEl, "div", {
|
||||
text: $msg("Revision metadata is unavailable on this device"),
|
||||
cls: "mod-warning",
|
||||
});
|
||||
const actions = this.createEl(revisionEl, "div", { cls: "sls-repair-actions" });
|
||||
addActionButton(actions, $msg("Retry reading revision"), async () => {
|
||||
await retryReadFileDatabaseRevision(this.core, path, revision);
|
||||
await refresh();
|
||||
});
|
||||
addActionButton(
|
||||
actions,
|
||||
$msg("Discard unreadable revision"),
|
||||
async () => {
|
||||
const confirmed =
|
||||
(await this.core.confirm.askYesNoDialog(
|
||||
$msg(
|
||||
"Discard database revision ${REVISION} of ${FILE}? This creates a logical deletion for that exact live revision. Missing content cannot be recovered by this action.",
|
||||
{
|
||||
REVISION: revision,
|
||||
FILE: path,
|
||||
}
|
||||
),
|
||||
{
|
||||
title: $msg("Discard unreadable revision"),
|
||||
defaultOption: "No",
|
||||
}
|
||||
)) === "yes";
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await discardUnreadableLiveRevision(this.core, path, revision);
|
||||
await refresh();
|
||||
},
|
||||
true
|
||||
addActionMenu(
|
||||
revisionMenuHost,
|
||||
$msg("More actions for revision ${REVISION}", {
|
||||
REVISION: revision,
|
||||
}),
|
||||
[
|
||||
{
|
||||
title: $msg("Retry reading revision"),
|
||||
run: async () => {
|
||||
await retryReadFileDatabaseRevision(
|
||||
this.core,
|
||||
path,
|
||||
revision
|
||||
);
|
||||
await refresh();
|
||||
},
|
||||
},
|
||||
discardLiveBranchAction(revision),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,45 +770,41 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
|
||||
}
|
||||
|
||||
const winner = revisions.find(({ role }) => role === "winner");
|
||||
const actions = this.createEl(card, "div", { cls: "sls-repair-actions" });
|
||||
if (winner?.loadedEntry && information.storage.exists) {
|
||||
const fileActions: RepairMenuAction[] = [];
|
||||
if (winner?.loadedEntry) {
|
||||
const winnerEntry = winner.loadedEntry;
|
||||
addActionButton(actions, $msg("Show revision history"), () => {
|
||||
eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, {
|
||||
file: path as FilePathWithPrefix,
|
||||
fileOnDB: winnerEntry,
|
||||
});
|
||||
fileActions.push({
|
||||
title: $msg("Show revision history"),
|
||||
run: () => {
|
||||
eventHub.emitEvent(EVENT_REQUEST_SHOW_HISTORY, {
|
||||
file: path as FilePathWithPrefix,
|
||||
fileOnDB: winnerEntry,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
information.storage.exists &&
|
||||
information.database.conflictCount === 0 &&
|
||||
(!winner || winner.contentReadable)
|
||||
) {
|
||||
addActionButton(actions, $msg("Use Vault file in local database"), async () => {
|
||||
if (!(await storeStorageInDatabase(path))) {
|
||||
Logger(`Failed to store the Vault file in the local database: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
if (information.storage.exists && !information.database.exists) {
|
||||
fileActions.push({
|
||||
title: $msg("Store Vault file as a new local database document"),
|
||||
run: async () => {
|
||||
await runMutation(
|
||||
"Store the Vault file as a new local database document",
|
||||
() => storeStorageInDatabase(path)
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
if (
|
||||
!information.storage.exists &&
|
||||
information.database.conflictCount === 0 &&
|
||||
winner?.loadedEntry
|
||||
) {
|
||||
addActionButton(actions, $msg("Restore database winner to Vault"), async () => {
|
||||
if (!(await applyWinnerToStorage(path, winner))) {
|
||||
Logger(`Failed to restore the database winner to the Vault: ${path}`, LOG_LEVEL_NOTICE);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
addActionButton(actions, $msg("Copy database information"), async () => {
|
||||
await copyFileDatabaseInfo(this.core, path);
|
||||
fileActions.push({
|
||||
title: $msg("Copy database information"),
|
||||
run: async () => {
|
||||
await copyFileDatabaseInfo(this.core, path);
|
||||
},
|
||||
});
|
||||
addActionMenu(
|
||||
fileMenuHost,
|
||||
$msg("More actions for ${FILE}", { FILE: path }),
|
||||
fileActions
|
||||
);
|
||||
};
|
||||
|
||||
new Setting(paneEl)
|
||||
|
||||
@@ -35,6 +35,8 @@ export type DiscardUnreadableRevisionResult =
|
||||
| "no-longer-live"
|
||||
| "revision-is-readable";
|
||||
|
||||
export type DiscardLiveBranchResult = "discarded" | "failed" | "no-longer-live" | "only-live-revision";
|
||||
|
||||
export async function inspectFileRepair(core: FileRepairCore, path: string): Promise<FileRepairInspection> {
|
||||
const information = await inspectFileDatabaseInfo(core, path);
|
||||
const storageContent = information.storage.exists
|
||||
@@ -62,12 +64,12 @@ export async function inspectFileRepair(core: FileRepairCore, path: string): Pro
|
||||
}
|
||||
|
||||
const winner = revisions.find(({ role }) => role === "winner");
|
||||
const winnerRepresentsStoredFile = winner !== undefined && !winner.metadata.deleted;
|
||||
const databaseAndStorageDiffer =
|
||||
information.storage.exists !== information.database.exists ||
|
||||
information.storage.exists !== winnerRepresentsStoredFile ||
|
||||
(information.storage.exists &&
|
||||
winner !== undefined &&
|
||||
(winner.metadata.deleted || winner.contentMatchesStorage === false)) ||
|
||||
(!information.storage.exists && winner !== undefined && !winner.metadata.deleted);
|
||||
winnerRepresentsStoredFile &&
|
||||
winner.contentMatchesStorage === false);
|
||||
const unreadableLiveRevision =
|
||||
information.database.unavailableConflictRevisions.length > 0 ||
|
||||
revisions.some(({ contentReadable }) => !contentReadable);
|
||||
@@ -107,3 +109,24 @@ export async function discardUnreadableLiveRevision(
|
||||
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
|
||||
return deleted ? "discarded" : "failed";
|
||||
}
|
||||
|
||||
export async function discardLiveBranch(
|
||||
core: FileRepairCore,
|
||||
path: string,
|
||||
revision: string
|
||||
): Promise<DiscardLiveBranchResult> {
|
||||
const latest = await inspectFileDatabaseInfo(core, path);
|
||||
const liveRevisions = [
|
||||
latest.database.currentRevision,
|
||||
...latest.database.conflictRevisions,
|
||||
].filter((candidate): candidate is string => candidate !== null);
|
||||
if (!liveRevisions.includes(revision)) {
|
||||
return "no-longer-live";
|
||||
}
|
||||
if (liveRevisions.length < 2) {
|
||||
return "only-live-revision";
|
||||
}
|
||||
|
||||
const deleted = await core.fileHandler.deleteRevisionFromDB(latest.databasePath, revision);
|
||||
return deleted ? "discarded" : "failed";
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
discardLiveBranch,
|
||||
discardUnreadableLiveRevision,
|
||||
inspectFileRepair,
|
||||
} from "./fileRepair";
|
||||
@@ -16,6 +17,7 @@ function createCore() {
|
||||
size: 7,
|
||||
type: "plain",
|
||||
children: ["h:current"],
|
||||
deleted: false,
|
||||
eden: {},
|
||||
};
|
||||
const conflict = {
|
||||
@@ -118,6 +120,29 @@ describe("file repair inspection", () => {
|
||||
expect(inspection.requiresAttention).toBe(true);
|
||||
});
|
||||
|
||||
it("omits a logical deletion which already matches an absent Vault file", async () => {
|
||||
const { core, current } = createCore();
|
||||
current.deleted = true;
|
||||
current._conflicts = [];
|
||||
current.children = [];
|
||||
core.storageAccess.isExistsIncludeHidden.mockResolvedValue(false);
|
||||
core.storageAccess.statHidden.mockResolvedValue(null as never);
|
||||
|
||||
const inspection = await inspectFileRepair(core as never, "note.md");
|
||||
|
||||
expect(inspection.revisions).toEqual([
|
||||
expect.objectContaining({
|
||||
role: "winner",
|
||||
contentReadable: true,
|
||||
metadata: expect.objectContaining({
|
||||
deleted: true,
|
||||
revision: "3-current",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(inspection.requiresAttention).toBe(false);
|
||||
});
|
||||
|
||||
it("rechecks liveness and readability before discarding an exact revision", async () => {
|
||||
const { core, deleteRevisionFromDB } = createCore();
|
||||
|
||||
@@ -169,4 +194,35 @@ describe("file repair inspection", () => {
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards an exact readable winner while another live branch remains", async () => {
|
||||
const { core, deleteRevisionFromDB } = createCore();
|
||||
|
||||
await expect(
|
||||
discardLiveBranch(core as never, "note.md", "3-current")
|
||||
).resolves.toBe("discarded");
|
||||
|
||||
expect(deleteRevisionFromDB).toHaveBeenCalledWith("note.md", "3-current");
|
||||
});
|
||||
|
||||
it("refuses to discard the only live branch", async () => {
|
||||
const { core, current, deleteRevisionFromDB } = createCore();
|
||||
current._conflicts = [];
|
||||
|
||||
await expect(
|
||||
discardLiveBranch(core as never, "note.md", "3-current")
|
||||
).resolves.toBe("only-live-revision");
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses to discard a branch which is no longer live", async () => {
|
||||
const { core, deleteRevisionFromDB } = createCore();
|
||||
|
||||
await expect(
|
||||
discardLiveBranch(core as never, "note.md", "1-stale")
|
||||
).resolves.toBe("no-longer-live");
|
||||
|
||||
expect(deleteRevisionFromDB).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
BASE_IS_NEW,
|
||||
EVEN,
|
||||
TARGET_IS_NEW,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/models/shared.const.symbols";
|
||||
import {
|
||||
compareMTime,
|
||||
readAsBlob,
|
||||
} from "@vrtmrz/livesync-commonlib/compat/common/utils";
|
||||
import { isPlainText } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
|
||||
import type {
|
||||
FileRepairInspection,
|
||||
FileRepairRevision,
|
||||
} from "./fileRepair";
|
||||
|
||||
export type FileRepairRevisionActions = {
|
||||
compareWithVault: boolean;
|
||||
applyRevisionToVault: boolean;
|
||||
markAsVaultRevision: boolean;
|
||||
storeVaultOnBranch: boolean;
|
||||
applyLogicalDeletionToVault: boolean;
|
||||
retryRevision: boolean;
|
||||
discardBranch: boolean;
|
||||
discardRevision: boolean;
|
||||
};
|
||||
|
||||
export type FileRepairTimestampRelation =
|
||||
| "vault-newer"
|
||||
| "database-newer"
|
||||
| "same-window"
|
||||
| "unavailable";
|
||||
|
||||
export type FileRepairRevisionComparison = {
|
||||
recordedSize: number;
|
||||
decodedSize: number | null;
|
||||
recordedToDecodedSizeDifference: number | null;
|
||||
vaultSize: number | null;
|
||||
databaseToVaultSizeDifference: number | null;
|
||||
databaseMtime: number;
|
||||
vaultMtime: number | null;
|
||||
timestampDifferenceMs: number | null;
|
||||
timestampRelation: FileRepairTimestampRelation;
|
||||
};
|
||||
|
||||
export function getFileRepairRevisionActions(
|
||||
inspection: FileRepairInspection,
|
||||
revision: FileRepairRevision
|
||||
): FileRepairRevisionActions {
|
||||
const storageExists = inspection.information.storage.exists;
|
||||
const hasRevision = revision.metadata.revision !== null;
|
||||
const readableFileRevision =
|
||||
!revision.metadata.deleted &&
|
||||
revision.contentReadable &&
|
||||
revision.loadedEntry !== false;
|
||||
const matchesVault = storageExists && revision.contentMatchesStorage === true;
|
||||
const hasConflictBranches = inspection.information.database.conflictCount > 0;
|
||||
|
||||
return {
|
||||
compareWithVault:
|
||||
readableFileRevision &&
|
||||
storageExists &&
|
||||
revision.contentMatchesStorage === false &&
|
||||
isPlainText(inspection.information.path),
|
||||
applyRevisionToVault:
|
||||
hasRevision &&
|
||||
readableFileRevision &&
|
||||
(!storageExists || revision.contentMatchesStorage !== true),
|
||||
markAsVaultRevision:
|
||||
hasRevision &&
|
||||
readableFileRevision &&
|
||||
matchesVault,
|
||||
storeVaultOnBranch:
|
||||
hasRevision &&
|
||||
storageExists &&
|
||||
revision.contentMatchesStorage !== true,
|
||||
applyLogicalDeletionToVault:
|
||||
hasRevision &&
|
||||
revision.metadata.deleted &&
|
||||
storageExists,
|
||||
retryRevision:
|
||||
hasRevision &&
|
||||
!revision.metadata.deleted &&
|
||||
!revision.contentReadable,
|
||||
discardBranch: hasRevision && hasConflictBranches,
|
||||
discardRevision:
|
||||
hasRevision &&
|
||||
!hasConflictBranches &&
|
||||
!revision.metadata.deleted &&
|
||||
!revision.contentReadable,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFileRepairRevisionComparison(
|
||||
inspection: FileRepairInspection,
|
||||
revision: FileRepairRevision
|
||||
): FileRepairRevisionComparison {
|
||||
const decodedSize =
|
||||
revision.loadedEntry === false
|
||||
? null
|
||||
: readAsBlob(revision.loadedEntry).size;
|
||||
const vaultSize =
|
||||
inspection.information.storage.exists
|
||||
? (inspection.information.storage.size ?? null)
|
||||
: null;
|
||||
const databaseMtime = revision.metadata.mtime;
|
||||
const vaultMtime =
|
||||
inspection.information.storage.exists
|
||||
? (inspection.information.storage.mtime ?? null)
|
||||
: null;
|
||||
const timestampDifferenceMs =
|
||||
databaseMtime > 0 && vaultMtime !== null && vaultMtime > 0
|
||||
? vaultMtime - databaseMtime
|
||||
: null;
|
||||
let timestampRelation: FileRepairTimestampRelation = "unavailable";
|
||||
if (timestampDifferenceMs !== null) {
|
||||
const comparison = compareMTime(vaultMtime!, databaseMtime);
|
||||
timestampRelation =
|
||||
comparison === EVEN
|
||||
? "same-window"
|
||||
: comparison === BASE_IS_NEW
|
||||
? "vault-newer"
|
||||
: comparison === TARGET_IS_NEW
|
||||
? "database-newer"
|
||||
: "unavailable";
|
||||
}
|
||||
|
||||
return {
|
||||
recordedSize: revision.metadata.recordedSize,
|
||||
decodedSize,
|
||||
recordedToDecodedSizeDifference:
|
||||
decodedSize === null
|
||||
? null
|
||||
: decodedSize - revision.metadata.recordedSize,
|
||||
vaultSize,
|
||||
databaseToVaultSizeDifference:
|
||||
decodedSize === null || vaultSize === null
|
||||
? null
|
||||
: vaultSize - decodedSize,
|
||||
databaseMtime,
|
||||
vaultMtime,
|
||||
timestampDifferenceMs,
|
||||
timestampRelation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
|
||||
import type { FileRepairInspection, FileRepairRevision } from "./fileRepair";
|
||||
import {
|
||||
getFileRepairRevisionActions,
|
||||
getFileRepairRevisionComparison,
|
||||
} from "./fileRepairPresentation";
|
||||
|
||||
function createInspection(
|
||||
revision: Partial<FileRepairRevision> = {},
|
||||
storage: { exists: boolean; size?: number; mtime?: number } = {
|
||||
exists: true,
|
||||
size: 12,
|
||||
mtime: 5_500,
|
||||
}
|
||||
): { inspection: FileRepairInspection; revision: FileRepairRevision } {
|
||||
const completeRevision = {
|
||||
role: "conflict",
|
||||
metadata: {
|
||||
documentId: "f:note",
|
||||
revision: "2-conflict",
|
||||
current: false,
|
||||
deleted: false,
|
||||
storageType: "plain",
|
||||
storageLayout: "chunked",
|
||||
ctime: 1,
|
||||
mtime: 2_000,
|
||||
recordedSize: 9,
|
||||
revisionHistory: [],
|
||||
chunkReferences: 0,
|
||||
uniqueChunkReferences: 0,
|
||||
embeddedChunkReferences: 0,
|
||||
locallyStoredChunkReferences: 0,
|
||||
contentAvailableLocally: true,
|
||||
chunks: [],
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: false,
|
||||
loadedEntry: {
|
||||
_id: "f:note",
|
||||
_rev: "2-conflict",
|
||||
path: "note.md",
|
||||
ctime: 1,
|
||||
mtime: 2_000,
|
||||
size: 9,
|
||||
type: "plain",
|
||||
datatype: "plain",
|
||||
children: [],
|
||||
eden: {},
|
||||
data: "content",
|
||||
},
|
||||
...revision,
|
||||
} as FileRepairRevision;
|
||||
const inspection = {
|
||||
information: {
|
||||
path: "note.md",
|
||||
databasePath: "note.md" as FilePathWithPrefix,
|
||||
storage,
|
||||
database: {
|
||||
source: "local database on this device",
|
||||
remoteQueried: false,
|
||||
exists: true,
|
||||
currentRevision: "3-winner",
|
||||
conflictCount: 1,
|
||||
conflictRevisions: ["2-conflict"],
|
||||
unavailableConflictRevisions: [],
|
||||
revisions: [],
|
||||
mergeBases: [],
|
||||
},
|
||||
},
|
||||
revisions: [completeRevision],
|
||||
requiresAttention: true,
|
||||
} satisfies FileRepairInspection;
|
||||
return { inspection, revision: completeRevision };
|
||||
}
|
||||
|
||||
describe("file repair presentation", () => {
|
||||
it("offers both reconciliation directions for a readable differing revision", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toEqual({
|
||||
compareWithVault: true,
|
||||
applyRevisionToVault: true,
|
||||
markAsVaultRevision: false,
|
||||
storeVaultOnBranch: true,
|
||||
applyLogicalDeletionToVault: false,
|
||||
retryRevision: false,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks an exact matching revision without creating another child", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
contentMatchesStorage: true,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: false,
|
||||
markAsVaultRevision: true,
|
||||
storeVaultOnBranch: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a text comparison for a binary file", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
inspection.information.path = "image.png";
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: true,
|
||||
storeVaultOnBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers explicit deletion or branch extension for a logical deletion", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
deleted: true,
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
applyRevisionToVault: false,
|
||||
storeVaultOnBranch: true,
|
||||
applyLogicalDeletionToVault: true,
|
||||
retryRevision: false,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("offers retry, discard, and branch extension for an unreadable live revision", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
contentReadable: false,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
compareWithVault: false,
|
||||
applyRevisionToVault: false,
|
||||
markAsVaultRevision: false,
|
||||
storeVaultOnBranch: true,
|
||||
retryRevision: true,
|
||||
discardRevision: false,
|
||||
discardBranch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the existing unreadable-leaf escape hatch when there is no conflict branch", () => {
|
||||
const { inspection, revision } = createInspection({
|
||||
role: "winner",
|
||||
contentReadable: false,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
});
|
||||
inspection.information.database.conflictCount = 0;
|
||||
inspection.information.database.conflictRevisions = [];
|
||||
inspection.information.database.currentRevision = revision.metadata.revision;
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
discardRevision: true,
|
||||
discardBranch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not offer a storage action for a matching absent logical deletion", () => {
|
||||
const { inspection, revision } = createInspection(
|
||||
{
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
deleted: true,
|
||||
},
|
||||
contentReadable: true,
|
||||
contentMatchesStorage: null,
|
||||
loadedEntry: false,
|
||||
},
|
||||
{ exists: false }
|
||||
);
|
||||
|
||||
expect(getFileRepairRevisionActions(inspection, revision)).toMatchObject({
|
||||
applyLogicalDeletionToVault: false,
|
||||
storeVaultOnBranch: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports recorded, decoded, Vault-size, and timestamp differences", () => {
|
||||
const { inspection, revision } = createInspection();
|
||||
|
||||
expect(getFileRepairRevisionComparison(inspection, revision)).toEqual({
|
||||
recordedSize: 9,
|
||||
decodedSize: 7,
|
||||
recordedToDecodedSizeDifference: -2,
|
||||
vaultSize: 12,
|
||||
databaseToVaultSizeDifference: 5,
|
||||
databaseMtime: 2_000,
|
||||
vaultMtime: 5_500,
|
||||
timestampDifferenceMs: 3_500,
|
||||
timestampRelation: "vault-newer",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the same two-second timestamp comparison window as synchronisation", () => {
|
||||
const { inspection, revision } = createInspection(
|
||||
{
|
||||
metadata: {
|
||||
...createInspection().revision.metadata,
|
||||
mtime: 3_001,
|
||||
},
|
||||
},
|
||||
{
|
||||
exists: true,
|
||||
size: 12,
|
||||
mtime: 3_999,
|
||||
}
|
||||
);
|
||||
|
||||
expect(getFileRepairRevisionComparison(inspection, revision)).toMatchObject({
|
||||
timestampDifferenceMs: 998,
|
||||
timestampRelation: "same-window",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user