/** * 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(docId); if (!doc) { doc = Automerge.change(Automerge.init(), '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, "&").replace(/"/g, """).replace(//g, ">"); } // ── 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(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(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(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: ``, scripts: ``, styles: ``, })); }); // ── 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: `

Book not found

Back to library

`, modules: getModuleInfoList(), }); return c.html(html, 404); } // Increment view count const docId = booksCatalogDocId(spaceSlug); _syncServer!.changeDoc(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: ` `, modules: getModuleInfoList(), theme: "dark", head: ``, scripts: ` `, }); return c.html(html); }); // ── Seed template data ── function seedTemplateBooks(space: string) { if (!_syncServer) return; const docId = booksCatalogDocId(space); const doc = ensureDoc(space); if (Object.keys(doc.items).length > 0) return; const now = Date.now(); const books: Array<{ title: string; author: string; desc: string; tags: string[]; color: string }> = [ { title: 'Governing the Commons', author: 'Elinor Ostrom', desc: 'Nobel Prize-winning analysis of how communities self-organize to manage shared resources without privatization or state control.', tags: ['commons', 'governance', 'economics'], color: '#6366f1', }, { title: 'Doughnut Economics', author: 'Kate Raworth', desc: 'A framework for 21st-century economics that meets the needs of all within the means of the planet.', tags: ['economics', 'sustainability', 'systems-thinking'], color: '#10b981', }, { title: 'Sacred Economics', author: 'Charles Eisenstein', desc: 'Tracing the history of money from ancient gift economies to modern capitalism, and envisioning a more connected economy.', tags: ['economics', 'gift-economy', 'philosophy'], color: '#f59e0b', }, ]; _syncServer.changeDoc(docId, 'seed template books', (d) => { for (const b of books) { const id = crypto.randomUUID(); const slug = b.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); d.items[id] = { id, slug, title: b.title, author: b.author, description: b.desc, pdfPath: '', pdfSizeBytes: 0, pageCount: 0, tags: b.tags, license: 'CC BY-SA 4.0', coverColor: b.color, contributorId: 'did:demo:seed', contributorName: 'Community Library', status: 'published', featured: false, viewCount: 0, downloadCount: 0, createdAt: now, updatedAt: now, }; } }); console.log(`[Books] Template seeded for "${space}": 3 book entries`); } // ── 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, seedTemplate: seedTemplateBooks, 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. }, };