292 lines
10 KiB
TypeScript
292 lines
10 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.
|
|
*/
|
|
|
|
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";
|
|
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/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);
|
|
}
|
|
}
|
|
|
|
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 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 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]
|
|
);
|
|
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 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;
|
|
|
|
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 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;
|
|
|
|
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: `<link rel="stylesheet" href="/modules/notes/notes.css">`,
|
|
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
|
|
scripts: `<script type="module" src="/modules/notes/folk-notes-app.js"></script>`,
|
|
}));
|
|
});
|
|
|
|
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",
|
|
};
|