181 lines
4.4 KiB
TypeScript
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;
|
|
}
|