Polish diagnostic reporting and local pseudonym follow-up

This commit is contained in:
vorotamoroz committed 2026-10-20 08:23:56 +00:00
1 parent c1ed71f686
commit 32874ea97f
21 files changed
+2425 -154

No files matched your search

@@ -7,6 +7,43 @@
* remove it from this map in the same change.
*/
export const liveSyncProvisionalEnglishMessages = {
"Preparing follow-up report…": "Preparing follow-up report…",
"Cancelling local search. Checked ${COUNT} entries.": "Cancelling local search. Checked ${COUNT} entries.",
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.":
"Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.",
"The lookup does not read file contents or contact a remote database. Follow-up reports include current conflicts and merge bases.":
"The lookup does not read file contents or contact a remote database. Follow-up reports include current conflicts and merge bases.",
"Local file diagnostic report": "Local file diagnostic report",
"Checked ${COUNT} local entries.": "Checked ${COUNT} local entries.",
"Enter a valid report path pseudonym.": "Enter a valid report path pseudonym.",
"Search cancelled. Matches found so far are shown.": "Search cancelled. Matches found so far are shown.",
"No matches were found in current Vault paths or current local Metadata. Older history and remote-only files are not searched.":
"No matches were found in current Vault paths or current local Metadata. Older history and remote-only files are not searched.",
"Found ${COUNT} local matches.": "Found ${COUNT} local matches.",
"The local search could not finish.": "The local search could not finish.",
"The file could not be opened.": "The file could not be opened.",
"The follow-up report could not be prepared.": "The follow-up report could not be prepared.",
"Report path pseudonym": "Report path pseudonym",
Source: "Source",
Metadata: "Metadata",
Vault: "Vault",
"Metadata document ID": "Metadata document ID",
"No current Metadata document": "No current Metadata document",
"Current Vault file": "Current Vault file",
Present: "Present",
Missing: "Missing",
"Open file": "Open file",
"Prepare follow-up report": "Prepare follow-up report",
"This report uses pseudonyms. Review its coverage before sharing.":
"This report uses pseudonyms. Review its coverage before sharing.",
"Back to matches": "Back to matches",
"Reading revisions": "Reading revisions",
"Calculating database usage": "Calculating database usage",
"Discovering revisions": "Discovering revisions",
"Find a file from a report pseudonym": "Find a file from a report pseudonym",
"Find matching paths in this Vault and local Metadata, then prepare a follow-up report using the same pseudonyms.":
"Find matching paths in this Vault and local Metadata, then prepare a follow-up report using the same pseudonyms.",
"Find file": "Find file",
"⚠️ Initialise or overwrite the remote": "⚠️ Initialise or overwrite the remote",
"🔗 Join this device": "🔗 Join this device",
"⚙️ Apply settings only (advanced)": "⚙️ Apply settings only (advanced)",
@@ -268,12 +305,13 @@ export const liveSyncProvisionalEnglishMessages = {
"Copy failed. Select the report text and copy it manually.",
"Download requested. You can also copy the report if downloads are unavailable on this device.":
"Download requested. You can also copy the report if downloads are unavailable on this device.",
"Choose how names appear in this run. Pseudonyms replace paths and identifiers consistently. Original names include private filenames. File contents and credentials are omitted in either mode.":
"Choose how names appear in this run. Pseudonyms replace paths and identifiers consistently. Original names include private filenames. File contents and credentials are omitted in either mode.",
"Pseudonyms include a public salt, so guessed paths can be checked. Original names include private filenames. Reports omit file contents and credentials.":
"Pseudonyms include a public salt, so guessed paths can be checked. Original names include private filenames. Reports omit file contents and credentials.",
"Read available local revisions and report their Chunk references. Settings are left unchanged.":
"Read available local revisions and report their Chunk references. Settings are left unchanged.",
"Read normal-file Metadata, current conflicts, and content for suspect groups from this device and CouchDB. Other remote types and older revision history are outside this inspection. Results do not authorise deletion.":
"Read normal-file Metadata, current conflicts, and content for suspect groups from this device and CouchDB. Other remote types and older revision history are outside this inspection. Results do not authorise deletion.",
"Inspect normal-file Metadata, current conflicts, and suspect content against the Vault. Remote inspection supports CouchDB. Older history is excluded; findings do not authorise deletion.":
"Inspect normal-file Metadata, current conflicts, and suspect content against the Vault. Remote inspection supports CouchDB. Older history is excluded; findings do not authorise deletion.",
"Include remote database": "Include remote database",
"Names in this report": "Names in this report",
"Pseudonyms (recommended)": "Pseudonyms (recommended)",
"Original names": "Original names",
@@ -10,7 +10,7 @@ import {
import { createBlob, escapeMarkdownValue, 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 { Menu, TFile, 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";
@@ -62,10 +62,70 @@ import {
executeMetadataIdentityRepair,
MetadataIdentityRepairExecutions,
} from "@/serviceFeatures/metadataIdentityRepair.ts";
import { showDiagnosticFileLookup } from "@/serviceFeatures/diagnosticFileLookup.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) => {
void addPanel(paneEl, "Reporting Tools").then((paneEl) => {
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("Find a file from a report pseudonym"))
.setDesc(
$msg(
"Find matching paths in this Vault and local Metadata, then prepare a follow-up report using the same pseudonyms."
)
)
.addButton((button) =>
button.setButtonText($msg("Find file")).onClick(async () => {
await showDiagnosticFileLookup(this.core, async (path) => {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile) await this.app.workspace.getLeaf(false).openFile(file);
});
})
);
new Setting(paneEl)
.setName($msg("Inspect database integrity"))
.setDesc(
$msg("Read normal-file Metadata and current conflicts locally and in CouchDB, then compare content for suspect groups. Choose original names or pseudonyms before starting.")
)
.addButton((button) =>
button.setButtonText($msg("Inspect")).onClick(() => eventHub.emitEvent(EVENT_INSPECT_DB_INTEGRITY))
);
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);
})
);
});
void addPanel(paneEl, "Checks and Recovery").then((paneEl) => {
new Setting(paneEl)
.setName($msg("Setting.TroubleShooting.Doctor"))
.setDesc($msg("Setting.TroubleShooting.Doctor.Desc"))
@@ -92,48 +152,9 @@ export function paneHatch(this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement,
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("Inspect database integrity"))
.setDesc(
$msg("Read normal-file Metadata and current conflicts locally and in CouchDB, then compare content for suspect groups. Choose original names or pseudonyms before starting.")
)
.addButton((button) =>
button.setButtonText($msg("Inspect")).onClick(() => eventHub.emitEvent(EVENT_INSPECT_DB_INTEGRITY))
);
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);
})
);
void addPanel(paneEl, $msg("Setting.TroubleShooting")).then((paneEl) => {
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."))
@@ -1,21 +1,31 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { onDestroy, onMount, tick } from "svelte";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import { $msg as translate } from "@/common/translation";
import { ProgressFragment } from "@vrtmrz/obsidian-plugin-kit/progress";
import type { DatabaseDiagnosticDialogOptions } from "@/serviceFeatures/databaseDiagnosticDialogTypes";
import type { DatabaseDiagnosticOutput } from "@/serviceFeatures/databaseDiagnostics";
import type {
DatabaseDiagnosticOutput,
DatabaseDiagnosticProgress,
} from "@/serviceFeatures/databaseDiagnostics";
import type { DiagnosticNameMode } from "@vrtmrz/livesync-commonlib/diagnostics";
const { setResult, getInitialData }: GuestDialogProps<"close", DatabaseDiagnosticDialogOptions> = $props();
let options = $state<DatabaseDiagnosticDialogOptions>();
let phase = $state<"choice" | "running" | "result" | "failed">("choice");
let nameMode = $state<DiagnosticNameMode>("pseudonym");
let processed = $state(0);
let includeRemote = $state(true);
let cancelling = $state(false);
let output = $state<DatabaseDiagnosticOutput>();
let selectedFormat = $state("");
let actionStatus = $state("");
let thisElement: HTMLDivElement;
let progressHost = $state<HTMLDivElement>();
let currentProgress: DatabaseDiagnosticProgress | undefined;
let progressFragment: ProgressFragment | undefined;
let lastProgressRender = 0;
let progressTimer: ReturnType<typeof setTimeout> | undefined;
let disposed = false;
const selected = $derived(output?.artifacts.find((artifact) => artifact.format === selectedFormat));
const title = $derived(
@@ -33,20 +43,44 @@
);
onMount(() => {
options = getInitialData?.();
const modalContainer = thisElement.closest(".modal-container");
const alreadyScoped = modalContainer?.classList.contains("sls-database-diagnostic-modal") ?? false;
modalContainer?.classList.add("sls-database-diagnostic-modal");
return () => {
if (!alreadyScoped) modalContainer?.classList.remove("sls-database-diagnostic-modal");
};
});
onDestroy(() => {
disposed = true;
clearTimeout(progressTimer);
options?.cancel();
});
async function begin() {
if (!options || phase !== "choice") return;
const diagnosticOptions = options;
if (!diagnosticOptions || phase !== "choice") return;
phase = "running";
await tick();
if (disposed) return;
currentProgress = undefined;
progressFragment = undefined;
progressHost?.replaceChildren();
updateProgress({
phase: diagnosticOptions.kind === "usage" ? "usage" : "metadata",
source: diagnosticOptions.kind === "integrity" ? "local" : undefined,
processed: 0,
elapsedMs: 0,
});
try {
const result = await options.run(nameMode, (count) => {
if (!disposed) processed = count;
});
const result = await diagnosticOptions.run(
nameMode,
(progress) => {
if (!disposed) updateProgress(progress);
},
includeRemote
);
if (disposed) return;
finishProgress(result.status);
output = result;
selectedFormat = result.artifacts[0].format;
phase = "result";
@@ -55,6 +89,58 @@
}
}
function finishProgress(status: DatabaseDiagnosticOutput["status"]) {
if (status === "cancelled") progressFragment?.cancel();
else progressFragment?.complete();
}
function updateProgress(progress: DatabaseDiagnosticProgress, force = false) {
const host = progressHost;
if (!host) return;
const stageChanged =
currentProgress?.phase !== progress.phase || currentProgress?.source !== progress.source;
const stageFinished =
progress.totalIsFinal !== false && progress.total !== undefined && progress.processed === progress.total;
currentProgress = progress;
const now = performance.now();
if (!force && !stageChanged && !stageFinished && now - lastProgressRender < 100) {
progressTimer ??= setTimeout(() => {
progressTimer = undefined;
if (!disposed && phase === "running" && currentProgress) updateProgress(currentProgress, true);
}, 100 - (now - lastProgressRender));
return;
}
clearTimeout(progressTimer);
progressTimer = undefined;
lastProgressRender = now;
const phaseTitle =
progress.phase === "usage" ? translate("Reading revisions") :
progress.phase === "accounting" ? translate("Calculating database usage") :
progress.phase === "metadata" ? "Metadata" : "Content";
const sourceTitle = progress.source ? ` · ${progress.source}` : "";
const unit = progress.phase === "usage" ? "revisions" :
progress.phase === "accounting" ? "items" : progress.phase === "metadata" ? "documents" : "groups";
const total = progress.total ?? 0;
const note = `${(progress.elapsedMs / 1000).toFixed(1)} s elapsed${
progress.totalIsFinal === false ? ` · ${translate("Discovering revisions")}` : ""
}`;
if (stageChanged) {
progressFragment?.complete();
progressFragment = new ProgressFragment({
value: progress.processed,
total,
title: `${phaseTitle}${sourceTitle}`,
note,
autoComplete: false,
document: host.ownerDocument,
formatNumeric: (snapshot) => `${snapshot.value} / ${currentProgress?.total === undefined ? "—" : snapshot.total} ${unit}`,
});
host.replaceChildren(progressFragment.fragment);
return;
}
progressFragment?.update({ value: progress.processed, total, title: `${phaseTitle}${sourceTitle}`, note });
}
async function copyReport(event: MouseEvent) {
if (!selected) return;
const owner = (event.currentTarget as HTMLElement).ownerDocument.defaultView;
@@ -82,13 +168,13 @@
</script>
<DialogHeader {title} />
<div class="database-diagnostic" data-diagnostic-phase={phase}>
<div class="database-diagnostic" bind:this={thisElement} data-diagnostic-phase={phase}>
{#if phase === "choice"}
<p>{translate("Choose how names appear in this run. Pseudonyms replace paths and identifiers consistently. Original names include private filenames. File contents and credentials are omitted in either mode.")}</p>
<p>{translate("Pseudonyms include a public salt, so guessed paths can be checked. Original names include private filenames. Reports omit file contents and credentials.")}</p>
<p>
{options?.kind === "usage"
? translate("Read available local revisions and report their Chunk references. Settings are left unchanged.")
: translate("Read normal-file Metadata, current conflicts, and content for suspect groups from this device and CouchDB. Other remote types and older revision history are outside this inspection. Results do not authorise deletion.")}
: translate("Inspect normal-file Metadata, current conflicts, and suspect content against the Vault. Remote inspection supports CouchDB. Older history is excluded; findings do not authorise deletion.")}
</p>
<fieldset class="diagnostic-options">
<legend>{translate("Names in this report")}</legend>
@@ -103,14 +189,21 @@
</label>
</div>
</fieldset>
{#if options?.kind === "integrity"}
<label class="diagnostic-remote-choice">
<input type="checkbox" bind:checked={includeRemote} />
<span>{translate("Include remote database")}</span>
</label>
{/if}
<div class="diagnostic-controls">
<button onclick={() => setResult("close")}>{translate("Cancel")}</button>
<button class="mod-cta" disabled={!options} onclick={() => void begin()}>{translate("Begin inspection")}</button>
</div>
{:else if phase === "running"}
<p role="status">
{cancelling ? translate("Cancelling inspection…") : `${translate("Items inspected")}: ${processed}`}
</p>
<div class="diagnostic-progress" role="status" aria-live="polite" bind:this={progressHost}></div>
{#if cancelling}
<p role="status">{translate("Cancelling inspection…")}</p>
{/if}
<button disabled={cancelling} onclick={() => { cancelling = true; options?.cancel(); }}>
{translate("Cancel inspection")}
</button>
@@ -149,16 +242,48 @@
</div>
<style>
.database-diagnostic { display: flex; flex-direction: column; gap: var(--size-4-2); }
p { margin-block: var(--size-4-2); }
:global(.sls-database-diagnostic-modal .modal) {
display: flex;
flex-direction: column;
}
:global(.sls-database-diagnostic-modal .modal-title) { flex: 0 0 auto; }
:global(.sls-database-diagnostic-modal .modal-content) {
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.sls-database-diagnostic-modal .modal-content > .dialog-host) {
flex: 1 1 auto;
min-height: 0;
}
.database-diagnostic {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--size-4-2);
min-height: 0;
}
.database-diagnostic > :not(textarea) { flex: 0 0 auto; }
p { margin: 0; }
.diagnostic-options { border: 0; margin: 0; padding: 0; min-width: 0; }
legend { padding: 0; margin-bottom: var(--size-4-2); }
.diagnostic-option-list { display: flex; flex-wrap: wrap; gap: var(--size-4-2) var(--size-4-4); }
.diagnostic-option-list label { display: flex; align-items: center; gap: var(--size-4-2); width: auto; min-height: 44px; cursor: pointer; }
.database-diagnostic .diagnostic-option-list label > span { width: auto; }
input[type="radio"] { margin: 0; flex: 0 0 auto; }
.diagnostic-controls { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: var(--size-4-2); }
.diagnostic-remote-choice { display: flex; align-items: center; gap: var(--size-4-2); min-height: 44px; cursor: pointer; }
.database-diagnostic .diagnostic-remote-choice > span { width: auto; }
input[type="checkbox"] { margin: 0; flex: 0 0 auto; }
.diagnostic-controls { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-end; gap: var(--size-4-2); }
button { min-height: 44px; }
textarea { width: 100%; resize: vertical; font-family: var(--font-monospace); font-size: var(--font-ui-small); }
textarea {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
width: 100%;
resize: vertical;
font-family: var(--font-monospace);
font-size: var(--font-ui-small);
}
:global(body.is-mobile) textarea { max-height: 25vh; }
</style>
@@ -0,0 +1,367 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import type { GuestDialogProps } from "@/modules/services/LiveSyncUI/svelteDialog";
import DialogHeader from "@/modules/services/LiveSyncUI/components/DialogHeader.svelte";
import { $msg as translate } from "@/common/translation";
import type {
DiagnosticFileCandidate,
DiagnosticFileLookupDialogOptions,
DiagnosticFileLookupResult,
} from "@/serviceFeatures/diagnosticFileLookupTypes";
import type { DatabaseDiagnosticOutput } from "@/serviceFeatures/databaseDiagnostics";
const { setResult, getInitialData }: GuestDialogProps<"close", DiagnosticFileLookupDialogOptions> = $props();
let options = $state<DiagnosticFileLookupDialogOptions>();
let phase = $state<"input" | "running" | "results" | "report">("input");
let token = $state("");
let completedToken = $state<string>();
let processed = $state(0);
let cancelling = $state(false);
let preparingReport = $state(false);
let lookupResult = $state<DiagnosticFileLookupResult>();
let output = $state<DatabaseDiagnosticOutput>();
let selectedFormat = $state("");
let actionStatus = $state("");
let thisElement: HTMLDivElement;
let disposed = false;
const title = translate("Find a file from a report pseudonym");
const selected = $derived(output?.artifacts.find((artifact) => artifact.format === selectedFormat));
const lookupStatus = $derived.by(() => {
if (phase === "running") {
return cancelling
? translate("Cancelling local search. Checked ${COUNT} entries.", { COUNT: `${processed}` })
: translate("Checked ${COUNT} local entries.", { COUNT: `${processed}` });
}
if (!lookupResult) return "";
if (!lookupResult.valid) return translate("Enter a valid report path pseudonym.");
if (lookupResult.status === "cancelled") return translate("Search cancelled. Matches found so far are shown.");
if (lookupResult.candidates.length === 0) {
return translate(
"No matches were found in current Vault paths or current local Metadata. Older history and remote-only files are not searched."
);
}
return translate("Found ${COUNT} local matches.", { COUNT: `${lookupResult.candidates.length}` });
});
onMount(() => {
options = getInitialData?.();
const modalContainer = thisElement.closest(".modal-container");
const alreadyScoped = modalContainer?.classList.contains("sls-diagnostic-file-lookup-modal") ?? false;
modalContainer?.classList.add("sls-diagnostic-file-lookup-modal");
return () => {
if (!alreadyScoped) modalContainer?.classList.remove("sls-diagnostic-file-lookup-modal");
};
});
onDestroy(() => {
disposed = true;
options?.cancel();
});
async function findFiles(event?: SubmitEvent) {
event?.preventDefault();
const dialogOptions = options;
if (!dialogOptions || phase === "running" || preparingReport) return;
phase = "running";
cancelling = false;
processed = 0;
lookupResult = undefined;
completedToken = undefined;
output = undefined;
actionStatus = "";
const searchedToken = token;
try {
const result = await dialogOptions.find(searchedToken, (count) => {
if (!disposed) processed = count;
});
if (disposed) return;
lookupResult = result;
completedToken = result.valid ? searchedToken : undefined;
phase = result.valid ? "results" : "input";
} catch {
if (!disposed) {
completedToken = undefined;
phase = "input";
actionStatus = translate("The local search could not finish.");
}
}
}
function updateToken(event: Event) {
token = (event.currentTarget as HTMLInputElement).value;
if (phase === "results") {
lookupResult = undefined;
completedToken = undefined;
phase = "input";
actionStatus = "";
}
}
function cancelSearch() {
cancelling = true;
options?.cancel();
}
async function openCandidate(candidate: DiagnosticFileCandidate) {
if (!options?.openFile || !candidate.storageExists) return;
try {
await options.openFile(candidate.path);
} catch {
actionStatus = translate("The file could not be opened.");
}
}
async function prepareReport(candidate: DiagnosticFileCandidate) {
const reportToken = completedToken;
if (!options || !reportToken || preparingReport || phase !== "results") return;
preparingReport = true;
actionStatus = "";
try {
const report = await options.prepareReport(candidate, reportToken);
if (disposed) return;
output = report;
selectedFormat = report.artifacts[0]?.format ?? "";
phase = "report";
} catch {
if (!disposed) actionStatus = translate("The follow-up report could not be prepared.");
} finally {
if (!disposed) preparingReport = false;
}
}
async function copyReport(event: MouseEvent) {
if (!selected) return;
const owner = (event.currentTarget as HTMLElement).ownerDocument.defaultView;
try {
await owner?.navigator.clipboard.writeText(selected.content);
actionStatus = translate("Report copied.");
} catch {
actionStatus = translate("Copy failed. Select the report text and copy it manually.");
}
}
function saveReport(event: MouseEvent) {
if (!selected) return;
const document = (event.currentTarget as HTMLElement).ownerDocument;
const url = URL.createObjectURL(new Blob([selected.content], { type: "text/plain;charset=utf-8" }));
const link = document.createElement("a");
link.href = url;
link.download = selected.filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
actionStatus = translate("Download requested. You can also copy the report if downloads are unavailable on this device.");
}
</script>
<DialogHeader {title} />
<div class="diagnostic-file-lookup" bind:this={thisElement} data-diagnostic-phase={phase}>
{#if phase !== "report"}
{#if phase !== "results"}
<p>
{translate("Search current Vault paths and current local Metadata only. Older history and remote-only files are not searched.")}
</p>
<p>
{translate(
"The lookup does not read file contents or contact a remote database. Follow-up reports include current conflicts and merge bases."
)}
</p>
{/if}
<form class="lookup-form" onsubmit={findFiles}>
<label for="diagnostic-file-lookup-token">{translate("Report path pseudonym")}</label>
<input
id="diagnostic-file-lookup-token"
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
disabled={phase === "running" || preparingReport}
value={token}
oninput={updateToken}
/>
<div class="lookup-controls">
<button
type="submit"
class="mod-cta"
disabled={!options || phase === "running" || preparingReport}
>
{translate("Find file")}
</button>
{#if phase === "running"}
<button type="button" disabled={cancelling} onclick={cancelSearch}>{translate("Cancel")}</button>
{/if}
<button type="button" onclick={() => setResult("close")}>{translate("Close")}</button>
</div>
</form>
{:else if completedToken}
<p class="report-token"><span>{translate("Report path pseudonym")}</span>: <code>{completedToken}</code></p>
{/if}
{#if phase === "input" && actionStatus}
<p role="status">{actionStatus}</p>
{/if}
{#if phase === "running"}
<p role="status" aria-live="polite">{lookupStatus}</p>
{:else if phase === "input" && lookupResult?.valid === false}
<p role="status">{lookupStatus}</p>
{:else if phase === "results" && lookupResult?.valid}
<p role="status" aria-live="polite">{lookupStatus}</p>
{#if lookupResult.candidates.length > 0}
<ul class="lookup-candidates">
{#each lookupResult.candidates as candidate}
<li
data-diagnostic-candidate="true"
data-document-id={candidate.documentId}
data-source={candidate.source}
data-storage-exists={candidate.storageExists}
>
<div class="candidate-details">
<strong>{candidate.path}</strong>
<dl>
<dt>{translate("Source")}</dt>
<dd>{candidate.source === "metadata" ? translate("Metadata") : translate("Vault")}</dd>
<dt>{translate("Metadata document ID")}</dt>
<dd>{candidate.documentId ?? translate("No current Metadata document")}</dd>
<dt>{translate("Current Vault file")}</dt>
<dd>{candidate.storageExists ? translate("Present") : translate("Missing")}</dd>
</dl>
</div>
<div class="candidate-controls">
<button
type="button"
disabled={!options?.openFile || !candidate.storageExists || preparingReport}
onclick={() => void openCandidate(candidate)}
>
{translate("Open file")}
</button>
<button
type="button"
class="mod-cta"
disabled={preparingReport}
onclick={() => void prepareReport(candidate)}
>
{translate("Prepare follow-up report")}
</button>
</div>
</li>
{/each}
</ul>
{/if}
<p role="status">{preparingReport ? translate("Preparing follow-up report…") : actionStatus}</p>
{:else if phase === "report" && output}
<p role="status">{translate("This report uses pseudonyms. Review its coverage before sharing.")}</p>
<fieldset class="report-formats">
<legend>{translate("Report format")}</legend>
<div class="format-options">
{#each output.artifacts as artifact}
<label>
<input type="radio" name="diagnostic-file-report-format" value={artifact.format} bind:group={selectedFormat} />
<span>{artifact.format.toUpperCase()}</span>
</label>
{/each}
</div>
</fieldset>
<textarea readonly rows="16" aria-label={translate("Diagnostic report")} value={selected?.content ?? ""}></textarea>
<p role="status">{actionStatus}</p>
<div class="lookup-controls">
<button type="button" onclick={(event) => void copyReport(event)}>{translate("Copy report")}</button>
<button type="button" onclick={saveReport}>{translate("Save report")}</button>
<button type="button" onclick={() => { output = undefined; phase = "results"; }}>{translate("Back to matches")}</button>
<button type="button" onclick={() => setResult("close")}>{translate("Close")}</button>
</div>
{/if}
</div>
<style>
:global(.sls-diagnostic-file-lookup-modal .modal) {
display: flex;
flex-direction: column;
}
:global(.sls-diagnostic-file-lookup-modal .modal-title) { flex: 0 0 auto; }
:global(.sls-diagnostic-file-lookup-modal .modal-content) {
display: flex;
flex-direction: column;
min-height: 0;
}
:global(.sls-diagnostic-file-lookup-modal .modal-content > .dialog-host) {
flex: 1 1 auto;
min-height: 0;
}
.diagnostic-file-lookup {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--size-4-2);
min-height: 0;
}
.diagnostic-file-lookup > p,
.diagnostic-file-lookup form,
.lookup-controls,
.candidate-controls,
.report-formats { flex: 0 0 auto; }
p { margin: 0; }
.lookup-form { display: flex; flex-direction: column; gap: var(--size-4-2); }
.lookup-form label { min-height: 24px; }
.lookup-form input { width: 100%; min-height: 44px; }
.lookup-controls,
.candidate-controls {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--size-4-2);
}
.diagnostic-file-lookup button { min-height: 44px; max-width: 100%; margin-left: 0; white-space: normal; }
.candidate-controls { min-width: 0; max-width: 100%; }
.lookup-candidates {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--size-4-2);
min-height: 0;
margin: 0;
overflow: auto;
padding: 0;
list-style: none;
}
.lookup-candidates li {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: var(--size-4-2);
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m);
padding: var(--size-4-2);
}
.candidate-details { flex: 1 1 18rem; min-width: 0; }
.candidate-details strong { display: block; overflow-wrap: anywhere; }
dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: var(--size-4-1) var(--size-4-2); margin: var(--size-4-2) 0 0; }
dt { font-weight: var(--font-semibold); }
dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.report-formats { border: 0; margin: 0; padding: 0; min-width: 0; }
.report-token { overflow-wrap: anywhere; }
.report-token code { white-space: normal; }
legend { padding: 0; margin-bottom: var(--size-4-2); }
.format-options { display: flex; flex-wrap: wrap; gap: var(--size-4-2) var(--size-4-4); }
.format-options label { display: flex; align-items: center; gap: var(--size-4-2); width: auto; min-height: 44px; cursor: pointer; }
.diagnostic-file-lookup .format-options label > span { width: auto; }
input[type="radio"] { margin: 0; flex: 0 0 auto; }
textarea {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
width: 100%;
resize: vertical;
font-family: var(--font-monospace);
font-size: var(--font-ui-small);
}
:global(body.is-mobile) textarea { max-height: 25vh; }
@media (max-width: 480px) {
dl { grid-template-columns: minmax(0, 1fr); }
}
</style>
@@ -1,9 +1,17 @@
import type { DiagnosticNameMode } from "@vrtmrz/livesync-commonlib/diagnostics";
import type { DatabaseDiagnosticKind, DatabaseDiagnosticOutput } from "./databaseDiagnostics";
import type {
DatabaseDiagnosticKind,
DatabaseDiagnosticOutput,
DatabaseDiagnosticProgress,
} from "./databaseDiagnostics";
/** The dialogue can run or cancel an inspection without receiving the service container. */
export interface DatabaseDiagnosticDialogOptions {
kind: DatabaseDiagnosticKind;
run(nameMode: DiagnosticNameMode, onProgress: (processed: number) => void): Promise<DatabaseDiagnosticOutput>;
run(
nameMode: DiagnosticNameMode,
onProgress: (progress: DatabaseDiagnosticProgress) => void,
includeRemote?: boolean
): Promise<DatabaseDiagnosticOutput>;
cancel(): void;
}
+75 -13
View File
@@ -17,6 +17,7 @@ import { REMOTE_COUCHDB, type ObsidianLiveSyncSettings } from "@vrtmrz/livesync-
import type { StorageAccess } from "@vrtmrz/livesync-commonlib/compat/interfaces/StorageAccess";
import type { NecessaryServicesInterfaces } from "@vrtmrz/livesync-commonlib/compat/interfaces/ServiceModule";
import commonlibPackage from "@vrtmrz/livesync-commonlib/package.json";
import { LOG_LEVEL_INFO, Logger } from "octagonal-wheels/common/logger";
export type DatabaseDiagnosticKind = "usage" | "integrity";
export type DatabaseDiagnosticHost = NecessaryServicesInterfaces<
@@ -36,6 +37,15 @@ export interface DatabaseDiagnosticOutput {
artifacts: DatabaseDiagnosticArtifact[];
}
export interface DatabaseDiagnosticProgress {
phase: "usage" | "accounting" | "metadata" | "content";
source?: "local" | "remote";
processed: number;
total?: number;
totalIsFinal?: boolean;
elapsedMs: number;
}
export async function readDiagnosticStorage(
storage: Pick<StorageAccess, "statHidden" | "readHiddenFileBinary">,
path: string
@@ -59,7 +69,7 @@ export async function readDiagnosticStorage(
) {
return { status: "unreadable", reason: "storage-changed" };
}
return { status: "available", bytes };
return { status: "available", bytes, mtime: after.mtime };
} catch {
return { status: "unreadable", reason: "content-unreadable" };
}
@@ -70,8 +80,13 @@ export async function runDatabaseDiagnostic(
host: DatabaseDiagnosticHost,
kind: DatabaseDiagnosticKind,
nameMode: DiagnosticNameMode,
options: { signal?: AbortSignal; onProgress?(processed: number): void } = {}
options: {
signal?: AbortSignal;
onProgress?(progress: DatabaseDiagnosticProgress): void;
includeRemote?: boolean;
} = {}
): Promise<DatabaseDiagnosticOutput> {
const started = performance.now();
const configuration = JSON.stringify(host.services.setting.currentSettings());
const settings = JSON.parse(configuration) as ObsidianLiveSyncSettings;
const database = host.services.database.localDatabase.localDatabase;
@@ -79,6 +94,12 @@ export async function runDatabaseDiagnostic(
JSON.stringify(host.services.setting.currentSettings()) === configuration &&
host.services.database.localDatabase.localDatabase === database;
const mapper = createDiagnosticNameMapper(nameMode);
const httpRequestCounters = { httpRequests: 0, httpFailedRequests: 0, httpRequestDurationMs: 0 };
const getHttpRequestCounters = () => ({
httpRequests: httpRequestCounters.httpRequests,
httpFailedRequests: httpRequestCounters.httpFailedRequests,
httpRequestDurationMs: Math.round(httpRequestCounters.httpRequestDurationMs),
});
const versions = {
commonlib: commonlibPackage.version,
plugin: host.services.API.getPluginVersion(),
@@ -89,7 +110,14 @@ export async function runDatabaseDiagnostic(
const result = await analyseDatabaseUsage({
source: local,
signal: options.signal,
onProgress: (progress) => options.onProgress?.(progress.revisionsProcessed),
onProgress: (progress) =>
options.onProgress?.({
phase: progress.phase === "accounting" ? "accounting" : "usage",
processed: progress.processed,
total: progress.total,
totalIsFinal: progress.totalIsFinal,
elapsedMs: Math.round(performance.now() - started),
}),
});
if (!isConfigurationCurrent()) {
result.errors.push({ code: "configuration_changed" });
@@ -110,23 +138,41 @@ export async function runDatabaseDiagnostic(
}
return { status: result.status, nameMode, artifacts };
}
const result = await analyseDatabaseIntegrity({
const includeRemote = options.includeRemote !== false;
const integrityOptions: Parameters<typeof analyseDatabaseIntegrity>[0] = {
local,
remote:
settings.remoteType === REMOTE_COUCHDB
includeRemote && settings.remoteType === REMOTE_COUCHDB
? () =>
Promise.resolve(
createCouchDBDiagnosticSource({
settings,
signal: options.signal,
fetch: (request, requestOptions) =>
settings.useRequestAPI
? host.services.API.nativeFetch(request, requestOptions)
: host.services.API.webCompatFetch(request, requestOptions),
fetch: async (request, requestOptions) => {
const requestStarted = performance.now();
httpRequestCounters.httpRequests++;
try {
const response = settings.useRequestAPI
? await host.services.API.nativeFetch(request, requestOptions)
: await host.services.API.webCompatFetch(request, requestOptions);
if (!response.ok) httpRequestCounters.httpFailedRequests++;
return response;
} catch (error) {
httpRequestCounters.httpFailedRequests++;
throw error;
} finally {
httpRequestCounters.httpRequestDurationMs += performance.now() - requestStarted;
}
},
})
)
: undefined,
remoteUnavailableReason: settings.remoteType === REMOTE_COUCHDB ? "source-unavailable" : "unsupported-remote",
remoteUnavailableReason: includeRemote
? settings.remoteType === REMOTE_COUCHDB
? "source-unavailable"
: "unsupported-remote"
: undefined,
includeRemote,
path: {
getPath: (entry) => host.services.path.getPath(entry),
path2id: (path, prefix) => host.services.path.path2idWithSettings(path, settings, prefix),
@@ -141,10 +187,17 @@ export async function runDatabaseDiagnostic(
readStorage: (path) => readDiagnosticStorage(host.serviceModules.storageAccess, path),
isConfigurationCurrent,
signal: options.signal,
onProgress: (progress) => options.onProgress?.(progress.processed),
});
onProgress: (progress) => options.onProgress?.(progress),
onLog: (event) =>
Logger(
`Database integrity inspection: ${JSON.stringify({ ...event, ...getHttpRequestCounters() })}`,
LOG_LEVEL_INFO
),
};
const result = await analyseDatabaseIntegrity(integrityOptions);
const formattingStarted = performance.now();
const report = projectIntegrityReport(result, mapper, versions);
return {
const output: DatabaseDiagnosticOutput = {
status: result.status,
nameMode,
artifacts: [
@@ -160,4 +213,13 @@ export async function runDatabaseDiagnostic(
},
],
};
const requestCounters = getHttpRequestCounters();
Logger(
`Database integrity report: status=${result.status}, observations=${result.observations.length}, ` +
`httpRequests=${requestCounters.httpRequests}, httpFailedRequests=${requestCounters.httpFailedRequests}, ` +
`httpRequestDurationMs=${requestCounters.httpRequestDurationMs}, ` +
`formattingMs=${Math.round(performance.now() - formattingStarted)}, elapsedMs=${Math.round(performance.now() - started)}`,
LOG_LEVEL_INFO
);
return output;
}
@@ -1,12 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, REMOTE_P2P } from "@vrtmrz/livesync-commonlib/compat/common/types";
import {
DEFAULT_SETTINGS,
REMOTE_COUCHDB,
REMOTE_P2P,
type ObsidianLiveSyncSettings,
} from "@vrtmrz/livesync-commonlib/compat/common/types";
const diagnosticsMocks = vi.hoisted(() => ({ analyseOptions: vi.fn() }));
vi.mock("@vrtmrz/livesync-commonlib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("@vrtmrz/livesync-commonlib/diagnostics")>();
return {
...actual,
analyseDatabaseIntegrity: (options: Parameters<typeof actual.analyseDatabaseIntegrity>[0]) => {
diagnosticsMocks.analyseOptions(options);
return actual.analyseDatabaseIntegrity(options);
},
};
});
import { readDiagnosticStorage, runDatabaseDiagnostic, type DatabaseDiagnosticHost } from "./databaseDiagnostics";
function fixture() {
const path = "Private/日本語.md";
const body = "private file contents";
const bytes = new TextEncoder().encode(body);
const settings = {
const settings: ObsidianLiveSyncSettings = {
...DEFAULT_SETTINGS,
remoteType: REMOTE_P2P,
readChunksOnline: true,
@@ -81,6 +100,41 @@ function fixture() {
}
describe("database diagnostic host composition", () => {
it("passes growing Usage totals and the separate accounting phase to the dialogue", async () => {
const onProgress = vi.fn();
await runDatabaseDiagnostic(fixture().host, "usage", "pseudonym", { onProgress });
const progress = onProgress.mock.calls.map(([value]) => value);
expect(progress).toContainEqual(
expect.objectContaining({
phase: "usage",
processed: 0,
total: 0,
totalIsFinal: false,
})
);
expect(progress).toContainEqual(
expect.objectContaining({
phase: "usage",
processed: 2,
total: 2,
totalIsFinal: true,
})
);
const accounting = progress.filter((value) => value.phase === "accounting");
expect(accounting.length).toBeGreaterThan(1);
const final = accounting[accounting.length - 1];
expect(final).toMatchObject({ processed: final.total, totalIsFinal: true });
});
it("preserves phase, source, and totals for the inspection dialogue", async () => {
const onProgress = vi.fn();
await runDatabaseDiagnostic(fixture().host, "integrity", "pseudonym", { onProgress });
expect(onProgress).toHaveBeenCalledWith(
expect.objectContaining({ phase: "metadata", source: "local", processed: 0 })
);
expect(onProgress).toHaveBeenCalledWith(expect.objectContaining({ phase: "content", processed: 1, total: 1 }));
});
it("keeps on-demand fetching enabled while analysing only existing local revisions", async () => {
const data = fixture();
const before = JSON.stringify(data.settings);
@@ -113,6 +167,9 @@ describe("database diagnostic host composition", () => {
it("keeps local findings when the remote type is unsupported and projects both formats consistently", async () => {
const data = fixture();
const result = await runDatabaseDiagnostic(data.host, "integrity", "pseudonym");
expect(diagnosticsMocks.analyseOptions).toHaveBeenLastCalledWith(
expect.objectContaining({ includeRemote: true })
);
expect(result.status).toBe("partial");
expect(data.fetch).not.toHaveBeenCalled();
expect(data.put).not.toHaveBeenCalled();
@@ -135,6 +192,82 @@ describe("database diagnostic host composition", () => {
}
});
it("excludes the remote source for a local integrity run", async () => {
const data = fixture();
data.settings.remoteType = REMOTE_COUCHDB;
data.settings.couchDB_DBNAME = "private-database";
data.settings.useRequestAPI = true;
data.fetch
.mockImplementationOnce(async () => {
await new Promise((resolve) => setTimeout(resolve, 2));
return { ok: false, status: 503 } as Response;
})
.mockRejectedValue(new Error("simulated diagnostic request failure"));
diagnosticsMocks.analyseOptions.mockClear();
const result = await runDatabaseDiagnostic(data.host, "integrity", "pseudonym", {
includeRemote: false,
});
expect(data.fetch).not.toHaveBeenCalled();
const passedOptions = diagnosticsMocks.analyseOptions.mock.calls[0][0];
expect(passedOptions.includeRemote).toBe(false);
expect(passedOptions.remote).toBeUndefined();
expect(result.artifacts.map((artifact) => artifact.format)).toEqual(["markdown", "json"]);
const report = JSON.parse(result.artifacts.find((artifact) => artifact.format === "json")!.content);
expect(report.scope.remote).toBe("excluded");
expect(report.coverage.remote.status).toBe("not-checked");
expect(report.errors.some((error: { source: string }) => error.source === "remote")).toBe(false);
});
it("logs cumulative HTTP dispatch, failure, and duration counters for remote inspection", async () => {
const log = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const nonOk = fixture();
nonOk.settings.remoteType = REMOTE_COUCHDB;
nonOk.settings.couchDB_DBNAME = "private-database";
nonOk.settings.useRequestAPI = true;
nonOk.fetch.mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 2));
return { ok: false, status: 503 } as Response;
});
await runDatabaseDiagnostic(nonOk.host, "integrity", "pseudonym");
const thrown = fixture();
thrown.settings.remoteType = REMOTE_COUCHDB;
thrown.settings.couchDB_DBNAME = "private-database";
thrown.settings.useRequestAPI = true;
thrown.fetch.mockRejectedValue(new Error("simulated diagnostic request failure"));
await runDatabaseDiagnostic(thrown.host, "integrity", "pseudonym");
const messages = log.mock.calls.map(([message]) => String(message));
const inspectionLogs = messages.filter((message) => message.includes("Database integrity inspection:"));
expect(inspectionLogs.length).toBeGreaterThan(0);
const inspectionEvents = inspectionLogs.map((message) => {
const json = message.slice(message.indexOf("{"));
return JSON.parse(json);
});
expect(inspectionEvents.some((event) => event.httpRequests > 0)).toBe(true);
expect(inspectionEvents.some((event) => event.httpFailedRequests > 0)).toBe(true);
expect(inspectionEvents.every((event) => typeof event.httpRequestDurationMs === "number")).toBe(true);
const summaries = messages.filter((message) => message.includes("Database integrity report:"));
expect(summaries).toHaveLength(2);
for (const [index, summary] of summaries.entries()) {
const match = /httpRequests=(\d+), httpFailedRequests=(\d+), httpRequestDurationMs=(\d+)/.exec(summary);
expect(match).not.toBeNull();
const data = index === 0 ? nonOk : thrown;
expect(Number(match![1])).toBe(data.fetch.mock.calls.length);
expect(Number(match![2])).toBe(data.fetch.mock.calls.length);
expect(Number(match![3])).toBeGreaterThanOrEqual(0);
if (index === 0) expect(Number(match![3])).toBeGreaterThan(0);
}
} finally {
log.mockRestore();
}
});
it("includes names only when requested and never includes credentials or file contents", async () => {
const data = fixture();
const result = await runDatabaseDiagnostic(data.host, "usage", "original");
@@ -172,6 +305,17 @@ describe("database diagnostic host composition", () => {
});
describe("diagnostic Vault reads", () => {
it("reuses the validated snapshot timestamp without adding stat or content reads", async () => {
const data = fixture();
expect(await readDiagnosticStorage(data.storage, data.path)).toEqual({
status: "available",
bytes: data.bytes,
mtime: 1,
});
expect(data.storage.statHidden).toHaveBeenCalledTimes(2);
expect(data.storage.readHiddenFileBinary).toHaveBeenCalledTimes(1);
});
it("distinguishes an absent file, a read error, and a file changing during its read", async () => {
const data = fixture();
data.storage.statHidden.mockResolvedValueOnce(null as never);
+346
View File
@@ -0,0 +1,346 @@
import { $msg } from "@/common/translation";
import { delay } from "octagonal-wheels/promises";
import { createDiagnosticNameMapper, parseDiagnosticPathToken } from "@vrtmrz/livesync-commonlib/diagnostics";
import { EntryTypes, type FilePath, type FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ICHeader, ICXHeader, PSCHeader } from "@vrtmrz/livesync-commonlib/compat/common/models/fileaccess.const";
import { stripAllPrefixes } from "@vrtmrz/livesync-commonlib/compat/string_and_binary/path";
import type { ComponentHasResult } from "@/modules/services/LiveSyncUI/svelteDialog";
import DiagnosticFileLookupDialog from "@/modules/services/LiveSyncUI/dialogues/DiagnosticFileLookupDialog.svelte";
import type {
FileDatabaseInfo,
FileDatabaseInfoCore,
FileDatabaseMergeBaseInfo,
InspectFileDatabaseInfoOptions,
RevisionDatabaseInfo,
} from "./fileDatabaseInfo";
import { inspectFileDatabaseInfo } from "./fileDatabaseInfo";
import type {
DiagnosticFileCandidate,
DiagnosticFileLookupDialogOptions,
DiagnosticFileLookupResult,
FindDiagnosticFilesOptions,
} from "./diagnosticFileLookupTypes";
import type { DatabaseDiagnosticArtifact, DatabaseDiagnosticOutput } from "./databaseDiagnostics";
type CurrentFileMetadata = {
_id: string;
path: FilePathWithPrefix | FilePath;
type: string;
_deleted?: boolean;
};
type FileLookupDialogManager = {
open<TResult, TInitial = TResult>(
component: ComponentHasResult<TResult, TInitial>,
initialData?: TInitial
): Promise<TResult | undefined>;
};
type FileLookupUI = FileDatabaseInfoCore["services"]["UI"] & {
dialogManager: FileLookupDialogManager;
};
function isCurrentFileMetadata(value: unknown): value is CurrentFileMetadata {
if (value === null || typeof value !== "object") return false;
const entry = value as Record<string, unknown>;
if (
typeof entry._id !== "string" ||
typeof entry.path !== "string" ||
(entry.type !== EntryTypes.NOTE_BINARY && entry.type !== EntryTypes.NOTE_PLAIN) ||
entry._deleted === true
) {
return false;
}
const path = entry.path;
return ![ICHeader, ICXHeader, PSCHeader].some((prefix) => path.startsWith(prefix));
}
function cancelledResult(processed: number, candidates: DiagnosticFileCandidate[]): DiagnosticFileLookupResult {
return { valid: true, status: "cancelled", processed, candidates };
}
async function yieldToEventLoop(): Promise<void> {
await delay(0);
}
export async function findDiagnosticFiles(
core: FileDatabaseInfoCore,
token: string,
options: FindDiagnosticFilesOptions = {}
): Promise<DiagnosticFileLookupResult> {
const parsedToken = parseDiagnosticPathToken(token);
if (!parsedToken) {
return { valid: false, status: "invalid", processed: 0, candidates: [] };
}
const mapper = createDiagnosticNameMapper("pseudonym", parsedToken.salt);
const candidates: DiagnosticFileCandidate[] = [];
let processed = 0;
const reportProgress = () => options.onProgress?.(processed);
reportProgress();
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
const vaultPaths = new Set(await core.storageAccess.getFileNames());
const matchingMetadataPaths = new Set<string>();
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
for await (const entry of core.localDatabase.findAllDocs()) {
processed++;
if (isCurrentFileMetadata(entry)) {
const databasePath = entry.path;
const path = stripAllPrefixes(databasePath);
if (mapper.path(path) === token) {
matchingMetadataPaths.add(path);
candidates.push({
path,
databasePath,
documentId: entry._id,
storageExists: vaultPaths.has(path),
source: "metadata",
});
}
}
if (processed % 128 === 0) {
reportProgress();
await yieldToEventLoop();
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
}
}
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
for (const path of vaultPaths) {
processed++;
if (mapper.path(path) === token && !matchingMetadataPaths.has(path)) {
candidates.push({ path, storageExists: true, source: "vault" });
}
if (processed % 128 === 0) {
reportProgress();
await yieldToEventLoop();
if (options.signal?.aborted) {
reportProgress();
return cancelledResult(processed, candidates);
}
}
}
reportProgress();
if (options.signal?.aborted) return cancelledResult(processed, candidates);
return { valid: true, status: "complete", processed, candidates };
}
function finiteMetric(value: number | undefined, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function nonnegativeMetric(value: number | undefined): number {
return Math.max(0, finiteMetric(value));
}
function revisionStatus(value: string): "available" | "missing" | "unknown" {
if (value === "available" || value === "missing") return value;
return "unknown";
}
function storageType(value: string): "absent" | "notes" | "newnote" | "plain" | "unknown" {
if (value === "absent" || value === "notes" || value === "newnote" || value === "plain") return value;
return "unknown";
}
function localChunkState(value: string): "available" | "deleted" | "missing" | "unknown" {
if (value === "available" || value === "deleted" || value === "missing") return value;
return "unknown";
}
function projectRevisionInfo(info: RevisionDatabaseInfo, mapper: ReturnType<typeof createDiagnosticNameMapper>) {
return {
documentId: mapper.id(info.documentId),
revision: info.revision === null ? null : mapper.revision(info.revision),
current: info.current,
deleted: info.deleted,
storageType: storageType(info.storageType),
storageLayout: info.storageLayout === "legacy-inline" ? "legacy-inline" : "chunked",
ctime: finiteMetric(info.ctime),
mtime: finiteMetric(info.mtime),
recordedSize: nonnegativeMetric(info.recordedSize),
revisionHistory: info.revisionHistory.map(({ revision, status }) => ({
revision: mapper.revision(revision),
status: revisionStatus(status),
})),
chunkReferences: nonnegativeMetric(info.chunkReferences),
uniqueChunkReferences: nonnegativeMetric(info.uniqueChunkReferences),
embeddedChunkReferences: nonnegativeMetric(info.embeddedChunkReferences),
locallyStoredChunkReferences: nonnegativeMetric(info.locallyStoredChunkReferences),
contentAvailableLocally: info.contentAvailableLocally,
chunks: info.chunks.map((chunk) => ({
id: mapper.chunk(chunk.id),
referenceCount: nonnegativeMetric(chunk.referenceCount),
embedded: chunk.embedded,
storedInLocalDatabase: chunk.storedInLocalDatabase,
localDatabaseState: localChunkState(chunk.localDatabaseState),
localDatabaseRevision:
chunk.localDatabaseRevision === null ? null : mapper.revision(chunk.localDatabaseRevision),
})),
};
}
function projectMergeBase(info: FileDatabaseMergeBaseInfo, mapper: ReturnType<typeof createDiagnosticNameMapper>) {
return {
winnerRevision: mapper.revision(info.winnerRevision),
conflictRevision: mapper.revision(info.conflictRevision),
revision: info.revision === null ? null : mapper.revision(info.revision),
metadataAvailableLocally: info.metadataAvailableLocally,
contentAvailableLocally: info.contentAvailableLocally,
missingChunkIds: info.missingChunkIds.map((id) => mapper.chunk(id)),
unavailableSharedRevisions: info.unavailableSharedRevisions.map((revision) => mapper.revision(revision)),
};
}
function projectStorage(info: FileDatabaseInfo["storage"]) {
return {
exists: info.exists,
...(info.ctime === undefined ? {} : { ctime: finiteMetric(info.ctime) }),
...(info.mtime === undefined ? {} : { mtime: finiteMetric(info.mtime) }),
...(info.size === undefined ? {} : { size: nonnegativeMetric(info.size) }),
};
}
function projectFileInfo(
info: FileDatabaseInfo,
source: DiagnosticFileCandidate["source"],
mapper: ReturnType<typeof createDiagnosticNameMapper>
) {
return {
path: mapper.path(info.path),
databasePath: mapper.path(info.databasePath),
source,
storage: projectStorage(info.storage),
database: {
source: "local database on this device" as const,
remoteQueried: false as const,
inspected: true,
exists: info.database.exists,
...(info.database.documentId === undefined ? {} : { documentId: mapper.id(info.database.documentId) }),
currentRevision:
info.database.currentRevision === null ? null : mapper.revision(info.database.currentRevision),
conflictCount: nonnegativeMetric(info.database.conflictCount),
conflictRevisions: info.database.conflictRevisions.map((revision) => mapper.revision(revision)),
unavailableConflictRevisions: info.database.unavailableConflictRevisions.map((revision) =>
mapper.revision(revision)
),
revisions: info.database.revisions.map((revision) => projectRevisionInfo(revision, mapper)),
mergeBases: info.database.mergeBases.map((mergeBase) => projectMergeBase(mergeBase, mapper)),
},
};
}
async function inspectVaultOnlyCandidate(
core: FileDatabaseInfoCore,
candidate: DiagnosticFileCandidate,
mapper: ReturnType<typeof createDiagnosticNameMapper>,
signal?: AbortSignal
) {
const exists = await core.storageAccess.isExistsIncludeHidden(candidate.path);
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
const stat = exists ? await core.storageAccess.statHidden(candidate.path) : null;
return {
path: mapper.path(candidate.path),
source: "vault" as const,
storage: projectStorage(
stat ? { exists: true, ctime: stat.ctime, mtime: stat.mtime, size: stat.size } : { exists: false }
),
database: {
source: "local database on this device" as const,
remoteQueried: false as const,
inspected: false,
reason: "no-local-target" as const,
},
};
}
export async function buildPseudonymousFileReport(
core: FileDatabaseInfoCore,
candidate: DiagnosticFileCandidate,
salt: string,
signal?: AbortSignal
): Promise<DatabaseDiagnosticOutput> {
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
const mapper = createDiagnosticNameMapper("pseudonym", salt);
if (!mapper.salt) throw new Error("Invalid diagnostic path token");
if (candidate.source === "metadata" && candidate.documentId === undefined) {
throw new Error("A Metadata candidate requires its exact document ID");
}
const info =
candidate.source === "metadata" && candidate.documentId !== undefined
? await inspectFileDatabaseInfo(core, candidate.path, {
documentId: candidate.documentId,
signal,
...(candidate.databasePath === undefined ? {} : { databasePath: candidate.databasePath }),
} satisfies InspectFileDatabaseInfoOptions)
: undefined;
const file = info
? projectFileInfo(info, candidate.source, mapper)
: await inspectVaultOnlyCandidate(core, candidate, mapper, signal);
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
const report = {
schemaVersion: 1,
kind: "file",
status: "complete",
nameMode: "pseudonym",
salt: mapper.salt,
scope: {
source: "local database on this device",
remoteQueried: false,
vaultContentRead: false,
fileContents: "omitted",
revisions: "current revision, current conflict revisions, and selected merge-base revisions",
revisionHistory: "status metadata is listed for current and conflict revisions where available",
historicalFileBodiesScanned: false,
},
file,
};
const json = JSON.stringify(report, null, 2);
const markdown = `<details>\n<summary>${$msg("Local file diagnostic report")}</summary>\n\n\`\`\`json\n${json}\n\`\`\`\n</details>`;
const artifacts: DatabaseDiagnosticArtifact[] = [
{ format: "json", filename: "diagnostic-file-report.json", content: json },
{ format: "markdown", filename: "diagnostic-file-report.md", content: markdown },
];
return { status: "complete", nameMode: "pseudonym", artifacts };
}
export async function showDiagnosticFileLookup(
core: FileDatabaseInfoCore,
openFile?: (path: string) => Promise<void> | void
): Promise<void> {
let activeController: AbortController | undefined;
const options: DiagnosticFileLookupDialogOptions = {
find: async (token, onProgress) => {
activeController?.abort();
activeController = new AbortController();
return await findDiagnosticFiles(core, token, { signal: activeController.signal, onProgress });
},
prepareReport: async (candidate, token) => {
const parsed = parseDiagnosticPathToken(token);
if (!parsed) throw new Error("Invalid diagnostic path token");
activeController?.abort();
activeController = new AbortController();
return await buildPseudonymousFileReport(core, candidate, parsed.salt, activeController.signal);
},
openFile,
cancel: () => activeController?.abort(),
};
const ui = core.services.UI as FileLookupUI;
try {
await ui.dialogManager.open(DiagnosticFileLookupDialog, options);
} finally {
activeController?.abort();
}
}
@@ -0,0 +1,362 @@
import { createDiagnosticNameMapper } from "@vrtmrz/livesync-commonlib/diagnostics";
import { describe, expect, it, vi } from "vitest";
import { buildPseudonymousFileReport, findDiagnosticFiles, showDiagnosticFileLookup } from "./diagnosticFileLookup";
import type { DiagnosticFileLookupDialogOptions } from "./diagnosticFileLookupTypes";
type TestDocument = {
_id: string;
_rev?: string;
_conflicts?: string[];
_revs_info?: Array<{ rev: string; status: string }>;
_deleted?: boolean;
children?: string[];
ctime?: number;
data?: string;
deleted?: boolean;
eden?: Record<string, unknown>;
mtime?: number;
path?: string;
size?: number;
type?: string;
};
async function* entries(documents: TestDocument[]) {
for (const document of documents) yield document;
}
function createCore(vaultPaths: string[] = [], documents: TestDocument[] = []) {
const existingPaths = new Set(vaultPaths);
const chunkRevisions = new Map([["h:secret-chunk", "1-secret-chunk-revision"]]);
const core = {
settings: {},
storageAccess: {
getFileNames: vi.fn(async () => [...vaultPaths]),
getFilesIncludeHidden: vi.fn(async () => [...vaultPaths]),
isExistsIncludeHidden: vi.fn(async (path: string) => existingPaths.has(path)),
statHidden: vi.fn(async (path: string) =>
existingPaths.has(path) ? { ctime: 10, mtime: 20, size: 30, type: "file" } : undefined
),
},
localDatabase: {
allDocsRaw: vi.fn(async ({ keys }: { keys: string[] }) => ({
rows: keys.flatMap((key) => {
const rev = chunkRevisions.get(key);
return rev ? [{ id: key, key, value: { rev } }] : [];
}),
})),
findAllDocs: vi.fn(() => entries(documents)),
getDBEntryFromMeta: vi.fn(),
getDBEntry: vi.fn(),
localDatabase: {
get: vi.fn(async (id: string) => {
const document = documents.find(({ _id }) => _id === id);
if (document) return document;
throw Object.assign(new Error("missing"), { status: 404 });
}),
},
},
services: {
path: { path2id: vi.fn(async () => "f:path-derived") },
UI: {
promptCopyToClipboard: vi.fn(async () => true),
dialogManager: { open: vi.fn() },
},
},
};
return core;
}
function diagnosticPath(path: string, salt = "salt1234"): string {
return createDiagnosticNameMapper("pseudonym", salt).path(path);
}
describe("diagnostic file lookup", () => {
it.each(["metadata", "chunks"] as const)(
"stops follow-up reads when the dialogue closes during a %s read",
async (pendingRead) => {
const path = "cancelled.md";
const selected = {
_id: "f:cancelled",
_rev: "3-current",
_conflicts: ["3-conflict"],
path,
type: "plain",
children: ["h:secret-chunk"],
};
const core = createCore([path], [selected]);
let markStarted!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
let releaseRead!: () => void;
const pending = new Promise<void>((resolve) => {
releaseRead = resolve;
});
if (pendingRead === "metadata") {
core.localDatabase.localDatabase.get.mockImplementationOnce(async () => {
markStarted();
await pending;
return selected;
});
} else {
core.localDatabase.allDocsRaw.mockImplementationOnce(async () => {
markStarted();
await pending;
return { rows: [] };
});
}
core.services.UI.dialogManager.open.mockImplementation(async (...args: unknown[]) => {
const options = args[1] as DiagnosticFileLookupDialogOptions;
const preparation = options.prepareReport(
{ path, documentId: selected._id, storageExists: true, source: "metadata" },
diagnosticPath(path)
);
await started;
const cancelled = expect(preparation).rejects.toMatchObject({ name: "AbortError" });
options.cancel();
releaseRead();
await cancelled;
});
await showDiagnosticFileLookup(core as never);
expect(core.localDatabase.localDatabase.get).toHaveBeenCalledTimes(1);
expect(core.localDatabase.allDocsRaw).toHaveBeenCalledTimes(pendingRead === "metadata" ? 0 : 1);
}
);
it("rejects an invalid path token before reading storage or the database", async () => {
const core = createCore(["note.md"], [{ _id: "f:note", path: "note.md", type: "plain" }]);
await expect(findDiagnosticFiles(core as never, "not-a-path-token")).resolves.toEqual({
valid: false,
status: "invalid",
processed: 0,
candidates: [],
});
expect(core.storageAccess.getFileNames).not.toHaveBeenCalled();
expect(core.localDatabase.findAllDocs).not.toHaveBeenCalled();
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
});
it("matches path spelling exactly without case folding or Unicode normalisation", async () => {
const decomposed = "Cafe\u0301.md";
const core = createCore(
[decomposed, "Café.md", "CAFÉ.md"],
[
{ _id: "f:decomposed", path: decomposed, type: "plain" },
{ _id: "f:composed", path: "Café.md", type: "plain" },
{ _id: "f:case", path: "CAFÉ.md", type: "plain" },
]
);
const progressCounts: number[] = [];
const result = await findDiagnosticFiles(core as never, diagnosticPath(decomposed), {
onProgress: (processed) => progressCounts.push(processed),
});
expect(result).toMatchObject({ valid: true, status: "complete" });
expect(result.candidates).toEqual([
expect.objectContaining({
path: decomposed,
documentId: "f:decomposed",
source: "metadata",
storageExists: true,
}),
]);
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
expect(progressCounts).toEqual([0, 6]);
});
it("keeps distinct Metadata documents with the same exact path as separate candidates", async () => {
const path = "duplicate.md";
const core = createCore(
[path],
[
{ _id: "f:duplicate-one", path, type: "plain" },
{ _id: "f:duplicate-two", path, type: "plain" },
]
);
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
expect(result.candidates).toEqual([
expect.objectContaining({ documentId: "f:duplicate-one", path, storageExists: true, source: "metadata" }),
expect.objectContaining({ documentId: "f:duplicate-two", path, storageExists: true, source: "metadata" }),
]);
expect(result.candidates).toHaveLength(2);
});
it("keeps logically deleted current file Metadata as an exact candidate", async () => {
const path = "vault-only.md";
const core = createCore(
[path],
[
{ _id: "f:deleted", path, type: "plain", deleted: true },
{ _id: "f:chunk", path, type: "chunk" },
{ _id: "i:internal", path: "i:.obsidian/config.json", type: "plain" },
]
);
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
expect(result.candidates).toEqual([
expect.objectContaining({ path, documentId: "f:deleted", storageExists: true, source: "metadata" }),
]);
});
it("returns a Vault-only candidate when there is no current normal-file Metadata", async () => {
const path = "vault-only.md";
const core = createCore(
[path],
[
{ _id: "f:chunk", path, type: "chunk" },
{ _id: "f:legacy", path, type: "notes" },
{ _id: "i:internal", path: "i:.obsidian/config.json", type: "plain" },
]
);
const result = await findDiagnosticFiles(core as never, diagnosticPath(path));
expect(result.candidates).toEqual([expect.objectContaining({ path, storageExists: true, source: "vault" })]);
expect(result.candidates[0]).not.toHaveProperty("documentId");
const report = await buildPseudonymousFileReport(core as never, result.candidates[0], "salt1234");
const jsonText = report.artifacts.find(({ format }) => format === "json")!.content;
const json = JSON.parse(jsonText);
expect(json.file.path).toBe(diagnosticPath(path));
expect(jsonText).not.toContain(path);
expect(json.file.database).toEqual({
source: "local database on this device",
remoteQueried: false,
inspected: false,
reason: "no-local-target",
});
expect(json.scope).toEqual({
source: "local database on this device",
remoteQueried: false,
vaultContentRead: false,
fileContents: "omitted",
revisions: "current revision, current conflict revisions, and selected merge-base revisions",
revisionHistory: "status metadata is listed for current and conflict revisions where available",
historicalFileBodiesScanned: false,
});
expect(core.localDatabase.localDatabase.get).not.toHaveBeenCalled();
});
it("returns partial exact matches when cancellation arrives during enumeration", async () => {
const path = "match.md";
const documents = [
{ _id: "f:match", path, type: "plain" },
...Array.from({ length: 299 }, (_, index) => ({
_id: `f:other-${index}`,
path: `other-${index}.md`,
type: "plain",
})),
];
const core = createCore([], documents);
const controller = new AbortController();
const progressCounts: number[] = [];
const result = await findDiagnosticFiles(core as never, diagnosticPath(path), {
signal: controller.signal,
onProgress: (processed) => {
progressCounts.push(processed);
if (processed >= 128) controller.abort();
},
});
expect(result).toMatchObject({ valid: true, status: "cancelled", processed: 128 });
expect(result.candidates).toEqual([
expect.objectContaining({ documentId: "f:match", path, storageExists: false, source: "metadata" }),
]);
expect(progressCounts).toEqual([0, 128, 128]);
});
it("projects an exact target with the token salt, preserves revision generations, and omits raw values", async () => {
const path = "private-name.md";
const salt = "salt1234";
const token = diagnosticPath(path, salt);
const selected = {
_id: "f:selected-private-id",
_rev: "3-private-revision",
_conflicts: [],
_revs_info: [{ rev: "3-private-revision", status: "private-status-value" }],
path,
ctime: 11,
mtime: 22,
size: 33,
type: "plain",
data: "private file contents",
children: ["h:private-chunk-id"],
eden: {},
unreported: "private-extra-value",
};
const decoy = { ...selected, _id: "f:path-derived", path: "different.md", _rev: "9-decoy-revision" };
const core = createCore([path], [selected, decoy]);
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
if (id === selected._id) return selected;
if (id === decoy._id) return decoy;
throw Object.assign(new Error("missing"), { status: 404 });
});
const candidate = {
path,
databasePath: path as never,
documentId: selected._id,
storageExists: true,
source: "metadata" as const,
};
const output = await buildPseudonymousFileReport(core as never, candidate, salt);
const jsonText = output.artifacts.find(({ format }) => format === "json")!.content;
const json = JSON.parse(jsonText);
const revision = json.file.database.revisions[0];
expect(output.nameMode).toBe("pseudonym");
expect(json.kind).toBe("file");
expect(json.nameMode).toBe("pseudonym");
expect(json.salt).toBe(salt);
expect(json.file.path).toBe(token);
expect(json.scope).toMatchObject({ vaultContentRead: false, fileContents: "omitted" });
expect(revision.documentId).toMatch(/^document-[0-9a-f]{32}$/);
expect(revision.revision).toMatch(/^3-psu[0-9a-f]{32}$/);
expect(revision.chunks[0].id).toMatch(/^chunk-[0-9a-f]{32}$/);
expect(revision.revisionHistory[0].status).toBe("unknown");
expect(json.file.database.inspected).toBe(true);
expect(output.artifacts.map(({ format }) => format)).toEqual(["json", "markdown"]);
expect(output.artifacts.find(({ format }) => format === "markdown")?.content).toContain("```json");
expect(jsonText).not.toContain(path);
expect(jsonText).not.toContain(selected._id);
expect(jsonText).not.toContain("3-private-revision");
expect(jsonText).not.toContain("h:private-chunk-id");
expect(jsonText).not.toContain("private-status-value");
expect(jsonText).not.toContain("private file contents");
expect(jsonText).not.toContain("private-extra-value");
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([selected._id]);
expect(core.services.path.path2id).not.toHaveBeenCalled();
});
it("keeps an exact Metadata target visibly missing without substituting a path-derived document", async () => {
const path = "vanished.md";
const salt = "salt1234";
const selected = { _id: "f:vanished", path, type: "plain" };
const decoy = { _id: "f:path-derived", path: "wrong.md", type: "plain", _rev: "1-decoy" };
const core = createCore([path], [selected, decoy]);
const lookup = await findDiagnosticFiles(core as never, diagnosticPath(path, salt));
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
if (id === decoy._id) return decoy;
throw Object.assign(new Error("missing"), { status: 404 });
});
const output = await buildPseudonymousFileReport(core as never, lookup.candidates[0], salt);
const jsonText = output.artifacts.find(({ format }) => format === "json")!.content;
const json = JSON.parse(jsonText);
expect(json.file.database).toMatchObject({ inspected: true, exists: false });
expect(json.file.database.documentId).toMatch(/^document-[0-9a-f]{32}$/);
expect(jsonText).not.toContain("f:vanished");
expect(jsonText).not.toContain("f:path-derived");
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([selected._id]);
expect(core.services.path.path2id).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import type { FilePath, FilePathWithPrefix } from "@vrtmrz/livesync-commonlib/compat/common/types";
import type { DatabaseDiagnosticOutput } from "./databaseDiagnostics";
export type DiagnosticFileCandidate = {
path: string;
databasePath?: FilePathWithPrefix | FilePath;
documentId?: string;
storageExists: boolean;
source: "metadata" | "vault";
};
export type DiagnosticFileLookupResult =
| {
valid: false;
status: "invalid";
processed: 0;
candidates: [];
}
| {
valid: true;
status: "cancelled" | "complete";
processed: number;
candidates: DiagnosticFileCandidate[];
};
export interface FindDiagnosticFilesOptions {
signal?: AbortSignal;
onProgress?(processed: number): void;
}
export interface DiagnosticFileLookupDialogOptions {
find(token: string, onProgress: (processed: number) => void): Promise<DiagnosticFileLookupResult>;
prepareReport(candidate: DiagnosticFileCandidate, token: string): Promise<DatabaseDiagnosticOutput>;
openFile?(path: string): Promise<void> | void;
cancel(): void;
}
+64 -25
View File
@@ -91,6 +91,7 @@ export type FileDatabaseInfo = {
source: "local database on this device";
remoteQueried: false;
exists: boolean;
documentId?: string;
currentRevision: string | null;
conflictCount: number;
conflictRevisions: string[];
@@ -100,6 +101,12 @@ export type FileDatabaseInfo = {
};
};
export type InspectFileDatabaseInfoOptions = {
documentId?: string;
databasePath?: FilePathWithPrefix | FilePath;
signal?: AbortSignal;
};
const REPORT_WARNING =
"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.";
@@ -125,25 +132,35 @@ type RawDatabaseDocument = {
data?: string | string[];
eden?: Record<string, unknown>;
mtime?: number;
path?: FilePathWithPrefix | FilePath;
size?: number;
type?: string;
};
function checkInspectionCancelled(signal?: AbortSignal): void {
if (signal?.aborted) throw new DOMException("File inspection cancelled", "AbortError");
}
async function getLocalDatabaseMeta(
core: FileDatabaseInfoCore,
path: FilePathWithPrefix | FilePath,
options: PouchDB.Core.GetOptions
options: PouchDB.Core.GetOptions,
target?: InspectFileDatabaseInfoOptions
): Promise<DatabaseMeta | false> {
const documentId = await core.services.path.path2id(path);
checkInspectionCancelled(target?.signal);
const documentId = target?.documentId ?? (await core.services.path.path2id(path));
checkInspectionCancelled(target?.signal);
let raw: RawDatabaseDocument;
try {
raw = await core.localDatabase.localDatabase.get(documentId, options);
} catch (error) {
checkInspectionCancelled(target?.signal);
if (isNotFoundError(error)) {
return false;
}
throw error;
}
checkInspectionCancelled(target?.signal);
if (raw.type === "leaf") {
return false;
@@ -156,13 +173,15 @@ async function getLocalDatabaseMeta(
const legacy = rawStorageType === null || rawStorageType === "notes";
const type = legacy ? "notes" : rawStorageType;
const legacyBodyPresent =
legacy && (typeof raw.data === "string" || (Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string")));
legacy &&
(typeof raw.data === "string" ||
(Array.isArray(raw.data) && raw.data.every((item) => typeof item === "string")));
return {
_id: raw._id,
_rev: raw._rev,
_conflicts: raw._conflicts,
_revs_info: raw._revs_info,
path,
path: target ? (raw.path ?? path) : path,
data: legacyBodyPresent ? raw.data : "",
ctime: raw.ctime ?? 0,
mtime: raw.mtime ?? 0,
@@ -180,8 +199,10 @@ async function getLocalDatabaseMeta(
async function collectRevisionDatabaseInfo(
core: FileDatabaseInfoCore,
meta: DatabaseMeta,
current: boolean
current: boolean,
signal?: AbortSignal
): Promise<RevisionDatabaseInfo> {
checkInspectionCancelled(signal);
const legacy = meta._rawStorageType === null || meta._rawStorageType === "notes";
const children = legacy ? [] : "children" in meta ? meta.children : [];
const uniqueChildren = [...new Set(children)];
@@ -201,6 +222,7 @@ async function collectRevisionDatabaseInfo(
include_docs: false,
})
).rows;
checkInspectionCancelled(signal);
const localChunkStates = new Map(
localRows
.filter((row) => "value" in row)
@@ -236,9 +258,7 @@ async function collectRevisionDatabaseInfo(
locallyStoredChunkReferences: children.filter((id) => localChunkStates.get(id)?.state === "available").length,
contentAvailableLocally: legacy
? meta._legacyBodyPresent
: uniqueChildren.every(
(id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available"
),
: uniqueChildren.every((id) => embeddedChildren.has(id) || localChunkStates.get(id)?.state === "available"),
chunks: uniqueChildren.map((id) => {
const localState = localChunkStates.get(id);
return {
@@ -273,15 +293,27 @@ function missingChunkIds(info: RevisionDatabaseInfo): string[] {
.map(({ id }) => id);
}
export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path: string): Promise<FileDatabaseInfo> {
export async function inspectFileDatabaseInfo(
core: FileDatabaseInfoCore,
path: string,
options?: InspectFileDatabaseInfoOptions
): Promise<FileDatabaseInfo> {
checkInspectionCancelled(options?.signal);
const storageExists = await core.storageAccess.isExistsIncludeHidden(path);
checkInspectionCancelled(options?.signal);
const storageStat = storageExists ? await core.storageAccess.statHidden(path) : null;
const databasePath = toDatabasePath(path);
const currentMeta = await getLocalDatabaseMeta(core, databasePath, {
conflicts: true,
revs: true,
revs_info: true,
});
const requestedDatabasePath = options?.databasePath ?? toDatabasePath(path);
const currentMeta = await getLocalDatabaseMeta(
core,
requestedDatabasePath,
{
conflicts: true,
revs: true,
revs_info: true,
},
options
);
const databasePath = options && currentMeta ? currentMeta.path : requestedDatabasePath;
const revisions: RevisionDatabaseInfo[] = [];
const conflictRevisions = currentMeta === false ? [] : (currentMeta._conflicts ?? []);
@@ -292,25 +324,31 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
metadataByRevision.set(currentMeta._rev, currentMeta);
}
const getRevisionMeta = async (revision: string): Promise<DatabaseMeta | false> => {
checkInspectionCancelled(options?.signal);
const cached = metadataByRevision.get(revision);
if (cached !== undefined) {
return cached;
}
const meta = await getLocalDatabaseMeta(core, databasePath, {
rev: revision,
revs: true,
revs_info: true,
});
const meta = await getLocalDatabaseMeta(
core,
requestedDatabasePath,
{
rev: revision,
revs: true,
revs_info: true,
},
options
);
metadataByRevision.set(revision, meta);
return meta;
};
if (currentMeta) {
revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true));
revisions.push(await collectRevisionDatabaseInfo(core, currentMeta, true, options?.signal));
for (const revision of conflictRevisions) {
const conflictMeta = await getRevisionMeta(revision);
if (conflictMeta) {
revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false));
revisions.push(await collectRevisionDatabaseInfo(core, conflictMeta, false, options?.signal));
const winnerHistory = revisionHistory(currentMeta);
const conflictHistory = revisionHistory(conflictMeta);
const conflictHistoryByRevision = new Map(
@@ -323,13 +361,12 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
const unavailableSharedRevisions = sharedHistory
.filter(
({ revision: historyRevision, status }) =>
status !== "available" ||
conflictHistoryByRevision.get(historyRevision) !== "available"
status !== "available" || conflictHistoryByRevision.get(historyRevision) !== "available"
)
.map(({ revision: historyRevision }) => historyRevision);
const sharedMeta = sharedRevision ? await getRevisionMeta(sharedRevision) : false;
const sharedInfo = sharedMeta
? await collectRevisionDatabaseInfo(core, sharedMeta, false)
? await collectRevisionDatabaseInfo(core, sharedMeta, false, options?.signal)
: undefined;
mergeBases.push({
winnerRevision: currentMeta._rev ?? "",
@@ -346,6 +383,7 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
}
}
checkInspectionCancelled(options?.signal);
const report: FileDatabaseInfo = {
path,
databasePath,
@@ -363,6 +401,7 @@ export async function inspectFileDatabaseInfo(core: FileDatabaseInfoCore, path:
source: "local database on this device",
remoteQueried: false,
exists: currentMeta !== false,
...(options?.documentId !== undefined ? { documentId: options.documentId } : {}),
currentRevision: currentMeta ? (currentMeta._rev ?? null) : null,
conflictCount: conflictRevisions.length,
conflictRevisions,
@@ -198,8 +198,10 @@ describe("file database information", () => {
it("reports the exact shared ancestor and its missing chunks for each conflict", async () => {
const { conflict, core, current } = createCore();
const selectedCurrent = { ...current, _id: "f:selected" };
const selectedConflict = { ...conflict, _id: "f:selected" };
const parent = {
...current,
...selectedCurrent,
_rev: "2-parent",
_conflicts: undefined,
_revs_info: [
@@ -212,7 +214,7 @@ describe("file database information", () => {
core.localDatabase.localDatabase.get.mockImplementation(async (_id: string, options?: { rev?: string }) => {
if (options?.rev === "2-conflict") {
return {
...conflict,
...selectedConflict,
_revs_info: [
{ rev: "2-conflict", status: "available" },
{ rev: "2-parent", status: "available" },
@@ -224,7 +226,7 @@ describe("file database information", () => {
return parent;
}
return {
...current,
...selectedCurrent,
_revs_info: [
{ rev: "3-current", status: "available" },
{ rev: "2-parent", status: "available" },
@@ -233,7 +235,10 @@ describe("file database information", () => {
};
});
const info = await inspectFileDatabaseInfo(core as never, "note.md");
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
documentId: "f:selected",
databasePath: "note.md" as never,
});
expect(info.database.mergeBases).toEqual([
{
@@ -246,6 +251,64 @@ describe("file database information", () => {
unavailableSharedRevisions: ["1-root"],
},
]);
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual([
"f:selected",
"f:selected",
"f:selected",
]);
});
it("inspects the exact selected document when its ID differs from the path-derived ID", async () => {
const { core, current } = createCore();
const selected = {
...current,
_id: "f:selected",
_rev: "4-selected",
_conflicts: [],
path: "stored-name.md",
children: [],
};
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) =>
id === "f:selected" ? selected : current
);
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
documentId: "f:selected",
databasePath: "listed-name.md" as never,
});
expect(info.database.exists).toBe(true);
expect(info.database.revisions).toEqual([
expect.objectContaining({
documentId: "f:selected",
revision: "4-selected",
}),
]);
expect(info.databasePath).toBe("stored-name.md");
expect(info.database.documentId).toBe("f:selected");
expect(core.services.path.path2id).not.toHaveBeenCalled();
});
it("reports a missing exact document without falling back to the path-derived ID", async () => {
const { core, current } = createCore();
core.localDatabase.localDatabase.get.mockImplementation(async (id: string) => {
if (id === "f:missing") {
throw Object.assign(new Error("missing"), { status: 404 });
}
return current;
});
const info = await inspectFileDatabaseInfo(core as never, "note.md", {
documentId: "f:missing",
databasePath: "requested-name.md" as never,
});
expect(info.database.exists).toBe(false);
expect(info.database.revisions).toEqual([]);
expect(info.databasePath).toBe("requested-name.md");
expect(info.database.documentId).toBe("f:missing");
expect(core.localDatabase.localDatabase.get.mock.calls.map(([id]) => id)).toEqual(["f:missing"]);
expect(core.services.path.path2id).not.toHaveBeenCalled();
});
it("does not decode a revision whose chunks are not all available locally", async () => {
@@ -293,13 +356,7 @@ describe("file database information", () => {
await retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict");
expect(core.localDatabase.getDBEntry).toHaveBeenCalledWith(
"note.md",
{ rev: "2-conflict" },
false,
true,
true
);
expect(core.localDatabase.getDBEntry).toHaveBeenCalledWith("note.md", { rev: "2-conflict" }, false, true, true);
});
it("reports the exact revision as locally available after retry recovers its missing chunk", async () => {
@@ -322,14 +379,10 @@ describe("file database information", () => {
: [],
}));
await expect(
retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict")
).resolves.not.toBe(false);
await expect(retryReadFileDatabaseRevision(core as never, "note.md", "2-conflict")).resolves.not.toBe(false);
const information = await inspectFileDatabaseInfo(core as never, "note.md");
expect(
information.database.revisions.find(({ revision }) => revision === "2-conflict")
).toEqual(
expect(information.database.revisions.find(({ revision }) => revision === "2-conflict")).toEqual(
expect.objectContaining({
contentAvailableLocally: true,
chunks: [
@@ -23,11 +23,12 @@ export const useDatabaseDiagnosticsFeature = createServiceFeature((host: Databas
try {
await services.UI.dialogManager.open<"close", DatabaseDiagnosticDialogOptions>(DatabaseDiagnosticDialog, {
kind,
run: async (nameMode, onProgress) => {
run: async (nameMode, onProgress, includeRemote = true) => {
if (!services.database.isDatabaseReady()) throw new Error("local-database-unavailable");
return await runDatabaseDiagnostic(host, kind, nameMode, {
signal: controller.signal,
onProgress,
includeRemote,
});
},
cancel: () => controller.abort(),
@@ -119,4 +119,29 @@ describe("database diagnostics serviceFeature", () => {
data.close();
await opened;
});
it("forwards the per-run remote inspection choice and defaults existing callers to include the remote database", async () => {
vi.mocked(runDatabaseDiagnostic).mockClear();
const data = fixture();
const opened = data.view.open("integrity");
const options = data.openDialog.mock.calls[0][1];
await options.run("pseudonym", () => {}, false);
expect(runDatabaseDiagnostic).toHaveBeenLastCalledWith(
data.host,
"integrity",
"pseudonym",
expect.objectContaining({ includeRemote: false, signal: expect.any(AbortSignal) })
);
await options.run("pseudonym", () => {});
expect(runDatabaseDiagnostic).toHaveBeenLastCalledWith(
data.host,
"integrity",
"pseudonym",
expect.objectContaining({ includeRemote: true, signal: expect.any(AbortSignal) })
);
data.close();
await opened;
});
});