feat: MVP-4 Sprint 1 - Query parser, real-time search, keyboard navigation
- Add query-parser.ts with AST for type:, tag:, is:favorite, is:pinned filters - Integrate parser into search API - Add real-time search with 300ms debounce in search-bar - Add keyboard navigation (↑↓ Enter ESC) for Spotlight-like UX - Add dropdown results display with loading state - Add 22 tests for query parser Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
145
__tests__/query-parser.test.ts
Normal file
145
__tests__/query-parser.test.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { parseQuery, QueryAST } from '@/lib/query-parser'
|
||||||
|
|
||||||
|
describe('query-parser', () => {
|
||||||
|
describe('basic text queries', () => {
|
||||||
|
it('returns text with no filters for simple text', () => {
|
||||||
|
const result = parseQuery('docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves multi-word text', () => {
|
||||||
|
const result = parseQuery('hello world')
|
||||||
|
expect(result.text).toBe('hello world')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('type filter', () => {
|
||||||
|
it('extracts type filter from beginning of query', () => {
|
||||||
|
const result = parseQuery('type:command docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ type: 'command' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles query with only type filter', () => {
|
||||||
|
const result = parseQuery('type:snippet')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({ type: 'snippet' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('extracts type filter from end of query', () => {
|
||||||
|
const result = parseQuery('docker type:command')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ type: 'command' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('tag filter', () => {
|
||||||
|
it('extracts tag filter with text', () => {
|
||||||
|
const result = parseQuery('tag:api error')
|
||||||
|
expect(result.text).toBe('error')
|
||||||
|
expect(result.filters).toEqual({ tag: 'api' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles query with only tag filter', () => {
|
||||||
|
const result = parseQuery('tag:backend')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({ tag: 'backend' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('combined filters', () => {
|
||||||
|
it('parses multiple filters together', () => {
|
||||||
|
const result = parseQuery('docker tag:backend type:command')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ type: 'command', tag: 'backend' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles type, tag, and isFavorite combined', () => {
|
||||||
|
const result = parseQuery('type:snippet tag:python is:favorite')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({ type: 'snippet', tag: 'python', isFavorite: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('boolean filters', () => {
|
||||||
|
it('extracts is:favorite filter', () => {
|
||||||
|
const result = parseQuery('is:favorite docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ isFavorite: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles is:pinned filter alone', () => {
|
||||||
|
const result = parseQuery('is:pinned')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({ isPinned: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles both boolean filters with text', () => {
|
||||||
|
const result = parseQuery('is:favorite is:pinned docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ isFavorite: true, isPinned: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('edge cases', () => {
|
||||||
|
it('handles empty string', () => {
|
||||||
|
const result = parseQuery('')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles whitespace only', () => {
|
||||||
|
const result = parseQuery(' ')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores empty type value', () => {
|
||||||
|
const result = parseQuery('type:')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores empty tag value', () => {
|
||||||
|
const result = parseQuery('tag:')
|
||||||
|
expect(result.text).toBe('')
|
||||||
|
expect(result.filters).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('last duplicate filter wins for type', () => {
|
||||||
|
const result = parseQuery('type:command type:snippet docker')
|
||||||
|
expect(result.text).toBe('docker')
|
||||||
|
expect(result.filters).toEqual({ type: 'snippet' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('last duplicate filter wins for tag', () => {
|
||||||
|
const result = parseQuery('tag:python tag:javascript code')
|
||||||
|
expect(result.text).toBe('code')
|
||||||
|
expect(result.filters).toEqual({ tag: 'javascript' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('case sensitivity', () => {
|
||||||
|
it('filter name is case insensitive', () => {
|
||||||
|
const result = parseQuery('TYPE:command')
|
||||||
|
expect(result.filters).toEqual({ type: 'command' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filter value is case sensitive', () => {
|
||||||
|
const result = parseQuery('type:Command')
|
||||||
|
expect(result.filters).toEqual({ type: 'Command' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is:favorite is case insensitive', () => {
|
||||||
|
const result = parseQuery('IS:FAVORITE docker')
|
||||||
|
expect(result.filters).toEqual({ isFavorite: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is:pinned is case insensitive', () => {
|
||||||
|
const result = parseQuery('IS:PINNED')
|
||||||
|
expect(result.filters).toEqual({ isPinned: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
252
backlog/recall-mvp4-tickets.md
Normal file
252
backlog/recall-mvp4-tickets.md
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
# Recall — Tickets técnicos MVP-4 (Camino Producto)
|
||||||
|
|
||||||
|
## 🎯 Objetivo
|
||||||
|
Convertir Recall en una herramienta confiable, rápida y diaria, enfocada en:
|
||||||
|
- búsqueda tipo Google personal
|
||||||
|
- navegación instantánea
|
||||||
|
- confianza (historial + backup)
|
||||||
|
- contexto activo
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧩 EPIC 1 — Búsqueda avanzada (Google personal)
|
||||||
|
|
||||||
|
## [P1] Ticket 01 — Parser de query avanzada
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Permitir búsquedas expresivas tipo: `docker tag:backend type:command`
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear `src/lib/query-parser.ts`
|
||||||
|
- Soportar:
|
||||||
|
- texto libre
|
||||||
|
- `type:`
|
||||||
|
- `tag:`
|
||||||
|
- `is:favorite`, `is:pinned`
|
||||||
|
- Devolver AST simple
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Queries válidas parsean correctamente
|
||||||
|
- Soporta combinación de filtros + texto
|
||||||
|
- Tests unitarios incluidos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 02 — Integrar query avanzada en search
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Aplicar parser en `/api/search`
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Filtrar por AST antes de scoring
|
||||||
|
- Mantener scoring existente
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- `type:command docker` filtra correctamente
|
||||||
|
- `tag:api error` funciona
|
||||||
|
- No rompe búsqueda actual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 03 — Búsqueda en tiempo real
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Actualizar resultados mientras el usuario escribe
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Debounce en `search-bar.tsx`
|
||||||
|
- Fetch automático
|
||||||
|
- Estado loading ligero
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Resultados cambian en <300ms
|
||||||
|
- No bloquea UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 04 — Navegación por teclado en búsqueda
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
UX tipo Spotlight
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- ↑ ↓ para moverse
|
||||||
|
- Enter para abrir
|
||||||
|
- ESC para cerrar
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Navegación sin mouse
|
||||||
|
- Estado seleccionado visible
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧠 EPIC 2 — Contexto activo
|
||||||
|
|
||||||
|
## [P1] Ticket 05 — Sidebar contextual inteligente
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Mostrar contexto dinámico mientras navegas
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Crear `note-context-sidebar.tsx`
|
||||||
|
- Mostrar:
|
||||||
|
- relacionadas
|
||||||
|
- co-uso
|
||||||
|
- backlinks
|
||||||
|
- recientes
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Siempre muestra contenido relevante
|
||||||
|
- No rompe layout responsive
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 06 — Sugerencias dinámicas en lectura
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Recomendar contenido mientras lees
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Hook en `notes/[id]/page.tsx`
|
||||||
|
- Actualizar sugerencias según scroll/uso
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Sugerencias cambian según contexto
|
||||||
|
- No afecta performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🔐 EPIC 3 — Confianza total
|
||||||
|
|
||||||
|
## [P1] Ticket 07 — Historial de versiones
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Permitir ver y revertir cambios
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Modelo `NoteVersion`
|
||||||
|
- Guardar snapshot en cada update
|
||||||
|
- Endpoint `/api/notes/[id]/versions`
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Se pueden listar versiones
|
||||||
|
- Se puede restaurar versión
|
||||||
|
- No rompe edición actual
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 08 — UI historial de versiones
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Visualizar cambios
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Vista en `notes/[id]`
|
||||||
|
- Mostrar lista de versiones
|
||||||
|
- Botón restaurar
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- UX clara
|
||||||
|
- Confirmación antes de revertir
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 09 — Backup automático
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Evitar pérdida de datos
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Export JSON automático
|
||||||
|
- Guardar en local (descarga o storage)
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Backup se genera periódicamente
|
||||||
|
- No bloquea app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ⚡ EPIC 4 — Rendimiento y UX
|
||||||
|
|
||||||
|
## [P1] Ticket 10 — Cache de resultados de búsqueda
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Reducir latencia
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Cache en cliente por query
|
||||||
|
- Invalidación simple
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Queries repetidas son instantáneas
|
||||||
|
- No datos stale críticos
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 11 — Preload de notas frecuentes
|
||||||
|
|
||||||
|
**Objetivo**
|
||||||
|
Abrir notas más rápido
|
||||||
|
|
||||||
|
**Alcance**
|
||||||
|
- Prefetch en hover/listado
|
||||||
|
- Usar Next.js prefetch
|
||||||
|
|
||||||
|
**Criterios**
|
||||||
|
- Navegación instantánea en notas frecuentes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🧪 EPIC 5 — Calidad
|
||||||
|
|
||||||
|
## [P1] Ticket 12 — Tests query avanzada
|
||||||
|
|
||||||
|
- parser
|
||||||
|
- integración search
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P1] Ticket 13 — Tests historial versiones
|
||||||
|
|
||||||
|
- creación
|
||||||
|
- restore
|
||||||
|
- edge cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [P2] Ticket 14 — Tests navegación teclado
|
||||||
|
|
||||||
|
- selección
|
||||||
|
- acciones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 🗺️ Orden sugerido
|
||||||
|
|
||||||
|
## Sprint 1
|
||||||
|
- Query parser
|
||||||
|
- Integración search
|
||||||
|
- Real-time search
|
||||||
|
- Tests base
|
||||||
|
|
||||||
|
## Sprint 2
|
||||||
|
- Navegación teclado
|
||||||
|
- Sidebar contextual
|
||||||
|
- Cache búsqueda
|
||||||
|
|
||||||
|
## Sprint 3
|
||||||
|
- Historial versiones (API + UI)
|
||||||
|
|
||||||
|
## Sprint 4
|
||||||
|
- Backup automático
|
||||||
|
- Preload notas
|
||||||
|
- Tests finales
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# ✅ Definition of Done
|
||||||
|
|
||||||
|
- Feature usable sin bugs críticos
|
||||||
|
- Tests pasando
|
||||||
|
- No regresiones
|
||||||
|
- UX fluida (<300ms interacción)
|
||||||
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server'
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import { noteSchema } from '@/lib/validators'
|
import { noteSchema } from '@/lib/validators'
|
||||||
import { normalizeTag } from '@/lib/tags'
|
import { normalizeTag } from '@/lib/tags'
|
||||||
import { noteQuery } from '@/lib/search'
|
import { searchNotes } from '@/lib/search'
|
||||||
import { syncBacklinks } from '@/lib/backlinks'
|
import { syncBacklinks } from '@/lib/backlinks'
|
||||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const tag = searchParams.get('tag') || undefined
|
const tag = searchParams.get('tag') || undefined
|
||||||
|
|
||||||
if (q || type || tag) {
|
if (q || type || tag) {
|
||||||
const notes = await noteQuery(q, { type, tag })
|
const notes = await searchNotes(q, { type, tag })
|
||||||
return createSuccessResponse(notes)
|
return createSuccessResponse(notes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { searchNotes } from '@/lib/search'
|
import { searchNotes } from '@/lib/search'
|
||||||
|
import { parseQuery } from '@/lib/query-parser'
|
||||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { searchParams } = new URL(req.url)
|
const { searchParams } = new URL(req.url)
|
||||||
const q = searchParams.get('q') || ''
|
const q = searchParams.get('q') || ''
|
||||||
const type = searchParams.get('type') || undefined
|
|
||||||
const tag = searchParams.get('tag') || undefined
|
|
||||||
|
|
||||||
const notes = await searchNotes(q, { type, tag })
|
const queryAST = parseQuery(q)
|
||||||
|
const notes = await searchNotes(queryAST.text, queryAST.filters)
|
||||||
|
|
||||||
return createSuccessResponse(notes)
|
return createSuccessResponse(notes)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,34 +1,178 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Search } from 'lucide-react'
|
import { Search, Loader2 } from 'lucide-react'
|
||||||
|
import { ScoredNote } from '@/lib/search'
|
||||||
|
|
||||||
export function SearchBar() {
|
export function SearchBar() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [results, setResults] = useState<ScoredNote[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([])
|
||||||
|
setIsOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceRef.current = setTimeout(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
|
||||||
|
const json = await res.json()
|
||||||
|
if (json.success) {
|
||||||
|
setResults(json.data.slice(0, 8))
|
||||||
|
setIsOpen(true)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
inputRef.current &&
|
||||||
|
!inputRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (query.trim()) {
|
if (query.trim()) {
|
||||||
router.push(`/notes?q=${encodeURIComponent(query)}`)
|
router.push(`/notes?q=${encodeURIComponent(query)}`)
|
||||||
|
setIsOpen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (!isOpen || results.length === 0) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => (prev < results.length - 1 ? prev + 1 : prev))
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault()
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
e.preventDefault()
|
||||||
|
if (selectedIndex >= 0 && results[selectedIndex]) {
|
||||||
|
router.push(`/notes/${results[selectedIndex].id}`)
|
||||||
|
setIsOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
} else {
|
||||||
|
handleSearch(e)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
e.preventDefault()
|
||||||
|
setIsOpen(false)
|
||||||
|
setSelectedIndex(-1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResultClick = (note: ScoredNote) => {
|
||||||
|
router.push(`/notes/${note.id}`)
|
||||||
|
setIsOpen(false)
|
||||||
|
setQuery('')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSearch} className="flex gap-2 w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<form onSubmit={handleSearch} className="flex gap-2 w-full">
|
||||||
type="text"
|
<Input
|
||||||
placeholder="Buscar notas..."
|
ref={inputRef}
|
||||||
value={query}
|
type="text"
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
placeholder="Buscar notas..."
|
||||||
className="flex-1 min-w-0"
|
value={query}
|
||||||
/>
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
<Button type="submit" variant="secondary" size="icon">
|
onKeyDown={handleKeyDown}
|
||||||
<Search className="h-4 w-4" />
|
className="flex-1 min-w-0"
|
||||||
</Button>
|
/>
|
||||||
</form>
|
<Button type="submit" variant="secondary" size="icon" disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isOpen && results.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="absolute top-full left-0 right-0 mt-1 bg-background border rounded-md shadow-lg z-50 max-h-96 overflow-y-auto"
|
||||||
|
>
|
||||||
|
{results.map((note, index) => (
|
||||||
|
<button
|
||||||
|
key={note.id}
|
||||||
|
onClick={() => handleResultClick(note)}
|
||||||
|
className={`w-full px-3 py-2 text-left flex items-center justify-between gap-2 transition-colors ${
|
||||||
|
index === selectedIndex ? 'bg-muted' : 'hover:bg-muted/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{note.title}</div>
|
||||||
|
{note.highlight && (
|
||||||
|
<div
|
||||||
|
className="text-sm text-muted-foreground truncate"
|
||||||
|
dangerouslySetInnerHTML={{ __html: note.highlight }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded ${
|
||||||
|
note.type === 'document'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300'
|
||||||
|
: note.type === 'task'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||||||
|
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{note.type}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/lib/query-parser.ts
Normal file
61
src/lib/query-parser.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export interface QueryAST {
|
||||||
|
text: string
|
||||||
|
filters: {
|
||||||
|
type?: string
|
||||||
|
tag?: string
|
||||||
|
isFavorite?: boolean
|
||||||
|
isPinned?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTER_REGEX = /(tag|type):(\S*)/gi
|
||||||
|
const IS_FAVORITE_REGEX = /\bis:favorite\b/gi
|
||||||
|
const IS_PINNED_REGEX = /\bis:pinned\b/gi
|
||||||
|
|
||||||
|
export function parseQuery(query: string): QueryAST {
|
||||||
|
if (!query || typeof query !== 'string') {
|
||||||
|
return { text: '', filters: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters: QueryAST['filters'] = {}
|
||||||
|
|
||||||
|
// Extract type: filter
|
||||||
|
const typeMatches = query.matchAll(/(?:^|\s)(type):(\S*)/gi)
|
||||||
|
for (const match of typeMatches) {
|
||||||
|
const value = match[2]
|
||||||
|
if (value) {
|
||||||
|
filters.type = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tag: filter
|
||||||
|
const tagMatches = query.matchAll(/(?:^|\s)(tag):(\S*)/gi)
|
||||||
|
for (const match of tagMatches) {
|
||||||
|
const value = match[2]
|
||||||
|
if (value) {
|
||||||
|
filters.tag = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for is:favorite (case insensitive filter name)
|
||||||
|
const isFavoriteMatches = query.match(/\bis:favorite\b/i)
|
||||||
|
if (isFavoriteMatches) {
|
||||||
|
filters.isFavorite = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for is:pinned (case insensitive filter name)
|
||||||
|
const isPinnedMatches = query.match(/\bis:pinned\b/i)
|
||||||
|
if (isPinnedMatches) {
|
||||||
|
filters.isPinned = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all filter patterns from the query to get remaining text
|
||||||
|
let text = query
|
||||||
|
.replace(FILTER_REGEX, '')
|
||||||
|
.replace(IS_FAVORITE_REGEX, '')
|
||||||
|
.replace(IS_PINNED_REGEX, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
|
||||||
|
return { text, filters }
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
import stringSimilarity from 'string-similarity'
|
import stringSimilarity from 'string-similarity'
|
||||||
import { getUsageStats } from '@/lib/usage'
|
import { getUsageStats } from '@/lib/usage'
|
||||||
|
import { parseQuery, QueryAST } from '@/lib/query-parser'
|
||||||
|
|
||||||
export interface SearchFilters {
|
export interface SearchFilters {
|
||||||
type?: string
|
type?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
|
isFavorite?: boolean
|
||||||
|
isPinned?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScoredNote {
|
export interface ScoredNote {
|
||||||
@@ -133,10 +136,19 @@ async function scoreNote(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function noteQuery(
|
export async function noteQuery(
|
||||||
query: string,
|
queryOrAST: string | QueryAST,
|
||||||
filters: SearchFilters = {}
|
filters: SearchFilters = {}
|
||||||
): Promise<ScoredNote[]> {
|
): Promise<ScoredNote[]> {
|
||||||
const queryLower = query.toLowerCase().trim()
|
// Support both old signature (query string + filters) and new (QueryAST)
|
||||||
|
let queryAST: QueryAST
|
||||||
|
if (typeof queryOrAST === 'string') {
|
||||||
|
queryAST = { text: queryOrAST, filters }
|
||||||
|
} else {
|
||||||
|
queryAST = queryOrAST
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text: queryText, filters: appliedFilters } = queryAST
|
||||||
|
const queryLower = queryText.toLowerCase().trim()
|
||||||
|
|
||||||
const allNotes = await prisma.note.findMany({
|
const allNotes = await prisma.note.findMany({
|
||||||
include: { tags: { include: { tag: true } } },
|
include: { tags: { include: { tag: true } } },
|
||||||
@@ -145,12 +157,14 @@ export async function noteQuery(
|
|||||||
const scored: ScoredNote[] = []
|
const scored: ScoredNote[] = []
|
||||||
|
|
||||||
for (const note of allNotes) {
|
for (const note of allNotes) {
|
||||||
if (filters.type && note.type !== filters.type) continue
|
// Apply filters from AST BEFORE scoring
|
||||||
|
if (appliedFilters.type && note.type !== appliedFilters.type) continue
|
||||||
if (filters.tag) {
|
if (appliedFilters.tag) {
|
||||||
const hasTag = note.tags.some(t => t.tag.name === filters.tag)
|
const hasTag = note.tags.some(t => t.tag.name === appliedFilters.tag)
|
||||||
if (!hasTag) continue
|
if (!hasTag) continue
|
||||||
}
|
}
|
||||||
|
if (appliedFilters.isFavorite && note.isFavorite !== true) continue
|
||||||
|
if (appliedFilters.isPinned && note.isPinned !== true) continue
|
||||||
|
|
||||||
const titleLower = note.title.toLowerCase()
|
const titleLower = note.title.toLowerCase()
|
||||||
const contentLower = note.content.toLowerCase()
|
const contentLower = note.content.toLowerCase()
|
||||||
@@ -181,7 +195,7 @@ export async function noteQuery(
|
|||||||
|
|
||||||
const highlight = highlightMatches(
|
const highlight = highlightMatches(
|
||||||
exactTitleMatch ? note.title + ' ' + note.content : note.content,
|
exactTitleMatch ? note.title + ' ' + note.content : note.content,
|
||||||
query
|
queryText
|
||||||
)
|
)
|
||||||
|
|
||||||
scored.push({
|
scored.push({
|
||||||
@@ -202,5 +216,11 @@ export async function searchNotes(
|
|||||||
query: string,
|
query: string,
|
||||||
filters: SearchFilters = {}
|
filters: SearchFilters = {}
|
||||||
): Promise<ScoredNote[]> {
|
): Promise<ScoredNote[]> {
|
||||||
return noteQuery(query, filters)
|
const queryAST: QueryAST = {
|
||||||
|
text: query,
|
||||||
|
filters: {
|
||||||
|
...filters,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return noteQuery(queryAST)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user