mirror of
https://github.com/vrtmrz/obsidian-livesync.git
synced 2026-10-21 16:52:32 +00:00
Polish diagnostic reporting and local pseudonym follow-up
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user