/** * Publications store — filesystem-backed persistent publications per space. * * Layout: * {PUBS_DIR}/{space}/{slug}/publication.json * {PUBS_DIR}/{space}/{slug}/source.pdf * {PUBS_DIR}/{space}/{slug}/source.md * {PUBS_DIR}/{space}/{slug}/reflowable.epub * {PUBS_DIR}/{space}/{slug}/fixed.epub * * The reflowable EPUB is always built. The fixed EPUB is optional (costlier * to generate — we only build it if asked). */ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; export const PUBS_DIR = process.env.PUBS_DIR || "/data/rpubs-publications"; export interface PublicationRecord { id: string; space: string; slug: string; title: string; author: string; description?: string; formatId: string; formatName: string; pageCount: number; pdfBytes: number; reflowableEpubBytes: number; fixedEpubBytes?: number; createdAt: number; updatedAt: number; } export function slugify(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 80) || "publication"; } export function safeSpace(space: string): string { // Limit to safe chars — we're using this as a directory name. return space.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 60) || "personal"; } function spaceDir(space: string): string { return resolve(PUBS_DIR, safeSpace(space)); } function pubDir(space: string, slug: string): string { return resolve(spaceDir(space), slugify(slug)); } export async function listPublications(space: string): Promise { const dir = spaceDir(space); try { await stat(dir); } catch { return []; } const slugs = await readdir(dir); const records: PublicationRecord[] = []; for (const slug of slugs) { try { const meta = await readFile(join(dir, slug, "publication.json"), "utf-8"); records.push(JSON.parse(meta)); } catch { // skip malformed entries } } return records.sort((a, b) => b.updatedAt - a.updatedAt); } export async function getPublication( space: string, slug: string, ): Promise { try { const meta = await readFile( join(pubDir(space, slug), "publication.json"), "utf-8", ); return JSON.parse(meta); } catch { return null; } } export async function getPublicationFile( space: string, slug: string, file: "pdf" | "reflowable.epub" | "fixed.epub" | "source.md", ): Promise { const name = file === "pdf" ? "source.pdf" : file === "reflowable.epub" ? "reflowable.epub" : file === "fixed.epub" ? "fixed.epub" : "source.md"; try { return Buffer.from(await readFile(join(pubDir(space, slug), name))); } catch { return null; } } /** Ensure the slug is unique within the space — if not, append a short suffix. */ async function uniqueSlug(space: string, desired: string): Promise { let slug = slugify(desired); const existing = new Set( (await listPublications(space)).map((p) => p.slug), ); if (!existing.has(slug)) return slug; const suffix = Math.random().toString(36).slice(2, 7); return `${slug}-${suffix}`; } export interface SavePublicationInput { space: string; title: string; author: string; description?: string; formatId: string; formatName: string; pageCount: number; sourceMarkdown: string; pdf: Buffer; reflowableEpub: Buffer; fixedEpub?: Buffer; } export async function savePublication( input: SavePublicationInput, ): Promise { const space = safeSpace(input.space); const slug = await uniqueSlug(space, input.title); const dir = pubDir(space, slug); await mkdir(dir, { recursive: true }); await Promise.all([ writeFile(join(dir, "source.md"), input.sourceMarkdown, "utf-8"), writeFile(join(dir, "source.pdf"), input.pdf), writeFile(join(dir, "reflowable.epub"), input.reflowableEpub), input.fixedEpub ? writeFile(join(dir, "fixed.epub"), input.fixedEpub) : Promise.resolve(), ]); const now = Date.now(); const record: PublicationRecord = { id: crypto.randomUUID(), space, slug, title: input.title, author: input.author, description: input.description, formatId: input.formatId, formatName: input.formatName, pageCount: input.pageCount, pdfBytes: input.pdf.length, reflowableEpubBytes: input.reflowableEpub.length, fixedEpubBytes: input.fixedEpub?.length, createdAt: now, updatedAt: now, }; await writeFile( join(dir, "publication.json"), JSON.stringify(record, null, 2), "utf-8", ); return record; }