// Copyright 2020 The Chromium Authors
// 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.js';
import type {ItemData, TabData, TabGroupData} from './tab_data.js';
export interface OptionKeyObject {
name: string;
getter: (data: TabData|TabGroupData) => string | undefined;
weight: number;
}
export interface SearchOptions {
includeScore?: boolean;
includeMatches?: boolean;
ignoreLocation?: boolean;
threshold?: number;
distance?: number;
keys: OptionKeyObject[];
}
/**
* @return A new array of entries satisfying the input. If no search input is
* present, returns a shallow copy of the records.
*/
export function search<T extends ItemData>(
input: string, records: T[], options: SearchOptions): T[] {
if (input.length === 0) {
return [...records];
}
const searchStartTime = Date.now();
const result = exactSearch(input, records, options);
chrome.metricsPrivate.recordTime(
'Tabs.TabSearch.WebUI.SearchAlgorithmDuration',
Math.round(Date.now() - searchStartTime));
return result;
}
function cloneTabDataObj<T extends ItemData>(tabData: T): T {
const clone = Object.assign({}, tabData);
clone.highlightRanges = {};
Object.setPrototypeOf(clone, Object.getPrototypeOf(tabData));
return clone;
}
////////////////////////////////////////////////////////////////////////////////
// Exact Match Implementation :
/**
* The exact match algorithm returns records ranked according to priorities
* and scores. Records are ordered by priority (higher priority comes
* first) and sorted by score within the same priority. See `scoringFunction`
* for how to calculate score and `prioritizeMatchResult` for how to calculate
* priority.
*/
function exactSearch<T extends ItemData>(
searchText: string, records: T[], options: SearchOptions): T[] {
if (searchText.length === 0) {
return records;
}
// Default distance to calculate score for search fields based on match
// position.
const defaultDistance = 200;
const distance = options.distance || defaultDistance;
// Controls how heavily weighted the search field weights are relative to each
// other in the scoring function.
const searchFieldWeights =
(options.keys as OptionKeyObject[]).reduce((acc, {name, weight}) => {
acc[name as string] = weight;
return acc;
}, {} as {[key: string]: number});
// Perform an exact match search with range discovery.
const exactMatches = [];
for (const tabDataRecord of records) {
let matchFound = false;
const matchedRecord = cloneTabDataObj(tabDataRecord);
// Searches for fields or nested fields in the record.
for (const key of options.keys) {
const text = key.getter(tabDataRecord as TabData | TabGroupData);
if (text) {
const ranges = getRanges(text, searchText);
if (ranges.length !== 0) {
matchedRecord.highlightRanges[key.name] = ranges;
matchFound = true;
}
}
}
if (matchFound) {
exactMatches.push({
tab: matchedRecord,
score: scoringFunction(matchedRecord, distance, searchFieldWeights),
});
}
}
// Sort by score.
exactMatches.sort((a, b) => (b.score - a.score));
// Reorder match result by priorities.
return prioritizeMatchResult(
searchText, options.keys, exactMatches.map(item => item.tab));
}
/**
* Determines whether the given tab has a search field with identified matches
* at the beginning of the string.
*/
function hasMatchStringStart(
tab: ItemData, searchText: string, keys: OptionKeyObject[]): boolean {
return keys.some(key => {
const value = key.getter(tab as TabData | TabGroupData);
return value !== undefined && value.startsWith(searchText);
});
}
/**
* Determines whether the given tab has a match for the given regexp in its
* search fields.
*/
function hasRegexMatch(
tab: ItemData, regexp: RegExp, keys: OptionKeyObject[]): boolean {
return keys.some((key) => {
const value = key.getter(tab as TabData | TabGroupData);
return value !== undefined && value.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.
*/
function getRanges(target: string, searchText: string):
Array<{start: number, length: number}> {
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 specified search fields.
* 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.
*/
function scoringFunction(
tabData: ItemData, distance: number,
searchFieldWeights: {[key: string]: number}) {
let score = 0;
// For every match, map the match index in [0, distance] to a scalar value in
// [1, 0].
for (const key in searchFieldWeights) {
if (tabData.highlightRanges[key]) {
for (const {start} of tabData.highlightRanges[key]!) {
score += Math.max((distance - start) / distance, 0) *
searchFieldWeights[key]!;
}
}
}
return score;
}
/**
* Reorder match result based on priorities (highest to lowest priority):
* 1. All items with a search key matching the searchText at the beginning of
* the string.
* 2. All items with a search key matching the searchText at the beginning of a
* word in the string.
* 3. All remaining items with a search key matching the searchText elsewhere in
* the string.
*/
function prioritizeMatchResult<T extends ItemData>(
searchText: string, keys: OptionKeyObject[], result: T[]): T[] {
const itemsMatchingStringStart = [];
const itemsMatchingWordStart = [];
const others = [];
const wordStartRegexp = new RegExp(`\\b${quoteString(searchText)}`, 'i');
for (const tab of result) {
// Find matches that occur at the beginning of the string.
if (hasMatchStringStart(tab, searchText, keys)) {
itemsMatchingStringStart.push(tab);
} else if (hasRegexMatch(tab, wordStartRegexp, keys)) {
itemsMatchingWordStart.push(tab);
} else {
others.push(tab);
}
}
return itemsMatchingStringStart.concat(itemsMatchingWordStart, others);
}