Просмотр исходного кода

Merge pull request #16478 from ChillarAnand/fmatch

feat: Added fuzzy search to awesome bar
version-14
mergify[bot] 3 лет назад
committed by GitHub
Родитель
Сommit
b73580a262
Не найден GPG ключ соответствующий данной подписи Идентификатор GPG ключа: 4AEE18F83AFDEB23
3 измененных файлов: 225 добавлений и 87 удалений
  1. +3
    -3
      cypress/integration/awesome_bar.js
  2. +191
    -0
      frappe/public/js/frappe/ui/toolbar/fuzzy_match.js
  3. +31
    -84
      frappe/public/js/frappe/ui/toolbar/search_utils.js

+ 3
- 3
cypress/integration/awesome_bar.js Просмотреть файл

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


+ 191
- 0
frappe/public/js/frappe/ui/toolbar/fuzzy_match.js Просмотреть файл

@@ -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];
}

+ 31
- 84
frappe/public/js/frappe/ui/toolbar/search_utils.js Просмотреть файл

@@ -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) {


Загрузка…
Отмена
Сохранить