mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-08-11 14:15:46 +00:00
1040 lines
49 KiB
TypeScript
1040 lines
49 KiB
TypeScript
import {
|
|
type FilePathWithPrefix,
|
|
type DocumentID,
|
|
LOG_LEVEL_NOTICE,
|
|
LOG_LEVEL_VERBOSE,
|
|
type FilePath,
|
|
type EntryDoc,
|
|
type diff_result,
|
|
} from "@vrtmrz/livesync-commonlib/compat/common/types";
|
|
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, setIcon } from "@/deps.ts";
|
|
import { $msg } from "@/common/translation";
|
|
import { Semaphore } from "octagonal-wheels/concurrency/semaphore";
|
|
import { LiveSyncSetting as Setting } from "./LiveSyncSetting.ts";
|
|
import {
|
|
EVENT_ANALYSE_DB_USAGE,
|
|
EVENT_REQUEST_CHECK_REMOTE_SIZE,
|
|
EVENT_REQUEST_RUN_DOCTOR,
|
|
EVENT_REQUEST_RUN_FIX_INCOMPLETE,
|
|
eventHub,
|
|
} from "@/common/events.ts";
|
|
import { HiddenFileSync } from "@/features/HiddenFileSync/CmdHiddenFileSync.ts";
|
|
import { EVENT_REQUEST_SHOW_HISTORY } from "@/common/obsidianEvents.ts";
|
|
import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts";
|
|
import type { PageFunctions } from "./SettingPane.ts";
|
|
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
|
|
import {
|
|
chooseAndCopyFileDatabaseInfo,
|
|
collectFileDatabaseInfoPaths,
|
|
copyFileDatabaseInfo,
|
|
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");
|
|
void addPanel(paneEl, $msg("Setting.TroubleShooting")).then((paneEl) => {
|
|
new Setting(paneEl)
|
|
.setName($msg("Setting.TroubleShooting.Doctor"))
|
|
.setDesc($msg("Setting.TroubleShooting.Doctor.Desc"))
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText($msg("Run Doctor"))
|
|
.setCta()
|
|
.setDisabled(false)
|
|
.onClick(() => {
|
|
this.closeSetting();
|
|
eventHub.emitEvent(EVENT_REQUEST_RUN_DOCTOR, "you wanted(Thank you)!");
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName($msg("Setting.TroubleShooting.ScanBrokenFiles"))
|
|
.setDesc($msg("Setting.TroubleShooting.ScanBrokenFiles.Desc"))
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText("Scan for Broken files")
|
|
.setCta()
|
|
.setDisabled(false)
|
|
.onClick(() => {
|
|
this.closeSetting();
|
|
eventHub.emitEvent(EVENT_REQUEST_RUN_FIX_INCOMPLETE);
|
|
})
|
|
);
|
|
|
|
new Setting(paneEl).setName($msg("Prepare the 'report' to create an issue")).addButton((button) =>
|
|
button
|
|
.setButtonText($msg("Copy Report to clipboard"))
|
|
.setCta()
|
|
.setDisabled(false)
|
|
.onClick(async () => {
|
|
await this.app.commands.executeCommandById("obsidian-livesync:dump-debug-info");
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName($msg("Copy database information for a file"))
|
|
.setDesc(
|
|
$msg(
|
|
"Copy revision, conflict, and local chunk availability information, including document and chunk identifiers but not file contents."
|
|
)
|
|
)
|
|
.addButton((button) =>
|
|
button.setButtonText($msg("Choose file")).onClick(async () => {
|
|
await chooseAndCopyFileDatabaseInfo(this.core);
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName($msg("Analyse database usage"))
|
|
.setDesc(
|
|
$msg(
|
|
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like."
|
|
)
|
|
)
|
|
.addButton((button) =>
|
|
button.setButtonText($msg("Analyse")).onClick(() => {
|
|
eventHub.emitEvent(EVENT_ANALYSE_DB_USAGE);
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName($msg("Reset notification threshold and check the remote database usage"))
|
|
.setDesc($msg("Reset the remote storage size threshold and check the remote storage size again."))
|
|
.addButton((button) =>
|
|
button.setButtonText($msg("Check")).onClick(() => {
|
|
eventHub.emitEvent(EVENT_REQUEST_CHECK_REMOTE_SIZE);
|
|
})
|
|
);
|
|
new Setting(paneEl).autoWireToggle("writeLogToTheFile");
|
|
});
|
|
|
|
void addPanel(paneEl, "Scram Switches").then((paneEl) => {
|
|
new Setting(paneEl).autoWireToggle("suspendFileWatching");
|
|
this.addOnSaved("suspendFileWatching", () => this.services.appLifecycle.askRestart());
|
|
|
|
new Setting(paneEl).autoWireToggle("suspendParseReplicationResult");
|
|
this.addOnSaved("suspendParseReplicationResult", () => this.services.appLifecycle.askRestart());
|
|
});
|
|
|
|
void addPanel(paneEl, "Recovery and Repair").then((paneEl) => {
|
|
const resultArea = paneEl.createDiv({ text: "", cls: "sls-repair-results" });
|
|
type RepairMenuAction = {
|
|
title: string;
|
|
run: () => Promise<void> | void;
|
|
warning?: boolean;
|
|
};
|
|
const addActionMenu = (
|
|
parent: HTMLElement,
|
|
label: string,
|
|
actions: RepairMenuAction[]
|
|
) => {
|
|
if (actions.length === 0) {
|
|
return;
|
|
}
|
|
this.createEl(parent, "button", { cls: "sls-repair-action-menu" }, (button) => {
|
|
setIcon(button, "wrench");
|
|
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 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 storeStorageOnRevision = async (
|
|
path: string,
|
|
revision: string,
|
|
createIfDifferent = true
|
|
): Promise<boolean> => {
|
|
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.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;
|
|
const path = information.path;
|
|
const card = this.createEl(resultArea, "div", { cls: "sls-repair-result" });
|
|
const refresh = async () => {
|
|
card.remove();
|
|
const refreshed = await inspectFileRepair(this.core, path);
|
|
if (refreshed.requiresAttention) {
|
|
addRepairResult(refreshed);
|
|
} else {
|
|
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();
|
|
},
|
|
});
|
|
|
|
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: ${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: missing"),
|
|
cls: "sls-repair-metric",
|
|
});
|
|
}
|
|
if (!information.database.exists) {
|
|
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" });
|
|
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"),
|
|
cls: "sls-repair-metric",
|
|
});
|
|
} else if (revision.contentReadable) {
|
|
this.createEl(revisionEl, "div", {
|
|
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(
|
|
({ embedded, localDatabaseState }) =>
|
|
!embedded && localDatabaseState !== "available"
|
|
);
|
|
this.createEl(revisionEl, "div", {
|
|
text: $msg("🧩 Missing chunks: ${COUNT}", {
|
|
COUNT: `${missing.length}`,
|
|
}),
|
|
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", {
|
|
text: missing
|
|
.slice(0, 3)
|
|
.map(({ id }) => id)
|
|
.join(", ") + (missing.length > 3 ? ", …" : ""),
|
|
});
|
|
}
|
|
}
|
|
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 Vault"),
|
|
cls: "sls-repair-metric",
|
|
});
|
|
} else if (revision.contentMatchesStorage === false) {
|
|
this.createEl(revisionEl, "div", {
|
|
text: $msg("⚠️ Differs from Vault"),
|
|
cls: "sls-repair-metric mod-warning",
|
|
});
|
|
}
|
|
|
|
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!);
|
|
},
|
|
});
|
|
}
|
|
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(
|
|
"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: metadata.revision!,
|
|
FILE: path,
|
|
}
|
|
),
|
|
{
|
|
title: $msg("Discard unreadable revision"),
|
|
defaultOption: "No",
|
|
}
|
|
)) === "yes";
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
const result = await discardUnreadableLiveRevision(
|
|
this.core,
|
|
path,
|
|
metadata.revision!
|
|
);
|
|
Logger(
|
|
`Discard unreadable revision ${metadata.revision} of ${path}: ${result}`,
|
|
result === "discarded" ? LOG_LEVEL_NOTICE : LOG_LEVEL_VERBOSE
|
|
);
|
|
await refresh();
|
|
},
|
|
});
|
|
}
|
|
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" });
|
|
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",
|
|
});
|
|
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),
|
|
]
|
|
);
|
|
}
|
|
|
|
for (const base of information.database.mergeBases) {
|
|
if (base.contentAvailableLocally) {
|
|
continue;
|
|
}
|
|
this.createEl(card, "div", {
|
|
text: base.revision
|
|
? $msg(
|
|
"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.",
|
|
{
|
|
REVISION: base.revision,
|
|
}
|
|
)
|
|
: $msg(
|
|
"No shared ancestor is available for this conflict. The live revisions remain available for explicit review."
|
|
),
|
|
cls: "sls-repair-ancestor-warning",
|
|
});
|
|
}
|
|
|
|
const winner = revisions.find(({ role }) => role === "winner");
|
|
const fileActions: RepairMenuAction[] = [];
|
|
if (winner?.loadedEntry) {
|
|
const winnerEntry = winner.loadedEntry;
|
|
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.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)
|
|
);
|
|
},
|
|
});
|
|
}
|
|
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)
|
|
.setName($msg("Recreate chunks for current Vault files"))
|
|
.setDesc(
|
|
$msg(
|
|
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content."
|
|
)
|
|
)
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText($msg("Recreate current chunks"))
|
|
.setCta()
|
|
.onClick(async () => {
|
|
await this.core.fileHandler.createAllChunks(true);
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName($msg("Inspect conflicts and file/database differences"))
|
|
.setDesc(
|
|
$msg(
|
|
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision."
|
|
)
|
|
)
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText($msg("Begin inspection"))
|
|
.setDisabled(false)
|
|
.setCta()
|
|
.onClick(async () => {
|
|
resultArea.replaceChildren();
|
|
Logger("Start inspecting file/database state", LOG_LEVEL_NOTICE, "verify");
|
|
this.core.localDatabase.clearCaches();
|
|
const allPaths = await collectFileDatabaseInfoPaths(this.core);
|
|
let i = 0;
|
|
const incProc = () => {
|
|
i++;
|
|
if (i % 25 == 0)
|
|
Logger(
|
|
`Checking ${i}/${allPaths.length} files \n`,
|
|
LOG_LEVEL_NOTICE,
|
|
"verify-processed"
|
|
);
|
|
};
|
|
const semaphore = Semaphore(10);
|
|
const processes = allPaths.map(async (path) => {
|
|
try {
|
|
if (shouldBeIgnored(path)) {
|
|
return incProc();
|
|
}
|
|
const stat = (await this.core.storageAccess.isExistsIncludeHidden(path))
|
|
? await this.core.storageAccess.statHidden(path)
|
|
: false;
|
|
const fileOnStorage = stat != null ? stat : false;
|
|
if (!(await this.services.vault.isTargetFile(path))) return incProc();
|
|
if (fileOnStorage && this.services.vault.isFileSizeTooLarge(fileOnStorage.size))
|
|
return incProc();
|
|
const releaser = await semaphore.acquire(1);
|
|
try {
|
|
const inspection = await inspectFileRepair(this.core, path);
|
|
const winner = inspection.revisions.find(({ role }) => role === "winner");
|
|
if (
|
|
winner &&
|
|
this.services.vault.isFileSizeTooLarge(winner.metadata.recordedSize)
|
|
)
|
|
return incProc();
|
|
if (inspection.requiresAttention) {
|
|
addRepairResult(inspection);
|
|
} else {
|
|
Logger(`Compare: SAME: ${path}`);
|
|
}
|
|
} catch (ex) {
|
|
Logger(`Error while processing ${path}`, LOG_LEVEL_NOTICE);
|
|
Logger(ex, LOG_LEVEL_VERBOSE);
|
|
} finally {
|
|
releaser();
|
|
incProc();
|
|
}
|
|
} catch (ex) {
|
|
Logger(`Error while processing without semaphore ${path}`, LOG_LEVEL_NOTICE);
|
|
Logger(ex, LOG_LEVEL_VERBOSE);
|
|
}
|
|
});
|
|
await Promise.all(processes);
|
|
Logger("done", LOG_LEVEL_NOTICE, "verify");
|
|
// Logger(`${i}/${files.length}\n`, LOG_LEVEL_NOTICE, "verify-processed");
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName("Resolve All conflicted files by the newer one")
|
|
.setDesc(
|
|
"Resolve all conflicted files by the newer one. Caution: This will overwrite the older one, and cannot resurrect the overwritten one."
|
|
)
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText("Resolve All")
|
|
.setCta()
|
|
.onClick(async () => {
|
|
const confirmed =
|
|
(await this.core.confirm.askYesNoDialog(
|
|
$msg(
|
|
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable."
|
|
),
|
|
{
|
|
title: $msg("Resolve all conflicts by the newest version"),
|
|
defaultOption: "No",
|
|
}
|
|
)) === "yes";
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
await this.services.conflict.resolveAllConflictedFilesByNewerOnes();
|
|
})
|
|
);
|
|
new Setting(paneEl)
|
|
.setName("Check and convert non-path-obfuscated files")
|
|
.setDesc("")
|
|
.addButton((button) =>
|
|
button
|
|
.setButtonText("Perform")
|
|
.setDisabled(false)
|
|
.setWarning()
|
|
.onClick(async () => {
|
|
for await (const docName of this.core.localDatabase.findAllDocNames()) {
|
|
if (!docName.startsWith("f:")) {
|
|
const idEncoded = await this.services.path.path2id(docName as FilePathWithPrefix);
|
|
const doc = await this.core.localDatabase.getRaw(docName as DocumentID);
|
|
if (!doc) continue;
|
|
if (doc.type != "newnote" && doc.type != "plain") {
|
|
continue;
|
|
}
|
|
if (doc?.deleted ?? false) continue;
|
|
const newDoc = { ...doc };
|
|
//Prepare converted data
|
|
newDoc._id = idEncoded;
|
|
newDoc.path = docName as FilePathWithPrefix;
|
|
// @ts-ignore
|
|
delete newDoc._rev;
|
|
try {
|
|
const obfuscatedDoc = await this.core.localDatabase.getRaw(idEncoded, {
|
|
revs_info: true,
|
|
});
|
|
// Unfortunately we have to delete one of them.
|
|
// Just now, save it as a conflicted document.
|
|
obfuscatedDoc._revs_info?.shift(); // Drop latest revision.
|
|
const previousRev = obfuscatedDoc._revs_info?.shift(); // Use second revision.
|
|
if (previousRev) {
|
|
newDoc._rev = previousRev.rev;
|
|
} else {
|
|
//If there are no revisions, set the possibly unique one
|
|
newDoc._rev =
|
|
"1-" +
|
|
`00000000000000000000000000000000${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}${~~(Math.random() * 1e9)}`.slice(
|
|
-32
|
|
);
|
|
}
|
|
const ret = await this.core.localDatabase.putRaw(newDoc, { force: true });
|
|
if (ret.ok) {
|
|
Logger(
|
|
`${docName} has been converted as conflicted document`,
|
|
LOG_LEVEL_NOTICE
|
|
);
|
|
doc._deleted = true;
|
|
if ((await this.core.localDatabase.putRaw(doc)).ok) {
|
|
Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE);
|
|
}
|
|
await this.services.conflict.queueCheckForIfOpen(docName as FilePathWithPrefix);
|
|
} else {
|
|
Logger(`Converting ${docName} Failed!`, LOG_LEVEL_NOTICE);
|
|
Logger(ret, LOG_LEVEL_VERBOSE);
|
|
}
|
|
} catch (ex: unknown) {
|
|
if (isNotFoundError(ex)) {
|
|
// We can perform this safely
|
|
if ((await this.core.localDatabase.putRaw(newDoc)).ok) {
|
|
Logger(`${docName} has been converted`, LOG_LEVEL_NOTICE);
|
|
doc._deleted = true;
|
|
if ((await this.core.localDatabase.putRaw(doc)).ok) {
|
|
Logger(`Old ${docName} has been deleted`, LOG_LEVEL_NOTICE);
|
|
}
|
|
}
|
|
} else {
|
|
Logger(`Something went wrong while converting ${docName}`, LOG_LEVEL_NOTICE);
|
|
Logger(ex, LOG_LEVEL_VERBOSE);
|
|
// Something wrong.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Logger(`Converting finished`, LOG_LEVEL_NOTICE);
|
|
})
|
|
);
|
|
});
|
|
void addPanel(paneEl, "Reset").then((paneEl) => {
|
|
new Setting(paneEl).setName("Back to non-configured").addButton((button) =>
|
|
button
|
|
.setButtonText("Back")
|
|
.setDisabled(false)
|
|
.onClick(async () => {
|
|
this.editingSettings.isConfigured = false;
|
|
await this.saveAllDirtySettings();
|
|
this.services.appLifecycle.askRestart();
|
|
})
|
|
);
|
|
|
|
new Setting(paneEl).setName("Delete all customization sync data").addButton((button) =>
|
|
button
|
|
.setButtonText("Delete")
|
|
.setDisabled(false)
|
|
.setWarning()
|
|
.onClick(async () => {
|
|
Logger(`Deleting customization sync data`, LOG_LEVEL_NOTICE);
|
|
const entriesToDelete = await this.core.localDatabase.allDocsRaw({
|
|
startkey: "ix:",
|
|
endkey: "ix:\u{10ffff}",
|
|
include_docs: true,
|
|
});
|
|
const newData = entriesToDelete.rows.map((e) => ({
|
|
...e.doc,
|
|
_deleted: true,
|
|
})) as EntryDoc[];
|
|
const r = await this.core.localDatabase.bulkDocsRaw(newData);
|
|
// Do not care about the result.
|
|
Logger(
|
|
`${r.length} items have been removed, to confirm how many items are left, please perform it again.`,
|
|
LOG_LEVEL_NOTICE
|
|
);
|
|
})
|
|
);
|
|
});
|
|
}
|