match-sorter
Version:
Simple, expected, and deterministic best-match sorting of an array in JavaScript
347 lines (309 loc) • 12.5 kB
JavaScript
;
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
}