rspace-online/modules/rpubs/publications-store.ts

181 lines
4.4 KiB
TypeScript

/**
* 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<PublicationRecord[]> {
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<PublicationRecord | null> {
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<Buffer | null> {
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<string> {
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<PublicationRecord> {
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;
}