diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index 3e512d912..e8a1e9446 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -519,7 +519,7 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params * // declares it, or the index is not used. q := *params.Subject webPos, addrPos := argPos, argPos+1 - args = append(args, q, q) + args = append(args, q, escapeLikePattern(q)) argPos += 2 match := fmt.Sprintf(`ue.search_tsv @@ websearch_to_tsquery('english', $%d) @@ -536,9 +536,9 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params * // Participants. The stored header is "Display Name (addr)", so one // substring match covers searching by either. match += fmt.Sprintf(` - OR EXISTS (SELECT 1 FROM unnest(ue.from_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%') - OR EXISTS (SELECT 1 FROM unnest(ue.to_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%') - OR EXISTS (SELECT 1 FROM unnest(ue.cc) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%')`, + OR EXISTS (SELECT 1 FROM unnest(ue.from_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%' ESCAPE '\') + OR EXISTS (SELECT 1 FROM unnest(ue.to_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%' ESCAPE '\') + OR EXISTS (SELECT 1 FROM unnest(ue.cc) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%' ESCAPE '\')`, addrPos, addrPos, addrPos) inner += ` AND (` + match + `)` @@ -1494,6 +1494,9 @@ func (r *uniboxRepository) scanGrounding(ctx context.Context, query string, orgI // so adding a word narrows rather than widens. func prefixTSQuery(input string) string { const maxTerms = 8 + if hasWebSearchSyntax(input) { + return "" + } cleaned := strings.Map(func(r rune) rune { switch { @@ -1509,10 +1512,26 @@ func prefixTSQuery(input string) string { return "" } if len(terms) > maxTerms { - terms = terms[:maxTerms] + return "" } for i, t := range terms { terms[i] = t + ":*" } return strings.Join(terms, " & ") } + +func hasWebSearchSyntax(input string) bool { + if strings.Contains(input, `"`) { + return true + } + for _, term := range strings.Fields(input) { + if strings.HasPrefix(term, "-") || strings.EqualFold(term, "OR") { + return true + } + } + return false +} + +func escapeLikePattern(input string) string { + return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(input) +} diff --git a/internal/repository/unibox_search_test.go b/internal/repository/unibox_search_test.go index 76453a9bd..89ffa4308 100644 --- a/internal/repository/unibox_search_test.go +++ b/internal/repository/unibox_search_test.go @@ -19,8 +19,10 @@ func TestPrefixTSQuery(t *testing.T) { // Every one of these makes to_tsquery raise when passed through raw. {"operators are stripped", "a & b | c", "a:* & b:* & c:*"}, {"unbalanced bracket", "re: (urgent", "re:* & urgent:*"}, - {"negation and colons", "!foo:bar", "foo:* & bar:*"}, - {"quotes", `"exact phrase"`, "exact:* & phrase:*"}, + {"punctuation and colons", "!foo:bar", "foo:* & bar:*"}, + {"negation keeps web-search semantics", "foo -bar", ""}, + {"quotes keep web-search semantics", `"exact phrase"`, ""}, + {"OR keeps web-search semantics", "foo OR bar", ""}, {"a lone operator has nothing to search", "&&&", ""}, {"empty", "", ""}, @@ -37,11 +39,17 @@ func TestPrefixTSQuery(t *testing.T) { } } -// A pasted paragraph must not become a 200-term query the planner has to walk. +// Long input stays on websearch_to_tsquery instead of being broadened by a truncated prefix query. func TestPrefixTSQueryCapsTerms(t *testing.T) { got := prefixTSQuery("one two three four five six seven eight nine ten eleven") - want := "one:* & two:* & three:* & four:* & five:* & six:* & seven:* & eight:*" + want := "" if got != want { - t.Fatalf("prefixTSQuery capped = %q, want %q", got, want) + t.Fatalf("prefixTSQuery long input = %q, want %q", got, want) + } +} + +func TestEscapeLikePattern(t *testing.T) { + if got, want := escapeLikePattern(`100%_done\later`), `100\%\_done\\later`; got != want { + t.Fatalf("escapeLikePattern = %q, want %q", got, want) } } diff --git a/web/src/components/app/unibox/ConversationList.tsx b/web/src/components/app/unibox/ConversationList.tsx index b3087532c..88ae320cf 100644 --- a/web/src/components/app/unibox/ConversationList.tsx +++ b/web/src/components/app/unibox/ConversationList.tsx @@ -304,7 +304,7 @@ export function ConversationList({ // The box searches people, subject and message body, and naming // that is the difference between it looking broken and looking // useful: nobody tries an address in a box labelled "Search inbox". - placeholder={`Search ${scopeLabel.toLowerCase()} — name, address, or any word`} + placeholder={`Search ${scopeLabel.toLowerCase()}: name, address, or any word`} title={'Searches the sender, recipients, subject and message body. "quoted phrases", OR and -exclude work.'} className="flex-1 min-w-0 h-full bg-transparent text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none" />