Added fuzzy search for operations
This commit is contained in:
parent
4169a15066
commit
21236f1938
220
src/core/lib/FuzzySearch.mjs
Normal file
220
src/core/lib/FuzzySearch.mjs
Normal file
@ -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<EFBFBD>rgen Tjern<EFBFBD> - 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;
|
||||||
|
}
|
@ -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]]} nameIdxs - Indexes of the search strings in the operation name [[start, length]]
|
||||||
* @param {number} namePos - The position of the search string in the operation name
|
* @param {[[number]]} descIdxs - Indexes of the search strings in the operation description [[start, length]]
|
||||||
* @param {number} descPos - The position of the search string in the operation description
|
|
||||||
*/
|
*/
|
||||||
highlightSearchString(searchStr, namePos, descPos) {
|
highlightSearchStrings(nameIdxs, descIdxs) {
|
||||||
if (namePos >= 0) {
|
if (nameIdxs.length) {
|
||||||
this.name = this.name.slice(0, namePos) + "<b><u>" +
|
let opName = "",
|
||||||
this.name.slice(namePos, namePos + searchStr.length) + "</u></b>" +
|
pos = 0;
|
||||||
this.name.slice(namePos + searchStr.length);
|
|
||||||
|
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
|
// Find HTML tag offsets
|
||||||
const re = /<[^>]+>/g;
|
const re = /<[^>]+>/g;
|
||||||
let match;
|
let match;
|
||||||
while ((match = re.exec(this.description))) {
|
while ((match = re.exec(this.description))) {
|
||||||
// If the search string occurs within an HTML tag, return without highlighting it.
|
// If the search string occurs within an HTML tag, return without highlighting it.
|
||||||
if (descPos >= match.index && descPos <= (match.index + match[0].length))
|
const inHTMLTag = descIdxs.reduce((acc, idxs) => {
|
||||||
return;
|
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>" +
|
let desc = "",
|
||||||
this.description.slice(descPos, descPos + searchStr.length) + "</u></b>" +
|
pos = 0;
|
||||||
this.description.slice(descPos + searchStr.length);
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import HTMLOperation from "../HTMLOperation.mjs";
|
import HTMLOperation from "../HTMLOperation.mjs";
|
||||||
import Sortable from "sortablejs";
|
import Sortable from "sortablejs";
|
||||||
|
import {fuzzyMatch} from "../../core/lib/FuzzySearch.mjs";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -112,24 +113,31 @@ class OperationsWaiter {
|
|||||||
|
|
||||||
for (const opName in this.app.operations) {
|
for (const opName in this.app.operations) {
|
||||||
const op = this.app.operations[opName];
|
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);
|
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);
|
const operation = new HTMLOperation(opName, this.app.operations[opName], this.app, this.manager);
|
||||||
if (highlight) {
|
if (highlight) {
|
||||||
operation.highlightSearchString(searchStr, namePos, descPos);
|
operation.highlightSearchStrings(idxs || [], [[descPos, searchStr.length]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (namePos < 0) {
|
if (nameMatch) {
|
||||||
matchedOps.push(operation);
|
matchedOps.push([operation, score]);
|
||||||
} else {
|
} else {
|
||||||
matchedDescs.push(operation);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user