rspace-online/modules/rnotes/mod.ts

762 lines
30 KiB
TypeScript

/**
* 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 migration: dual-write (Automerge + PG) during transition.
*/
import { Hono } from "hono";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import * as Automerge from "@automerge/automerge";
import { sql } from "../../shared/db/pool";
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 } from "./schemas";
import type { NotebookDoc, NoteItem } from "./schemas";
import type { SyncServer } from "../../server/local-first/sync-server";
const routes = new Hono();
// ── DB initialization ──
const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8");
async function initDB() {
try {
await sql.unsafe(SCHEMA_SQL);
console.log("[Notes] DB schema initialized");
} catch (e) {
console.error("[Notes] DB init error:", e);
}
}
async function seedDemoIfEmpty() {
try {
const count = await sql.unsafe("SELECT count(*)::int as cnt FROM rnotes.notebooks");
if (parseInt(count[0].cnt) > 0) return;
// Notebook 1: Project Ideas
const nb1 = await sql.unsafe(
`INSERT INTO rnotes.notebooks (title, description, cover_color, is_public)
VALUES ('Project Ideas', 'Brainstorms and design notes for the r* ecosystem', '#6366f1', true) RETURNING id`
);
// Notebook 2: Meeting Notes
const nb2 = await sql.unsafe(
`INSERT INTO rnotes.notebooks (title, description, cover_color, is_public)
VALUES ('Meeting Notes', 'Weekly standups, design reviews, and retrospectives', '#f59e0b', true) RETURNING id`
);
// Notebook 3: How-To Guides
const nb3 = await sql.unsafe(
`INSERT INTO rnotes.notebooks (title, description, cover_color, is_public)
VALUES ('How-To Guides', 'Tutorials and onboarding guides for contributors', '#10b981', true) RETURNING id`
);
// Create tags
const tagIds: Record<string, string> = {};
for (const name of ["design", "architecture", "cosmolocal", "governance", "onboarding", "review", "standup"]) {
const row = await sql.unsafe(
`INSERT INTO rnotes.tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = $1 RETURNING id`,
[name]
);
tagIds[name] = row[0].id;
}
// Seed notes
const notes = [
{
nbId: nb1[0].id, 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: nb1[0].id, 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: nb1[0].id, 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: nb2[0].id, 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: nb2[0].id, 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: nb3[0].id, 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: nb3[0].id, 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 row = await sql.unsafe(
`INSERT INTO rnotes.notes (notebook_id, title, content, content_plain, type, is_pinned)
VALUES ($1, $2, $3, $4, 'NOTE', $5) RETURNING id`,
[n.nbId, n.title, n.content, n.content.replace(/<[^>]*>/g, " ").replace(/[#*|`\-\[\]]/g, " ").replace(/\s+/g, " ").trim(), n.pinned || false]
);
for (const tagName of n.tags) {
if (tagIds[tagName]) {
await sql.unsafe(
"INSERT INTO rnotes.note_tags (note_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[row[0].id, tagIds[tagName]]
);
}
}
}
console.log("[Notes] Demo data seeded: 3 notebooks, 7 notes, 7 tags");
} catch (e) {
console.error("[Notes] Seed error:", e);
}
}
// initDB + seedDemo are called from onInit lifecycle hook (see module export below)
// ── SyncServer ref (set during onInit) ──
let _syncServer: SyncServer | null = null;
/** Check if a space has been migrated to local-first for notes. */
function isLocalFirst(space: string): boolean {
if (!_syncServer) return false;
// A space is local-first if any notebook doc exists for it in the SyncServer
// We check by looking for docs with the pattern {space}:notes:notebooks:*
return _syncServer.getDoc(`${space}:notes:notebooks:default`) !== undefined;
}
// ── Automerge ↔ REST conversion helpers ──
/** List all notebook docs for a space from the SyncServer. */
function listAutomergeNotebooks(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<NotebookDoc>(docId);
if (doc) 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,
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 findNoteInAutomerge(space: string, noteId: string): { docId: string; doc: NotebookDoc; item: NoteItem } | null {
for (const { docId, doc } of listAutomergeNotebooks(space)) {
const item = doc.items[noteId];
if (item) return { docId, doc, item };
}
return null;
}
/** Write a note to the Automerge doc (creates/updates). */
function writeNoteToAutomerge(space: string, notebookPgId: string, noteId: string, data: Partial<NoteItem>): void {
if (!_syncServer) return;
// Find the Automerge notebook doc for this PG notebook
// Convention: PG notebook UUID maps to docId suffix
const docId = notebookDocId(space, notebookPgId);
const doc = _syncServer.getDoc<NotebookDoc>(docId);
if (!doc) return; // not migrated yet
_syncServer.changeDoc<NotebookDoc>(docId, `Update note ${noteId}`, (d) => {
if (!d.items[noteId]) {
// New note
d.items[noteId] = {
id: noteId,
notebookId: notebookPgId,
authorId: data.authorId ?? null,
title: data.title ?? '',
content: data.content ?? '',
contentPlain: data.contentPlain ?? '',
type: data.type ?? 'NOTE',
url: data.url ?? null,
language: data.language ?? null,
fileUrl: data.fileUrl ?? null,
mimeType: data.mimeType ?? null,
fileSize: data.fileSize ?? null,
duration: data.duration ?? null,
isPinned: data.isPinned ?? false,
sortOrder: data.sortOrder ?? 0,
tags: data.tags ?? [],
createdAt: data.createdAt ?? Date.now(),
updatedAt: Date.now(),
};
} else {
// Update existing fields
const item = d.items[noteId];
if (data.title !== undefined) item.title = data.title;
if (data.content !== undefined) item.content = data.content;
if (data.contentPlain !== undefined) item.contentPlain = data.contentPlain;
if (data.type !== undefined) item.type = data.type;
if (data.url !== undefined) item.url = data.url;
if (data.language !== undefined) item.language = data.language;
if (data.isPinned !== undefined) item.isPinned = data.isPinned;
if (data.sortOrder !== undefined) item.sortOrder = data.sortOrder;
if (data.tags !== undefined) item.tags = data.tags;
item.updatedAt = Date.now();
}
});
}
// ── Helper: get or create user ──
async function getOrCreateUser(did: string, username?: string) {
const rows = await sql.unsafe(
`INSERT INTO rnotes.users (did, username) VALUES ($1, $2)
ON CONFLICT (did) DO UPDATE SET username = COALESCE($2, rnotes.users.username)
RETURNING *`,
[did, username || null]
);
return rows[0];
}
// ── Notebooks API ──
// GET /api/notebooks — list notebooks (Automerge-first, PG fallback)
routes.get("/api/notebooks", async (c) => {
const space = c.req.param("space") || "demo";
// Try Automerge first
if (isLocalFirst(space)) {
const notebooks = listAutomergeNotebooks(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" });
}
// PG fallback
const rows = await sql.unsafe(
`SELECT n.*, count(note.id) as note_count
FROM rnotes.notebooks n
LEFT JOIN rnotes.notes note ON note.notebook_id = n.id
GROUP BY n.id
ORDER BY n.updated_at DESC LIMIT 50`
);
return c.json({ notebooks: rows });
});
// POST /api/notebooks — create notebook (dual-write)
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;
// PG write
const user = await getOrCreateUser(claims.sub, claims.username);
const rows = await sql.unsafe(
`INSERT INTO rnotes.notebooks (title, description, cover_color, owner_id)
VALUES ($1, $2, $3, $4) RETURNING *`,
[title || "Untitled Notebook", description || null, cover_color || "#3b82f6", user.id]
);
const pgRow = rows[0];
// Automerge dual-write: create a new notebook doc
if (_syncServer && isLocalFirst(space)) {
const docId = notebookDocId(space, pgRow.id);
if (!_syncServer.getDoc(docId)) {
const doc = Automerge.init<NotebookDoc>();
const initialized = Automerge.change(doc, "Create notebook", (d) => {
d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: space, createdAt: Date.now() };
d.notebook = {
id: pgRow.id, title: pgRow.title, slug: pgRow.slug || "",
description: pgRow.description || "", coverColor: pgRow.cover_color || "#3b82f6",
isPublic: pgRow.is_public || false, createdAt: Date.now(), updatedAt: Date.now(),
};
d.items = {};
});
_syncServer.setDoc(docId, initialized);
}
}
return c.json(pgRow, 201);
});
// GET /api/notebooks/:id — notebook detail with notes (Automerge-first)
routes.get("/api/notebooks/:id", async (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
// Automerge first
if (isLocalFirst(space)) {
const docId = notebookDocId(space, id);
const doc = _syncServer?.getDoc<NotebookDoc>(docId);
if (doc) {
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" });
}
}
// PG fallback
const nb = await sql.unsafe("SELECT * FROM rnotes.notebooks WHERE id = $1", [id]);
if (nb.length === 0) return c.json({ error: "Notebook not found" }, 404);
const notes = await sql.unsafe(
`SELECT n.*, array_agg(t.name) FILTER (WHERE t.name IS NOT NULL) as tags
FROM rnotes.notes n
LEFT JOIN rnotes.note_tags nt ON nt.note_id = n.id
LEFT JOIN rnotes.tags t ON t.id = nt.tag_id
WHERE n.notebook_id = $1
GROUP BY n.id
ORDER BY n.is_pinned DESC, n.sort_order ASC, n.updated_at DESC`,
[id]
);
return c.json({ ...nb[0], notes });
});
// PUT /api/notebooks/:id — update notebook (dual-write)
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;
// PG write
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (title !== undefined) { fields.push(`title = $${idx}`); params.push(title); idx++; }
if (description !== undefined) { fields.push(`description = $${idx}`); params.push(description); idx++; }
if (cover_color !== undefined) { fields.push(`cover_color = $${idx}`); params.push(cover_color); idx++; }
if (is_public !== undefined) { fields.push(`is_public = $${idx}`); params.push(is_public); idx++; }
if (fields.length === 0) return c.json({ error: "No fields to update" }, 400);
fields.push("updated_at = NOW()");
params.push(id);
const rows = await sql.unsafe(
`UPDATE rnotes.notebooks SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
if (rows.length === 0) return c.json({ error: "Notebook not found" }, 404);
// Automerge dual-write: update notebook metadata
if (_syncServer && isLocalFirst(space)) {
const docId = notebookDocId(space, id);
_syncServer.changeDoc<NotebookDoc>(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();
});
}
return c.json(rows[0]);
});
// DELETE /api/notebooks/:id (dual-write)
routes.delete("/api/notebooks/:id", async (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
const result = await sql.unsafe(
"DELETE FROM rnotes.notebooks WHERE id = $1 RETURNING id", [id]
);
if (result.length === 0) return c.json({ error: "Notebook not found" }, 404);
// Automerge: remove the entire doc from SyncServer
// (SyncServer doesn't have a removeDoc — setting it to empty is the equivalent)
// For now, the doc persists in Automerge but is effectively orphaned once PG row is gone.
return c.json({ ok: true });
});
// ── Notes API ──
// GET /api/notes — list all notes (Automerge-first, PG fallback)
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();
// Automerge first
if (isLocalFirst(space)) {
let allNotes: ReturnType<typeof noteToRest>[] = [];
const notebooks = notebook_id
? [{ doc: _syncServer!.getDoc<NotebookDoc>(notebookDocId(space, notebook_id))! }].filter(x => x.doc)
: listAutomergeNotebooks(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" });
}
// PG fallback
const conditions: string[] = [];
const params: any[] = [];
let idx = 1;
if (notebook_id) { conditions.push(`n.notebook_id = $${idx}`); params.push(notebook_id); idx++; }
if (type) { conditions.push(`n.type = $${idx}`); params.push(type); idx++; }
if (q) {
conditions.push(`(n.title ILIKE $${idx} OR n.content_plain ILIKE $${idx})`);
params.push(`%${q}%`);
idx++;
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const rows = await sql.unsafe(
`SELECT n.*, array_agg(t.name) FILTER (WHERE t.name IS NOT NULL) as tags
FROM rnotes.notes n
LEFT JOIN rnotes.note_tags nt ON nt.note_id = n.id
LEFT JOIN rnotes.tags t ON t.id = nt.tag_id
${where}
GROUP BY n.id
ORDER BY n.is_pinned DESC, n.updated_at DESC
LIMIT ${Math.min(parseInt(limit), 100)} OFFSET ${parseInt(offset) || 0}`,
params
);
return c.json({ notes: rows });
});
// POST /api/notes — create note (dual-write)
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, type, url, language, file_url, mime_type, file_size, duration, tags } = body;
if (!title?.trim()) return c.json({ error: "Title is required" }, 400);
// Strip HTML for plain text search
const contentPlain = content ? content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() : null;
// PG write
const rows = await sql.unsafe(
`INSERT INTO rnotes.notes (notebook_id, title, content, content_plain, type, url, language, file_url, mime_type, file_size, duration)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[notebook_id || null, title.trim(), content || "", contentPlain, type || "NOTE",
url || null, language || null, file_url || null, mime_type || null, file_size || null, duration || null]
);
// Handle tags in PG
const tagNames: string[] = [];
if (tags && Array.isArray(tags)) {
for (const tagName of tags) {
const name = tagName.trim().toLowerCase();
if (!name) continue;
tagNames.push(name);
const tag = await sql.unsafe(
"INSERT INTO rnotes.tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = $1 RETURNING id",
[name]
);
await sql.unsafe(
"INSERT INTO rnotes.note_tags (note_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[rows[0].id, tag[0].id]
);
}
}
// Automerge dual-write
if (notebook_id && isLocalFirst(space)) {
writeNoteToAutomerge(space, notebook_id, rows[0].id, {
title: title.trim(),
content: content || '',
contentPlain: 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,
});
}
return c.json(rows[0], 201);
});
// GET /api/notes/:id — note detail (Automerge-first)
routes.get("/api/notes/:id", async (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
// Automerge first
if (isLocalFirst(space)) {
const found = findNoteInAutomerge(space, id);
if (found) return c.json({ ...noteToRest(found.item), source: "automerge" });
}
// PG fallback
const rows = await sql.unsafe(
`SELECT n.*, array_agg(t.name) FILTER (WHERE t.name IS NOT NULL) as tags
FROM rnotes.notes n
LEFT JOIN rnotes.note_tags nt ON nt.note_id = n.id
LEFT JOIN rnotes.tags t ON t.id = nt.tag_id
WHERE n.id = $1
GROUP BY n.id`,
[id]
);
if (rows.length === 0) return c.json({ error: "Note not found" }, 404);
return c.json(rows[0]);
});
// PUT /api/notes/:id — update note (dual-write)
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, type, url, language, is_pinned, sort_order } = body;
// PG write
const fields: string[] = [];
const params: any[] = [];
let idx = 1;
if (title !== undefined) { fields.push(`title = $${idx}`); params.push(title); idx++; }
if (content !== undefined) {
fields.push(`content = $${idx}`); params.push(content); idx++;
const plain = content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
fields.push(`content_plain = $${idx}`); params.push(plain); idx++;
}
if (type !== undefined) { fields.push(`type = $${idx}`); params.push(type); idx++; }
if (url !== undefined) { fields.push(`url = $${idx}`); params.push(url); idx++; }
if (language !== undefined) { fields.push(`language = $${idx}`); params.push(language); idx++; }
if (is_pinned !== undefined) { fields.push(`is_pinned = $${idx}`); params.push(is_pinned); idx++; }
if (sort_order !== undefined) { fields.push(`sort_order = $${idx}`); params.push(sort_order); idx++; }
if (fields.length === 0) return c.json({ error: "No fields to update" }, 400);
fields.push("updated_at = NOW()");
params.push(id);
const rows = await sql.unsafe(
`UPDATE rnotes.notes SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`,
params
);
if (rows.length === 0) return c.json({ error: "Note not found" }, 404);
// Automerge dual-write
if (isLocalFirst(space)) {
const found = findNoteInAutomerge(space, id);
if (found) {
const contentPlain = content ? content.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim() : undefined;
writeNoteToAutomerge(space, found.item.notebookId, id, {
...(title !== undefined ? { title } : {}),
...(content !== undefined ? { content, contentPlain } : {}),
...(type !== undefined ? { type } : {}),
...(url !== undefined ? { url } : {}),
...(language !== undefined ? { language } : {}),
...(is_pinned !== undefined ? { isPinned: is_pinned } : {}),
...(sort_order !== undefined ? { sortOrder: sort_order } : {}),
});
}
}
return c.json(rows[0]);
});
// DELETE /api/notes/:id (dual-write)
routes.delete("/api/notes/:id", async (c) => {
const space = c.req.param("space") || "demo";
const id = c.req.param("id");
// PG delete
const result = await sql.unsafe("DELETE FROM rnotes.notes WHERE id = $1 RETURNING id, notebook_id", [id]);
if (result.length === 0) return c.json({ error: "Note not found" }, 404);
// Automerge dual-write: remove note from notebook doc
if (isLocalFirst(space) && result[0].notebook_id && _syncServer) {
const docId = notebookDocId(space, result[0].notebook_id);
_syncServer.changeDoc<NotebookDoc>(docId, `Delete note ${id}`, (d) => {
delete d.items[id];
});
}
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: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css">`,
}));
});
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,
},
],
async onInit({ syncServer }) {
_syncServer = syncServer;
// Init PG (still needed during dual-write period)
await initDB();
await seedDemoIfEmpty();
console.log("[Notes] onInit complete (PG + schema registered)");
},
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<NotebookDoc>();
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" },
],
};