Source: QuickScore.js

import {quickScore} from "./quick-score";


/**
 * A class for scoring and sorting a list of items against a query string.  Each
 * item receives a floating point score between `0` and `1`.
 */
export class QuickScore {
	/**
	 * @memberOf QuickScore.prototype
	 * @member {Array<object>} items  The array of items to search, which
	 * should only be modified via the [setItems()]{@link QuickScore#setItems}
	 * method.
	 * @readonly
	 */

	/**
	 * @memberOf QuickScore.prototype
	 * @member {Array<ItemKey>} keys  The keys to search on each item, which
	 * should only be modified via the [setItems()]{@link QuickScore#setKeys}
	 * method.
	 * @readonly
	 */

	/**
	 * @param {Array<string|object>} [items]  The list of items to score.  If
	 * the list is not a flat array of strings, a `keys` array must be supplied
	 * via the second parameter.  QuickScore makes a shallow copy of the `items`
	 * array, so changes to it won't have any affect, but changes to the objects
	 * referenced by the array need to be passed to the instance by a call to
	 * its [setItems()]{@link QuickScore#setItems} method.
	 *
	 * @param {Array<ItemKey>|Options} [options]  If the `items` parameter
	 * is an array of flat strings, the `options` parameter can be left out.  If
	 * it is a list of objects containing keys that should be scored, the
	 * `options` parameter must either be an array of key names or an object
	 * containing a `keys` property.
	 *
	 * @param {Array<ItemKey>} [options.keys]  In the simplest case, an array of
	 * key names to score on the objects in the `items` array.
	 *
	 * The key names can point to a nested key by passing either a dot-delimited
	 * string or an array of sub-keys that specify the path to the value.  So a
	 * key `name` of `"foo.bar"` would evaluate to `"baz"` given an object like
	 * `{ foo: { bar: "baz" } }`.  Alternatively, that path could be passed as
	 * an array, like `["foo", "bar"]`.  In either case, if this sub-key's match
	 * produces the highest score for an item in the search results, its
	 * `scoreKey` name will be `"foo.bar"`.
	 *
	 * If your items have keys that contain periods, e.g., `"first.name"`, but
	 * you don't want these names to be treated as paths to nested keys, simply
	 * wrap the name in an array, like `{ keys: ["ssn", ["first.name"],
	 * ["last.name"]] }`.
	 *
	 * Instead of a string or string array, an item in `keys` can also be passed
	 * as a `{name, scorer}` object, which lets you specify a different scoring
	 * function for each key.  The scoring function should behave as described
	 * next.
	 *
	 * @param {string} [options.sortKey=options.keys[0]]  An optional key name
	 * that will be used to sort items with identical scores.  Defaults to the
	 * name of the first item in the `keys` parameter.  If `sortKey` points to
	 * a nested key, use a dot-delimited string instead of an array to specify
	 * the path.
	 *
	 * @param {number} [options.minimumScore=0]  An optional value that
	 * specifies the minimum score an item must have to appear in the results
	 * returned from [search()]{@link QuickScore#search}.  Defaults to `0`,
	 * so items that don't match the full `query` will not be returned.  This
	 * value is ignored if the `query` is empty or undefined, in which case all
	 * items are returned, sorted alphabetically and case-insensitively on the
	 * `sortKey`, if any.
	 *
	 * @param {TransformStringFunction} [options.transformString]  An optional
	 * function that takes a `string` parameter and returns a transformed
	 * version of that string.  This function will be called on each of the
	 * searchable keys in the `items` array as well as on the `query`
	 * parameter to the `search()` method.  The default function calls
	 * `toLocaleLowerCase()` on each string, for a case-insensitive search.  The
	 * result of this function is cached for each searchable key on each item.
	 *
	 * You can pass a function here to do other kinds of preprocessing, such as
	 * removing diacritics from all the strings or converting Chinese characters
	 * to pinyin.  For example, you could use the
	 * [`latinize`](https://www.npmjs.com/package/latinize) npm package to
	 * convert characters with diacritics to the base character so that your
	 * users can type an unaccented character in the query while still matching
	 * items that have accents or diacritics.  Pass in an `options` object like
	 * this to use a custom `transformString()` function:
	 * `{ transformString: s => latinize(s.toLocaleLowerCase()) }`
	 *
	 * @param {ScorerFunction} [options.scorer]  An optional function that takes
	 * `string` and `query` parameters and returns a floating point number
	 * between 0 and 1 that represents how well the `query` matches the
	 * `string`.  It defaults to the [quickScore()]{@link quickScore} function
	 * in this library.
	 *
	 * If the function gets a third `matches` parameter, it should fill the
	 * passed-in array with indexes corresponding to where the query
	 * matches the string, as described in the [search()]{@link QuickScore#search}
	 * method.
	 *
	 * @param {Config} [options.config]  An optional object that is passed to
	 * the scorer function to further customize its behavior.  If the
	 * `scorer` function has a `createConfig()` method on it, the `QuickScore`
	 * instance will call that with the `config` value and store the result.
	 * This can be used to extend the `config` parameter with default values.
	 */
	constructor(
		items = [],
		options = {})
	{
		const {
			scorer = quickScore,
			transformString = toLocaleLowerCase,
			keys = [],
			sortKey = "",
			minimumScore = 0,
			config
		} = Array.isArray(options)
			? { keys: options }
			: options;

		this.scorer = scorer;
		this.minimumScore = minimumScore;
		this.config = config;
		this.transformStringFunc = transformString;

		if (typeof scorer.createConfig === "function") {
				// let the scorer fill out the config with default values
			this.config = scorer.createConfig(config);
		}

		this.setKeys(keys, sortKey);
		this.setItems(items);

			// the scoring function needs access to this.sortKey
		this.compareScoredStrings = this.compareScoredStrings.bind(this);
	}


	/**
	 * Scores the instance's items against the `query` and sorts them from
	 * highest to lowest.
	 *
	 * @param {string} query  The string to score each item against.  The
	 * instance's `transformString()` function is called on this string before
	 * it's matched against each item.
	 *
	 * @returns {Array<ScoredString|ScoredObject>}  When the instance's `items`
	 * are flat strings, an array of [`ScoredString`]{@link ScoredString}
	 * objects containing the following properties is returned:
	 *
	 * - `item`: the string that was scored
	 * - `score`: the floating point score of the string for the current query
	 * - `matches`: an array of arrays that specify the character ranges
	 *   where the query matched the string
	 *
	 * When the `items` are objects, an array of [`ScoredObject`]{@link ScoredObject}
	 * results is returned:
	 *
	 * - `item`: the object that was scored
	 * - `score`: the highest score from among the individual key scores
	 * - `scoreKey`: the name of the key with the highest score, which will be
	 *   an empty string if they're all zero
	 * - `scoreValue`: the value of the key with the highest score, which makes
	 *   it easier to access if it's a nested string
	 * - `scores`: a hash of the individual scores for each key
	 * - `matches`: a hash of arrays that specify the character ranges of the
	 *   query match for each key
	 *
	 * The results array is sorted high to low on each item's score.  Items with
	 * identical scores are sorted alphabetically and case-insensitively on the
	 * `sortKey` option.  Items with scores that are <= the `minimumScore` option
	 * (defaults to `0`) are not returned, unless the `query` is falsy, in which
	 * case all of the items are returned, sorted alphabetically.
	 *
	 * The start and end indices in each [`RangeTuple`]{@link RangeTuple} in the
	 * `matches` array can be used as parameters to the `substring()` method to
	 * extract the characters from each string that match the query.  This can
	 * then be used to format the matching characters with a different color or
	 * style.
	 *
	 * Each `ScoredObject` item also has a `_` property, which caches transformed
	 * versions of the item's strings, and might contain additional internal
	 * metadata in the future.  It can be ignored.
	 */
	search(
		query)
	{
		const results = [];
		const {items, transformedItems, keys: sharedKeys, config} = this;
			// if the query is empty, we want to return all items, so make the
			// minimum score less than 0
		const minScore = query ? this.minimumScore : -1;
		const transformedQuery = this.transformString(query);
		const itemCount = items.length;
		const sharedKeyCount = sharedKeys.length;

		if (typeof items[0] === "string") {
				// items is an array of strings
			for (let i = 0; i < itemCount; i++) {
				const item = items[i];
				const transformedItem = transformedItems[i];
				const matches = [];
				const score = this.scorer(item, query, matches, transformedItem,
					transformedQuery, config);

				if (score > minScore) {
					results.push({
						item,
						score,
						matches,
						_: transformedItem
					});
				}
			}
		} else {
			for (let i = 0; i < itemCount; i++) {
				const item = items[i];
				const transformedItem = transformedItems[i];
				const result = {
					item,
					score: 0,
					scoreKey: "",
					scoreValue: "",
					scores: {},
					matches: {},
					_: transformedItem
				};
					// if an empty keys array was passed into the constructor,
					// score all of the non-empty string keys on the object
				const keys = sharedKeyCount ? sharedKeys : Object.keys(transformedItem);
				const keyCount = keys.length;
				let highScore = 0;
				let scoreKey = "";
				let scoreValue = "";

					// find the highest score for each keyed string on this item
				for (let j = 0; j < keyCount; j++) {
					const key = keys[j];
						// use the key as the name if it's just a string, and
						// default to the instance's scorer function
					const {name = key, scorer = this.scorer} = key;
					const transformedString = transformedItem[name];

						// setItems() checks for non-strings and empty strings
						// when creating the transformed objects, so if the key
						// doesn't exist there, we can skip the processing
						// below for this key in this item
					if (transformedString) {
						const string = this.getItemString(item, key);
						const matches = [];
						const newScore = scorer(string, query, matches,
							transformedString, transformedQuery, config);

						result.scores[name] = newScore;
						result.matches[name] = matches;

						if (newScore > highScore) {
							highScore = newScore;
							scoreKey = name;
							scoreValue = string;
						}
					}
				}

				if (highScore > minScore) {
					result.score = highScore;
					result.scoreKey = scoreKey;
					result.scoreValue = scoreValue;
					results.push(result);
				}
			}
		}

		results.sort(this.compareScoredStrings);

		return results;
	}


	/**
	 * Sets the `keys` configuration.  `setItems()` must be called after
	 * changing the keys so that the items' transformed strings get cached.
	 *
	 * @param {Array<ItemKey>} keys  List of keys to score, as either strings
	 * or `{name, scorer}` objects.
	 *
	 * @param {string} [sortKey=keys[0]]  Name of key on which to sort
	 * identically scored items.  Defaults to the first `keys` item.
	 */
	setKeys(
		keys,
		sortKey)
	{
			// create a shallow copy of the keys array so that changes to its
			// order outside of this instance won't affect searching
		this.keys = keys.slice();
		this.sortKey = sortKey;

		if (this.keys.length) {
			const {scorer} = this;

				// associate each key with the scorer function, if it isn't already
			this.keys = this.keys.map(itemKey => {
					// items in the keys array should either be a string or
					// array specifying a key name, or a { name, scorer } object
				const key = itemKey.length
					? { name: itemKey, scorer }
					: itemKey;

				if (Array.isArray(key.name)) {
					if (key.name.length > 1) {
						key.path = key.name;
						key.name = key.path.join(".");
					} else {
							// this path consists of just one key name, which was
							// probably wrapped in an array because it contains
							// dots but isn't intended as a key path.  so don't
							// create a path array on this key, so that we're not
							// constantly calling reduce() to get this one key.
						[key.name] = key.name;
					}
				} else if (key.name.indexOf(".") > -1) {
					key.path = key.name.split(".");
				}

				return key;
			});

			this.sortKey = this.sortKey || this.keys[0].name;
		}
	}


	/**
	 * Sets the `items` array and caches a transformed copy of all the item
	 * strings specified by the `keys` parameter to the constructor, using the
	 * `transformString` option (which defaults to `toLocaleLowerCase()`).
	 *
	 * @param {Array<string|object>} items  List of items to score.
	 */
	setItems(
		items)
	{
			// create a shallow copy of the items array so that changes to its
			// order outside of this instance won't affect searching
		const itemArray = items.slice();
		const itemCount = itemArray.length;
		const transformedItems = [];
		const sharedKeys = this.keys;
		const sharedKeyCount = sharedKeys.length;

		if (typeof itemArray[0] === "string") {
			for (let i = 0; i < itemCount; i++) {
				transformedItems.push(this.transformString(itemArray[i]));
			}
		} else {
			for (let i = 0; i < itemCount; i++) {
				const item = itemArray[i];
				const transformedItem = {};
				const keys = sharedKeyCount ? sharedKeys : Object.keys(item);
				const keyCount = keys.length;

				for (let j = 0; j < keyCount; j++) {
					const key = keys[j];
					const string = this.getItemString(item, key);

					if (string && typeof string === "string") {
						transformedItem[key.name || key] =
							this.transformString(string);
					}
				}

				transformedItems.push(transformedItem);
			}
		}

		this.items = itemArray;
		this.transformedItems = transformedItems;
	}


	/**
	 * Gets an item's key, possibly at a nested path.
	 *
	 * @private
	 * @param {object} item  An object with multiple string properties.
	 * @param {object|string} key  A key object with
	 * the name of the string to get from `item`, or a plain string when all
	 * keys on an item are being matched.
	 * @returns {string}
	 */
	getItemString(
		item,
		key)
	{
		const {name, path} = key;

		if (path) {
			return path.reduce((value, prop) => value && value[prop], item);
		} else {
				// if this instance is scoring all the keys on each item, key
				// will just be a string, not a { name, scorer } object
			return item[name || key];
		}
	}


	/**
	 * Transforms a string into a canonical form for scoring.
	 *
	 * @private
	 * @param {string} string  The string to transform.
	 * @returns {string}
	 */
	transformString(
		string)
	{
		return this.transformStringFunc(string);
	}


	/**
	 * Compares two items based on their scores, or on their `sortKey` if the
	 * scores are identical.
	 *
	 * @private
	 * @param {object} a  First item.
	 * @param {object} b  Second item.
	 * @returns {number}
	 */
	compareScoredStrings(
		a,
		b)
	{
			// use the transformed versions of the strings for sorting
		const itemA = a._;
		const itemB = b._;
		const itemAString = typeof itemA === "string"
			? itemA
			: itemA[this.sortKey];
		const itemBString = typeof itemB === "string"
			? itemB
			: itemB[this.sortKey];

		if (a.score === b.score) {
				// sort undefineds to the end of the array, as per the ES spec
			if (itemAString === undefined || itemBString === undefined) {
				if (itemAString === undefined && itemBString === undefined) {
					return 0;
				} else if (itemAString === undefined) {
					return 1;
				} else {
					return -1;
				}
			} else if (itemAString === itemBString) {
				return 0;
			} else if (itemAString < itemBString) {
				return -1;
			} else {
				return 1;
			}
		} else {
			return b.score - a.score;
		}
	}
}


/**
 * Default function for transforming each string to be searched.
 *
 * @private
 * @param {string} string  The string to transform.
 * @returns {string}  The transformed string.
 */
function toLocaleLowerCase(
	string)
{
	return string.toLocaleLowerCase();
}