mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-24 01:53:20 +00:00
Polish diagnostic reporting and local pseudonym follow-up
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user