619 lines
24 KiB
TypeScript
619 lines
24 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: 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<NotebookDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<NotebookDoc>(), '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<NotebookDoc>(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<NotebookDoc>(), "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<NotebookDoc>(), "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<NotebookDoc>(), "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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(docId);
|
|
if (!doc || !doc.notebook || !doc.notebook.title) {
|
|
return c.json({ error: "Notebook not found" }, 404);
|
|
}
|
|
|
|
_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();
|
|
});
|
|
|
|
const updatedDoc = _syncServer!.getDoc<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<typeof noteToRest>[] = [];
|
|
const notebooks = notebook_id
|
|
? (() => {
|
|
const doc = _syncServer?.getDoc<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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<NotebookDoc>(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: `<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,
|
|
},
|
|
],
|
|
|
|
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<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" },
|
|
],
|
|
};
|