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 { noteSchema } from '@/lib/validators'
|
||||
import { normalizeTag } from '@/lib/tags'
|
||||
import { noteQuery } from '@/lib/search'
|
||||
import { searchNotes } from '@/lib/search'
|
||||
import { syncBacklinks } from '@/lib/backlinks'
|
||||
import { createErrorResponse, createSuccessResponse, ValidationError } from '@/lib/errors'
|
||||
|
||||
@@ -14,7 +14,7 @@ export async function GET(req: NextRequest) {
|
||||
const tag = searchParams.get('tag') || undefined
|
||||
|
||||
if (q || type || tag) {
|
||||
const notes = await noteQuery(q, { type, tag })
|
||||
const notes = await searchNotes(q, { type, tag })
|
||||
return createSuccessResponse(notes)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { searchNotes } from '@/lib/search'
|
||||
import { parseQuery } from '@/lib/query-parser'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/lib/errors'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
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)
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,34 +1,178 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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() {
|
||||
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 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) => {
|
||||
e.preventDefault()
|
||||
if (query.trim()) {
|
||||
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 (
|
||||
<form onSubmit={handleSearch} className="flex gap-2 w-full">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar notas..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="icon">
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
<div className="relative w-full">
|
||||
<form onSubmit={handleSearch} className="flex gap-2 w-full">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Buscar notas..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<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 stringSimilarity from 'string-similarity'
|
||||
import { getUsageStats } from '@/lib/usage'
|
||||
import { parseQuery, QueryAST } from '@/lib/query-parser'
|
||||
|
||||
export interface SearchFilters {
|
||||
type?: string
|
||||
tag?: string
|
||||
isFavorite?: boolean
|
||||
isPinned?: boolean
|
||||
}
|
||||
|
||||
export interface ScoredNote {
|
||||
@@ -133,10 +136,19 @@ async function scoreNote(
|
||||
}
|
||||
|
||||
export async function noteQuery(
|
||||
query: string,
|
||||
queryOrAST: string | QueryAST,
|
||||
filters: SearchFilters = {}
|
||||
): 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({
|
||||
include: { tags: { include: { tag: true } } },
|
||||
@@ -145,12 +157,14 @@ export async function noteQuery(
|
||||
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)
|
||||
// Apply filters from AST BEFORE scoring
|
||||
if (appliedFilters.type && note.type !== appliedFilters.type) continue
|
||||
if (appliedFilters.tag) {
|
||||
const hasTag = note.tags.some(t => t.tag.name === appliedFilters.tag)
|
||||
if (!hasTag) continue
|
||||
}
|
||||
if (appliedFilters.isFavorite && note.isFavorite !== true) continue
|
||||
if (appliedFilters.isPinned && note.isPinned !== true) continue
|
||||
|
||||
const titleLower = note.title.toLowerCase()
|
||||
const contentLower = note.content.toLowerCase()
|
||||
@@ -181,7 +195,7 @@ export async function noteQuery(
|
||||
|
||||
const highlight = highlightMatches(
|
||||
exactTitleMatch ? note.title + ' ' + note.content : note.content,
|
||||
query
|
||||
queryText
|
||||
)
|
||||
|
||||
scored.push({
|
||||
@@ -202,5 +216,11 @@ export async function searchNotes(
|
||||
query: string,
|
||||
filters: SearchFilters = {}
|
||||
): Promise<ScoredNote[]> {
|
||||
return noteQuery(query, filters)
|
||||
const queryAST: QueryAST = {
|
||||
text: query,
|
||||
filters: {
|
||||
...filters,
|
||||
},
|
||||
}
|
||||
return noteQuery(queryAST)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user