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:
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