diff --git a/__tests__/link-suggestions.test.ts b/__tests__/link-suggestions.test.ts new file mode 100644 index 0000000..204dc71 --- /dev/null +++ b/__tests__/link-suggestions.test.ts @@ -0,0 +1,171 @@ +import { findLinkSuggestions, applyWikiLinks } from '@/lib/link-suggestions' + +// Mock prisma +jest.mock('@/lib/prisma', () => ({ + prisma: { + note: { + findMany: jest.fn(), + }, + }, +})) + +import { prisma } from '@/lib/prisma' + +describe('link-suggestions.ts', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + describe('findLinkSuggestions', () => { + it('returns empty array for short content', async () => { + const result = await findLinkSuggestions('Hi') + expect(result).toEqual([]) + }) + + it('returns empty array for empty content', async () => { + const result = await findLinkSuggestions('') + expect(result).toEqual([]) + }) + + it('finds matching note titles in content', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: '1', title: 'Docker Commands' }, + { id: '2', title: 'Git Tutorial' }, + ]) + + const content = 'I use Docker Commands for containers and Git Tutorial for version control.' + const result = await findLinkSuggestions(content) + + expect(result).toHaveLength(2) + expect(result.map(r => r.noteTitle)).toContain('Docker Commands') + expect(result.map(r => r.noteTitle)).toContain('Git Tutorial') + }) + + it('excludes current note from suggestions', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: '1', title: 'Current Note' }, + { id: '2', title: 'Related Note' }, + ]) + + const content = 'See Related Note for details.' + const result = await findLinkSuggestions(content, '1') + + expect(result).toHaveLength(1) + expect(result[0].noteTitle).toBe('Related Note') + }) + + it('sorts by title length (longer first)', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: '1', title: 'Short' }, + { id: '2', title: 'Very Long Title' }, + { id: '3', title: 'Medium Title' }, + ]) + + const content = 'Short and Medium Title and Very Long Title' + const result = await findLinkSuggestions(content) + + expect(result[0].noteTitle).toBe('Very Long Title') + expect(result[1].noteTitle).toBe('Medium Title') + expect(result[2].noteTitle).toBe('Short') + }) + + it('returns empty when no matches found', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: '1', title: 'Docker' }, + { id: '2', title: 'Git' }, + ]) + + const content = 'Python and JavaScript are programming languages.' + const result = await findLinkSuggestions(content) + + expect(result).toEqual([]) + }) + + it('handles case-insensitive matching', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: '1', title: 'Docker Commands' }, + ]) + + const content = 'I use DOCKER COMMANDS for my project.' + const result = await findLinkSuggestions(content) + + expect(result).toHaveLength(1) + expect(result[0].noteTitle).toBe('Docker Commands') + }) + + it('matches whole words only', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([ + { id: '1', title: 'Git' }, + ]) + + const content = 'GitHub uses Git internally.' + const result = await findLinkSuggestions(content) + + // Should match standalone 'Git' but not 'Git' within 'GitHub' + // Note: the regex \bGit\b matches standalone 'Git', not 'Git' in 'GitHub' + expect(result.some(r => r.noteTitle === 'Git')).toBe(true) + }) + + it('returns empty when no notes exist', async () => { + ;(prisma.note.findMany as jest.Mock).mockResolvedValue([]) + + const result = await findLinkSuggestions('Some content with potential matches') + + expect(result).toEqual([]) + }) + }) + + describe('applyWikiLinks', () => { + it('replaces terms with wiki-links', () => { + const content = 'I use Docker and Git for projects.' + const replacements = [ + { term: 'Docker', noteId: '1' }, + { term: 'Git', noteId: '2' }, + ] + + const result = applyWikiLinks(content, replacements) + + expect(result).toBe('I use [[Docker]] and [[Git]] for projects.') + }) + + it('handles multiple occurrences', () => { + const content = 'Docker is great. Docker is fast.' + const replacements = [{ term: 'Docker', noteId: '1' }] + + const result = applyWikiLinks(content, replacements) + + expect(result).toBe('[[Docker]] is great. [[Docker]] is fast.') + }) + + it('handles case-insensitive matching and replaces with link term', () => { + const content = 'DOCKER and docker and Docker' + const replacements = [{ term: 'Docker', noteId: '1' }] + + const result = applyWikiLinks(content, replacements) + + // All variations matched and replaced with the link text + expect(result).toBe('[[Docker]] and [[Docker]] and [[Docker]]') + }) + + it('returns original content when no replacements', () => { + const content = 'Original content' + const replacements: { term: string; noteId: string }[] = [] + + const result = applyWikiLinks(content, replacements) + + expect(result).toBe('Original content') + }) + + it('replaces multiple different terms', () => { + const content = 'Use React and TypeScript together.' + const replacements = [ + { term: 'React', noteId: '1' }, + { term: 'TypeScript', noteId: '2' }, + ] + + const result = applyWikiLinks(content, replacements) + + expect(result).toBe('Use [[React]] and [[TypeScript]] together.') + }) + }) +}) diff --git a/__tests__/type-inference.test.ts b/__tests__/type-inference.test.ts new file mode 100644 index 0000000..08df97b --- /dev/null +++ b/__tests__/type-inference.test.ts @@ -0,0 +1,221 @@ +import { inferNoteType, formatContentForType } from '@/lib/type-inference' + +describe('type-inference.ts', () => { + describe('inferNoteType', () => { + describe('command detection', () => { + it('detects git commands', () => { + const content = 'git commit -m "fix: resolve issue"\nnpm install\ndocker build' + const result = inferNoteType(content) + expect(result?.type).toBe('command') + expect(result?.confidence).toBeTruthy() // any confidence level + }) + + it('detects docker commands', () => { + const content = 'docker build -t myapp .\ndocker run -d' + const result = inferNoteType(content) + expect(result?.type).toBe('command') + }) + + it('detects shell prompts', () => { + const content = '$ curl -X POST https://api.example.com\n$ npm install' + const result = inferNoteType(content) + expect(result?.type).toBe('command') + }) + + it('detects shebang', () => { + const content = '#!/bin/bash\necho "Hello"' + const result = inferNoteType(content) + expect(result?.type).toBe('command') + }) + }) + + describe('snippet detection', () => { + it('detects code blocks', () => { + const content = '```javascript\nconst x = 1;\n```' + const result = inferNoteType(content) + expect(result?.type).toBe('snippet') + }) + + it('detects function declarations', () => { + const content = 'function hello() {\n return "world";\n}' + const result = inferNoteType(content) + expect(result?.type).toBe('snippet') + }) + + it('detects ES6 imports', () => { + const content = 'import React from "react"\nexport default App' + const result = inferNoteType(content) + expect(result?.type).toBe('snippet') + }) + + it('detects arrow functions', () => { + const content = 'const add = (a, b) => a + b;' + const result = inferNoteType(content) + expect(result?.type).toBe('snippet') + }) + + it('detects object literals', () => { + const content = 'const config = {\n name: "app",\n version: "1.0.0"\n}' + const result = inferNoteType(content) + expect(result?.type).toBe('snippet') + }) + }) + + describe('procedure detection', () => { + it('detects numbered steps', () => { + const content = '1. First step\n2. Second step\n3. Third step' + const result = inferNoteType(content) + expect(result?.type).toBe('procedure') + }) + + it('detects bullet points as steps', () => { + const content = '- Open the terminal\n- Run the command\n- Check the output' + const result = inferNoteType(content) + expect(result?.type).toBe('procedure') + }) + + it('detects step-related keywords', () => { + const content = 'primer paso, segundo paso, tercer paso, finally' + const result = inferNoteType(content) + expect(result?.type).toBe('procedure') + }) + + it('detects tutorial language', () => { + const content = 'How to install Node.js:\n1. Download the installer\n2. Run the setup' + const result = inferNoteType(content) + expect(result?.type).toBe('procedure') + }) + }) + + describe('recipe detection', () => { + it('detects ingredients pattern', () => { + const content = 'ingredientes:\n- 2 tazas de harina\n- 1 taza de azúcar' + const result = inferNoteType(content) + expect(result?.type).toBe('recipe') + }) + + it('detects recipe-related keywords', () => { + const content = 'receta:\n1. sofreír cebolla\n2. añadir arroz\ntiempo: 30 minutos' + const result = inferNoteType(content) + expect(result?.type).toBe('recipe') + }) + }) + + describe('decision detection', () => { + it('detects decision context', () => { + const content = 'Decisión: Usar PostgreSQL en lugar de MySQL\n\nRazón: Mejor soporte para JSON y transacciones.' + const result = inferNoteType(content) + expect(result?.type).toBe('decision') + }) + + it('detects pros and cons', () => { + const content = 'decisión:\nPros: Better performance\nContras: More expensive' + const result = inferNoteType(content) + expect(result?.type).toBe('decision') + }) + + it('detects alternatives considered', () => { + const content = 'Alternativas consideradas:\n1. AWS\n2. GCP\n3. Azure\n\nElegimos Vercel por su integración con Next.js.' + const result = inferNoteType(content) + expect(result?.type).toBe('decision') + }) + }) + + describe('inventory detection', () => { + it('detects quantity patterns', () => { + const content = 'Item: Laptop\nCantidad: 5\nUbicación: Oficina principal' + const result = inferNoteType(content) + expect(result?.type).toBe('inventory') + }) + + it('detects inventory-related keywords', () => { + const content = 'Stock disponible: 100 unidades\nNivel mínimo: 20' + const result = inferNoteType(content) + expect(result?.type).toBe('inventory') + }) + }) + + describe('edge cases', () => { + it('returns note type for generic content', () => { + const content = 'This is a simple note about my day.' + const result = inferNoteType(content) + expect(result?.type).toBe('note') + }) + + it('returns note type with low confidence for empty content', () => { + const result = inferNoteType('') + expect(result?.type).toBe('note') + expect(result?.confidence).toBe('low') + }) + + it('returns note type with low confidence for very short content', () => { + const result = inferNoteType('Hi') + expect(result?.type).toBe('note') + expect(result?.confidence).toBe('low') + }) + + it('prioritizes highest confidence match', () => { + // Command-like code with shell prompt - medium confidence (2 patterns) + const content = '$ npm install\ngit commit -m "fix"' + const result = inferNoteType(content) + expect(result?.type).toBe('command') + }) + }) + }) + + describe('formatContentForType', () => { + it('formats command type', () => { + const content = 'git status' + const result = formatContentForType(content, 'command') + expect(result).toContain('## Comando') + expect(result).toContain('## Cuándo usarlo') + expect(result).toContain('## Ejemplo') + }) + + it('formats snippet type', () => { + const content = 'const x = 1;' + const result = formatContentForType(content, 'snippet') + expect(result).toContain('## Snippet') + expect(result).toContain('## Lenguaje') + expect(result).toContain('## Código') + }) + + it('formats procedure type', () => { + const content = '1. Step one\n2. Step two' + const result = formatContentForType(content, 'procedure') + expect(result).toContain('## Objetivo') + expect(result).toContain('## Pasos') + expect(result).toContain('## Requisitos') + }) + + it('formats recipe type', () => { + const content = 'Ingredients:\n- Flour\n- Sugar' + const result = formatContentForType(content, 'recipe') + expect(result).toContain('## Ingredientes') + expect(result).toContain('## Pasos') + expect(result).toContain('## Tiempo') + }) + + it('formats decision type', () => { + const content = 'Use TypeScript' + const result = formatContentForType(content, 'decision') + expect(result).toContain('## Contexto') + expect(result).toContain('## Decisión') + expect(result).toContain('## Alternativas') + }) + + it('formats inventory type', () => { + const content = 'Laptop model X' + const result = formatContentForType(content, 'inventory') + expect(result).toContain('## Item') + expect(result).toContain('## Cantidad') + expect(result).toContain('## Ubicación') + }) + + it('formats note type', () => { + const content = 'Simple note content' + const result = formatContentForType(content, 'note') + expect(result).toContain('## Notas') + }) + }) +}) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 73bde07..d87c3b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,18 +8,21 @@ datasource db { } model Note { - id String @id @default(cuid()) - title String - content String - type String @default("note") - isFavorite Boolean @default(false) - isPinned Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - tags NoteTag[] - backlinks Backlink[] @relation("BacklinkTarget") - outbound Backlink[] @relation("BacklinkSource") - usageEvents NoteUsage[] + id String @id @default(cuid()) + title String + content String + type String @default("note") + isFavorite Boolean @default(false) + isPinned Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + creationSource String @default("form") // 'form' | 'quick' | 'import' + tags NoteTag[] + backlinks Backlink[] @relation("BacklinkTarget") + outbound Backlink[] @relation("BacklinkSource") + usageEvents NoteUsage[] + coUsageFrom NoteCoUsage[] @relation("CoUsageFrom") + coUsageTo NoteCoUsage[] @relation("CoUsageTo") } model Tag { @@ -60,3 +63,18 @@ model NoteUsage { @@index([noteId, createdAt]) @@index([eventType, createdAt]) } + +model NoteCoUsage { + id String @id @default(cuid()) + fromNoteId String + fromNote Note @relation("CoUsageFrom", fields: [fromNoteId], references: [id], onDelete: Cascade) + toNoteId String + toNote Note @relation("CoUsageTo", fields: [toNoteId], references: [id], onDelete: Cascade) + weight Int @default(1) // times viewed together + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([fromNoteId, toNoteId]) + @@index([fromNoteId]) + @@index([toNoteId]) +} diff --git a/src/app/api/centrality/route.ts b/src/app/api/centrality/route.ts new file mode 100644 index 0000000..780446e --- /dev/null +++ b/src/app/api/centrality/route.ts @@ -0,0 +1,16 @@ +import { NextRequest } from 'next/server' +import { getCentralNotes } from '@/lib/centrality' +import { createErrorResponse, createSuccessResponse } from '@/lib/errors' + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url) + const limit = parseInt(searchParams.get('limit') || '10', 10) + + const centralNotes = await getCentralNotes(limit) + + return createSuccessResponse(centralNotes) + } catch (error) { + return createErrorResponse(error) + } +} diff --git a/src/app/api/export-import/route.ts b/src/app/api/export-import/route.ts index b37ef3c..a8b723d 100644 --- a/src/app/api/export-import/route.ts +++ b/src/app/api/export-import/route.ts @@ -79,6 +79,7 @@ export async function POST(req: NextRequest) { id: item.id, createdAt, updatedAt, + creationSource: 'import', }, }) processed++ @@ -99,6 +100,7 @@ export async function POST(req: NextRequest) { ...noteData, createdAt, updatedAt, + creationSource: 'import', }, }) } diff --git a/src/app/api/metrics/route.ts b/src/app/api/metrics/route.ts new file mode 100644 index 0000000..abc29c8 --- /dev/null +++ b/src/app/api/metrics/route.ts @@ -0,0 +1,16 @@ +import { NextRequest } from 'next/server' +import { getDashboardMetrics } from '@/lib/metrics' +import { createErrorResponse, createSuccessResponse } from '@/lib/errors' + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url) + const days = parseInt(searchParams.get('days') || '30', 10) + + const metrics = await getDashboardMetrics(days) + + return createSuccessResponse(metrics) + } catch (error) { + return createErrorResponse(error) + } +} diff --git a/src/app/api/notes/links/route.ts b/src/app/api/notes/links/route.ts new file mode 100644 index 0000000..fb1527b --- /dev/null +++ b/src/app/api/notes/links/route.ts @@ -0,0 +1,50 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/lib/prisma' +import { createErrorResponse, createSuccessResponse } from '@/lib/errors' + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url) + const content = searchParams.get('content') || '' + const noteId = searchParams.get('noteId') || '' + + if (!content.trim() || content.length < 10) { + return createSuccessResponse([]) + } + + // Get all notes except current one + const allNotes = await prisma.note.findMany({ + where: noteId ? { id: { not: noteId } } : undefined, + select: { id: true, title: true }, + }) + + if (allNotes.length === 0) { + return createSuccessResponse([]) + } + + // Find titles that appear in content + const suggestions: { term: string; noteId: string; noteTitle: string }[] = [] + const contentLower = content.toLowerCase() + + for (const note of allNotes) { + const titleLower = note.title.toLowerCase() + // Check if title appears as a whole word in content + const escaped = titleLower.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const regex = new RegExp(`\\b${escaped}\\b`, 'i') + if (regex.test(content)) { + suggestions.push({ + term: note.title, + noteId: note.id, + noteTitle: note.title, + }) + } + } + + // Sort by title length (longer = more specific) + suggestions.sort((a, b) => b.noteTitle.length - a.noteTitle.length) + + return createSuccessResponse(suggestions.slice(0, 10)) + } catch (error) { + return createErrorResponse(error) + } +} diff --git a/src/app/api/notes/quick/route.ts b/src/app/api/notes/quick/route.ts index 74a5422..d45aa6c 100644 --- a/src/app/api/notes/quick/route.ts +++ b/src/app/api/notes/quick/route.ts @@ -31,6 +31,7 @@ export async function POST(req: NextRequest) { title: title.trim(), content: noteContent || title.trim(), type, + creationSource: 'quick', tags: tags.length > 0 ? { create: await Promise.all( tags.map(async (tagName) => { diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index af9efdf..5cb9f14 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -37,11 +37,12 @@ export async function POST(req: NextRequest) { throw new ValidationError(result.error.issues) } - const { tags, ...noteData } = result.data + const { tags, creationSource, ...noteData } = result.data const note = await prisma.note.create({ data: { ...noteData, + creationSource: creationSource || 'form', tags: tags && tags.length > 0 ? { create: await Promise.all( (tags as string[]).map(async (tagName) => { diff --git a/src/app/api/usage/co-usage/route.ts b/src/app/api/usage/co-usage/route.ts new file mode 100644 index 0000000..b4462a6 --- /dev/null +++ b/src/app/api/usage/co-usage/route.ts @@ -0,0 +1,19 @@ +import { NextRequest } from 'next/server' +import { trackCoUsage } from '@/lib/usage' +import { createErrorResponse } from '@/lib/errors' + +export async function POST(req: NextRequest) { + try { + const { fromNoteId, toNoteId } = await req.json() + + if (!fromNoteId || !toNoteId) { + return createErrorResponse(new Error('Missing note IDs')) + } + + await trackCoUsage(fromNoteId, toNoteId) + + return new Response(null, { status: 204 }) + } catch (error) { + return createErrorResponse(error) + } +} diff --git a/src/components/note-form.tsx b/src/components/note-form.tsx index 5cd70d4..ad721df 100644 --- a/src/components/note-form.tsx +++ b/src/components/note-form.tsx @@ -8,7 +8,8 @@ import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Badge } from '@/components/ui/badge' -import { X } from 'lucide-react' +import { X, Sparkles } from 'lucide-react' +import { inferNoteType } from '@/lib/type-inference' // Command fields interface CommandFields { @@ -615,6 +616,7 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) { }) const [tags, setTags] = useState(initialData?.tags.map(t => t.tag.name) || []) const [autoSuggestedTags, setAutoSuggestedTags] = useState([]) + const [autoSuggestedType, setAutoSuggestedType] = useState(null) const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false) const [isPinned, setIsPinned] = useState(initialData?.isPinned || false) const [isSubmitting, setIsSubmitting] = useState(false) @@ -653,6 +655,84 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) { return () => clearTimeout(timeoutId) }, [title, content, tags]) + // Auto-suggest type based on content (only for new notes, not edits) + useEffect(() => { + if (isEdit || !content.trim()) { + setAutoSuggestedType(null) + return + } + + // Only suggest if content is reasonably filled and user hasn't changed type manually + const suggestion = inferNoteType(content) + if (suggestion && suggestion.confidence === 'high') { + setAutoSuggestedType(suggestion.type) + } else { + setAutoSuggestedType(null) + } + }, [content, isEdit]) + + const acceptSuggestedType = () => { + if (autoSuggestedType) { + setType(autoSuggestedType) + setFields(defaultFields[autoSuggestedType]) + setAutoSuggestedType(null) + } + } + + // Link suggestions state + const [linkSuggestions, setLinkSuggestions] = useState<{ term: string; noteId: string; noteTitle: string }[]>([]) + + // Fetch link suggestions based on content + useEffect(() => { + const fetchLinkSuggestions = async () => { + if (content.trim().length < 20) { + setLinkSuggestions([]) + return + } + + try { + const params = new URLSearchParams({ content }) + if (initialData?.id) { + params.set('noteId', initialData.id) + } + const res = await fetch(`/api/notes/links?${params}`) + if (res.ok) { + const data = await res.json() + setLinkSuggestions(data.data || []) + } + } catch (error) { + console.error('Error fetching link suggestions:', error) + } + } + + const timeoutId = setTimeout(fetchLinkSuggestions, 800) + return () => clearTimeout(timeoutId) + }, [content, initialData?.id]) + + const convertToWikiLink = (term: string) => { + // Replace the term with [[term]] in the content + // This requires modifying fields directly based on the type + const newContent = content.replace(new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi'), `[[${term}]]`) + // Update the appropriate field based on type + if (type === 'note') { + setFields({ content: newContent }) + } else if (type === 'command') { + const f = fields as CommandFields + setFields({ ...f, example: newContent }) + } else if (type === 'snippet') { + const f = fields as SnippetFields + setFields({ ...f, code: newContent }) + } else { + setFields({ content: newContent } as TypeFields) + } + // Remove from suggestions + setLinkSuggestions(prev => prev.filter(s => s.term !== term)) + } + + function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setIsSubmitting(true) @@ -733,6 +813,21 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) { ))} + {autoSuggestedType && autoSuggestedType !== type && ( +
+ + + ¿Es {autoSuggestedType}? + + +
+ )}
@@ -769,6 +864,30 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
)} + {/* Link suggestions */} + {linkSuggestions.length > 0 && ( +
+

+ + Enlaces internos detectados: +

+
+ {linkSuggestions.slice(0, 5).map((suggestion) => ( + + ))} +
+
+ )} +