This commit is contained in:
Zack Barett 2022-05-06 21:48:57 -05:00 committed by GitHub
parent cf05fbaa9d
commit ce77ddf365
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 911 additions and 227 deletions

View File

@ -106,7 +106,6 @@
"deep-clone-simple": "^1.1.1",
"deep-freeze": "^0.0.1",
"fuse.js": "^6.0.0",
"fuzzysort": "^1.2.1",
"google-timezones-json": "^1.0.2",
"hls.js": "^1.1.5",
"home-assistant-js-websocket": "^7.0.3",

View File

@ -0,0 +1,244 @@
// MIT License
// Copyright (c) 2015 - present Microsoft Corporation
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/
/**
* An inlined enum containing useful character codes (to be used with String.charCodeAt).
* Please leave the const keyword such that it gets inlined when compiled to JavaScript!
*/
export enum CharCode {
Null = 0,
/**
* The `\b` character.
*/
Backspace = 8,
/**
* The `\t` character.
*/
Tab = 9,
/**
* The `\n` character.
*/
LineFeed = 10,
/**
* The `\r` character.
*/
CarriageReturn = 13,
Space = 32,
/**
* The `!` character.
*/
ExclamationMark = 33,
/**
* The `"` character.
*/
DoubleQuote = 34,
/**
* The `#` character.
*/
Hash = 35,
/**
* The `$` character.
*/
DollarSign = 36,
/**
* The `%` character.
*/
PercentSign = 37,
/**
* The `&` character.
*/
Ampersand = 38,
/**
* The `'` character.
*/
SingleQuote = 39,
/**
* The `(` character.
*/
OpenParen = 40,
/**
* The `)` character.
*/
CloseParen = 41,
/**
* The `*` character.
*/
Asterisk = 42,
/**
* The `+` character.
*/
Plus = 43,
/**
* The `,` character.
*/
Comma = 44,
/**
* The `-` character.
*/
Dash = 45,
/**
* The `.` character.
*/
Period = 46,
/**
* The `/` character.
*/
Slash = 47,
Digit0 = 48,
Digit1 = 49,
Digit2 = 50,
Digit3 = 51,
Digit4 = 52,
Digit5 = 53,
Digit6 = 54,
Digit7 = 55,
Digit8 = 56,
Digit9 = 57,
/**
* The `:` character.
*/
Colon = 58,
/**
* The `;` character.
*/
Semicolon = 59,
/**
* The `<` character.
*/
LessThan = 60,
/**
* The `=` character.
*/
Equals = 61,
/**
* The `>` character.
*/
GreaterThan = 62,
/**
* The `?` character.
*/
QuestionMark = 63,
/**
* The `@` character.
*/
AtSign = 64,
A = 65,
B = 66,
C = 67,
D = 68,
E = 69,
F = 70,
G = 71,
H = 72,
I = 73,
J = 74,
K = 75,
L = 76,
M = 77,
N = 78,
O = 79,
P = 80,
Q = 81,
R = 82,
S = 83,
T = 84,
U = 85,
V = 86,
W = 87,
X = 88,
Y = 89,
Z = 90,
/**
* The `[` character.
*/
OpenSquareBracket = 91,
/**
* The `\` character.
*/
Backslash = 92,
/**
* The `]` character.
*/
CloseSquareBracket = 93,
/**
* The `^` character.
*/
Caret = 94,
/**
* The `_` character.
*/
Underline = 95,
/**
* The ``(`)`` character.
*/
BackTick = 96,
a = 97,
b = 98,
c = 99,
d = 100,
e = 101,
f = 102,
g = 103,
h = 104,
i = 105,
j = 106,
k = 107,
l = 108,
m = 109,
n = 110,
o = 111,
p = 112,
q = 113,
r = 114,
s = 115,
t = 116,
u = 117,
v = 118,
w = 119,
x = 120,
y = 121,
z = 122,
/**
* The `{` character.
*/
OpenCurlyBrace = 123,
/**
* The `|` character.
*/
Pipe = 124,
/**
* The `}` character.
*/
CloseCurlyBrace = 125,
/**
* The `~` character.
*/
Tilde = 126,
}

View File

@ -0,0 +1,551 @@
/* eslint-disable no-console */
// MIT License
// Copyright (c) 2015 - present Microsoft Corporation
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { CharCode } from "./char-code";
const _debug = false;
export interface Match {
start: number;
end: number;
}
const _maxLen = 128;
function initTable() {
const table: number[][] = [];
const row: number[] = [];
for (let i = 0; i <= _maxLen; i++) {
row[i] = 0;
}
for (let i = 0; i <= _maxLen; i++) {
table.push(row.slice(0));
}
return table;
}
function isSeparatorAtPos(value: string, index: number): boolean {
if (index < 0 || index >= value.length) {
return false;
}
const code = value.codePointAt(index);
switch (code) {
case CharCode.Underline:
case CharCode.Dash:
case CharCode.Period:
case CharCode.Space:
case CharCode.Slash:
case CharCode.Backslash:
case CharCode.SingleQuote:
case CharCode.DoubleQuote:
case CharCode.Colon:
case CharCode.DollarSign:
case CharCode.LessThan:
case CharCode.OpenParen:
case CharCode.OpenSquareBracket:
return true;
case undefined:
return false;
default:
if (isEmojiImprecise(code)) {
return true;
}
return false;
}
}
function isWhitespaceAtPos(value: string, index: number): boolean {
if (index < 0 || index >= value.length) {
return false;
}
const code = value.charCodeAt(index);
switch (code) {
case CharCode.Space:
case CharCode.Tab:
return true;
default:
return false;
}
}
function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean {
return word[pos] !== wordLow[pos];
}
export function isPatternInWord(
patternLow: string,
patternPos: number,
patternLen: number,
wordLow: string,
wordPos: number,
wordLen: number,
fillMinWordPosArr = false
): boolean {
while (patternPos < patternLen && wordPos < wordLen) {
if (patternLow[patternPos] === wordLow[wordPos]) {
if (fillMinWordPosArr) {
// Remember the min word position for each pattern position
_minWordMatchPos[patternPos] = wordPos;
}
patternPos += 1;
}
wordPos += 1;
}
return patternPos === patternLen; // pattern must be exhausted
}
enum Arrow {
Diag = 1,
Left = 2,
LeftLeft = 3,
}
/**
* An array representing a fuzzy match.
*
* 0. the score
* 1. the offset at which matching started
* 2. `<match_pos_N>`
* 3. `<match_pos_1>`
* 4. `<match_pos_0>` etc
*/
// export type FuzzyScore = [score: number, wordStart: number, ...matches: number[]];// [number, number, number];
export type FuzzyScore = Array<number>;
export function fuzzyScore(
pattern: string,
patternLow: string,
patternStart: number,
word: string,
wordLow: string,
wordStart: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined {
const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length;
const wordLen = word.length > _maxLen ? _maxLen : word.length;
if (
patternStart >= patternLen ||
wordStart >= wordLen ||
patternLen - patternStart > wordLen - wordStart
) {
return undefined;
}
// Run a simple check if the characters of pattern occur
// (in order) at all in word. If that isn't the case we
// stop because no match will be possible
if (
!isPatternInWord(
patternLow,
patternStart,
patternLen,
wordLow,
wordStart,
wordLen,
true
)
) {
return undefined;
}
// Find the max matching word position for each pattern position
// NOTE: the min matching word position was filled in above, in the `isPatternInWord` call
_fillInMaxWordMatchPos(
patternLen,
wordLen,
patternStart,
wordStart,
patternLow,
wordLow
);
let row: number;
let column = 1;
let patternPos: number;
let wordPos: number;
const hasStrongFirstMatch = [false];
// There will be a match, fill in tables
for (
row = 1, patternPos = patternStart;
patternPos < patternLen;
row++, patternPos++
) {
// Reduce search space to possible matching word positions and to possible access from next row
const minWordMatchPos = _minWordMatchPos[patternPos];
const maxWordMatchPos = _maxWordMatchPos[patternPos];
const nextMaxWordMatchPos =
patternPos + 1 < patternLen ? _maxWordMatchPos[patternPos + 1] : wordLen;
for (
column = minWordMatchPos - wordStart + 1, wordPos = minWordMatchPos;
wordPos < nextMaxWordMatchPos;
column++, wordPos++
) {
let score = Number.MIN_SAFE_INTEGER;
let canComeDiag = false;
if (wordPos <= maxWordMatchPos) {
score = _doScore(
pattern,
patternLow,
patternPos,
patternStart,
word,
wordLow,
wordPos,
wordLen,
wordStart,
_diag[row - 1][column - 1] === 0,
hasStrongFirstMatch
);
}
let diagScore = 0;
if (score !== Number.MAX_SAFE_INTEGER) {
canComeDiag = true;
diagScore = score + _table[row - 1][column - 1];
}
const canComeLeft = wordPos > minWordMatchPos;
const leftScore = canComeLeft
? _table[row][column - 1] + (_diag[row][column - 1] > 0 ? -5 : 0)
: 0; // penalty for a gap start
const canComeLeftLeft =
wordPos > minWordMatchPos + 1 && _diag[row][column - 1] > 0;
const leftLeftScore = canComeLeftLeft
? _table[row][column - 2] + (_diag[row][column - 2] > 0 ? -5 : 0)
: 0; // penalty for a gap start
if (
canComeLeftLeft &&
(!canComeLeft || leftLeftScore >= leftScore) &&
(!canComeDiag || leftLeftScore >= diagScore)
) {
// always prefer choosing left left to jump over a diagonal because that means a match is earlier in the word
_table[row][column] = leftLeftScore;
_arrows[row][column] = Arrow.LeftLeft;
_diag[row][column] = 0;
} else if (canComeLeft && (!canComeDiag || leftScore >= diagScore)) {
// always prefer choosing left since that means a match is earlier in the word
_table[row][column] = leftScore;
_arrows[row][column] = Arrow.Left;
_diag[row][column] = 0;
} else if (canComeDiag) {
_table[row][column] = diagScore;
_arrows[row][column] = Arrow.Diag;
_diag[row][column] = _diag[row - 1][column - 1] + 1;
} else {
throw new Error(`not possible`);
}
}
}
if (_debug) {
printTables(pattern, patternStart, word, wordStart);
}
if (!hasStrongFirstMatch[0] && !firstMatchCanBeWeak) {
return undefined;
}
row--;
column--;
const result: FuzzyScore = [_table[row][column], wordStart];
let backwardsDiagLength = 0;
let maxMatchColumn = 0;
while (row >= 1) {
// Find the column where we go diagonally up
let diagColumn = column;
do {
const arrow = _arrows[row][diagColumn];
if (arrow === Arrow.LeftLeft) {
diagColumn -= 2;
} else if (arrow === Arrow.Left) {
diagColumn -= 1;
} else {
// found the diagonal
break;
}
} while (diagColumn >= 1);
// Overturn the "forwards" decision if keeping the "backwards" diagonal would give a better match
if (
backwardsDiagLength > 1 && // only if we would have a contiguous match of 3 characters
patternLow[patternStart + row - 1] === wordLow[wordStart + column - 1] && // only if we can do a contiguous match diagonally
!isUpperCaseAtPos(diagColumn + wordStart - 1, word, wordLow) && // only if the forwards chose diagonal is not an uppercase
backwardsDiagLength + 1 > _diag[row][diagColumn] // only if our contiguous match would be longer than the "forwards" contiguous match
) {
diagColumn = column;
}
if (diagColumn === column) {
// this is a contiguous match
backwardsDiagLength++;
} else {
backwardsDiagLength = 1;
}
if (!maxMatchColumn) {
// remember the last matched column
maxMatchColumn = diagColumn;
}
row--;
column = diagColumn - 1;
result.push(column);
}
if (wordLen === patternLen) {
// the word matches the pattern with all characters!
// giving the score a total match boost (to come up ahead other words)
result[0] += 2;
}
// Add 1 penalty for each skipped character in the word
const skippedCharsCount = maxMatchColumn - patternLen;
result[0] -= skippedCharsCount;
return result;
}
function _doScore(
pattern: string,
patternLow: string,
patternPos: number,
patternStart: number,
word: string,
wordLow: string,
wordPos: number,
wordLen: number,
wordStart: number,
newMatchStart: boolean,
outFirstMatchStrong: boolean[]
): number {
if (patternLow[patternPos] !== wordLow[wordPos]) {
return Number.MIN_SAFE_INTEGER;
}
let score = 1;
let isGapLocation = false;
if (wordPos === patternPos - patternStart) {
// common prefix: `foobar <-> foobaz`
// ^^^^^
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
} else if (
isUpperCaseAtPos(wordPos, word, wordLow) &&
(wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow))
) {
// hitting upper-case: `foo <-> forOthers`
// ^^ ^
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
isGapLocation = true;
} else if (
isSeparatorAtPos(wordLow, wordPos) &&
(wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1))
) {
// hitting a separator: `. <-> foo.bar`
// ^
score = 5;
} else if (
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1)
) {
// post separator: `foo <-> bar_foo`
// ^^^
score = 5;
isGapLocation = true;
}
if (score > 1 && patternPos === patternStart) {
outFirstMatchStrong[0] = true;
}
if (!isGapLocation) {
isGapLocation =
isUpperCaseAtPos(wordPos, word, wordLow) ||
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1);
}
//
if (patternPos === patternStart) {
// first character in pattern
if (wordPos > wordStart) {
// the first pattern character would match a word character that is not at the word start
// so introduce a penalty to account for the gap preceding this match
score -= isGapLocation ? 3 : 5;
}
} else if (newMatchStart) {
// this would be the beginning of a new match (i.e. there would be a gap before this location)
score += isGapLocation ? 2 : 0;
} else {
// this is part of a contiguous match, so give it a slight bonus, but do so only if it would not be a prefered gap location
score += isGapLocation ? 0 : 1;
}
if (wordPos + 1 === wordLen) {
// we always penalize gaps, but this gives unfair advantages to a match that would match the last character in the word
// so pretend there is a gap after the last character in the word to normalize things
score -= isGapLocation ? 3 : 5;
}
return score;
}
function printTable(
table: number[][],
pattern: string,
patternLen: number,
word: string,
wordLen: number
): string {
function pad(s: string, n: number, _pad = " ") {
while (s.length < n) {
s = _pad + s;
}
return s;
}
let ret = ` | |${word
.split("")
.map((c) => pad(c, 3))
.join("|")}\n`;
for (let i = 0; i <= patternLen; i++) {
if (i === 0) {
ret += " |";
} else {
ret += `${pattern[i - 1]}|`;
}
ret +=
table[i]
.slice(0, wordLen + 1)
.map((n) => pad(n.toString(), 3))
.join("|") + "\n";
}
return ret;
}
function printTables(
pattern: string,
patternStart: number,
word: string,
wordStart: number
): void {
pattern = pattern.substr(patternStart);
word = word.substr(wordStart);
console.log(printTable(_table, pattern, pattern.length, word, word.length));
console.log(printTable(_arrows, pattern, pattern.length, word, word.length));
console.log(printTable(_diag, pattern, pattern.length, word, word.length));
}
const _minWordMatchPos = initArr(2 * _maxLen); // min word position for a certain pattern position
const _maxWordMatchPos = initArr(2 * _maxLen); // max word position for a certain pattern position
const _diag = initTable(); // the length of a contiguous diagonal match
const _table = initTable();
const _arrows = <Arrow[][]>initTable();
function initArr(maxLen: number) {
const row: number[] = [];
for (let i = 0; i <= maxLen; i++) {
row[i] = 0;
}
return row;
}
function _fillInMaxWordMatchPos(
patternLen: number,
wordLen: number,
patternStart: number,
wordStart: number,
patternLow: string,
wordLow: string
) {
let patternPos = patternLen - 1;
let wordPos = wordLen - 1;
while (patternPos >= patternStart && wordPos >= wordStart) {
if (patternLow[patternPos] === wordLow[wordPos]) {
_maxWordMatchPos[patternPos] = wordPos;
patternPos--;
}
wordPos--;
}
}
export interface FuzzyScorer {
(
pattern: string,
lowPattern: string,
patternPos: number,
word: string,
lowWord: string,
wordPos: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined;
}
export function createMatches(score: undefined | FuzzyScore): Match[] {
if (typeof score === "undefined") {
return [];
}
const res: Match[] = [];
const wordPos = score[1];
for (let i = score.length - 1; i > 1; i--) {
const pos = score[i] + wordPos;
const last = res[res.length - 1];
if (last && last.end === pos) {
last.end = pos + 1;
} else {
res.push({ start: pos, end: pos + 1 });
}
}
return res;
}
/**
* A fast function (therefore imprecise) to check if code points are emojis.
* Generated using https://github.com/alexdima/unicode-utils/blob/master/generate-emoji-test.js
*/
export function isEmojiImprecise(x: number): boolean {
return (
(x >= 0x1f1e6 && x <= 0x1f1ff) ||
x === 8986 ||
x === 8987 ||
x === 9200 ||
x === 9203 ||
(x >= 9728 && x <= 10175) ||
x === 11088 ||
x === 11093 ||
(x >= 127744 && x <= 128591) ||
(x >= 128640 && x <= 128764) ||
(x >= 128992 && x <= 129003) ||
(x >= 129280 && x <= 129535) ||
(x >= 129648 && x <= 129750)
);
}

View File

@ -1,4 +1,52 @@
import fuzzysort from "fuzzysort";
import { fuzzyScore } from "./filter";
/**
* Determine whether a sequence of letters exists in another string,
* in that order, allowing for skipping. Ex: "chdr" exists in "chandelier")
*
* @param {string} filter - Sequence of letters to check for
* @param {ScorableTextItem} item - Item against whose strings will be checked
*
* @return {number} Score representing how well the word matches the filter. Return of 0 means no match.
*/
export const fuzzySequentialMatch = (
filter: string,
item: ScorableTextItem
) => {
let topScore = Number.NEGATIVE_INFINITY;
for (const word of item.strings) {
const scores = fuzzyScore(
filter,
filter.toLowerCase(),
0,
word,
word.toLowerCase(),
0,
true
);
if (!scores) {
continue;
}
// 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) {
topScore = score;
}
}
if (topScore === Number.NEGATIVE_INFINITY) {
return undefined;
}
return topScore;
};
/**
* An interface that objects must extend in order to use the fuzzy sequence matcher
@ -18,48 +66,18 @@ export interface ScorableTextItem {
strings: string[];
}
export type FuzzyFilterSort = <T extends ScorableTextItem>(
type FuzzyFilterSort = <T extends ScorableTextItem>(
filter: string,
items: T[]
) => T[];
export function fuzzyMatcher(search: string | null): (string) => boolean {
const scorer = fuzzyScorer(search);
return (value: string) => scorer([value]) !== Number.NEGATIVE_INFINITY;
}
export function fuzzyScorer(
search: string | null
): (values: string[]) => number {
const searchTerms = (search || "").match(/("[^"]+"|[^"\s]+)/g);
if (!searchTerms) {
return () => 0;
}
return (values) =>
searchTerms
.map((term) => {
const resultsForTerm = fuzzysort.go(term, values, {
allowTypo: true,
});
if (resultsForTerm.length > 0) {
return Math.max(...resultsForTerm.map((result) => result.score));
}
return Number.NEGATIVE_INFINITY;
})
.reduce((partial, current) => partial + current, 0);
}
export const fuzzySortFilterSort: FuzzyFilterSort = (filter, items) => {
const scorer = fuzzyScorer(filter);
return items
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) =>
items
.map((item) => {
item.score = scorer(item.strings);
item.score = fuzzySequentialMatch(filter, item);
return item;
})
.filter((item) => item.score !== undefined && item.score > -100000)
.filter((item) => item.score !== undefined)
.sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) =>
scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0
);
};
export const defaultFuzzyFilterSort = fuzzySortFilterSort;

View File

@ -7,26 +7,25 @@ import type {
SortableColumnContainer,
SortingDirection,
} from "./ha-data-table";
import { fuzzyMatcher } from "../../common/string/filter/sequence-matching";
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
const matcher = fuzzyMatcher(filter);
filter = filter.toUpperCase();
return data.filter((row) =>
Object.entries(columns).some((columnEntry) => {
const [key, column] = columnEntry;
if (column.filterable) {
if (
matcher(
String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
)
String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
)
.toUpperCase()
.includes(filter)
) {
return true;
}

View File

@ -15,7 +15,6 @@ import type { HaComboBox } from "../ha-combo-box";
import "../ha-icon-button";
import "../ha-svg-icon";
import "./state-badge";
import { defaultFuzzyFilterSort } from "../../common/string/filter/sequence-matching";
interface HassEntityWithCachedName extends HassEntity {
friendly_name: string;
@ -337,18 +336,11 @@ export class HaEntityPicker extends LitElement {
}
private _filterChanged(ev: CustomEvent): void {
const filterString = ev.detail.value;
const sortableEntityStates = this._states.map((entityState) => ({
strings: [entityState.entity_id, computeStateName(entityState)],
entityState: entityState,
}));
const sortedEntityStates = defaultFuzzyFilterSort(
filterString,
sortableEntityStates
);
(this.comboBox as any).filteredItems = sortedEntityStates.map(
(sortableItem) => sortableItem.entityState
const filterString = ev.detail.value.toLowerCase();
(this.comboBox as any).filteredItems = this._states.filter(
(entityState) =>
entityState.entity_id.toLowerCase().includes(filterString) ||
computeStateName(entityState).toLowerCase().includes(filterString)
);
}

View File

@ -25,7 +25,7 @@ import { domainIcon } from "../../common/entity/domain_icon";
import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import {
defaultFuzzyFilterSort,
fuzzyFilterSort,
ScorableTextItem,
} from "../../common/string/filter/sequence-matching";
import { debounce } from "../../common/util/debounce";
@ -725,7 +725,7 @@ export class QuickBar extends LitElement {
private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] =>
defaultFuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
);
static get styles() {

View File

@ -1,7 +1,8 @@
import { assert, expect } from "chai";
import { assert } from "chai";
import {
fuzzySortFilterSort,
fuzzyFilterSort,
fuzzySequentialMatch,
ScorableTextItem,
} from "../../../src/common/string/filter/sequence-matching";
@ -10,34 +11,45 @@ describe("fuzzySequentialMatch", () => {
strings: ["automation.ticker", "Stocks"],
};
const createExpectation: (
pattern,
expected
) => {
pattern: string;
expected: string | number | undefined;
} = (pattern, expected) => ({
pattern,
expected,
});
const shouldMatchEntity = [
"",
" ",
"automation.ticker",
"stocks",
"automation.ticke",
"automation. ticke",
"automation.",
"automationticker",
"automation.r",
"aumatick",
"tion.tick",
"aion.tck",
"s",
"au.tce",
"au",
"ticker",
"tick",
"ioticker",
"sks",
"tomaontkr",
"atmto.ikr",
"uoaintce",
createExpectation("automation.ticker", 131),
createExpectation("automation.ticke", 121),
createExpectation("automation.", 82),
createExpectation("au", 10),
createExpectation("automationticker", 85),
createExpectation("tion.tick", 8),
createExpectation("ticker", -4),
createExpectation("automation.r", 73),
createExpectation("tick", -8),
createExpectation("aumatick", 9),
createExpectation("aion.tck", 4),
createExpectation("ioticker", -4),
createExpectation("atmto.ikr", -34),
createExpectation("uoaintce", -39),
createExpectation("au.tce", -3),
createExpectation("tomaontkr", -19),
createExpectation("s", 1),
createExpectation("stocks", 42),
createExpectation("sks", -5),
];
const shouldNotMatchEntity = [
"",
" ",
"abcdefghijklmnopqrstuvwxyz",
"automation.tickerz",
"automation. ticke",
"1",
"noitamotua",
"autostocks",
@ -45,23 +57,23 @@ describe("fuzzySequentialMatch", () => {
];
describe(`Entity '${item.strings[0]}'`, () => {
for (const filter of shouldMatchEntity) {
it(`Should matches ${filter}`, () => {
const res = fuzzySortFilterSort(filter, [item]);
assert.lengthOf(res, 1);
for (const expectation of shouldMatchEntity) {
it(`matches '${expectation.pattern}' with return of '${expectation.expected}'`, () => {
const res = fuzzySequentialMatch(expectation.pattern, item);
assert.equal(res, expectation.expected);
});
}
for (const badFilter of shouldNotMatchEntity) {
it(`fails to match with '${badFilter}'`, () => {
const res = fuzzySortFilterSort(badFilter, [item]);
assert.lengthOf(res, 0);
const res = fuzzySequentialMatch(badFilter, item);
assert.equal(res, undefined);
});
}
});
});
describe("fuzzyFilterSort original tests", () => {
describe("fuzzyFilterSort", () => {
const filter = "ticker";
const automationTicker = {
strings: ["automation.ticker", "Stocks"],
@ -93,137 +105,14 @@ describe("fuzzyFilterSort original tests", () => {
it(`filters and sorts correctly`, () => {
const expectedItemsAfterFilter = [
{ ...ticker, score: 0 },
{ ...sensorTicker, score: -14 },
{ ...automationTicker, score: -22 },
{ ...timerCheckRouter, score: -32012 },
{ ...ticker, score: 44 },
{ ...sensorTicker, score: 1 },
{ ...automationTicker, score: -4 },
{ ...timerCheckRouter, score: -8 },
];
const res = fuzzySortFilterSort(filter, itemsBeforeFilter);
const res = fuzzyFilterSort(filter, itemsBeforeFilter);
assert.deepEqual(res, expectedItemsAfterFilter);
});
});
describe("Fuzzy filter new tests", () => {
const testEntities = [
{
id: "binary_sensor.garage_door_opened",
name: "Garage Door Opened (Sensor, Binary)",
},
{
id: "sensor.garage_door_status",
name: "Garage Door Opened (Sensor)",
},
{
id: "sensor.temperature_living_room",
name: "[Living room] temperature",
},
{
id: "sensor.temperature_parents_bedroom",
name: "[Parents bedroom] temperature",
},
{
id: "sensor.temperature_children_bedroom",
name: "[Children bedroom] temperature",
},
];
function testEntitySearch(
searchInput: string | null,
expectedResults: string[]
) {
const sortableEntities = testEntities.map((entity) => ({
strings: [entity.id, entity.name],
entity: entity,
}));
const sortedEntities = fuzzySortFilterSort(
searchInput || "",
sortableEntities
);
// console.log(sortedEntities);
expect(sortedEntities.map((it) => it.entity.id)).to.have.ordered.members(
expectedResults
);
}
it(`test empty or null query`, () => {
testEntitySearch(
"",
testEntities.map((it) => it.id)
);
testEntitySearch(
null,
testEntities.map((it) => it.id)
);
});
it(`test single word search`, () => {
testEntitySearch("bedroom", [
"sensor.temperature_parents_bedroom",
"sensor.temperature_children_bedroom",
]);
});
it(`test no result`, () => {
testEntitySearch("does not exist", []);
testEntitySearch("betroom", []);
});
it(`test single word search with typo`, () => {
testEntitySearch("bedorom", [
"sensor.temperature_parents_bedroom",
"sensor.temperature_children_bedroom",
]);
});
it(`test multi word search`, () => {
testEntitySearch("bedroom children", [
"sensor.temperature_children_bedroom",
]);
});
it(`test partial word search`, () => {
testEntitySearch("room", [
"sensor.temperature_living_room",
"sensor.temperature_parents_bedroom",
"sensor.temperature_children_bedroom",
]);
});
it(`test mixed cased word search`, () => {
testEntitySearch("garage binary", ["binary_sensor.garage_door_opened"]);
});
it(`test mixed id and name search`, () => {
testEntitySearch("status opened", ["sensor.garage_door_status"]);
});
it(`test special chars in query`, () => {
testEntitySearch("sensor.temperature", [
"sensor.temperature_living_room",
"sensor.temperature_parents_bedroom",
"sensor.temperature_children_bedroom",
]);
testEntitySearch("sensor.temperature parents", [
"sensor.temperature_parents_bedroom",
]);
testEntitySearch("parents_Bedroom", ["sensor.temperature_parents_bedroom"]);
});
it(`test search in name`, () => {
testEntitySearch("Binary)", ["binary_sensor.garage_door_opened"]);
testEntitySearch("Binary)NotExists", []);
});
it(`test regex special chars`, () => {
// Should return an empty result, but no error
testEntitySearch("\\{}()*+?.,[])", []);
testEntitySearch("[Children bedroom]", [
"sensor.temperature_children_bedroom",
]);
});
});

View File

@ -8433,13 +8433,6 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
"fuzzysort@npm:^1.2.1":
version: 1.2.1
resolution: "fuzzysort@npm:1.2.1"
checksum: 74dad902a0aef6c3237d5ae5330aacca23d408f0e07125fcc39b57561b4c29da512fbf3826c3f3918da89f132f5b393cf5d56b3217282ecfb80a90124bdf03d1
languageName: node
linkType: hard
"gauge@npm:~2.7.3":
version: 2.7.4
resolution: "gauge@npm:2.7.4"
@ -9126,7 +9119,6 @@ fsevents@^1.2.7:
fancy-log: ^1.3.3
fs-extra: ^7.0.1
fuse.js: ^6.0.0
fuzzysort: ^1.2.1
glob: ^7.2.0
google-timezones-json: ^1.0.2
gulp: ^4.0.2