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:
2026-03-22 17:33:17 -03:00
parent d5c418c84f
commit 9af25927b7
7 changed files with 650 additions and 28 deletions

61
src/lib/query-parser.ts Normal file
View 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 }
}

View File

@@ -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)
}