Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions src/lib/Search.fns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { createServerFn } from '@tanstack/react-start'
import { z } from 'zod'
import type { Result } from './Search'
import { buildFtsSearchQuery, buildTitleFtsSearchQuery } from './SearchQuery'

/** Search TIPs with FTS5, supporting exact number shortcuts and prefix matching. */
export const query = createServerFn({ method: 'POST' })
Expand Down Expand Up @@ -34,15 +35,11 @@ export const query = createServerFn({ method: 'POST' })
] as Result[]
}

// FTS5 query with prefix matching
const ftsQuery = trimmed
.split(/\s+/)
.filter((term) => term.length > 0)
.map((term) => term.replace(/"/g, '""') + '*')
.join(' ')
const ftsQuery = buildFtsSearchQuery(trimmed)
if (!ftsQuery) return [] as Result[]

// Run two queries: title-scoped matches first, then general matches
const titleQuery = `title : ${ftsQuery}`
const titleQuery = buildTitleFtsSearchQuery(ftsQuery)
const [titleRows, allRows] = await Promise.all([
db
.prepare(
Expand Down Expand Up @@ -78,8 +75,8 @@ export const query = createServerFn({ method: 'POST' })

// Merge: title matches first, then remaining general matches
const seen = new Set(titleRows.results.map((r) => r.number))
return [
...titleRows.results,
...allRows.results.filter((r) => !seen.has(r.number)),
].slice(0, 20)
return [...titleRows.results, ...allRows.results.filter((r) => !seen.has(r.number))].slice(
0,
20,
)
})
35 changes: 35 additions & 0 deletions src/lib/SearchQuery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import { buildFtsSearchQuery, buildTitleFtsSearchQuery } from './SearchQuery'

describe('buildFtsSearchQuery', () => {
test('keeps unquoted terms as prefix searches', () => {
assert.equal(buildFtsSearchQuery('bal'), '"bal"*')
})

test('treats quoted terms as exact phrase searches', () => {
assert.equal(buildFtsSearchQuery('"bal"'), '"bal"')
})

test('mixes prefix terms and exact phrases', () => {
assert.equal(buildFtsSearchQuery('bal "asset layer"'), '"bal"* "asset layer"')
})

test('treats an unfinished quote as an exact phrase', () => {
assert.equal(buildFtsSearchQuery('"bal'), '"bal"')
})

test('quotes punctuation so direct search text cannot break FTS syntax', () => {
assert.equal(buildFtsSearchQuery('foo/bar'), '"foo/bar"*')
})

test('ignores empty quoted phrases', () => {
assert.equal(buildFtsSearchQuery('""'), '')
})
})

describe('buildTitleFtsSearchQuery', () => {
test('scopes the whole search expression to the title column', () => {
assert.equal(buildTitleFtsSearchQuery('"foo"* "bar"*'), 'title : ("foo"* "bar"*)')
})
})
58 changes: 58 additions & 0 deletions src/lib/SearchQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
type SearchTerm = {
text: string
exact: boolean
}

/** Build an FTS5 MATCH query from direct search-box input. */
export function buildFtsSearchQuery(input: string): string {
return parseSearchTerms(input)
.map((term) => {
const phrase = quoteFtsPhrase(term.text)
return term.exact ? phrase : `${phrase}*`
})
.join(' ')
}

export function buildTitleFtsSearchQuery(ftsQuery: string): string {
return `title : (${ftsQuery})`
}

function parseSearchTerms(input: string): SearchTerm[] {
const terms: SearchTerm[] = []
let text = ''
let inQuote = false

function push(exact: boolean) {
const trimmed = text.trim()
if (trimmed) terms.push({ text: trimmed, exact })
text = ''
}

for (const char of input.trim()) {
if (inQuote) {
if (char === '"') {
push(true)
inQuote = false
} else {
text += char
}
continue
}

if (char === '"') {
push(false)
inQuote = true
} else if (/\s/.test(char)) {
push(false)
} else {
text += char
}
}

push(inQuote)
return terms
}

function quoteFtsPhrase(value: string): string {
return `"${value.replace(/"/g, '""')}"`
}