Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion src/common/string/strip-diacritics.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,26 @@
// Characters that don't decompose via NFD normalization (e.g. Polish ł, Danish ø)
const NON_DECOMPOSABLE_MAP: Record<string, string> = {
ł: "l",
Ł: "L",
đ: "d",
Đ: "D",
ø: "o",
Ø: "O",
ħ: "h",
Ħ: "H",
ŧ: "t",
Ŧ: "T",
ı: "i",
ß: "ss",
};

const NON_DECOMPOSABLE_RE = new RegExp(
`[${Object.keys(NON_DECOMPOSABLE_MAP).join("")}]`,
"g"
);

export const stripDiacritics = (str: string) =>
str.normalize("NFD").replace(/[\u0300-\u036F]/g, "");
str
.replace(NON_DECOMPOSABLE_RE, (ch) => NON_DECOMPOSABLE_MAP[ch])
.normalize("NFD")
.replace(/[\u0300-\u036F]/g, "");
3 changes: 2 additions & 1 deletion src/components/ha-area-controls-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { computeRTL } from "../common/util/compute_rtl";
import type { LocalizeFunc } from "../common/translations/localize";
import {
multiTermSortedSearch,
normalizingGetFn,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import {
Expand Down Expand Up @@ -96,7 +97,7 @@ export class HaAreaControlsPicker extends LitElement {
private _createFuseIndex = (
items: AreaControlPickerItem[],
keys: FuseWeightedKey[]
) => Fuse.createIndex(keys, items);
) => Fuse.createIndex(keys, items, { getFn: normalizingGetFn });

private _domainFuseIndex = memoizeOne((items: AreaControlPickerItem[]) =>
this._createFuseIndex(items, this._domainSearchKeys)
Expand Down
21 changes: 16 additions & 5 deletions src/components/ha-navigation-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import { getPanelIcon, getPanelTitle } from "../data/panel";
import { findRelated, type RelatedResult } from "../data/search";
import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards";
import { computeAreaPath } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import { multiTermSortedSearch } from "../resources/fuseMultiTerm";
import {
multiTermSortedSearch,
normalizingGetFn,
} from "../resources/fuseMultiTerm";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { ActionRelatedContext } from "../panels/lovelace/components/hui-action-editor";
import "./ha-generic-picker";
Expand Down Expand Up @@ -180,16 +183,24 @@ export class HaNavigationPicker extends LitElement {

private _fuseIndexes = {
related: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items, {
getFn: normalizingGetFn,
})
),
dashboards: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items, {
getFn: normalizingGetFn,
})
),
views: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items, {
getFn: normalizingGetFn,
})
),
other_routes: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items, {
getFn: normalizingGetFn,
})
),
};

Expand Down
5 changes: 4 additions & 1 deletion src/components/ha-picker-combo-box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { localeContext, localizeContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import {
multiTermSortedSearch,
normalizingGetFn,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import { haStyleScrollbar } from "../resources/styles";
Expand Down Expand Up @@ -451,7 +452,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {

private _fuseIndex = memoizeOne(
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states, {
getFn: normalizingGetFn,
})
);

private _filterChanged = (ev: InputEvent) => {
Expand Down
3 changes: 2 additions & 1 deletion src/components/ha-target-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-hel
import {
multiTermSearch,
multiTermSortedSearch,
normalizingGetFn,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import type { HomeAssistant, ValueChangedEvent } from "../types";
Expand Down Expand Up @@ -165,7 +166,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}

private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
Fuse.createIndex(keys, states, { getFn: normalizingGetFn });

protected render() {
if (this.addOnTop) {
Expand Down
3 changes: 2 additions & 1 deletion src/dialogs/quick-bar/ha-quick-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
import type { RelatedResult } from "../../data/search";
import {
multiTermSortedSearch,
normalizingGetFn,
type FuseWeightedKey,
} from "../../resources/fuseMultiTerm";
import { buttonLinkStyle } from "../../resources/styles";
Expand Down Expand Up @@ -655,7 +656,7 @@ export class QuickBar extends LitElement {
private _generateActionCommandsMemoized = memoizeOne(generateActionCommands);

private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
Fuse.createIndex(keys, states, { getFn: normalizingGetFn });

private _fuseIndexes = {
entity: memoizeOne((states: PickerComboBoxItem[]) =>
Expand Down
5 changes: 4 additions & 1 deletion src/panels/config/apps/components/supervisor-apps-filter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import { stripDiacritics } from "../../../../common/string/strip-diacritics";
import type { StoreAddon } from "../../../../data/supervisor/store";
import { normalizingGetFn } from "../../../../resources/fuseMultiTerm";

export function filterAndSort(addons: StoreAddon[], filter: string) {
const options: IFuseOptions<StoreAddon> = {
Expand All @@ -9,7 +11,8 @@ export function filterAndSort(addons: StoreAddon[], filter: string) {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
getFn: normalizingGetFn,
};
const fuse = new Fuse(addons, options);
return fuse.search(filter).map((result) => result.item);
return fuse.search(stripDiacritics(filter)).map((result) => result.item);
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
import {
multiTermSearch,
multiTermSortedSearch,
normalizingGetFn,
type FuseWeightedKey,
} from "../../../../resources/fuseMultiTerm";
import { loadVirtualizer } from "../../../../resources/virtualizer";
Expand Down Expand Up @@ -452,7 +453,7 @@ export class HaAutomationAddSearch extends LitElement {
typeof item === "string" ? item : item.id;

private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
Fuse.createIndex(keys, states, { getFn: normalizingGetFn });

private _fuseIndexes = {
area: memoizeOne((states: PickerComboBoxItem[]) =>
Expand Down
5 changes: 4 additions & 1 deletion src/panels/config/automation/dialog-new-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { showScriptEditor } from "../../../data/script";
import {
type FuseWeightedKey,
multiTermSearch,
normalizingGetFn,
} from "../../../resources/fuseMultiTerm";
import { mdiHomeAssistant } from "../../../resources/home-assistant-logo-svg";
import { haStyle, haStyleDialog } from "../../../resources/styles";
Expand Down Expand Up @@ -123,7 +124,9 @@ class DialogNewAutomation extends LitElement {

private _blueprintFuseIndex = memoizeOne(
(blueprints: ReturnType<DialogNewAutomation["_processedBlueprints"]>) =>
Fuse.createIndex(BLUEPRINT_SEARCH_KEYS, blueprints)
Fuse.createIndex(BLUEPRINT_SEARCH_KEYS, blueprints, {
getFn: normalizingGetFn,
})
);

private _filteredBlueprints = memoizeOne(
Expand Down
10 changes: 7 additions & 3 deletions src/panels/config/integrations/dialog-add-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { stripDiacritics } from "../../../common/string/strip-diacritics";
import { normalizingGetFn } from "../../../resources/fuseMultiTerm";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-dialog";
import "../../../components/ha-icon-button-prev";
Expand Down Expand Up @@ -317,7 +319,9 @@ class AddIntegrationDialog extends LitElement {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
getFn: normalizingGetFn,
};
const normalizedFilter = stripDiacritics(filter);
const helpers = Object.entries(h).map(([domain, integration]) => ({
domain,
name: integration.name || domainToName(localize, domain),
Expand All @@ -328,13 +332,13 @@ class AddIntegrationDialog extends LitElement {
}));
return [
...new Fuse(integrations, options)
.search(filter)
.search(normalizedFilter)
.map((result) => result.item),
...new Fuse(yamlIntegrations, options)
.search(filter)
.search(normalizedFilter)
.map((result) => result.item),
...new Fuse(helpers, options)
.search(filter)
.search(normalizedFilter)
.map((result) => result.item),
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { stripDiacritics } from "../../../common/string/strip-diacritics";
import { normalizingGetFn } from "../../../resources/fuseMultiTerm";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
Expand Down Expand Up @@ -362,9 +364,12 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
getFn: normalizingGetFn,
};
const fuse = new Fuse(inProgress, options);
filteredEntries = fuse.search(filter).map((result) => result.item);
filteredEntries = fuse
.search(stripDiacritics(filter))
.map((result) => result.item);
} else {
filteredEntries = inProgress;
}
Expand Down
7 changes: 6 additions & 1 deletion src/panels/lovelace/editor/badge-editor/hui-badge-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import { stripDiacritics } from "../../../../common/string/strip-diacritics";
import { normalizingGetFn } from "../../../../resources/fuseMultiTerm";
import "../../../../components/ha-spinner";
import "../../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../../components/input/ha-input-search";
Expand Down Expand Up @@ -84,9 +86,12 @@ export class HuiBadgePicker extends LitElement {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
getFn: normalizingGetFn,
};
const fuse = new Fuse(badges, options);
badges = fuse.search(filter).map((result) => result.item);
badges = fuse
.search(stripDiacritics(filter))
.map((result) => result.item);
return badgeElements.filter((badgeElement: BadgeElement) =>
badges.includes(badgeElement.badge)
);
Expand Down
5 changes: 4 additions & 1 deletion src/panels/lovelace/editor/card-editor/hui-card-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import memoizeOne from "memoize-one";
import { storage } from "../../../../common/decorators/storage";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stringCompare } from "../../../../common/string/compare";
import { stripDiacritics } from "../../../../common/string/strip-diacritics";
import { normalizingGetFn } from "../../../../resources/fuseMultiTerm";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-spinner";
import "../../../../components/input/ha-input-search";
Expand Down Expand Up @@ -90,9 +92,10 @@ export class HuiCardPicker extends LitElement {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
getFn: normalizingGetFn,
};
const fuse = new Fuse(cards, options);
cards = fuse.search(filter).map((result) => result.item);
cards = fuse.search(stripDiacritics(filter)).map((result) => result.item);
return cardElements.filter((cardElement: CardElement) =>
cards.includes(cardElement.card)
);
Expand Down
34 changes: 31 additions & 3 deletions src/resources/fuseMultiTerm.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,45 @@
import type {
Expression,
FuseGetFunction,
FuseIndex,
FuseOptionKey,
FuseResult,
IFuseOptions,
} from "fuse.js";
import Fuse from "fuse.js";
import { stripDiacritics } from "../common/string/strip-diacritics";

export interface FuseWeightedKey {
name: string | string[];
weight: number;
}

/**
* Custom getFn that normalizes non-decomposable diacritics (e.g. ł→l, ø→o)
* before Fuse.js indexes values. Fuse's built-in ignoreDiacritics only handles
* characters that decompose via NFD, missing characters like Polish ł.
*/
export const normalizingGetFn: FuseGetFunction<any> = (
obj: unknown,
path: string | string[]
) => {
const value = Fuse.config.getFn(obj, path);
if (typeof value === "string") {
return stripDiacritics(value);
}
if (Array.isArray(value)) {
return value.map((v) => (typeof v === "string" ? stripDiacritics(v) : v));
}
return value;
};

const DEFAULT_OPTIONS: IFuseOptions<any> = {
ignoreDiacritics: true,
isCaseSensitive: false,
threshold: 0.3,
minMatchCharLength: 2,
ignoreLocation: true, // don't care where the pattern is
getFn: normalizingGetFn,
};

const DEFAULT_MIN_CHAR_LENGTH = 2;
Expand All @@ -37,6 +59,10 @@ function searchTerm<T>(
options?: IFuseOptions<T>,
minMatchCharLength?: number
) {
// Normalize non-decomposable diacritics in search terms to match getFn normalization
const normalizedSearch =
typeof search === "string" ? stripDiacritics(search) : search;

Comment on lines +62 to +65
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is not exported and only used in this file. Other callers already normalize terms before passing them in so the second strip is not useful.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is needed, for example HaAutomationAddSearch component directly passes filter from UI and I can't find any normalization out there.

In general it's not too DRY, IMO we can solve it in two phases

  1. Make actual fix, accept that the change is distributed (such implementation should not "leak" but we want small code increment)
  2. Create a facade that will wrap Fuse, for example class FuzzySearch. Inside instantionate Fuse, allow to inject options, make a public "filter" method that will normalize inside it and delegate to Fuse. Then replace Fuse everywhere and block access to Fuse import in the codebase in other places than FuzzySearch. I can implement this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied other suggestions

const fuse = new Fuse<T>(
items,
{
Expand All @@ -51,7 +77,7 @@ function searchTerm<T>(
fuseIndex
);

return fuse.search(search);
return fuse.search(normalizedSearch);
}

/**
Expand All @@ -77,7 +103,8 @@ export function multiTermSearch<T>(
const terms = search
.toLowerCase()
.split(" ")
.filter((t) => t.trim());
.filter((t) => t.trim())
.map((t) => stripDiacritics(t));

if (!terms.length) {
return items;
Expand Down Expand Up @@ -150,7 +177,8 @@ export function multiTermSortedSearch<T>(
const terms = search
.toLowerCase()
.split(" ")
.filter((t) => t.trim());
.filter((t) => t.trim())
.map((t) => stripDiacritics(t));

if (!terms.length) {
return items;
Expand Down
Loading
Loading