Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 60 additions & 30 deletions src/fix/fix-executor.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,96 @@
import { App, TFile } from "obsidian";
import type { FixAction } from "../scanner/Issue";
import type { MutationFence } from "./metadata-write-fence";
import { markdownLinks, wikiLinkRanges } from "../utils/markdown-source";

export async function executeFixAction(app: App, action: FixAction): Promise<number> {
export async function executeFixAction(
app: App,
action: FixAction,
fence?: MutationFence,
): Promise<number> {
switch (action.kind) {
case "trash-file":
return trashFiles(app, action.targetPaths);
return trashFiles(app, action.targetPaths, fence);
case "remove-link-text": {
const source = action.targetPaths[0];
if (action.original !== undefined) {
return replaceLinkText(app, source, action.original, action.replacement ?? "");
return replaceLinkText(app, source, action.original, action.replacement ?? "", undefined, fence);
}
return removeLinkText(app, source, action.linkText!);
return removeLinkText(app, source, action.linkText!, fence);
}
default:
return 0;
}
}

async function trashFiles(app: App, paths: string[]): Promise<number> {
async function trashFiles(app: App, paths: string[], fence?: MutationFence): Promise<number> {
let count = 0;
for (const path of paths) {
if (fence && !fence.ready) break;
const file = app.vault.getAbstractFileByPath(path);
if (file) {
await app.fileManager.trashFile(file);
if (file instanceof TFile) {
const ready = fence
? await fence.mutate(file, null, () => app.fileManager.trashFile(file))
: (await app.fileManager.trashFile(file), true);
count++;
if (!ready) break;
}
}
return count;
}

async function removeLinkText(app: App, sourcePath: string, linkText: string): Promise<number> {
return replaceLinkText(app, sourcePath, undefined, "", linkText);
async function removeLinkText(
app: App,
sourcePath: string,
linkText: string,
fence?: MutationFence,
): Promise<number> {
return replaceLinkText(app, sourcePath, undefined, "", linkText, fence);
}

/** Parse once per action, then splice only complete, matching source ranges. */
/**
* Parse once per action, then splice only complete, matching source ranges.
* The atomic read/transform/write happens inside vault.process; when a fence
* is provided, expected content is armed synchronously before the write
* returns so cache events can be correlated with this exact mutation.
*/
async function replaceLinkText(
app: App,
sourcePath: string,
original: string | undefined,
replacement: string,
legacyLinkText?: string,
fence?: MutationFence,
): Promise<number> {
const file = app.vault.getAbstractFileByPath(sourcePath);
if (!(file instanceof TFile)) return 0;
const content = await app.vault.read(file);
const wiki = original === undefined || /^!?\[\[/.test(original);
const ranges = (wiki ? wikiLinkRanges(content) : markdownLinks(content)).filter(({ start, end }) => {
const source = content.slice(start, end);
return original !== undefined
? source === original
: source === `[[${legacyLinkText}]]` || source === `![[${legacyLinkText}]]`;
}).sort((left, right) => left.start - right.start);
let cursor = 0;
let updated = "";
for (const { start, end } of ranges) {
if (start < cursor) continue;
updated += content.slice(cursor, start) + replacement;
cursor = end;
}
updated += content.slice(cursor);
if (updated === content) return 0;
await app.vault.modify(file, updated);
return 1;
if (!(file instanceof TFile) || (fence && !fence.ready)) return 0;
let affectedCount = 0;
const write = async (expectContent: (updated: string) => void) => {
await app.vault.process(file, content => {
const wiki = original === undefined || /^!?\[\[/.test(original);
const ranges = (wiki ? wikiLinkRanges(content) : markdownLinks(content))
.filter(({ start, end }) => {
const source = content.slice(start, end);
return original !== undefined
? source === original
: source === `[[${legacyLinkText}]]` || source === `![[${legacyLinkText}]]`;
}).sort((left, right) => left.start - right.start);
let cursor = 0;
let updated = "";
for (const { start, end } of ranges) {
if (start < cursor) continue;
updated += content.slice(cursor, start) + replacement;
cursor = end;
}
updated += content.slice(cursor);
if (updated !== content) {
affectedCount = 1;
expectContent(updated);
}
return updated;
});
};
if (fence) await fence.mutate(file, undefined, write);
else await write(() => {});
return affectedCount;
}
39 changes: 35 additions & 4 deletions src/fix/fix-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@ import {
isBlockedFromExecution,
type FixDecision,
} from "./fix-decisions";
import { METADATA_NOT_READY } from "./metadata-write-fence";

export type FixRunnerDependencies = {
/** Read live settings once; the batch clones and freezes the value for every scan. */
settings: () => InspectorSettings;
/** Receives a clone of the frozen settings on every call (preflights + final verification). */
scan: (settings: InspectorSettings) => Promise<ScanResult | null>;
execute: (action: FixAction) => Promise<number>;
/** Executors may return a number (legacy) or a result carrying verification readiness. */
execute: (action: FixAction) => Promise<number | FixExecutionResult>;
/** Optional batch-wide guard; false stops all further scans and mutations. */
canScan?: () => boolean;
};

export type FixExecutionResult = {
affectedCount: number;
verificationReady: boolean;
verificationMessage?: string;
};

export type FixBatchResult = {
Expand All @@ -33,7 +43,13 @@ export async function runFixBatch(
dependencies: FixRunnerDependencies,
): Promise<FixBatchResult> {
const frozenSettings = structuredClone(dependencies.settings());
const scanOnce = () => dependencies.scan(structuredClone(frozenSettings));
// Set when a write succeeded but its cache synchronization could not be
// confirmed: later preflights would read stale metadata and must not run.
let verificationProblem: string | undefined;
const scanOnce = () => {
if (verificationProblem || dependencies.canScan?.() === false) return Promise.resolve(null);
return dependencies.scan(structuredClone(frozenSettings));
};

const decisionsByFingerprint = new Map(
decisions.map((decision) => [decision.fingerprint, decision]),
Expand All @@ -43,6 +59,10 @@ export async function runFixBatch(
let scannedDuringBatch = false;

for (const [index, issue] of issues.entries()) {
if (verificationProblem || dependencies.canScan?.() === false) {
outcomes[index] = skipped(issue, METADATA_NOT_READY);
continue;
}
if (isBlockedFromExecution(issue)) {
outcomes[index] = skipped(
issue,
Expand Down Expand Up @@ -86,12 +106,19 @@ export async function runFixBatch(
}

try {
const raw = await dependencies.execute(freshAction);
const execution = typeof raw === "number"
? { affectedCount: raw, verificationReady: true }
: raw;
pending.push({
index,
fingerprint: issue.fingerprint,
affectedPaths: [...freshAction.targetPaths],
affectedCount: await dependencies.execute(freshAction),
affectedCount: execution.affectedCount,
});
if (!execution.verificationReady) {
verificationProblem = execution.verificationMessage ?? METADATA_NOT_READY;
}
} catch (error) {
outcomes[index] = {
fingerprint: issue.fingerprint,
Expand All @@ -114,7 +141,11 @@ export async function runFixBatch(
fingerprint: action.fingerprint,
outcome: "failed",
phase: "verification",
message: "The final verification scan did not complete.",
message: verificationProblem ?? (
dependencies.canScan?.() === false
? METADATA_NOT_READY
: "The final verification scan did not complete."
),
affectedPaths: action.affectedPaths,
};
}
Expand Down
97 changes: 97 additions & 0 deletions src/fix/metadata-write-fence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { App, EventRef, TFile } from "obsidian";

export type MutationFence = {
readonly ready: boolean;
mutate(file: TFile, content: string | null | undefined,
write: (expectContent: (updated: string) => void) => Promise<void>): Promise<boolean>;
};

export const METADATA_NOT_READY =
"Changes may already be saved, but metadata synchronization did not complete. " +
"Remaining fixes in this batch were skipped. Run a fresh scan and review saved contents before retrying.";

export class MetadataWriteFence implements MutationFence {
private poisoned = false;
private disposed = false;
private readonly cancel = new Set<() => void>();

constructor(private readonly app: App, private readonly timeoutMs = 10000) {}

get ready(): boolean { return !this.poisoned && !this.disposed; }

async mutate(file: TFile, content: string | null | undefined,
write: (expectContent: (updated: string) => void) => Promise<void>): Promise<boolean> {
if (!this.ready) return false;
const path = file.path;
let relevant = false;
let resolved = false;
let writeDone = false;
let done = false;
let settle!: (value: boolean) => void;
const waiting = new Promise<boolean>(resolve => { settle = resolve; });
const metadataRefs: EventRef[] = [];
const vaultRefs: EventRef[] = [];
let timer: number | undefined;
const finish = (success: boolean) => {
if (done) return;
done = true;
if (!success) this.poisoned = true;
for (const ref of metadataRefs) this.app.metadataCache.offref(ref);
for (const ref of vaultRefs) this.app.vault.offref(ref);
if (timer !== undefined) window.clearTimeout(timer);
this.cancel.delete(cancel);
settle(success);
};
const cancel = () => finish(false);
const check = () => {
if (!writeDone || !relevant || !resolved) return;
if (content === null && this.app.vault.getAbstractFileByPath(path)) return;
finish(true);
};
this.cancel.add(cancel);
metadataRefs.push(this.app.metadataCache.on("changed", (changed, data) => {
if (content === null || changed.path !== path) return;
// A later change with other content invalidates earlier proof too.
relevant = data === content;
resolved = false;
}));
metadataRefs.push(this.app.metadataCache.on("deleted", deleted => {
if (deleted.path !== path) return;
relevant = content === null;
resolved = false;
}));
if (content === null && file.extension !== "md") {
vaultRefs.push(this.app.vault.on("delete", deleted => {
if (deleted.path !== path) return;
relevant = true;
resolved = false;
}));
}
metadataRefs.push(this.app.metadataCache.on("resolved", () => {
if (!relevant) return;
resolved = true;
check();
}));
timer = window.setTimeout(cancel, this.timeoutMs);
try {
await write(updated => {
content = updated;
relevant = false;
resolved = false;
});
writeDone = true;
// process callbacks that return unchanged content do not schedule a cache event.
if (content === undefined) finish(true);
else check();
return await waiting;
} catch (error) {
finish(false);
throw error;
}
}

dispose(): void {
this.disposed = true;
for (const cancel of [...this.cancel]) cancel();
}
}
32 changes: 26 additions & 6 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
MAX_SAFE_VAULT_REPORT_BYTES,
} from "./report/report-export";
import { executeFixAction } from "./fix/fix-executor";
import { METADATA_NOT_READY, MetadataWriteFence } from "./fix/metadata-write-fence";
import { showConfirmModal } from "./fix/confirm-modal";
import { runFixBatch } from "./fix/fix-runner";
import type { DispositionOutcome } from "./fix/action-outcomes";
Expand Down Expand Up @@ -92,7 +93,12 @@ export default class VaultInspectorPlugin extends Plugin {
this.addRibbonIcon("shield-check", "Run scan", () => this.runScan());
}

onunload() {}
private activeMetadataFences = new Set<MetadataWriteFence>();

onunload() {
for (const fence of this.activeMetadataFences) fence.dispose();
this.activeMetadataFences.clear();
}

async loadSettings() {
const parsed = parsePluginData(await this.loadData());
Expand Down Expand Up @@ -240,11 +246,25 @@ export default class VaultInspectorPlugin extends Plugin {
await this.enqueueOperation(async () => {
const fixSettings = structuredClone(this.settings);
const scanProfile = await createScanProfile(fixSettings);
const batch = await runFixBatch(issues, decisions, {
settings: () => fixSettings,
scan: (batchSettings) => this.scan(view, batchSettings),
execute: (action) => executeFixAction(this.app, action),
});
const fence = new MetadataWriteFence(this.app);
this.activeMetadataFences.add(fence);
const batch = await (async () => {
try {
return await runFixBatch(issues, decisions, {
settings: () => fixSettings,
scan: (batchSettings) => this.scan(view, batchSettings),
canScan: () => fence.ready,
execute: async (action) => ({
affectedCount: await executeFixAction(this.app, action, fence),
verificationReady: fence.ready,
verificationMessage: fence.ready ? undefined : METADATA_NOT_READY,
}),
});
} finally {
fence.dispose();
this.activeMetadataFences.delete(fence);
}
})();
let acceptanceFailed = false;
let acceptanceError: unknown;
if (batch.verificationResult) {
Expand Down
Loading