2017-06-23 20:30:16 +03:00
|
|
|
/*
|
|
|
|
Copyright 2017 Aviral Dasgupta
|
2018-06-23 04:20:41 +03:00
|
|
|
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
2018-10-11 20:34:01 +03:00
|
|
|
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 _flatMap from 'lodash/flatMap';
|
2017-07-18 18:23:54 +03:00
|
|
|
import _uniq from 'lodash/uniq';
|
2017-02-10 20:04:52 +03:00
|
|
|
|
2018-06-23 04:20:41 +03:00
|
|
|
function stripDiacritics(str: string): string {
|
|
|
|
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
|
|
}
|
|
|
|
|
2020-04-20 21:00:54 +03:00
|
|
|
interface IOptions<T extends {}> {
|
|
|
|
keys: Array<string | keyof T>;
|
|
|
|
funcs?: Array<(T) => string>;
|
|
|
|
shouldMatchWordsOnly?: boolean;
|
|
|
|
shouldMatchPrefix?: boolean;
|
|
|
|
}
|
|
|
|
|
2018-10-11 20:34:01 +03:00
|
|
|
/**
|
|
|
|
* 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
|
2020-06-18 06:31:02 +03:00
|
|
|
* 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.
|
2018-10-11 20:34:01 +03:00
|
|
|
*
|
|
|
|
* @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> {
|
2020-04-20 21:00:54 +03:00
|
|
|
private _options: IOptions<T>;
|
|
|
|
private _keys: IOptions<T>["keys"];
|
|
|
|
private _funcs: Required<IOptions<T>["funcs"]>;
|
2020-06-18 06:31:02 +03:00
|
|
|
private _items: Map<{value: string, weight: number}, T[]>;
|
2020-04-20 21:00:54 +03:00
|
|
|
|
|
|
|
constructor(objects: T[], options: IOptions<T> = { keys: [] }) {
|
2018-10-11 20:34:01 +03:00
|
|
|
this._options = options;
|
|
|
|
this._keys = options.keys;
|
|
|
|
this._funcs = options.funcs || [];
|
|
|
|
|
2017-02-10 20:04:52 +03:00
|
|
|
this.setObjects(objects);
|
2017-06-29 13:29:55 +03:00
|
|
|
|
|
|
|
// By default, we remove any non-alphanumeric characters ([^A-Za-z0-9_]) from the
|
|
|
|
// query and the value being queried before matching
|
2018-10-11 20:34:01 +03:00
|
|
|
if (this._options.shouldMatchWordsOnly === undefined) {
|
|
|
|
this._options.shouldMatchWordsOnly = true;
|
2017-06-29 13:29:55 +03:00
|
|
|
}
|
2017-07-04 18:24:59 +03:00
|
|
|
|
|
|
|
// By default, match anywhere in the string being searched. If enabled, only return
|
|
|
|
// matches that are prefixed with the query.
|
2018-10-11 20:34:01 +03:00
|
|
|
if (this._options.shouldMatchPrefix === undefined) {
|
|
|
|
this._options.shouldMatchPrefix = false;
|
2017-07-04 18:24:59 +03:00
|
|
|
}
|
2017-02-10 20:04:52 +03:00
|
|
|
}
|
|
|
|
|
2020-04-20 21:00:54 +03:00
|
|
|
setObjects(objects: T[]) {
|
2018-10-11 20:34:01 +03:00
|
|
|
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.
|
2020-05-26 14:09:23 +03:00
|
|
|
const keyValues = _at<string>(<any>object, this._keys);
|
2018-10-11 20:34:01 +03:00
|
|
|
|
|
|
|
for (const f of this._funcs) {
|
|
|
|
keyValues.push(f(object));
|
|
|
|
}
|
|
|
|
|
2020-06-18 06:31:02 +03:00
|
|
|
for (const [index, keyValue] of Object.entries(keyValues)) {
|
2019-12-18 18:40:19 +03:00
|
|
|
if (!keyValue) continue; // skip falsy keyValues
|
2020-06-18 06:31:02 +03:00
|
|
|
const key = {
|
|
|
|
value: stripDiacritics(keyValue).toLowerCase(),
|
|
|
|
weight: Number(index)
|
|
|
|
};
|
2018-10-11 20:34:01 +03:00
|
|
|
if (!this._items.has(key)) {
|
|
|
|
this._items.set(key, []);
|
|
|
|
}
|
|
|
|
this._items.get(key).push(object);
|
|
|
|
}
|
|
|
|
}
|
2017-02-10 20:04:52 +03:00
|
|
|
}
|
|
|
|
|
2020-04-20 21:00:54 +03:00
|
|
|
match(query: string): T[] {
|
2018-06-23 04:20:41 +03:00
|
|
|
query = stripDiacritics(query).toLowerCase();
|
2018-10-11 20:34:01 +03:00
|
|
|
if (this._options.shouldMatchWordsOnly) {
|
2017-06-29 13:29:55 +03:00
|
|
|
query = query.replace(/[^\w]/g, '');
|
|
|
|
}
|
2017-07-04 19:32:07 +03:00
|
|
|
if (query.length === 0) {
|
|
|
|
return [];
|
|
|
|
}
|
2017-07-04 15:53:06 +03:00
|
|
|
const results = [];
|
2018-10-11 20:34:01 +03:00
|
|
|
// 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 of this._items.keys()) {
|
2020-06-18 06:31:02 +03:00
|
|
|
let {value: resultKey} = key;
|
2018-10-11 20:34:01 +03:00
|
|
|
if (this._options.shouldMatchWordsOnly) {
|
2017-06-29 13:29:55 +03:00
|
|
|
resultKey = resultKey.replace(/[^\w]/g, '');
|
|
|
|
}
|
2017-07-04 15:53:06 +03:00
|
|
|
const index = resultKey.indexOf(query);
|
2018-10-11 20:34:01 +03:00
|
|
|
if (index !== -1 && (!this._options.shouldMatchPrefix || index === 0)) {
|
2017-07-04 15:53:06 +03:00
|
|
|
results.push({key, index});
|
|
|
|
}
|
2018-10-11 20:34:01 +03:00
|
|
|
}
|
2017-07-04 15:53:06 +03:00
|
|
|
|
2020-06-18 06:31:02 +03:00
|
|
|
// Sort them by where the query appeared in the search key, then by
|
|
|
|
// where the matched key appeared in the provided array of keys.
|
|
|
|
const sortedResults = results.slice().sort((a, b) => {
|
|
|
|
if (a.index < b.index) {
|
|
|
|
return -1;
|
|
|
|
} else if (a.index === b.index && a.key.weight < b.key.weight) {
|
|
|
|
return -1;
|
|
|
|
}
|
|
|
|
return 1;
|
2018-10-11 20:34:01 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
// Now map the keys to the result objects. Each result object is a list, so
|
|
|
|
// flatMap will flatten those lists out into a single list. Also remove any
|
|
|
|
// duplicates.
|
|
|
|
return _uniq(_flatMap(sortedResults, (candidate) => this._items.get(candidate.key)));
|
2017-02-10 20:04:52 +03:00
|
|
|
}
|
|
|
|
}
|