fix(notes): export route order and lexical snippet ellipsis

Register GET /notes/export before GET /notes/:id so the path is not captured as an id.

Compute search snippets from stripped plain text so the trailing ellipsis matches visible length, not raw HTML length.

Made-with: Cursor
This commit is contained in:
Saravana Achu Mac 2026-03-31 13:05:29 -07:00
parent a697752d15
commit 5e3e374d3a

View File

@ -40,14 +40,17 @@ const ChatBodySchema = z.object({
});
function toLexicalHits(items: NoteDoc[]) {
return items.map((n) => ({
noteId: n.id,
workspaceId: n.workspaceId,
title: n.title,
score: 1,
matchKind: 'lexical' as const,
snippet: n.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 180) + (n.body.length > 180 ? '…' : ''),
}));
return items.map((n) => {
const plain = n.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
return {
noteId: n.id,
workspaceId: n.workspaceId,
title: n.title,
score: 1,
matchKind: 'lexical' as const,
snippet: plain.slice(0, 180) + (plain.length > 180 ? '…' : ''),
};
});
}
export async function noteRoutes(app: RouteApp) {
@ -81,6 +84,33 @@ export async function noteRoutes(app: RouteApp) {
return { ...result, limit: parsed.data.limit, offset: parsed.data.offset };
});
// Must be registered before GET /notes/:id or "export" is captured as :id.
app.get('/notes/export', async (req, reply) => {
const auth = await extractAuth(req);
const query = req.query as { format?: string; workspaceId?: string };
const format = query.format ?? 'json';
if (format !== 'json' && format !== 'markdown') {
throw new BadRequestError('format must be json or markdown');
}
const listQuery = { workspaceId: query.workspaceId, limit: 100, offset: 0 };
const result = await repo.listNotes(auth.sub, PRODUCT_ID, listQuery);
if (format === 'markdown') {
const md = result.items
.map((n: NoteDoc) => `# ${n.title}\n\n${n.body}\n\n---\n`)
.join('\n');
reply.header('Content-Type', 'text/markdown');
reply.header('Content-Disposition', 'attachment; filename="notes-export.md"');
return md;
}
reply.header('Content-Type', 'application/json');
reply.header('Content-Disposition', 'attachment; filename="notes-export.json"');
return { exportedAt: new Date().toISOString(), notes: result.items };
});
app.get('/notes/:id', async req => {
const auth = await extractAuth(req);
const { id } = req.params as { id: string };
@ -470,32 +500,6 @@ export async function noteRoutes(app: RouteApp) {
return { answer, citations };
});
app.get('/notes/export', async (req, reply) => {
const auth = await extractAuth(req);
const query = req.query as { format?: string; workspaceId?: string };
const format = query.format ?? 'json';
if (format !== 'json' && format !== 'markdown') {
throw new BadRequestError('format must be json or markdown');
}
const listQuery = { workspaceId: query.workspaceId, limit: 100, offset: 0 };
const result = await repo.listNotes(auth.sub, PRODUCT_ID, listQuery);
if (format === 'markdown') {
const md = result.items
.map((n: NoteDoc) => `# ${n.title}\n\n${n.body}\n\n---\n`)
.join('\n');
reply.header('Content-Type', 'text/markdown');
reply.header('Content-Disposition', 'attachment; filename="notes-export.md"');
return md;
}
reply.header('Content-Type', 'application/json');
reply.header('Content-Disposition', 'attachment; filename="notes-export.json"');
return { exportedAt: new Date().toISOString(), notes: result.items };
});
app.delete('/notes/:id', async (req, reply) => {
const auth = await requireWriter(req);
const { id } = req.params as { id: string };