mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-23 09:16:38 +00:00
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:
parent
c53575a74f
commit
538028a003
@ -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)
|
||||
|
@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -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 = [
|
||||
|
Loading…
x
Reference in New Issue
Block a user