rspace-online/modules/rbooks/mod.ts

416 lines
11 KiB
TypeScript

/**
* Books module — community PDF library with flipbook reader.
*
* Ported from rbooks-online (Next.js) to Hono routes.
* Routes are relative to mount point (/:space/books in unified, / in standalone).
*
* Storage: Automerge documents via SyncServer (one doc per space).
* PDF files stay on the filesystem — only metadata lives in Automerge.
*/
import { Hono } from "hono";
import { resolve } from "node:path";
import { mkdir } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { renderLanding } from "./landing";
import {
verifyEncryptIDToken,
extractToken,
} from "@encryptid/sdk/server";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
booksCatalogSchema,
booksCatalogDocId,
type BooksCatalogDoc,
type BookItem,
} from './schemas';
let _syncServer: SyncServer | null = null;
const BOOKS_DIR = process.env.BOOKS_DIR || "/data/books";
// ── Helpers ──
function ensureDoc(space: string): BooksCatalogDoc {
const docId = booksCatalogDocId(space);
let doc = _syncServer!.getDoc<BooksCatalogDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<BooksCatalogDoc>(), 'init', (d) => {
const init = booksCatalogSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.items = {};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
/** Find a book by slug or id across the items map. */
function findBook(doc: BooksCatalogDoc, idOrSlug: string): BookItem | undefined {
// Direct key lookup first (by id)
if (doc.items[idOrSlug]) return doc.items[idOrSlug];
// Then scan by slug
return Object.values(doc.items).find(
(b) => b.slug === idOrSlug || b.id === idOrSlug
);
}
/** Convert a BookItem to the JSON shape the API has always returned. */
function bookToRow(b: BookItem) {
return {
id: b.id,
slug: b.slug,
title: b.title,
author: b.author,
description: b.description,
pdf_path: b.pdfPath,
pdf_size_bytes: b.pdfSizeBytes,
page_count: b.pageCount,
tags: b.tags,
license: b.license,
cover_color: b.coverColor,
contributor_id: b.contributorId,
contributor_name: b.contributorName,
status: b.status,
featured: b.featured,
view_count: b.viewCount,
download_count: b.downloadCount,
created_at: new Date(b.createdAt).toISOString(),
updated_at: new Date(b.updatedAt).toISOString(),
};
}
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 80);
}
function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// ── Routes ──
const routes = new Hono();
// ── API: List books ──
routes.get("/api/books", async (c) => {
const space = c.req.param("space") || "global";
const search = c.req.query("search")?.toLowerCase();
const tag = c.req.query("tag");
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100);
const offset = parseInt(c.req.query("offset") || "0");
const doc = ensureDoc(space);
let books = Object.values(doc.items).filter((b) => b.status === "published");
if (search) {
books = books.filter(
(b) =>
b.title.toLowerCase().includes(search) ||
b.author.toLowerCase().includes(search) ||
b.description.toLowerCase().includes(search)
);
}
if (tag) {
books = books.filter((b) => b.tags.includes(tag));
}
// Sort: featured first, then newest
books.sort((a, b) => {
if (a.featured !== b.featured) return a.featured ? -1 : 1;
return b.createdAt - a.createdAt;
});
// Paginate
const paged = books.slice(offset, offset + limit);
// Return the subset of fields the old query returned
const rows = paged.map((b) => ({
id: b.id,
slug: b.slug,
title: b.title,
author: b.author,
description: b.description,
pdf_size_bytes: b.pdfSizeBytes,
page_count: b.pageCount,
tags: [...b.tags],
cover_color: b.coverColor,
contributor_name: b.contributorName,
featured: b.featured,
view_count: b.viewCount,
created_at: new Date(b.createdAt).toISOString(),
}));
return c.json({ books: rows });
});
// ── API: Upload book ──
routes.post("/api/books", async (c) => {
const space = c.req.param("space") || "global";
const token = extractToken(c.req.raw.headers);
if (!token) return c.json({ error: "Authentication required" }, 401);
let claims;
try {
claims = await verifyEncryptIDToken(token);
} catch {
return c.json({ error: "Invalid token" }, 401);
}
const formData = await c.req.formData();
const file = formData.get("pdf") as File | null;
const title = (formData.get("title") as string || "").trim();
const author = (formData.get("author") as string || "").trim() || null;
const description = (formData.get("description") as string || "").trim() || null;
const tagsRaw = (formData.get("tags") as string || "").trim();
const license = (formData.get("license") as string || "CC BY-SA 4.0").trim();
if (!file || file.type !== "application/pdf") {
return c.json({ error: "PDF file required" }, 400);
}
if (!title) {
return c.json({ error: "Title required" }, 400);
}
const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];
const shortId = randomUUID().slice(0, 8);
const id = randomUUID();
let slug = slugify(title);
// Check slug collision
const doc = ensureDoc(space);
const slugExists = Object.values(doc.items).some((b) => b.slug === slug);
if (slugExists) {
slug = `${slug}-${shortId}`;
}
// Save PDF to disk
await mkdir(BOOKS_DIR, { recursive: true });
const filename = `${slug}.pdf`;
const filepath = resolve(BOOKS_DIR, filename);
const buffer = Buffer.from(await file.arrayBuffer());
await Bun.write(filepath, buffer);
const now = Date.now();
// Insert into Automerge doc
const docId = booksCatalogDocId(space);
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `add book: ${slug}`, (d) => {
d.items[id] = {
id,
slug,
title,
author: author || "",
description: description || "",
pdfPath: filename,
pdfSizeBytes: buffer.length,
pageCount: 0,
tags,
license,
coverColor: null,
contributorId: claims.sub,
contributorName: claims.username || null,
status: "published",
featured: false,
viewCount: 0,
downloadCount: 0,
createdAt: now,
updatedAt: now,
};
});
return c.json({
id,
slug,
title,
author,
description,
tags,
created_at: new Date(now).toISOString(),
}, 201);
});
// ── API: Get book details ──
routes.get("/api/books/:id", async (c) => {
const space = c.req.param("space") || "global";
const id = c.req.param("id");
const doc = ensureDoc(space);
const book = findBook(doc, id);
if (!book || book.status !== "published") {
return c.json({ error: "Book not found" }, 404);
}
// Increment view count
const docId = booksCatalogDocId(space);
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `view: ${book.slug}`, (d) => {
if (d.items[book.id]) {
d.items[book.id].viewCount += 1;
d.items[book.id].updatedAt = Date.now();
}
});
return c.json(bookToRow(book));
});
// ── API: Serve PDF ──
routes.get("/api/books/:id/pdf", async (c) => {
const space = c.req.param("space") || "global";
const id = c.req.param("id");
const doc = ensureDoc(space);
const book = findBook(doc, id);
if (!book || book.status !== "published") {
return c.json({ error: "Book not found" }, 404);
}
const filepath = resolve(BOOKS_DIR, book.pdfPath);
const file = Bun.file(filepath);
if (!(await file.exists())) {
return c.json({ error: "PDF file not found" }, 404);
}
// Increment download count
const docId = booksCatalogDocId(space);
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `download: ${book.slug}`, (d) => {
if (d.items[book.id]) {
d.items[book.id].downloadCount += 1;
d.items[book.id].updatedAt = Date.now();
}
});
return new Response(file, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${book.slug}.pdf"`,
"Content-Length": String(file.size),
},
});
});
// ── Page: Library ──
routes.get("/", (c) => {
const spaceSlug = c.req.param("space") || "personal";
return c.html(renderShell({
title: `${spaceSlug} — Library | rSpace`,
moduleId: "rbooks",
spaceSlug,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-book-shelf space-slug="${spaceSlug}"></folk-book-shelf>`,
scripts: `<script type="module" src="/modules/rbooks/folk-book-shelf.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
}));
});
// ── Page: Book reader ──
routes.get("/read/:id", async (c) => {
const spaceSlug = c.req.param("space") || "personal";
const id = c.req.param("id");
const doc = ensureDoc(spaceSlug);
const book = findBook(doc, id);
if (!book || book.status !== "published") {
const html = renderShell({
title: "Book not found | rSpace",
moduleId: "rbooks",
spaceSlug,
body: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Book not found</h2><p><a href="/${spaceSlug}/rbooks" style="color:#60a5fa;">Back to library</a></p></div>`,
modules: getModuleInfoList(),
});
return c.html(html, 404);
}
// Increment view count
const docId = booksCatalogDocId(spaceSlug);
_syncServer!.changeDoc<BooksCatalogDoc>(docId, `view: ${book.slug}`, (d) => {
if (d.items[book.id]) {
d.items[book.id].viewCount += 1;
d.items[book.id].updatedAt = Date.now();
}
});
// Build the PDF URL relative to this module's mount point
const pdfUrl = `/${spaceSlug}/rbooks/api/books/${book.slug}/pdf`;
const html = renderShell({
title: `${book.title} | rSpace`,
moduleId: "rbooks",
spaceSlug,
body: `
<folk-book-reader
id="reader"
pdf-url="${pdfUrl}"
book-id="${book.slug}"
title="${escapeAttr(book.title)}"
author="${escapeAttr(book.author || '')}"
></folk-book-reader>
`,
modules: getModuleInfoList(),
theme: "dark",
head: `<link rel="stylesheet" href="/modules/rbooks/books.css">`,
scripts: `
<script type="module">
import { FolkBookReader } from '/modules/rbooks/folk-book-reader.js';
</script>
`,
});
return c.html(html);
});
// ── Module export ──
export const booksModule: RSpaceModule = {
id: "rbooks",
name: "rBooks",
icon: "📚",
description: "Community PDF library with flipbook reader",
scoping: { defaultScope: 'global', userConfigurable: true },
docSchemas: [{ pattern: '{space}:books:catalog', description: 'Book catalog metadata', init: booksCatalogSchema.init }],
routes,
standaloneDomain: "rbooks.online",
landingPage: renderLanding,
async onInit(ctx) {
_syncServer = ctx.syncServer;
console.log("[Books] Module initialized (Automerge storage)");
},
feeds: [
{
id: "reading-list",
name: "Reading List",
kind: "data",
description: "Books in the community library — titles, authors, tags",
filterable: true,
},
{
id: "annotations",
name: "Annotations",
kind: "data",
description: "Reader highlights, bookmarks, and notes on books",
},
],
acceptsFeeds: ["data", "resource"],
outputPaths: [
{ path: "books", name: "Books", icon: "📚", description: "Community PDF library" },
{ path: "collections", name: "Collections", icon: "📑", description: "Curated book collections" },
],
async onSpaceCreate(ctx: SpaceLifecycleContext) {
// Books are global, not space-scoped (for now). No-op.
},
};