feat: MVP-2 completion - search, quick add, backlinks, guided forms

## Search & Retrieval
- Improved search ranking with scoring (title match, favorites, recency)
- Highlight matches with excerpt extraction
- Fuzzy search with string-similarity
- Unified noteQuery function

## Quick Capture
- Quick Add API (POST /api/notes/quick) with type prefixes
- Quick add parser with tag extraction
- Global Quick Add UI (Ctrl+N shortcut)
- Tag autocomplete in forms

## Note Relations
- Automatic backlinks with sync on create/update/delete
- Backlinks API (GET /api/notes/[id]/backlinks)
- Related notes with scoring and reasons

## Guided Forms
- Type-specific form fields (command, snippet, decision, recipe, procedure, inventory)
- Serialization to/from markdown
- Tag suggestions based on content (GET /api/tags/suggest)

## UX by Type
- Command: Copy button for code blocks
- Snippet: Syntax highlighting with react-syntax-highlighter
- Procedure: Interactive checkboxes

## Quality
- Standardized error handling across all APIs
- Integration tests (28 tests passing)
- Unit tests for search, tags, quick-add

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 13:51:39 -03:00
parent 6694bce736
commit 8b77c7b5df
30 changed files with 6548 additions and 282 deletions

122
src/lib/backlinks.ts Normal file
View File

@@ -0,0 +1,122 @@
import { prisma } from '@/lib/prisma'
const BACKLINK_REGEX = /\[\[([^\]]+)\]\]/g
export function parseBacklinks(content: string): string[] {
const matches = content.matchAll(BACKLINK_REGEX)
const titles = new Set<string>()
for (const match of matches) {
const title = match[1].trim()
if (title) {
titles.add(title)
}
}
return Array.from(titles)
}
export async function syncBacklinks(noteId: string, content: string): Promise<void> {
const linkedTitles = parseBacklinks(content)
await prisma.backlink.deleteMany({
where: { sourceNoteId: noteId },
})
if (linkedTitles.length === 0) return
const targetNotes = await prisma.note.findMany({
where: {
title: { in: linkedTitles },
},
select: { id: true, title: true },
})
const titleToId = new Map(targetNotes.map((n) => [n.title.toLowerCase(), n.id]))
const backlinksToCreate: { sourceNoteId: string; targetNoteId: string }[] = []
for (const title of linkedTitles) {
const targetNoteId = titleToId.get(title.toLowerCase())
if (targetNoteId && targetNoteId !== noteId) {
backlinksToCreate.push({
sourceNoteId: noteId,
targetNoteId,
})
}
}
if (backlinksToCreate.length > 0) {
await prisma.backlink.createMany({
data: backlinksToCreate,
})
}
}
export interface BacklinkWithNote {
id: string
sourceNoteId: string
targetNoteId: string
createdAt: string
sourceNote: {
id: string
title: string
type: string
}
}
export async function getBacklinksForNote(noteId: string): Promise<BacklinkWithNote[]> {
const backlinks = await prisma.backlink.findMany({
where: { targetNoteId: noteId },
include: {
sourceNote: {
select: {
id: true,
title: true,
type: true,
},
},
},
orderBy: { createdAt: 'desc' },
})
return backlinks.map((bl) => ({
id: bl.id,
sourceNoteId: bl.sourceNoteId,
targetNoteId: bl.targetNoteId,
createdAt: bl.createdAt.toISOString(),
sourceNote: {
id: bl.sourceNote.id,
title: bl.sourceNote.title,
type: bl.sourceNote.type,
},
}))
}
export async function getOutgoingLinksForNote(noteId: string): Promise<BacklinkWithNote[]> {
const backlinks = await prisma.backlink.findMany({
where: { sourceNoteId: noteId },
include: {
targetNote: {
select: {
id: true,
title: true,
type: true,
},
},
},
orderBy: { createdAt: 'desc' },
})
return backlinks.map((bl) => ({
id: bl.id,
sourceNoteId: bl.sourceNoteId,
targetNoteId: bl.targetNoteId,
createdAt: bl.createdAt.toISOString(),
sourceNote: {
id: bl.targetNote.id,
title: bl.targetNote.title,
type: bl.targetNote.type,
},
}))
}