diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index 81e460b0c4..0dc6e05a5e 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -16,8 +16,10 @@ import memoizeOne from "memoize-one"; import { restoreScroll } from "../../common/decorators/restore-scroll"; import { fireEvent } from "../../common/dom/fire_event"; import { stringCompare } from "../../common/string/compare"; +import type { LocalizeFunc } from "../../common/translations/localize"; import { debounce } from "../../common/util/debounce"; import { groupBy } from "../../common/util/group-by"; +import { nextRender } from "../../common/util/render-status"; import { haStyleScrollbar } from "../../resources/styles"; import { loadVirtualizer } from "../../resources/virtualizer"; import type { HomeAssistant } from "../../types"; @@ -26,8 +28,6 @@ import type { HaCheckbox } from "../ha-checkbox"; import "../ha-svg-icon"; import "../search-input"; import { filterData, sortData } from "./sort-filter"; -import type { LocalizeFunc } from "../../common/translations/localize"; -import { nextRender } from "../../common/util/render-status"; export interface RowClickedEvent { id: string; diff --git a/src/components/data-table/sort-filter-worker.ts b/src/components/data-table/sort-filter-worker.ts index 1c08401d5e..14e52f1288 100644 --- a/src/components/data-table/sort-filter-worker.ts +++ b/src/components/data-table/sort-filter-worker.ts @@ -1,9 +1,9 @@ import { expose } from "comlink"; -import Fuse from "fuse.js"; +import Fuse, { type FuseOptionKey } from "fuse.js"; import memoizeOne from "memoize-one"; import { ipCompare, stringCompare } from "../../common/string/compare"; import { stripDiacritics } from "../../common/string/strip-diacritics"; -import { HaFuse } from "../../resources/fuse"; +import { multiTermSearch } from "../../resources/fuseMultiTerm"; import type { ClonedDataTableColumnData, DataTableRowData, @@ -11,9 +11,10 @@ import type { SortingDirection, } from "./ha-data-table"; -const fuseIndex = memoizeOne( - (data: DataTableRowData[], columns: SortableColumnContainer) => { +const getSearchKeys = memoizeOne( + (columns: SortableColumnContainer): FuseOptionKey[] => { const searchKeys = new Set(); + Object.entries(columns).forEach(([key, column]) => { if (column.filterable) { searchKeys.add( @@ -23,10 +24,15 @@ const fuseIndex = memoizeOne( ); } }); - return Fuse.createIndex([...searchKeys], data); + return Array.from(searchKeys); } ); +const fuseIndex = memoizeOne( + (data: DataTableRowData[], keys: FuseOptionKey[]) => + Fuse.createIndex(keys, data) +); + const filterData = ( data: DataTableRowData[], columns: SortableColumnContainer, @@ -38,21 +44,13 @@ const filterData = ( return data; } - const index = fuseIndex(data, columns); + const keys = getSearchKeys(columns); - const fuse = new HaFuse( - data, - { shouldSort: false, minMatchCharLength: 1 }, - index - ); + const index = fuseIndex(data, keys); - const searchResults = fuse.multiTermsSearch(filter); - - if (searchResults) { - return searchResults.map((result) => result.item); - } - - return data; + return multiTermSearch(data, filter, keys, index, { + threshold: 0.2, // reduce fuzzy matches in data tables + }); }; const sortData = ( diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index baa3161ded..b3542e8bb1 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -9,6 +9,7 @@ import { computeDeviceName } from "../../common/entity/compute_device_name"; import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; import { + deviceComboBoxKeys, getDevices, type DevicePickerItem, type DeviceRegistryEntry, @@ -216,6 +217,7 @@ export class HaDevicePicker extends LitElement { .getItems=${this._getItems} .hideClearIcon=${this.hideClearIcon} .valueRenderer=${valueRenderer} + .searchKeys=${deviceComboBoxKeys} .unknownItemText=${this.hass.localize( "ui.components.device-picker.unknown" )} diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index ce107026e9..1f0a70a6ba 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -9,6 +9,7 @@ import { isValidEntityId } from "../../common/entity/valid_entity_id"; import { computeRTL } from "../../common/util/compute_rtl"; import type { HaEntityPickerEntityFilterFunc } from "../../data/entity"; import { + entityComboBoxKeys, getEntities, type EntityComboBoxItem, } from "../../data/entity_registry"; @@ -288,6 +289,7 @@ export class HaEntityPicker extends LitElement { .hideClearIcon=${this.hideClearIcon} .searchFn=${this._searchFn} .valueRenderer=${this._valueRenderer} + .searchKeys=${entityComboBoxKeys} .addButtonLabel=${this.addButton ? this.hass.localize("ui.components.entity.entity-picker.add") : undefined} diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index b57bfc0f3b..8486ba6341 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -38,9 +38,21 @@ type StatisticItemType = "entity" | "external" | "no_state"; interface StatisticComboBoxItem extends PickerComboBoxItem { statistic_id?: string; stateObj?: HassEntity; + domainName?: string; type?: StatisticItemType; } +const SEARCH_KEYS = [ + { name: "label", weight: 10 }, + { name: "search_labels.entityName", weight: 10 }, + { name: "search_labels.friendlyName", weight: 9 }, + { name: "search_labels.deviceName", weight: 8 }, + { name: "search_labels.areaName", weight: 6 }, + { name: "search_labels.domainName", weight: 4 }, + { name: "statisticId", weight: 3 }, + { name: "id", weight: 2 }, +]; + @customElement("ha-statistic-picker") export class HaStatisticPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -233,7 +245,6 @@ export class HaStatisticPicker extends LitElement { ), type, sorting_label: [sortingPrefix, label].join("_"), - search_labels: [label, id], icon_path: mdiShape, }); } else if (type === "external") { @@ -246,7 +257,7 @@ export class HaStatisticPicker extends LitElement { secondary: domainName, type, sorting_label: [sortingPrefix, label].join("_"), - search_labels: [label, domainName, id], + search_labels: { label, domainName }, icon_path: mdiChartLine, }); } @@ -280,13 +291,12 @@ export class HaStatisticPicker extends LitElement { stateObj: stateObj, type: "entity", sorting_label: [sortingPrefix, deviceName, entityName].join("_"), - search_labels: [ - entityName, - deviceName, - areaName, + search_labels: { + entityName: entityName || null, + deviceName: deviceName || null, + areaName: areaName || null, friendlyName, - id, - ].filter(Boolean) as string[], + }, }); }); @@ -361,13 +371,13 @@ export class HaStatisticPicker extends LitElement { stateObj: stateObj, type: "entity", sorting_label: [sortingPrefix, deviceName, entityName].join("_"), - search_labels: [ - entityName, - deviceName, - areaName, + search_labels: { + entityName: entityName || null, + deviceName: deviceName || null, + areaName: areaName || null, friendlyName, statisticId, - ].filter(Boolean) as string[], + }, }; } @@ -394,7 +404,7 @@ export class HaStatisticPicker extends LitElement { secondary: domainName, type: "external", sorting_label: [sortingPrefix, label].join("_"), - search_labels: [label, domainName, statisticId], + search_labels: { label, domainName, statisticId }, icon_path: mdiChartLine, }; } @@ -409,7 +419,7 @@ export class HaStatisticPicker extends LitElement { secondary: this.hass.localize("ui.components.statistic-picker.no_state"), type: "no_state", sorting_label: [sortingPrefix, label].join("_"), - search_labels: [label, statisticId], + search_labels: { label, statisticId }, icon_path: mdiShape, }; } @@ -475,6 +485,7 @@ export class HaStatisticPicker extends LitElement { .searchFn=${this._searchFn} .valueRenderer=${this._valueRenderer} .helper=${this.helper} + .searchKeys=${SEARCH_KEYS} .unknownItemText=${this.hass.localize( "ui.components.statistic-picker.unknown" )} diff --git a/src/components/ha-area-floor-picker.ts b/src/components/ha-area-floor-picker.ts deleted file mode 100644 index ca342a57d6..0000000000 --- a/src/components/ha-area-floor-picker.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { mdiTextureBox } from "@mdi/js"; -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import type { HassEntity } from "home-assistant-js-websocket"; -import type { TemplateResult } from "lit"; -import { LitElement, html, nothing } from "lit"; -import { customElement, property, query } from "lit/decorators"; -import { styleMap } from "lit/directives/style-map"; -import memoizeOne from "memoize-one"; -import { fireEvent } from "../common/dom/fire_event"; -import { computeAreaName } from "../common/entity/compute_area_name"; -import { computeFloorName } from "../common/entity/compute_floor_name"; -import { computeRTL } from "../common/util/compute_rtl"; -import { - getAreasAndFloors, - type AreaFloorValue, - type FloorComboBoxItem, -} from "../data/area_floor"; -import type { HomeAssistant, ValueChangedEvent } from "../types"; -import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; -import "./ha-combo-box-item"; -import "./ha-floor-icon"; -import "./ha-generic-picker"; -import type { HaGenericPicker } from "./ha-generic-picker"; -import "./ha-icon-button"; -import type { PickerValueRenderer } from "./ha-picker-field"; -import "./ha-svg-icon"; -import "./ha-tree-indicator"; - -const SEPARATOR = "________"; - -@customElement("ha-area-floor-picker") -export class HaAreaFloorPicker extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() public label?: string; - - @property({ attribute: false }) public value?: AreaFloorValue; - - @property() public helper?: string; - - @property() public placeholder?: string; - - @property({ type: String, attribute: "search-label" }) - public searchLabel?: string; - - /** - * Show only areas with entities from specific domains. - * @type {Array} - * @attr include-domains - */ - @property({ type: Array, attribute: "include-domains" }) - public includeDomains?: string[]; - - /** - * Show no areas with entities of these domains. - * @type {Array} - * @attr exclude-domains - */ - @property({ type: Array, attribute: "exclude-domains" }) - public excludeDomains?: string[]; - - /** - * Show only areas with entities of these device classes. - * @type {Array} - * @attr include-device-classes - */ - @property({ type: Array, attribute: "include-device-classes" }) - public includeDeviceClasses?: string[]; - - /** - * List of areas to be excluded. - * @type {Array} - * @attr exclude-areas - */ - @property({ type: Array, attribute: "exclude-areas" }) - public excludeAreas?: string[]; - - /** - * List of floors to be excluded. - * @type {Array} - * @attr exclude-floors - */ - @property({ type: Array, attribute: "exclude-floors" }) - public excludeFloors?: string[]; - - @property({ attribute: false }) - public deviceFilter?: HaDevicePickerDeviceFilterFunc; - - @property({ attribute: false }) - public entityFilter?: (entity: HassEntity) => boolean; - - @property({ type: Boolean }) public disabled = false; - - @property({ type: Boolean }) public required = false; - - @query("ha-generic-picker") private _picker?: HaGenericPicker; - - public async open() { - await this.updateComplete; - await this._picker?.open(); - } - - private _valueRenderer: PickerValueRenderer = (value: string) => { - const item = this._parseValue(value); - - const area = item.type === "area" && this.hass.areas[value]; - - if (area) { - const areaName = computeAreaName(area); - return html` - ${area.icon - ? html`` - : html``} - ${areaName} - `; - } - - const floor = item.type === "floor" && this.hass.floors[value]; - - if (floor) { - const floorName = computeFloorName(floor); - return html` - - ${floorName} - `; - } - - return html` - - ${value} - `; - }; - - private _rowRenderer: ComboBoxLitRenderer = ( - item, - { index }, - combobox - ) => { - const nextItem = combobox.filteredItems?.[index + 1]; - const isLastArea = - !nextItem || - nextItem.type === "floor" || - (nextItem.type === "area" && !nextItem.area?.floor_id); - - const rtl = computeRTL(this.hass); - - const hasFloor = item.type === "area" && item.area?.floor_id; - - return html` - - ${item.type === "area" && hasFloor - ? html` - - ` - : nothing} - ${item.type === "floor" && item.floor - ? html`` - : item.icon - ? html`` - : html``} - ${item.primary} - - `; - }; - - private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors); - - private _getItems = () => - this._getAreasAndFloorsMemoized( - this.hass.states, - this.hass.floors, - this.hass.areas, - this.hass.devices, - this.hass.entities, - this._formatValue, - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.excludeAreas, - this.excludeFloors - ); - - private _formatValue = memoizeOne((value: AreaFloorValue): string => - [value.type, value.id].join(SEPARATOR) - ); - - private _parseValue = memoizeOne((value: string): AreaFloorValue => { - const [type, id] = value.split(SEPARATOR); - - return { id, type: type as "floor" | "area" }; - }); - - protected render(): TemplateResult { - const placeholder = - this.placeholder ?? this.hass.localize("ui.components.area-picker.area"); - - const value = this.value ? this._formatValue(this.value) : undefined; - - return html` - - - `; - } - - private _valueChanged(ev: ValueChangedEvent) { - ev.stopPropagation(); - const value = ev.detail.value; - - if (!value) { - this._setValue(undefined); - return; - } - - const selected = this._parseValue(value); - this._setValue(selected); - } - - private _setValue(value?: AreaFloorValue) { - this.value = value; - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - } -} - -declare global { - interface HTMLElementTagNameMap { - "ha-area-floor-picker": HaAreaFloorPicker; - } -} diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index fd0bcd77ce..a031fd224d 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -30,6 +30,12 @@ import "./ha-svg-icon"; const ADD_NEW_ID = "___ADD_NEW___"; +const SEARCH_KEYS = [ + { name: "areaName", weight: 10 }, + { name: "aliases", weight: 8 }, + { name: "floorName", weight: 6 }, + { name: "id", weight: 3 }, +]; @customElement("ha-area-picker") export class HaAreaPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -291,12 +297,12 @@ export class HaAreaPicker extends LitElement { icon: area.icon || undefined, icon_path: area.icon ? undefined : mdiTextureBox, sorting_label: areaName, - search_labels: [ - areaName, - floorName, - area.area_id, - ...area.aliases, - ].filter((v): v is string => Boolean(v)), + search_labels: { + areaName: areaName || null, + floorName: floorName || null, + id: area.area_id, + aliases: area.aliases.join(" "), + }, }; }); @@ -379,6 +385,7 @@ export class HaAreaPicker extends LitElement { .getAdditionalItems=${this._getAdditionalItems} .valueRenderer=${valueRenderer} .addButtonLabel=${this.addButtonLabel} + .searchKeys=${SEARCH_KEYS} .unknownItemText=${this.hass.localize( "ui.components.area-picker.unknown" )} diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index b57c254edc..9627b59e55 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -35,6 +35,12 @@ import "./ha-svg-icon"; const ADD_NEW_ID = "___ADD_NEW___"; +const SEARCH_KEYS = [ + { name: "floorName", weight: 10 }, + { name: "aliases", weight: 8 }, + { name: "floor_id", weight: 3 }, +]; + interface FloorComboBoxItem extends PickerComboBoxItem { floor?: FloorRegistryEntry; } @@ -286,9 +292,11 @@ export class HaFloorPicker extends LitElement { primary: floorName, floor: floor, sorting_label: floor.level?.toString() || "zzzzz", - search_labels: [floorName, floor.floor_id, ...floor.aliases].filter( - (v): v is string => Boolean(v) - ), + search_labels: { + floorName, + floor_id: floor.floor_id, + aliases: floor.aliases.join(" "), + }, }; }); @@ -393,6 +401,7 @@ export class HaFloorPicker extends LitElement { .getAdditionalItems=${this._getAdditionalItems} .valueRenderer=${valueRenderer} .rowRenderer=${this._rowRenderer} + .searchKeys=${SEARCH_KEYS} .unknownItemText=${this.hass.localize( "ui.components.floor-picker.unknown" )} diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index a198ab49ff..46d8fb9622 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -7,6 +7,7 @@ import { ifDefined } from "lit/directives/if-defined"; import memoizeOne from "memoize-one"; import { tinykeys } from "tinykeys"; import { fireEvent } from "../common/dom/fire_event"; +import type { FuseWeightedKey } from "../resources/fuseMultiTerm"; import type { HomeAssistant } from "../types"; import "./ha-bottom-sheet"; import "./ha-button"; @@ -66,6 +67,9 @@ export class HaGenericPicker extends LitElement { @property({ attribute: false }) public searchFn?: PickerComboBoxSearchFn; + @property({ attribute: false }) + public searchKeys?: FuseWeightedKey[]; + @property({ attribute: false }) public notFoundLabel?: string | ((search: string) => string); @@ -235,6 +239,7 @@ export class HaGenericPicker extends LitElement { .sections=${this.sections} .sectionTitleFunction=${this.sectionTitleFunction} .selectedSection=${this.selectedSection} + .searchKeys=${this.searchKeys} > `; } diff --git a/src/components/ha-label-picker.ts b/src/components/ha-label-picker.ts index d28a7d47a8..f52e6eeeb5 100644 --- a/src/components/ha-label-picker.ts +++ b/src/components/ha-label-picker.ts @@ -15,6 +15,7 @@ import type { LabelRegistryEntry } from "../data/label_registry"; import { createLabelRegistryEntry, getLabels, + labelComboBoxKeys, subscribeLabelRegistry, } from "../data/label_registry"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; @@ -237,6 +238,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { .getItems=${this._getItems} .getAdditionalItems=${this._getAdditionalItems} .valueRenderer=${valueRenderer} + .searchKeys=${labelComboBoxKeys} @value-changed=${this._valueChanged} > diff --git a/src/components/ha-language-picker.ts b/src/components/ha-language-picker.ts index 1f3f824be5..5b3fdedae9 100644 --- a/src/components/ha-language-picker.ts +++ b/src/components/ha-language-picker.ts @@ -40,14 +40,12 @@ export const getLanguageOptions = ( return { id: lang, primary, - search_labels: [primary], }; }); } else if (locale) { options = languages.map((lang) => ({ id: lang, primary: formatLanguageCode(lang, locale), - search_labels: [formatLanguageCode(lang, locale)], })); } diff --git a/src/components/ha-picker-combo-box.ts b/src/components/ha-picker-combo-box.ts index c47fab7440..1402763b07 100644 --- a/src/components/ha-picker-combo-box.ts +++ b/src/components/ha-picker-combo-box.ts @@ -15,7 +15,10 @@ import { tinykeys } from "tinykeys"; import { fireEvent } from "../common/dom/fire_event"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin"; -import { HaFuse } from "../resources/fuse"; +import { + multiTermSortedSearch, + type FuseWeightedKey, +} from "../resources/fuseMultiTerm"; import { haStyleScrollbar } from "../resources/styles"; import { loadVirtualizer } from "../resources/virtualizer"; import type { HomeAssistant } from "../types"; @@ -26,11 +29,26 @@ import "./ha-icon"; import "./ha-textfield"; import type { HaTextField } from "./ha-textfield"; +export const DEFAULT_SEARCH_KEYS: FuseWeightedKey[] = [ + { + name: "primary", + weight: 10, + }, + { + name: "secondary", + weight: 7, + }, + { + name: "id", + weight: 3, + }, +]; + export interface PickerComboBoxItem { id: string; primary: string; secondary?: string; - search_labels?: string[]; + search_labels?: Record; sorting_label?: string; icon_path?: string; icon?: string; @@ -77,6 +95,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) { @property() public value?: string; + @property({ attribute: false }) + public searchKeys?: FuseWeightedKey[]; + @state() private _listScrolled = false; @property({ attribute: false }) @@ -291,6 +312,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) { }; private _renderItem = (item: PickerComboBoxItem | string, index: number) => { + if (!item) { + return nothing; + } if (item === "padding") { return html`
`; } @@ -351,8 +375,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) { fireEvent(this, "value-changed", { value: newValue }); }; - private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) => - Fuse.createIndex(["search_labels"], states) + private _fuseIndex = memoizeOne( + (states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) => + Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states) ); private _filterChanged = (ev: Event) => { @@ -368,34 +393,26 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) { return; } - const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]); - const fuse = new HaFuse( + const index = this._fuseIndex( this._allItems as PickerComboBoxItem[], - { - shouldSort: false, - minMatchCharLength: Math.min(searchString.length, 2), - }, - index + this.searchKeys ); - const results = fuse.multiTermsSearch(searchString); - let filteredItems = [...this._allItems]; + let filteredItems = multiTermSortedSearch( + this._allItems as PickerComboBoxItem[], + searchString, + this.searchKeys || DEFAULT_SEARCH_KEYS, + (item) => item.id, + index + ) as (PickerComboBoxItem | string)[]; - if (results) { - const items: (PickerComboBoxItem | string)[] = results.map( - (result) => result.item - ); - - if (!items.length) { - filteredItems.push(NO_ITEMS_AVAILABLE_ID); - } - - const additionalItems = this._getAdditionalItems(); - items.push(...additionalItems); - - filteredItems = items; + if (!filteredItems.length) { + filteredItems.push(NO_ITEMS_AVAILABLE_ID); } + const additionalItems = this._getAdditionalItems(); + filteredItems.push(...additionalItems); + if (this.searchFn) { filteredItems = this.searchFn( searchString, @@ -602,7 +619,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) { } private _keyFunction = (item: PickerComboBoxItem | string) => - typeof item === "string" ? item : item.id; + typeof item === "string" ? item : item?.id; private _getInitialSelectedIndex() { if (!this._virtualizerElement || !this.value) { diff --git a/src/components/ha-service-picker.ts b/src/components/ha-service-picker.ts index 6875943025..9b7a91c448 100644 --- a/src/components/ha-service-picker.ts +++ b/src/components/ha-service-picker.ts @@ -21,6 +21,13 @@ interface ServiceComboBoxItem extends PickerComboBoxItem { service_id?: string; } +const SEARCH_KEYS = [ + { name: "name", weight: 10 }, + { name: "description", weight: 8 }, + { name: "domainName", weight: 6 }, + { name: "serviceId", weight: 3 }, +]; + @customElement("ha-service-picker") class HaServicePicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -141,6 +148,7 @@ class HaServicePicker extends LitElement { this.hass.localize, this.hass.services )} + .searchKeys=${SEARCH_KEYS} .unknownItemText=${this.hass.localize( "ui.components.service-picker.unknown" )} @@ -197,9 +205,7 @@ class HaServicePicker extends LitElement { secondary: description, domain_name: domainName, service_id: serviceId, - search_labels: [serviceId, domainName, name, description].filter( - Boolean - ), + search_labels: { serviceId, domainName, name, description }, sorting_label: serviceId, }); } diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 0d77a72695..171835f7da 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -15,17 +15,30 @@ import { fireEvent } from "../common/dom/fire_event"; import { isValidEntityId } from "../common/entity/valid_entity_id"; import { computeRTL } from "../common/util/compute_rtl"; import { + areaFloorComboBoxKeys, getAreasAndFloors, type AreaFloorValue, type FloorComboBoxItem, } from "../data/area_floor"; import { getConfigEntries, type ConfigEntry } from "../data/config_entries"; import { labelsContext } from "../data/context"; -import { getDevices, type DevicePickerItem } from "../data/device_registry"; +import { + deviceComboBoxKeys, + getDevices, + type DevicePickerItem, +} from "../data/device_registry"; import type { HaEntityPickerEntityFilterFunc } from "../data/entity"; -import { getEntities, type EntityComboBoxItem } from "../data/entity_registry"; +import { + entityComboBoxKeys, + getEntities, + type EntityComboBoxItem, +} from "../data/entity_registry"; import { domainToName } from "../data/integration"; -import { getLabels, type LabelRegistryEntry } from "../data/label_registry"; +import { + getLabels, + labelComboBoxKeys, + type LabelRegistryEntry, +} from "../data/label_registry"; import { areaMeetsFilter, deviceMeetsFilter, @@ -37,7 +50,11 @@ import { import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { isHelperDomain } from "../panels/config/helpers/const"; import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail"; -import { HaFuse } from "../resources/fuse"; +import { + multiTermSearch, + multiTermSortedSearch, + type FuseWeightedKey, +} from "../resources/fuseMultiTerm"; import type { HomeAssistant } from "../types"; import { brandsUrl } from "../util/brands-url"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; @@ -113,16 +130,16 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { private _fuseIndexes = { area: memoizeOne((states: FloorComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, areaFloorComboBoxKeys) ), entity: memoizeOne((states: EntityComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, entityComboBoxKeys) ), device: memoizeOne((states: DevicePickerItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, deviceComboBoxKeys) ), label: memoizeOne((states: PickerComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, labelComboBoxKeys) ), }; @@ -134,8 +151,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { } } - private _createFuseIndex = (states) => - Fuse.createIndex(["search_labels"], states); + private _createFuseIndex = (states, keys: FuseWeightedKey[]) => + Fuse.createIndex(keys, states); protected render() { if (this.addOnTop) { @@ -735,8 +752,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { "entity", entityItems, searchTerm, - (item: EntityComboBoxItem) => - item.stateObj?.entity_id === searchTerm + entityComboBoxKeys ) as EntityComboBoxItem[]; } @@ -765,7 +781,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { ); if (searchTerm) { - deviceItems = this._filterGroup("device", deviceItems, searchTerm); + deviceItems = this._filterGroup( + "device", + deviceItems, + searchTerm, + deviceComboBoxKeys + ); } if (!filterType && deviceItems.length) { @@ -799,7 +820,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { areasAndFloors = this._filterGroup( "area", areasAndFloors, - searchTerm + searchTerm, + areaFloorComboBoxKeys, + false ) as FloorComboBoxItem[]; } @@ -844,7 +867,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { ); if (searchTerm) { - labels = this._filterGroup("label", labels, searchTerm); + labels = this._filterGroup( + "label", + labels, + searchTerm, + labelComboBoxKeys + ); } if (!filterType && labels.length) { @@ -863,40 +891,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { type: TargetType, items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[], searchTerm: string, - checkExact?: ( - item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem - ) => boolean + weightedKeys: FuseWeightedKey[], + sort = true ) { const fuseIndex = this._fuseIndexes[type](items); - const fuse = new HaFuse( - items, - { - shouldSort: false, - minMatchCharLength: Math.min(searchTerm.length, 2), - }, - fuseIndex - ); - const results = fuse.multiTermsSearch(searchTerm); - let filteredItems = items; - if (results) { - filteredItems = results.map((result) => result.item); + if (sort) { + return multiTermSortedSearch( + items, + searchTerm, + weightedKeys, + (item) => item.id, + fuseIndex + ); } - if (!checkExact) { - return filteredItems; - } - - // If there is exact match for entity id, put it first - const index = filteredItems.findIndex((item) => checkExact(item)); - if (index === -1) { - return filteredItems; - } - - const [exactMatch] = filteredItems.splice(index, 1); - filteredItems.unshift(exactMatch); - - return filteredItems; + return multiTermSearch(items, searchTerm, weightedKeys, fuseIndex, { + ignoreLocation: true, + }); } private _getAdditionalItems = () => this._getCreateItems(this.createDomains); diff --git a/src/components/user/ha-user-picker.ts b/src/components/user/ha-user-picker.ts index f0288104e2..49f6d6205a 100644 --- a/src/components/user/ha-user-picker.ts +++ b/src/components/user/ha-user-picker.ts @@ -17,6 +17,12 @@ interface UserComboBoxItem extends PickerComboBoxItem { user?: User; } +const SEARCH_KEYS = [ + { name: "primary", weight: 10 }, + { name: "search_labels.username", weight: 6 }, + { name: "id", weight: 3 }, +]; + @customElement("ha-user-picker") class HaUserPicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -109,9 +115,7 @@ class HaUserPicker extends LitElement { id: user.id, primary: user.name, domain_name: user.name, - search_labels: [user.name, user.id, user.username].filter( - Boolean - ) as string[], + search_labels: { username: user.username }, sorting_label: user.name, user, })); @@ -134,6 +138,7 @@ class HaUserPicker extends LitElement { .getItems=${this._getItems} .valueRenderer=${this._valueRenderer} .rowRenderer=${this._rowRenderer} + .searchKeys=${SEARCH_KEYS} .unknownItemText=${this.hass.localize( "ui.components.user-picker.unknown" )} diff --git a/src/data/area_floor.ts b/src/data/area_floor.ts index bbaf4226b2..821b3978fb 100644 --- a/src/data/area_floor.ts +++ b/src/data/area_floor.ts @@ -4,6 +4,7 @@ import { computeDomain } from "../common/entity/compute_domain"; import { computeFloorName } from "../common/entity/compute_floor_name"; import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; +import type { FuseWeightedKey } from "../resources/fuseMultiTerm"; import type { HomeAssistant } from "../types"; import type { AreaRegistryEntry } from "./area_registry"; import { @@ -26,7 +27,8 @@ export interface FloorNestedComboBoxItem extends PickerComboBoxItem { areas: FloorComboBoxItem[]; } -export interface UnassignedAreasFloorComboBoxItem extends PickerComboBoxItem { +export interface UnassignedAreasFloorComboBoxItem { + id: undefined; areas: FloorComboBoxItem[]; } @@ -98,6 +100,29 @@ export const getAreasAndFloors = ( excludeFloors ) as FloorComboBoxItem[]; +export const areaFloorComboBoxKeys: FuseWeightedKey[] = [ + { + name: "search_labels.name", + weight: 10, + }, + { + name: "search_labels.aliases", + weight: 8, + }, + { + name: "search_labels.floorName", + weight: 6, + }, + { + name: "search_labels.relatedAreas", + weight: 4, + }, + { + name: "search_labels.id", + weight: 3, + }, +]; + const getAreasAndFloorsItems = ( states: HomeAssistant["states"], haFloors: HomeAssistant["floors"], @@ -304,12 +329,12 @@ const getAreasAndFloorsItems = ( primary: floorName, floor: floor, icon: floor.icon || undefined, - search_labels: [ - floor.floor_id, - floorName, - ...floor.aliases, - ...areaSearchLabels, - ], + search_labels: { + id: floor.floor_id, + name: floorName || null, + aliases: floor.aliases.join(", ") || null, + relatedAreas: areaSearchLabels.join(" ") || null, + }, }; items.push(floorItem); @@ -322,11 +347,12 @@ const getAreasAndFloorsItems = ( primary: areaName || area.area_id, area: area, icon: area.icon || undefined, - search_labels: [ - area.area_id, - ...(areaName ? [areaName] : []), - ...area.aliases, - ], + search_labels: { + id: area.area_id, + name: areaName || null, + aliases: area.aliases.join(", ") || null, + floorName: floorName || null, + }, }; }); @@ -339,19 +365,24 @@ const getAreasAndFloorsItems = ( const unassignedAreaItems = hierarchy.areas.map((areaId) => { const area = haAreas[areaId]; - const areaName = computeAreaName(area) || area.area_id; + const areaName = computeAreaName(area); return { id: formatId({ id: area.area_id, type: "area" }), type: "area" as const, - primary: areaName, + primary: areaName || area.area_id, area: area, icon: area.icon || undefined, - search_labels: [area.area_id, areaName, ...area.aliases], + search_labels: { + id: area.area_id, + name: areaName || null, + aliases: area.aliases.join(", ") || null, + }, }; }); if (nested && unassignedAreaItems.length) { items.push({ + id: undefined, areas: unassignedAreaItems, } as UnassignedAreasFloorComboBoxItem); } else { diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 861983bb4c..ad7d3ad69a 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -6,6 +6,7 @@ import { getDeviceContext } from "../common/entity/context/get_device_context"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; +import type { FuseWeightedKey } from "../resources/fuseMultiTerm"; import type { HomeAssistant } from "../types"; import type { ConfigEntry } from "./config_entries"; import type { HaEntityPickerEntityFilterFunc } from "./entity"; @@ -181,6 +182,25 @@ export interface DevicePickerItem extends PickerComboBoxItem { domain_name?: string; } +export const deviceComboBoxKeys: FuseWeightedKey[] = [ + { + name: "search_labels.deviceName", + weight: 10, + }, + { + name: "search_labels.areaName", + weight: 8, + }, + { + name: "search_labels.domainName", + weight: 4, + }, + { + name: "search_labels.domain", + weight: 4, + }, +]; + export const getDevices = ( hass: HomeAssistant, configEntryLookup: Record, @@ -311,9 +331,12 @@ export const getDevices = ( secondary: areaName, domain: configEntry?.domain, domain_name: domainName, - search_labels: [deviceName, areaName, domain, domainName].filter( - Boolean - ) as string[], + search_labels: { + deviceName, + areaName: areaName || null, + domain: domain || null, + domainName: domainName || null, + }, sorting_label: deviceName || "zzz", }; }); diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index d6ef7678e1..e7352aa90c 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -9,6 +9,7 @@ import { caseInsensitiveStringCompare } from "../common/string/compare"; import { computeRTL } from "../common/util/compute_rtl"; import { debounce } from "../common/util/debounce"; import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; +import type { FuseWeightedKey } from "../resources/fuseMultiTerm"; import type { HomeAssistant } from "../types"; import type { HaEntityPickerEntityFilterFunc } from "./entity"; import { domainToName } from "./integration"; @@ -335,6 +336,33 @@ export interface EntityComboBoxItem extends PickerComboBoxItem { stateObj?: HassEntity; } +export const entityComboBoxKeys: FuseWeightedKey[] = [ + { + name: "search_labels.entityName", + weight: 10, + }, + { + name: "search_labels.friendlyName", + weight: 8, + }, + { + name: "search_labels.deviceName", + weight: 7, + }, + { + name: "search_labels.areaName", + weight: 6, + }, + { + name: "search_labels.domainName", + weight: 6, + }, + { + name: "search_labels.entityId", + weight: 3, + }, +]; + export const getEntities = ( hass: HomeAssistant, includeDomains?: string[], @@ -403,14 +431,14 @@ export const getEntities = ( secondary: secondary, domain_name: domainName, sorting_label: [deviceName, entityName].filter(Boolean).join("_"), - search_labels: [ - entityName, - deviceName, - areaName, - domainName, - friendlyName, - entityId, - ].filter(Boolean) as string[], + search_labels: { + entityName: entityName || null, + deviceName: deviceName || null, + areaName: areaName || null, + domainName: domainName || null, + friendlyName: friendlyName || null, + entityId: entityId, + }, stateObj: stateObj, }; }); diff --git a/src/data/label_registry.ts b/src/data/label_registry.ts index 286ecbe1b8..8702c42ce5 100644 --- a/src/data/label_registry.ts +++ b/src/data/label_registry.ts @@ -7,6 +7,7 @@ import { stringCompare } from "../common/string/compare"; import { debounce } from "../common/util/debounce"; import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker"; import type { PickerComboBoxItem } from "../components/ha-picker-combo-box"; +import type { FuseWeightedKey } from "../resources/fuseMultiTerm"; import type { HomeAssistant } from "../types"; import { getDeviceEntityDisplayLookup, @@ -100,6 +101,21 @@ export const deleteLabelRegistryEntry = ( label_id: labelId, }); +export const labelComboBoxKeys: FuseWeightedKey[] = [ + { + name: "search_labels.name", + weight: 10, + }, + { + name: "search_labels.description", + weight: 5, + }, + { + name: "search_labels.id", + weight: 4, + }, +]; + export const getLabels = ( hassStates: HomeAssistant["states"], hassAreas: HomeAssistant["areas"], @@ -273,9 +289,11 @@ export const getLabels = ( icon: label.icon || undefined, icon_path: label.icon ? undefined : mdiLabel, sorting_label: label.name, - search_labels: [label.name, label.label_id, label.description].filter( - (v): v is string => Boolean(v) - ), + search_labels: { + name: label.name, + description: label.description, + id: label.label_id, + }, })); return items; diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index a3890da0bf..fbf2cd602a 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -45,7 +45,7 @@ import { domainToName } from "../../data/integration"; import { getPanelNameTranslationKey } from "../../data/panel"; import type { PageNavigation } from "../../layouts/hass-tabs-subpage"; import { configSections } from "../../panels/config/ha-panel-config"; -import { HaFuse } from "../../resources/fuse"; +import { multiTermSortedSearch } from "../../resources/fuseMultiTerm"; import { haStyleDialog, haStyleDialogFixedTop, @@ -58,7 +58,17 @@ import { showConfirmationDialog } from "../generic/show-dialog-box"; import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog"; import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar"; +const SEARCH_KEYS = [ + { name: "primaryText", weight: 10 }, + { name: "altText", weight: 8 }, + { name: "friendlyName", weight: 8 }, + { name: "area", weight: 6 }, + { name: "translatedDomain", weight: 5 }, + { name: "entityId", weight: 4 }, // for technical search +]; + interface QuickBarItem extends ScorableTextItem { + id: string; primaryText: string; iconPath?: string; action(data?: any): void; @@ -593,6 +603,7 @@ export class QuickBar extends LitElement { const areaName = area ? computeAreaName(area) : undefined; const deviceItem = { + id: device.id, primaryText: deviceName, deviceId: device.id, area: areaName, @@ -666,6 +677,7 @@ export class QuickBar extends LitElement { ); const entityItem = { + id: `entity-${entityId}`, primaryText: primary, altText: secondary, icon: html` @@ -767,8 +779,9 @@ export class QuickBar extends LitElement { ), }); - return commands.map((command) => ({ + return commands.map((command, index) => ({ ...command, + id: `command_${index}_${command.primaryText}`, categoryKey: "reload", strings: [`${command.categoryText} ${command.primaryText}`], })); @@ -777,10 +790,11 @@ export class QuickBar extends LitElement { private _generateServerControlCommands(): CommandItem[] { const serverActions = ["restart", "stop"] as const; - return serverActions.map((action) => { + return serverActions.map((action, index) => { const categoryKey: CommandItem["categoryKey"] = "server_control"; const item = { + id: `server_control_${index}_${action}`, primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.server_control.perform_action", { @@ -940,10 +954,11 @@ export class QuickBar extends LitElement { private _finalizeNavigationCommands( items: BaseNavigationCommand[] ): CommandItem[] { - return items.map((item) => { + return items.map((item, index) => { const categoryKey: CommandItem["categoryKey"] = "navigation"; const navItem = { + id: `navigation_${index}_${item.path}`, iconPath: mdiEarth, categoryText: this.hass.localize( `ui.dialogs.quick-bar.commands.types.${categoryKey}` @@ -961,28 +976,20 @@ export class QuickBar extends LitElement { } private _fuseIndex = memoizeOne((items: QuickBarItem[]) => - Fuse.createIndex( - [ - "primaryText", - "altText", - "friendlyName", - "translatedDomain", - "entityId", // for technical search - ], - items - ) + Fuse.createIndex(SEARCH_KEYS, items) ); private _filterItems = memoizeOne( (items: QuickBarItem[], filter: string): QuickBarItem[] => { const index = this._fuseIndex(items); - const fuse = new HaFuse(items, {}, index); - const results = fuse.multiTermsSearch(filter.trim()); - if (!results || !results.length) { - return items; - } - return results.map((result) => result.item); + return multiTermSortedSearch( + items, + filter, + SEARCH_KEYS, + (item) => item.id, + index + ); } ); diff --git a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts index f52c6d0f79..4afb9dbee9 100644 --- a/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts +++ b/src/panels/config/automation/add-automation-element/ha-automation-add-search.ts @@ -2,7 +2,6 @@ import type { LitVirtualizer } from "@lit-labs/virtualizer"; import { consume } from "@lit/context"; import "@material/mwc-list/mwc-list"; import { mdiPlus, mdiTextureBox } from "@mdi/js"; -import type { IFuseOptions } from "fuse.js"; import Fuse from "fuse.js"; import { css, html, LitElement, nothing, type PropertyValues } from "lit"; import { @@ -34,6 +33,7 @@ import "../../../../components/ha-section-title"; import "../../../../components/ha-tree-indicator"; import { ACTION_BUILDING_BLOCKS_GROUP } from "../../../../data/action"; import { + areaFloorComboBoxKeys, getAreasAndFloors, type AreaFloorValue, type FloorComboBoxItem, @@ -42,23 +42,30 @@ import { CONDITION_BUILDING_BLOCKS_GROUP } from "../../../../data/condition"; import type { ConfigEntry } from "../../../../data/config_entries"; import { labelsContext } from "../../../../data/context"; import { + deviceComboBoxKeys, getDevices, type DevicePickerItem, } from "../../../../data/device_registry"; import { + entityComboBoxKeys, getEntities, type EntityComboBoxItem, } from "../../../../data/entity_registry"; import type { DomainManifestLookup } from "../../../../data/integration"; import { getLabels, + labelComboBoxKeys, type LabelRegistryEntry, } from "../../../../data/label_registry"; import { getTargetComboBoxItemType, TARGET_SEPARATOR, } from "../../../../data/target"; -import { HaFuse } from "../../../../resources/fuse"; +import { + multiTermSearch, + multiTermSortedSearch, + type FuseWeightedKey, +} from "../../../../resources/fuseMultiTerm"; import { loadVirtualizer } from "../../../../resources/virtualizer"; import type { HomeAssistant } from "../../../../types"; import type { @@ -76,6 +83,17 @@ const TARGET_SEARCH_SECTIONS = [ "label", ] as const; +export const ITEM_SEARCH_KEYS: FuseWeightedKey[] = [ + { + name: "name", + weight: 10, + }, + { + name: "description", + weight: 7, + }, +]; + type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label"; @customElement("ha-automation-add-search") @@ -434,27 +452,27 @@ export class HaAutomationAddSearch extends LitElement { private _keyFunction = (item: PickerComboBoxItem | string) => typeof item === "string" ? item : item.id; - private _createFuseIndex = (states) => - Fuse.createIndex(["search_labels"], states); + private _createFuseIndex = (states, keys: FuseWeightedKey[]) => + Fuse.createIndex(keys, states); private _fuseIndexes = { area: memoizeOne((states: PickerComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, areaFloorComboBoxKeys) ), entity: memoizeOne((states: PickerComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, entityComboBoxKeys) ), device: memoizeOne((states: PickerComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, deviceComboBoxKeys) ), label: memoizeOne((states: PickerComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, labelComboBoxKeys) ), item: memoizeOne((states: PickerComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, ITEM_SEARCH_KEYS) ), block: memoizeOne((states: PickerComboBoxItem[]) => - this._createFuseIndex(states) + this._createFuseIndex(states, ITEM_SEARCH_KEYS) ), }; @@ -478,11 +496,12 @@ export class HaAutomationAddSearch extends LitElement { if (!selectedSection || selectedSection === "item") { let items = this._convertItemsToComboBoxItems(automationItems, type); if (searchTerm) { - items = this._filterGroup("item", items, searchTerm, { - ignoreLocation: true, - includeScore: true, - minMatchCharLength: Math.min(2, this.filter.length), - }) as AutomationItemComboBoxItem[]; + items = this._filterGroup( + "item", + items, + searchTerm, + ITEM_SEARCH_KEYS + ) as AutomationItemComboBoxItem[]; } if (!selectedSection && items.length) { @@ -514,11 +533,12 @@ export class HaAutomationAddSearch extends LitElement { ); if (searchTerm) { - blocks = this._filterGroup("block", blocks, searchTerm, { - ignoreLocation: true, - includeScore: true, - minMatchCharLength: Math.min(2, this.filter.length), - }) as AutomationItemComboBoxItem[]; + blocks = this._filterGroup( + "block", + blocks, + searchTerm, + ITEM_SEARCH_KEYS + ) as AutomationItemComboBoxItem[]; } if (!selectedSection && blocks.length) { @@ -550,9 +570,7 @@ export class HaAutomationAddSearch extends LitElement { "entity", entityItems, searchTerm, - undefined, - (item: EntityComboBoxItem) => - item.stateObj?.entity_id === searchTerm + entityComboBoxKeys ) as EntityComboBoxItem[]; } @@ -581,7 +599,12 @@ export class HaAutomationAddSearch extends LitElement { ); if (searchTerm) { - deviceItems = this._filterGroup("device", deviceItems, searchTerm); + deviceItems = this._filterGroup( + "device", + deviceItems, + searchTerm, + deviceComboBoxKeys + ); } if (!selectedSection && deviceItems.length) { @@ -617,7 +640,9 @@ export class HaAutomationAddSearch extends LitElement { areasAndFloors = this._filterGroup( "area", areasAndFloors, - searchTerm + searchTerm, + areaFloorComboBoxKeys, + false ) as FloorComboBoxItem[]; } @@ -664,7 +689,12 @@ export class HaAutomationAddSearch extends LitElement { ); if (searchTerm) { - labels = this._filterGroup("label", labels, searchTerm); + labels = this._filterGroup( + "label", + labels, + searchTerm, + labelComboBoxKeys + ); } if (!selectedSection && labels.length) { @@ -691,50 +721,28 @@ export class HaAutomationAddSearch extends LitElement { | AutomationItemComboBoxItem )[], searchTerm: string, - fuseOptions?: IFuseOptions, - checkExact?: ( - item: - | FloorComboBoxItem - | PickerComboBoxItem - | EntityComboBoxItem - | AutomationItemComboBoxItem - ) => boolean + searchKeys: FuseWeightedKey[], + sort = true ) { const fuseIndex = this._fuseIndexes[type](items); - const fuse = new HaFuse< - | FloorComboBoxItem - | PickerComboBoxItem - | EntityComboBoxItem - | AutomationItemComboBoxItem - >( + + if (sort) { + return multiTermSortedSearch( + items, + searchTerm, + searchKeys, + (item) => item.id, + fuseIndex + ); + } + + return multiTermSearch( items, - fuseOptions || { - shouldSort: false, - minMatchCharLength: Math.min(searchTerm.length, 2), - }, - fuseIndex + searchTerm, + searchKeys, + fuseIndex, + { ignoreLocation: true } ); - - const results = fuse.multiTermsSearch(searchTerm); - let filteredItems = items; - if (results) { - filteredItems = results.map((result) => result.item); - } - - if (!checkExact) { - return filteredItems; - } - - // If there is exact match for entity id, put it first - const index = filteredItems.findIndex((item) => checkExact(item)); - if (index === -1) { - return filteredItems; - } - - const [exactMatch] = filteredItems.splice(index, 1); - filteredItems.unshift(exactMatch); - - return filteredItems; } private _toggleSection(ev: Event) { @@ -787,7 +795,11 @@ export class HaAutomationAddSearch extends LitElement { iconPath, renderedIcon: icon, type, - search_labels: [key, name, description], + search_labels: { + key, + name, + description, + }, })); private _focusSearchList = () => { diff --git a/src/panels/config/category/ha-category-picker.ts b/src/panels/config/category/ha-category-picker.ts index 25edfdd30e..d52d55173e 100644 --- a/src/panels/config/category/ha-category-picker.ts +++ b/src/panels/config/category/ha-category-picker.ts @@ -120,9 +120,6 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) { icon: category.icon || undefined, icon_path: category.icon ? undefined : mdiTag, sorting_label: category.name, - search_labels: [category.name, category.category_id].filter( - (v): v is string => Boolean(v) - ), })); return items; diff --git a/src/resources/fuse.ts b/src/resources/fuse.ts deleted file mode 100644 index 390dc0fa52..0000000000 --- a/src/resources/fuse.ts +++ /dev/null @@ -1,78 +0,0 @@ -import Fuse, { - type Expression, - type FuseIndex, - type FuseResult, - type FuseSearchOptions, - type IFuseOptions, -} from "fuse.js"; - -export interface FuseKey { - getFn: null; - id: string; - path: string[]; - src: string; - weight: number; -} - -const DEFAULT_OPTIONS: IFuseOptions = { - ignoreDiacritics: true, - isCaseSensitive: false, - threshold: 0.3, - minMatchCharLength: 2, -}; - -export class HaFuse extends Fuse { - public constructor( - list: readonly T[], - options?: IFuseOptions, - index?: FuseIndex - ) { - const mergedOptions = { - ...DEFAULT_OPTIONS, - ...options, - }; - super(list, mergedOptions, index); - } - - /** - * Performs a multi-term search across the indexed data. - * Splits the search string into individual terms and performs an AND operation between terms, - * where each term is searched across all indexed keys with an OR operation. words with less than - * 2 characters are ignored. If no valid terms are found, the search will return null. - * - * @param search - The search string to split into terms. Terms are space-separated. - * @param options - Optional Fuse.js search options to customize the search behavior. - * @typeParam R - The type of the result items. Defaults to T (the type of the indexed items). - * @returns An array of FuseResult objects containing matched items and their matching information. - * If no valid terms are found (after filtering by minimum length), returns all items with empty matches. - */ - public multiTermsSearch( - search: string, - options?: FuseSearchOptions - ): FuseResult[] | null { - const terms = search.toLowerCase().split(" "); - - // @ts-expect-error options is not part of the Fuse type - const { minMatchCharLength } = this.options as IFuseOptions; - - const filteredTerms = minMatchCharLength - ? terms.filter((term) => term.length >= minMatchCharLength) - : terms; - - if (filteredTerms.length === 0) { - // If no valid terms are found, return null to indicate no search was performed - return null; - } - - const index = this.getIndex().toJSON(); - const keys = index.keys as unknown as FuseKey[]; // Fuse type for key is not correct - - const expression: Expression = { - $and: filteredTerms.map((term) => ({ - $or: keys.map((key) => ({ $path: key.path, $val: term })), - })), - }; - - return this.search(expression, options); - } -} diff --git a/src/resources/fuseMultiTerm.ts b/src/resources/fuseMultiTerm.ts new file mode 100644 index 0000000000..3c5ddb1b8a --- /dev/null +++ b/src/resources/fuseMultiTerm.ts @@ -0,0 +1,254 @@ +import type { + Expression, + FuseIndex, + FuseOptionKey, + FuseResult, + IFuseOptions, +} from "fuse.js"; +import Fuse from "fuse.js"; + +export interface FuseWeightedKey { + name: string | string[]; + weight: number; +} + +const DEFAULT_OPTIONS: IFuseOptions = { + ignoreDiacritics: true, + isCaseSensitive: false, + threshold: 0.3, + minMatchCharLength: 2, +}; + +const DEFAULT_MIN_CHAR_LENGTH = 2; + +/** + * Searches for a term within a collection of items using Fuse.js fuzzy search. + * @param items - The array of items to search through. + * @param search - The search term to look for. + * @param fuseIndex - Optional pre-built Fuse index for improved performance. + * @param options - Optional Fuse.js configuration options to override defaults. + * @returns An array of search results matching the search term. + */ +function searchTerm( + items: T[], + search: string | Expression, + fuseIndex?: FuseIndex, + options?: IFuseOptions, + minMatchCharLength?: number +) { + const fuse = new Fuse( + items, + { + ...DEFAULT_OPTIONS, + minMatchCharLength: + minMatchCharLength ?? + (typeof search === "string" && search.length < DEFAULT_MIN_CHAR_LENGTH + ? search.length + : DEFAULT_MIN_CHAR_LENGTH), + ...(options || {}), + }, + fuseIndex + ); + + return fuse.search(search); +} + +/** + * Performs a multi-term search across an array of items using Fuse.js. + * All search terms must match for an item to be included in the results. + * Result is NOT sorted by relevance. + * + * @template T - The type of items being searched + * @param items - The array of items to search through + * @param search - The search string containing one or more space-separated terms + * @param searchKeys - An array of weighted keys defining which properties to search + * @param fuseIndex - Optional pre-built Fuse index for improved performance + * @param options - Optional Fuse.js configuration options + * @returns An array of items that match all search terms + */ +export function multiTermSearch( + items: T[], + search: string, + searchKeys: FuseOptionKey[], + fuseIndex?: FuseIndex, + options: IFuseOptions = {} +): T[] { + const terms = search + .toLowerCase() + .split(" ") + .filter((t) => t.trim()); + + if (!terms.length) { + return items; + } + + // be sure that all terms are used in the search + // just use DEFAULT_MIN_CHAR_LENGTH if the terms are at least that long + let minLength = DEFAULT_MIN_CHAR_LENGTH; + terms.forEach((term) => { + if (term.length < minLength) { + minLength = term.length; + } + }); + + const expression: Expression = { + $and: terms.map((term) => ({ + $or: searchKeys.map((key) => ({ + $path: + typeof key === "string" + ? key + : Array.isArray(key) + ? key.join(".") + : typeof key.name === "string" + ? key.name + : key.name.join("."), + $val: term, + })), + })), + }; + + return searchTerm( + items, + expression, + fuseIndex, + { + ...options, + shouldSort: false, + }, + minLength + ).map((r) => r.item); +} + +/** + * Performs a multi-term search across items using Fuse.js, returning results sorted by relevance. + * + * This function splits the search string into individual terms and searches for each term + * independently. Results are aggregated and scored based on: + * - Number of terms matched (items must match ALL terms to be included) + * - Fuse.js match score for each term + * - Weight of the matched keys + * + * @template T - The type of items being searched + * @param items - The array of items to search through + * @param search - The search string, which will be split by spaces into multiple terms + * @param searchKeys - Array of weighted keys configuration for Fuse.js search + * @param getItemId - Function to extract a unique identifier from each item + * @param fuseIndex - Optional but highly recommended! Pre-built Fuse.js index for improved performance + * @param options - Optional Fuse.js options to customize search behavior + * @returns An array of items that match all search terms, sorted by relevance score (highest first). + * Returns all items if search is empty, or empty array if not all terms have matches. + */ +export function multiTermSortedSearch( + items: T[], + search: string, + searchKeys: FuseWeightedKey[], + getItemId: (item: T) => string, + fuseIndex?: FuseIndex, + options: IFuseOptions = {} +) { + const terms = search + .toLowerCase() + .split(" ") + .filter((t) => t.trim()); + + if (!terms.length) { + return items; + } + + if (terms.length === 1) { + return searchTerm(items, terms[0], fuseIndex, options).map( + (r) => r.item + ); + } + + const searchResults: Record< + string, + { item: T; hits: number; score: number } + > = {}; + + let termHits = 0; + + terms.forEach((term) => { + if (!term.trim()) { + return; + } + + const termResults = searchTerm(items, term, fuseIndex, { + ...options, + shouldSort: false, + includeScore: true, + includeMatches: true, + }); + + if (termResults.length) { + termHits++; + termResults.forEach((r) => { + const itemId = getItemId(r.item); + if (!searchResults[itemId]) { + searchResults[itemId] = { + item: r.item, + hits: 0, + score: 0, + }; + } + + searchResults[itemId].hits += 1; + + const weight = _getMatchedKeyHighestWeight(r, searchKeys); + + const score = r.score ? 1 - r.score : 0; + const weightedScore = score * weight; + + searchResults[itemId].score += weightedScore; + }); + } + }); + + // just return smth if for the full terms combination are results + if (termHits !== terms.length) { + return []; + } + + // Filter to only items that matched all terms + const results = Object.values(searchResults).filter( + ({ hits }) => hits === terms.length + ); + + // Sort by score descending + results.sort((a, b) => b.score - a.score); + + return results.map(({ item }) => item); +} + +/** + * Finds the highest weight among all matched keys in a Fuse.js search result. + * + * @typeParam T - The type of items being searched + * @param result - A single Fuse.js search result containing match information + * @param searchKeys - Array of weighted search keys configured for the Fuse instance + * @returns The highest weight value among matched keys, or 1 if no matches exist or no weights are defined + */ +function _getMatchedKeyHighestWeight( + result: FuseResult, + searchKeys: FuseWeightedKey[] +): number { + if (!result.matches || result.matches.length === 0) { + return 1; + } + + // Find the highest weighted key that matched + let maxWeight = 1; + for (const match of result.matches) { + const keyConfig = searchKeys.find((k) => { + if (typeof k.name === "string") { + return k.name === match.key; + } + return k.name.join(".") === match.key; + }); + if (keyConfig && keyConfig.weight && keyConfig.weight > maxWeight) { + maxWeight = keyConfig.weight; + } + } + + return maxWeight; +}