/** * Notes module — notebooks, rich-text notes, voice transcription. * * Port of rnotes-online (Next.js + Prisma → Hono + postgres.js). * Supports multiple note types: text, code, bookmark, audio, image, file. * * Local-first: All data stored exclusively in Automerge documents via SyncServer. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import { notebookSchema, notebookDocId, createNoteItem } from "./schemas"; import type { NotebookDoc, NoteItem } from "./schemas"; import type { SyncServer } from "../../server/local-first/sync-server"; const routes = new Hono(); // ── SyncServer ref (set during onInit) ── let _syncServer: SyncServer | null = null; // ── Automerge helpers ── /** Lazily ensure a notebook doc exists for a given space + notebookId. */ function ensureDoc(space: string, notebookId: string): NotebookDoc { const docId = notebookDocId(space, notebookId); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init', (d) => { const init = notebookSchema.init(); Object.assign(d, init); d.meta.spaceSlug = space; d.notebook.id = notebookId; }); _syncServer!.setDoc(docId, doc); } return doc; } /** Generate a URL-safe slug from a title. */ function slugify(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 80) || "untitled"; } /** Generate a compact unique ID (timestamp + random suffix). */ function newId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } // ── Automerge ↔ REST conversion helpers ── /** List all notebook docs for a space from the SyncServer. */ function listNotebooks(space: string): { docId: string; doc: NotebookDoc }[] { if (!_syncServer) return []; const results: { docId: string; doc: NotebookDoc }[] = []; const prefix = `${space}:notes:notebooks:`; for (const docId of _syncServer.listDocs()) { if (docId.startsWith(prefix)) { const doc = _syncServer.getDoc(docId); if (doc && doc.notebook && doc.notebook.title) results.push({ docId, doc }); } } return results; } /** Convert an Automerge NotebookDoc to REST API format. */ function notebookToRest(doc: NotebookDoc) { const nb = doc.notebook; return { id: nb.id, title: nb.title, slug: nb.slug, description: nb.description, cover_color: nb.coverColor, is_public: nb.isPublic, note_count: String(Object.keys(doc.items).length), created_at: new Date(nb.createdAt).toISOString(), updated_at: new Date(nb.updatedAt).toISOString(), }; } /** Convert an Automerge NoteItem to REST API format. */ function noteToRest(item: NoteItem) { return { id: item.id, notebook_id: item.notebookId, title: item.title, content: item.content, content_plain: item.contentPlain, content_format: item.contentFormat || undefined, type: item.type, tags: item.tags.length > 0 ? item.tags : null, is_pinned: item.isPinned, sort_order: item.sortOrder, url: item.url, language: item.language, file_url: item.fileUrl, mime_type: item.mimeType, file_size: item.fileSize, duration: item.duration, created_at: new Date(item.createdAt).toISOString(), updated_at: new Date(item.updatedAt).toISOString(), }; } /** Find the notebook doc that contains a given note ID. */ function findNote(space: string, noteId: string): { docId: string; doc: NotebookDoc; item: NoteItem } | null { for (const { docId, doc } of listNotebooks(space)) { const item = doc.items[noteId]; if (item) return { docId, doc, item }; } return null; } // ── Seed demo data into Automerge (runs once if no notebooks exist) ── function seedDemoIfEmpty(space: string) { if (!_syncServer) return; // If the space already has notebooks, skip if (listNotebooks(space).length > 0) return; const now = Date.now(); // Notebook 1: Project Ideas const nb1Id = newId(); const nb1DocId = notebookDocId(space, nb1Id); const nb1Doc = Automerge.change(Automerge.init(), "Seed: Project Ideas", (d) => { d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: space, createdAt: now }; d.notebook = { id: nb1Id, title: "Project Ideas", slug: "project-ideas", description: "Brainstorms and design notes for the r* ecosystem", coverColor: "#6366f1", isPublic: true, createdAt: now, updatedAt: now }; d.items = {}; }); _syncServer.setDoc(nb1DocId, nb1Doc); // Notebook 2: Meeting Notes const nb2Id = newId(); const nb2DocId = notebookDocId(space, nb2Id); const nb2Doc = Automerge.change(Automerge.init(), "Seed: Meeting Notes", (d) => { d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: space, createdAt: now }; d.notebook = { id: nb2Id, title: "Meeting Notes", slug: "meeting-notes", description: "Weekly standups, design reviews, and retrospectives", coverColor: "#f59e0b", isPublic: true, createdAt: now, updatedAt: now }; d.items = {}; }); _syncServer.setDoc(nb2DocId, nb2Doc); // Notebook 3: How-To Guides const nb3Id = newId(); const nb3DocId = notebookDocId(space, nb3Id); const nb3Doc = Automerge.change(Automerge.init(), "Seed: How-To Guides", (d) => { d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: space, createdAt: now }; d.notebook = { id: nb3Id, title: "How-To Guides", slug: "how-to-guides", description: "Tutorials and onboarding guides for contributors", coverColor: "#10b981", isPublic: true, createdAt: now, updatedAt: now }; d.items = {}; }); _syncServer.setDoc(nb3DocId, nb3Doc); // Seed notes into notebooks const notes = [ { nbId: nb1Id, nbDocId: nb1DocId, title: "Cosmolocal Manufacturing Network", content: "## Vision\n\nDesign global, manufacture local. Every creative work should be producible by the nearest capable provider.\n\n## Key Components\n\n- **Artifact Spec**: Standardized envelope describing what to produce\n- **Provider Registry**: Directory of local makers with capabilities + pricing\n- **rCart**: Marketplace connecting creators to providers\n- **Revenue Splits**: 50% provider, 35% creator, 15% community\n\n## Open Questions\n\n- How do we handle quality assurance across distributed providers?\n- Should providers be able to set custom margins?\n- What's the minimum viable set of capabilities for launch?", tags: ["cosmolocal", "architecture"], pinned: true, }, { nbId: nb1Id, nbDocId: nb1DocId, title: "Revenue Sharing Model", content: "## Current Split\n\n| Recipient | Share | Rationale |\n|-----------|-------|-----------|\n| Provider | 50% | Covers materials, labor, shipping |\n| Creator | 35% | Design and creative work |\n| Community | 15% | Platform maintenance, commons fund |\n\n## Enoughness Thresholds\n\nOnce a funnel reaches its sufficient threshold, surplus flows to the next highest-need funnel. This prevents accumulation and keeps resources flowing.\n\n## Implementation\n\nrFunds Flow Service handles deposits from rCart. Each order total is routed through the configured flow → funnel → overflow splits.", tags: ["cosmolocal", "governance"], }, { nbId: nb1Id, nbDocId: nb1DocId, title: "FUN Model: Forget, Update, New", content: "## Replacing CRUD\n\nNothing is permanently destroyed in rSpace.\n\n- **Forget** replaces Delete — soft-delete with `forgotten: true`. Shapes stay in document, hidden from canvas. Memory panel lets you browse + Remember.\n- **Update** stays the same — public `sync.updateShape()` for programmatic updates\n- **New** replaces Create — language shift: toolbar says \"New X\", events are `new-shape`\n\n## Why?\n\nData sovereignty means users should always be able to recover their work. The Memory panel makes forgotten shapes discoverable, like a digital archive.", tags: ["design", "architecture"], }, { nbId: nb2Id, nbDocId: nb2DocId, title: "Weekly Standup — Feb 15, 2026", content: "## Attendees\n\nAlice, Bob, Carol\n\n## Updates\n\n**Alice**: Finished EncryptID guardian recovery flow. 2-of-3 guardian approval working. Next: device linking via QR code.\n\n**Bob**: Provider registry now has 6 printers globally. Working on proximity search with earthdistance extension.\n\n**Carol**: rFunds river visualization deployed. Enoughness layer showing golden glow on sufficient funnels.\n\n## Action Items\n\n- [ ] Alice: Document guardian recovery API endpoints\n- [ ] Bob: Add turnaround time estimates to provider matching\n- [ ] Carol: Add demo mode to river view with mock data", tags: ["standup"], }, { nbId: nb2Id, nbDocId: nb2DocId, title: "Design Review — rBooks Flipbook Reader", content: "## What We Reviewed\n\nThe react-pageflip integration for PDF reading in rBooks.\n\n## Feedback\n\n1. **Page turn animation** — smooth, feels good on desktop. On mobile, swipe gesture needs larger hit area.\n2. **PDF rendering** — react-pdf handles most PDFs well. Large files (>50MB) cause browser memory issues.\n3. **Read Locally mode** — IndexedDB storage works. Need to show storage usage somewhere.\n\n## Decisions\n\n- Ship current version, iterate on mobile\n- Add a 50MB soft warning on upload\n- Explore PDF.js worker for background rendering", tags: ["review", "design"], }, { nbId: nb3Id, nbDocId: nb3DocId, title: "Getting Started with rSpace Development", content: "## Prerequisites\n\n- Bun runtime (v1.3+)\n- Docker + Docker Compose\n- Git access to Gitea\n\n## Local Setup\n\n```bash\ngit clone ssh://git@gitea.jeffemmett.com:223/jeffemmett/rspace-online.git\ncd rspace-online\nbun install\nbun run dev\n```\n\n## Module Structure\n\nEach module lives in `modules/{name}/` and exports an `RSpaceModule` interface:\n\n```typescript\nexport interface RSpaceModule {\n id: string;\n name: string;\n icon: string;\n description: string;\n routes: Hono;\n}\n```\n\n## Adding a New Module\n\n1. Create `modules/{name}/mod.ts`\n2. Create `modules/{name}/components/` for web components\n3. Add build step in `vite.config.ts`\n4. Register in `server/index.ts`", tags: ["onboarding"], }, { nbId: nb3Id, nbDocId: nb3DocId, title: "How to Add a Cosmolocal Provider", content: "## Overview\n\nProviders are local print shops, makerspaces, or studios that can fulfill rCart orders.\n\n## Steps\n\n1. Visit `providers.mycofi.earth`\n2. Sign in with your rStack passkey\n3. Click \"Register Provider\"\n4. Fill in:\n - Name, location (address + coordinates)\n - Capabilities (laser-print, risograph, screen-print, etc.)\n - Substrates (paper types, fabric, vinyl)\n - Turnaround time and pricing\n5. Submit for review\n\n## Matching Algorithm\n\nWhen an order comes in, rCart matches based on:\n- Required capabilities vs. provider capabilities\n- Geographic distance (earthdistance extension)\n- Turnaround time\n- Price", tags: ["cosmolocal", "onboarding"], }, ]; for (const n of notes) { const noteId = newId(); const contentPlain = n.content.replace(/<[^>]*>/g, " ").replace(/[#*|`\-\[\]]/g, " ").replace(/\s+/g, " ").trim(); const item = createNoteItem(noteId, n.nbId, n.title, { content: n.content, contentPlain, tags: n.tags, isPinned: n.pinned || false, }); _syncServer!.changeDoc(n.nbDocId, `Seed note: ${n.title}`, (d) => { d.items[noteId] = item; }); } console.log("[Notes] Demo data seeded: 3 notebooks, 7 notes"); } // ── Content extraction helpers ── /** Recursively extract plain text from a Tiptap JSON node tree. */ function walkTiptapNodes(node: any): string { if (node.text) return node.text; if (!node.content) return ''; return node.content.map(walkTiptapNodes).join(node.type === 'paragraph' ? '\n' : ''); } /** Extract plain text from content, handling both HTML and tiptap-json formats. */ function extractPlainText(content: string, format?: string): string { if (format === 'tiptap-json') { try { const doc = JSON.parse(content); return walkTiptapNodes(doc).trim(); } catch { return ''; } } // Legacy HTML stripping return content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); } // ── Notebooks API ── // GET /api/notebooks — list notebooks routes.get("/api/notebooks", async (c) => { const space = c.req.param("space") || "demo"; const notebooks = listNotebooks(space).map(({ doc }) => notebookToRest(doc)); notebooks.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()); return c.json({ notebooks, source: "automerge" }); }); // POST /api/notebooks — create notebook routes.post("/api/notebooks", async (c) => { const space = c.req.param("space") || "demo"; 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 body = await c.req.json(); const { title, description, cover_color } = body; const nbTitle = title || "Untitled Notebook"; const notebookId = newId(); const now = Date.now(); const doc = ensureDoc(space, notebookId); _syncServer!.changeDoc(notebookDocId(space, notebookId), "Create notebook", (d) => { d.notebook.id = notebookId; d.notebook.title = nbTitle; d.notebook.slug = slugify(nbTitle); d.notebook.description = description || ""; d.notebook.coverColor = cover_color || "#3b82f6"; d.notebook.isPublic = false; d.notebook.createdAt = now; d.notebook.updatedAt = now; }); const updatedDoc = _syncServer!.getDoc(notebookDocId(space, notebookId))!; return c.json(notebookToRest(updatedDoc), 201); }); // GET /api/notebooks/:id — notebook detail with notes routes.get("/api/notebooks/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const docId = notebookDocId(space, id); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook || !doc.notebook.title) { return c.json({ error: "Notebook not found" }, 404); } const nb = notebookToRest(doc); const notes = Object.values(doc.items) .map(noteToRest) .sort((a, b) => { if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); }); return c.json({ ...nb, notes, source: "automerge" }); }); // PUT /api/notebooks/:id — update notebook routes.put("/api/notebooks/:id", async (c) => { const space = c.req.param("space") || "demo"; 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 id = c.req.param("id"); const body = await c.req.json(); const { title, description, cover_color, is_public } = body; if (title === undefined && description === undefined && cover_color === undefined && is_public === undefined) { return c.json({ error: "No fields to update" }, 400); } const docId = notebookDocId(space, id); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook || !doc.notebook.title) { return c.json({ error: "Notebook not found" }, 404); } _syncServer!.changeDoc(docId, "Update notebook", (d) => { if (title !== undefined) d.notebook.title = title; if (description !== undefined) d.notebook.description = description; if (cover_color !== undefined) d.notebook.coverColor = cover_color; if (is_public !== undefined) d.notebook.isPublic = is_public; d.notebook.updatedAt = Date.now(); }); const updatedDoc = _syncServer!.getDoc(docId)!; return c.json(notebookToRest(updatedDoc)); }); // DELETE /api/notebooks/:id routes.delete("/api/notebooks/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const docId = notebookDocId(space, id); const doc = _syncServer?.getDoc(docId); if (!doc || !doc.notebook || !doc.notebook.title) { return c.json({ error: "Notebook not found" }, 404); } // Clear all items and blank the notebook title to mark as deleted. // SyncServer has no removeDoc API, so we empty the doc instead. _syncServer!.changeDoc(docId, "Delete notebook", (d) => { for (const key of Object.keys(d.items)) { delete d.items[key]; } d.notebook.title = ""; d.notebook.updatedAt = Date.now(); }); return c.json({ ok: true }); }); // ── Notes API ── // GET /api/notes — list all notes routes.get("/api/notes", async (c) => { const space = c.req.param("space") || "demo"; const { notebook_id, type, q, limit = "50", offset = "0" } = c.req.query(); let allNotes: ReturnType[] = []; const notebooks = notebook_id ? (() => { const doc = _syncServer?.getDoc(notebookDocId(space, notebook_id)); return doc ? [{ doc }] : []; })() : listNotebooks(space); for (const { doc } of notebooks) { for (const item of Object.values(doc.items)) { if (type && item.type !== type) continue; if (q) { const lower = q.toLowerCase(); if (!item.title.toLowerCase().includes(lower) && !item.contentPlain.toLowerCase().includes(lower)) continue; } allNotes.push(noteToRest(item)); } } // Sort: pinned first, then by updated_at desc allNotes.sort((a, b) => { if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); }); const lim = Math.min(parseInt(limit), 100); const off = parseInt(offset) || 0; return c.json({ notes: allNotes.slice(off, off + lim), source: "automerge" }); }); // POST /api/notes — create note routes.post("/api/notes", async (c) => { const space = c.req.param("space") || "demo"; 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 body = await c.req.json(); const { notebook_id, title, content, content_format, type, url, language, file_url, mime_type, file_size, duration, tags } = body; if (!title?.trim()) return c.json({ error: "Title is required" }, 400); if (!notebook_id) return c.json({ error: "notebook_id is required" }, 400); const contentPlain = content ? extractPlainText(content, content_format) : ""; // Normalize tags const tagNames: string[] = []; if (tags && Array.isArray(tags)) { for (const tagName of tags) { const name = (tagName as string).trim().toLowerCase(); if (name) tagNames.push(name); } } const noteId = newId(); const item = createNoteItem(noteId, notebook_id, title.trim(), { authorId: claims.sub ?? null, content: content || "", contentPlain, type: type || "NOTE", url: url || null, language: language || null, fileUrl: file_url || null, mimeType: mime_type || null, fileSize: file_size || null, duration: duration || null, tags: tagNames, }); // Ensure the notebook doc exists, then add the note ensureDoc(space, notebook_id); const docId = notebookDocId(space, notebook_id); _syncServer!.changeDoc(docId, `Create note: ${title.trim()}`, (d) => { d.items[noteId] = item; d.notebook.updatedAt = Date.now(); }); return c.json(noteToRest(item), 201); }); // GET /api/notes/:id — note detail routes.get("/api/notes/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const found = findNote(space, id); if (!found) return c.json({ error: "Note not found" }, 404); return c.json({ ...noteToRest(found.item), source: "automerge" }); }); // PUT /api/notes/:id — update note routes.put("/api/notes/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const body = await c.req.json(); const { title, content, content_format, type, url, language, is_pinned, sort_order } = body; if (title === undefined && content === undefined && type === undefined && url === undefined && language === undefined && is_pinned === undefined && sort_order === undefined) { return c.json({ error: "No fields to update" }, 400); } const found = findNote(space, id); if (!found) return c.json({ error: "Note not found" }, 404); const contentPlain = content !== undefined ? extractPlainText(content, content_format || found.item.contentFormat) : undefined; _syncServer!.changeDoc(found.docId, `Update note ${id}`, (d) => { const item = d.items[id]; if (!item) return; if (title !== undefined) item.title = title; if (content !== undefined) item.content = content; if (contentPlain !== undefined) item.contentPlain = contentPlain; if (content_format !== undefined) (item as any).contentFormat = content_format; if (type !== undefined) item.type = type; if (url !== undefined) item.url = url; if (language !== undefined) item.language = language; if (is_pinned !== undefined) item.isPinned = is_pinned; if (sort_order !== undefined) item.sortOrder = sort_order; item.updatedAt = Date.now(); }); // Return the updated note const updatedDoc = _syncServer!.getDoc(found.docId)!; const updatedItem = updatedDoc.items[id]; return c.json(noteToRest(updatedItem)); }); // DELETE /api/notes/:id routes.delete("/api/notes/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const found = findNote(space, id); if (!found) return c.json({ error: "Note not found" }, 404); _syncServer!.changeDoc(found.docId, `Delete note ${id}`, (d) => { delete d.items[id]; d.notebook.updatedAt = Date.now(); }); return c.json({ ok: true }); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Notes | rSpace`, moduleId: "rnotes", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); export const notesModule: RSpaceModule = { id: "rnotes", name: "rNotes", icon: "📝", description: "Notebooks with rich-text notes, voice transcription, and collaboration", scoping: { defaultScope: 'global', userConfigurable: true }, routes, docSchemas: [ { pattern: '{space}:notes:notebooks:{notebookId}', description: 'One Automerge doc per notebook, containing all notes as items', init: notebookSchema.init, }, ], seedTemplate: seedDemoIfEmpty, async onInit({ syncServer }) { _syncServer = syncServer; // Seed demo notebooks if the "demo" space is empty seedDemoIfEmpty("demo"); console.log("[Notes] onInit complete (Automerge-only)"); }, async onSpaceCreate(ctx: SpaceLifecycleContext) { if (!_syncServer) return; // Create a default "My Notes" notebook doc for the new space const notebookId = "default"; const docId = notebookDocId(ctx.spaceSlug, notebookId); if (_syncServer.getDoc(docId)) return; // already exists const doc = Automerge.init(); const initialized = Automerge.change(doc, "Create default notebook", (d) => { d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: ctx.spaceSlug, createdAt: Date.now(), }; d.notebook = { id: notebookId, title: "My Notes", slug: "my-notes", description: "Default notebook", coverColor: "#3b82f6", isPublic: false, createdAt: Date.now(), updatedAt: Date.now(), }; d.items = {}; }); _syncServer.setDoc(docId, initialized); console.log(`[Notes] Created default notebook for space: ${ctx.spaceSlug}`); }, landingPage: renderLanding, standaloneDomain: "rnotes.online", feeds: [ { id: "notes-by-tag", name: "Notes by Tag", kind: "data", description: "Stream of notes filtered by tag (design, architecture, etc.)", emits: ["folk-markdown"], filterable: true, }, { id: "recent-notes", name: "Recent Notes", kind: "data", description: "Latest notes across all notebooks", emits: ["folk-markdown"], }, ], acceptsFeeds: ["data", "resource"], outputPaths: [ { path: "notebooks", name: "Notebooks", icon: "📓", description: "Rich-text collaborative notebooks" }, { path: "transcripts", name: "Transcripts", icon: "🎙️", description: "Voice transcription records" }, { path: "articles", name: "Articles", icon: "📰", description: "Published articles and posts" }, ], };