From 562c9340f8e34501b50c313c4637967d2061e712 Mon Sep 17 00:00:00 2001 From: Dan Cline <6798349+Rjected@users.noreply.github.com> Date: Tue, 5 May 2026 15:39:08 +0000 Subject: [PATCH] fix(search): preserve quoted exact matches --- src/lib/Search.fns.ts | 19 +++++------- src/lib/SearchQuery.test.ts | 35 ++++++++++++++++++++++ src/lib/SearchQuery.ts | 58 +++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 src/lib/SearchQuery.test.ts create mode 100644 src/lib/SearchQuery.ts diff --git a/src/lib/Search.fns.ts b/src/lib/Search.fns.ts index 07b7738..b29c917 100644 --- a/src/lib/Search.fns.ts +++ b/src/lib/Search.fns.ts @@ -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' }) @@ -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( @@ -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, + ) }) diff --git a/src/lib/SearchQuery.test.ts b/src/lib/SearchQuery.test.ts new file mode 100644 index 0000000..dacc2dc --- /dev/null +++ b/src/lib/SearchQuery.test.ts @@ -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"*)') + }) +}) diff --git a/src/lib/SearchQuery.ts b/src/lib/SearchQuery.ts new file mode 100644 index 0000000..5ce3f28 --- /dev/null +++ b/src/lib/SearchQuery.ts @@ -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, '""')}"` +}