From ef0aebf51078f376d0dbdf497f02c2554f53dfca Mon Sep 17 00:00:00 2001 From: Daniel Arroyo Date: Sun, 22 Mar 2026 16:20:39 -0300 Subject: [PATCH] feat: MVP-3 Sprint 2 - Auto tag suggestions, note connections panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add auto tag suggestions to note form with debounced API calls - Rename related notes to "También podrías necesitar" in detail view - Create NoteConnections component showing backlinks, outgoing links, and related notes - Add tests for backlinks module and tags/suggest API endpoint Co-Authored-By: Claude Opus 4.6 --- __tests__/api.integration.test.ts | 58 ++++++++ __tests__/backlinks.test.ts | 215 ++++++++++++++++++++++++++++ src/app/notes/[id]/page.tsx | 14 +- src/components/note-connections.tsx | 142 ++++++++++++++++++ src/components/note-form.tsx | 52 +++++++ src/components/related-notes.tsx | 2 +- 6 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 __tests__/backlinks.test.ts create mode 100644 src/components/note-connections.tsx diff --git a/__tests__/api.integration.test.ts b/__tests__/api.integration.test.ts index e002153..1a3727f 100644 --- a/__tests__/api.integration.test.ts +++ b/__tests__/api.integration.test.ts @@ -531,6 +531,64 @@ describe('API Integration Tests', () => { }) }) + // ============================================ + // GET /api/tags/suggest - Suggest tags based on content + // ============================================ + describe('GET /api/tags/suggest', () => { + it('suggests tags based on title keywords', async () => { + const { GET } = await import('@/app/api/tags/suggest/route') + const request = new NextRequest('http://localhost/api/tags/suggest?title=Docker%20deployment&content=') + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(Array.isArray(data.data)).toBe(true) + }) + + it('suggests tags based on content keywords', async () => { + const { GET } = await import('@/app/api/tags/suggest/route') + const request = new NextRequest('http://localhost/api/tags/suggest?title=&content=Docker%20and%20Kubernetes%20deployment') + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(Array.isArray(data.data)) + }) + + it('combines title and content for suggestions', async () => { + const { GET } = await import('@/app/api/tags/suggest/route') + const request = new NextRequest('http://localhost/api/tags/suggest?title=Python%20script&content=SQL%20database%20query') + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + }) + + it('returns empty array for generic content', async () => { + const { GET } = await import('@/app/api/tags/suggest/route') + const request = new NextRequest('http://localhost/api/tags/suggest?title=Note&content=content') + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(Array.isArray(data.data)).toBe(true) + }) + + it('handles empty parameters gracefully', async () => { + const { GET } = await import('@/app/api/tags/suggest/route') + const request = new NextRequest('http://localhost/api/tags/suggest') + const response = await GET(request) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + }) + }) + // ============================================ // GET /api/search - Search notes // ============================================ diff --git a/__tests__/backlinks.test.ts b/__tests__/backlinks.test.ts new file mode 100644 index 0000000..65b411f --- /dev/null +++ b/__tests__/backlinks.test.ts @@ -0,0 +1,215 @@ +import { parseBacklinks, syncBacklinks, getBacklinksForNote, getOutgoingLinksForNote } from '@/lib/backlinks' + +// Mock prisma before importing backlinks module +jest.mock('@/lib/prisma', () => ({ + prisma: { + backlink: { + deleteMany: jest.fn(), + createMany: jest.fn(), + findMany: jest.fn(), + }, + note: { + findMany: jest.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' + +describe('backlinks.ts', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('parseBacklinks', () => { + it('extracts single wiki-link', () => { + const content = 'This is about [[Docker Commands]]' + const result = parseBacklinks(content) + expect(result).toEqual(['Docker Commands']) + }) + + it('extracts multiple wiki-links', () => { + const content = 'See [[Docker Commands]] and [[Git Commands]] for reference' + const result = parseBacklinks(content) + expect(result).toContain('Docker Commands') + expect(result).toContain('Git Commands') + }) + + it('extracts wiki-links with extra whitespace', () => { + const content = 'Check [[ Docker Commands ]] for details' + const result = parseBacklinks(content) + expect(result).toEqual(['Docker Commands']) + }) + + it('returns empty array when no wiki-links', () => { + const content = 'This is a plain note without any links' + const result = parseBacklinks(content) + expect(result).toEqual([]) + }) + + it('handles wiki-links at start of content', () => { + const content = '[[First Note]] is the beginning' + const result = parseBacklinks(content) + expect(result).toEqual(['First Note']) + }) + + it('handles wiki-links at end of content', () => { + const content = 'The solution is [[Last Note]]' + const result = parseBacklinks(content) + expect(result).toEqual(['Last Note']) + }) + + it('deduplicates repeated wiki-links', () => { + const content = 'See [[Docker Commands]] and again [[Docker Commands]]' + const result = parseBacklinks(content) + expect(result).toEqual(['Docker Commands']) + }) + + it('handles nested brackets gracefully', () => { + const content = 'Check [[This]] and [[That]]' + const result = parseBacklinks(content) + expect(result).toHaveLength(2) + }) + }) + + describe('syncBacklinks', () => { + it('deletes existing backlinks before creating new ones', async () => { + ;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 2 }) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([]) + ;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 0 }) + + await syncBacklinks('note-1', 'No links here') + + expect(prisma.backlink.deleteMany).toHaveBeenCalledWith({ + where: { sourceNoteId: 'note-1' }, + }) + }) + + it('creates backlinks for valid linked notes', async () => { + ;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 }) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: 'note-2', title: 'Docker Commands' }, + { id: 'note-3', title: 'Git Commands' }, + ]) + ;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 2 }) + + await syncBacklinks('note-1', 'See [[Docker Commands]] and [[Git Commands]]') + + expect(prisma.backlink.createMany).toHaveBeenCalledWith({ + data: [ + { sourceNoteId: 'note-1', targetNoteId: 'note-2' }, + { sourceNoteId: 'note-1', targetNoteId: 'note-3' }, + ], + }) + }) + + it('does not create backlink to self', async () => { + ;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 }) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: 'note-1', title: 'Docker Commands' }, + ]) + ;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 0 }) + + await syncBacklinks('note-1', 'This is [[Docker Commands]]') + + // Should not create a backlink to itself + expect(prisma.backlink.createMany).not.toHaveBeenCalled() + }) + + it('handles case-insensitive title matching', async () => { + ;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 0 }) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: 'note-2', title: 'Docker Commands' }, + ]) + ;(prisma.backlink.createMany as jest.Mock).mockResolvedValue({ count: 1 }) + + await syncBacklinks('note-1', 'See [[docker commands]]') + + expect(prisma.backlink.createMany).toHaveBeenCalled() + }) + + it('does nothing when no wiki-links in content', async () => { + ;(prisma.backlink.deleteMany as jest.Mock).mockResolvedValue({ count: 2 }) + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([]) + + await syncBacklinks('note-1', 'Plain content without links') + + expect(prisma.backlink.createMany).not.toHaveBeenCalled() + }) + }) + + describe('getBacklinksForNote', () => { + it('returns backlinks with source note info', async () => { + const mockBacklinks = [ + { + id: 'bl-1', + sourceNoteId: 'note-2', + targetNoteId: 'note-1', + createdAt: new Date('2024-01-01'), + sourceNote: { id: 'note-2', title: 'Related Note', type: 'command' }, + }, + ] + ;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks) + + const result = await getBacklinksForNote('note-1') + + expect(result).toHaveLength(1) + expect(result[0].sourceNote.title).toBe('Related Note') + expect(result[0].sourceNote.type).toBe('command') + }) + + it('returns empty array when no backlinks', async () => { + ;(prisma.backlink.findMany as jest.Mock).mockResolvedValue([]) + + const result = await getBacklinksForNote('note-1') + + expect(result).toEqual([]) + }) + + it('converts Date to ISO string', async () => { + const mockBacklinks = [ + { + id: 'bl-1', + sourceNoteId: 'note-2', + targetNoteId: 'note-1', + createdAt: new Date('2024-01-01T12:00:00Z'), + sourceNote: { id: 'note-2', title: 'Related Note', type: 'command' }, + }, + ] + ;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks) + + const result = await getBacklinksForNote('note-1') + + expect(result[0].createdAt).toBe('2024-01-01T12:00:00.000Z') + }) + }) + + describe('getOutgoingLinksForNote', () => { + it('returns outgoing links with target note info', async () => { + const mockBacklinks = [ + { + id: 'bl-1', + sourceNoteId: 'note-1', + targetNoteId: 'note-2', + createdAt: new Date('2024-01-01'), + targetNote: { id: 'note-2', title: 'Linked Note', type: 'snippet' }, + }, + ] + ;(prisma.backlink.findMany as jest.Mock).mockResolvedValue(mockBacklinks) + + const result = await getOutgoingLinksForNote('note-1') + + expect(result).toHaveLength(1) + expect(result[0].sourceNote.title).toBe('Linked Note') + expect(result[0].sourceNote.type).toBe('snippet') + }) + + it('returns empty array when no outgoing links', async () => { + ;(prisma.backlink.findMany as jest.Mock).mockResolvedValue([]) + + const result = await getOutgoingLinksForNote('note-1') + + expect(result).toEqual([]) + }) + }) +}) diff --git a/src/app/notes/[id]/page.tsx b/src/app/notes/[id]/page.tsx index 1e404c7..dee9342 100644 --- a/src/app/notes/[id]/page.tsx +++ b/src/app/notes/[id]/page.tsx @@ -1,7 +1,8 @@ import { prisma } from '@/lib/prisma' import { notFound } from 'next/navigation' -import { RelatedNotes } from '@/components/related-notes' import { getRelatedNotes } from '@/lib/related' +import { getBacklinksForNote, getOutgoingLinksForNote } from '@/lib/backlinks' +import { NoteConnections } from '@/components/note-connections' import { MarkdownContent } from '@/components/markdown-content' import { DeleteNoteButton } from '@/components/delete-note-button' import { TrackNoteView } from '@/components/track-note-view' @@ -33,6 +34,8 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id: } const related = await getRelatedNotes(id, 5) + const backlinks = await getBacklinksForNote(id) + const outgoingLinks = await getOutgoingLinksForNote(id) const noteType = note.type as NoteType return ( @@ -92,9 +95,12 @@ export default async function NoteDetailPage({ params }: { params: Promise<{ id: /> - {related.length > 0 && ( - - )} + ) diff --git a/src/components/note-connections.tsx b/src/components/note-connections.tsx new file mode 100644 index 0000000..715c1b0 --- /dev/null +++ b/src/components/note-connections.tsx @@ -0,0 +1,142 @@ +'use client' + +import Link from 'next/link' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { ArrowRight, Link2, RefreshCw, ExternalLink } from 'lucide-react' + +interface BacklinkInfo { + id: string + sourceNoteId: string + targetNoteId: string + sourceNote: { + id: string + title: string + type: string + } +} + +interface RelatedNote { + id: string + title: string + type: string + tags: string[] + score: number + reason: string +} + +interface NoteConnectionsProps { + noteId: string + backlinks: BacklinkInfo[] + outgoingLinks: BacklinkInfo[] + relatedNotes: RelatedNote[] +} + +function ConnectionGroup({ + title, + icon: Icon, + notes, + emptyMessage, +}: { + title: string + icon: React.ComponentType<{ className?: string }> + notes: { id: string; title: string; type: string }[] + emptyMessage: string +}) { + if (notes.length === 0) { + return ( +
+

+ + {title} +

+

{emptyMessage}

+
+ ) + } + + return ( +
+

+ + {title} + + {notes.length} + +

+
+ {notes.map((note) => ( + + {note.title} + + ))} +
+
+ ) +} + +export function NoteConnections({ + noteId, + backlinks, + outgoingLinks, + relatedNotes, +}: NoteConnectionsProps) { + const hasAnyConnections = + backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 + + if (!hasAnyConnections) { + return null + } + + return ( + + + + + Conectado con + + + + {/* Backlinks - notes that link TO this note */} + ({ + id: bl.sourceNote.id, + title: bl.sourceNote.title, + type: bl.sourceNote.type, + }))} + emptyMessage="Ningún otro documento enlaza a esta nota" + /> + + {/* Outgoing links - notes this note links TO */} + ({ + id: ol.sourceNote.id, + title: ol.sourceNote.title, + type: ol.sourceNote.type, + }))} + emptyMessage="Esta nota no enlaza a ningún otro documento" + /> + + {/* Related notes - by content similarity and scoring */} + ({ + id: rn.id, + title: rn.title, + type: rn.type, + }))} + emptyMessage="No hay notas relacionadas" + /> + + + ) +} diff --git a/src/components/note-form.tsx b/src/components/note-form.tsx index f8cb093..5cd70d4 100644 --- a/src/components/note-form.tsx +++ b/src/components/note-form.tsx @@ -614,6 +614,7 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) { return defaultFields[type] }) const [tags, setTags] = useState(initialData?.tags.map(t => t.tag.name) || []) + const [autoSuggestedTags, setAutoSuggestedTags] = useState([]) const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false) const [isPinned, setIsPinned] = useState(initialData?.isPinned || false) const [isSubmitting, setIsSubmitting] = useState(false) @@ -625,6 +626,33 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) { const content = useMemo(() => serializeToMarkdown(type, fields), [type, fields]) + // Auto-suggest tags based on title and content + useEffect(() => { + const fetchSuggestions = async () => { + if (title.trim().length < 3 && content.trim().length < 10) { + setAutoSuggestedTags([]) + return + } + + try { + const res = await fetch( + `/api/tags/suggest?title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}` + ) + if (res.ok) { + const data = await res.json() + const suggested: string[] = data.data || data || [] + // Filter out tags already added + setAutoSuggestedTags(suggested.filter((t: string) => !tags.includes(t))) + } + } catch (error) { + console.error('Error fetching tag suggestions:', error) + } + } + + const timeoutId = setTimeout(fetchSuggestions, 500) + return () => clearTimeout(timeoutId) + }, [title, content, tags]) + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setIsSubmitting(true) @@ -717,6 +745,30 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) { + {autoSuggestedTags.length > 0 && ( +
+

Sugerencias basadas en tu contenido:

+
+ {autoSuggestedTags.map((tag) => ( + + ))} +
+
+ )} +