mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
3f646048a2
services/content watches the vault's Site folder, runs every change through fixed rules, keeps the last accepted version of each note with its history, and serves one write API. The HTTP API, the MCP server for agents, and the admin page and editing tools to come all write through it. - SQLite store with history, review queue, redirects, links and full-text search - Rules: invalid, misplaced, duplicate name or slug, text loss, secrets, agent contact details, agent edits, agent publishing, agent batches - Watcher with debounce and a periodic full scan - HTTP API with reader, agent, editor and admin tokens - MCP tools: search, get_note, get_schema, list_held, set_status, add_library_item, update_fields, draft_post - Tests, including taking in all 545 migrated notes with nothing held Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
/**
|
|
* The Site folder on disk. Only the content service reads or writes it.
|
|
*/
|
|
import { createHash } from "node:crypto";
|
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
import { basename, dirname, join, relative, sep } from "node:path";
|
|
|
|
export const hashText = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
|
|
/**
|
|
* Whether a path under the Site folder can hold a note. Folders starting with
|
|
* `_` or `.` hold templates, attachments and Obsidian's own files; files
|
|
* starting with `_` are settings notes and do count. Sync conflict copies
|
|
* never count, so a conflict can never overwrite a page.
|
|
*/
|
|
export function isNotePath(rel: string): boolean {
|
|
const parts = rel.split(/[\\/]/);
|
|
const file = parts.pop() ?? "";
|
|
if (!file.endsWith(".md") || file.startsWith(".")) return false;
|
|
if (parts.some((p) => p.startsWith("_") || p.startsWith("."))) return false;
|
|
if (/\(conflicted copy|\.sync-conflict-/i.test(file)) return false;
|
|
return true;
|
|
}
|
|
|
|
export class Vault {
|
|
constructor(readonly dir: string) {}
|
|
|
|
read(rel: string): string | null {
|
|
try {
|
|
return readFileSync(join(this.dir, rel), "utf8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
exists(rel: string): boolean {
|
|
return existsSync(join(this.dir, rel));
|
|
}
|
|
|
|
/** Write through a hidden temp file and a rename, so a reader never sees half a note. */
|
|
write(rel: string, text: string) {
|
|
const full = join(this.dir, rel);
|
|
mkdirSync(dirname(full), { recursive: true });
|
|
const tmp = join(dirname(full), `.${basename(full)}.${process.pid}.tmp`);
|
|
writeFileSync(tmp, text);
|
|
renameSync(tmp, full);
|
|
}
|
|
|
|
/** Every note path under the Site folder, relative, with `/` separators. */
|
|
listNotes(): string[] {
|
|
const out: string[] = [];
|
|
const walk = (d: string) => {
|
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
if (e.name.startsWith(".")) continue;
|
|
const full = join(d, e.name);
|
|
const rel = relative(this.dir, full).split(sep).join("/");
|
|
if (e.isDirectory()) {
|
|
if (!e.name.startsWith("_")) walk(full);
|
|
} else if (isNotePath(rel)) out.push(rel);
|
|
}
|
|
};
|
|
if (existsSync(this.dir)) walk(this.dir);
|
|
return out.sort();
|
|
}
|
|
|
|
/** File names in `_attachments`, for checking image links. */
|
|
listAttachments(): string[] {
|
|
const out: string[] = [];
|
|
const walk = (d: string) => {
|
|
if (!existsSync(d)) return;
|
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
if (e.isDirectory()) walk(join(d, e.name));
|
|
else out.push(e.name);
|
|
}
|
|
};
|
|
walk(join(this.dir, "_attachments"));
|
|
return out;
|
|
}
|
|
|
|
/** Absolute path of an attachment by file name, for serving images. */
|
|
attachmentPath(name: string): string | null {
|
|
const walk = (d: string): string | null => {
|
|
if (!existsSync(d)) return null;
|
|
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
const full = join(d, e.name);
|
|
if (e.isDirectory()) {
|
|
const found = walk(full);
|
|
if (found) return found;
|
|
} else if (e.name === name && statSync(full).isFile()) return full;
|
|
}
|
|
return null;
|
|
};
|
|
return walk(join(this.dir, "_attachments"));
|
|
}
|
|
}
|