diff --git a/src/core/lib/FuzzySearch.mjs b/src/core/lib/FuzzySearch.mjs new file mode 100644 index 00000000..662d7760 --- /dev/null +++ b/src/core/lib/FuzzySearch.mjs @@ -0,0 +1,220 @@ +/** + * LICENSE + * + * This software is dual-licensed to the public domain and under the following + * license: you are granted a perpetual, irrevocable license to copy, modify, + * publish, and distribute this file as you see fit. + * + * VERSION + * 0.1.0 (2016-03-28) Initial release + * + * AUTHOR + * Forrest Smith + * + * CONTRIBUTORS + * J�rgen Tjern� - async helper + * Anurag Awasthi - updated to 0.2.0 + */ + +const SEQUENTIAL_BONUS = 15; // bonus for adjacent matches +const SEPARATOR_BONUS = 30; // bonus if match occurs after a separator +const CAMEL_BONUS = 30; // bonus if match is uppercase and prev is lower +const FIRST_LETTER_BONUS = 15; // bonus if the first letter is matched + +const LEADING_LETTER_PENALTY = -5; // penalty applied for every letter in str before the first match +const MAX_LEADING_LETTER_PENALTY = -15; // maximum penalty for leading letters +const UNMATCHED_LETTER_PENALTY = -1; + +/** + * Does a fuzzy search to find pattern inside a string. + * @param {*} pattern string pattern to search for + * @param {*} str string string which is being searched + * @returns [boolean, number] a boolean which tells if pattern was + * found or not and a search score + */ +export function fuzzyMatch(pattern, str) { + const recursionCount = 0; + const recursionLimit = 10; + const matches = []; + const maxMatches = 256; + + return fuzzyMatchRecursive( + pattern, + str, + 0 /* patternCurIndex */, + 0 /* strCurrIndex */, + null /* srcMatces */, + matches, + maxMatches, + 0 /* nextMatch */, + recursionCount, + recursionLimit + ); +} + +/** + * Recursive helper function + */ +function fuzzyMatchRecursive( + pattern, + str, + patternCurIndex, + strCurrIndex, + srcMatches, + matches, + maxMatches, + nextMatch, + recursionCount, + recursionLimit +) { + let outScore = 0; + + // Return if recursion limit is reached. + if (++recursionCount >= recursionLimit) { + return [false, outScore]; + } + + // Return if we reached ends of strings. + if (patternCurIndex === pattern.length || strCurrIndex === str.length) { + return [false, outScore]; + } + + // Recursion params + let recursiveMatch = false; + let bestRecursiveMatches = []; + let bestRecursiveScore = 0; + + // Loop through pattern and str looking for a match. + let firstMatch = true; + while (patternCurIndex < pattern.length && strCurrIndex < str.length) { + // Match found. + if ( + pattern[patternCurIndex].toLowerCase() === str[strCurrIndex].toLowerCase() + ) { + if (nextMatch >= maxMatches) { + return [false, outScore]; + } + + if (firstMatch && srcMatches) { + matches = [...srcMatches]; + firstMatch = false; + } + + const recursiveMatches = []; + const [matched, recursiveScore] = fuzzyMatchRecursive( + pattern, + str, + patternCurIndex, + strCurrIndex + 1, + matches, + recursiveMatches, + maxMatches, + nextMatch, + recursionCount, + recursionLimit + ); + + if (matched) { + // Pick best recursive score. + if (!recursiveMatch || recursiveScore > bestRecursiveScore) { + bestRecursiveMatches = [...recursiveMatches]; + bestRecursiveScore = recursiveScore; + } + recursiveMatch = true; + } + + matches[nextMatch++] = strCurrIndex; + ++patternCurIndex; + } + ++strCurrIndex; + } + + const matched = patternCurIndex === pattern.length; + + if (matched) { + outScore = 100; + + // Apply leading letter penalty + let penalty = LEADING_LETTER_PENALTY * matches[0]; + penalty = + penalty < MAX_LEADING_LETTER_PENALTY ? + MAX_LEADING_LETTER_PENALTY : + penalty; + outScore += penalty; + + // Apply unmatched penalty + const unmatched = str.length - nextMatch; + outScore += UNMATCHED_LETTER_PENALTY * unmatched; + + // Apply ordering bonuses + for (let i = 0; i < nextMatch; i++) { + const currIdx = matches[i]; + + if (i > 0) { + const prevIdx = matches[i - 1]; + if (currIdx === prevIdx + 1) { + outScore += SEQUENTIAL_BONUS; + } + } + + // Check for bonuses based on neighbor character value. + if (currIdx > 0) { + // Camel case + const neighbor = str[currIdx - 1]; + const curr = str[currIdx]; + if ( + neighbor !== neighbor.toUpperCase() && + curr !== curr.toLowerCase() + ) { + outScore += CAMEL_BONUS; + } + const isNeighbourSeparator = neighbor === "_" || neighbor === " "; + if (isNeighbourSeparator) { + outScore += SEPARATOR_BONUS; + } + } else { + // First letter + outScore += FIRST_LETTER_BONUS; + } + } + + // Return best result + if (recursiveMatch && (!matched || bestRecursiveScore > outScore)) { + // Recursive score is better than "this" + matches = [...bestRecursiveMatches]; + outScore = bestRecursiveScore; + return [true, outScore, calcMatchRanges(matches)]; + } else if (matched) { + // "this" score is better than recursive + return [true, outScore, calcMatchRanges(matches)]; + } else { + return [false, outScore]; + } + } + return [false, outScore]; +} + +/** + * Turns a list of match indexes into a list of match ranges + * + * @author n1474335 [n1474335@gmail.com] + * @param [number] matches + * @returns [[number]] + */ +function calcMatchRanges(matches) { + const ranges = []; + let start = matches[0], + curr = start; + + matches.forEach(m => { + if (m === curr || m === curr + 1) curr = m; + else { + ranges.push([start, curr - start + 1]); + start = m; + curr = m; + } + }); + + ranges.push([start, curr - start + 1]); + return ranges; +} diff --git a/src/web/HTMLOperation.mjs b/src/web/HTMLOperation.mjs index fe075c48..a8c89922 100755 --- a/src/web/HTMLOperation.mjs +++ b/src/web/HTMLOperation.mjs @@ -91,32 +91,51 @@ class HTMLOperation { /** - * Highlights the searched string in the name and description of the operation. + * Highlights searched strings in the name and description of the operation. * - * @param {string} searchStr - * @param {number} namePos - The position of the search string in the operation name - * @param {number} descPos - The position of the search string in the operation description + * @param {[[number]]} nameIdxs - Indexes of the search strings in the operation name [[start, length]] + * @param {[[number]]} descIdxs - Indexes of the search strings in the operation description [[start, length]] */ - highlightSearchString(searchStr, namePos, descPos) { - if (namePos >= 0) { - this.name = this.name.slice(0, namePos) + "" + - this.name.slice(namePos, namePos + searchStr.length) + "" + - this.name.slice(namePos + searchStr.length); + highlightSearchStrings(nameIdxs, descIdxs) { + if (nameIdxs.length) { + let opName = "", + pos = 0; + + nameIdxs.forEach(idxs => { + const [start, length] = idxs; + opName += this.name.slice(pos, start) + "" + + this.name.slice(start, start + length) + ""; + pos = start + length; + }); + opName += this.name.slice(pos, this.name.length); + this.name = opName; } - if (this.description && descPos >= 0) { + if (this.description && descIdxs.length) { // Find HTML tag offsets const re = /<[^>]+>/g; let match; while ((match = re.exec(this.description))) { // If the search string occurs within an HTML tag, return without highlighting it. - if (descPos >= match.index && descPos <= (match.index + match[0].length)) - return; + const inHTMLTag = descIdxs.reduce((acc, idxs) => { + const start = idxs[0]; + return start >= match.index && start <= (match.index + match[0].length); + }, false); + + if (inHTMLTag) return; } - this.description = this.description.slice(0, descPos) + "" + - this.description.slice(descPos, descPos + searchStr.length) + "" + - this.description.slice(descPos + searchStr.length); + let desc = "", + pos = 0; + + descIdxs.forEach(idxs => { + const [start, length] = idxs; + desc += this.description.slice(pos, start) + "" + + this.description.slice(start, start + length) + ""; + pos = start + length; + }); + desc += this.description.slice(pos, this.description.length); + this.description = desc; } } diff --git a/src/web/waiters/OperationsWaiter.mjs b/src/web/waiters/OperationsWaiter.mjs index 4a591249..d2da074c 100755 --- a/src/web/waiters/OperationsWaiter.mjs +++ b/src/web/waiters/OperationsWaiter.mjs @@ -6,6 +6,7 @@ import HTMLOperation from "../HTMLOperation.mjs"; import Sortable from "sortablejs"; +import {fuzzyMatch} from "../../core/lib/FuzzySearch.mjs"; /** @@ -112,24 +113,31 @@ class OperationsWaiter { for (const opName in this.app.operations) { const op = this.app.operations[opName]; - const namePos = opName.toLowerCase().indexOf(searchStr); + + // Match op name using fuzzy match + const [nameMatch, score, idxs] = fuzzyMatch(inStr, opName); + + // Match description based on exact match const descPos = op.description.toLowerCase().indexOf(searchStr); - if (namePos >= 0 || descPos >= 0) { + if (nameMatch || descPos >= 0) { const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager); if (highlight) { - operation.highlightSearchString(searchStr, namePos, descPos); + operation.highlightSearchStrings(idxs || [], [[descPos, searchStr.length]]); } - if (namePos < 0) { - matchedOps.push(operation); + if (nameMatch) { + matchedOps.push([operation, score]); } else { matchedDescs.push(operation); } } } - return matchedDescs.concat(matchedOps); + // Sort matched operations based on fuzzy score + matchedOps.sort((a, b) => b[1] - a[1]); + + return matchedOps.map(a => a[0]).concat(matchedDescs); }