From 64c008fa8a91d1c9a984ce17d0c6da92b10566fb Mon Sep 17 00:00:00 2001 From: Donnie Date: Tue, 6 Apr 2021 13:04:44 -0700 Subject: [PATCH 1/3] Refactor sequence matching to require an item rather than array of words to filter against --- src/common/string/filter/sequence-matching.ts | 14 +- src/dialogs/quick-bar/ha-quick-bar.ts | 129 ++++++++++-------- .../common/string/sequence_matching.test.ts | 34 ++--- 3 files changed, 91 insertions(+), 86 deletions(-) diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index aa670934978e..cae846bc77fb 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.words) { const scores = fuzzyScore( filter, filter.toLowerCase(), @@ -51,8 +54,7 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { export interface ScorableTextItem { score?: number; - filterText: string; - altText?: string; + words: string[]; } type FuzzyFilterSort = ( @@ -63,9 +65,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 443b8b847026..260ce9700075 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -66,13 +66,18 @@ 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; }; +const isEntityItem = (item: QuickBarItem): item is EntityItem => { + return !isCommandItem(item); +}; + interface QuickBarNavigationItem extends CommandItem { path: string; } @@ -228,9 +233,15 @@ export class QuickBar extends LitElement { } private _renderItem(item: QuickBarItem, index?: number) { - return isCommandItem(item) - ? this._renderCommandItem(item, index) - : this._renderEntityItem(item, index); + if (isCommandItem(item)) { + return this._renderCommandItem(item, index); + } + + if (isEntityItem(item)) { + return this._renderEntityItem(item, index); + } + + return html``; } private _renderEntityItem(item: EntityItem, index?: number) { @@ -289,13 +300,6 @@ export class QuickBar extends LitElement { ${item.primaryText} - ${item.altText - ? html` - ${item.altText} - ` - : null} `; } @@ -389,17 +393,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, + words: [entityItem.primaryText, entityItem.altText], + }; }) .sort((a, b) => compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase()) @@ -412,7 +419,7 @@ export class QuickBar extends LitElement { ...this._generateServerControlCommands(), ...this._generateNavigationCommands(), ].sort((a, b) => - compare(a.filterText.toLowerCase(), b.filterText.toLowerCase()) + compare(a.words.join(" ").toLowerCase(), b.words.join(" ").toLowerCase()) ); } @@ -420,24 +427,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, + words: [`${commandItem.categoryText} ${commandItem.primaryText}`], }; }); } @@ -446,26 +456,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, + words: [`${item.categoryText} ${item.primaryText}`], }, this.hass.localize("ui.dialogs.generic.ok") ); @@ -550,19 +562,22 @@ 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"; - return { + const navItem = { ...item, - categoryKey, iconPath: mdiEarth, - categoryText, - filterText: `${categoryText} ${item.primaryText}`, + categoryText: this.hass.localize( + `ui.dialogs.quick-bar.commands.types.${categoryKey}` + ), action: () => navigate(this, item.path), }; + + return { + ...navItem, + words: [`${navItem.categoryText} ${navItem.primaryText}`], + categoryKey, + }; }); } diff --git a/test-mocha/common/string/sequence_matching.test.ts b/test-mocha/common/string/sequence_matching.test.ts index 1b079cb4ba62..86f6834e8138 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 = { + words: ["automation.ticker", "Stocks"], + }; const createExpectation: ( pattern, @@ -53,25 +56,17 @@ describe("fuzzySequentialMatch", () => { "stox", ]; - describe(`Entity '${entity.entity_id}'`, () => { + describe(`Entity '${item.words[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", + words: ["automation.ticker", "Stocks"], score: 0, }; const ticker = { - filterText: "ticker", - altText: "Just ticker", + words: ["ticker", "Just ticker"], score: 0, }; const sensorTicker = { - filterText: "sensor.ticker", - altText: "Stocks up", + words: ["sensor.ticker", "Stocks up"], score: 0, }; const timerCheckRouter = { - filterText: "automation.check_router", - altText: "Timer Check Router", + words: ["automation.check_router", "Timer Check Router"], score: 0, }; const badMatch = { - filterText: "light.chandelier", - altText: "Chandelier", + words: ["light.chandelier", "Chandelier"], score: 0, }; const itemsBeforeFilter = [ From e9e693b336e7d1aa310db3d1b52d67e95edc6d22 Mon Sep 17 00:00:00 2001 From: Donnie Date: Tue, 13 Apr 2021 13:18:47 -0700 Subject: [PATCH 2/3] change 'words' to 'strings'. Add tsdoc description for ScorableTextItem --- src/common/string/filter/sequence-matching.ts | 27 ++++++++++++------- src/dialogs/quick-bar/ha-quick-bar.ts | 13 +++++---- .../common/string/sequence_matching.test.ts | 14 +++++----- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index cae846bc77fb..8c50326fd2a1 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -16,7 +16,7 @@ export const fuzzySequentialMatch = ( ) => { let topScore = Number.NEGATIVE_INFINITY; - for (const word of item.words) { + for (const word of item.strings) { const scores = fuzzyScore( filter, filter.toLowerCase(), @@ -31,13 +31,9 @@ export const fuzzySequentialMatch = ( 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) { @@ -52,9 +48,22 @@ export const fuzzySequentialMatch = ( 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; - words: string[]; + strings: string[]; } type FuzzyFilterSort = ( diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 260ce9700075..55de524ac5fa 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -405,7 +405,7 @@ export class QuickBar extends LitElement { return { ...entityItem, - words: [entityItem.primaryText, entityItem.altText], + strings: [entityItem.primaryText, entityItem.altText], }; }) .sort((a, b) => @@ -419,7 +419,10 @@ export class QuickBar extends LitElement { ...this._generateServerControlCommands(), ...this._generateNavigationCommands(), ].sort((a, b) => - compare(a.words.join(" ").toLowerCase(), b.words.join(" ").toLowerCase()) + compare( + a.strings.join(" ").toLowerCase(), + b.strings.join(" ").toLowerCase() + ) ); } @@ -447,7 +450,7 @@ export class QuickBar extends LitElement { return { ...commandItem, categoryKey: "reload", - words: [`${commandItem.categoryText} ${commandItem.primaryText}`], + strings: [`${commandItem.categoryText} ${commandItem.primaryText}`], }; }); } @@ -477,7 +480,7 @@ export class QuickBar extends LitElement { return this._generateConfirmationCommand( { ...item, - words: [`${item.categoryText} ${item.primaryText}`], + strings: [`${item.categoryText} ${item.primaryText}`], }, this.hass.localize("ui.dialogs.generic.ok") ); @@ -575,7 +578,7 @@ export class QuickBar extends LitElement { return { ...navItem, - words: [`${navItem.categoryText} ${navItem.primaryText}`], + strings: [`${navItem.categoryText} ${navItem.primaryText}`], categoryKey, }; }); diff --git a/test-mocha/common/string/sequence_matching.test.ts b/test-mocha/common/string/sequence_matching.test.ts index 86f6834e8138..f631a232857d 100644 --- a/test-mocha/common/string/sequence_matching.test.ts +++ b/test-mocha/common/string/sequence_matching.test.ts @@ -8,7 +8,7 @@ import { describe("fuzzySequentialMatch", () => { const item: ScorableTextItem = { - words: ["automation.ticker", "Stocks"], + strings: ["automation.ticker", "Stocks"], }; const createExpectation: ( @@ -56,7 +56,7 @@ describe("fuzzySequentialMatch", () => { "stox", ]; - describe(`Entity '${item.words[0]}'`, () => { + describe(`Entity '${item.strings[0]}'`, () => { for (const expectation of shouldMatchEntity) { it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => { const res = fuzzySequentialMatch(expectation.pattern, item); @@ -76,23 +76,23 @@ describe("fuzzySequentialMatch", () => { describe("fuzzyFilterSort", () => { const filter = "ticker"; const automationTicker = { - words: ["automation.ticker", "Stocks"], + strings: ["automation.ticker", "Stocks"], score: 0, }; const ticker = { - words: ["ticker", "Just ticker"], + strings: ["ticker", "Just ticker"], score: 0, }; const sensorTicker = { - words: ["sensor.ticker", "Stocks up"], + strings: ["sensor.ticker", "Stocks up"], score: 0, }; const timerCheckRouter = { - words: ["automation.check_router", "Timer Check Router"], + strings: ["automation.check_router", "Timer Check Router"], score: 0, }; const badMatch = { - words: ["light.chandelier", "Chandelier"], + strings: ["light.chandelier", "Chandelier"], score: 0, }; const itemsBeforeFilter = [ From e6320bd455415b1838b1e494f87c8268e4bca6be Mon Sep 17 00:00:00 2001 From: Donnie Date: Wed, 14 Apr 2021 15:16:03 -0700 Subject: [PATCH 3/3] Replace type checking with 'as' to clean up code --- src/dialogs/quick-bar/ha-quick-bar.ts | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 55de524ac5fa..2e6147f7df6a 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -74,10 +74,6 @@ const isCommandItem = (item: QuickBarItem): item is CommandItem => { return (item as CommandItem).categoryKey !== undefined; }; -const isEntityItem = (item: QuickBarItem): item is EntityItem => { - return !isCommandItem(item); -}; - interface QuickBarNavigationItem extends CommandItem { path: string; } @@ -233,15 +229,9 @@ export class QuickBar extends LitElement { } private _renderItem(item: QuickBarItem, index?: number) { - if (isCommandItem(item)) { - return this._renderCommandItem(item, index); - } - - if (isEntityItem(item)) { - return this._renderEntityItem(item, index); - } - - return html``; + return isCommandItem(item) + ? this._renderCommandItem(item, index) + : this._renderEntityItem(item as EntityItem, index); } private _renderEntityItem(item: EntityItem, index?: number) {