diff --git a/go/cmd/kjol-website/app/components.go b/go/cmd/kjol-website/app/components.go index ba1ad16d..f4881feb 100644 --- a/go/cmd/kjol-website/app/components.go +++ b/go/cmd/kjol-website/app/components.go @@ -1121,6 +1121,7 @@ func navigationSection() func() *VNode { func searchSection() func() *VNode { q := NewSignal("") hit := NewSignal("") + andAmpQ := NewSignal("") return func() *VNode { options := []string{ @@ -1129,6 +1130,20 @@ func searchSection() func() *VNode { "SegmentedButtons", "SignaturePad", "TabGroup", "ThemeToggle", "Toast", "ToggleSwitch", "Tooltip", } + // Both spellings on purpose: with AndAmpersand on, typing either finds both, + // and the exact spelling ranks above the substituted one. "Standard" / "Brand" + // hold an "and" that is not the whole word, so it is left untouched. + andAmpNames := []string{ + "First Bank & Trust", + "First Bank and Trust Company", + "Smith & Wesson Financial", + "Johnson and Johnson Federal CU", + "Highland Savings & Loan", + "Standard Chartered", + "Brand Mortgage Group", + "AT&T Employees CU", + } + return docSection("search", "Fuzzy search", prose("Subsequence matching with a typo tolerance, scored so the best hit sorts first, and "+ "the matched characters highlighted in the result. \"atbl\" finds AutoTable; so does "+ @@ -1147,10 +1162,30 @@ func searchSection() func() *VNode { }), ), ), + + prose("Set AndAmpersand and the word \"and\" and the symbol \"&\" match each other, so "+ + "\"First Bank and Trust\" also finds \"First Bank & Trust\". The exact spelling still wins — "+ + "the substituted form is a penalized extra pass, not a free swap — and a stray \"and\" inside "+ + "\"Standard\" or \"Brand\" is left alone."), + + demo("AndAmpersand — type \"first bank and trust\", or \"smith & wesson\"", + row("max-w-md", + ui.FuzzyMatch(ui.FuzzyMatchProps{ + Options: andAmpNames, + Query: andAmpQ.Get(), + AndAmpersand: true, + Placeholder: "Search bank names…", + MaxResults: 6, + ShowScores: true, + OnQueryChange: func(v string) { andAmpQ.Set(v) }, + }), + ), + ), apiTable( apiRow{"RankFuzzyMatches", "The scorer, headless. Use it and render the results yourself."}, apiRow{"FuzzySegments", "Splits a result into matched / unmatched runs, for highlighting."}, apiRow{"FuzzyMatchTypoTolerant", "One transposition or substitution forgiven."}, + apiRow{"FuzzyOptions{AndAmpersand}", "Treat the word \"and\" and \"&\" as interchangeable, exact spelling still first."}, ), ) } diff --git a/go/cmd/kjol-website/frontend/src/pages/Components.tsx b/go/cmd/kjol-website/frontend/src/pages/Components.tsx index ee0fd674..9b06ca89 100644 --- a/go/cmd/kjol-website/frontend/src/pages/Components.tsx +++ b/go/cmd/kjol-website/frontend/src/pages/Components.tsx @@ -1332,6 +1332,20 @@ const KIT_NAMES = [ "Tooltip", "TutorialProvider", "SidebarNav", "Chart", "FuzzyMatch", ]; +// Both "and" and "&" spellings appear here on purpose: with andAmpersand on, typing +// either finds both, and the exact spelling ranks above the substituted one. +// "Standard" / "Brand" hold an "and" that is NOT the whole word — left untouched. +const AND_AMP_NAMES = [ + "First Bank & Trust", + "First Bank and Trust Company", + "Smith & Wesson Financial", + "Johnson and Johnson Federal CU", + "Highland Savings & Loan", + "Standard Chartered", + "Brand Mortgage Group", + "AT&T Employees CU", +]; + function Search() { const [hit, setHit] = createSignal(""); @@ -1359,6 +1373,25 @@ function Search() { and you render them however you like.

+ + + Pass andAmpersand and the word "and" and the symbol "&" + match each other, so "First Bank and Trust" also finds "First Bank & Trust". The exact + spelling still wins — the substituted form is a penalized extra pass, not a free swap — and a + stray "and" inside "Standard" or "Brand" is left alone. + + + +
+ +
+
); } diff --git a/go/jsruntime/uikit/FuzzyMatch.tsx b/go/jsruntime/uikit/FuzzyMatch.tsx index b2711344..7b98f3c0 100644 --- a/go/jsruntime/uikit/FuzzyMatch.tsx +++ b/go/jsruntime/uikit/FuzzyMatch.tsx @@ -37,6 +37,16 @@ const FUZZY_UNMATCHED_PENALTY = -1; const FUZZY_RECURSION_LIMIT = 10; const FUZZY_TRANSPOSE_PENALTY = -20; const FUZZY_EXACT_SUBSTRING_BONUS = 100; +const FUZZY_AND_AMPERSAND_PENALTY = -20; + +// Options that turn on extra, penalized match passes. Off by default so the base +// matcher stays exact-only. +export interface FuzzyMatchOptions { + // Treat the word "and" and the symbol "&" as interchangeable, so "First Bank + // and Trust" also finds "First Bank & Trust" (and vice versa). The exact + // spelling still wins — the substituted form is penalized, not free. + andAmpersand?: boolean; +} const isLower = (c: string) => c >= "a" && c <= "z"; const isUpper = (c: string) => c >= "A" && c <= "Z"; @@ -93,11 +103,26 @@ export function fuzzyMatch(query: string, target: string): FuzzyMatchResult | nu return { score, positions: matches }; } +// Alternative spellings of `query` with the word "and" and the symbol "&" swapped +// for each other, excluding `query` itself. `\band\b` only rewrites a standalone +// "and", so "brand" and "island" are left alone. These are the candidates the +// matcher tries (penalized) so "... and ..." can still hit a "... & ..." target +// and vice versa, while the exact spelling ranks first. +function andAmpersandVariants(query: string): string[] { + const out: string[] = []; + const amp = query.replace(/\band\b/gi, "&"); + if (amp !== query) out.push(amp); + const and = query.replace(/&/g, "and"); + if (and !== query) out.push(and); + return out; +} + // Subsequence matching can't tolerate a transposed typo ("teh" vs "the") because // the swapped letters violate ordering. So also try every single adjacent-swap // variant of the query and keep the best, penalizing transposed hits so exact -// matches still rank first. -export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatchResult | null { +// matches still rank first. With `opts.andAmpersand`, the "and"/"&" spellings are +// tried the same way — an extra penalized pass, not a free substitution. +export function fuzzyMatchTypoTolerant(query: string, target: string, opts?: FuzzyMatchOptions): FuzzyMatchResult | null { let best = fuzzyMatch(query, target); for (let i = 0; i < query.length - 1; i++) { const swapped = query.slice(0, i) + query[i + 1] + query[i] + query.slice(i + 2); @@ -106,6 +131,14 @@ export function fuzzyMatchTypoTolerant(query: string, target: string): FuzzyMatc const score = m.score + FUZZY_TRANSPOSE_PENALTY; if (!best || score > best.score) best = { score, positions: m.positions }; } + if (opts?.andAmpersand) { + for (const variant of andAmpersandVariants(query)) { + const m = fuzzyMatch(variant, target); + if (!m) continue; + const score = m.score + FUZZY_AND_AMPERSAND_PENALTY; + if (!best || score > best.score) best = { score, positions: m.positions }; + } + } return best; } @@ -130,12 +163,12 @@ export function fuzzySegments(text: string, positions: number[]): FuzzySegment[] // Rank `options` against `query`, best score first, with highlight segments. // Returns [] for an empty query. This is the headless entry point. -export function rankFuzzyMatches(query: string, options: string[], maxResults?: number): FuzzyRankedItem[] { +export function rankFuzzyMatches(query: string, options: string[], maxResults?: number, opts?: FuzzyMatchOptions): FuzzyRankedItem[] { const q = query.trim(); if (!q) return []; const out: FuzzyRankedItem[] = []; for (const value of options) { - const m = fuzzyMatchTypoTolerant(q, value); + const m = fuzzyMatchTypoTolerant(q, value, opts); if (m) out.push({ value, score: m.score, segments: fuzzySegments(value, m.positions) }); } out.sort((a, b) => b.score - a.score); @@ -157,6 +190,8 @@ export interface FuzzyMatchProps { // Show each result's match score. Debug aid — off by default. showScores?: boolean; maxResults?: number; + // Treat "and" and "&" as interchangeable (see FuzzyMatchOptions.andAmpersand). + andAmpersand?: boolean; placeholder?: string; class?: string; listClass?: string; @@ -192,7 +227,7 @@ export function FuzzyMatch(props: FuzzyMatchProps) { let dropdownRef: HTMLDivElement | undefined; const display = () => props.display ?? "list"; - const results = createMemo(() => rankFuzzyMatches(query(), props.options, props.maxResults)); + const results = createMemo(() => rankFuzzyMatches(query(), props.options, props.maxResults, { andAmpersand: props.andAmpersand })); // Show all options (unranked) in list mode before the user types anything, // so the searchable set is visible up front. @@ -318,4 +353,4 @@ export function FuzzyMatch(props: FuzzyMatchProps) { ; -} +} \ No newline at end of file diff --git a/go/webui/fuzzymatch.go b/go/webui/fuzzymatch.go index 31b84fa0..6353937e 100644 --- a/go/webui/fuzzymatch.go +++ b/go/webui/fuzzymatch.go @@ -57,8 +57,25 @@ const ( fuzzyRecursionLimit = 10 fuzzyTransposePenalty = -20 fuzzyExactSubstrBonus = 100 + fuzzyAndAmpersandPen = -20 ) +// FuzzyOptions turns on extra, penalized match passes. The zero value is exact- +// only, so passing no options leaves the base matcher unchanged. +type FuzzyOptions struct { + // AndAmpersand treats the word "and" and the symbol "&" as interchangeable, + // so "First Bank and Trust" also finds "First Bank & Trust" (and vice versa). + // The exact spelling still wins — the substituted form is penalized, not free. + AndAmpersand bool +} + +func fuzzyOpt(opts []FuzzyOptions) FuzzyOptions { + if len(opts) > 0 { + return opts[0] + } + return FuzzyOptions{} +} + func fuzzyIsLower(c rune) bool { return c >= 'a' && c <= 'z' } func fuzzyIsUpper(c rune) bool { return c >= 'A' && c <= 'Z' } func fuzzyIsSeparator(c rune) bool { return c == ' ' || c == '_' || c == '-' } @@ -147,10 +164,59 @@ func FuzzyMatchOne(query, target string) *FuzzyMatchResult { return &FuzzyMatchResult{Score: score, Positions: matches} } +func fuzzyIsWordByte(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '_' || c >= 0x80 +} + +func fuzzyByteAt(s string, i int) byte { + if i < 0 || i >= len(s) { + return 0 + } + return s[i] +} + +// fuzzyReplaceAndWord replaces every standalone "and" (case-insensitive) in s +// with repl, leaving substrings like "brand" or "island" untouched. "and", "&" +// and the ASCII separators are all single-byte, so a byte scan suffices. +func fuzzyReplaceAndWord(s, repl string) string { + var b strings.Builder + for i := 0; i < len(s); { + isAnd := i+3 <= len(s) && + (s[i] == 'a' || s[i] == 'A') && + (s[i+1] == 'n' || s[i+1] == 'N') && + (s[i+2] == 'd' || s[i+2] == 'D') + if isAnd && !fuzzyIsWordByte(fuzzyByteAt(s, i-1)) && !fuzzyIsWordByte(fuzzyByteAt(s, i+3)) { + b.WriteString(repl) + i += 3 + continue + } + b.WriteByte(s[i]) + i++ + } + return b.String() +} + +// fuzzyAndAmpersandVariants returns alternative spellings of query with the word +// "and" and the symbol "&" swapped for each other, excluding query itself. These +// are the candidates the matcher tries (penalized) so "... and ..." can still hit +// a "... & ..." target and vice versa, while the exact spelling ranks first. +func fuzzyAndAmpersandVariants(query string) []string { + var out []string + if amp := fuzzyReplaceAndWord(query, "&"); amp != query { + out = append(out, amp) + } + if and := strings.ReplaceAll(query, "&", "and"); and != query { + out = append(out, and) + } + return out +} + // FuzzyMatchTypoTolerant also tries every single adjacent-swap variant of the // query (subsequence matching can't tolerate a transposed typo like "teh" vs -// "the"), penalizing transposed hits so exact matches still rank first. -func FuzzyMatchTypoTolerant(query, target string) *FuzzyMatchResult { +// "the"), penalizing transposed hits so exact matches still rank first. With +// opts.AndAmpersand set, the "and"/"&" spellings are tried the same way — an +// extra penalized pass, not a free substitution. +func FuzzyMatchTypoTolerant(query, target string, opts ...FuzzyOptions) *FuzzyMatchResult { best := FuzzyMatchOne(query, target) qr := []rune(query) for i := 0; i < len(qr)-1; i++ { @@ -164,6 +230,18 @@ func FuzzyMatchTypoTolerant(query, target string) *FuzzyMatchResult { best = &FuzzyMatchResult{Score: score, Positions: m.Positions} } } + if fuzzyOpt(opts).AndAmpersand { + for _, variant := range fuzzyAndAmpersandVariants(query) { + m := FuzzyMatchOne(variant, target) + if m == nil { + continue + } + score := m.Score + fuzzyAndAmpersandPen + if best == nil || score > best.Score { + best = &FuzzyMatchResult{Score: score, Positions: m.Positions} + } + } + } return best } @@ -198,14 +276,14 @@ func FuzzySegments(text string, positions []int) []FuzzySegment { // RankFuzzyMatches ranks options against query, best score first, with // highlight segments. Returns nil for an empty query. maxResults <= 0 means no // limit. This is the headless entry point. -func RankFuzzyMatches(query string, options []string, maxResults int) []FuzzyRankedItem { +func RankFuzzyMatches(query string, options []string, maxResults int, opts ...FuzzyOptions) []FuzzyRankedItem { q := strings.TrimSpace(query) if q == "" { return nil } var out []FuzzyRankedItem for _, value := range options { - m := FuzzyMatchTypoTolerant(q, value) + m := FuzzyMatchTypoTolerant(q, value, opts...) if m != nil { out = append(out, FuzzyRankedItem{Value: value, Score: m.Score, Segments: FuzzySegments(value, m.Positions)}) } @@ -240,13 +318,14 @@ const fuzzyDropdownOptionHighlightCls = "bg-surface-raised" // FuzzyMatchProps configures the FuzzyMatch component. Query / Open / Highlighted // are caller-held state (read at the call site); the On* callbacks report changes. type FuzzyMatchProps struct { - Options []string - Display FuzzyMatchDisplay // default FuzzyDisplayList - ShowScores bool - MaxResults int - Placeholder string - Class string - ListClass string + Options []string + Display FuzzyMatchDisplay // default FuzzyDisplayList + ShowScores bool + MaxResults int + AndAmpersand bool // treat "and" and "&" as interchangeable (see FuzzyOptions) + Placeholder string + Class string + ListClass string Query string // current query text Open bool // dropdown open (Display == dropdown) @@ -362,7 +441,7 @@ func FuzzyMatch(p FuzzyMatchProps) *vdom.VNode { display = FuzzyDisplayList } - results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults) + results := RankFuzzyMatches(p.Query, p.Options, p.MaxResults, FuzzyOptions{AndAmpersand: p.AndAmpersand}) inputMods := []vdom.Mod{ vdom.Attr("type", "text"),