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:
122
src/lib/backlinks.ts
Normal file
122
src/lib/backlinks.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const BACKLINK_REGEX = /\[\[([^\]]+)\]\]/g
|
||||
|
||||
export function parseBacklinks(content: string): string[] {
|
||||
const matches = content.matchAll(BACKLINK_REGEX)
|
||||
const titles = new Set<string>()
|
||||
|
||||
for (const match of matches) {
|
||||
const title = match[1].trim()
|
||||
if (title) {
|
||||
titles.add(title)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(titles)
|
||||
}
|
||||
|
||||
export async function syncBacklinks(noteId: string, content: string): Promise<void> {
|
||||
const linkedTitles = parseBacklinks(content)
|
||||
|
||||
await prisma.backlink.deleteMany({
|
||||
where: { sourceNoteId: noteId },
|
||||
})
|
||||
|
||||
if (linkedTitles.length === 0) return
|
||||
|
||||
const targetNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
title: { in: linkedTitles },
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
|
||||
const titleToId = new Map(targetNotes.map((n) => [n.title.toLowerCase(), n.id]))
|
||||
|
||||
const backlinksToCreate: { sourceNoteId: string; targetNoteId: string }[] = []
|
||||
|
||||
for (const title of linkedTitles) {
|
||||
const targetNoteId = titleToId.get(title.toLowerCase())
|
||||
if (targetNoteId && targetNoteId !== noteId) {
|
||||
backlinksToCreate.push({
|
||||
sourceNoteId: noteId,
|
||||
targetNoteId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (backlinksToCreate.length > 0) {
|
||||
await prisma.backlink.createMany({
|
||||
data: backlinksToCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface BacklinkWithNote {
|
||||
id: string
|
||||
sourceNoteId: string
|
||||
targetNoteId: string
|
||||
createdAt: string
|
||||
sourceNote: {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBacklinksForNote(noteId: string): Promise<BacklinkWithNote[]> {
|
||||
const backlinks = await prisma.backlink.findMany({
|
||||
where: { targetNoteId: noteId },
|
||||
include: {
|
||||
sourceNote: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return backlinks.map((bl) => ({
|
||||
id: bl.id,
|
||||
sourceNoteId: bl.sourceNoteId,
|
||||
targetNoteId: bl.targetNoteId,
|
||||
createdAt: bl.createdAt.toISOString(),
|
||||
sourceNote: {
|
||||
id: bl.sourceNote.id,
|
||||
title: bl.sourceNote.title,
|
||||
type: bl.sourceNote.type,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export async function getOutgoingLinksForNote(noteId: string): Promise<BacklinkWithNote[]> {
|
||||
const backlinks = await prisma.backlink.findMany({
|
||||
where: { sourceNoteId: noteId },
|
||||
include: {
|
||||
targetNote: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
|
||||
return backlinks.map((bl) => ({
|
||||
id: bl.id,
|
||||
sourceNoteId: bl.sourceNoteId,
|
||||
targetNoteId: bl.targetNoteId,
|
||||
createdAt: bl.createdAt.toISOString(),
|
||||
sourceNote: {
|
||||
id: bl.targetNote.id,
|
||||
title: bl.targetNote.title,
|
||||
type: bl.targetNote.type,
|
||||
},
|
||||
}))
|
||||
}
|
||||
113
src/lib/errors.ts
Normal file
113
src/lib/errors.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { ZodError } from 'zod'
|
||||
|
||||
export interface ApiError {
|
||||
code: string
|
||||
message: string
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: ApiError
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public code: string,
|
||||
message: string,
|
||||
public statusCode: number = 500,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends AppError {
|
||||
constructor(resource: string) {
|
||||
super('NOT_FOUND', `${resource} not found`, 404)
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends AppError {
|
||||
constructor(details: unknown) {
|
||||
super('VALIDATION_ERROR', 'Validation failed', 400, details)
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends AppError {
|
||||
constructor() {
|
||||
super('UNAUTHORIZED', 'Unauthorized access', 401)
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends AppError {
|
||||
constructor() {
|
||||
super('FORBIDDEN', 'Access forbidden', 403)
|
||||
}
|
||||
}
|
||||
|
||||
export class ConflictError extends AppError {
|
||||
constructor(message: string) {
|
||||
super('CONFLICT', message, 409)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatZodError(error: ZodError): ApiError {
|
||||
return {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Validation failed',
|
||||
details: error.issues.map((issue) => ({
|
||||
path: issue.path.join('.'),
|
||||
message: issue.message,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export function createErrorResponse(error: unknown): NextResponse {
|
||||
if (error instanceof AppError) {
|
||||
const body: ApiResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
return NextResponse.json(body, { status: error.statusCode })
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
const body: ApiResponse = {
|
||||
success: false,
|
||||
error: formatZodError(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
return NextResponse.json(body, { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Unexpected error:', error)
|
||||
|
||||
const body: ApiResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An unexpected error occurred',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
return NextResponse.json(body, { status: 500 })
|
||||
}
|
||||
|
||||
export function createSuccessResponse<T>(data: T, statusCode: number = 200): NextResponse {
|
||||
const body: ApiResponse<T> = {
|
||||
success: true,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
return NextResponse.json(body, { status: statusCode })
|
||||
}
|
||||
240
src/lib/guided-fields.ts
Normal file
240
src/lib/guided-fields.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import type { NoteType } from '@/types/note'
|
||||
|
||||
export interface GuidedData {
|
||||
// Command
|
||||
command?: string
|
||||
description?: string
|
||||
example?: string
|
||||
// Snippet
|
||||
language?: string
|
||||
code?: string
|
||||
snippetDescription?: string
|
||||
// Decision
|
||||
context?: string
|
||||
decision?: string
|
||||
alternatives?: string
|
||||
consequences?: string
|
||||
// Recipe
|
||||
ingredients?: string
|
||||
steps?: string
|
||||
time?: string
|
||||
recipeNotes?: string
|
||||
// Procedure
|
||||
objective?: string
|
||||
procedureSteps?: string
|
||||
requirements?: string
|
||||
commonProblems?: string
|
||||
// Inventory
|
||||
item?: string
|
||||
quantity?: string
|
||||
location?: string
|
||||
inventoryNotes?: string
|
||||
}
|
||||
|
||||
export function serializeToMarkdown(type: NoteType, data: GuidedData): string {
|
||||
switch (type) {
|
||||
case 'command':
|
||||
return `## Comando
|
||||
|
||||
${data.command || ''}
|
||||
|
||||
## Qué hace
|
||||
|
||||
${data.description || ''}
|
||||
|
||||
## Cuándo usarlo
|
||||
|
||||
|
||||
## Ejemplo
|
||||
\`\`\`bash
|
||||
${data.example || ''}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
case 'snippet':
|
||||
return `## Snippet
|
||||
|
||||
## Lenguaje
|
||||
${data.language || ''}
|
||||
|
||||
## Qué resuelve
|
||||
${data.snippetDescription || ''}
|
||||
|
||||
## Código
|
||||
\`\`\`${data.language || ''}
|
||||
${data.code || ''}
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
case 'decision':
|
||||
return `## Contexto
|
||||
|
||||
${data.context || ''}
|
||||
|
||||
## Decisión
|
||||
|
||||
${data.decision || ''}
|
||||
|
||||
## Alternativas consideradas
|
||||
|
||||
${data.alternatives || ''}
|
||||
|
||||
## Consecuencias
|
||||
|
||||
${data.consequences || ''}
|
||||
`
|
||||
|
||||
case 'recipe':
|
||||
return `## Ingredientes
|
||||
|
||||
${data.ingredients || ''}
|
||||
|
||||
## Pasos
|
||||
|
||||
${data.steps || ''}
|
||||
|
||||
## Tiempo
|
||||
|
||||
${data.time || ''}
|
||||
|
||||
## Notas
|
||||
|
||||
${data.recipeNotes || ''}
|
||||
`
|
||||
|
||||
case 'procedure':
|
||||
return `## Objetivo
|
||||
|
||||
${data.objective || ''}
|
||||
|
||||
## Pasos
|
||||
|
||||
${data.procedureSteps || ''}
|
||||
|
||||
## Requisitos
|
||||
|
||||
${data.requirements || ''}
|
||||
|
||||
## Problemas comunes
|
||||
|
||||
${data.commonProblems || ''}
|
||||
`
|
||||
|
||||
case 'inventory':
|
||||
return `## Item
|
||||
|
||||
${data.item || ''}
|
||||
|
||||
## Cantidad
|
||||
|
||||
${data.quantity || ''}
|
||||
|
||||
## Ubicación
|
||||
|
||||
${data.location || ''}
|
||||
|
||||
## Notas
|
||||
|
||||
${data.inventoryNotes || ''}
|
||||
`
|
||||
|
||||
default:
|
||||
return data.context || ''
|
||||
}
|
||||
}
|
||||
|
||||
export function parseMarkdownToGuided(type: NoteType, content: string): GuidedData {
|
||||
const sections = content.split(/^##\s+/m).filter(Boolean)
|
||||
const result: GuidedData = {}
|
||||
|
||||
for (const section of sections) {
|
||||
const lines = section.split('\n')
|
||||
const title = lines[0].trim().toLowerCase()
|
||||
const body = lines.slice(2).join('\n').trim()
|
||||
|
||||
switch (title) {
|
||||
case 'comando':
|
||||
result.command = body
|
||||
break
|
||||
case 'qué hace':
|
||||
result.description = body
|
||||
break
|
||||
case 'ejemplo':
|
||||
const match = body.match(/```bash\n([\s\S]*?)```/)
|
||||
result.example = match ? match[1].trim() : body.replace(/```\w*\n?/g, '').trim()
|
||||
break
|
||||
case 'lenguaje':
|
||||
result.language = body
|
||||
break
|
||||
case 'qué resuelve':
|
||||
result.snippetDescription = body
|
||||
break
|
||||
case 'código':
|
||||
result.code = body.replace(/```\w*\n?/g, '').trim()
|
||||
break
|
||||
case 'contexto':
|
||||
result.context = body
|
||||
break
|
||||
case 'decisión':
|
||||
result.decision = body
|
||||
break
|
||||
case 'alternativas consideradas':
|
||||
result.alternatives = body
|
||||
break
|
||||
case 'consecuencias':
|
||||
result.consequences = body
|
||||
break
|
||||
case 'ingredientes':
|
||||
result.ingredients = body
|
||||
break
|
||||
case 'pasos':
|
||||
result.steps = body
|
||||
break
|
||||
case 'tiempo':
|
||||
result.time = body
|
||||
break
|
||||
case 'notas':
|
||||
result.recipeNotes = body
|
||||
break
|
||||
case 'objetivo':
|
||||
result.objective = body
|
||||
break
|
||||
case 'requisitos':
|
||||
result.requirements = body
|
||||
break
|
||||
case 'problemas comunes':
|
||||
result.commonProblems = body
|
||||
break
|
||||
case 'item':
|
||||
result.item = body
|
||||
break
|
||||
case 'cantidad':
|
||||
result.quantity = body
|
||||
break
|
||||
case 'ubicación':
|
||||
result.location = body
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function isGuidedContent(type: NoteType, content: string): boolean {
|
||||
if (!content) return false
|
||||
const guidedTypes: NoteType[] = ['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory']
|
||||
if (!guidedTypes.includes(type)) return false
|
||||
|
||||
// Check if content follows the guided template pattern
|
||||
const patterns: Record<NoteType, RegExp> = {
|
||||
command: /^##\s+Comando\n/m,
|
||||
snippet: /^##\s+Snippet\n/m,
|
||||
decision: /^##\s+Contexto\n/m,
|
||||
recipe: /^##\s+Ingredientes\n/m,
|
||||
procedure: /^##\s+Objetivo\n/m,
|
||||
inventory: /^##\s+Item\n/m,
|
||||
note: /^/m,
|
||||
}
|
||||
|
||||
return patterns[type].test(content)
|
||||
}
|
||||
52
src/lib/quick-add.ts
Normal file
52
src/lib/quick-add.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { NoteType } from '@/types/note'
|
||||
|
||||
export interface QuickAddResult {
|
||||
type: NoteType
|
||||
tags: string[]
|
||||
content: string
|
||||
}
|
||||
|
||||
const TYPE_PREFIXES: Record<string, NoteType> = {
|
||||
'cmd:': 'command',
|
||||
'snip:': 'snippet',
|
||||
'dec:': 'decision',
|
||||
'rec:': 'recipe',
|
||||
'proc:': 'procedure',
|
||||
'inv:': 'inventory',
|
||||
}
|
||||
|
||||
const TAG_REGEX = /#([a-z0-9]+)/g
|
||||
|
||||
export function parseQuickAdd(text: string): QuickAddResult {
|
||||
let remaining = text.trim()
|
||||
let type: NoteType = 'note'
|
||||
|
||||
// Extract type prefix
|
||||
for (const [prefix, noteType] of Object.entries(TYPE_PREFIXES)) {
|
||||
if (remaining.toLowerCase().startsWith(prefix)) {
|
||||
type = noteType
|
||||
remaining = remaining.slice(prefix.length).trim()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Extract tags
|
||||
const tags: string[] = []
|
||||
const tagMatches = remaining.match(TAG_REGEX)
|
||||
if (tagMatches) {
|
||||
for (const match of tagMatches) {
|
||||
const tagName = match.slice(1).toLowerCase().trim()
|
||||
if (tagName && !tags.includes(tagName)) {
|
||||
tags.push(tagName)
|
||||
}
|
||||
}
|
||||
// Remove tags from content
|
||||
remaining = remaining.replace(TAG_REGEX, '').trim()
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
tags,
|
||||
content: remaining,
|
||||
}
|
||||
}
|
||||
195
src/lib/search.ts
Normal file
195
src/lib/search.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import stringSimilarity from 'string-similarity'
|
||||
|
||||
export interface SearchFilters {
|
||||
type?: string
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export interface ScoredNote {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
isFavorite: boolean
|
||||
isPinned: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
tags: { tag: { id: string; name: string } }[]
|
||||
score: number
|
||||
highlight?: string
|
||||
matchType: 'exact' | 'fuzzy'
|
||||
}
|
||||
|
||||
const FUZZY_THRESHOLD = 0.3
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
export function highlightMatches(text: string, query: string): string {
|
||||
if (!query.trim()) return text.slice(0, 150)
|
||||
|
||||
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 1)
|
||||
if (words.length === 0) return text.slice(0, 150)
|
||||
|
||||
const textLower = text.toLowerCase()
|
||||
let matchIndex = -1
|
||||
|
||||
for (const word of words) {
|
||||
const idx = textLower.indexOf(word)
|
||||
if (idx !== -1) {
|
||||
matchIndex = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let excerpt: string
|
||||
if (matchIndex !== -1) {
|
||||
const start = Math.max(0, matchIndex - 75)
|
||||
const end = Math.min(text.length, matchIndex + 75)
|
||||
excerpt = text.slice(start, end)
|
||||
if (start > 0) excerpt = '...' + excerpt
|
||||
if (end < text.length) excerpt = excerpt + '...'
|
||||
} else {
|
||||
excerpt = text.slice(0, 150)
|
||||
if (text.length > 150) excerpt += '...'
|
||||
}
|
||||
|
||||
for (const word of words) {
|
||||
const regex = new RegExp(`(${escapeRegex(word)})`, 'gi')
|
||||
excerpt = excerpt.replace(regex, '<mark>$1</mark>')
|
||||
}
|
||||
|
||||
return excerpt
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function scoreNote(
|
||||
note: {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
isFavorite: boolean
|
||||
isPinned: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
tags: { tag: { id: string; name: string } }[]
|
||||
},
|
||||
query: string,
|
||||
exactTitleMatch: boolean
|
||||
): { score: number; matchType: 'exact' | 'fuzzy' } {
|
||||
let score = 0
|
||||
let matchType: 'exact' | 'fuzzy' = 'exact'
|
||||
const queryLower = query.toLowerCase()
|
||||
const titleLower = note.title.toLowerCase()
|
||||
const contentLower = note.content.toLowerCase()
|
||||
|
||||
if (exactTitleMatch) {
|
||||
if (titleLower === queryLower) {
|
||||
score += 10
|
||||
} else if (titleLower.includes(queryLower)) {
|
||||
score += 5
|
||||
}
|
||||
} else {
|
||||
const similarity = stringSimilarity.compareTwoStrings(queryLower, titleLower)
|
||||
if (similarity >= FUZZY_THRESHOLD) {
|
||||
score += similarity * 5
|
||||
matchType = 'fuzzy'
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLower.includes(queryLower)) {
|
||||
score += 3
|
||||
}
|
||||
|
||||
if (note.isFavorite) {
|
||||
score += 2
|
||||
}
|
||||
|
||||
if (note.isPinned) {
|
||||
score += 1
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const updatedAt = note.updatedAt.getTime()
|
||||
if (now - updatedAt < SEVEN_DAYS_MS) {
|
||||
score += 1
|
||||
}
|
||||
|
||||
return { score, matchType }
|
||||
}
|
||||
|
||||
export async function noteQuery(
|
||||
query: string,
|
||||
filters: SearchFilters = {}
|
||||
): Promise<ScoredNote[]> {
|
||||
const queryLower = query.toLowerCase().trim()
|
||||
|
||||
const allNotes = await prisma.note.findMany({
|
||||
include: { tags: { include: { tag: true } } },
|
||||
})
|
||||
|
||||
const scored: ScoredNote[] = []
|
||||
|
||||
for (const note of allNotes) {
|
||||
if (filters.type && note.type !== filters.type) continue
|
||||
|
||||
if (filters.tag) {
|
||||
const hasTag = note.tags.some(t => t.tag.name === filters.tag)
|
||||
if (!hasTag) continue
|
||||
}
|
||||
|
||||
const titleLower = note.title.toLowerCase()
|
||||
const contentLower = note.content.toLowerCase()
|
||||
|
||||
const exactTitleMatch = titleLower.includes(queryLower)
|
||||
const exactContentMatch = contentLower.includes(queryLower)
|
||||
|
||||
if (!queryLower) {
|
||||
const { score, matchType } = { score: 0, matchType: 'exact' as const }
|
||||
scored.push({
|
||||
...note,
|
||||
score,
|
||||
matchType,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt.toISOString(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (!exactTitleMatch && !exactContentMatch) {
|
||||
const similarity = stringSimilarity.compareTwoStrings(queryLower, titleLower)
|
||||
if (similarity < FUZZY_THRESHOLD && !contentLower.includes(queryLower)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const { score, matchType } = scoreNote(note, queryLower, exactTitleMatch || exactContentMatch)
|
||||
|
||||
const highlight = highlightMatches(
|
||||
exactTitleMatch ? note.title + ' ' + note.content : note.content,
|
||||
query
|
||||
)
|
||||
|
||||
scored.push({
|
||||
...note,
|
||||
score,
|
||||
matchType,
|
||||
highlight,
|
||||
createdAt: note.createdAt.toISOString(),
|
||||
updatedAt: note.updatedAt.toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
|
||||
export async function searchNotes(
|
||||
query: string,
|
||||
filters: SearchFilters = {}
|
||||
): Promise<ScoredNote[]> {
|
||||
return noteQuery(query, filters)
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
/**
|
||||
* Normalizes a tag by converting to lowercase and trimming whitespace.
|
||||
*/
|
||||
export function normalizeTag(tag: string): string {
|
||||
return tag.toLowerCase().trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an array of tags.
|
||||
*/
|
||||
export function normalizeTags(tags: string[]): string[] {
|
||||
return tags.map(normalizeTag)
|
||||
}
|
||||
|
||||
const TAG_KEYWORDS: Record<string, string[]> = {
|
||||
code: ['code', 'function', 'class', 'algorithm', 'programming', 'javascript', 'typescript', 'python', 'react'],
|
||||
bash: ['bash', 'shell', 'command', 'terminal', 'script', 'cli'],
|
||||
|
||||
@@ -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