element-web/src/autocomplete/QueryMatcher.ts

146 lines
5.4 KiB
TypeScript
Raw Normal View History

2017-06-23 20:30:16 +03:00
/*
Copyright 2017 Aviral Dasgupta
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2018 New Vector Ltd
2017-06-23 20:30:16 +03:00
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
2017-02-10 20:04:52 +03:00
import _at from 'lodash/at';
import _uniq from 'lodash/uniq';
2017-02-10 20:04:52 +03:00
function stripDiacritics(str: string): string {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
interface IOptions<T extends {}> {
keys: Array<string | keyof T>;
funcs?: Array<(T) => string>;
shouldMatchWordsOnly?: boolean;
shouldMatchPrefix?: boolean;
}
/**
* Simple search matcher that matches any results with the query string anywhere
* in the search string. Returns matches in the order the query string appears
* in the search key, earliest first, then in the order the search key appears
* in the provided array of keys, then in the order the items appeared in the
* source array.
*
* @param {Object[]} objects Initial list of objects. Equivalent to calling
* setObjects() after construction
* @param {Object} options Options object
* @param {string[]} options.keys List of keys to use as indexes on the objects
* @param {function[]} options.funcs List of functions that when called with the
* object as an arg will return a string to use as an index
*/
2020-05-25 18:47:57 +03:00
export default class QueryMatcher<T extends Object> {
private _options: IOptions<T>;
private _keys: IOptions<T>["keys"];
private _funcs: Required<IOptions<T>["funcs"]>;
private _items: Map<string, {object: T, keyWeight: number}[]>;
constructor(objects: T[], options: IOptions<T> = { keys: [] }) {
this._options = options;
this._keys = options.keys;
this._funcs = options.funcs || [];
2017-02-10 20:04:52 +03:00
this.setObjects(objects);
// By default, we remove any non-alphanumeric characters ([^A-Za-z0-9_]) from the
// query and the value being queried before matching
if (this._options.shouldMatchWordsOnly === undefined) {
this._options.shouldMatchWordsOnly = true;
}
// By default, match anywhere in the string being searched. If enabled, only return
// matches that are prefixed with the query.
if (this._options.shouldMatchPrefix === undefined) {
this._options.shouldMatchPrefix = false;
}
2017-02-10 20:04:52 +03:00
}
setObjects(objects: T[]) {
this._items = new Map();
for (const object of objects) {
2020-05-25 18:47:57 +03:00
// Need to use unsafe coerce here because the objects can have any
// type for their values. We assume that those values who's keys have
2020-05-25 18:53:09 +03:00
// been specified will be string. Also, we cannot infer all the
// types of the keys of the objects at compile.
const keyValues = _at<string>(<any>object, this._keys);
for (const f of this._funcs) {
keyValues.push(f(object));
}
for (const [index, keyValue] of Object.entries(keyValues)) {
if (!keyValue) continue; // skip falsy keyValues
const key = stripDiacritics(keyValue).toLowerCase();
if (!this._items.has(key)) {
this._items.set(key, []);
}
this._items.get(key).push({
keyWeight: Number(index),
object,
});
}
}
2017-02-10 20:04:52 +03:00
}
match(query: string): T[] {
query = stripDiacritics(query).toLowerCase();
if (this._options.shouldMatchWordsOnly) {
query = query.replace(/[^\w]/g, '');
}
if (query.length === 0) {
return [];
}
const matches = [];
// Iterate through the map & check each key.
// ES6 Map iteration order is defined to be insertion order, so results
// here will come out in the order they were put in.
for (const [key, candidates] of this._items.entries()) {
let resultKey = key;
if (this._options.shouldMatchWordsOnly) {
resultKey = resultKey.replace(/[^\w]/g, '');
}
const index = resultKey.indexOf(query);
if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) {
matches.push(
...candidates.map((candidate) => ({index, ...candidate}))
);
}
}
// Sort matches by where the query appeared in the search key, then by
// where the matched key appeared in the provided array of keys.
matches.sort((a, b) => {
if (a.index < b.index) {
return -1;
} else if (a.index === b.index) {
if (a.keyWeight < b.keyWeight) {
return -1;
} else if (a.keyWeight === b.keyWeight) {
return 0;
}
}
return 1;
});
// Now map the keys to the result objects. Also remove any duplicates.
return _uniq(matches.map((match) => match.object));
2017-02-10 20:04:52 +03:00
}
}