diff --git a/.agents/skills/fuzzy-search/SKILL.md b/.agents/skills/fuzzy-search/SKILL.md new file mode 100644 index 0000000..9d459ee --- /dev/null +++ b/.agents/skills/fuzzy-search/SKILL.md @@ -0,0 +1,33 @@ +--- +name: fuzzy-search +description: Add or maintain buzz-sheet name searches using the project’s shared collection-level fuzzy fallback. Use for guide, team, character, weapon, artifact, and glossary searches or catalog pickers; exclude numeric and fixed-option dropdowns. +--- + +# Buzz-sheet fuzzy search + +Use this project’s shared search implementation for finding entities by name or alias. Preserve explicit user choices if they call for different tolerance or ranking. + +## Default behavior + +- Preserve existing case-insensitive substring matching and its searchable fields. Return direct results in their original order. +- Attempt fuzzy matching only when the entire eligible collection has no direct results. Do not decide fallback independently for each item. +- Restrict fuzzy candidates to names, aliases, and individual whitespace-separated words. Keep overview prose and internal keys available for existing direct searches, but exclude them from fuzzy candidates. +- Normalize with NFC, trim, lowercase, and compare Unicode code points. Keep queries shorter than four code points strict. +- Use bounded optimal-string-alignment Damerau–Levenshtein distance, including adjacent transpositions. Allow one edit for 4–7 code points and two for longer queries, capped at 25% of query length. Rank by edit distance with stable source-order ties. +- Empty queries return the eligible collection. Show the localized close-match notice only when fuzzy fallback returns results. + +## Reuse the project implementation + +Use `searchItems` from `lib/search.ts`, returning `{ items, fuzzy }`. Supply separate selectors for existing direct-search text and fuzzy candidate names. Use `SearchNotice` from `components/search-notice.tsx` for fallback feedback. + +Use `searchTeams` from `lib/guides/team-search.ts` for collection-level team searches. Apply active groups, permissions, and category restrictions before matching. Explicit DPS sorting takes precedence over fuzzy ranking. Direct team queries must match within one candidate name, not span multiple members. + +Use `FuzzyCombobox` from `components/ui/combobox.tsx` for catalog pickers, passing `items` and `searchNames`. It retains Base UI selection handling and disables native filtering with `filter={null}` after filtering the collection. Do not add another filter that would discard typo results. Preserve object identities and single/multiple selection behavior. + +Keep searches local against the already loaded collections. Reuse these utilities instead of adding another search engine or dependency. + +## Verify observable behavior + +Test collection-wide direct-match precedence, empty and short queries, edits and transpositions, aliases, multiword names, Thai/Unicode text, thresholds, unrelated inputs, stable ranking, and exclusion of prose/keys from fuzzy candidates. `muala` should directly match `Mualani`; `mulani` should find it through fallback. + +For pickers, verify keyboard selection, clearing, reopening, popup inputs, and multiple selection against the actual component. For teams, verify category restrictions and explicit sorting after matching. Run `bun run test`, `bun run lint`, and `bun run typecheck`; report pre-existing failures separately. diff --git a/.agents/skills/fuzzy-search/agents/openai.yaml b/.agents/skills/fuzzy-search/agents/openai.yaml new file mode 100644 index 0000000..5d2fe93 --- /dev/null +++ b/.agents/skills/fuzzy-search/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Fuzzy Search" + short_description: "Add typo-tolerant searches with direct-match fallback" diff --git a/components/admin/create-guide-flow.tsx b/components/admin/create-guide-flow.tsx index ec42612..b9c072f 100644 --- a/components/admin/create-guide-flow.tsx +++ b/components/admin/create-guide-flow.tsx @@ -1,5 +1,8 @@ "use client"; +import { searchItems } from "@/lib/search"; +import { SearchNotice } from "@/components/search-notice"; + import Image from "next/image"; import { useRouter } from "next/navigation"; import { ArrowLeftIcon, CheckIcon, SearchIcon } from "lucide-react"; @@ -28,12 +31,10 @@ export function CreateGuideFlow({ characters, existingKeys }: { characters: Char const [overview, setOverview] = useState(""); const [pending, startTransition] = useTransition(); const existing = useMemo(() => new Set(existingKeys), [existingKeys]); - const filtered = characters.filter((character) => - [character.name, ...character.aliases] - .join(" ") - .toLocaleLowerCase("th") - .includes(query.trim().toLocaleLowerCase("th")), - ); + const search = searchItems(characters, query, + (character) => [character.name, ...character.aliases].join(" "), + (character) => [character.name, ...character.aliases]); + const filtered = search.items; if (selected) { return ( @@ -79,6 +80,7 @@ export function CreateGuideFlow({ characters, existingKeys }: { characters: Char setQuery(event.target.value)} placeholder="ค้นหาตัวละคร…" aria-label="ค้นหาตัวละคร" /> +
{filtered.map((character) => { const disabled = existing.has(character.key); diff --git a/components/admin/glossary-manager.tsx b/components/admin/glossary-manager.tsx index f24f9fd..4fa7dde 100644 --- a/components/admin/glossary-manager.tsx +++ b/components/admin/glossary-manager.tsx @@ -1,5 +1,8 @@ "use client"; +import { searchItems } from "@/lib/search"; +import { SearchNotice } from "@/components/search-notice"; + import Image from "next/image"; import { SearchIcon, Trash2Icon } from "lucide-react"; import { useMemo, useState, useTransition } from "react"; @@ -10,7 +13,7 @@ import { addGlossaryAlias, removeGlossaryAlias } from "@/app/admin/actions"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"; +import { FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList } from "@/components/ui/combobox"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -46,11 +49,10 @@ export function GlossaryManager({ items.map((catalogItem) => [`${catalogKind}:${catalogItem.key}`, catalogItem.name] as const), ), ), [catalogs]); - const visibleAliases = aliases.filter((entry) => - `${entry.alias} ${itemNames.get(`${entry.kind}:${entry.catalogKey}`) ?? entry.catalogKey}` - .toLocaleLowerCase("th") - .includes(query.trim().toLocaleLowerCase("th")), - ); + const search = searchItems(aliases, query, + (entry) => `${entry.alias} ${itemNames.get(`${entry.kind}:${entry.catalogKey}`) ?? entry.catalogKey}`, + (entry) => [entry.alias, itemNames.get(`${entry.kind}:${entry.catalogKey}`) ?? ""]); + const visibleAliases = search.items; const submit = () => startTransition(async () => { const result = await addGlossaryAlias({ kind, catalogKey: item?.key, alias }); @@ -90,8 +92,9 @@ export function GlossaryManager({ รายการ - [value.name, ...aliases.filter((entry) => entry.kind === kind && entry.catalogKey === value.key).map((entry) => entry.alias)]} value={item} onValueChange={setItem} itemToStringLabel={(value) => value.name} @@ -114,7 +117,7 @@ export function GlossaryManager({ )} - + Shortcut @@ -143,6 +146,7 @@ export function GlossaryManager({ setQuery(event.target.value)} />
+ {visibleAliases.map((entry) => { const name = itemNames.get(`${entry.kind}:${entry.catalogKey}`); return ( diff --git a/components/admin/guide-section-editor.tsx b/components/admin/guide-section-editor.tsx index 84f3af1..2ec8051 100644 --- a/components/admin/guide-section-editor.tsx +++ b/components/admin/guide-section-editor.tsx @@ -1,5 +1,8 @@ "use client"; +import { searchItems } from "@/lib/search"; +import { SearchNotice } from "@/components/search-notice"; + import dynamic from "next/dynamic"; import Image from "next/image"; import { useRouter } from "next/navigation"; @@ -78,6 +81,7 @@ import { } from "@/components/ui/card"; import { Combobox, + FuzzyCombobox, ComboboxContent, ComboboxEmpty, ComboboxInput, @@ -168,7 +172,7 @@ import { type TeamToolValue, } from "@/lib/guides/team-tools"; import { - matchesTeamSearch, + searchTeams, type TeamSearchTarget, } from "@/lib/guides/team-search"; import { @@ -475,18 +479,13 @@ function CatalogPicker({ ) : null}
- onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} - filter={(item, query) => - [item.name, ...(item.aliases ?? [])] - .join(" ") - .toLocaleLowerCase("th") - .includes(query.trim().toLocaleLowerCase("th")) - } + searchNames={(item) => [item.name, ...(item.aliases ?? [])]} autoHighlight > - +
); @@ -546,18 +545,13 @@ function CatalogSlotPicker({ }) { const selected = items.find((item) => item.key === value) ?? null; return ( - onChange(next?.key ?? "")} itemToStringLabel={(item) => item.name} itemToStringValue={(item) => item.key} - filter={(item, query) => - [item.name, ...(item.aliases ?? [])] - .join(" ") - .toLocaleLowerCase("th") - .includes(query.trim().toLocaleLowerCase("th")) - } + searchNames={(item) => [item.name, ...(item.aliases ?? [])]} autoHighlight > - + ); } @@ -640,7 +634,7 @@ function ArtifactSlotPicker({ : null; return ( - item.name} itemToStringValue={(item) => item.key} isItemEqualToValue={catalogItemsEqual} - filter={(item, query) => - [item.name, ...(item.aliases ?? [])] - .join(" ") - .toLocaleLowerCase("th") - .includes(query.trim().toLocaleLowerCase("th")) - } + searchNames={(item) => [item.name, ...(item.aliases ?? [])]} autoHighlight > - + ); } @@ -2887,14 +2876,12 @@ function TeamEditor({ const [multipleApplyGuideId, setMultipleApplyGuideId] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [searchTarget, setSearchTarget] = useState("all"); - const filteredTeams = useMemo(() => { + const teamSearch = useMemo(() => { const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); - return teams - .map((team, teamIndex) => ({ team, teamIndex })) - .filter(({ team }) => matchesTeamSearch({ + return searchTeams(teams.map((team, teamIndex) => ({ team, teamIndex })), ({ team }) => ({ team: [team.title], character: team.members.flatMap((member) => { const name = characterNames.get(member.characterKey); @@ -2912,27 +2899,29 @@ function TeamEditor({ return name ?? []; }), ), - }, searchQuery, searchTarget)); + }), searchQuery, searchTarget); }, [artifacts, characters, searchQuery, searchTarget, teams, weapons]); const activeImportGroup = activeImportGuide?.groups[Number(guideImportCondition)] ?? activeImportGuide?.groups[0]; - const filteredImportTeams = useMemo(() => { + const importTeamSearch = useMemo(() => { const characterNames = new Map(characters.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); const weaponNames = new Map(weapons.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); const artifactNames = new Map(artifacts.map((item) => [item.key, [item.name, ...(item.aliases ?? [])]])); - return (activeImportGroup?.teams ?? []).filter((team) => matchesTeamSearch({ + return searchTeams(activeImportGroup?.teams ?? [], (team) => ({ team: [team.title], character: team.members.flatMap((member) => characterNames.get(member.characterKey) ?? [member.characterName]), weapon: team.members.flatMap((member) => member.weaponKey ? weaponNames.get(member.weaponKey) ?? [] : []), artifact: team.members.flatMap((member) => [member.artifactKey, member.secondArtifactKey].flatMap((key) => key ? artifactNames.get(key) ?? [] : [])), - }, guideTeamQuery, guideTeamSearchTarget)); + }), guideTeamQuery, guideTeamSearchTarget); }, [activeImportGroup, artifacts, characters, guideTeamQuery, guideTeamSearchTarget, weapons]); - const filteredImportGuides = useMemo(() => { - const query = guideListQuery.trim().toLocaleLowerCase(); - return (importGuides ?? []).filter((sourceGuide) => - `${sourceGuide.name} ${sourceGuide.characterKey}`.toLocaleLowerCase().includes(query), - ); + const importGuideSearch = useMemo(() => { + return searchItems(importGuides ?? [], guideListQuery, + (sourceGuide) => `${sourceGuide.name} ${sourceGuide.characterKey}`, + (sourceGuide) => [sourceGuide.name]); }, [guideListQuery, importGuides]); + const filteredTeams = teamSearch.items; + const filteredImportTeams = importTeamSearch.items; + const filteredImportGuides = importGuideSearch.items; const value = { groups: teamGroups.map((group) => ({ name: group.name.trim() || null, note: group.note.trim() || null, teams: group.teams.map((team) => ({ title: team.title, @@ -3351,6 +3340,7 @@ function TeamEditor({ ค้นหา Team +