diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index aa67093497..8c50326fd2 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -10,10 +10,13 @@ import { fuzzyScore } from "./filter"; * @return {number} Score representing how well the word matches the filter. Return of 0 means no match. */ -export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { +export const fuzzySequentialMatch = ( + filter: string, + item: ScorableTextItem +) => { let topScore = Number.NEGATIVE_INFINITY; - for (const word of words) { + for (const word of item.strings) { const scores = fuzzyScore( filter, filter.toLowerCase(), @@ -28,13 +31,9 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { continue; } - // The VS Code implementation of filter returns a: - // - Negative score for a good match that starts in the middle of the string - // - Positive score if the match starts at the beginning of the string - // - 0 if the filter string is just barely a match - // - undefined for no match - // The "0" return is problematic since .filter() will remove that match, even though a 0 == good match. - // So, if we encounter a 0 return, set it to 1 so the match will be included, and still respect ordering. + // The VS Code implementation of filter returns a 0 for a weak match. + // But if .filter() sees a "0", it considers that a failed match and will remove it. + // So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering. const score = scores[0] === 0 ? 1 : scores[0]; if (score > topScore) { @@ -49,10 +48,22 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { return topScore; }; +/** + * An interface that objects must extend in order to use the fuzzy sequence matcher + * + * @param {number} score - A number representing the existence and strength of a match. + * - `< 0` means a good match that starts in the middle of the string + * - `> 0` means a good match that starts at the beginning of the string + * - `0` means just barely a match + * - `undefined` means not a match + * + * @param {string} strings - Array of strings (aliases) representing the item. The filter string will be compared against each of these for a match. + * + */ + export interface ScorableTextItem { score?: number; - filterText: string; - altText?: string; + strings: string[]; } type FuzzyFilterSort = ( @@ -63,9 +74,7 @@ type FuzzyFilterSort = ( export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { return items .map((item) => { - item.score = item.altText - ? fuzzySequentialMatch(filter, item.filterText, item.altText) - : fuzzySequentialMatch(filter, item.filterText); + item.score = fuzzySequentialMatch(filter, item); return item; }) .filter((item) => item.score !== undefined) diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 443b8b8470..2e6147f7df 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -66,10 +66,11 @@ interface CommandItem extends QuickBarItem { } interface EntityItem extends QuickBarItem { + altText: string; icon?: string; } -const isCommandItem = (item: EntityItem | CommandItem): item is CommandItem => { +const isCommandItem = (item: QuickBarItem): item is CommandItem => { return (item as CommandItem).categoryKey !== undefined; }; @@ -230,7 +231,7 @@ export class QuickBar extends LitElement { private _renderItem(item: QuickBarItem, index?: number) { return isCommandItem(item) ? this._renderCommandItem(item, index) - : this._renderEntityItem(item, index); + : this._renderEntityItem(item as EntityItem, index); } private _renderEntityItem(item: EntityItem, index?: number) { @@ -289,13 +290,6 @@ export class QuickBar extends LitElement { ${item.primaryText} - ${item.altText - ? html` - ${item.altText} - ` - : null} `; } @@ -389,17 +383,20 @@ export class QuickBar extends LitElement { } } - private _generateEntityItems(): QuickBarItem[] { + private _generateEntityItems(): EntityItem[] { return Object.keys(this.hass.states) .map((entityId) => { - const primaryText = computeStateName(this.hass.states[entityId]); - return { - primaryText, - filterText: primaryText, + const entityItem = { + primaryText: computeStateName(this.hass.states[entityId]), altText: entityId, icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), action: () => fireEvent(this, "hass-more-info", { entityId }), }; + + return { + ...entityItem, + strings: [entityItem.primaryText, entityItem.altText], + }; }) .sort((a, b) => compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase()) @@ -412,7 +409,10 @@ export class QuickBar extends LitElement { ...this._generateServerControlCommands(), ...this._generateNavigationCommands(), ].sort((a, b) => - compare(a.filterText.toLowerCase(), b.filterText.toLowerCase()) + compare( + a.strings.join(" ").toLowerCase(), + b.strings.join(" ").toLowerCase() + ) ); } @@ -420,24 +420,27 @@ export class QuickBar extends LitElement { const reloadableDomains = componentsWithService(this.hass, "reload").sort(); return reloadableDomains.map((domain) => { - const categoryText = this.hass.localize( - `ui.dialogs.quick-bar.commands.types.reload` - ); - const primaryText = - this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) || - this.hass.localize( - "ui.dialogs.quick-bar.commands.reload.reload", - "domain", - domainToName(this.hass.localize, domain) - ); + const commandItem = { + primaryText: + this.hass.localize( + `ui.dialogs.quick-bar.commands.reload.${domain}` + ) || + this.hass.localize( + "ui.dialogs.quick-bar.commands.reload.reload", + "domain", + domainToName(this.hass.localize, domain) + ), + action: () => this.hass.callService(domain, "reload"), + iconPath: mdiReload, + categoryText: this.hass.localize( + `ui.dialogs.quick-bar.commands.types.reload` + ), + }; return { - primaryText, - filterText: `${categoryText} ${primaryText}`, - action: () => this.hass.callService(domain, "reload"), + ...commandItem, categoryKey: "reload", - iconPath: mdiReload, - categoryText, + strings: [`${commandItem.categoryText} ${commandItem.primaryText}`], }; }); } @@ -446,26 +449,28 @@ export class QuickBar extends LitElement { const serverActions = ["restart", "stop"]; return serverActions.map((action) => { - const categoryKey = "server_control"; - const categoryText = this.hass.localize( - `ui.dialogs.quick-bar.commands.types.${categoryKey}` - ); - const primaryText = this.hass.localize( - "ui.dialogs.quick-bar.commands.server_control.perform_action", - "action", - this.hass.localize( - `ui.dialogs.quick-bar.commands.server_control.${action}` - ) - ); + const categoryKey: CommandItem["categoryKey"] = "server_control"; + + const item = { + primaryText: this.hass.localize( + "ui.dialogs.quick-bar.commands.server_control.perform_action", + "action", + this.hass.localize( + `ui.dialogs.quick-bar.commands.server_control.${action}` + ) + ), + iconPath: mdiServerNetwork, + categoryText: this.hass.localize( + `ui.dialogs.quick-bar.commands.types.${categoryKey}` + ), + categoryKey, + action: () => this.hass.callService("homeassistant", action), + }; return this._generateConfirmationCommand( { - primaryText, - filterText: `${categoryText} ${primaryText}`, - categoryKey, - iconPath: mdiServerNetwork, - categoryText, - action: () => this.hass.callService("homeassistant", action), + ...item, + strings: [`${item.categoryText} ${item.primaryText}`], }, this.hass.localize("ui.dialogs.generic.ok") ); @@ -550,18 +555,21 @@ export class QuickBar extends LitElement { items: BaseNavigationCommand[] ): CommandItem[] { return items.map((item) => { - const categoryKey = "navigation"; - const categoryText = this.hass.localize( - `ui.dialogs.quick-bar.commands.types.${categoryKey}` - ); + const categoryKey: CommandItem["categoryKey"] = "navigation"; + + const navItem = { + ...item, + iconPath: mdiEarth, + categoryText: this.hass.localize( + `ui.dialogs.quick-bar.commands.types.${categoryKey}` + ), + action: () => navigate(this, item.path), + }; return { - ...item, + ...navItem, + strings: [`${navItem.categoryText} ${navItem.primaryText}`], categoryKey, - iconPath: mdiEarth, - categoryText, - filterText: `${categoryText} ${item.primaryText}`, - action: () => navigate(this, item.path), }; }); } diff --git a/test-mocha/common/string/sequence_matching.test.ts b/test-mocha/common/string/sequence_matching.test.ts index 1b079cb4ba..f631a23285 100644 --- a/test-mocha/common/string/sequence_matching.test.ts +++ b/test-mocha/common/string/sequence_matching.test.ts @@ -3,10 +3,13 @@ import { assert } from "chai"; import { fuzzyFilterSort, fuzzySequentialMatch, + ScorableTextItem, } from "../../../src/common/string/filter/sequence-matching"; describe("fuzzySequentialMatch", () => { - const entity = { entity_id: "automation.ticker", friendly_name: "Stocks" }; + const item: ScorableTextItem = { + strings: ["automation.ticker", "Stocks"], + }; const createExpectation: ( pattern, @@ -53,25 +56,17 @@ describe("fuzzySequentialMatch", () => { "stox", ]; - describe(`Entity '${entity.entity_id}'`, () => { + describe(`Entity '${item.strings[0]}'`, () => { for (const expectation of shouldMatchEntity) { it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => { - const res = fuzzySequentialMatch( - expectation.pattern, - entity.entity_id, - entity.friendly_name - ); + const res = fuzzySequentialMatch(expectation.pattern, item); assert.equal(res, expectation.expected); }); } for (const badFilter of shouldNotMatchEntity) { it(`fails to match with '${badFilter}'`, () => { - const res = fuzzySequentialMatch( - badFilter, - entity.entity_id, - entity.friendly_name - ); + const res = fuzzySequentialMatch(badFilter, item); assert.equal(res, undefined); }); } @@ -81,28 +76,23 @@ describe("fuzzySequentialMatch", () => { describe("fuzzyFilterSort", () => { const filter = "ticker"; const automationTicker = { - filterText: "automation.ticker", - altText: "Stocks", + strings: ["automation.ticker", "Stocks"], score: 0, }; const ticker = { - filterText: "ticker", - altText: "Just ticker", + strings: ["ticker", "Just ticker"], score: 0, }; const sensorTicker = { - filterText: "sensor.ticker", - altText: "Stocks up", + strings: ["sensor.ticker", "Stocks up"], score: 0, }; const timerCheckRouter = { - filterText: "automation.check_router", - altText: "Timer Check Router", + strings: ["automation.check_router", "Timer Check Router"], score: 0, }; const badMatch = { - filterText: "light.chandelier", - altText: "Chandelier", + strings: ["light.chandelier", "Chandelier"], score: 0, }; const itemsBeforeFilter = [