blob: 4f6c3b4bad1c6124bfb3c91c87a2f27f7c4e84e2 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {quoteString} from 'chrome://resources/js/util.m.js';
import Fuse from './fuse.js';
import {TabData} from './tab_data.js';
/**
* @param {string} input
* @param {!Array<!TabData>} records
* @param {!Object} options
* @return {!Array<!TabData>} A new array of entries satisfying the input. If no
* search input is present, returns a shallow copy of the records.
*/
export function fuzzySearch(input, records, options) {
if (input.length === 0) {
return [...records];
}
// Fuse does not handle exact match searches well. It indiscriminately
// searches for direct matches that appear anywhere in the string. This
// results in a bad search experience as users expect matches at the beginning
// of the title / hostname, or at the beginning of words to receive
// preferential treatment. Matched ranges returned by Fuse also fail to
// highlight only the matching text, but instead match to any character
// present in the input string.
// To address these shortcomings we use the exactSearch implementation below
// if the options indicate an exact matching algorithm should be used.
performance.mark('search_algorithm:benchmark_begin');
let result;
if (options.threshold === 0.0) {
result = exactSearch(input, records, options);
} else {
result = new Fuse(records, options).search(input).map(result => {
const titleMatch = result.matches.find(e => e.key === 'tab.title');
const hostnameMatch = result.matches.find(e => e.key === 'hostname');
const item = cloneTabDataObj(result.item);
if (titleMatch) {
item.titleHighlightRanges = convertToRanges(titleMatch.indices);
}
if (hostnameMatch) {
item.hostnameHighlightRanges = convertToRanges(hostnameMatch.indices);
}
return item;
});
}
performance.mark('search_algorithm:benchmark_end');
return result;
}
/**
* @param {!TabData} tabData
* @return {!TabData}
*/
function cloneTabDataObj(tabData) {
const clone = Object.assign({}, tabData);
Object.setPrototypeOf(clone, TabData.prototype);
return /** @type {!TabData} */ (clone);
}
/**
* Convert fuse.js matches [start1, end1], [start2, end2] ... to
* ranges {start:start1, length:length1}, {start:start2, length:length2} ...
* to be used by search_highlight_utils.js
* @param {!Array<!Array<number>>} matches
* @return {!Array<!{start: number, length: number}>}
*/
function convertToRanges(matches) {
return matches.map(
([start, end]) => ({start: start, length: end - start + 1}));
}
////////////////////////////////////////////////////////////////////////////////
// Exact Match Implementation :
/**
* The exact match algorithm returns records ranked according to the following
* priorities (highest to lowest priority):
* 1. All items with |title| or |hostname| matching the searchText at the
* beginning of the string.
* 2. All items with |title| or |hostname| matching the searchText at the
* beginning of a word in the string.
* 3. All remaining items with |title| or |hostname| matching the searchText
* elsewhere in the string.
* @param {string} searchText
* @param {!Array<!TabData>} records
* @param {!Object} options
* @return {!Array<!TabData>}
*/
function exactSearch(searchText, records, options) {
if (searchText.length === 0) {
return records;
}
// Controls how heavily weighted the tab's title is relative to the hostname
// in the scoring function.
const key =
options.keys ? options.keys.find(e => e.name === 'tab.title') : undefined;
const titleToHostnameWeightRatio = key ? key.weight : 1;
// Default distance to calculate score for title/hostname based on match
// position.
const defaultDistance = 200;
const distance = options.distance || defaultDistance;
// Perform an exact match search with range discovery.
const exactMatches = [];
for (const tab of records) {
const titleHighlightRanges = getRanges(tab.tab.title, searchText);
const hostnameHighlightRanges = getRanges(tab.hostname, searchText);
if (!titleHighlightRanges.length && !hostnameHighlightRanges.length) {
continue;
}
const matchedTab = cloneTabDataObj(tab);
if (titleHighlightRanges.length) {
matchedTab.titleHighlightRanges = titleHighlightRanges;
}
if (hostnameHighlightRanges.length) {
matchedTab.hostnameHighlightRanges = hostnameHighlightRanges;
}
exactMatches.push({
tab: matchedTab,
score: scoringFunction(matchedTab, distance, titleToHostnameWeightRatio)
});
}
// Sort by score.
exactMatches.sort((a, b) => (b.score - a.score));
// Prioritize items.
const itemsMatchingStringStart = [];
const itemsMatchingWordStart = [];
const others = [];
const wordStartRegexp = new RegExp(`\\b${quoteString(searchText)}`, 'i');
for (const {tab} of exactMatches) {
// Find matches that occur at the beginning of the string.
if (hasMatchStringStart(tab)) {
itemsMatchingStringStart.push(tab);
} else if (hasRegexMatch(tab, wordStartRegexp)) {
itemsMatchingWordStart.push(tab);
} else {
others.push(tab);
}
}
return itemsMatchingStringStart.concat(itemsMatchingWordStart, others);
}
/**
* Determines whether the given tab has a title or hostname with identified
* matches at the beginning of the string.
* @param {!TabData} tab
* @return {boolean}
*/
function hasMatchStringStart(tab) {
return (tab.titleHighlightRanges !== undefined &&
tab.titleHighlightRanges[0].start === 0) ||
(tab.hostnameHighlightRanges !== undefined &&
tab.hostnameHighlightRanges[0].start === 0);
}
/**
* Determines whether the given tab has a match for the given regexp in its
* title or hostname.
* @param {!TabData} tab
* @param {RegExp} regexp
* @return {boolean}
*/
function hasRegexMatch(tab, regexp) {
return (tab.titleHighlightRanges !== undefined &&
tab.tab.title.search(regexp) !== -1) ||
(tab.hostnameHighlightRanges !== undefined &&
tab.hostname.search(regexp) !== -1);
}
/**
* Returns an array of matches that indicate where in the target string the
* searchText appears. If there are no identified matches an empty array is
* returned.
* @param {string} target
* @param {string} searchText
* @return {!Array<!{start: number, length: number}>}
*/
function getRanges(target, searchText) {
const escapedText = quoteString(searchText);
const ranges = [];
let match = null;
for (const re = new RegExp(escapedText, 'gi'); match = re.exec(target);) {
ranges.push({
start : match.index,
length : searchText.length,
});
}
return ranges;
}
/**
* A scoring function based on match indices of title and hostname.
* Matches near the beginning of the string will have a higher score than
* matches near the end of the string. Multiple matches will have a higher score
* than single matches.
* @param {!TabData} tab
* @param {number} distance
* @param {number} titleToHostnameWeightRatio
*/
function scoringFunction(tab, distance, titleToHostnameWeightRatio) {
let score = 0;
// For every match, map the match index in [0, distance] to a scalar value in
// [1, 0].
if (tab.titleHighlightRanges) {
for (const {start} of tab.titleHighlightRanges) {
score += Math.max((distance - start) / distance, 0) *
titleToHostnameWeightRatio;
}
}
if (tab.hostnameHighlightRanges) {
for (const {start} of tab.hostnameHighlightRanges) {
score += Math.max((distance - start) / distance, 0);
}
}
return score;
}