feat: MVP-2 completion - search, quick add, backlinks, guided forms
## Search & Retrieval - Improved search ranking with scoring (title match, favorites, recency) - Highlight matches with excerpt extraction - Fuzzy search with string-similarity - Unified noteQuery function ## Quick Capture - Quick Add API (POST /api/notes/quick) with type prefixes - Quick add parser with tag extraction - Global Quick Add UI (Ctrl+N shortcut) - Tag autocomplete in forms ## Note Relations - Automatic backlinks with sync on create/update/delete - Backlinks API (GET /api/notes/[id]/backlinks) - Related notes with scoring and reasons ## Guided Forms - Type-specific form fields (command, snippet, decision, recipe, procedure, inventory) - Serialization to/from markdown - Tag suggestions based on content (GET /api/tags/suggest) ## UX by Type - Command: Copy button for code blocks - Snippet: Syntax highlighting with react-syntax-highlighter - Procedure: Interactive checkboxes ## Quality - Standardized error handling across all APIs - Integration tests (28 tests passing) - Unit tests for search, tags, quick-add Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,60 +1,246 @@
|
||||
export const templates: Record<string, string> = {
|
||||
command: `## Comando
|
||||
import type { NoteType } from '@/types/note'
|
||||
|
||||
export interface GuidedField {
|
||||
command: {
|
||||
command: string
|
||||
description: string
|
||||
example: string
|
||||
}
|
||||
snippet: {
|
||||
language: string
|
||||
code: string
|
||||
description: string
|
||||
}
|
||||
decision: {
|
||||
context: string
|
||||
decision: string
|
||||
alternatives: string
|
||||
consequences: string
|
||||
}
|
||||
recipe: {
|
||||
ingredients: string
|
||||
steps: string
|
||||
time: string
|
||||
}
|
||||
procedure: {
|
||||
objective: string
|
||||
steps: string
|
||||
requirements: string
|
||||
}
|
||||
inventory: {
|
||||
item: string
|
||||
quantity: string
|
||||
location: string
|
||||
}
|
||||
note: Record<string, never>
|
||||
}
|
||||
|
||||
export type GuidedType = keyof GuidedField
|
||||
|
||||
export function isGuidedType(type: NoteType): type is GuidedType {
|
||||
return type !== 'note'
|
||||
}
|
||||
|
||||
export function isFreeMarkdown(content: string): boolean {
|
||||
if (!content) return false
|
||||
const lines = content.trim().split('\n')
|
||||
const guidedPatterns = [
|
||||
/^##\s*(Comando|Qué hace|Cuando usarlo|Ejemplo)$/,
|
||||
/^##\s*(Snippet|Lenguaje|Qué resuelve|Notas)$/,
|
||||
/^##\s*(Contexto|Decisión|Alternativas|を考慮|Consecuencias)$/,
|
||||
/^##\s*(Ingredientes|Pasos|Tiempo|Notas)$/,
|
||||
/^##\s*(Objetivo|Requisitos|Problemas comunes)$/,
|
||||
/^##\s*(Item|Cantidad|Ubicación|Notas)$/,
|
||||
/^##\s*Notas$/,
|
||||
]
|
||||
|
||||
let matchCount = 0
|
||||
for (const line of lines) {
|
||||
for (const pattern of guidedPatterns) {
|
||||
if (pattern.test(line)) {
|
||||
matchCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchCount < 3
|
||||
}
|
||||
|
||||
export function serializeToMarkdown(type: NoteType, fields: Record<string, string>): string {
|
||||
switch (type) {
|
||||
case 'command':
|
||||
return `## Comando
|
||||
|
||||
${fields.command || ''}
|
||||
|
||||
## Qué hace
|
||||
|
||||
## Cuándo usarlo
|
||||
${fields.description || ''}
|
||||
|
||||
## Ejemplo
|
||||
\`\`\`bash
|
||||
|
||||
\`\`\`bash
|
||||
${fields.example || ''}
|
||||
\`\`\`
|
||||
`,
|
||||
snippet: `## Snippet
|
||||
`
|
||||
case 'snippet':
|
||||
return `## Snippet
|
||||
|
||||
## Lenguaje
|
||||
|
||||
## Qué resuelve
|
||||
${fields.language || ''}
|
||||
|
||||
## Notas
|
||||
`,
|
||||
decision: `## Contexto
|
||||
## Código
|
||||
|
||||
\`\`\`${fields.language || ''}
|
||||
${fields.code || ''}
|
||||
\`\`\`
|
||||
|
||||
## Descripción
|
||||
|
||||
${fields.description || ''}
|
||||
`
|
||||
case 'decision':
|
||||
return `## Contexto
|
||||
|
||||
${fields.context || ''}
|
||||
|
||||
## Decisión
|
||||
|
||||
${fields.decision || ''}
|
||||
|
||||
## Alternativas consideradas
|
||||
|
||||
${fields.alternatives || ''}
|
||||
|
||||
## Consecuencias
|
||||
`,
|
||||
recipe: `## Ingredientes
|
||||
|
||||
${fields.consequences || ''}
|
||||
`
|
||||
case 'recipe':
|
||||
return `## Ingredientes
|
||||
|
||||
${fields.ingredients || ''}
|
||||
|
||||
## Pasos
|
||||
|
||||
${fields.steps || ''}
|
||||
|
||||
## Tiempo
|
||||
|
||||
## Notas
|
||||
`,
|
||||
procedure: `## Objetivo
|
||||
${fields.time || ''}
|
||||
`
|
||||
case 'procedure':
|
||||
return `## Objetivo
|
||||
|
||||
${fields.objective || ''}
|
||||
|
||||
## Pasos
|
||||
|
||||
${fields.steps || ''}
|
||||
|
||||
## Requisitos
|
||||
|
||||
## Problemas comunes
|
||||
`,
|
||||
inventory: `## Item
|
||||
${fields.requirements || ''}
|
||||
`
|
||||
case 'inventory':
|
||||
return `## Item
|
||||
|
||||
${fields.item || ''}
|
||||
|
||||
## Cantidad
|
||||
|
||||
${fields.quantity || ''}
|
||||
|
||||
## Ubicación
|
||||
|
||||
## Notas
|
||||
`,
|
||||
note: `## Notas
|
||||
|
||||
`,
|
||||
${fields.location || ''}
|
||||
`
|
||||
case 'note':
|
||||
default:
|
||||
return fields.content || ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getTemplate(type: string): string {
|
||||
return templates[type] || templates.note
|
||||
export function parseMarkdownToFields(type: NoteType, content: string): Record<string, string> {
|
||||
const fields: Record<string, string> = {}
|
||||
|
||||
if (!content) return fields
|
||||
|
||||
const sectionPattern = /^##\s+(.+)$/gm
|
||||
const sections: { title: string; content: string }[] = []
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = sectionPattern.exec(content)) !== null) {
|
||||
if (lastIndex !== 0) {
|
||||
const prevMatch = sectionPattern.exec(content)
|
||||
if (prevMatch) {
|
||||
sections.push({
|
||||
title: prevMatch[1],
|
||||
content: content.slice(lastIndex, match.index).trim()
|
||||
})
|
||||
}
|
||||
}
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
|
||||
const remainingContent = content.slice(lastIndex).trim()
|
||||
if (remainingContent) {
|
||||
sections.push({
|
||||
title: sections.length > 0 ? sections[sections.length - 1].title : '',
|
||||
content: remainingContent
|
||||
})
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'command':
|
||||
fields.command = extractSection(content, 'Comando')
|
||||
fields.description = extractSection(content, 'Qué hace')
|
||||
fields.example = extractCodeBlock(content)
|
||||
break
|
||||
case 'snippet':
|
||||
fields.language = extractSection(content, 'Lenguaje')
|
||||
fields.code = extractCodeBlock(content)
|
||||
fields.description = extractSection(content, 'Descripción')
|
||||
break
|
||||
case 'decision':
|
||||
fields.context = extractSection(content, 'Contexto')
|
||||
fields.decision = extractSection(content, 'Decisión')
|
||||
fields.alternatives = extractSection(content, 'Alternativas')
|
||||
fields.consequences = extractSection(content, 'Consecuencias')
|
||||
break
|
||||
case 'recipe':
|
||||
fields.ingredients = extractSection(content, 'Ingredientes')
|
||||
fields.steps = extractSection(content, 'Pasos')
|
||||
fields.time = extractSection(content, 'Tiempo')
|
||||
break
|
||||
case 'procedure':
|
||||
fields.objective = extractSection(content, 'Objetivo')
|
||||
fields.steps = extractSection(content, 'Pasos')
|
||||
fields.requirements = extractSection(content, 'Requisitos')
|
||||
break
|
||||
case 'inventory':
|
||||
fields.item = extractSection(content, 'Item')
|
||||
fields.quantity = extractSection(content, 'Cantidad')
|
||||
fields.location = extractSection(content, 'Ubicación')
|
||||
break
|
||||
case 'note':
|
||||
default:
|
||||
fields.content = content
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
function extractSection(content: string, sectionName: string): string {
|
||||
const pattern = new RegExp(`##\\s+${sectionName}\\s*\\n([\\s\\S]*?)(?=##\\s+|\\z)`, 'i')
|
||||
const match = content.match(pattern)
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractCodeBlock(content: string): string {
|
||||
const match = content.match(/```[\w]*\n?([\s\S]*?)```/)
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user