/** * rSheet module — collaborative spreadsheets powered by dSheet. * * Embeds @fileverse-dev/dsheet as an external app within the rSpace shell. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderExternalAppShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyToken, extractToken } from "../../server/auth"; import type { SyncServer } from '../../server/local-first/sync-server'; import { sheetSchema, sheetDocId } from './schemas'; import type { SheetDoc } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Local-first helpers ── function ensureSheetDoc(space: string, sheetId: string): SheetDoc { const docId = sheetDocId(space, sheetId); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init sheet', (d) => { const init = sheetSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.sheet.id = sheetId; }); _syncServer!.setDoc(docId, doc); } return doc; } // ── CRUD: Sheets ── routes.get("/api/sheets", (c) => { if (!_syncServer) return c.json({ sheets: [] }); const space = c.req.param("space") || "demo"; const prefix = `${space}:sheet:sheets:`; const sheets: any[] = []; for (const docId of _syncServer.listDocs()) { if (!docId.startsWith(prefix)) continue; const doc = _syncServer.getDoc(docId); if (!doc?.sheet) continue; sheets.push({ id: doc.sheet.id, name: doc.sheet.name, description: doc.sheet.description, cellCount: Object.keys(doc.cells || {}).length, createdAt: doc.sheet.createdAt, updatedAt: doc.sheet.updatedAt }); } return c.json({ sheets }); }); routes.post("/api/sheets", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } 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 { name = "Untitled Sheet", description = "" } = await c.req.json(); const id = crypto.randomUUID(); const docId = sheetDocId(space, id); const doc = Automerge.change(Automerge.init(), 'create sheet', (d) => { const init = sheetSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.sheet.id = id; d.sheet.name = name; d.sheet.description = description; }); _syncServer.setDoc(docId, doc); const created = _syncServer.getDoc(docId)!; return c.json({ id: created.sheet.id, name: created.sheet.name }, 201); }); routes.get("/api/sheets/:id", (c) => { if (!_syncServer) return c.json({ error: "Not initialized" }, 503); const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const doc = _syncServer.getDoc(sheetDocId(space, id)); if (!doc) return c.json({ error: "Not found" }, 404); return c.json({ sheet: doc.sheet, cells: doc.cells, columns: doc.columns, rows: doc.rows }); }); routes.put("/api/sheets/:id/cells", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyToken(token); } 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 = sheetDocId(space, id); const doc = _syncServer.getDoc(docId); if (!doc) return c.json({ error: "Not found" }, 404); const { cells } = await c.req.json(); if (!cells || typeof cells !== 'object') return c.json({ error: "cells object required" }, 400); _syncServer.changeDoc(docId, 'update cells', (d) => { for (const [key, val] of Object.entries(cells as Record)) { d.cells[key] = { value: val.value || '', formula: val.formula || null, format: val.format || null, updatedAt: Date.now() }; } }); return c.json({ ok: true }); }); // ── Routes ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html( renderExternalAppShell({ title: `Spreadsheets — rSheet | rSpace`, moduleId: "rsheet", spaceSlug: space, modules: getModuleInfoList(), appUrl: `/rsheet/app`, appName: "dSheet", theme: "dark", }) ); }); routes.get("/app", (c) => { const space = c.req.param("space") || "demo"; const collabWs = process.env.COLLAB_WS_URL || "wss://collab-ws.rnotes.online"; return c.html(` rSheet — ${space}
Loading spreadsheet...
`); }); // ── MI Integration ── export function getRecentSheetsForMI(space: string, limit = 5): { id: string; name: string; cellCount: number; updatedAt: number }[] { if (!_syncServer) return []; const sheets: { id: string; name: string; cellCount: number; updatedAt: number }[] = []; const prefix = `${space}:sheet:sheets:`; for (const docId of _syncServer.listDocs()) { if (!docId.startsWith(prefix)) continue; const doc = _syncServer.getDoc(docId); if (!doc?.sheet) continue; sheets.push({ id: doc.sheet.id, name: doc.sheet.name, cellCount: Object.keys(doc.cells || {}).length, updatedAt: doc.sheet.updatedAt }); } return sheets.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit); } // ── Module definition ── export const sheetModule: RSpaceModule = { id: "rsheet", name: "rSheet", icon: "\u{1F4CA}", description: "Collaborative spreadsheets", scoping: { defaultScope: "space", userConfigurable: false }, docSchemas: [{ pattern: '{space}:sheet:sheets:{sheetId}', description: 'One doc per spreadsheet', init: sheetSchema.init }], routes, externalApp: { url: "/rsheet/app", name: "dSheet", }, async onInit(ctx) { _syncServer = ctx.syncServer; }, outputPaths: [ { path: "", name: "Spreadsheets", icon: "\u{1F4CA}", description: "Collaborative spreadsheet workspace", }, ], onboardingActions: [ { label: "Open Spreadsheet", icon: "\u{1F4CA}", description: "Create or edit a collaborative spreadsheet", type: "navigate" as any, href: "/rsheet", }, ], };