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);
}