From f8eb746c7fe0abaebb62542f135c8de7e6335495 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:20:28 +0200 Subject: [PATCH 01/10] Add fusejs dep --- package.json | 1 + pnpm-lock.yaml | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/package.json b/package.json index 23520f8..6b33d0d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@astrojs/netlify": "^7.0.13", "@eslint-community/eslint-plugin-eslint-comments": "^4.7.2", "astro": "^6.4.4", + "fuse.js": "^7.5.0", "lit": "^3.3.2", "lucide": "^1.17.0", "undici": "^8.10.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bea414e..67fc763 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: astro: specifier: ^6.4.4 version: 6.4.7(@netlify/blobs@10.7.9)(@types/node@25.9.3)(jiti@2.7.0)(lightningcss@1.32.0)(rollup@4.62.0)(yaml@2.9.0) + fuse.js: + specifier: ^7.5.0 + version: 7.5.0 lit: specifier: ^3.3.2 version: 3.3.3 @@ -2678,6 +2681,10 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + fuse.js@7.5.0: + resolution: {integrity: sha512-sQtrEfA+ez/3G0cCZecF70oqpCRttCexYUG4mUrtWL49ULUzUyxokt5kyqwtKzj1270RaKih+hcP3qLcumccow==} + engines: {node: '>=10'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -7949,6 +7956,8 @@ snapshots: functions-have-names@1.2.3: {} + fuse.js@7.5.0: {} + generator-function@2.0.1: {} get-amd-module-type@6.0.2: From 41a6b2594e3f17ba0d16ab776dc63ca3ddfc8030 Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:25:02 +0200 Subject: [PATCH 02/10] Add fuse ranking and highlight utility logic, add tests --- src/utilities/search.ts | 133 ++++++++++++++++++++++++++++++++++++++++ test/search.test.ts | 111 +++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 src/utilities/search.ts create mode 100644 test/search.test.ts diff --git a/src/utilities/search.ts b/src/utilities/search.ts new file mode 100644 index 0000000..7051c45 --- /dev/null +++ b/src/utilities/search.ts @@ -0,0 +1,133 @@ +import Fuse from "fuse.js"; +import { html } from "lit"; +import type { TemplateResult } from "lit"; + +const OPTIONS = { + ignoreDiacritics: true, + ignoreLocation: true, + includeMatches: true, + includeScore: true, + // avoids single-character highlight fragments on fuzzy matches + minMatchCharLength: 2, + threshold: 0.3, +} as const; + +const tokens = (term: string): string[] => + term.split(/\s+/).filter((token) => token.length > 0); + +/** + * fuzzy, word-order independent filtering. ranks by amount of matched term + * words, then by match quality, then by count. an empty term keeps every item + */ +export const rank = ( + items: readonly T[], + term: string, + label: (item: T) => string, +): T[] => { + const byCount = (a: T, b: T) => (b.count ?? 0) - (a.count ?? 0); + const query = tokens(term); + if (query.length === 0) { + return items.toSorted(byCount); + } + + return ( + new Fuse(items, { ...OPTIONS, keys: [{ name: "label", getFn: label }] }) + .search({ $or: query.map((token) => ({ label: token })) }) + // ponytail: scores bucketed to tenths so that count decides between + // similarly good matches, a weighted blend if this ranks poorly + .toSorted( + (a, b) => + (b.matches?.length ?? 0) - (a.matches?.length ?? 0) || + Math.round((a.score ?? 0) * 10) - Math.round((b.score ?? 0) * 10) || + byCount(a.item, b.item), + ) + .map(({ item }) => item) + ); +}; + +/** + * moves term words naming exactly one category (prefix, typo or accent + * tolerant) into a category selection, as the api is unaware of translations + */ +export const extractCategories = ( + term: string, + categories: readonly { id: Id; label: string }[], +): { term: string; category: Set } => { + const fuse = new Fuse( + categories.flatMap(({ id, label }) => + tokens(label).map((word) => ({ id, word })), + ), + // distance 0 anchors matches at word start, "leak" shouldn't hit "cleaning". + // stricter than ranking, as a wrong category silently hides results: + // "nest" shouldn't hit "networking" + { + keys: ["word"], + ignoreDiacritics: true, + includeScore: true, + distance: 0, + threshold: 0.25, + }, + ); + // only the best matches compete: exact "weather" beats fuzzy "water". + // four letter words must match exactly, "nest" shouldn't hit "networking" + const search = (token: string) => { + const results = fuse.search(token); + const top = Math.min(...results.map(({ score }) => score ?? 1)); + return results.filter( + ({ score }) => + (score ?? 1) <= top + 0.05 && (token.length >= 5 || (score ?? 1) < 0.1), + ); + }; + // short words are mostly conjunctions ("and", "und", "y") + const candidates = tokens(term).map( + (token) => + [ + token, + new Set( + token.length >= 4 ? search(token).map(({ item }) => item.id) : [], + ), + ] as const, + ); + const category = new Set( + candidates.flatMap(([, ids]) => (ids.size === 1 ? [...ids] : [])), + ); + + return { + // also drops ambiguous words of an extracted category ("climate control") + term: candidates + .filter(([, ids]) => ![...ids].some((id) => category.has(id))) + .map(([token]) => token) + .join(" "), + category, + }; +}; + +/** wraps the segments of `text` matched by any term word in `` */ +export const highlight = ( + text: string, + term: string, +): (string | TemplateResult)[] => { + const ranges = tokens(term) + .flatMap((token) => { + const { isMatch, indices } = Fuse.match(token, text, OPTIONS); + return isMatch ? (indices ?? []) : []; + }) + .toSorted(([a], [b]) => a - b); + + const parts: (string | TemplateResult)[] = []; + let at = 0; + for (const [start, end] of ranges) { + if (end < at) { + continue; + } + const from = Math.max(start, at); + parts.push( + text.slice(at, from), + html`${text.slice(from, end + 1)}`, + ); + at = end + 1; + } + parts.push(text.slice(at)); + + return parts; +}; diff --git a/test/search.test.ts b/test/search.test.ts new file mode 100644 index 0000000..8daf94b --- /dev/null +++ b/test/search.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; + +import { extractCategories, rank } from "../src/utilities/search"; + +const manufacturers = [ + { name: "Samsung Electronics", count: 1510 }, + { name: "Samjin", count: 7 }, + { name: "AirBeamTV", count: 1 }, + { name: "Xiaomi", count: 140 }, + { name: "Aqara", count: 50 }, + { name: "Xiaomi Aqara", count: 28 }, + { name: "BSH Hausgeräte", count: 1 }, +]; +const names = (term: string) => + rank(manufacturers, term, ({ name }) => name).map(({ name }) => name); + +const categories = [ + { id: "lighting", label: "Lighting" }, + { id: "printing", label: "Printing" }, + { id: "cleaning", label: "Cleaning" }, + { id: "climate-control", label: "Climate control" }, + { id: "security-and-access-control", label: "Security and access control" }, + { id: "pool-and-spa", label: "Pool and spa" }, + { id: "weather", label: "Weather" }, + { id: "water-management", label: "Water management" }, + { id: "networking", label: "Networking" }, + { id: "monitoring", label: "Monitoring" }, + { id: "power-and-energy", label: "Power and energy" }, +]; +const extract = (term: string) => { + const result = extractCategories(term, categories); + return { term: result.term, category: [...result.category] }; +}; + +describe("rank", () => { + it("keeps everything, sorted by count, for an empty term", () => { + expect(names(" ")).toEqual( + manufacturers + .toSorted((a, b) => b.count - a.count) + .map(({ name }) => name), + ); + }); + + it("prefers count among equally good matches", () => { + expect(names("sam")[0]).toBe("Samsung Electronics"); + }); + + it("tolerates unrelated words, word order, typos and accents", () => { + expect(names("samsung tv")[0]).toBe("Samsung Electronics"); + expect(names("electronics samsung")[0]).toBe("Samsung Electronics"); + expect(names("aqara xiaomi")[0]).toBe("Xiaomi Aqara"); + expect(names("xiomi")[0]).toBe("Xiaomi"); + expect(names("hausgerate")).toEqual(["BSH Hausgeräte"]); + }); + + it("drops items matching no word", () => { + expect(names("zzzz")).toEqual([]); + }); +}); + +describe("extractCategories", () => { + it("moves (partial, misspelled) category names out of the term", () => { + expect(extract("hue lighting")).toEqual({ + term: "hue", + category: ["lighting"], + }); + expect(extract("philips hue light")).toEqual({ + term: "philips hue", + category: ["lighting"], + }); + expect(extract("ligthing")).toEqual({ term: "", category: ["lighting"] }); + expect(extract("print brother")).toEqual({ + term: "brother", + category: ["printing"], + }); + }); + + it("drops ambiguous words belonging to an extracted category", () => { + expect(extract("climate control")).toEqual({ + term: "", + category: ["climate-control"], + }); + }); + + it("lets an exact word win over fuzzy alternatives", () => { + expect(extract("weather station")).toEqual({ + term: "station", + category: ["weather"], + }); + }); + + it("does not mistake manufacturer names for categories", () => { + for (const name of ["Nest Labs", "Flo by Moen", "MINI", "Slim Devices"]) { + expect(extract(name).category).toEqual([]); + } + }); + + it("leaves ambiguous, short and mid-word matches alone", () => { + expect(extract("cont")).toEqual({ term: "cont", category: [] }); + expect(extract("and")).toEqual({ term: "and", category: [] }); + expect(extract("leak detector")).toEqual({ + term: "leak detector", + category: [], + }); + expect(extract("powerview hub")).toEqual({ + term: "powerview hub", + category: [], + }); + expect(extract("tp-link")).toEqual({ term: "tp-link", category: [] }); + }); +}); From 35c283679ddd48c829409d5dd57f150b964857ad Mon Sep 17 00:00:00 2001 From: Piero <15440183+pierosavi@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:26:58 +0200 Subject: [PATCH 03/10] Replacc search components logic with new ranking and highlight utilities --- src/islands/device-search.ts | 105 +++++++++++++++++------------------ src/islands/filter-modal.ts | 78 ++++++++++++-------------- src/islands/filter-sheet.ts | 58 +++++-------------- 3 files changed, 100 insertions(+), 141 deletions(-) diff --git a/src/islands/device-search.ts b/src/islands/device-search.ts index 86830cd..6bce8e0 100644 --- a/src/islands/device-search.ts +++ b/src/islands/device-search.ts @@ -8,7 +8,11 @@ import type { Ref } from "lit/directives/ref.js"; import { getDevices } from "../io/device.js"; import { m } from "../paraglide/messages.js"; import { localizeHref } from "../paraglide/runtime.js"; -import { browseFiltersToSearchParams } from "../types/browse/index.js"; +import { + browseFiltersToHref, + browseFiltersToQuery, + browseFiltersToSearchParams, +} from "../types/browse/index.js"; import { DeviceCategoryTopLevelId, topLevelCategoryResolver, @@ -27,7 +31,9 @@ import { render, } from "../utilities/presentation/index.js"; import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset.js"; +import { extractCategories, highlight, rank } from "../utilities/search.js"; import type { IoDimensionCategory } from "../io/dimension.js"; +import type { BrowseFilters } from "../types/browse/index.js"; import type { DevicePoly } from "../types/device/index.js"; type SectionCategory = { @@ -118,6 +124,7 @@ export class DeviceSearch extends LitElement { this._input?.addEventListener("keydown", this._onInputKeyDown); this._clearButton?.addEventListener("click", this._onClearButtonClick); + this._form?.addEventListener("submit", this._onFormSubmit); } override disconnectedCallback(): void { @@ -128,6 +135,7 @@ export class DeviceSearch extends LitElement { this._input?.removeEventListener("keydown", this._onInputKeyDown); this._clearButton?.removeEventListener("click", this._onClearButtonClick); + this._form?.removeEventListener("submit", this._onFormSubmit); this._resetFetched(); } @@ -153,18 +161,24 @@ export class DeviceSearch extends LitElement { return localizeHref(`/devices/${id}`); } - private _urlTerm(term: string): string { - const params = browseFiltersToSearchParams({ - category: new Set(), + // "hue lighting" becomes term "hue" in category "lighting" + private get _searchFilters(): BrowseFilters { + const { term, category } = extractCategories( + this._term, + DeviceCategoryTopLevelId.options.map((id) => ({ + id, + label: device.category(id).label, + })), + ); + + return { + term: term.length > 0 ? term : undefined, + category, categoryMode: "include", localOnly: false, manufacturer: new Set(), manufacturerMode: "include", - term, - }); - - const qs = params.toString(); - return localizeHref(`/browse${qs ? "?" + qs : ""}`); + }; } private _urlManufacturer(manufacturer: string): string { @@ -300,6 +314,12 @@ export class DeviceSearch extends LitElement { } }; + // without a selected row, carry the extracted category into the browse page + private _onFormSubmit = (event: SubmitEvent) => { + event.preventDefault(); + void navigate(localizeHref(browseFiltersToHref(this._searchFilters))); + }; + private _onClearButtonClick = () => { if (this._input !== null) { this._input.value = ""; @@ -319,13 +339,15 @@ export class DeviceSearch extends LitElement { } private async _fetchDevices() { - const term = this._term.trim(); this._fetchAbort?.abort(); const controller = new AbortController(); this._fetchAbort = controller; try { - const { devices, total } = await getDevices({ term }, controller.signal); + const { devices, total } = await getDevices( + browseFiltersToQuery(this._searchFilters), + controller.signal, + ); this._fetchedDevices = { devices: devices.map(devicePolyMap), total, @@ -362,24 +384,13 @@ export class DeviceSearch extends LitElement { return { kind: "category", - items: - typeof this.dimensions?.categories !== "undefined" - ? Object.entries(this.dimensions?.categories) - ?.flatMap(([id, category]) => { - if (!is(id)) { - return []; - } - const { label } = device.category(id); - if ( - !label.toLocaleLowerCase().includes(term.toLocaleLowerCase()) - ) { - return []; - } - - return [{ id, count: category.count }]; - }) - .toSorted((a, b) => b.count - a.count) - : [], + items: rank( + Object.entries(this.dimensions?.categories ?? {}).flatMap( + ([id, { count }]) => (is(id) ? [{ id, count }] : []), + ), + term, + ({ id }) => device.category(id).label, + ), }; } @@ -395,10 +406,11 @@ export class DeviceSearch extends LitElement { private _sectionManufacturer(term: string): SectionManufacturer { return { kind: "manufacturer", - items: - this.dimensions?.manufacturers.filter(({ name }) => - name.toLocaleLowerCase().includes(term.toLocaleLowerCase()), - ) ?? [], + items: rank( + this.dimensions?.manufacturers ?? [], + term, + ({ name }) => name, + ), }; } @@ -464,9 +476,7 @@ export class DeviceSearch extends LitElement { ${unsafeHTML(render(category, PresentationRenderPresetRoleIcon.withSize(16)))}
-
- ${DeviceSearch._highlight(category.label, this._term)} -
+
${highlight(category.label, this._term)}
${c.count} `; @@ -489,7 +499,7 @@ export class DeviceSearch extends LitElement { > ${unsafeHTML(render(generic("users"), PresentationRenderPresetRoleIcon.withSize(16)))}
-
${DeviceSearch._highlight(m.name, this._term)}
+
${highlight(m.name, this._term)}
${m.count} ${DeviceSearch._highlight(d.manufacturer, this._term)}${highlight(d.manufacturer, this._term)} - - ${DeviceSearch._highlight(device.name(d), this._term)} - + ${highlight(device.name(d), this._term)} ${d.count} void navigate(this._urlTerm(this._term))} + @click=${() => void navigate(localizeHref(browseFiltersToHref(this._searchFilters)))} > ${unsafeHTML(render(generic("arrow"), PresentationRenderPresetRoleIcon.withSize(16)))} @@ -567,19 +575,6 @@ export class DeviceSearch extends LitElement { return html`
${rendered}
`; } - private static _highlight(text: string, term: string) { - if (!term) { - return text; - } - const i = text.toLowerCase().indexOf(term.toLowerCase()); - if (i < 0) { - return text; - } - return html`${text.slice(0, i)}${text.slice(i, i + term.length)}${text.slice( - i + term.length, - )}`; - } - protected override render() { const sections = this._sections; return html`
{ + const matched = rank(options, query, ({ label }) => label); + if (groupBy === "none") { + return [["", matched] as const]; + } + + const groups = new Map(); + const ungrouped: FilterModalOption[] = []; + for (const option of matched) { + const letter = option.label.at(0)?.normalize().trim().toUpperCase(); + if (typeof letter === "undefined" || !/[A-Z]/.test(letter)) { + ungrouped.push(option); + continue; + } + const list = groups.get(letter) ?? []; + list.push(option); + groups.set(letter, list); + } + + return [ + ...[...groups.entries()].sort(([a], [b]) => a.localeCompare(b)), + ...(ungrouped.length > 0 ? [["#", ungrouped] as const] : []), + ]; +}; export class FilterModal extends LitElement { @property() dim = ""; @@ -121,44 +151,6 @@ export class FilterModal extends LitElement { this._draftSelected = next; } - private _groups(): (readonly [string, FilterModalOption[]])[] { - const term = this._query.trim().toLowerCase(); - const matched = - term.length > 0 - ? this.options.filter((option) => - option.label.toLowerCase().includes(term), - ) - : this.options; - if (!this.lettergroups) { - return [["", matched] as const]; - } - - if (this._groupBy === "none") { - const sorted = [...matched].sort( - (a, b) => (b.count ?? 0) - (a.count ?? 0), - ); - return [["", sorted] as const]; - } - - const groups = new Map(); - const ungrouped: FilterModalOption[] = []; - for (const option of matched) { - const letter = option.label.at(0)?.normalize().trim().toUpperCase(); - if (typeof letter === "undefined" || !/[A-Z]/.test(letter)) { - ungrouped.push(option); - continue; - } - const list = groups.get(letter) ?? []; - list.push(option); - groups.set(letter, list); - } - - return [ - ...[...groups.entries()].sort(([a], [b]) => a.localeCompare(b)), - ...(ungrouped.length > 0 ? [["#", ungrouped] as const] : []), - ]; - } - private _renderModeButton(mode: FilterModalMode, text: string) { return html`