/** * 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. */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; 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); } } initDB(); // ── 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 routes.get("/api/notebooks", async (c) => { 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 routes.post("/api/notebooks", async (c) => { const body = await c.req.json(); const { title, description, cover_color } = body; const rows = await sql.unsafe( `INSERT INTO rnotes.notebooks (title, description, cover_color) VALUES ($1, $2, $3) RETURNING *`, [title || "Untitled Notebook", description || null, cover_color || "#3b82f6"] ); return c.json(rows[0], 201); }); // GET /api/notebooks/:id — notebook detail with notes routes.get("/api/notebooks/:id", async (c) => { const id = c.req.param("id"); 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 routes.put("/api/notebooks/:id", async (c) => { const id = c.req.param("id"); const body = await c.req.json(); const { title, description, cover_color, is_public } = body; const fields: string[] = []; const params: unknown[] = []; 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); return c.json(rows[0]); }); // DELETE /api/notebooks/:id routes.delete("/api/notebooks/:id", async (c) => { const result = await sql.unsafe( "DELETE FROM rnotes.notebooks WHERE id = $1 RETURNING id", [c.req.param("id")] ); if (result.length === 0) return c.json({ error: "Notebook not found" }, 404); return c.json({ ok: true }); }); // ── Notes API ── // GET /api/notes — list all notes (query: notebook_id, type, q) routes.get("/api/notes", async (c) => { const { notebook_id, type, q, limit = "50", offset = "0" } = c.req.query(); const conditions: string[] = []; const params: unknown[] = []; 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 routes.post("/api/notes", async (c) => { 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; 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 if (tags && Array.isArray(tags)) { for (const tagName of tags) { const name = tagName.trim().toLowerCase(); if (!name) continue; 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] ); } } return c.json(rows[0], 201); }); // GET /api/notes/:id — note detail routes.get("/api/notes/:id", async (c) => { const id = c.req.param("id"); 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 routes.put("/api/notes/:id", async (c) => { const id = c.req.param("id"); const body = await c.req.json(); const { title, content, type, url, language, is_pinned, sort_order } = body; const fields: string[] = []; const params: unknown[] = []; 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); return c.json(rows[0]); }); // DELETE /api/notes/:id routes.delete("/api/notes/:id", async (c) => { const result = await sql.unsafe("DELETE FROM rnotes.notes WHERE id = $1 RETURNING id", [c.req.param("id")]); if (result.length === 0) return c.json({ error: "Note not found" }, 404); 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: "notes", spaceSlug: space, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const notesModule: RSpaceModule = { id: "notes", name: "rNotes", icon: "\u{1F4DD}", description: "Notebooks with rich-text notes, voice transcription, and collaboration", routes, standaloneDomain: "rnotes.online", };