feat: Added fuzzy search to awesome barversion-14
@@ -13,7 +13,7 @@ context('Awesome Bar', () => { | |||
it('navigates to doctype list', () => { | |||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('todo', { delay: 700 }); | |||
cy.get('.awesomplete').findByRole('listbox').should('be.visible'); | |||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{downarrow}{enter}', { delay: 700 }); | |||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)').type('{enter}', { delay: 700 }); | |||
cy.get('.title-text').should('contain', 'To Do'); | |||
@@ -22,7 +22,7 @@ context('Awesome Bar', () => { | |||
it('find text in doctype list', () => { | |||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)') | |||
.type('test in todo{downarrow}{enter}', { delay: 700 }); | |||
.type('test in todo{enter}', { delay: 700 }); | |||
cy.get('.title-text').should('contain', 'To Do'); | |||
@@ -32,7 +32,7 @@ context('Awesome Bar', () => { | |||
it('navigates to new form', () => { | |||
cy.findByPlaceholderText('Search or type a command (Ctrl + G)') | |||
.type('new blog post{downarrow}{enter}', { delay: 700 }); | |||
.type('new blog post{enter}', { delay: 700 }); | |||
cy.get('.title-text:visible').should('have.text', 'New Blog Post'); | |||
}); | |||
@@ -0,0 +1,191 @@ | |||
// 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 fuzzy_match(pattern, str) { | |||
const recursion_count = 0; | |||
const recursion_limit = 10; | |||
const matches = []; | |||
const max_matches = 256; | |||
return fuzzy_match_recursive( | |||
pattern, | |||
str, | |||
0 /* pattern_cur_index */, | |||
0 /* str_curr_index */, | |||
null /* src_matches */, | |||
matches, | |||
max_matches, | |||
0 /* next_match */, | |||
recursion_count, | |||
recursion_limit | |||
); | |||
} | |||
function fuzzy_match_recursive( | |||
pattern, | |||
str, | |||
pattern_cur_index, | |||
str_curr_index, | |||
src_matches, | |||
matches, | |||
max_matches, | |||
next_match, | |||
recursion_count, | |||
recursion_limit | |||
) { | |||
let out_score = 0; | |||
// Return if recursion limit is reached. | |||
if (++recursion_count >= recursion_limit) { | |||
return [false, out_score]; | |||
} | |||
// Return if we reached ends of strings. | |||
if (pattern_cur_index === pattern.length || str_curr_index === str.length) { | |||
return [false, out_score]; | |||
} | |||
// Recursion params | |||
let recursive_match = false; | |||
let best_recursive_matches = []; | |||
let best_recursive_score = 0; | |||
// Loop through pattern and str looking for a match. | |||
let first_match = true; | |||
while (pattern_cur_index < pattern.length && str_curr_index < str.length) { | |||
// Match found. | |||
if ( | |||
pattern[pattern_cur_index].toLowerCase() === str[str_curr_index].toLowerCase() | |||
) { | |||
if (next_match >= max_matches) { | |||
return [false, out_score]; | |||
} | |||
if (first_match && src_matches) { | |||
matches = [...src_matches]; | |||
first_match = false; | |||
} | |||
const recursive_matches = []; | |||
const [matched, recursive_score] = fuzzy_match_recursive( | |||
pattern, | |||
str, | |||
pattern_cur_index, | |||
str_curr_index + 1, | |||
matches, | |||
recursive_matches, | |||
max_matches, | |||
next_match, | |||
recursion_count, | |||
recursion_limit | |||
); | |||
if (matched) { | |||
// Pick best recursive score. | |||
if (!recursive_match || recursive_score > best_recursive_score) { | |||
best_recursive_matches = [...recursive_matches]; | |||
best_recursive_score = recursive_score; | |||
} | |||
recursive_match = true; | |||
} | |||
matches[next_match++] = str_curr_index; | |||
++pattern_cur_index; | |||
} | |||
++str_curr_index; | |||
} | |||
const matched = pattern_cur_index === pattern.length; | |||
if (matched) { | |||
out_score = 100; | |||
// Apply leading letter penalty | |||
let penalty = LEADING_LETTER_PENALTY * matches[0]; | |||
penalty = | |||
penalty < MAX_LEADING_LETTER_PENALTY | |||
? MAX_LEADING_LETTER_PENALTY | |||
: penalty; | |||
out_score += penalty; | |||
//Apply unmatched penalty | |||
const unmatched = str.length - next_match; | |||
out_score += UNMATCHED_LETTER_PENALTY * unmatched; | |||
// Apply ordering bonuses | |||
for (let i = 0; i < next_match; i++) { | |||
const curr_idx = matches[i]; | |||
if (i > 0) { | |||
const prev_idx = matches[i - 1]; | |||
if (curr_idx == prev_idx + 1) { | |||
out_score += SEQUENTIAL_BONUS; | |||
} | |||
} | |||
// Check for bonuses based on neighbor character value. | |||
if (curr_idx > 0) { | |||
// Camel case | |||
const neighbor = str[curr_idx - 1]; | |||
const curr = str[curr_idx]; | |||
if ( | |||
neighbor !== neighbor.toUpperCase() && | |||
curr !== curr.toLowerCase() | |||
) { | |||
out_score += CAMEL_BONUS; | |||
} | |||
const is_neighbour_separator = neighbor == "_" || neighbor == " "; | |||
if (is_neighbour_separator) { | |||
out_score += SEPARATOR_BONUS; | |||
} | |||
} else { | |||
// First letter | |||
out_score += FIRST_LETTER_BONUS; | |||
} | |||
} | |||
// Return best result | |||
if (recursive_match && (!matched || best_recursive_score > out_score)) { | |||
// Recursive score is better than "this" | |||
matches = [...best_recursive_matches]; | |||
out_score = best_recursive_score; | |||
return [true, out_score]; | |||
} else if (matched) { | |||
// "this" score is better than recursive | |||
return [true, out_score]; | |||
} else { | |||
return [false, out_score]; | |||
} | |||
} | |||
return [false, out_score]; | |||
} |
@@ -1,4 +1,6 @@ | |||
frappe.provide('frappe.search'); | |||
import { fuzzy_match } from './fuzzy_match.js'; | |||
frappe.search.utils = { | |||
setup_recent: function() { | |||
@@ -533,101 +535,46 @@ frappe.search.utils = { | |||
}, | |||
fuzzy_search: function(keywords, _item) { | |||
// Returns 10 for case-perfect contain, 0 for not found | |||
// 9 for perfect contain, | |||
// 0 - 6 for fuzzy contain | |||
// **Specific use-case step** | |||
keywords = keywords || ''; | |||
var item = __(_item || ''); | |||
var item_without_hyphen = item.replace(/-/g, " "); | |||
var item_length = item.length; | |||
var query_length = keywords.length; | |||
var length_ratio = query_length / item_length; | |||
var max_skips = 3, max_mismatch_len = 2; | |||
if (query_length > item_length) { | |||
return 0; | |||
} | |||
// check for perfect string matches or | |||
// matches that start with the keyword | |||
if ([item, item_without_hyphen].includes(keywords) | |||
|| [item, item_without_hyphen].some((txt) => txt.toLowerCase().indexOf(keywords) === 0)) { | |||
return 10 + length_ratio; | |||
} | |||
if (item.indexOf(keywords) !== -1 && keywords !== keywords.toLowerCase()) { | |||
return 9 + length_ratio; | |||
} | |||
item = item.toLowerCase(); | |||
keywords = keywords.toLowerCase(); | |||
if (item.indexOf(keywords) !== -1) { | |||
return 8 + length_ratio; | |||
} | |||
var skips = 0, mismatches = 0; | |||
outer: for (var i = 0, j = 0; i < query_length; i++) { | |||
if (mismatches !== 0) skips++; | |||
if (skips > max_skips) return 0; | |||
var k_ch = keywords.charCodeAt(i); | |||
mismatches = 0; | |||
while (j < item_length) { | |||
if (item.charCodeAt(j++) === k_ch) { | |||
continue outer; | |||
} | |||
if(++mismatches > max_mismatch_len) return 0 ; | |||
} | |||
return 0; | |||
} | |||
// Since indexOf didn't pass, there will be atleast 1 skip | |||
// hence no divide by zero, but just to be safe | |||
if((skips + mismatches) > 0) { | |||
return (5 + length_ratio)/(skips + mismatches); | |||
} else { | |||
return 0; | |||
} | |||
var match = fuzzy_match(keywords, item); | |||
return match[1]; | |||
}, | |||
bolden_match_part: function(str, subseq) { | |||
var rendered = ""; | |||
if(this.fuzzy_search(subseq, str) === 0) { | |||
if (fuzzy_match(subseq, str)[0] === false) { | |||
return str; | |||
} else if(this.fuzzy_search(subseq, str) > 6) { | |||
var regEx = new RegExp("("+ subseq +")", "ig"); | |||
return str.replace(regEx, '<mark>$1</mark>'); | |||
} else { | |||
var str_orig = str; | |||
var str = str.toLowerCase(); | |||
var str_len = str.length; | |||
var subseq = subseq.toLowerCase(); | |||
outer: for(var i = 0, j = 0; i < subseq.length; i++) { | |||
var sub_ch = subseq.charCodeAt(i); | |||
while(j < str_len) { | |||
if(str.charCodeAt(j) === sub_ch) { | |||
var str_char = str_orig.charAt(j); | |||
if(str_char === str_char.toLowerCase()) { | |||
rendered += '<mark>' + subseq.charAt(i) + '</mark>'; | |||
} else { | |||
rendered += '<mark>' + subseq.charAt(i).toUpperCase() + '</mark>'; | |||
} | |||
j++; | |||
continue outer; | |||
} | |||
if (str.indexOf(subseq) == 0) { | |||
var tail = str.split(subseq)[1]; | |||
return '<mark>' + subseq + '</mark>' + tail; | |||
} | |||
var rendered = ""; | |||
var str_orig = str; | |||
var str_len = str.length; | |||
str = str.toLowerCase(); | |||
subseq = subseq.toLowerCase(); | |||
outer: for (var i = 0, j = 0; i < subseq.length; i++) { | |||
var sub_ch = subseq.charCodeAt(i); | |||
while (j < str_len) { | |||
if (str.charCodeAt(j) === sub_ch) { | |||
var str_char = str_orig.charAt(j); | |||
if (str_char === str_char.toLowerCase()) { | |||
rendered += '<mark>' + subseq.charAt(i) + '</mark>'; | |||
} else { | |||
rendered += '<mark>' + subseq.charAt(i).toUpperCase() + '</mark>'; | |||
} | |||
rendered += str_orig.charAt(j); | |||
j++; | |||
continue outer; | |||
} | |||
return str_orig; | |||
rendered += str_orig.charAt(j); | |||
j++; | |||
} | |||
rendered += str_orig.slice(j); | |||
return rendered; | |||
return str_orig; | |||
} | |||
rendered += str_orig.slice(j); | |||
return rendered; | |||
}, | |||
get_executables(keywords) { | |||