## 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>
247 lines
5.3 KiB
TypeScript
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() : ''
|
|
}
|