/** * Pubs module — markdown → print-ready pocket books via Typst. * * Ported from pocket-press (Next.js) to Hono routes. * Stateless — no database needed. Artifacts stored in /tmp. */ import { Hono } from "hono"; import { resolve, join } from "node:path"; import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { createTransport, type Transporter } from "nodemailer"; import { parseMarkdown } from "./parse-document"; import { compileDocument, compileDocumentToPages } from "./typst-compile"; import { buildReflowableEpub, buildFixedLayoutEpub } from "./epub-gen"; import { getFormat, FORMATS, listFormats } from "./formats"; import { listPublications, getPublication, getPublicationFile, savePublication, slugify as slugifyPub, } from "./publications-store"; import type { BookFormat } from "./formats"; import { generateImposition } from "./imposition"; import { discoverPrinters } from "./printer-discovery"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; import * as Automerge from "@automerge/automerge"; import { verifyToken, extractToken } from "../../server/auth"; import type { SyncServer } from '../../server/local-first/sync-server'; import { pubsDraftSchema, pubsDocId } from './schemas'; import type { PubsDoc } from './schemas'; const ARTIFACTS_DIR = process.env.ARTIFACTS_DIR || "/tmp/rpubs-artifacts"; // ── SMTP ── let _smtpTransport: Transporter | null = null; function getSmtpTransport(): Transporter | null { if (_smtpTransport) return _smtpTransport; const host = process.env.SMTP_HOST || "mail.rmail.online"; const isInternal = host.includes('mailcow') || host.includes('postfix'); if (!process.env.SMTP_PASS && !isInternal) return null; _smtpTransport = createTransport({ host, port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587), secure: !isInternal && Number(process.env.SMTP_PORT) === 465, ...(isInternal ? {} : { auth: { user: process.env.SMTP_USER || "noreply@rmail.online", pass: process.env.SMTP_PASS!, }, }), tls: { rejectUnauthorized: false }, }); return _smtpTransport; } // ── Email rate limiter (5/hour per IP) ── const emailRateMap = new Map(); function checkEmailRate(ip: string): boolean { const now = Date.now(); const hour = 60 * 60 * 1000; const attempts = (emailRateMap.get(ip) || []).filter((t) => now - t < hour); if (attempts.length >= 5) return false; attempts.push(now); emailRateMap.set(ip, attempts); return true; } // rCart internal URL const RCART_URL = process.env.RCART_URL || "http://localhost:3000"; // ── Types ── interface ArtifactRequest { content: string; title?: string; author?: string; format: string; creator_id?: string; creator_name?: string; creator_wallet?: string; source_space?: string; license?: string; tags?: string[]; description?: string; pricing?: { creator_share_pct?: number; community_share_pct?: number; creator_min?: { amount: number; currency?: string }; suggested_retail?: { amount: number; currency?: string }; }; } // ── Helpers ── function buildArtifactEnvelope(opts: { id: string; format: BookFormat; pageCount: number; pdfSizeBytes: number; parsedTitle: string; req: ArtifactRequest; baseUrl: string; }) { const { id, format, pageCount, pdfSizeBytes, parsedTitle, req, baseUrl } = opts; const isBook = pageCount > 48; const productType = isBook ? "book" : "zine"; const binding = isBook ? "perfect-bind" : "saddle-stitch"; const substrates = isBook ? ["paper-100gsm", "paper-100gsm-recycled", "cover-250gsm"] : ["paper-100gsm-recycled", "paper-100gsm", "paper-80gsm"]; const requiredCapabilities = isBook ? ["laser-print", "perfect-bind"] : ["laser-print", "saddle-stitch"]; const pdfUrl = `${baseUrl}/api/artifact/${id}/pdf`; return { id, schema_version: "1.0" as const, type: "print-ready" as const, origin: "rpubs.online", source_space: req.source_space || null, creator: { id: req.creator_id || "anonymous", ...(req.creator_name ? { name: req.creator_name } : {}), ...(req.creator_wallet ? { wallet: req.creator_wallet } : {}), }, created_at: new Date().toISOString(), ...(req.license ? { license: req.license } : {}), payload: { title: parsedTitle, ...(req.description ? { description: req.description } : { description: `A ${pageCount}-page ${productType} in ${format.name} format, generated by rPubs.` }), ...(req.tags && req.tags.length > 0 ? { tags: req.tags } : {}), }, spec: { product_type: productType, dimensions: { width_mm: format.widthMm, height_mm: format.heightMm, bleed_mm: 3, }, pages: pageCount, color_space: "grayscale", dpi: 300, binding, finish: "uncoated", substrates, required_capabilities: requiredCapabilities, }, render_targets: { [format.id]: { url: pdfUrl, format: "pdf", dpi: 300, file_size_bytes: pdfSizeBytes, notes: `${format.name} format, grayscale, ${binding}. ${pageCount} pages.`, }, }, pricing: { creator_share_pct: req.pricing?.creator_share_pct ?? 30, community_share_pct: req.pricing?.community_share_pct ?? 10, ...(req.pricing?.creator_min ? { creator_min: { amount: req.pricing.creator_min.amount, currency: req.pricing.creator_min.currency || "USD" } } : {}), ...(req.pricing?.suggested_retail ? { suggested_retail: { amount: req.pricing.suggested_retail.amount, currency: req.pricing.suggested_retail.currency || "USD" } } : {}), }, next_actions: [ { tool: "rcart.online", action: "list-for-sale", label: "Sell in community shop", endpoint: "/api/catalog/ingest", method: "POST" }, { tool: "rpubs.online", action: "reformat", label: "Generate another format", endpoint: "/api/reformat", method: "POST" }, { tool: "rfiles.online", action: "archive", label: "Save to files", endpoint: "/api/v1/files/import", method: "POST" }, ], }; } function escapeAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } function renderReaderPage(opts: { record: import("./publications-store").PublicationRecord; spaceSlug: string }): string { const { record, spaceSlug } = opts; const base = `/${escapeAttr(spaceSlug)}/rpubs/publications/${escapeAttr(record.slug)}`; const pdfUrl = `${base}/pdf`; const epubUrl = `${base}/epub`; const epubFixedUrl = record.fixedEpubBytes ? `${base}/epub-fixed` : null; return `

${escapeAttr(record.title)}

${record.author ? `
by ${escapeAttr(record.author)}
` : ""}
${escapeAttr(record.formatName)} · ${record.pageCount} pages · ${new Date(record.createdAt).toLocaleDateString()}
${record.description ? `

${escapeAttr(record.description)}

` : ""}
`; } // ── Routes ── let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── API: List available formats ── routes.get("/api/formats", (c) => { return c.json({ formats: listFormats() }); }); // ── API: Generate PDF (direct download) ── routes.post("/api/generate", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } if (!formatId || !getFormat(formatId)) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); const filename = `${document.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${formatId}.pdf`; return new Response(new Uint8Array(result.pdf), { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": `attachment; filename="${filename}"`, "X-Page-Count": String(result.pageCount), }, }); } catch (error) { console.error("[Pubs] Generation error:", error); return c.json({ error: error instanceof Error ? error.message : "Generation failed" }, 500); } }); // ── API: Create persistent artifact ── routes.post("/api/artifact", async (c) => { try { const body: ArtifactRequest = await c.req.json(); const { content, title, author, format: formatId } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } const format = getFormat(formatId); if (!formatId || !format) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } if (body.tags && !Array.isArray(body.tags)) { return c.json({ error: "tags must be an array of strings" }, 400); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); // Store artifact const artifactId = randomUUID(); const artifactDir = join(ARTIFACTS_DIR, artifactId); await mkdir(artifactDir, { recursive: true }); await writeFile(join(artifactDir, `${formatId}.pdf`), result.pdf); await writeFile(join(artifactDir, "source.md"), content); // Build envelope const proto = c.req.header("x-forwarded-proto") || "https"; const host = c.req.header("host") || "rpubs.online"; const baseUrl = `${proto}://${host}`; const artifact = buildArtifactEnvelope({ id: artifactId, format, pageCount: result.pageCount, pdfSizeBytes: result.pdf.length, parsedTitle: document.title, req: body, baseUrl, }); await writeFile(join(artifactDir, "artifact.json"), JSON.stringify(artifact, null, 2)); return c.json(artifact, 201); } catch (error) { console.error("[Pubs] Artifact error:", error); return c.json({ error: error instanceof Error ? error.message : "Artifact generation failed" }, 500); } }); // ── API: Get artifact (metadata or files) ── routes.get("/api/artifact/:id", async (c) => { const id = c.req.param("id"); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { return c.json({ error: "Invalid artifact ID" }, 400); } const artifactDir = join(ARTIFACTS_DIR, id); try { await stat(artifactDir); } catch { return c.json({ error: "Artifact not found" }, 404); } const fileType = c.req.query("file"); if (fileType === "source") { try { const source = await readFile(join(artifactDir, "source.md")); return new Response(source, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Content-Disposition": `inline; filename="source.md"`, }, }); } catch { return c.json({ error: "Source file not found" }, 404); } } if (fileType === "pdf") { const files = await readdir(artifactDir); const pdfFile = files.find((f) => f.endsWith(".pdf")); if (!pdfFile) return c.json({ error: "PDF not found" }, 404); const pdf = await readFile(join(artifactDir, pdfFile)); return new Response(new Uint8Array(pdf), { headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${pdfFile}"`, "Content-Length": String(pdf.length), }, }); } // Default: return artifact JSON try { const artifactJson = await readFile(join(artifactDir, "artifact.json"), "utf-8"); return new Response(artifactJson, { headers: { "Content-Type": "application/json" }, }); } catch { return c.json({ error: "Artifact metadata not found" }, 404); } }); // ── API: Direct PDF access ── routes.get("/api/artifact/:id/pdf", async (c) => { const id = c.req.param("id"); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { return c.json({ error: "Invalid artifact ID" }, 400); } const artifactDir = join(ARTIFACTS_DIR, id); try { await stat(artifactDir); } catch { return c.json({ error: "Artifact not found" }, 404); } const files = await readdir(artifactDir); const pdfFile = files.find((f) => f.endsWith(".pdf")); if (!pdfFile) return c.json({ error: "PDF not found" }, 404); const pdf = await readFile(join(artifactDir, pdfFile)); return new Response(new Uint8Array(pdf), { headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${pdfFile}"`, "Content-Length": String(pdf.length), }, }); }); // ── API: Generate EPUB (reflowable or fixed-layout) ── routes.post("/api/generate-epub", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId, mode = "reflowable", description } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } const epubMode = mode === "fixed" ? "fixed" : "reflowable"; // Format is only required for fixed-layout (used by Typst). if (epubMode === "fixed" && (!formatId || !getFormat(formatId))) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const document = parseMarkdown(content, title, author); const slug = (document.title || "document").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "document"; let epubBuf: Buffer; let filename: string; let pageCount = 0; if (epubMode === "fixed") { const { pages } = await compileDocumentToPages({ document, formatId, ppi: 144 }); pageCount = pages.length; epubBuf = await buildFixedLayoutEpub({ document, pages, formatId, description }); filename = `${slug}-${formatId}-fixed.epub`; } else { epubBuf = await buildReflowableEpub({ document, description }); filename = `${slug}.epub`; } return new Response(new Uint8Array(epubBuf), { status: 200, headers: { "Content-Type": "application/epub+zip", "Content-Disposition": `attachment; filename="${filename}"`, "Content-Length": String(epubBuf.length), "X-Epub-Mode": epubMode, ...(pageCount ? { "X-Page-Count": String(pageCount) } : {}), }, }); } catch (error) { console.error("[Pubs] EPUB error:", error); return c.json({ error: error instanceof Error ? error.message : "EPUB generation failed" }, 500); } }); // ── API: Publish to a space (persistent, publicly addressable) ── routes.post("/api/publish", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId, description, include_fixed_epub = false, } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } const format = getFormat(formatId); if (!formatId || !format) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const space = c.req.param("space") || c.get("effectiveSpace") || "personal"; const document = parseMarkdown(content, title, author); // Compile PDF (always) + reflowable EPUB (always). const pdfResult = await compileDocument({ document, formatId }); const reflowable = await buildReflowableEpub({ document, description }); // Fixed EPUB only if requested — it's the costly path. let fixedEpub: Buffer | undefined; if (include_fixed_epub) { const { pages } = await compileDocumentToPages({ document, formatId, ppi: 144 }); fixedEpub = await buildFixedLayoutEpub({ document, pages, formatId, description }); } const record = await savePublication({ space, title: document.title, author: document.author || author || "Unknown", description, formatId, formatName: format.name, pageCount: pdfResult.pageCount, sourceMarkdown: content, pdf: pdfResult.pdf, reflowableEpub: reflowable, fixedEpub, }); const proto = c.req.header("x-forwarded-proto") || "https"; const host = c.req.header("host") || "rspace.online"; const baseUrl = `${proto}://${host}`; // The reader lives under the module mount (`/:space/rpubs/publications/:slug`) // or, on the standalone rpubs.online domain, at `/publications/:slug`. const isStandalone = host.includes("rpubs.online"); const hostedPath = isStandalone ? `/publications/${record.slug}` : `/${record.space}/rpubs/publications/${record.slug}`; return c.json({ ...record, hosted_url: `${baseUrl}${hostedPath}`, hosted_path: hostedPath, pdf_url: `${baseUrl}${hostedPath}/pdf`, epub_url: `${baseUrl}${hostedPath}/epub`, epub_fixed_url: fixedEpub ? `${baseUrl}${hostedPath}/epub-fixed` : null, }, 201); } catch (error) { console.error("[Pubs] Publish error:", error); return c.json({ error: error instanceof Error ? error.message : "Publish failed" }, 500); } }); // ── API: List publications in a space ── routes.get("/api/publications", async (c) => { const space = c.req.param("space") || c.get("effectiveSpace") || "personal"; const records = await listPublications(space); return c.json({ publications: records }); }); // ── API: Get publication metadata ── routes.get("/api/publications/:slug", async (c) => { const space = c.req.param("space") || c.get("effectiveSpace") || "personal"; const slug = c.req.param("slug"); const record = await getPublication(space, slug); if (!record) return c.json({ error: "Publication not found" }, 404); return c.json(record); }); // ── Public: Reader page ── routes.get("/publications/:slug", async (c) => { const spaceSlug = c.req.param("space") || "personal"; const dataSpace = c.get("effectiveSpace") || spaceSlug; const slug = c.req.param("slug"); const record = await getPublication(dataSpace, slug); if (!record) { return c.html(`Not found

Publication not found

No publication exists at this URL.

Back to rPubs

`, 404); } return c.html(renderShell({ title: `${record.title} — ${spaceSlug} | rSpace`, moduleId: "rpubs", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: renderReaderPage({ record, spaceSlug }), scripts: ``, styles: ``, })); }); // ── Public: Download PDF ── routes.get("/publications/:slug/pdf", async (c) => { const space = c.req.param("space") || c.get("effectiveSpace") || "personal"; const slug = c.req.param("slug"); const buf = await getPublicationFile(space, slug, "pdf"); if (!buf) return c.json({ error: "PDF not found" }, 404); return new Response(new Uint8Array(buf), { headers: { "Content-Type": "application/pdf", "Content-Disposition": `inline; filename="${slugifyPub(slug)}.pdf"`, "Content-Length": String(buf.length), }, }); }); // ── Public: Download EPUB (reflowable default) ── routes.get("/publications/:slug/epub", async (c) => { const space = c.req.param("space") || c.get("effectiveSpace") || "personal"; const slug = c.req.param("slug"); const buf = await getPublicationFile(space, slug, "reflowable.epub"); if (!buf) return c.json({ error: "EPUB not found" }, 404); return new Response(new Uint8Array(buf), { headers: { "Content-Type": "application/epub+zip", "Content-Disposition": `attachment; filename="${slugifyPub(slug)}.epub"`, "Content-Length": String(buf.length), }, }); }); // ── Public: Download fixed-layout EPUB (if available) ── routes.get("/publications/:slug/epub-fixed", async (c) => { const space = c.req.param("space") || c.get("effectiveSpace") || "personal"; const slug = c.req.param("slug"); const buf = await getPublicationFile(space, slug, "fixed.epub"); if (!buf) return c.json({ error: "Fixed-layout EPUB not available for this publication" }, 404); return new Response(new Uint8Array(buf), { headers: { "Content-Type": "application/epub+zip", "Content-Disposition": `attachment; filename="${slugifyPub(slug)}-fixed.epub"`, "Content-Length": String(buf.length), }, }); }); // ── API: Generate imposition PDF ── routes.post("/api/imposition", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } if (!formatId || !getFormat(formatId)) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); const imposition = await generateImposition(result.pdf, formatId); const filename = `${document.title.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${formatId}-imposition.pdf`; return new Response(new Uint8Array(imposition.pdf), { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": `attachment; filename="${filename}"`, "X-Sheet-Count": String(imposition.sheetCount), "X-Page-Count": String(imposition.pageCount), }, }); } catch (error) { console.error("[Pubs] Imposition error:", error); return c.json({ error: error instanceof Error ? error.message : "Imposition generation failed" }, 500); } }); // ── API: Email PDF ── routes.post("/api/email-pdf", async (c) => { try { const body = await c.req.json(); const { content, title, author, format: formatId, email } = body; if (!content || typeof content !== "string" || content.trim().length === 0) { return c.json({ error: "Content is required" }, 400); } if (!email || typeof email !== "string" || !email.includes("@")) { return c.json({ error: "Valid email is required" }, 400); } const format = getFormat(formatId); if (!formatId || !format) { return c.json({ error: `Invalid format. Available: ${Object.keys(FORMATS).join(", ")}` }, 400); } const ip = c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || "unknown"; if (!checkEmailRate(ip)) { return c.json({ error: "Rate limit exceeded (5 emails/hour). Try again later." }, 429); } const transport = getSmtpTransport(); if (!transport) { return c.json({ error: "Email service not configured" }, 503); } const document = parseMarkdown(content, title, author); const result = await compileDocument({ document, formatId }); const slug = (title || document.title || "document") .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@rmail.online"; await transport.sendMail({ from: `"rPubs Press" <${fromAddr}>`, to: email, subject: `Your publication: ${title || document.title || "Untitled"}`, text: [ `Here's your publication from rPubs Pocket Press.`, ``, `Title: ${title || document.title || "Untitled"}`, author ? `Author: ${author}` : null, `Format: ${format.name} (${format.widthMm}\u00D7${format.heightMm}mm)`, `Pages: ${result.pageCount}`, ``, `---`, `rPubs \u00B7 Community pocket press`, `https://rpubs.online`, ].filter(Boolean).join("\n"), html: [ `
`, `

Your publication is ready

`, `

`, `${title || document.title || "Untitled"}`, author ? ` by ${author}` : "", `

`, ``, ``, ``, `
Format${format.name}
Pages${result.pageCount}
`, `

The PDF is attached below.

`, `
`, `

rPubs · Community pocket press · rpubs.online

`, `
`, ].join("\n"), attachments: [{ filename: `${slug}-${formatId}.pdf`, content: Buffer.from(result.pdf), contentType: "application/pdf", }], }); return c.json({ ok: true, message: `PDF sent to ${email}` }); } catch (error) { console.error("[Pubs] Email error:", error); return c.json({ error: error instanceof Error ? error.message : "Failed to send email" }, 500); } }); // ── API: Discover printers ── routes.get("/api/printers", async (c) => { try { const lat = parseFloat(c.req.query("lat") || ""); const lng = parseFloat(c.req.query("lng") || ""); if (isNaN(lat) || isNaN(lng)) { return c.json({ error: "lat and lng are required" }, 400); } const radiusKm = parseFloat(c.req.query("radius") || "100"); const formatId = c.req.query("format") || undefined; const providers = await discoverPrinters({ lat, lng, radiusKm, formatId }); return c.json({ providers }); } catch (error) { console.error("[Pubs] Printer discovery error:", error); return c.json({ error: error instanceof Error ? error.message : "Discovery failed" }, 500); } }); // ── API: Place order (forward to rCart) ── routes.post("/api/order", async (c) => { try { const body = await c.req.json(); const { provider_id, total_price } = body; if (!provider_id || total_price === undefined) { return c.json({ error: "provider_id and total_price are required" }, 400); } // Generate artifact first if content is provided let artifactId = body.artifact_id; if (!artifactId && body.content) { const document = parseMarkdown(body.content, body.title, body.author); const formatId = body.format || "digest"; const result = await compileDocument({ document, formatId }); artifactId = randomUUID(); const artifactDir = join(ARTIFACTS_DIR, artifactId); await mkdir(artifactDir, { recursive: true }); await writeFile(join(artifactDir, `${formatId}.pdf`), result.pdf); await writeFile(join(artifactDir, "source.md"), body.content); } const orderRes = await fetch(`${RCART_URL}/api/orders`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ catalog_entry_id: body.catalog_entry_id, artifact_id: artifactId, provider_id, provider_name: body.provider_name, provider_distance_km: body.provider_distance_km, quantity: body.quantity || 1, production_cost: body.production_cost, creator_payout: body.creator_payout, community_payout: body.community_payout, total_price, currency: body.currency || "USD", payment_method: "manual", buyer_contact: body.buyer_contact, buyer_location: body.buyer_location, }), }); if (!orderRes.ok) { const err = await orderRes.json().catch(() => ({})); console.error("[Pubs] rCart order failed:", err); return c.json({ error: "Failed to create order" }, 502 as any); } const order = await orderRes.json(); return c.json(order, 201); } catch (error) { console.error("[Pubs] Order error:", error); return c.json({ error: error instanceof Error ? error.message : "Order creation failed" }, 500); } }); // ── API: Batch / group buy ── routes.post("/api/batch", async (c) => { try { const body = await c.req.json(); const { artifact_id, catalog_entry_id, provider_id, provider_name, buyer_contact, buyer_location, quantity = 1 } = body; if (!artifact_id && !catalog_entry_id) { return c.json({ error: "artifact_id or catalog_entry_id required" }, 400); } if (!provider_id) { return c.json({ error: "provider_id required" }, 400); } // Check for existing open batch const searchParams = new URLSearchParams({ artifact_id: artifact_id || catalog_entry_id, status: "open", ...(provider_id && { provider_id }), }); const existingRes = await fetch(`${RCART_URL}/api/batches?${searchParams}`); const existingData = await existingRes.json(); const openBatches = (existingData.batches || []).filter( (b: { provider_id: string }) => b.provider_id === provider_id ); if (openBatches.length > 0) { const batch = openBatches[0]; const joinRes = await fetch(`${RCART_URL}/api/batches/${batch.id}/join`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ buyer_contact, buyer_location, quantity }), }); if (!joinRes.ok) { const err = await joinRes.json().catch(() => ({})); return c.json({ error: (err as any).error || "Failed to join batch" }, 502 as any); } const result = await joinRes.json(); return c.json({ action: "joined", ...result }); } // Create new batch const createRes = await fetch(`${RCART_URL}/api/batches`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ catalog_entry_id, artifact_id, provider_id, provider_name }), }); if (!createRes.ok) { const err = await createRes.json().catch(() => ({})); return c.json({ error: (err as any).error || "Failed to create batch" }, 502 as any); } const batch = await createRes.json(); const joinRes = await fetch(`${RCART_URL}/api/batches/${batch.id}/join`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ buyer_contact, buyer_location, quantity }), }); if (!joinRes.ok) { return c.json({ action: "created", batch, member: null }, 201); } const joinResult = await joinRes.json(); return c.json({ action: "created", ...joinResult }, 201); } catch (error) { console.error("[Pubs] Batch error:", error); return c.json({ error: error instanceof Error ? error.message : "Batch operation failed" }, 500); } }); routes.get("/api/batch", async (c) => { const artifactId = c.req.query("artifact_id"); const providerId = c.req.query("provider_id"); if (!artifactId) { return c.json({ error: "artifact_id required" }, 400); } const params = new URLSearchParams({ artifact_id: artifactId, status: "open" }); if (providerId) params.set("provider_id", providerId); try { const res = await fetch(`${RCART_URL}/api/batches?${params}`); if (!res.ok) return c.json({ batches: [] }); const data = await res.json(); return c.json(data); } catch { return c.json({ batches: [] }); } }); // ── CRUD: Drafts (Automerge) ── routes.get("/api/drafts", (c) => { if (!_syncServer) return c.json({ drafts: [] }); const space = c.req.param("space") || "demo"; const prefix = `${space}:pubs:drafts:`; const drafts: any[] = []; for (const docId of _syncServer.listDocs()) { if (!docId.startsWith(prefix)) continue; const doc = _syncServer.getDoc(docId); if (!doc?.draft) continue; drafts.push({ id: doc.draft.id, title: doc.draft.title, author: doc.draft.author, format: doc.draft.format, createdAt: doc.draft.createdAt, updatedAt: doc.draft.updatedAt }); } return c.json({ drafts: drafts.sort((a, b) => b.updatedAt - a.updatedAt) }); }); routes.post("/api/drafts", async (c) => { const authToken = extractToken(c.req.raw.headers); if (!authToken) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const { title = "Untitled", author = "", format = "digest", content = "" } = await c.req.json(); const id = crypto.randomUUID(); const docId = pubsDocId(space, id); const now = Date.now(); const doc = Automerge.change(Automerge.init(), 'create draft', (d) => { const init = pubsDraftSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.draft.id = id; d.draft.title = title; d.draft.author = author; d.draft.format = format; d.draft.createdAt = now; d.draft.updatedAt = now; d.content = content; }); _syncServer.setDoc(docId, doc); return c.json({ id, title, author, format }, 201); }); routes.delete("/api/drafts/:id", async (c) => { const authToken = extractToken(c.req.raw.headers); if (!authToken) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); } if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const docId = pubsDocId(space, id); const doc = _syncServer.getDoc(docId); if (!doc) return c.json({ error: "Not found" }, 404); _syncServer.changeDoc(docId, `delete draft ${id}`, (d) => { d.draft.title = '[deleted]'; d.content = ''; }); return c.json({ ok: true }); }); // ── Page: Zine Generator (redirect to canvas with auto-spawn) ── routes.get("/zine", (c) => { const spaceSlug = c.req.param("space") || "personal"; return c.redirect(`/${spaceSlug}?tool=folk-zine-gen`); }); // ── Page: Editor (also served at /press) ── routes.get("/press", (c) => { const spaceSlug = c.req.param("space") || "personal"; const dataSpace = c.get("effectiveSpace") || spaceSlug; return c.html(renderShell({ title: `${spaceSlug} — rPubs Press | rSpace`, moduleId: "rpubs", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ` `, styles: ``, })); }); routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "personal"; const dataSpace = c.get("effectiveSpace") || spaceSlug; return c.html(renderShell({ title: `${spaceSlug} — rPubs Editor | rSpace`, moduleId: "rpubs", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ` `, styles: ``, })); }); export function getRecentPublicationsForMI(space: string, limit = 5): { id: string; title: string; author: string; format: string; updatedAt: number }[] { if (!_syncServer) return []; const prefix = `${space}:pubs:drafts:`; const items: { id: string; title: string; author: string; format: string; updatedAt: number }[] = []; for (const docId of _syncServer.listDocs()) { if (!docId.startsWith(prefix)) continue; const doc = _syncServer.getDoc(docId); if (!doc?.draft || doc.draft.title === '[deleted]') continue; items.push({ id: doc.draft.id, title: doc.draft.title, author: doc.draft.author, format: doc.draft.format, updatedAt: doc.draft.updatedAt }); } return items.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit); } // ── Module export ── export const pubsModule: RSpaceModule = { id: "rpubs", name: "rPubs", icon: "📖", description: "Drop in a document, get a pocket book", scoping: { defaultScope: 'global', userConfigurable: true }, routes, docSchemas: [{ pattern: '{space}:pubs:drafts:{draftId}', description: 'One doc per publication draft', init: pubsDraftSchema.init }], async onInit(ctx) { _syncServer = ctx.syncServer; }, publicWrite: true, standaloneDomain: "rpubs.online", landingPage: renderLanding, feeds: [ { id: "publications", name: "Publications", kind: "data", description: "Print-ready artifacts generated from markdown documents", filterable: true, }, { id: "citations", name: "Citations", kind: "data", description: "Citation and reference data extracted from documents", }, ], acceptsFeeds: ["data"], outputPaths: [ { path: "publications", name: "Publications", icon: "📖", description: "Published pocket books and zines" }, { path: "drafts", name: "Drafts", icon: "📝", description: "Work-in-progress documents" }, ], };