/** * The content service core. Every change, from the vault or from the write * API, goes through `propose`, which runs the rules and either accepts the * change into the store or holds it for review. * * The HTTP API, the MCP server, the admin page and any later editing tool all * call these methods; none of them write notes any other way. */ import { basename } from "node:path"; import { checkNote, checkVault, formatNote, notePath, outgoingLinks, slugify, typeDef, type CheckedNote } from "@gk/schema"; import { defaultRules, evaluate, isLimited, type RuleSettings } from "./rules.ts"; import type { Author, HeldChange, HeldReason, StoredNote, Store } from "./store.ts"; import { hashText, isNotePath, type Vault } from "./vault.ts"; export type Outcome = | { status: "accepted"; path: string; note: StoredNote } | { status: "held"; path: string; held: HeldChange } | { status: "rejected"; path: string | null; reasons: HeldReason[] } | { status: "unchanged"; path: string }; export type Change = | { op: "create"; data: Record; body?: string } | { op: "update"; path: string; fields?: Record; body?: string }; export interface ServiceOptions { store: Store; vault: Vault; rules?: Partial; /** Injectable clock, for tests. */ now?: () => Date; /** Called whenever a change is held, for notifications. */ onHold?: (held: HeldChange) => void; } /** How long after a note disappears a new note can still claim its old address. */ const MOVE_WINDOW_MS = 10 * 60_000; export class ContentService { readonly store: Store; readonly vault: Vault; readonly rules: RuleSettings; private readonly now: () => Date; private readonly onHold?: (held: HeldChange) => void; constructor(o: ServiceOptions) { this.store = o.store; this.vault = o.vault; this.rules = { ...defaultRules, ...o.rules }; this.now = o.now ?? (() => new Date()); this.onHold = o.onHold; } /** What the admin page shows as the service's own health. */ readonly health: { startedAt: string; lastScan: { at: string; changed: number; held: number } | null; lastError: { at: string; message: string } | null; } = { startedAt: new Date().toISOString(), lastScan: null, lastError: null }; noteError(err: unknown) { this.health.lastError = { at: this.iso(), message: err instanceof Error ? err.message : String(err) }; } private iso = () => this.now().toISOString(); private today = () => this.iso().slice(0, 10); // ------------------------------------------------------------ from the vault /** Read one file from the vault and take it in if it changed. */ ingest(path: string): Outcome { const text = this.vault.read(path); if (text === null) return this.removed(path); const hash = hashText(text); if (this.store.seenHash(path) === hash) return { status: "unchanged", path }; this.store.markSeen(path, hash); const current = this.store.get(path); if (current && !current.removed && current.hash === hash) return { status: "unchanged", path }; return this.propose(path, text, "greg", "vault"); } /** The file is gone. The note leaves the site; its history stays. */ removed(path: string): Outcome { this.store.markSeen(path, null); this.store.supersede(path, this.iso()); const note = this.store.remove(path, "greg", this.iso()); return note ? { status: "accepted", path, note } : { status: "unchanged", path }; } /** * Compare the whole folder with what was seen before. Removals go first, so * a note moved between folders frees its name before it is read again. */ scan(): { changed: number; held: number } { const present = new Set(this.vault.listNotes()); let changed = 0; let held = 0; for (const path of this.store.seenPaths()) { if (!present.has(path)) { this.removed(path); changed++; } } for (const path of present) { const o = this.ingest(path); if (o.status !== "unchanged") changed++; if (o.status === "held") held++; } this.health.lastScan = { at: this.iso(), changed, held }; return { changed, held }; } // ------------------------------------------------------------ the write API /** One change from the API, made as `author`. Accepted changes are written into the vault. */ apply(change: Change, author: Author): Outcome { let data: Record; let body: string; let path: string; if (change.op === "create") { data = { ...change.data, publish: change.data.publish ?? false, created: change.data.created ?? this.today() }; body = change.body ?? ""; if (!typeDef(String(data.type))) return this.reject(null, "invalid", `Unknown type "${String(data.type)}"`); if (typeof data.title !== "string" || !data.title.trim()) return this.reject(null, "invalid", "title: A title is required"); path = notePath(data); if (this.vault.exists(path) || (this.store.get(path) && !this.store.get(path)!.removed)) { return this.reject(path, "exists", `A note already exists at ${path}; update it instead`); } } else { path = change.path; if (!isNotePath(path) || path.startsWith("/") || path.split("/").includes("..")) { return this.reject(null, "invalid", "path: Not a note path inside the Site folder"); } // Take in any edit made in Obsidian first, so it is never overwritten. this.ingest(path); const current = this.store.get(path); if (!current || current.removed) return this.reject(path, "missing", `No note at ${path}`); if (this.store.pendingFor(path)) { return this.reject(path, "waiting", "This note has a change waiting for review; decide on that first"); } data = { ...current.data }; for (const [k, v] of Object.entries(change.fields ?? {})) { if (k === "type") return this.reject(path, "invalid", "type: A note's type cannot change"); if (v === null) delete data[k]; else data[k] = v; } body = change.body ?? current.body; } data.updated = this.today(); data.updatedBy = author; const text = formatNote(data, body); return this.propose(path, text, author, "api"); } // ------------------------------------------------------------ review acceptHeld(id: number): Outcome { const h = this.store.heldById(id); if (!h || h.status !== "pending") return this.reject(h?.path ?? null, "missing", "Nothing is waiting with that id"); const checked = checkNote(h.text); if (!checked.ok) { return this.reject(h.path, "invalid", "This change is not a valid note, so it cannot go live. Fix the note in Obsidian."); } const clash = this.clashes(h.path, checked); if (clash.length) return { status: "rejected", path: h.path, reasons: clash }; if (h.source === "api") { const onDisk = this.vault.read(h.path); const current = this.store.get(h.path); if (onDisk !== null && hashText(onDisk) !== (current && !current.removed ? current.hash : null)) { return this.reject(h.path, "conflict", "The note changed in Obsidian since this was proposed"); } this.writeToVault(h.path, h.text); } const note = this.acceptText(h.path, h.text, checked, h.author, "accept"); this.store.decide(id, "accepted", this.iso()); return { status: "accepted", path: h.path, note }; } /** * Turn a held change down. With `restore`, a held edit from Obsidian is * replaced in the vault by the version the site is showing. */ rejectHeld(id: number, opts: { restore?: boolean } = {}): Outcome { const h = this.store.heldById(id); if (!h || h.status !== "pending") return this.reject(h?.path ?? null, "missing", "Nothing is waiting with that id"); this.store.decide(id, "rejected", this.iso()); const current = this.store.get(h.path); if (opts.restore && h.source === "vault" && current && !current.removed) this.writeToVault(h.path, current.text); return current ? { status: "accepted", path: h.path, note: current } : { status: "unchanged", path: h.path }; } /** Put an earlier accepted version back, in the vault and on the site. */ rollback(path: string, rev: number): Outcome { const r = this.store.revision(path, rev); if (!r) return this.reject(path, "missing", `No revision ${rev} for ${path}`); const checked = checkNote(r.text); if (!checked.ok) return this.reject(path, "invalid", "That version no longer passes the note rules"); const clash = this.clashes(path, checked); if (clash.length) return { status: "rejected", path, reasons: clash }; this.store.supersede(path, this.iso()); this.writeToVault(path, r.text); const note = this.acceptText(path, r.text, checked, "admin", "rollback"); return { status: "accepted", path, note }; } // ------------------------------------------------------------ reading /** Problems across the whole folder: links to notes that do not exist, and the like. */ problems() { const notes = this.store.list().map((n) => ({ path: n.path, type: n.type, data: n.data, body: n.body })); return checkVault(notes, this.vault.listAttachments()); } // ------------------------------------------------------------ internals private reject(path: string | null, code: string, message: string): Outcome { return { status: "rejected", path, reasons: [{ code, message }] }; } private slugOf(checked: CheckedNote): string { if (!checked.ok) return ""; return String(checked.data.slug ?? slugify(String(checked.data.title))); } /** Name and slug clashes with other live notes. */ private clashes(path: string, checked: CheckedNote): HeldReason[] { const reasons: HeldReason[] = []; if (!checked.ok) return reasons; const other = this.liveOther(this.store.byName(basename(path, ".md")), path); if (other) reasons.push({ code: "duplicate-name", message: `Another note already uses this file name: ${other}` }); const def = typeDef(checked.type); if (!def?.singleton) { const s = this.liveOther(this.store.bySlug(checked.type, this.slugOf(checked)), path); if (s) reasons.push({ code: "duplicate-slug", message: `Another ${checked.type} already uses this address: ${s}` }); } return reasons; } /** Another live note's path, after making sure that note's file still exists. */ private liveOther(note: StoredNote | null, path: string): string | null { if (!note || note.path === path) return null; if (!this.vault.exists(note.path)) { // Its file is gone (moved or deleted) and the watcher has not caught up. this.removed(note.path); return null; } return note.path; } private propose(path: string, text: string, author: Author, source: "vault" | "api"): Outcome { const checked = checkNote(text); const name = basename(path, ".md"); const slug = this.slugOf(checked); const window = new Date(this.now().getTime() - this.rules.agentBatchWindowMinutes * 60_000).toISOString(); const current = this.store.get(path); const nameTakenBy = checked.ok ? this.liveOther(this.store.byName(name), path) : null; const slugTakenBy = checked.ok && !typeDef(checked.type)?.singleton ? this.liveOther(this.store.bySlug(checked.type, slug), path) : null; const verdict = evaluate( { path, name, slug, text, author, checked }, { current, nameTakenBy, slugTakenBy, recentAgentChanges: isLimited(author) ? this.store.changesSince(author, window) : 0, settings: this.rules, }, ); if (verdict.decision === "accept" && checked.ok) { if (source === "api") this.writeToVault(path, text); const note = this.acceptText(path, text, checked, author, "accept"); return { status: "accepted", path, note }; } if (verdict.decision === "hold" && source === "api" && verdict.reasons.some((r) => r.code === "invalid")) { // An API caller gets the problems back at once instead of a broken note in the queue. return { status: "rejected", path, reasons: verdict.reasons }; } const reasons = verdict.decision === "hold" ? verdict.reasons : []; const held = this.store.hold({ path, author, source, text, baseHash: current && !current.removed ? current.hash : null, reasons, at: this.iso(), }); this.onHold?.(held); return { status: "held", path, held }; } private writeToVault(path: string, text: string) { // Mark it seen first, so the watcher recognises the service's own write. this.store.markSeen(path, hashText(text)); this.vault.write(path, text); } private acceptText(path: string, text: string, checked: CheckedNote & { ok: true }, author: Author, action: "accept" | "rollback"): StoredNote { const at = this.iso(); const data = checked.data; const slug = this.slugOf(checked); const hash = hashText(text); const vaultNote = { path, type: checked.type, data, body: checked.body }; return this.store.transaction(() => { const prev = this.store.get(path); // The old address keeps working after a rename or a slug change. if (prev && !prev.removed && prev.type === checked.type && prev.slug !== slug) { this.store.addRedirect(prev.type, prev.slug, path, at); } if (!prev || prev.removed) { const since = new Date(this.now().getTime() - MOVE_WINDOW_MS).toISOString(); const moved = this.store .recentlyRemoved(since) .find((n) => n.path !== path && n.type === checked.type && (n.hash === hash || n.title === data.title)); if (moved) { this.store.retarget(moved.path, path); if (moved.slug !== slug) this.store.addRedirect(moved.type, moved.slug, path, at); } } // A slug in use again belongs to its note, not to a redirect. this.store.dropRedirect(checked.type, slug); this.store.supersede(path, at); return this.store.accept( { path, type: checked.type, name: basename(path, ".md"), slug, title: String(data.title), publish: data.publish === true, data, body: checked.body, text, hash, author, acceptedAt: at, }, action, outgoingLinks(vaultNote), ); }); } }