diff --git a/package.json b/package.json index 25cbfbe761..23438c75eb 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "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", diff --git a/src/common/string/filter/char-code.ts b/src/common/string/filter/char-code.ts deleted file mode 100644 index faa7210898..0000000000 --- a/src/common/string/filter/char-code.ts +++ /dev/null @@ -1,244 +0,0 @@ -// 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, -} diff --git a/src/common/string/filter/filter.ts b/src/common/string/filter/filter.ts deleted file mode 100644 index e7c0103263..0000000000 --- a/src/common/string/filter/filter.ts +++ /dev/null @@ -1,551 +0,0 @@ -/* 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. `` - * 3. `` - * 4. `` etc - */ -// export type FuzzyScore = [score: number, wordStart: number, ...matches: number[]];// [number, number, number]; -export type FuzzyScore = Array; - -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 = 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) - ); -} diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index d502cec350..d0ec1f1e52 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -1,52 +1,4 @@ -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; -}; +import fuzzysort from "fuzzysort"; /** * An interface that objects must extend in order to use the fuzzy sequence matcher @@ -66,18 +18,48 @@ export interface ScorableTextItem { strings: string[]; } -type FuzzyFilterSort = ( +export type FuzzyFilterSort = ( filter: string, items: T[] ) => T[]; -export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => - items +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 .map((item) => { - item.score = fuzzySequentialMatch(filter, item); + item.score = scorer(item.strings); return item; }) - .filter((item) => item.score !== undefined) + .filter((item) => item.score !== undefined && item.score > -100000) .sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) => scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0 ); +}; + +export const defaultFuzzyFilterSort = fuzzySortFilterSort; diff --git a/src/components/data-table/sort_filter_worker.ts b/src/components/data-table/sort_filter_worker.ts index 286bae3014..c2b252f859 100644 --- a/src/components/data-table/sort_filter_worker.ts +++ b/src/components/data-table/sort_filter_worker.ts @@ -7,25 +7,26 @@ import type { SortableColumnContainer, SortingDirection, } from "./ha-data-table"; +import { fuzzyMatcher } from "../../common/string/filter/sequence-matching"; const filterData = ( data: DataTableRowData[], columns: SortableColumnContainer, filter: string ) => { - filter = filter.toUpperCase(); + const matcher = fuzzyMatcher(filter); return data.filter((row) => Object.entries(columns).some((columnEntry) => { const [key, column] = columnEntry; if (column.filterable) { if ( - String( - column.filterKey - ? row[column.valueColumn || key][column.filterKey] - : row[column.valueColumn || key] + matcher( + String( + column.filterKey + ? row[column.valueColumn || key][column.filterKey] + : row[column.valueColumn || key] + ) ) - .toUpperCase() - .includes(filter) ) { return true; } diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index 7f9c8d3d80..ad921650ce 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -15,6 +15,7 @@ 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; @@ -336,11 +337,18 @@ export class HaEntityPicker extends LitElement { } private _filterChanged(ev: CustomEvent): void { - 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) + 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 ); } diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 0165a14aca..8e7fbefd74 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -24,7 +24,7 @@ import { domainIcon } from "../../common/entity/domain_icon"; import { navigate } from "../../common/navigate"; import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { - fuzzyFilterSort, + defaultFuzzyFilterSort, ScorableTextItem, } from "../../common/string/filter/sequence-matching"; import { debounce } from "../../common/util/debounce"; @@ -694,7 +694,7 @@ export class QuickBar extends LitElement { private _filterItems = memoizeOne( (items: QuickBarItem[], filter: string): QuickBarItem[] => - fuzzyFilterSort(filter.trimLeft(), items) + defaultFuzzyFilterSort(filter.trimLeft(), items) ); static get styles() { diff --git a/test/common/string/sequence_matching.test.ts b/test/common/string/sequence_matching.test.ts index f631a23285..8f8f63bada 100644 --- a/test/common/string/sequence_matching.test.ts +++ b/test/common/string/sequence_matching.test.ts @@ -1,8 +1,7 @@ -import { assert } from "chai"; +import { assert, expect } from "chai"; import { - fuzzyFilterSort, - fuzzySequentialMatch, + fuzzySortFilterSort, ScorableTextItem, } from "../../../src/common/string/filter/sequence-matching"; @@ -11,45 +10,34 @@ describe("fuzzySequentialMatch", () => { strings: ["automation.ticker", "Stocks"], }; - const createExpectation: ( - pattern, - expected - ) => { - pattern: string; - expected: string | number | undefined; - } = (pattern, expected) => ({ - pattern, - expected, - }); - const shouldMatchEntity = [ - 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), + "", + " ", + "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", ]; const shouldNotMatchEntity = [ - "", - " ", "abcdefghijklmnopqrstuvwxyz", "automation.tickerz", - "automation. ticke", "1", "noitamotua", "autostocks", @@ -57,23 +45,23 @@ describe("fuzzySequentialMatch", () => { ]; describe(`Entity '${item.strings[0]}'`, () => { - 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 filter of shouldMatchEntity) { + it(`Should matches ${filter}`, () => { + const res = fuzzySortFilterSort(filter, [item]); + assert.lengthOf(res, 1); }); } for (const badFilter of shouldNotMatchEntity) { it(`fails to match with '${badFilter}'`, () => { - const res = fuzzySequentialMatch(badFilter, item); - assert.equal(res, undefined); + const res = fuzzySortFilterSort(badFilter, [item]); + assert.lengthOf(res, 0); }); } }); }); -describe("fuzzyFilterSort", () => { +describe("fuzzyFilterSort original tests", () => { const filter = "ticker"; const automationTicker = { strings: ["automation.ticker", "Stocks"], @@ -105,14 +93,137 @@ describe("fuzzyFilterSort", () => { it(`filters and sorts correctly`, () => { const expectedItemsAfterFilter = [ - { ...ticker, score: 44 }, - { ...sensorTicker, score: 1 }, - { ...automationTicker, score: -4 }, - { ...timerCheckRouter, score: -8 }, + { ...ticker, score: 0 }, + { ...sensorTicker, score: -14 }, + { ...automationTicker, score: -22 }, + { ...timerCheckRouter, score: -32012 }, ]; - const res = fuzzyFilterSort(filter, itemsBeforeFilter); + const res = fuzzySortFilterSort(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", + ]); + }); +}); diff --git a/yarn.lock b/yarn.lock index af02cfdc2a..1275415a57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8433,6 +8433,13 @@ 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" @@ -9119,6 +9126,7 @@ 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