feat: MVP-3 Sprint 4 - Co-usage, metrics, centrality, creation source, feature flags
- Add NoteCoUsage model and co-usage tracking when viewing notes - Add creationSource field to notes (form/quick/import) - Add dashboard metrics API (/api/metrics) - Add centrality calculation (/api/centrality) - Add feature flags system for toggling features - Add multiline QuickAdd with smart paste type detection - Add internal link suggestions while editing notes - Add type inference for automatic note type detection - Add comprehensive tests for type-inference and link-suggestions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
171
__tests__/link-suggestions.test.ts
Normal file
171
__tests__/link-suggestions.test.ts
Normal file
@@ -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.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
221
__tests__/type-inference.test.ts
Normal file
221
__tests__/type-inference.test.ts
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,10 +16,13 @@ model Note {
|
|||||||
isPinned Boolean @default(false)
|
isPinned Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
creationSource String @default("form") // 'form' | 'quick' | 'import'
|
||||||
tags NoteTag[]
|
tags NoteTag[]
|
||||||
backlinks Backlink[] @relation("BacklinkTarget")
|
backlinks Backlink[] @relation("BacklinkTarget")
|
||||||
outbound Backlink[] @relation("BacklinkSource")
|
outbound Backlink[] @relation("BacklinkSource")
|
||||||
usageEvents NoteUsage[]
|
usageEvents NoteUsage[]
|
||||||
|
coUsageFrom NoteCoUsage[] @relation("CoUsageFrom")
|
||||||
|
coUsageTo NoteCoUsage[] @relation("CoUsageTo")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Tag {
|
model Tag {
|
||||||
@@ -60,3 +63,18 @@ model NoteUsage {
|
|||||||
@@index([noteId, createdAt])
|
@@index([noteId, createdAt])
|
||||||
@@index([eventType, 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])
|
||||||
|
}
|
||||||
|
|||||||
16
src/app/api/centrality/route.ts
Normal file
16
src/app/api/centrality/route.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ export async function POST(req: NextRequest) {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
creationSource: 'import',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
processed++
|
processed++
|
||||||
@@ -99,6 +100,7 @@ export async function POST(req: NextRequest) {
|
|||||||
...noteData,
|
...noteData,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
creationSource: 'import',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/app/api/metrics/route.ts
Normal file
16
src/app/api/metrics/route.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/api/notes/links/route.ts
Normal file
50
src/app/api/notes/links/route.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ export async function POST(req: NextRequest) {
|
|||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
content: noteContent || title.trim(),
|
content: noteContent || title.trim(),
|
||||||
type,
|
type,
|
||||||
|
creationSource: 'quick',
|
||||||
tags: tags.length > 0 ? {
|
tags: tags.length > 0 ? {
|
||||||
create: await Promise.all(
|
create: await Promise.all(
|
||||||
tags.map(async (tagName) => {
|
tags.map(async (tagName) => {
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ export async function POST(req: NextRequest) {
|
|||||||
throw new ValidationError(result.error.issues)
|
throw new ValidationError(result.error.issues)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tags, ...noteData } = result.data
|
const { tags, creationSource, ...noteData } = result.data
|
||||||
|
|
||||||
const note = await prisma.note.create({
|
const note = await prisma.note.create({
|
||||||
data: {
|
data: {
|
||||||
...noteData,
|
...noteData,
|
||||||
|
creationSource: creationSource || 'form',
|
||||||
tags: tags && tags.length > 0 ? {
|
tags: tags && tags.length > 0 ? {
|
||||||
create: await Promise.all(
|
create: await Promise.all(
|
||||||
(tags as string[]).map(async (tagName) => {
|
(tags as string[]).map(async (tagName) => {
|
||||||
|
|||||||
19
src/app/api/usage/co-usage/route.ts
Normal file
19
src/app/api/usage/co-usage/route.ts
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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
|
// Command fields
|
||||||
interface CommandFields {
|
interface CommandFields {
|
||||||
@@ -615,6 +616,7 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
})
|
})
|
||||||
const [tags, setTags] = useState<string[]>(initialData?.tags.map(t => t.tag.name) || [])
|
const [tags, setTags] = useState<string[]>(initialData?.tags.map(t => t.tag.name) || [])
|
||||||
const [autoSuggestedTags, setAutoSuggestedTags] = useState<string[]>([])
|
const [autoSuggestedTags, setAutoSuggestedTags] = useState<string[]>([])
|
||||||
|
const [autoSuggestedType, setAutoSuggestedType] = useState<NoteType | null>(null)
|
||||||
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
|
const [isFavorite, setIsFavorite] = useState(initialData?.isFavorite || false)
|
||||||
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
const [isPinned, setIsPinned] = useState(initialData?.isPinned || false)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
@@ -653,6 +655,84 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
return () => clearTimeout(timeoutId)
|
return () => clearTimeout(timeoutId)
|
||||||
}, [title, content, tags])
|
}, [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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
@@ -733,6 +813,21 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{autoSuggestedType && autoSuggestedType !== type && (
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||||
|
<Sparkles className="h-3 w-3 text-primary" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
¿Es <span className="text-primary font-medium">{autoSuggestedType}</span>?
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={acceptSuggestedType}
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Usar tipo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -769,6 +864,30 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Link suggestions */}
|
||||||
|
{linkSuggestions.length > 0 && (
|
||||||
|
<div className="bg-muted/50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
Enlaces internos detectados:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{linkSuggestions.slice(0, 5).map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.term}
|
||||||
|
type="button"
|
||||||
|
onClick={() => convertToWikiLink(suggestion.term)}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 text-sm bg-background border rounded-full hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
title={`Convertir "${suggestion.term}" a [[${suggestion.term}]]`}
|
||||||
|
>
|
||||||
|
<span>{suggestion.term}</span>
|
||||||
|
<span className="text-xs opacity-60">[[]]</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className="flex items-center gap-2">
|
<label className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,19 +1,84 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { Plus, Loader2 } from 'lucide-react'
|
import { Plus, Loader2, Text, Sparkles, X } from 'lucide-react'
|
||||||
|
import { inferNoteType, formatContentForType } from '@/lib/type-inference'
|
||||||
|
import { NoteType } from '@/types/note'
|
||||||
|
|
||||||
|
interface TypeSuggestion {
|
||||||
|
type: NoteType
|
||||||
|
confidence: 'high' | 'medium' | 'low'
|
||||||
|
reason: string
|
||||||
|
formattedContent: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<NoteType, string> = {
|
||||||
|
command: 'Comando',
|
||||||
|
snippet: 'Snippet',
|
||||||
|
procedure: 'Procedimiento',
|
||||||
|
recipe: 'Receta',
|
||||||
|
decision: 'Decisión',
|
||||||
|
inventory: 'Inventario',
|
||||||
|
note: 'Nota',
|
||||||
|
}
|
||||||
|
|
||||||
export function QuickAdd() {
|
export function QuickAdd() {
|
||||||
const [value, setValue] = useState('')
|
const [value, setValue] = useState('')
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
|
const [isMultiline, setIsMultiline] = useState(false)
|
||||||
|
const [typeSuggestion, setTypeSuggestion] = useState<TypeSuggestion | null>(null)
|
||||||
|
const [dismissedSuggestion, setDismissedSuggestion] = useState(false)
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
|
const detectContentType = useCallback((text: string) => {
|
||||||
|
if (!text.trim() || text.length < 10) {
|
||||||
|
setTypeSuggestion(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (dismissedSuggestion) return
|
||||||
|
|
||||||
|
const suggestion = inferNoteType(text)
|
||||||
|
if (suggestion && suggestion.confidence === 'high') {
|
||||||
|
const formatted = formatContentForType(text, suggestion.type)
|
||||||
|
setTypeSuggestion({
|
||||||
|
...suggestion,
|
||||||
|
formattedContent: formatted,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setTypeSuggestion(null)
|
||||||
|
}
|
||||||
|
}, [dismissedSuggestion])
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent) => {
|
||||||
|
// Let the paste happen first
|
||||||
|
setDismissedSuggestion(false)
|
||||||
|
setTimeout(() => {
|
||||||
|
detectContentType(value)
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const acceptSuggestion = () => {
|
||||||
|
if (typeSuggestion) {
|
||||||
|
setValue(typeSuggestion.formattedContent)
|
||||||
|
setTypeSuggestion(null)
|
||||||
|
setIsMultiline(true)
|
||||||
|
setIsExpanded(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissSuggestion = () => {
|
||||||
|
setTypeSuggestion(null)
|
||||||
|
setDismissedSuggestion(true)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e?: React.FormEvent) => {
|
||||||
e?.preventDefault()
|
e?.preventDefault()
|
||||||
if (!value.trim() || isLoading) return
|
if (!value.trim() || isLoading) return
|
||||||
@@ -48,14 +113,24 @@ export function QuickAdd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey && !isMultiline) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
handleSubmit()
|
handleSubmit()
|
||||||
}
|
}
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setValue('')
|
setValue('')
|
||||||
setIsExpanded(false)
|
setIsExpanded(false)
|
||||||
|
setIsMultiline(false)
|
||||||
inputRef.current?.blur()
|
inputRef.current?.blur()
|
||||||
|
textareaRef.current?.blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMultiline = () => {
|
||||||
|
setIsMultiline(!isMultiline)
|
||||||
|
if (!isMultiline) {
|
||||||
|
setIsExpanded(true)
|
||||||
|
setTimeout(() => textareaRef.current?.focus(), 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,8 +155,29 @@ export function QuickAdd() {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-end gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
{isMultiline ? (
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
placeholder="cmd: título #tag Contenido multilínea..."
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value)
|
||||||
|
detectContentType(e.target.value)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
className={cn(
|
||||||
|
'min-h-[80px] max-h-[200px] transition-all duration-200 resize-none',
|
||||||
|
isExpanded && 'w-full'
|
||||||
|
)}
|
||||||
|
disabled={isLoading}
|
||||||
|
rows={isExpanded ? 4 : 2}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -90,16 +186,31 @@ export function QuickAdd() {
|
|||||||
onChange={(e) => setValue(e.target.value)}
|
onChange={(e) => setValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setIsExpanded(true)}
|
onFocus={() => setIsExpanded(true)}
|
||||||
|
onPaste={handlePaste}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-48 transition-all duration-200',
|
'w-48 transition-all duration-200',
|
||||||
isExpanded && 'w-72'
|
isExpanded && 'w-72'
|
||||||
)}
|
)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMultiline}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center rounded-lg border bg-background p-2',
|
||||||
|
'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
'transition-colors',
|
||||||
|
isMultiline && 'bg-accent text-accent-foreground'
|
||||||
|
)}
|
||||||
|
title={isMultiline ? 'Modo línea' : 'Modo multilínea'}
|
||||||
|
>
|
||||||
|
<Text className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!value.trim() || isLoading}
|
disabled={!value.trim() || isLoading}
|
||||||
@@ -117,5 +228,44 @@ export function QuickAdd() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{/* Smart paste suggestion */}
|
||||||
|
{typeSuggestion && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 p-3 bg-popover border rounded-lg shadow-md z-50">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Sparkles className="h-4 w-4 text-primary mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
Detectado: <span className="text-primary">{TYPE_LABELS[typeSuggestion.type]}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">{typeSuggestion.reason}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismissSuggestion}
|
||||||
|
className="p-1 hover:bg-accent rounded"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={acceptSuggestion}
|
||||||
|
className="text-xs px-2 py-1 bg-primary text-primary-foreground rounded hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
Usar plantilla
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismissSuggestion}
|
||||||
|
className="text-xs px-2 py-1 text-muted-foreground hover:bg-accent rounded"
|
||||||
|
>
|
||||||
|
Descartar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { addToRecentlyViewed } from '@/lib/usage'
|
import { addToRecentlyViewed, getRecentlyViewedIds } from '@/lib/usage'
|
||||||
|
|
||||||
export function TrackNoteView({ noteId }: { noteId: string }) {
|
export function TrackNoteView({ noteId }: { noteId: string }) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
addToRecentlyViewed(noteId)
|
addToRecentlyViewed(noteId)
|
||||||
|
|
||||||
|
// Track co-usage with previously viewed notes
|
||||||
|
const recentIds = getRecentlyViewedIds()
|
||||||
|
// Track co-usage with up to 3 most recent notes (excluding current)
|
||||||
|
const previousNotes = recentIds.filter(id => id !== noteId).slice(0, 3)
|
||||||
|
|
||||||
|
for (const prevNoteId of previousNotes) {
|
||||||
|
fetch('/api/usage/co-usage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ fromNoteId: prevNoteId, toNoteId: noteId }),
|
||||||
|
}).catch(() => {}) // Silently fail
|
||||||
|
}
|
||||||
}, [noteId])
|
}, [noteId])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
89
src/lib/centrality.ts
Normal file
89
src/lib/centrality.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export interface CentralNote {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
type: string
|
||||||
|
centralityScore: number
|
||||||
|
backlinks: number
|
||||||
|
outboundLinks: number
|
||||||
|
usageViews: number
|
||||||
|
coUsageWeight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate centrality score for all notes.
|
||||||
|
* A note is "central" if it has many connections (backlinks/outbound) and high usage.
|
||||||
|
*/
|
||||||
|
export async function getCentralNotes(limit = 10): Promise<CentralNote[]> {
|
||||||
|
try {
|
||||||
|
// Get all notes with their counts
|
||||||
|
const notes = await prisma.note.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
backlinks: { select: { id: true } },
|
||||||
|
outbound: { select: { id: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get usage stats for all notes
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||||
|
const usageStats = await prisma.noteUsage.groupBy({
|
||||||
|
by: ['noteId'],
|
||||||
|
where: {
|
||||||
|
eventType: 'view',
|
||||||
|
createdAt: { gte: thirtyDaysAgo },
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
})
|
||||||
|
const usageMap = new Map(usageStats.map((u) => [u.noteId, u._count.id]))
|
||||||
|
|
||||||
|
// Get co-usage stats
|
||||||
|
const coUsageStats = await prisma.noteCoUsage.groupBy({
|
||||||
|
by: ['fromNoteId', 'toNoteId'],
|
||||||
|
_sum: { weight: true },
|
||||||
|
})
|
||||||
|
const coUsageMap = new Map<string, number>()
|
||||||
|
for (const cu of coUsageStats) {
|
||||||
|
coUsageMap.set(cu.fromNoteId, (coUsageMap.get(cu.fromNoteId) || 0) + (cu._sum.weight || 0))
|
||||||
|
coUsageMap.set(cu.toNoteId, (coUsageMap.get(cu.toNoteId) || 0) + (cu._sum.weight || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate centrality score for each note
|
||||||
|
const scoredNotes: CentralNote[] = notes.map((note) => {
|
||||||
|
const backlinks = note.backlinks.length
|
||||||
|
const outboundLinks = note.outbound.length
|
||||||
|
const usageViews = usageMap.get(note.id) || 0
|
||||||
|
const coUsageWeight = coUsageMap.get(note.id) || 0
|
||||||
|
|
||||||
|
// Centrality formula:
|
||||||
|
// - Each backlink = 3 points (incoming connections show importance)
|
||||||
|
// - Each outbound link = 1 point (shows knowledge breadth)
|
||||||
|
// - Each usage view = 0.5 points (shows relevance)
|
||||||
|
// - Each co-usage weight = 2 points (shows related usage patterns)
|
||||||
|
const centralityScore =
|
||||||
|
backlinks * 3 + outboundLinks * 1 + usageViews * 0.5 + coUsageWeight * 2
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
type: note.type,
|
||||||
|
centralityScore,
|
||||||
|
backlinks,
|
||||||
|
outboundLinks,
|
||||||
|
usageViews,
|
||||||
|
coUsageWeight,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by centrality score descending
|
||||||
|
scoredNotes.sort((a, b) => b.centralityScore - a.centralityScore)
|
||||||
|
|
||||||
|
return scoredNotes.slice(0, limit)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error calculating centrality:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/lib/features.ts
Normal file
56
src/lib/features.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Feature flags for MVP-3 features.
|
||||||
|
* Can be toggled via environment variables or local config.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FeatureFlags {
|
||||||
|
centrality: boolean
|
||||||
|
passiveRecommendations: boolean
|
||||||
|
typeSuggestions: boolean
|
||||||
|
linkSuggestions: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default values - all enabled unless explicitly disabled
|
||||||
|
const defaults: FeatureFlags = {
|
||||||
|
centrality: true,
|
||||||
|
passiveRecommendations: true,
|
||||||
|
typeSuggestions: true,
|
||||||
|
linkSuggestions: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Environment variable parsing
|
||||||
|
function parseEnvBool(key: string, defaultValue: boolean): boolean {
|
||||||
|
const envValue = process.env[key]
|
||||||
|
if (envValue === undefined) return defaultValue
|
||||||
|
if (envValue === 'true' || envValue === '1') return true
|
||||||
|
if (envValue === 'false' || envValue === '0') return false
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current feature flags.
|
||||||
|
* Reads from environment variables with defaults.
|
||||||
|
*/
|
||||||
|
export function getFeatureFlags(): FeatureFlags {
|
||||||
|
return {
|
||||||
|
centrality: parseEnvBool('FLAG_CENTRALITY', defaults.centrality),
|
||||||
|
passiveRecommendations: parseEnvBool('FLAG_PASSIVE_RECOMMENDATIONS', defaults.passiveRecommendations),
|
||||||
|
typeSuggestions: parseEnvBool('FLAG_TYPE_SUGGESTIONS', defaults.typeSuggestions),
|
||||||
|
linkSuggestions: parseEnvBool('FLAG_LINK_SUGGESTIONS', defaults.linkSuggestions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a specific feature is enabled.
|
||||||
|
*/
|
||||||
|
export function isFeatureEnabled(feature: keyof FeatureFlags): boolean {
|
||||||
|
return getFeatureFlags()[feature]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get flags for client-side use (only boolean values).
|
||||||
|
* This is safe to expose to the client.
|
||||||
|
*/
|
||||||
|
export function getClientFeatureFlags(): FeatureFlags {
|
||||||
|
return getFeatureFlags()
|
||||||
|
}
|
||||||
72
src/lib/link-suggestions.ts
Normal file
72
src/lib/link-suggestions.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export interface LinkSuggestion {
|
||||||
|
term: string
|
||||||
|
noteId: string
|
||||||
|
noteTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find potential wiki-link suggestions in content.
|
||||||
|
* Returns notes whose titles appear in the content and could be converted to [[links]].
|
||||||
|
*/
|
||||||
|
export async function findLinkSuggestions(
|
||||||
|
content: string,
|
||||||
|
currentNoteId?: string
|
||||||
|
): Promise<LinkSuggestion[]> {
|
||||||
|
if (!content.trim() || content.length < 10) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all note titles except current note
|
||||||
|
const allNotes = await prisma.note.findMany({
|
||||||
|
where: currentNoteId ? { id: { not: currentNoteId } } : undefined,
|
||||||
|
select: { id: true, title: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allNotes.length === 0) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find titles that appear in content (case-insensitive)
|
||||||
|
const suggestions: LinkSuggestion[] = []
|
||||||
|
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 regex = new RegExp(`\\b${escapeRegex(titleLower)}\\b`, 'i')
|
||||||
|
if (regex.test(content)) {
|
||||||
|
suggestions.push({
|
||||||
|
term: note.title,
|
||||||
|
noteId: note.id,
|
||||||
|
noteTitle: note.title,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by title length (longer titles first - more specific matches)
|
||||||
|
return suggestions.sort((a, b) => b.noteTitle.length - a.noteTitle.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace terms in content with wiki-links
|
||||||
|
*/
|
||||||
|
export function applyWikiLinks(
|
||||||
|
content: string,
|
||||||
|
replacements: { term: string; noteId: string }[]
|
||||||
|
): string {
|
||||||
|
let result = content
|
||||||
|
|
||||||
|
for (const { term, noteId } of replacements) {
|
||||||
|
// Replace all occurrences of the term (case-insensitive, whole word only)
|
||||||
|
const regex = new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi')
|
||||||
|
result = result.replace(regex, `[[${term}]]`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}
|
||||||
110
src/lib/metrics.ts
Normal file
110
src/lib/metrics.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
|
||||||
|
export interface DashboardMetrics {
|
||||||
|
totalNotes: number
|
||||||
|
totalTags: number
|
||||||
|
notesByType: Record<string, number>
|
||||||
|
topTags: { name: string; count: number }[]
|
||||||
|
topViewedNotes: { id: string; title: string; type: string; views: number }[]
|
||||||
|
creationSourceStats: { form: number; quick: number; import: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardMetrics(days = 30): Promise<DashboardMetrics> {
|
||||||
|
try {
|
||||||
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||||
|
|
||||||
|
// Get totals
|
||||||
|
const [totalNotes, totalTags, notesByTypeResult, topTagsResult] = await Promise.all([
|
||||||
|
prisma.note.count(),
|
||||||
|
prisma.tag.count(),
|
||||||
|
prisma.note.groupBy({
|
||||||
|
by: ['type'],
|
||||||
|
_count: { id: true },
|
||||||
|
}),
|
||||||
|
prisma.noteTag.groupBy({
|
||||||
|
by: ['tagId'],
|
||||||
|
_count: { noteId: true },
|
||||||
|
orderBy: { _count: { noteId: 'desc' } },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Get top viewed notes from usage
|
||||||
|
const topUsage = await prisma.noteUsage.groupBy({
|
||||||
|
by: ['noteId'],
|
||||||
|
where: {
|
||||||
|
eventType: 'view',
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
_count: { id: true },
|
||||||
|
orderBy: { _count: { id: 'desc' } },
|
||||||
|
take: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
const topViewedNotes = await Promise.all(
|
||||||
|
topUsage.map(async (usage) => {
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { id: usage.noteId },
|
||||||
|
select: { id: true, title: true, type: true },
|
||||||
|
})
|
||||||
|
return note
|
||||||
|
? {
|
||||||
|
id: note.id,
|
||||||
|
title: note.title,
|
||||||
|
type: note.type,
|
||||||
|
views: usage._count.id,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get creation source stats
|
||||||
|
const creationSourceStats = await prisma.note.groupBy({
|
||||||
|
by: ['creationSource'],
|
||||||
|
_count: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const sourceMap = { form: 0, quick: 0, import: 0 }
|
||||||
|
for (const stat of creationSourceStats) {
|
||||||
|
if (stat.creationSource in sourceMap) {
|
||||||
|
sourceMap[stat.creationSource as keyof typeof sourceMap] = stat._count.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get tag names
|
||||||
|
const tagIds = topTagsResult.map((t) => t.tagId)
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
where: { id: { in: tagIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
})
|
||||||
|
const tagMap = new Map(tags.map((t) => [t.id, t.name]))
|
||||||
|
|
||||||
|
const topTags = topTagsResult
|
||||||
|
.map((t) => ({
|
||||||
|
name: tagMap.get(t.tagId) || 'unknown',
|
||||||
|
count: t._count.noteId,
|
||||||
|
}))
|
||||||
|
.filter((t) => t.name !== 'unknown')
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalNotes,
|
||||||
|
totalTags,
|
||||||
|
notesByType: Object.fromEntries(
|
||||||
|
notesByTypeResult.map((r) => [r.type, r._count.id])
|
||||||
|
),
|
||||||
|
topTags,
|
||||||
|
topViewedNotes: topViewedNotes.filter((n): n is NonNullable<typeof n> => n !== null),
|
||||||
|
creationSourceStats: sourceMap,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting dashboard metrics:', error)
|
||||||
|
return {
|
||||||
|
totalNotes: 0,
|
||||||
|
totalTags: 0,
|
||||||
|
notesByType: {},
|
||||||
|
topTags: [],
|
||||||
|
topViewedNotes: [],
|
||||||
|
creationSourceStats: { form: 0, quick: 0, import: 0 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/lib/type-inference.ts
Normal file
143
src/lib/type-inference.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { NoteType } from '@/types/note'
|
||||||
|
|
||||||
|
interface TypeSuggestion {
|
||||||
|
type: NoteType
|
||||||
|
confidence: 'high' | 'medium' | 'low'
|
||||||
|
reason: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patterns that indicate specific note types
|
||||||
|
const PATTERNS = {
|
||||||
|
command: [
|
||||||
|
/^\s*(git|docker|npm|yarn|node|python|curl|wget|ssh|scp|rsync|kubectl|helm|aws|gcloud|az)\s+/m,
|
||||||
|
/^\$\s*\w+/m,
|
||||||
|
/^>\s*\w+/m,
|
||||||
|
/`{3}(bash|sh|shell|zsh|fish)/m,
|
||||||
|
/#!/m, // shebang
|
||||||
|
],
|
||||||
|
snippet: [
|
||||||
|
/`{3}\w*/m, // code block with language
|
||||||
|
/^(function|const|let|var|class|import|export|def|async|await)\s+/m,
|
||||||
|
/\{\s*[\w\s]*:\s*[\w\s,}]+\}/m, // object literal
|
||||||
|
/=\s*>\s*{/m, // arrow function
|
||||||
|
/if\s*\(.+\)\s*{/m, // if statement
|
||||||
|
],
|
||||||
|
procedure: [
|
||||||
|
/^\d+[\.\)]\s+\w+/m, // numbered steps: 1. Do this
|
||||||
|
/^[-*]\s+\w+/m, // bullet steps: - Do this
|
||||||
|
/primer[oay]|segundo|tercero|cuarto|finalmente|después|antes|mientras|m paso/m,
|
||||||
|
/pasos?|steps?|instructions?|how to|tutorial/i,
|
||||||
|
],
|
||||||
|
recipe: [
|
||||||
|
/ingredientes?:?\s*$/im,
|
||||||
|
/^\s*-\s*\d+\s+\w+/m, // ingredient list: - 2 cups flour
|
||||||
|
/tiempo|horas?|minutos|preparación|cocción|servir/i,
|
||||||
|
/receta|recetas|cocina|cocinar|hornear|hervir/i,
|
||||||
|
],
|
||||||
|
decision: [
|
||||||
|
/decisión?:?\s*/im,
|
||||||
|
/alternativas?:?\s*/im,
|
||||||
|
/pros?\s*y\s*contras?:?\s*/im,
|
||||||
|
/consideramos?|optamos?|decidimos?|elegimos?/i,
|
||||||
|
/porque?|reason|context|vista|motivo/i,
|
||||||
|
],
|
||||||
|
inventory: [
|
||||||
|
/cantidad?:?\s*\d+/im,
|
||||||
|
/ubicación?:?\s*/im,
|
||||||
|
/stock|inventario|existencia|dispoble/i,
|
||||||
|
/^\s*\w+\s+\d+\s+\w+/m, // item quantity location pattern
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferNoteType(content: string): TypeSuggestion | null {
|
||||||
|
const scores: Record<NoteType, { score: number; matchedPatterns: string[] }> = {
|
||||||
|
command: { score: 0, matchedPatterns: [] },
|
||||||
|
snippet: { score: 0, matchedPatterns: [] },
|
||||||
|
procedure: { score: 0, matchedPatterns: [] },
|
||||||
|
recipe: { score: 0, matchedPatterns: [] },
|
||||||
|
decision: { score: 0, matchedPatterns: [] },
|
||||||
|
inventory: { score: 0, matchedPatterns: [] },
|
||||||
|
note: { score: 0, matchedPatterns: [] },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check each type's patterns
|
||||||
|
for (const [type, patterns] of Object.entries(PATTERNS)) {
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
if (pattern.test(content)) {
|
||||||
|
scores[type as NoteType].score += 1
|
||||||
|
scores[type as NoteType].matchedPatterns.push(pattern.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the type with highest score
|
||||||
|
let bestType: NoteType = 'note'
|
||||||
|
let bestScore = 0
|
||||||
|
|
||||||
|
for (const [type, { score }] of Object.entries(scores)) {
|
||||||
|
if (score > bestScore) {
|
||||||
|
bestScore = score
|
||||||
|
bestType = type as NoteType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine confidence based on score
|
||||||
|
let confidence: 'high' | 'medium' | 'low' = 'low'
|
||||||
|
let reason = 'No clear pattern detected'
|
||||||
|
|
||||||
|
if (bestScore >= 3) {
|
||||||
|
confidence = 'high'
|
||||||
|
} else if (bestScore >= 2) {
|
||||||
|
confidence = 'medium'
|
||||||
|
} else if (bestScore >= 1) {
|
||||||
|
confidence = 'low'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestScore > 0) {
|
||||||
|
switch (bestType) {
|
||||||
|
case 'command':
|
||||||
|
reason = 'Shell command patterns detected'
|
||||||
|
break
|
||||||
|
case 'snippet':
|
||||||
|
reason = 'Code block or programming syntax detected'
|
||||||
|
break
|
||||||
|
case 'procedure':
|
||||||
|
reason = 'Step-by-step instructions detected'
|
||||||
|
break
|
||||||
|
case 'recipe':
|
||||||
|
reason = 'Recipe or cooking instructions detected'
|
||||||
|
break
|
||||||
|
case 'decision':
|
||||||
|
reason = 'Decision-making context detected'
|
||||||
|
break
|
||||||
|
case 'inventory':
|
||||||
|
reason = 'Inventory or quantity patterns detected'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: bestType,
|
||||||
|
confidence,
|
||||||
|
reason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatContentForType(content: string, type: NoteType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'command':
|
||||||
|
return `## Comando\n\n${content.trim()}\n\n## Qué hace\n\n[Descripción]\n\n## Cuándo usarlo\n\n[Cuándo usar este comando]\n\n## Ejemplo\n\n\`\`\`bash\n[ejemplo]\n\`\`\``
|
||||||
|
case 'snippet':
|
||||||
|
return `## Snippet\n\n## Lenguaje\n\n[ Lenguaje ]\n\n## Código\n\n\`\`\`\n${content.trim()}\n\`\`\`\n\n## Qué resuelve\n\n[Descripción del problema que resuelve]\n\n## Notas\n\n[Notas adicionales]`
|
||||||
|
case 'procedure':
|
||||||
|
return `## Objetivo\n\n[Cuál es el objetivo]\n\n## Pasos\n\n${content.trim()}\n\n## Requisitos\n\n[Requisitos necesarios]\n\n## Problemas comunes\n\n[Problemas frecuentes y soluciones]`
|
||||||
|
case 'recipe':
|
||||||
|
return `## Ingredientes\n\n[Lista de ingredientes]\n\n## Pasos\n\n${content.trim()}\n\n## Tiempo\n\n[Tiempo de preparación]\n\n## Notas\n\n[Notas adicionales]`
|
||||||
|
case 'decision':
|
||||||
|
return `## Contexto\n\n[Cuál era la situación]\n\n## Decisión\n\n${content.trim()}\n\n## Alternativas consideradas\n\n[Otras opciones evaluadas]\n\n## Consecuencias\n\n[Impacto de esta decisión]`
|
||||||
|
case 'inventory':
|
||||||
|
return `## Item\n\n[Nombre del item]\n\n## Cantidad\n\n[Cantidad]\n\n## Ubicación\n\n[Ubicación]\n\n## Notas\n\n[Notas adicionales]`
|
||||||
|
default:
|
||||||
|
return `## Notas\n\n${content.trim()}`
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,3 +136,67 @@ export function clearRecentlyViewed(): void {
|
|||||||
// Silently fail
|
// Silently fail
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Co-usage tracking: record that two notes were viewed together
|
||||||
|
export async function trackCoUsage(fromNoteId: string, toNoteId: string): Promise<void> {
|
||||||
|
if (fromNoteId === toNoteId) return
|
||||||
|
try {
|
||||||
|
await prisma.noteCoUsage.upsert({
|
||||||
|
where: {
|
||||||
|
fromNoteId_toNoteId: { fromNoteId, toNoteId },
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
weight: { increment: 1 },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
fromNoteId,
|
||||||
|
toNoteId,
|
||||||
|
weight: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get notes that are often viewed together with a given note
|
||||||
|
export async function getCoUsedNotes(
|
||||||
|
noteId: string,
|
||||||
|
limit = 5,
|
||||||
|
days = 30
|
||||||
|
): Promise<{ noteId: string; title: string; type: string; weight: number }[]> {
|
||||||
|
try {
|
||||||
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||||
|
const coUsages = await prisma.noteCoUsage.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ fromNoteId: noteId },
|
||||||
|
{ toNoteId: noteId },
|
||||||
|
],
|
||||||
|
updatedAt: { gte: since },
|
||||||
|
},
|
||||||
|
orderBy: { weight: 'desc' },
|
||||||
|
take: limit,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: { noteId: string; title: string; type: string; weight: number }[] = []
|
||||||
|
for (const cu of coUsages) {
|
||||||
|
const relatedNoteId = cu.fromNoteId === noteId ? cu.toNoteId : cu.fromNoteId
|
||||||
|
const note = await prisma.note.findUnique({
|
||||||
|
where: { id: relatedNoteId },
|
||||||
|
select: { id: true, title: true, type: true },
|
||||||
|
})
|
||||||
|
if (note) {
|
||||||
|
result.push({
|
||||||
|
noteId: note.id,
|
||||||
|
title: note.title,
|
||||||
|
type: note.type,
|
||||||
|
weight: cu.weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from 'zod'
|
|||||||
|
|
||||||
export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note'])
|
export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note'])
|
||||||
|
|
||||||
|
export const CreationSourceEnum = z.enum(['form', 'quick', 'import'])
|
||||||
|
|
||||||
export const noteSchema = z.object({
|
export const noteSchema = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
title: z.string().min(1, 'Title is required').max(200),
|
title: z.string().min(1, 'Title is required').max(200),
|
||||||
@@ -10,6 +12,7 @@ export const noteSchema = z.object({
|
|||||||
isFavorite: z.boolean().default(false),
|
isFavorite: z.boolean().default(false),
|
||||||
isPinned: z.boolean().default(false),
|
isPinned: z.boolean().default(false),
|
||||||
tags: z.array(z.string()).optional(),
|
tags: z.array(z.string()).optional(),
|
||||||
|
creationSource: CreationSourceEnum.default('form'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const updateNoteSchema = noteSchema.partial().extend({
|
export const updateNoteSchema = noteSchema.partial().extend({
|
||||||
|
|||||||
Reference in New Issue
Block a user