T3/Phase 2B-4: port notes routes to Hono (~7 routes) + wikilink service + route registration
This commit is contained in:
@@ -10,11 +10,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@project-e/db": "^0.1.0",
|
||||
"hono": "^4.6.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"postgres": "^3.4.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jose": "^5.9.6"
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"hono": "^4.6.0",
|
||||
"jose": "^5.9.6",
|
||||
"postgres": "^3.4.9",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.0",
|
||||
|
||||
@@ -6,6 +6,10 @@ import { authRoutes } from "./routes/auth";
|
||||
import { mcpRoutes } from "./routes/mcp";
|
||||
import { realtimeRoutes } from "./routes/realtime";
|
||||
import { domainRoutes } from "./routes/domains";
|
||||
import { taskRoutes } from "./routes/tasks";
|
||||
import { habitRoutes } from "./routes/habits";
|
||||
import { projectRoutes } from "./routes/projects";
|
||||
import { noteRoutes } from "./routes/notes";
|
||||
import { healthHandler } from "./routes/health";
|
||||
|
||||
const app = new Hono();
|
||||
@@ -24,6 +28,10 @@ app.get("/api/health", async (c) => {
|
||||
// Routes
|
||||
app.route("/api/auth", authRoutes);
|
||||
app.route("/api/domains", domainRoutes);
|
||||
app.route("/api/tasks", taskRoutes);
|
||||
app.route("/api/habits", habitRoutes);
|
||||
app.route("/api/projects", projectRoutes);
|
||||
app.route("/api/notes", noteRoutes);
|
||||
app.route("/api", realtimeRoutes);
|
||||
app.route("/mcp", mcpRoutes);
|
||||
|
||||
|
||||
@@ -22,5 +22,5 @@ export async function recordActivity(params: RecordActivityParams): Promise<void
|
||||
});
|
||||
|
||||
const payload = JSON.stringify({ type: entityType, action, id: entityId, workspace_id: workspaceId });
|
||||
await sql`SELECT pg_notify(project_e_events, ${payload}::text)`;
|
||||
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Note Link Service
|
||||
*
|
||||
* Handles wikilink resolution and note_links / note_entity_links management.
|
||||
* On note save, parses content for [[wikilinks]], resolves each to a note_id or entity_id,
|
||||
* and diffs the existing links to produce idempotent deletes+inserts.
|
||||
*/
|
||||
|
||||
import { db, noteLinks, noteEntityLinks, notes, tasks, habits, projects, sections, tags as tagsTable } from "@project-e/db";
|
||||
import { and, eq, inArray, isNull, desc } from "drizzle-orm";
|
||||
import { extractLinkTargets } from "./wikilink-parser";
|
||||
|
||||
/**
|
||||
* Resolve a single link target to its entity ID.
|
||||
*/
|
||||
async function resolveTarget(entityType: string, title: string): Promise<{ entityId: string; entityType: string } | null> {
|
||||
const trimmedTitle = title.trim();
|
||||
|
||||
if (!entityType) {
|
||||
const [note] = await db
|
||||
.select({ id: notes.id })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
if (note) return { entityId: note.id, entityType: "note" };
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (entityType) {
|
||||
case "note": {
|
||||
const [note] = await db
|
||||
.select({ id: notes.id })
|
||||
.from(notes)
|
||||
.where(and(eq(notes.title, trimmedTitle), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
if (note) return { entityId: note.id, entityType: "note" };
|
||||
return null;
|
||||
}
|
||||
case "task": {
|
||||
const [task] = await db
|
||||
.select({ id: tasks.id })
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.title, trimmedTitle), isNull(tasks.deletedAt)))
|
||||
.limit(1);
|
||||
if (task) return { entityId: task.id, entityType: "task" };
|
||||
return null;
|
||||
}
|
||||
case "habit": {
|
||||
const [habit] = await db
|
||||
.select({ id: habits.id })
|
||||
.from(habits)
|
||||
.where(and(eq(habits.name, trimmedTitle), isNull(habits.deletedAt)))
|
||||
.limit(1);
|
||||
if (habit) return { entityId: habit.id, entityType: "habit" };
|
||||
return null;
|
||||
}
|
||||
case "project": {
|
||||
const [project] = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.name, trimmedTitle), isNull(projects.deletedAt)))
|
||||
.limit(1);
|
||||
if (project) return { entityId: project.id, entityType: "project" };
|
||||
return null;
|
||||
}
|
||||
case "section": {
|
||||
const [section] = await db
|
||||
.select({ id: sections.id })
|
||||
.from(sections)
|
||||
.where(eq(sections.name, trimmedTitle))
|
||||
.limit(1);
|
||||
if (section) return { entityId: section.id, entityType: "section" };
|
||||
return null;
|
||||
}
|
||||
case "tag": {
|
||||
const [tag] = await db
|
||||
.select({ id: tagsTable.id })
|
||||
.from(tagsTable)
|
||||
.where(eq(tagsTable.name, trimmedTitle))
|
||||
.limit(1);
|
||||
if (tag) return { entityId: tag.id, entityType: "tag" };
|
||||
return null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync wikilinks for a note: parse content, resolve targets, diff existing links.
|
||||
*/
|
||||
export async function syncNoteLinks(noteId: string, content: string): Promise<void> {
|
||||
const targets = extractLinkTargets(content);
|
||||
|
||||
const resolvedTargets: { entityType: string; entityId: string }[] = [];
|
||||
for (const target of targets) {
|
||||
const resolved = await resolveTarget(target.entityType, target.title);
|
||||
if (resolved) {
|
||||
resolvedTargets.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
const noteToNoteLinks = resolvedTargets.filter(t => t.entityType === "note");
|
||||
const entityLinks = resolvedTargets.filter(t => t.entityType !== "note");
|
||||
|
||||
// --- Sync note_links ---
|
||||
const existingNoteLinks = await db
|
||||
.select({ targetNoteId: noteLinks.targetNoteId })
|
||||
.from(noteLinks)
|
||||
.where(eq(noteLinks.sourceNoteId, noteId));
|
||||
|
||||
const existingTargetIds = new Set(existingNoteLinks.map(l => l.targetNoteId));
|
||||
const newTargetIds = new Set(noteToNoteLinks.map(l => l.entityId));
|
||||
|
||||
const staleTargetIds = [...existingTargetIds].filter(id => !newTargetIds.has(id));
|
||||
if (staleTargetIds.length > 0) {
|
||||
await db
|
||||
.delete(noteLinks)
|
||||
.where(and(
|
||||
eq(noteLinks.sourceNoteId, noteId),
|
||||
inArray(noteLinks.targetNoteId, staleTargetIds),
|
||||
));
|
||||
}
|
||||
|
||||
const missingTargetIds = [...newTargetIds].filter(id => !existingTargetIds.has(id));
|
||||
if (missingTargetIds.length > 0) {
|
||||
await db.insert(noteLinks).values(
|
||||
missingTargetIds.map(targetNoteId => ({ sourceNoteId: noteId, targetNoteId }))
|
||||
);
|
||||
}
|
||||
|
||||
// --- Sync note_entity_links ---
|
||||
const existingEntityLinks = await db
|
||||
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
|
||||
.from(noteEntityLinks)
|
||||
.where(eq(noteEntityLinks.noteId, noteId));
|
||||
|
||||
const existingEntityKeySet = new Set(existingEntityLinks.map(l => `${l.entityType}:${l.entityId}`));
|
||||
const newEntityKeySet = new Set(entityLinks.map(l => `${l.entityType}:${l.entityId}`));
|
||||
|
||||
const staleEntityLinks = existingEntityLinks.filter(l => !newEntityKeySet.has(`${l.entityType}:${l.entityId}`));
|
||||
for (const link of staleEntityLinks) {
|
||||
await db
|
||||
.delete(noteEntityLinks)
|
||||
.where(and(
|
||||
eq(noteEntityLinks.noteId, noteId),
|
||||
eq(noteEntityLinks.entityType, link.entityType),
|
||||
eq(noteEntityLinks.entityId, link.entityId),
|
||||
));
|
||||
}
|
||||
|
||||
const missingEntityLinks = entityLinks.filter(l => !existingEntityKeySet.has(`${l.entityType}:${l.entityId}`));
|
||||
if (missingEntityLinks.length > 0) {
|
||||
await db.insert(noteEntityLinks).values(
|
||||
missingEntityLinks.map(l => ({ noteId, entityType: l.entityType, entityId: l.entityId }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backlinks for a note — notes that link to this note.
|
||||
*/
|
||||
export async function getBacklinks(noteId: string): Promise<{ id: string; title: string; excerpt: string }[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: notes.id,
|
||||
title: notes.title,
|
||||
content: notes.content,
|
||||
})
|
||||
.from(noteLinks)
|
||||
.innerJoin(notes, eq(noteLinks.sourceNoteId, notes.id))
|
||||
.where(and(
|
||||
eq(noteLinks.targetNoteId, noteId),
|
||||
isNull(notes.deletedAt),
|
||||
));
|
||||
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
excerpt: extractExcerpt(row.content || "", row.title),
|
||||
}));
|
||||
}
|
||||
|
||||
function extractExcerpt(content: string, title: string): string {
|
||||
const linkMatch = content.match(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/);
|
||||
if (linkMatch) {
|
||||
const idx = content.indexOf(linkMatch[0]);
|
||||
const start = Math.max(0, idx - 40);
|
||||
const end = Math.min(content.length, idx + linkMatch[0].length + 40);
|
||||
let excerpt = content.slice(start, end).replace(/\n/g, " ");
|
||||
if (start > 0) excerpt = "..." + excerpt;
|
||||
if (end < content.length) excerpt = excerpt + "...";
|
||||
return excerpt;
|
||||
}
|
||||
return content.slice(0, 100).replace(/\n/g, " ") + (content.length > 100 ? "..." : "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all outgoing links for a note.
|
||||
*/
|
||||
export async function getOutgoingLinks(noteId: string): Promise<{
|
||||
noteLinks: { id: string; title: string }[];
|
||||
entityLinks: { entityType: string; entityId: string; title: string | null }[];
|
||||
}> {
|
||||
const noteLinkRows = await db
|
||||
.select({ id: notes.id, title: notes.title })
|
||||
.from(noteLinks)
|
||||
.innerJoin(notes, eq(noteLinks.targetNoteId, notes.id))
|
||||
.where(and(
|
||||
eq(noteLinks.sourceNoteId, noteId),
|
||||
isNull(notes.deletedAt),
|
||||
));
|
||||
|
||||
const entityLinkRows = await db
|
||||
.select({ entityType: noteEntityLinks.entityType, entityId: noteEntityLinks.entityId })
|
||||
.from(noteEntityLinks)
|
||||
.where(eq(noteEntityLinks.noteId, noteId));
|
||||
|
||||
const entityLinksWithTitles: { entityType: string; entityId: string; title: string | null }[] = [];
|
||||
for (const link of entityLinkRows) {
|
||||
let title: string | null = null;
|
||||
switch (link.entityType) {
|
||||
case "task": {
|
||||
const [t] = await db.select({ title: tasks.title }).from(tasks).where(eq(tasks.id, link.entityId)).limit(1);
|
||||
title = t?.title ?? null;
|
||||
break;
|
||||
}
|
||||
case "habit": {
|
||||
const [h] = await db.select({ name: habits.name }).from(habits).where(eq(habits.id, link.entityId)).limit(1);
|
||||
title = h?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "project": {
|
||||
const [p] = await db.select({ name: projects.name }).from(projects).where(eq(projects.id, link.entityId)).limit(1);
|
||||
title = p?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "section": {
|
||||
const [s] = await db.select({ name: sections.name }).from(sections).where(eq(sections.id, link.entityId)).limit(1);
|
||||
title = s?.name ?? null;
|
||||
break;
|
||||
}
|
||||
case "tag": {
|
||||
const [t] = await db.select({ name: tagsTable.name }).from(tagsTable).where(eq(tagsTable.id, link.entityId)).limit(1);
|
||||
title = t?.name ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
entityLinksWithTitles.push({ entityType: link.entityType, entityId: link.entityId, title });
|
||||
}
|
||||
|
||||
return {
|
||||
noteLinks: noteLinkRows,
|
||||
entityLinks: entityLinksWithTitles,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { Hono } from "hono";
|
||||
import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db";
|
||||
import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm";
|
||||
import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||
import { recordActivity } from "../middleware/activity";
|
||||
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
|
||||
import { z } from "zod";
|
||||
|
||||
export const noteRoutes = new Hono();
|
||||
|
||||
const createNoteSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
content: z.string().optional().nullable(),
|
||||
domain: z.string().min(1, "Domain is required"),
|
||||
isPinned: z.boolean().optional().default(false),
|
||||
isArchived: z.boolean().optional().default(false),
|
||||
tagIds: z.array(z.string().uuid()).optional(),
|
||||
});
|
||||
|
||||
const updateNoteSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
content: z.string().optional().nullable(),
|
||||
isPinned: z.boolean().optional(),
|
||||
isArchived: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// GET /api/notes — List notes with filtering, sorting, pagination
|
||||
noteRoutes.get("/", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const url = new URL(c.req.url);
|
||||
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
|
||||
const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
|
||||
const filter = url.searchParams.get("filter") || undefined;
|
||||
const sort = url.searchParams.get("sort") || "-updated_at";
|
||||
const pinned = url.searchParams.get("pinned");
|
||||
const archived = url.searchParams.get("archived");
|
||||
const search = url.searchParams.get("search");
|
||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||
const order = url.searchParams.get("order") || "desc";
|
||||
|
||||
let domainId = url.searchParams.get("domain") || undefined;
|
||||
if (!domainId) {
|
||||
const active = await resolveActiveDomain(user);
|
||||
domainId = active.id;
|
||||
}
|
||||
|
||||
const conditions: any[] = [
|
||||
eq(notes.domainId, domainId),
|
||||
isNull(notes.deletedAt),
|
||||
];
|
||||
|
||||
if (pinned === "true") conditions.push(eq(notes.isPinned, true));
|
||||
if (archived === "true") conditions.push(eq(notes.isArchived, true));
|
||||
else if (archived !== "all") conditions.push(eq(notes.isArchived, false));
|
||||
if (search) conditions.push(ilike(notes.title, `%${search}%`));
|
||||
if (filter) conditions.push(ilike(notes.title, `%${filter}%`));
|
||||
|
||||
const sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||
const sortField = sort.replace(/^-/, "");
|
||||
const sortColumns: Record<string, any> = {
|
||||
title: notes.title,
|
||||
created_at: notes.createdAt,
|
||||
updated_at: notes.updatedAt,
|
||||
is_pinned: notes.isPinned,
|
||||
};
|
||||
const orderColumn = sortDir === "asc"
|
||||
? asc(sortColumns[sortField] || notes.updatedAt)
|
||||
: desc(sortColumns[sortField] || notes.updatedAt);
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db.select()
|
||||
.from(notes)
|
||||
.where(and(...conditions))
|
||||
.orderBy(orderColumn)
|
||||
.limit(limit || perPage)
|
||||
.offset(offset || (page - 1) * perPage),
|
||||
db.select({ count: sql<number>`count(*)` })
|
||||
.from(notes)
|
||||
.where(and(...conditions)),
|
||||
]);
|
||||
|
||||
const totalItems = Number(countResult[0]?.count || 0);
|
||||
|
||||
// Fetch tags for all notes
|
||||
let noteTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||
if (items.length > 0) {
|
||||
const noteIds = items.map(n => n.id);
|
||||
const tagRows = await db.select({
|
||||
noteId: noteTags.noteId,
|
||||
id: tagsTable.id,
|
||||
name: tagsTable.name,
|
||||
color: tagsTable.color,
|
||||
})
|
||||
.from(noteTags)
|
||||
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
|
||||
.where(inArray(noteTags.noteId, noteIds));
|
||||
|
||||
for (const row of tagRows) {
|
||||
if (!noteTagMap.has(row.noteId)) noteTagMap.set(row.noteId, []);
|
||||
noteTagMap.get(row.noteId)!.push({ id: row.id, name: row.name, color: row.color });
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithTags = items.map(n => ({
|
||||
...n,
|
||||
tags: noteTagMap.get(n.id) || [],
|
||||
}));
|
||||
|
||||
return c.json({
|
||||
items: itemsWithTags,
|
||||
totalItems,
|
||||
totalPages: Math.ceil(totalItems / (limit || perPage)),
|
||||
page,
|
||||
perPage: limit || perPage,
|
||||
limit: limit || perPage,
|
||||
offset: offset || (page - 1) * perPage,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[notes] GET error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to list notes" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/notes — Create a note
|
||||
noteRoutes.post("/", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const body = await c.req.json();
|
||||
const data = createNoteSchema.parse({
|
||||
...body,
|
||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||
});
|
||||
|
||||
const [note] = await db.insert(notes).values({
|
||||
title: data.title,
|
||||
content: data.content ?? null,
|
||||
domainId: data.domain,
|
||||
isPinned: data.isPinned,
|
||||
isArchived: data.isArchived,
|
||||
}).returning();
|
||||
|
||||
if (data.tagIds && data.tagIds.length > 0) {
|
||||
await db.insert(noteTags).values(
|
||||
data.tagIds.map(tagId => ({ noteId: note.id, tagId }))
|
||||
);
|
||||
}
|
||||
|
||||
// Sync wikilinks from content
|
||||
if (data.content) {
|
||||
await syncNoteLinks(note.id, data.content);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "created",
|
||||
entityType: "note",
|
||||
entityId: note.id,
|
||||
changes: { title: note.title },
|
||||
workspaceId: data.domain,
|
||||
});
|
||||
|
||||
return c.json(note, 201);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[notes] POST error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create note" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/notes/:id — Get a single note with backlinks
|
||||
noteRoutes.get("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [note] = await db.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!note) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
// Fetch tags
|
||||
const tagRows = await db.select({
|
||||
id: tagsTable.id,
|
||||
name: tagsTable.name,
|
||||
color: tagsTable.color,
|
||||
})
|
||||
.from(noteTags)
|
||||
.innerJoin(tagsTable, eq(noteTags.tagId, tagsTable.id))
|
||||
.where(eq(noteTags.noteId, id));
|
||||
|
||||
// Fetch backlinks and outgoing links
|
||||
const [backlinks, outgoingLinks] = await Promise.all([
|
||||
getBacklinks(id),
|
||||
getOutgoingLinks(id),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
...note,
|
||||
tags: tagRows,
|
||||
backlinks,
|
||||
outgoingLinks,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[notes] GET/:id error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get note" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /api/notes/:id — Update a note
|
||||
noteRoutes.patch("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const data = updateNoteSchema.parse(body);
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
const updateValues: Record<string, unknown> = {};
|
||||
if (data.title !== undefined) updateValues.title = data.title;
|
||||
if (data.content !== undefined) updateValues.content = data.content;
|
||||
if (data.isPinned !== undefined) updateValues.isPinned = data.isPinned;
|
||||
if (data.isArchived !== undefined) updateValues.isArchived = data.isArchived;
|
||||
updateValues.updatedAt = new Date();
|
||||
|
||||
const [updated] = await db.update(notes)
|
||||
.set(updateValues)
|
||||
.where(eq(notes.id, id))
|
||||
.returning();
|
||||
|
||||
// Re-sync wikilinks if content changed
|
||||
const content = data.content ?? existing.content;
|
||||
if (content) {
|
||||
await syncNoteLinks(id, content);
|
||||
}
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "updated",
|
||||
entityType: "note",
|
||||
entityId: id,
|
||||
changes: { ...data, previousTitle: existing.title },
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400);
|
||||
}
|
||||
console.error("[notes] PATCH error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update note" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/notes/:id — Soft delete a note
|
||||
noteRoutes.delete("/:id", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const [existing] = await db.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.id, id), isNull(notes.deletedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||
}
|
||||
|
||||
await db.update(notes)
|
||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(notes.id, id));
|
||||
|
||||
await recordActivity({
|
||||
actor: user.name,
|
||||
action: "deleted",
|
||||
entityType: "note",
|
||||
entityId: id,
|
||||
changes: { title: existing.title },
|
||||
workspaceId: existing.domainId,
|
||||
});
|
||||
|
||||
return c.body(null, 204);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[notes] DELETE error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete note" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/notes/:id/backlinks — Notes that link TO this one
|
||||
noteRoutes.get("/:id/backlinks", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const backlinks = await getBacklinks(id);
|
||||
|
||||
return c.json({
|
||||
items: backlinks,
|
||||
totalItems: backlinks.length,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[notes] GET /:id/backlinks error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get backlinks" } }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/notes/:id/versions — Edit history (from activity feed)
|
||||
noteRoutes.get("/:id/versions", async (c) => {
|
||||
try {
|
||||
const user = await requireAuth(c);
|
||||
const id = c.req.param("id");
|
||||
|
||||
const versions = await db.select()
|
||||
.from(activityFeed)
|
||||
.where(and(
|
||||
eq(activityFeed.entityId, id),
|
||||
eq(activityFeed.entityType, "note"),
|
||||
))
|
||||
.orderBy(desc(activityFeed.createdAt))
|
||||
.limit(100);
|
||||
|
||||
return c.json({ items: versions, totalItems: versions.length });
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||
}
|
||||
console.error("[notes] GET /:id/versions error:", error);
|
||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get versions" } }, 500);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Wikilink Parser
|
||||
*
|
||||
* Parses note content for [[wikilink]] patterns:
|
||||
* - [[Title]] — links to a note by title
|
||||
* - [[Title|Display]] — links to a note with custom display text
|
||||
* - [[entity_type:Title]] — cross-entity links (e.g. [[task:Buy milk]], [[habit:Exercise]])
|
||||
*
|
||||
* Supported entity types: task, habit, project, note, section, tag
|
||||
*/
|
||||
|
||||
export interface WikilinkMatch {
|
||||
/** The full matched text including brackets, e.g. "[[Buy milk]]" */
|
||||
raw: string;
|
||||
/** Entity type prefix (empty for note links), e.g. "task", "habit" */
|
||||
entityType: string;
|
||||
/** The target title (after entity type prefix), e.g. "Buy milk" */
|
||||
title: string;
|
||||
/** Optional display text (after | separator), e.g. "Buy milk" */
|
||||
displayText: string | null;
|
||||
}
|
||||
|
||||
const WIKILINK_REGEX = /\[\[(?:([a-zA-Z_]+):)?([^\]|]+)(?:\|([^\]]+))?\]\]/g;
|
||||
|
||||
/**
|
||||
* Parse wikilinks from note content.
|
||||
*/
|
||||
export function parseWikilinks(content: string): WikilinkMatch[] {
|
||||
const matches: WikilinkMatch[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = WIKILINK_REGEX.exec(content)) !== null) {
|
||||
const entityType = (match[1] || "").toLowerCase();
|
||||
const title = match[2].trim();
|
||||
const displayText = match[3]?.trim() || null;
|
||||
|
||||
matches.push({
|
||||
raw: match[0],
|
||||
entityType,
|
||||
title,
|
||||
displayText,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique link targets from content.
|
||||
*/
|
||||
export function extractLinkTargets(content: string): { entityType: string; title: string }[] {
|
||||
const seen = new Set<string>();
|
||||
const targets: { entityType: string; title: string }[] = [];
|
||||
|
||||
for (const match of parseWikilinks(content)) {
|
||||
const key = `${match.entityType}:${match.title}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
targets.push({ entityType: match.entityType, title: match.title });
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
Reference in New Issue
Block a user