Files
com.gregkilmer.www/services/content/src/vault.ts
T
Claude a9fb5b3e85 Admin: write blog posts and manage images
A Posts page lists every post with Write a post: title, topic, kind,
date, summary, a cover picked from the vault or uploaded, the pages it is
about, the Markdown body with a rendered preview, and Save draft or
Publish. An Images page uploads several images into an _attachments
folder, shows which notes use each one, copies its embed line, and
deletes unused ones. The content service gains the attachment list,
upload and delete endpoints behind the editor and admin tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-12 03:40:59 +00:00

182 lines
6.5 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, 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<string, string> = {
".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/<folder>`. 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"));
}
}