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 && (
-
{emptyMessage}
+Sugerencias basadas en tu contenido:
+