UNPKG

match-sorter

Version:

Simple, expected, and deterministic best-match sorting of an array in JavaScript

347 lines (309 loc) 12.5 kB
'use strict'; var _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; /** * @name match-sorter * @license MIT license. * @copyright (c) 2016 Kent C. Dodds * @author Kent C. Dodds <kent@doddsfamily.us> */ var _diacritic = require('diacritic'); var _diacritic2 = _interopRequireDefault(_diacritic); var _globalObject = require('global-object'); var _globalObject2 = _interopRequireDefault(_globalObject); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } var rankings = { CASE_SENSITIVE_EQUAL: 7, EQUAL: 6, STARTS_WITH: 5, WORD_STARTS_WITH: 4, CONTAINS: 3, ACRONYM: 2, MATCHES: 1, NO_MATCH: 0 }; matchSorter.rankings = rankings; /** * Takes an array of items and a value and returns a new array with the items that match the given value * @param {Array} items - the items to sort * @param {String} value - the value to use for ranking * @param {Object} options - Some options to configure the sorter * @return {Array} - the new sorted array */ function matchSorter(items, value) { var options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; var keys = options.keys, _options$threshold = options.threshold, threshold = _options$threshold === undefined ? rankings.MATCHES : _options$threshold; var matchedItems = items.reduce(reduceItemsToRanked, []); return matchedItems.sort(sortRankedItems).map(function (_ref) { var item = _ref.item; return item; }); function reduceItemsToRanked(matches, item, index) { var _getHighestRanking = getHighestRanking(item, keys, value, options), rank = _getHighestRanking.rank, keyIndex = _getHighestRanking.keyIndex; if (rank >= threshold) { matches.push({ item: item, rank: rank, index: index, keyIndex: keyIndex }); } return matches; } } /** * Gets the highest ranking for value for the given item based on its values for the given keys * @param {*} item - the item to rank * @param {Array} keys - the keys to get values from the item for the ranking * @param {String} value - the value to rank against * @param {Object} options - options to control the ranking * @return {Number} - the highest ranking */ function getHighestRanking(item, keys, value, options) { if (!keys) { return { rank: getMatchRanking(item, value, options), keyIndex: -1 }; } var valuesToRank = getAllValuesToRank(item, keys); return valuesToRank.reduce(function (_ref2, _ref3, i) { var rank = _ref2.rank, keyIndex = _ref2.keyIndex; var itemValue = _ref3.itemValue, attributes = _ref3.attributes; var newRank = getMatchRanking(itemValue, value, options); if (newRank > rank) { rank = newRank; keyIndex = i; } var minRanking = attributes.minRanking, maxRanking = attributes.maxRanking; if (rank < minRanking && newRank >= rankings.MATCHES) { rank = minRanking; } else if (rank > maxRanking) { rank = maxRanking; } return { rank: rank, keyIndex: keyIndex }; }, { rank: rankings.NO_MATCH, keyIndex: -1 }); } /** * Gives a rankings score based on how well the two strings match. * @param {String} testString - the string to test against * @param {String} stringToRank - the string to rank * @param {Object} options - options for the match (like keepDiacritics for comparison) * @returns {Number} the ranking for how well stringToRank matches testString */ function getMatchRanking(testString, stringToRank, options) { /* eslint complexity:[2, 9] */ testString = prepareValueForComparison(testString, options); stringToRank = prepareValueForComparison(stringToRank, options); // too long if (stringToRank.length > testString.length) { return rankings.NO_MATCH; } // case sensitive equals if (testString === stringToRank) { return rankings.CASE_SENSITIVE_EQUAL; } // Lowercasing before further comparison testString = testString.toLowerCase(); stringToRank = stringToRank.toLowerCase(); // case insensitive equals if (testString === stringToRank) { return rankings.EQUAL; } // starts with if (testString.indexOf(stringToRank) === 0) { return rankings.STARTS_WITH; } // word starts with if (testString.indexOf(' ' + stringToRank) !== -1) { return rankings.WORD_STARTS_WITH; } // contains if (testString.indexOf(stringToRank) !== -1) { return rankings.CONTAINS; } else if (stringToRank.length === 1) { // If the only character in the given stringToRank // isn't even contained in the testString, then // it's definitely not a match. return rankings.NO_MATCH; } // acronym if (getAcronym(testString).indexOf(stringToRank) !== -1) { return rankings.ACRONYM; } // will return a number between rankings.MATCHES and // rankings.MATCHES + 1 depending on how close of a match it is. return getClosenessRanking(testString, stringToRank); } /** * Generates an acronym for a string. * * @param {String} string the string for which to produce the acronym * @returns {String} the acronym */ function getAcronym(string) { var acronym = ''; var wordsInString = string.split(' '); wordsInString.forEach(function (wordInString) { var splitByHyphenWords = wordInString.split('-'); splitByHyphenWords.forEach(function (splitByHyphenWord) { acronym += splitByHyphenWord.substr(0, 1); }); }); return acronym; } /** * Returns a score based on how spread apart the * characters from the stringToRank are within the testString. * A number close to rankings.MATCHES represents a loose match. A number close * to rankings.MATCHES + 1 represents a loose match. * @param {String} testString - the string to test against * @param {String} stringToRank - the string to rank * @returns {Number} the number between rankings.MATCHES and * rankings.MATCHES + 1 for how well stringToRank matches testString */ function getClosenessRanking(testString, stringToRank) { var charNumber = 0; function findMatchingCharacter(matchChar, string, index) { for (var j = index; j < string.length; j++) { var stringChar = string[j]; if (stringChar === matchChar) { return j + 1; } } return -1; } function getRanking(spread) { var matching = spread - stringToRank.length + 1; var ranking = rankings.MATCHES + 1 / matching; return ranking; } var firstIndex = findMatchingCharacter(stringToRank[0], testString, 0); if (firstIndex < 0) { return rankings.NO_MATCH; } charNumber = firstIndex; for (var i = 1; i < stringToRank.length; i++) { var matchChar = stringToRank[i]; charNumber = findMatchingCharacter(matchChar, testString, charNumber); var found = charNumber > -1; if (!found) { return rankings.NO_MATCH; } } var spread = charNumber - firstIndex; return getRanking(spread); } /** * Sorts items that have a rank, index, and keyIndex * @param {Object} a - the first item to sort * @param {Object} b - the second item to sort * @return {Number} -1 if a should come first, 1 if b should come first * Note: will never return 0 */ function sortRankedItems(a, b) { var aFirst = -1; var bFirst = 1; var aRank = a.rank, aIndex = a.index, aKeyIndex = a.keyIndex; var bRank = b.rank, bIndex = b.index, bKeyIndex = b.keyIndex; var same = aRank === bRank; if (same) { if (aKeyIndex === bKeyIndex) { return aIndex < bIndex ? aFirst : bFirst; } else { return aKeyIndex < bKeyIndex ? aFirst : bFirst; } } else { return aRank > bRank ? aFirst : bFirst; } } /** * Prepares value for comparison by stringifying it, removing diacritics (if specified) * @param {String} value - the value to clean * @param {Object} options - {keepDiacritics: whether to remove diacritics} * @return {String} the prepared value */ function prepareValueForComparison(value, _ref4) { var keepDiacritics = _ref4.keepDiacritics; value = '' + value; // toString if (!keepDiacritics) { value = _diacritic2.default.clean(value); } return value; } /** * Gets value for key in item at arbitrarily nested keypath * @param {Object} item - the item * @param {Object|Function} key - the potentially nested keypath or property callback * @return {Array} - an array containing the value(s) at the nested keypath */ function getItemValues(item, key) { if ((typeof key === 'undefined' ? 'undefined' : _typeof(key)) === 'object') { key = key.key; } var value = void 0; if (typeof key === 'function') { value = key(item); } else if (key.indexOf('.') !== -1) { // eslint-disable-line no-negated-condition // handle nested keys value = key.split('.').reduce(function (itemObj, nestedKey) { return itemObj[nestedKey]; }, item); } else { value = item[key]; } // concat because `value` can be a string or an array return value ? [].concat(value) : null; } /** * Gets all the values for the given keys in the given item and returns an array of those values * @param {Object} item - the item from which the values will be retrieved * @param {Array} keys - the keys to use to retrieve the values * @return {Array} objects with {itemValue, attributes} */ function getAllValuesToRank(item, keys) { return keys.reduce(function (allVals, key) { var values = getItemValues(item, key); if (values) { values.forEach(function (itemValue) { allVals.push({ itemValue: itemValue, attributes: getKeyAttributes(key) }); }); } return allVals; }, []); } /** * Gets all the attributes for the given key * @param {Object|String} key - the key from which the attributes will be retrieved * @return {Object} object containing the key's attributes */ function getKeyAttributes(key) { if (typeof key === 'string') { key = { key: key }; } return _extends({ maxRanking: Infinity, minRanking: -Infinity }, key); } // some manual ✨ magic umd ✨ here because Rollup isn't capable of exposing our module the way we want // see dist-test/index.js /* istanbul ignore next */ if ((typeof exports === 'undefined' ? 'undefined' : _typeof(exports)) === 'object' && typeof module !== 'undefined') { matchSorter.default = matchSorter; module.exports = matchSorter; Object.defineProperty(exports, '__esModule', { value: true }); } else if (typeof define === 'function' && define.amd) { // eslint-disable-line define(function () { return matchSorter; }); // eslint-disable-line } else { _globalObject2.default.matchSorter = matchSorter; // eslint-disable-line }