/** * 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, unlinkSync, 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; } /** Image types the admin page may add to `_attachments`. SVG stays out: it can carry script. */ export const uploadTypes: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".avif": "image/avif", }; /** A safe attachment file name: lowercase words joined by hyphens, plus the extension. */ export function attachmentName(original: string): string | null { const m = /^(.*?)(\.[A-Za-z0-9]+)?$/.exec(basename(original.replace(/\\/g, "/"))); const ext = (m?.[2] ?? "").toLowerCase(); if (!uploadTypes[ext]) return null; const stem = (m?.[1] ?? "") .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 80); return `${stem || "image"}${ext === ".jpeg" ? ".jpg" : ext}`; } /** A folder under `_attachments`: one plain name like Posts or Pages. */ const folderOk = (f: string) => /^[A-Za-z0-9][A-Za-z0-9 _-]{0,40}$/.test(f); export interface AttachmentFile { name: string; /** The folder under `_attachments`, or "" for its top level. */ folder: string; size: number; modified: string; } 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; } /** Every file in `_attachments` with its folder, size and time. */ attachmentFiles(): AttachmentFile[] { const root = join(this.dir, "_attachments"); const out: AttachmentFile[] = []; const walk = (d: string) => { if (!existsSync(d)) return; for (const e of readdirSync(d, { withFileTypes: true })) { if (e.name.startsWith(".")) continue; const full = join(d, e.name); if (e.isDirectory()) walk(full); else if (e.isFile()) { const st = statSync(full); const folder = relative(root, d).split(sep).join("/"); out.push({ name: e.name, folder, size: st.size, modified: st.mtime.toISOString() }); } } }; walk(root); return out.sort((a, b) => b.modified.localeCompare(a.modified) || a.name.localeCompare(b.name)); } /** * Add an image to `_attachments/`. Names are made safe, and a name * already used anywhere in `_attachments` gets -2, -3 and so on, because * Obsidian links attachments by file name alone. */ addAttachment(folder: string, original: string, bytes: Uint8Array): string { if (!folderOk(folder)) throw new Error("Use a plain folder name, like Posts"); const safe = attachmentName(original); if (!safe) throw new Error(`Only ${Object.keys(uploadTypes).join(", ")} images can be added`); const taken = new Set(this.listAttachments().map((n) => n.toLowerCase())); const dot = safe.lastIndexOf("."); let name = safe; for (let i = 2; taken.has(name.toLowerCase()); i++) name = `${safe.slice(0, dot)}-${i}${safe.slice(dot)}`; const full = join(this.dir, "_attachments", folder, name); mkdirSync(dirname(full), { recursive: true }); const tmp = join(dirname(full), `.${name}.${process.pid}.tmp`); writeFileSync(tmp, bytes); renameSync(tmp, full); return name; } /** Delete an attachment by file name. False when there is none. */ removeAttachment(name: string): boolean { const file = name.includes("/") || name.includes("\\") || name.includes("..") ? null : this.attachmentPath(name); if (!file) return false; unlinkSync(file); return true; } /** 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")); } }