Files
recall/src/lib/templates.ts
Daniel Arroyo 8b77c7b5df 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>
2026-03-22 13:51:39 -03:00

247 lines
5.3 KiB
TypeScript

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
${fields.description || ''}
## Ejemplo
\`\`\`bash
${fields.example || ''}
\`\`\`
`
case 'snippet':
return `## Snippet
## Lenguaje
${fields.language || ''}
## 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
${fields.consequences || ''}
`
case 'recipe':
return `## Ingredientes
${fields.ingredients || ''}
## Pasos
${fields.steps || ''}
## Tiempo
${fields.time || ''}
`
case 'procedure':
return `## Objetivo
${fields.objective || ''}
## Pasos
${fields.steps || ''}
## Requisitos
${fields.requirements || ''}
`
case 'inventory':
return `## Item
${fields.item || ''}
## Cantidad
${fields.quantity || ''}
## Ubicación
${fields.location || ''}
`
case 'note':
default:
return fields.content || ''
}
}
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() : ''
}