Refactor sequence matching to accept item rather than word array (#8866)

* Refactor sequence matching to require an item rather than array of words to filter against

* change 'words' to 'strings'. Add tsdoc description for ScorableTextItem

* Replace type checking with 'as' to clean up code
This commit is contained in:
Donnie 2021-04-14 15:29:10 -07:00 committed by GitHub
parent c53575a74f
commit 538028a003
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 99 additions and 92 deletions

View File

@ -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. * @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; let topScore = Number.NEGATIVE_INFINITY;
for (const word of words) { for (const word of item.strings) {
const scores = fuzzyScore( const scores = fuzzyScore(
filter, filter,
filter.toLowerCase(), filter.toLowerCase(),
@ -28,13 +31,9 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
continue; continue;
} }
// The VS Code implementation of filter returns a: // The VS Code implementation of filter returns a 0 for a weak match.
// - Negative score for a good match that starts in the middle of the string // But if .filter() sees a "0", it considers that a failed match and will remove it.
// - Positive score if the match starts at the beginning of the string // So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering.
// - 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.
const score = scores[0] === 0 ? 1 : scores[0]; const score = scores[0] === 0 ? 1 : scores[0];
if (score > topScore) { if (score > topScore) {
@ -49,10 +48,22 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
return topScore; 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 { export interface ScorableTextItem {
score?: number; score?: number;
filterText: string; strings: string[];
altText?: string;
} }
type FuzzyFilterSort = <T extends ScorableTextItem>( type FuzzyFilterSort = <T extends ScorableTextItem>(
@ -63,9 +74,7 @@ type FuzzyFilterSort = <T extends ScorableTextItem>(
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => {
return items return items
.map((item) => { .map((item) => {
item.score = item.altText item.score = fuzzySequentialMatch(filter, item);
? fuzzySequentialMatch(filter, item.filterText, item.altText)
: fuzzySequentialMatch(filter, item.filterText);
return item; return item;
}) })
.filter((item) => item.score !== undefined) .filter((item) => item.score !== undefined)

View File

@ -66,10 +66,11 @@ interface CommandItem extends QuickBarItem {
} }
interface EntityItem extends QuickBarItem { interface EntityItem extends QuickBarItem {
altText: string;
icon?: string; icon?: string;
} }
const isCommandItem = (item: EntityItem | CommandItem): item is CommandItem => { const isCommandItem = (item: QuickBarItem): item is CommandItem => {
return (item as CommandItem).categoryKey !== undefined; return (item as CommandItem).categoryKey !== undefined;
}; };
@ -230,7 +231,7 @@ export class QuickBar extends LitElement {
private _renderItem(item: QuickBarItem, index?: number) { private _renderItem(item: QuickBarItem, index?: number) {
return isCommandItem(item) return isCommandItem(item)
? this._renderCommandItem(item, index) ? this._renderCommandItem(item, index)
: this._renderEntityItem(item, index); : this._renderEntityItem(item as EntityItem, index);
} }
private _renderEntityItem(item: EntityItem, index?: number) { private _renderEntityItem(item: EntityItem, index?: number) {
@ -289,13 +290,6 @@ export class QuickBar extends LitElement {
</span> </span>
<span class="command-text">${item.primaryText}</span> <span class="command-text">${item.primaryText}</span>
${item.altText
? html`
<span slot="secondary" class="item-text secondary"
>${item.altText}</span
>
`
: null}
</mwc-list-item> </mwc-list-item>
`; `;
} }
@ -389,17 +383,20 @@ export class QuickBar extends LitElement {
} }
} }
private _generateEntityItems(): QuickBarItem[] { private _generateEntityItems(): EntityItem[] {
return Object.keys(this.hass.states) return Object.keys(this.hass.states)
.map((entityId) => { .map((entityId) => {
const primaryText = computeStateName(this.hass.states[entityId]); const entityItem = {
return { primaryText: computeStateName(this.hass.states[entityId]),
primaryText,
filterText: primaryText,
altText: entityId, altText: entityId,
icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]),
action: () => fireEvent(this, "hass-more-info", { entityId }), action: () => fireEvent(this, "hass-more-info", { entityId }),
}; };
return {
...entityItem,
strings: [entityItem.primaryText, entityItem.altText],
};
}) })
.sort((a, b) => .sort((a, b) =>
compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase()) compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase())
@ -412,7 +409,10 @@ export class QuickBar extends LitElement {
...this._generateServerControlCommands(), ...this._generateServerControlCommands(),
...this._generateNavigationCommands(), ...this._generateNavigationCommands(),
].sort((a, b) => ].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(); const reloadableDomains = componentsWithService(this.hass, "reload").sort();
return reloadableDomains.map((domain) => { return reloadableDomains.map((domain) => {
const categoryText = this.hass.localize( const commandItem = {
`ui.dialogs.quick-bar.commands.types.reload` primaryText:
); this.hass.localize(
const primaryText = `ui.dialogs.quick-bar.commands.reload.${domain}`
this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) || ) ||
this.hass.localize( this.hass.localize(
"ui.dialogs.quick-bar.commands.reload.reload", "ui.dialogs.quick-bar.commands.reload.reload",
"domain", "domain",
domainToName(this.hass.localize, 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 { return {
primaryText, ...commandItem,
filterText: `${categoryText} ${primaryText}`,
action: () => this.hass.callService(domain, "reload"),
categoryKey: "reload", categoryKey: "reload",
iconPath: mdiReload, strings: [`${commandItem.categoryText} ${commandItem.primaryText}`],
categoryText,
}; };
}); });
} }
@ -446,26 +449,28 @@ export class QuickBar extends LitElement {
const serverActions = ["restart", "stop"]; const serverActions = ["restart", "stop"];
return serverActions.map((action) => { return serverActions.map((action) => {
const categoryKey = "server_control"; const categoryKey: CommandItem["categoryKey"] = "server_control";
const categoryText = this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}` const item = {
); primaryText: this.hass.localize(
const primaryText = this.hass.localize( "ui.dialogs.quick-bar.commands.server_control.perform_action",
"ui.dialogs.quick-bar.commands.server_control.perform_action", "action",
"action", this.hass.localize(
this.hass.localize( `ui.dialogs.quick-bar.commands.server_control.${action}`
`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( return this._generateConfirmationCommand(
{ {
primaryText, ...item,
filterText: `${categoryText} ${primaryText}`, strings: [`${item.categoryText} ${item.primaryText}`],
categoryKey,
iconPath: mdiServerNetwork,
categoryText,
action: () => this.hass.callService("homeassistant", action),
}, },
this.hass.localize("ui.dialogs.generic.ok") this.hass.localize("ui.dialogs.generic.ok")
); );
@ -550,18 +555,21 @@ export class QuickBar extends LitElement {
items: BaseNavigationCommand[] items: BaseNavigationCommand[]
): CommandItem[] { ): CommandItem[] {
return items.map((item) => { return items.map((item) => {
const categoryKey = "navigation"; const categoryKey: CommandItem["categoryKey"] = "navigation";
const categoryText = this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}` const navItem = {
); ...item,
iconPath: mdiEarth,
categoryText: this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}`
),
action: () => navigate(this, item.path),
};
return { return {
...item, ...navItem,
strings: [`${navItem.categoryText} ${navItem.primaryText}`],
categoryKey, categoryKey,
iconPath: mdiEarth,
categoryText,
filterText: `${categoryText} ${item.primaryText}`,
action: () => navigate(this, item.path),
}; };
}); });
} }

View File

@ -3,10 +3,13 @@ import { assert } from "chai";
import { import {
fuzzyFilterSort, fuzzyFilterSort,
fuzzySequentialMatch, fuzzySequentialMatch,
ScorableTextItem,
} from "../../../src/common/string/filter/sequence-matching"; } from "../../../src/common/string/filter/sequence-matching";
describe("fuzzySequentialMatch", () => { describe("fuzzySequentialMatch", () => {
const entity = { entity_id: "automation.ticker", friendly_name: "Stocks" }; const item: ScorableTextItem = {
strings: ["automation.ticker", "Stocks"],
};
const createExpectation: ( const createExpectation: (
pattern, pattern,
@ -53,25 +56,17 @@ describe("fuzzySequentialMatch", () => {
"stox", "stox",
]; ];
describe(`Entity '${entity.entity_id}'`, () => { describe(`Entity '${item.strings[0]}'`, () => {
for (const expectation of shouldMatchEntity) { for (const expectation of shouldMatchEntity) {
it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => { it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => {
const res = fuzzySequentialMatch( const res = fuzzySequentialMatch(expectation.pattern, item);
expectation.pattern,
entity.entity_id,
entity.friendly_name
);
assert.equal(res, expectation.expected); assert.equal(res, expectation.expected);
}); });
} }
for (const badFilter of shouldNotMatchEntity) { for (const badFilter of shouldNotMatchEntity) {
it(`fails to match with '${badFilter}'`, () => { it(`fails to match with '${badFilter}'`, () => {
const res = fuzzySequentialMatch( const res = fuzzySequentialMatch(badFilter, item);
badFilter,
entity.entity_id,
entity.friendly_name
);
assert.equal(res, undefined); assert.equal(res, undefined);
}); });
} }
@ -81,28 +76,23 @@ describe("fuzzySequentialMatch", () => {
describe("fuzzyFilterSort", () => { describe("fuzzyFilterSort", () => {
const filter = "ticker"; const filter = "ticker";
const automationTicker = { const automationTicker = {
filterText: "automation.ticker", strings: ["automation.ticker", "Stocks"],
altText: "Stocks",
score: 0, score: 0,
}; };
const ticker = { const ticker = {
filterText: "ticker", strings: ["ticker", "Just ticker"],
altText: "Just ticker",
score: 0, score: 0,
}; };
const sensorTicker = { const sensorTicker = {
filterText: "sensor.ticker", strings: ["sensor.ticker", "Stocks up"],
altText: "Stocks up",
score: 0, score: 0,
}; };
const timerCheckRouter = { const timerCheckRouter = {
filterText: "automation.check_router", strings: ["automation.check_router", "Timer Check Router"],
altText: "Timer Check Router",
score: 0, score: 0,
}; };
const badMatch = { const badMatch = {
filterText: "light.chandelier", strings: ["light.chandelier", "Chandelier"],
altText: "Chandelier",
score: 0, score: 0,
}; };
const itemsBeforeFilter = [ const itemsBeforeFilter = [