416 lines
11 KiB
TypeScript
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, "&").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<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.
|
|
},
|
|
};
|