/** * 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")); } }