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.
*/
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 = <T extends ScorableTextItem>(
@ -63,9 +74,7 @@ type FuzzyFilterSort = <T extends ScorableTextItem>(
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)

View File

@ -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 {
</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>
`;
}
@ -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),
};
});
}

View File

@ -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 = [