diff --git a/src/core/VaultMutationService.ts b/src/core/VaultMutationService.ts index 647ce0275..9a47c22cf 100644 --- a/src/core/VaultMutationService.ts +++ b/src/core/VaultMutationService.ts @@ -1,4 +1,21 @@ import type { App, TFile } from "obsidian"; +import type { MdbaseMutationBackend } from "./mdbase/MdbaseMutationBackend"; + +let mdbaseBackend: MdbaseMutationBackend | null = null; + +/** + * Install (or remove, with `null`) the mdbase write path. While installed, writes + * to files in the mdbase collection go through the runtime as intents; everything + * else keeps using Obsidian's APIs. + */ +export function setMdbaseMutationBackend(backend: MdbaseMutationBackend | null): void { + mdbaseBackend = backend; +} + +/** The installed mdbase write path, if any. */ +export function getMdbaseMutationBackend(): MdbaseMutationBackend | null { + return mdbaseBackend; +} type FrontmatterMutationApp = { fileManager: { @@ -54,6 +71,10 @@ export async function processVaultFrontMatterWithinMutation( file: TFile, update: (frontmatter: Record) => void ): Promise { + if (mdbaseBackend?.claims(file.path)) { + const r = await mdbaseBackend.processFrontMatter(file.path, update); + if (r.handled) return; + } await app.fileManager.processFrontMatter(file, update); } @@ -70,10 +91,22 @@ export async function processVaultFileWithinMutation( file: TFile, update: (content: string) => string ): Promise { + if (mdbaseBackend?.claims(file.path)) { + const r = await mdbaseBackend.process(file.path, update); + if (r.handled) return r.value; + } return app.vault.process(file, update); } export async function createVaultFile(app: App, path: string, content: string): Promise { + if (mdbaseBackend?.claims(path)) { + const r = await mdbaseBackend.create(path, content); + if (r.handled) { + const file = app.vault.getFileByPath(path); + if (file) return file; + throw new Error(`mdbase created ${path}, but Obsidian has not indexed it yet`); + } + } return app.vault.create(path, content); } @@ -82,7 +115,13 @@ export async function createVaultFolder(app: App, path: string): Promise { } export async function modifyVaultFile(app: App, file: TFile, content: string): Promise { - await withVaultFileMutation(file, () => app.vault.modify(file, content)); + await withVaultFileMutation(file, async () => { + if (mdbaseBackend?.claims(file.path)) { + const r = await mdbaseBackend.modify(file.path, content); + if (r.handled) return; + } + await app.vault.modify(file, content); + }); } export async function renameVaultFile(app: App, file: TFile, newPath: string): Promise { diff --git a/src/core/mdbase/MdbaseMutationBackend.ts b/src/core/mdbase/MdbaseMutationBackend.ts new file mode 100644 index 000000000..d4ad0829e --- /dev/null +++ b/src/core/mdbase/MdbaseMutationBackend.ts @@ -0,0 +1,170 @@ +/** + * Route TaskNotes' vault writes through the mdbase runtime when it manages the + * vault's collection. + * + * TaskNotes funnels every write through `VaultMutationService` + * (`processFrontMatter`, `process`, `create`, `modify`). When a backend is installed + * and claims a file, each write becomes a replica intent, in every collection state + * (local only, synced, synced end to end), over one client API: + * - a frontmatter callback becomes a **field-level update**: the keys the callback + * set, and the keys it removed, with the values it read as the merge base. Two + * devices editing different fields of one task both win, instead of the last + * whole-file write; + * - a whole-content callback, or `modify`, becomes a document replace with the read + * document as its base. The replica merges, or holds the file; + * - `create` becomes a record create at the requested path. + * + * The replica publishes into the vault itself, using the editor fence when the note is + * open. Each call resolves once the write has settled (see + * {@link MdbaseWriteClient.settle}), so callers that re-read the file afterwards see + * the new content, as with Obsidian's own APIs. + * + * Files outside the collection, or written while no runtime is attached, keep using + * Obsidian's APIs unchanged. + */ + +/** A record as TaskNotes needs it, with plain JSON-like frontmatter. */ +export interface MdbaseRecord { + readonly id: string; + readonly path: string; + readonly frontmatter: Record; + /** The whole source, when read with `document`. */ + readonly document?: string; +} + +/** An accepted write (pending or confirmed). */ +export interface MdbaseWrite { + readonly mutation: string; +} + +/** + * What the backend needs from the mdbase client. The adapter over + * `@mdbase-dev/sdk`'s `MdbaseClient` converts values to and from plain JSON. + */ +export interface MdbaseWriteClient { + /** The collection-relative path for a vault path, or `null` if outside the collection. */ + collectionPath(vaultPath: string): string | null; + /** Read a record by path, with frontmatter (and the document when asked). */ + find(path: string, opts: { document: boolean }): Promise; + /** Field-level update, with `base` taken from `seen`. */ + update( + seen: MdbaseRecord, + changes: { patch: Record; unset: string[] } + ): Promise; + /** Replace the whole document, with `seen.document` as the merge base. */ + replaceDocument(seen: MdbaseRecord, document: string): Promise; + /** Create a record from a complete document at `path`. */ + create(path: string, document: string): Promise; + /** + * Resolve once the write is settled for TaskNotes' purposes: rejected (throws), or + * published into the vault file (the replica's publish, or the editor fence plus + * Obsidian's save) and indexed by Obsidian's vault (so `getFileByPath` finds a + * created file). It does not wait for log confirmation, so TaskNotes works the + * same offline. + */ + settle(write: MdbaseWrite, vaultPath: string): Promise; +} + +/** Result of a backend attempt: `false` means "not mine, use Obsidian's API". */ +export type Handled = { handled: true; value: T } | { handled: false }; + +const NOT_HANDLED = { handled: false } as const; + +function isPlainObject(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date); +} + +/** Structural equality for YAML-shaped values (objects compare as key sets). */ +export function yamlEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((x, i) => yamlEqual(x, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const ka = Object.keys(a); + const kb = Object.keys(b); + return ka.length === kb.length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && yamlEqual(a[k], b[k])); + } + return false; +} + +function clone(v: T): T { + return typeof structuredClone === "function" ? structuredClone(v) : (JSON.parse(JSON.stringify(v)) as T); +} + +/** The top-level keys a frontmatter callback set or removed. */ +export function frontmatterDiff( + before: Record, + after: Record +): { patch: Record; unset: string[] } { + const patch: Record = {}; + const unset: string[] = []; + for (const [k, v] of Object.entries(after)) { + if (v === undefined) { + if (k in before) unset.push(k); + } else if (!(k in before) || !yamlEqual(before[k], v)) { + patch[k] = v; + } + } + for (const k of Object.keys(before)) if (!(k in after)) unset.push(k); + return { patch, unset }; +} + +/** The mdbase write path for TaskNotes. */ +export class MdbaseMutationBackend { + constructor(private readonly client: MdbaseWriteClient) {} + + /** Whether this backend handles writes to `vaultPath`. */ + claims(vaultPath: string): boolean { + return this.client.collectionPath(vaultPath) !== null; + } + + /** `fileManager.processFrontMatter`, as a field-level update. */ + async processFrontMatter( + vaultPath: string, + update: (frontmatter: Record) => void + ): Promise> { + const path = this.client.collectionPath(vaultPath); + if (path === null) return NOT_HANDLED; + const seen = await this.client.find(path, { document: false }); + if (!seen) return NOT_HANDLED; // not a record (yet): let Obsidian write it + const working = clone(seen.frontmatter); + update(working); + const changes = frontmatterDiff(seen.frontmatter, working); + if (Object.keys(changes.patch).length === 0 && changes.unset.length === 0) { + return { handled: true, value: undefined }; + } + const write = await this.client.update(seen, changes); + await this.client.settle(write, vaultPath); + return { handled: true, value: undefined }; + } + + /** `vault.process`, as a document replace with the read document as base. */ + async process(vaultPath: string, update: (content: string) => string): Promise> { + const path = this.client.collectionPath(vaultPath); + if (path === null) return NOT_HANDLED; + const seen = await this.client.find(path, { document: true }); + if (!seen || seen.document === undefined) return NOT_HANDLED; + const next = update(seen.document); + if (next === seen.document) return { handled: true, value: next }; + const write = await this.client.replaceDocument(seen, next); + await this.client.settle(write, vaultPath); + return { handled: true, value: next }; + } + + /** `vault.modify`: a document replace from whatever is current. */ + async modify(vaultPath: string, content: string): Promise> { + const r = await this.process(vaultPath, () => content); + return r.handled ? { handled: true, value: undefined } : NOT_HANDLED; + } + + /** `vault.create`. The caller looks up the `TFile` once this resolves. */ + async create(vaultPath: string, content: string): Promise> { + const path = this.client.collectionPath(vaultPath); + if (path === null) return NOT_HANDLED; + const write = await this.client.create(path, content); + await this.client.settle(write, vaultPath); + return { handled: true, value: undefined }; + } +} diff --git a/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts new file mode 100644 index 000000000..afe08973a --- /dev/null +++ b/tests/unit/core/mdbase/MdbaseMutationBackend.test.ts @@ -0,0 +1,139 @@ +import type { TFile } from "obsidian"; +import { + frontmatterDiff, + MdbaseMutationBackend, + type MdbaseRecord, + type MdbaseWriteClient, +} from "../../../../src/core/mdbase/MdbaseMutationBackend"; +import { + createVaultFile, + modifyVaultFile, + processVaultFile, + processVaultFrontMatter, + setMdbaseMutationBackend, +} from "../../../../src/core/VaultMutationService"; + +class FakeClient implements MdbaseWriteClient { + records = new Map(); + calls: unknown[] = []; + settled: string[] = []; + collectionPath(vaultPath: string) { + return vaultPath.startsWith("Tasks/") ? vaultPath.slice("Tasks/".length) : null; + } + async find(path: string, opts: { document: boolean }) { + const r = this.records.get(path); + if (!r) return null; + return opts.document ? r : { ...r, document: undefined }; + } + async update(seen: MdbaseRecord, changes: { patch: Record; unset: string[] }) { + this.calls.push(["update", seen.id, changes]); + return { mutation: "m1" }; + } + async replaceDocument(seen: MdbaseRecord, document: string) { + this.calls.push(["replace", seen.id, seen.document, document]); + return { mutation: "m2" }; + } + async create(path: string, document: string) { + this.calls.push(["create", path, document]); + return { mutation: "m3" }; + } + async settle(_w: unknown, vaultPath: string) { + this.settled.push(vaultPath); + } +} + +const task: MdbaseRecord = { + id: "r1", + path: "t.md", + frontmatter: { status: "open", tags: ["a"], scheduled: "2026-10-04", meta: { x: 1 } }, + document: "---\nstatus: open\n---\nbody\n", +}; + +function app() { + return { + fileManager: { processFrontMatter: jest.fn(async () => {}) }, + vault: { + process: jest.fn(async (_f: TFile, fn: (c: string) => string) => fn("obsidian")), + modify: jest.fn(async () => {}), + create: jest.fn(async (p: string) => ({ path: p }) as TFile), + getFileByPath: jest.fn((p: string) => ({ path: p }) as TFile), + }, + }; +} + +describe("frontmatterDiff", () => { + it("reports only keys the callback set or removed", () => { + expect(frontmatterDiff({ a: 1, b: [1, 2], c: { d: 1 }, e: "x" }, { a: 1, b: [1, 2, 3], c: { d: 1 }, f: true })).toEqual({ + patch: { b: [1, 2, 3], f: true }, + unset: ["e"], + }); + expect(frontmatterDiff({ a: 1 }, { a: undefined })).toEqual({ patch: {}, unset: ["a"] }); + }); +}); + +describe("VaultMutationService with the mdbase backend", () => { + let client: FakeClient; + beforeEach(() => { + client = new FakeClient(); + client.records.set("t.md", task); + setMdbaseMutationBackend(new MdbaseMutationBackend(client)); + }); + afterEach(() => setMdbaseMutationBackend(null)); + + it("turns a frontmatter callback into a field-level update", async () => { + const a = app(); + await processVaultFrontMatter(a as never, { path: "Tasks/t.md" } as TFile, (fm) => { + fm.status = "done"; + fm.completedDate = "2026-10-04"; + delete fm.scheduled; + (fm.tags as string[]).push("b"); + }); + expect(client.calls).toEqual([ + ["update", "r1", { patch: { status: "done", tags: ["a", "b"], completedDate: "2026-10-04" }, unset: ["scheduled"] }], + ]); + expect(client.settled).toEqual(["Tasks/t.md"]); + expect(a.fileManager.processFrontMatter).not.toHaveBeenCalled(); + expect(task.frontmatter.tags).toEqual(["a"]); // the read view is not mutated + }); + + it("sends nothing when the callback changes nothing", async () => { + await processVaultFrontMatter(app() as never, { path: "Tasks/t.md" } as TFile, () => {}); + expect(client.calls).toEqual([]); + }); + + it("turns a content callback into a document replace with the read document as base", async () => { + const out = await processVaultFile(app() as never, { path: "Tasks/t.md" } as TFile, (c) => c.replace("body", "BODY")); + expect(out).toBe("---\nstatus: open\n---\nBODY\n"); + expect(client.calls).toEqual([["replace", "r1", task.document, out]]); + }); + + it("routes modify and create", async () => { + const a = app(); + await modifyVaultFile(a as never, { path: "Tasks/t.md" } as TFile, "new"); + const f = await createVaultFile(a as never, "Tasks/n.md", "---\ntitle: n\n---\n"); + expect(f.path).toBe("Tasks/n.md"); + expect(client.calls).toEqual([ + ["replace", "r1", task.document, "new"], + ["create", "n.md", "---\ntitle: n\n---\n"], + ]); + expect(a.vault.modify).not.toHaveBeenCalled(); + expect(a.vault.create).not.toHaveBeenCalled(); + }); + + it("leaves files outside the collection, and unknown records, to Obsidian", async () => { + const a = app(); + await processVaultFrontMatter(a as never, { path: "Other/x.md" } as TFile, (fm) => (fm.a = 1)); + await processVaultFrontMatter(a as never, { path: "Tasks/unknown.md" } as TFile, (fm) => (fm.a = 1)); + await createVaultFile(a as never, "Other/y.md", "y"); + expect(a.fileManager.processFrontMatter).toHaveBeenCalledTimes(2); + expect(a.vault.create).toHaveBeenCalledTimes(1); + expect(client.calls).toEqual([]); + }); + + it("without a backend, behaves exactly as before", async () => { + setMdbaseMutationBackend(null); + const a = app(); + await processVaultFrontMatter(a as never, { path: "Tasks/t.md" } as TFile, (fm) => (fm.a = 1)); + expect(a.fileManager.processFrontMatter).toHaveBeenCalledTimes(1); + }); +});