mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-07-27 06:52:59 +00:00
Improve recovery diagnostics and actions
This commit is contained in:
@@ -50,11 +50,24 @@ The compatibility implementation currently selects the newer modification time f
|
||||
|
||||
A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.
|
||||
|
||||
**Hatch** → **Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. It reports exact revision identifiers and local chunk availability separately:
|
||||
**Hatch** → **Verify and repair all files** inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another live branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status `✅ Vault matches winner · ⚠️ Conflicts: N`; matching the winner does not mean that the conflict has been resolved.
|
||||
|
||||
Each reported live revision has a compact **…** menu. The available actions depend on the exact revision and current Vault state:
|
||||
|
||||
- **Compare with Vault** opens the existing difference dialogue in read-only mode for differing text files.
|
||||
- **Apply this revision to Vault** writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation.
|
||||
- **Mark this revision as the Vault version** is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection.
|
||||
- **Store Vault file as a child of this revision** preserves the current Vault bytes on the explicitly selected live branch.
|
||||
- **Apply logical deletion to Vault** removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance.
|
||||
- **Retry reading revision** attempts the configured chunk-retrieval path again. It does not change the revision tree.
|
||||
- **Discard unreadable revision** is available only for a current winner or conflict revision which remains unreadable when the action is performed. It requires confirmation and creates a logical deletion for that exact revision.
|
||||
- A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
|
||||
- **Discard this branch** is available for each exact live revision while at least one other live branch remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
|
||||
- **Discard unreadable revision** remains available as a recovery action when an unreadable revision is the only live leaf. It requires confirmation because no other database branch remains.
|
||||
|
||||
Every mutating action rechecks that the selected revision is still a current live leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.
|
||||
|
||||
The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. `🧩 Missing chunks: N` identifies an unreadable revision. In the database row, `Δsize` is decoded size minus recorded size; in the Vault row, `Δsize vs DB` is Vault size minus decoded database size. `Δtime` is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win.
|
||||
|
||||
A shared ancestor is informational. An ancestor which is no longer a live revision cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable live revisions can still be selected manually.
|
||||
|
||||
Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.
|
||||
|
||||
|
||||
@@ -57,10 +57,12 @@ If the log reports missing chunks or a size mismatch:
|
||||
2. restart Obsidian once to rule out an interrupted fetch;
|
||||
3. synchronise a device or restore a backup which still has the correct content;
|
||||
4. on that healthy device, run `Recreate chunks for current Vault files`, then synchronise;
|
||||
5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; and
|
||||
6. use `Discard unreadable revision` only after confirming that the exact revision is no longer recoverable or wanted.
|
||||
5. run `Verify and repair all files` from `Hatch`; review the winner, every conflict revision, and any unavailable shared ancestor separately; use each revision's **…** menu to compare readable text, apply that exact revision to the Vault, store the Vault file as its child, or record an exact byte-for-byte match; and
|
||||
6. use `Discard this branch` only after confirming that the exact live branch is no longer wanted. Use the separate `Discard unreadable revision` recovery action only when an unreadable revision is the sole live leaf.
|
||||
|
||||
`Retry reading revision` does not change the revision tree. `Discard unreadable revision` creates a logical deletion for one current winner or conflict revision after rechecking it. It does not purge history or reconstruct missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
|
||||
The repair card uses compact diagnostic rows which remain readable in a narrow mobile settings pane. `🧩 Missing chunks: N` marks an unreadable revision. In the database row, `Δsize` means decoded size minus recorded size; `Δsize vs DB` means Vault size minus decoded database size; and `Δtime` means Vault modification time minus database modification time. These are diagnostic values, not a rule for deciding which revision is correct. `✅ Vault matches winner · ⚠️ Conflicts: N` means that the current Vault bytes agree with the database winner while other live branches still need a decision. Every mutating action rechecks that its selected revision is still live. Applying a logical deletion to an existing Vault file requires confirmation; a logical-deletion winner with no Vault file already agrees and is omitted.
|
||||
|
||||
`Retry reading revision` does not change the revision tree. `Discard this branch` creates a logical deletion on one exact live revision while another live branch remains and leaves the current Vault file unchanged. If the discarded revision was recorded as the Vault's exact source, that stale device-local provenance is removed. `Discard unreadable revision` provides the corresponding explicit escape hatch for a sole unreadable live leaf. Neither action purges history or reconstructs missing content. An unavailable non-live ancestor cannot be deleted through this workflow; it disables conservative three-way merge but does not prevent explicit selection between readable live revisions.
|
||||
|
||||
`Recreate chunks for current Vault files` uses current Vault content. It cannot recreate unique bytes which exist only in an unreadable historical or conflict revision.
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
+48
-7
@@ -612,6 +612,54 @@ body.is-mobile .livesync-compatibility-review-notice {
|
||||
background: var(--background-secondary);
|
||||
}
|
||||
|
||||
.sls-repair-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--size-4-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sls-repair-header > :first-child {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sls-repair-header h6 {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sls-repair-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-4-2);
|
||||
margin-top: var(--size-4-1);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
.sls-repair-status-ok {
|
||||
color: var(--text-success);
|
||||
}
|
||||
|
||||
.sls-repair-status-warning {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.sls-repair-metric {
|
||||
margin-top: var(--size-4-1);
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: var(--line-height-tight);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sls-repair-action-menu {
|
||||
min-width: var(--clickable-icon-size);
|
||||
width: var(--clickable-icon-size);
|
||||
height: var(--clickable-icon-size);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sls-repair-revision {
|
||||
margin-top: var(--size-4-2);
|
||||
padding: var(--size-4-2);
|
||||
@@ -636,13 +684,6 @@ body.is-mobile .livesync-compatibility-review-notice {
|
||||
color: var(--text-warning);
|
||||
}
|
||||
|
||||
.sls-repair-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-4-2);
|
||||
margin-top: var(--size-4-2);
|
||||
}
|
||||
|
||||
/* Diff navigation */
|
||||
.diff-options-row {
|
||||
display: flex;
|
||||
|
||||
@@ -161,7 +161,7 @@ This proves in real Obsidian the plug-in behaviour shared by supported platforms
|
||||
|
||||
`test:e2e:obsidian:conflict-dialog-policy` creates three real local revision leaves without a remote service and opens the pairwise merge dialogue in Obsidian. It verifies the three-version count, requires the four decision buttons to be stacked vertically, concatenates the displayed pair as a child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the same isolated Vault and profile, and confirms that only the two live versions are reconstructed. It also verifies that an ordinary repeated conflict check does not reopen a postponed dialogue, that **Resolve if conflicted.** explicitly reopens it, and that the active editor retains the appropriate unresolved-conflict warning. The scenario then invokes the same Commonlib consumer boundary used for an incoming replicated document and checks that a postponed warning disappears, an open stale dialogue closes, and the conflict-processing queue completes even when the dialogue closes immediately. This isolates the Obsidian UI contract from transport and second-device setup. The fixture owns one temporary Vault and profile, and the session runner stops Obsidian before removing them.
|
||||
|
||||
`test:e2e:obsidian:revision-repair` creates two live revisions in a temporary real Obsidian Vault, removes a chunk used only by the non-winning revision, and proves that automatic conflict checking does not discard the unreadable branch. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, and leave the revision tree unchanged when reading is retried. The scenario then verifies both the cancellation path and the explicit confirmation path for discarding that exact unreadable live revision, requires the winner to remain unchanged, and captures the repair card. It uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
|
||||
`test:e2e:obsidian:revision-repair` creates an ordinary healthy logical deletion and two conflicting live revisions in a temporary real Obsidian Vault, then removes a chunk used only by the non-winning revision. It proves that automatic conflict checking does not discard the unreadable branch, and that a healthy logical deletion with no Vault file is neither reported nor retained as Vault provenance. **Verify and repair all files** must show the winner and conflict separately, identify the exact unreadable revision and missing chunk, show the compact `Δsize` and `Δtime` diagnostics, and expose the appropriate `…` menu for each branch. The scenario opens the existing comparison dialogue in read-only mode, applies the readable winner to the Vault, shows the compact matching-winner and remaining-conflict status, records the exact winner as Vault provenance without creating a child, and confirms that retrying the unreadable branch leaves the revision tree unchanged. It then verifies both the cancellation path and the explicit confirmation path for discarding only that selected live branch, requires the winner and its Vault provenance to remain unchanged, and captures the repair card, a 360-pixel-wide reflow check, the matching-winner status, both revision menus, and the read-only comparison. The narrow capture checks responsive layout, not a mobile operating-system lifecycle. The scenario uses no remote service; a retry is therefore expected to remain unreadable unless the chunk is already available locally.
|
||||
|
||||
`test:e2e:obsidian:hidden-file-snippet-sync` runs a two-vault hidden file round-trip. It verifies creation and deletion of a real `.obsidian/snippets/*.css` file, automatic JSON conflict merging for a hidden file with the merged result propagated by a second synchronisation, manual JSON Resolve dialogue application through Obsidian's UI, and per-device target patterns where one vault ignores a hidden file that the other vault synchronises. Initial enablement must open one user-visible progress Notice before the enabled setting is saved, then retain that Notice while its nested rebuild and scan phases continue in the ordinary log. The configured fixture starts with a current CouchDB remote profile, so migration from legacy remote settings remains the responsibility of the upgrade scenarios and cannot add unrelated Notices to this check. It also covers [issue #555](https://github.com/vrtmrz/obsidian-livesync/issues/555) by requiring several plug-in and settings changes to share one separate action Notice whose controls remain usable in mobile layouts; a manually dismissed group must not repeat its acknowledged rows when a later change arrives.
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
|
||||
import { captureObsidianElement, withObsidianPage } from "../runner/ui.ts";
|
||||
import { createTemporaryVault } from "../runner/vault.ts";
|
||||
import type { Locator, Page } from "playwright";
|
||||
|
||||
const path = "revision-repair.md";
|
||||
const healthyDeletedPath = "healthy-logical-deletion.md";
|
||||
const baseContent = "Revision repair\n\nShared base.\n";
|
||||
const branchContents = [
|
||||
`Revision repair\n\nLeft branch.\n${"L".repeat(4096)}\n`,
|
||||
@@ -28,6 +30,11 @@ type RevisionTree = {
|
||||
conflictRevisions: string[];
|
||||
};
|
||||
|
||||
type VaultWinnerState = {
|
||||
matches: boolean;
|
||||
winnerRevision: string;
|
||||
};
|
||||
|
||||
type ObsidianSettingsController = {
|
||||
open(): void;
|
||||
openTabById(tabId: string): void;
|
||||
@@ -56,6 +63,49 @@ async function createAndOpenBaseFile(cliBinary: string, env: NodeJS.ProcessEnv):
|
||||
);
|
||||
}
|
||||
|
||||
async function createHealthyLogicalDeletion(cliBinary: string, env: NodeJS.ProcessEnv): Promise<string> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(healthyDeletedPath)};`,
|
||||
`const content=${JSON.stringify(`Healthy logical deletion\n\n${"D".repeat(4096)}\n`)};`,
|
||||
"let file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) file=await app.vault.create(path,content);",
|
||||
"return JSON.stringify({ok:true});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
await waitForLocalDatabaseEntry(cliBinary, env, healthyDeletedPath);
|
||||
return await evalObsidianJson<string>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(healthyDeletedPath)};`,
|
||||
`const timeoutMs=${JSON.stringify(uiTimeoutMs)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) throw new Error(`Logical-deletion fixture is missing from the Vault: ${path}`);",
|
||||
"await app.vault.delete(file);",
|
||||
"const id=await core.services.path.path2id(path);",
|
||||
"const deadline=Date.now()+timeoutMs;",
|
||||
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
|
||||
"while(Date.now()<deadline){",
|
||||
" await core.services.fileProcessing.commitPendingFileEvents();",
|
||||
" const doc=await core.localDatabase.localDatabase.get(id,{conflicts:true}).catch(()=>false);",
|
||||
" if(!app.vault.getAbstractFileByPath(path)&&doc?.deleted&&(doc._conflicts??[]).length===0){",
|
||||
" return JSON.stringify(doc._rev);",
|
||||
" }",
|
||||
" await sleep(250);",
|
||||
"}",
|
||||
"throw new Error(`Timed out waiting for a healthy logical deletion: ${path}`);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function createBrokenConflict(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
@@ -128,6 +178,93 @@ async function readRevisionTree(cliBinary: string, env: NodeJS.ProcessEnv): Prom
|
||||
);
|
||||
}
|
||||
|
||||
async function readVaultWinnerState(cliBinary: string, env: NodeJS.ProcessEnv): Promise<VaultWinnerState> {
|
||||
return await evalObsidianJson<VaultWinnerState>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(path)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const file=app.vault.getAbstractFileByPath(path);",
|
||||
"if(!file) throw new Error(`Vault file is missing: ${path}`);",
|
||||
"const entry=await core.localDatabase.getDBEntry(path,undefined,false,true,true);",
|
||||
"if(!entry||!entry._rev) throw new Error(`Database winner is missing: ${path}`);",
|
||||
"const vaultContent=await app.vault.read(file);",
|
||||
"const data=Array.isArray(entry.data)?entry.data:[entry.data];",
|
||||
"const databaseContent=await new Blob(data).text();",
|
||||
"return JSON.stringify({",
|
||||
" matches:vaultContent===databaseContent,",
|
||||
" winnerRevision:entry._rev,",
|
||||
"});",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
async function readFileReflectionProvenance(
|
||||
cliBinary: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
targetPath = path
|
||||
): Promise<{ revision: string; observedStorageMtime?: number } | null> {
|
||||
return await evalObsidianJson<{ revision: string; observedStorageMtime?: number } | null>(
|
||||
cliBinary,
|
||||
[
|
||||
"(async()=>{",
|
||||
`const path=${JSON.stringify(targetPath)};`,
|
||||
"const core=app.plugins.plugins['obsidian-livesync'].core;",
|
||||
"const store=core.services.keyValueDB.openSimpleStore('file-reflection-provenance-v1');",
|
||||
"return JSON.stringify((await store.get(path))??null);",
|
||||
"})()",
|
||||
].join(""),
|
||||
env
|
||||
);
|
||||
}
|
||||
|
||||
function repairCard(settings: Locator): Locator {
|
||||
return settings.locator(".sls-repair-result").filter({ hasText: path });
|
||||
}
|
||||
|
||||
function revisionCard(settings: Locator, revision: string): Locator {
|
||||
return repairCard(settings).locator(".sls-repair-revision").filter({ hasText: revision });
|
||||
}
|
||||
|
||||
async function openRevisionActionMenu(page: Page, settings: Locator, revision: string): Promise<Locator> {
|
||||
await revisionCard(settings, revision)
|
||||
.getByRole("button", {
|
||||
name: `More actions for revision ${revision}`,
|
||||
exact: true,
|
||||
})
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
const menu = page.locator(".menu:visible").last();
|
||||
await menu.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const box = await menu.boundingBox();
|
||||
const viewport = await page.evaluate(() => ({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
}));
|
||||
if (
|
||||
box === null ||
|
||||
box.y < 0 ||
|
||||
box.y + box.height > viewport.height - 4
|
||||
) {
|
||||
throw new Error(
|
||||
`Revision action menu is outside the viewport: ${JSON.stringify({
|
||||
box,
|
||||
viewport,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
return menu;
|
||||
}
|
||||
|
||||
async function selectRevisionAction(page: Page, settings: Locator, revision: string, action: string): Promise<void> {
|
||||
const menu = await openRevisionActionMenu(page, settings, revision);
|
||||
const item = menu.getByText(action, { exact: true });
|
||||
await item.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await item.click({ timeout: uiTimeoutMs });
|
||||
}
|
||||
|
||||
async function requestConflictCheck(cliBinary: string, env: NodeJS.ProcessEnv): Promise<void> {
|
||||
await evalObsidianJson<unknown>(
|
||||
cliBinary,
|
||||
@@ -187,7 +324,22 @@ async function main(): Promise<void> {
|
||||
await waitForLiveSyncCoreReady(cliBinary, session.cliEnv);
|
||||
await createAndOpenBaseFile(cliBinary, session.cliEnv);
|
||||
const base = await waitForLocalDatabaseEntry(cliBinary, session.cliEnv, path);
|
||||
const healthyDeletionRevision = await createHealthyLogicalDeletion(cliBinary, session.cliEnv);
|
||||
const fixture = await createBrokenConflict(cliBinary, session.cliEnv, base.rev);
|
||||
const healthyDeletionProvenance = await readFileReflectionProvenance(
|
||||
cliBinary,
|
||||
session.cliEnv,
|
||||
healthyDeletedPath
|
||||
);
|
||||
if (healthyDeletionProvenance !== null) {
|
||||
throw new Error(
|
||||
`A healthy logical deletion retained Vault provenance indefinitely: ${JSON.stringify({
|
||||
healthyDeletedPath,
|
||||
healthyDeletionRevision,
|
||||
healthyDeletionProvenance,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
await requestConflictCheck(cliBinary, session.cliEnv);
|
||||
const afterAutomaticCheck = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
@@ -219,13 +371,17 @@ async function main(): Promise<void> {
|
||||
await verifySetting.getByRole("button", { name: "Verify all", exact: true }).click({
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const card = settings.locator(".sls-repair-result").filter({ hasText: path });
|
||||
const card = repairCard(settings);
|
||||
await card.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const brokenRevision = card
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision });
|
||||
if ((await settings.locator(".sls-repair-result").filter({ hasText: healthyDeletedPath }).count()) !== 0) {
|
||||
throw new Error(
|
||||
`Verify and Repair reported the healthy logical deletion ${healthyDeletedPath} (${healthyDeletionRevision}).`
|
||||
);
|
||||
}
|
||||
const winnerRevision = revisionCard(settings, fixture.winnerRevision);
|
||||
const brokenRevision = revisionCard(settings, fixture.conflictRevision);
|
||||
await brokenRevision
|
||||
.getByText(/Unreadable on this device/u)
|
||||
.getByText(/🧩 Missing chunks: 1/u)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await brokenRevision.getByText(fixture.missingChunkId, { exact: false }).waitFor({
|
||||
state: "visible",
|
||||
@@ -234,43 +390,276 @@ async function main(): Promise<void> {
|
||||
if ((await card.locator(".sls-repair-revision").count()) !== 2) {
|
||||
throw new Error("Verify and Repair did not render the winner and conflict revision separately.");
|
||||
}
|
||||
|
||||
await brokenRevision.getByRole("button", { name: "Retry reading revision", exact: true }).click({
|
||||
for (const label of [
|
||||
/📦 DB: recorded/u,
|
||||
/📁 Vault:/u,
|
||||
/Δsize vs DB/u,
|
||||
/🕒 DB /u,
|
||||
/Δtime /u,
|
||||
/⚠️ Differs from Vault/u,
|
||||
]) {
|
||||
await winnerRevision.getByText(label).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
await brokenRevision.getByText(/decoded unavailable/u).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await settings
|
||||
.locator(".sls-repair-result")
|
||||
.filter({ hasText: path })
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision })
|
||||
.getByText(/Unreadable on this device/u)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const winnerMenu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
|
||||
for (const label of [
|
||||
"Compare with Vault",
|
||||
"Apply this revision to Vault",
|
||||
"Store Vault file as a child of this revision",
|
||||
"Discard this branch",
|
||||
]) {
|
||||
await winnerMenu.getByText(label, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
if (
|
||||
(await winnerMenu
|
||||
.getByText("Mark this revision as the Vault version", {
|
||||
exact: true,
|
||||
})
|
||||
.count()) !== 0
|
||||
) {
|
||||
throw new Error("A differing revision incorrectly offered to record an exact Vault match.");
|
||||
}
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
const afterRetry = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (!afterRetry.conflictRevisions.includes(fixture.conflictRevision)) {
|
||||
throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`);
|
||||
}
|
||||
|
||||
const screenshot = await captureObsidianElement(
|
||||
const repairCardScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-unreadable-conflict.png",
|
||||
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const card = page.locator(".sls-repair-result").filter({ hasText: path });
|
||||
await card.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
htmlElement.dataset.e2eOriginalStyle = htmlElement.getAttribute("style") ?? "";
|
||||
htmlElement.style.width = "360px";
|
||||
htmlElement.style.maxWidth = "100%";
|
||||
});
|
||||
const dimensions = await card.evaluate((element) => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
}));
|
||||
if (dimensions.scrollWidth > dimensions.clientWidth + 1) {
|
||||
throw new Error(
|
||||
`Revision repair card overflowed at mobile width: ${JSON.stringify(dimensions)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
const mobileWidthScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-mobile-width.png",
|
||||
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const card = page.locator(".sls-repair-result").filter({ hasText: path });
|
||||
await card.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
const originalStyle = htmlElement.dataset.e2eOriginalStyle ?? "";
|
||||
if (originalStyle.length > 0) {
|
||||
htmlElement.setAttribute("style", originalStyle);
|
||||
} else {
|
||||
htmlElement.removeAttribute("style");
|
||||
}
|
||||
delete htmlElement.dataset.e2eOriginalStyle;
|
||||
});
|
||||
});
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
const brokenRevision = () =>
|
||||
settings
|
||||
.locator(".sls-repair-result")
|
||||
.filter({ hasText: path })
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision });
|
||||
await brokenRevision()
|
||||
.getByRole("button", { name: "Discard unreadable revision", exact: true })
|
||||
await openRevisionActionMenu(page, settings, fixture.winnerRevision);
|
||||
});
|
||||
const readableMenuScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-readable-actions.png",
|
||||
(page) => page.locator(".menu:visible").last()
|
||||
);
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.winnerRevision, "Compare with Vault");
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Vault and database revision",
|
||||
}),
|
||||
});
|
||||
await modal.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await modal.getByText(path, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await modal.getByText(/Vault file:/u).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
await modal.getByText(/Database revision:/u).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
const actions = modal.locator(".conflict-action-container");
|
||||
await actions.getByRole("button", { name: "Close", exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
for (const action of ["Use Vault file", "Use Database revision", "Concat both", "Not now"]) {
|
||||
if ((await actions.getByRole("button", { name: action, exact: true }).count()) !== 0) {
|
||||
throw new Error(`Read-only comparison exposed the resolution action '${action}'.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
const comparisonScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-read-only-comparison.png",
|
||||
(page) =>
|
||||
page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Vault and database revision",
|
||||
}),
|
||||
})
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const modal = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Vault and database revision",
|
||||
}),
|
||||
});
|
||||
await modal
|
||||
.locator(".conflict-action-container")
|
||||
.getByRole("button", { name: "Close", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await modal.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const beforeApply = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.winnerRevision, "Apply this revision to Vault");
|
||||
const confirmation = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
|
||||
has: page.locator(".modal-title").filter({
|
||||
hasText: "Apply database revision to Vault",
|
||||
}),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await revisionCard(settings, fixture.winnerRevision)
|
||||
.getByText("✅ Matches Vault", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
const status = repairCard(settings).locator(".sls-repair-status");
|
||||
await status
|
||||
.getByText("✅ Vault matches winner", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await status
|
||||
.getByText("⚠️ Conflicts: 1", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
const matchedWinnerWithConflictScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-winner-match-with-conflict.png",
|
||||
(page) => page.locator(".sls-repair-result").filter({ hasText: path })
|
||||
);
|
||||
const afterApply = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (JSON.stringify(afterApply) !== JSON.stringify(beforeApply)) {
|
||||
throw new Error(
|
||||
`Applying a live revision to the Vault changed the revision tree: ${JSON.stringify({
|
||||
beforeApply,
|
||||
afterApply,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
const vaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
|
||||
const appliedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
|
||||
if (
|
||||
!vaultWinner.matches ||
|
||||
vaultWinner.winnerRevision !== fixture.winnerRevision ||
|
||||
appliedProvenance?.revision !== fixture.winnerRevision
|
||||
) {
|
||||
throw new Error(
|
||||
`Applying the winner did not preserve exact Vault provenance: ${JSON.stringify({
|
||||
vaultWinner,
|
||||
appliedProvenance,
|
||||
fixture,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
const menu = await openRevisionActionMenu(page, settings, fixture.winnerRevision);
|
||||
await menu
|
||||
.getByText("Mark this revision as the Vault version", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await menu
|
||||
.getByText("Discard this branch", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await menu
|
||||
.getByText("Mark this revision as the Vault version", { exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await revisionCard(settings, fixture.winnerRevision)
|
||||
.getByText("✅ Matches Vault", { exact: true })
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
const afterExactMark = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
const markedProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
|
||||
if (
|
||||
JSON.stringify(afterExactMark) !== JSON.stringify(beforeApply) ||
|
||||
markedProvenance?.revision !== fixture.winnerRevision
|
||||
) {
|
||||
throw new Error(
|
||||
`Recording an exact Vault match changed the tree or lost provenance: ${JSON.stringify({
|
||||
beforeApply,
|
||||
afterExactMark,
|
||||
markedProvenance,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
const menu = await openRevisionActionMenu(page, settings, fixture.conflictRevision);
|
||||
for (const label of [
|
||||
"Store Vault file as a child of this revision",
|
||||
"Retry reading revision",
|
||||
"Discard this branch",
|
||||
]) {
|
||||
await menu.getByText(label, { exact: true }).waitFor({
|
||||
state: "visible",
|
||||
timeout: uiTimeoutMs,
|
||||
});
|
||||
}
|
||||
});
|
||||
const unreadableMenuScreenshot = await captureObsidianElement(
|
||||
session.remoteDebuggingPort,
|
||||
"revision-repair-unreadable-actions-context.png",
|
||||
(page) => page.locator("body")
|
||||
);
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
await page.keyboard.press("Escape");
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.conflictRevision, "Retry reading revision");
|
||||
await revisionCard(settings, fixture.conflictRevision)
|
||||
.getByText(/🧩 Missing chunks:/u)
|
||||
.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const afterRetry = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (JSON.stringify(afterRetry) !== JSON.stringify(beforeApply)) {
|
||||
throw new Error(`Retry changed the revision tree: ${JSON.stringify(afterRetry)}`);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
|
||||
const confirmation = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "No", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
@@ -278,36 +667,23 @@ async function main(): Promise<void> {
|
||||
});
|
||||
|
||||
const afterCancellation = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (!afterCancellation.conflictRevisions.includes(fixture.conflictRevision)) {
|
||||
if (JSON.stringify(afterCancellation) !== JSON.stringify(beforeApply)) {
|
||||
throw new Error(`Cancelling discard changed the revision tree: ${JSON.stringify(afterCancellation)}`);
|
||||
}
|
||||
|
||||
await withObsidianPage(session.remoteDebuggingPort, async (page) => {
|
||||
const settings = page.locator(".sls-setting");
|
||||
const brokenRevision = settings
|
||||
.locator(".sls-repair-result")
|
||||
.filter({ hasText: path })
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision });
|
||||
await brokenRevision
|
||||
.getByRole("button", { name: "Discard unreadable revision", exact: true })
|
||||
.click({ timeout: uiTimeoutMs });
|
||||
await selectRevisionAction(page, settings, fixture.conflictRevision, "Discard this branch");
|
||||
const confirmation = page.locator(".modal-container").filter({
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard unreadable revision" }),
|
||||
has: page.locator(".modal-title").filter({ hasText: "Discard branch" }),
|
||||
});
|
||||
await confirmation.waitFor({ state: "visible", timeout: uiTimeoutMs });
|
||||
await confirmation.getByRole("button", { name: "Yes", exact: true }).click({ timeout: uiTimeoutMs });
|
||||
await settings
|
||||
.locator(".sls-repair-revision")
|
||||
.filter({ hasText: fixture.conflictRevision })
|
||||
.waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
await repairCard(settings).waitFor({ state: "hidden", timeout: uiTimeoutMs });
|
||||
});
|
||||
|
||||
const afterDiscard = await readRevisionTree(cliBinary, session.cliEnv);
|
||||
if (
|
||||
afterDiscard.winnerRevision !== fixture.winnerRevision ||
|
||||
afterDiscard.conflictRevisions.length !== 0
|
||||
) {
|
||||
if (afterDiscard.winnerRevision !== fixture.winnerRevision || afterDiscard.conflictRevisions.length !== 0) {
|
||||
throw new Error(
|
||||
`Explicit discard did not remove only the selected unreadable revision: ${JSON.stringify({
|
||||
fixture,
|
||||
@@ -315,11 +691,31 @@ async function main(): Promise<void> {
|
||||
})}`
|
||||
);
|
||||
}
|
||||
const finalVaultWinner = await readVaultWinnerState(cliBinary, session.cliEnv);
|
||||
const finalProvenance = await readFileReflectionProvenance(cliBinary, session.cliEnv);
|
||||
if (
|
||||
!finalVaultWinner.matches ||
|
||||
finalVaultWinner.winnerRevision !== fixture.winnerRevision ||
|
||||
finalProvenance?.revision !== fixture.winnerRevision
|
||||
) {
|
||||
throw new Error(
|
||||
`Discarding the unreadable branch disturbed the healthy Vault reflection: ${JSON.stringify({
|
||||
finalVaultWinner,
|
||||
finalProvenance,
|
||||
fixture,
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Real Obsidian kept an unreadable conflict revision through automatic checking and retry, rendered every live revision separately, required confirmation, and discarded only the selected revision."
|
||||
"Real Obsidian omitted a healthy logical deletion; rendered each live revision with compact actions and diagnostics; showed that the Vault matched the winner while one conflict remained; compared and applied an exact readable revision without changing the tree; preserved Vault provenance; kept an unreadable branch through automatic checking, retry, and cancelled discard; and discarded only the selected branch after confirmation."
|
||||
);
|
||||
console.log(`Repair screenshot: ${screenshot}`);
|
||||
console.log(`Repair card screenshot: ${repairCardScreenshot}`);
|
||||
console.log(`Mobile-width repair card screenshot: ${mobileWidthScreenshot}`);
|
||||
console.log(`Readable revision actions screenshot: ${readableMenuScreenshot}`);
|
||||
console.log(`Read-only comparison screenshot: ${comparisonScreenshot}`);
|
||||
console.log(`Matching winner with conflict screenshot: ${matchedWinnerWithConflictScreenshot}`);
|
||||
console.log(`Unreadable revision actions screenshot: ${unreadableMenuScreenshot}`);
|
||||
} finally {
|
||||
if (session) {
|
||||
await session.app.stop();
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
|
||||
|
||||
### Improved
|
||||
|
||||
- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, and unavailable shared ancestors separately. It can retry an exact revision without changing the tree, while discarding an unreadable live revision requires explicit confirmation.
|
||||
- **Verify and repair all files** now reports the database winner, every conflict revision, missing chunks, unavailable shared ancestors, and file-information differences separately. Compact, mobile-friendly indicators show missing chunks, `Δsize`, `Δtime`, a Vault which matches the winner, and conflict branches which still remain. Each live revision has an **…** menu for read-only text comparison, exact revision-to-Vault reflection, recording an exact match, storing the Vault file as a child of that branch, or explicitly discarding only that branch while another live branch remains. A logical deletion which already matches an absent Vault file is no longer reported. Retrying a revision does not change the tree, and discarding the sole unreadable live revision remains an explicitly confirmed recovery action.
|
||||
- Command-palette actions now use clearer names and appear only when their feature and current context make them usable. Renamed commands keep their identifiers, so hotkeys already assigned to them continue to work. The onboarding wizard can be reopened from **Self-hosted LiveSync settings** → **Setup**.
|
||||
- Text in setup and review dialogues can now be selected for copying or translation.
|
||||
- When LiveSync adopts an available interface translation on first start-up, it now continues initialisation and leaves a persistent Notice from which the translation details can be opened, instead of waiting for an unsolicited dialogue.
|
||||
|
||||
Reference in New Issue
Block a user