From ce77ddf3659f082de8f1aaf9a710149da4281bbc Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Fri, 6 May 2022 21:48:57 -0500 Subject: [PATCH] Revert #10991 (#12618) --- package.json | 1 - src/common/string/filter/char-code.ts | 244 ++++++++ src/common/string/filter/filter.ts | 551 ++++++++++++++++++ src/common/string/filter/sequence-matching.ts | 90 +-- .../data-table/sort_filter_worker.ts | 15 +- src/components/entity/ha-entity-picker.ts | 18 +- src/dialogs/quick-bar/ha-quick-bar.ts | 4 +- test/common/string/sequence_matching.test.ts | 207 ++----- yarn.lock | 8 - 9 files changed, 911 insertions(+), 227 deletions(-) create mode 100644 src/common/string/filter/char-code.ts create mode 100644 src/common/string/filter/filter.ts diff --git a/package.json b/package.json index 23438c75eb..25cbfbe761 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/common/string/filter/char-code.ts b/src/common/string/filter/char-code.ts new file mode 100644 index 0000000000..faa7210898 --- /dev/null +++ b/src/common/string/filter/char-code.ts @@ -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, +} diff --git a/src/common/string/filter/filter.ts b/src/common/string/filter/filter.ts new file mode 100644 index 0000000000..e7c0103263 --- /dev/null +++ b/src/common/string/filter/filter.ts @@ -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. `` + * 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 d0ec1f1e52..d502cec350 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -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 = ( +type FuzzyFilterSort = ( 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; diff --git a/src/components/data-table/sort_filter_worker.ts b/src/components/data-table/sort_filter_worker.ts index c2b252f859..286bae3014 100644 --- a/src/components/data-table/sort_filter_worker.ts +++ b/src/components/data-table/sort_filter_worker.ts @@ -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; } diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index ad921650ce..7f9c8d3d80 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -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) ); } diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 42007f2684..c87ef4244b 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -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(filter.trimLeft(), items) + fuzzyFilterSort(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 8f8f63bada..f631a23285 100644 --- a/test/common/string/sequence_matching.test.ts +++ b/test/common/string/sequence_matching.test.ts @@ -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", - ]); - }); -}); diff --git a/yarn.lock b/yarn.lock index 1275415a57..af02cfdc2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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