Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 23 additions & 14 deletions src/common/string/filter/sequence-matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
bramkragten marked this conversation as resolved.
) => {
let topScore = Number.NEGATIVE_INFINITY;

for (const word of words) {
for (const word of item.strings) {
const scores = fuzzyScore(
filter,
filter.toLowerCase(),
Expand All @@ -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) {
Expand All @@ -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>(
Expand All @@ -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);
Comment thread
bramkragten marked this conversation as resolved.
return item;
})
.filter((item) => item.score !== undefined)
Expand Down
118 changes: 63 additions & 55 deletions src/dialogs/quick-bar/ha-quick-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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>
`;
}
Expand Down Expand Up @@ -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())
Expand All @@ -412,32 +409,38 @@ 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()
)
);
}

private _generateReloadCommands(): CommandItem[] {
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}`],
};
});
}
Expand All @@ -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")
);
Expand Down Expand Up @@ -550,19 +555,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,
strings: [`${navItem.categoryText} ${navItem.primaryText}`],
categoryKey,
};
});
}

Expand Down
34 changes: 12 additions & 22 deletions test-mocha/common/string/sequence_matching.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
});
}
Expand All @@ -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 = [
Expand Down