Browse Source

Added fuzzy search for operations

n1474335 4 years ago
parent
commit
21236f1938
3 changed files with 268 additions and 21 deletions
  1. 220 0
      src/core/lib/FuzzySearch.mjs
  2. 34 15
      src/web/HTMLOperation.mjs
  3. 14 6
      src/web/waiters/OperationsWaiter.mjs

+ 220 - 0
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;
+}

+ 34 - 15
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) + "<b><u>" +
-                this.name.slice(namePos, namePos + searchStr.length) + "</u></b>" +
-                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) + "<b>" +
+                    this.name.slice(start, start + length) + "</b>";
+                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) + "<b><u>" +
-                this.description.slice(descPos, descPos + searchStr.length) + "</u></b>" +
-                this.description.slice(descPos + searchStr.length);
+            let desc = "",
+                pos = 0;
+
+            descIdxs.forEach(idxs => {
+                const [start, length] = idxs;
+                desc += this.description.slice(pos, start) + "<b><u>" +
+                    this.description.slice(start, start + length) + "</u></b>";
+                pos = start + length;
+            });
+            desc += this.description.slice(pos, this.description.length);
+            this.description = desc;
         }
     }
 

+ 14 - 6
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);
     }