@tanstack/match-sorter-utils
Version:
A fork of match-sorter with separated filtering and sorting phases
711 lines (690 loc) • 14.1 kB
JavaScript
/**
* match-sorter-utils
*
* Copyright (c) TanStack
*
* This source code is licensed under the MIT license found in the
* LICENSE.md file in the root directory of this source tree.
*
* @license MIT
*/
const characterMap = {
À: 'A',
Á: 'A',
Â: 'A',
Ã: 'A',
Ä: 'A',
Å: 'A',
Ấ: 'A',
Ắ: 'A',
Ẳ: 'A',
Ẵ: 'A',
Ặ: 'A',
Æ: 'AE',
Ầ: 'A',
Ằ: 'A',
Ȃ: 'A',
Ç: 'C',
Ḉ: 'C',
È: 'E',
É: 'E',
Ê: 'E',
Ë: 'E',
Ế: 'E',
Ḗ: 'E',
Ề: 'E',
Ḕ: 'E',
Ḝ: 'E',
Ȇ: 'E',
Ì: 'I',
Í: 'I',
Î: 'I',
Ï: 'I',
Ḯ: 'I',
Ȋ: 'I',
Ð: 'D',
Ñ: 'N',
Ò: 'O',
Ó: 'O',
Ô: 'O',
Õ: 'O',
Ö: 'O',
Ø: 'O',
Ố: 'O',
Ṍ: 'O',
Ṓ: 'O',
Ȏ: 'O',
Ù: 'U',
Ú: 'U',
Û: 'U',
Ü: 'U',
Ý: 'Y',
à: 'a',
á: 'a',
â: 'a',
ã: 'a',
ä: 'a',
å: 'a',
ấ: 'a',
ắ: 'a',
ẳ: 'a',
ẵ: 'a',
ặ: 'a',
æ: 'ae',
ầ: 'a',
ằ: 'a',
ȃ: 'a',
ç: 'c',
ḉ: 'c',
è: 'e',
é: 'e',
ê: 'e',
ë: 'e',
ế: 'e',
ḗ: 'e',
ề: 'e',
ḕ: 'e',
ḝ: 'e',
ȇ: 'e',
ì: 'i',
í: 'i',
î: 'i',
ï: 'i',
ḯ: 'i',
ȋ: 'i',
ð: 'd',
ñ: 'n',
ò: 'o',
ó: 'o',
ô: 'o',
õ: 'o',
ö: 'o',
ø: 'o',
ố: 'o',
ṍ: 'o',
ṓ: 'o',
ȏ: 'o',
ù: 'u',
ú: 'u',
û: 'u',
ü: 'u',
ý: 'y',
ÿ: 'y',
Ā: 'A',
ā: 'a',
Ă: 'A',
ă: 'a',
Ą: 'A',
ą: 'a',
Ć: 'C',
ć: 'c',
Ĉ: 'C',
ĉ: 'c',
Ċ: 'C',
ċ: 'c',
Č: 'C',
č: 'c',
C̆: 'C',
c̆: 'c',
Ď: 'D',
ď: 'd',
Đ: 'D',
đ: 'd',
Ē: 'E',
ē: 'e',
Ĕ: 'E',
ĕ: 'e',
Ė: 'E',
ė: 'e',
Ę: 'E',
ę: 'e',
Ě: 'E',
ě: 'e',
Ĝ: 'G',
Ǵ: 'G',
ĝ: 'g',
ǵ: 'g',
Ğ: 'G',
ğ: 'g',
Ġ: 'G',
ġ: 'g',
Ģ: 'G',
ģ: 'g',
Ĥ: 'H',
ĥ: 'h',
Ħ: 'H',
ħ: 'h',
Ḫ: 'H',
ḫ: 'h',
Ĩ: 'I',
ĩ: 'i',
Ī: 'I',
ī: 'i',
Ĭ: 'I',
ĭ: 'i',
Į: 'I',
į: 'i',
İ: 'I',
ı: 'i',
IJ: 'IJ',
ij: 'ij',
Ĵ: 'J',
ĵ: 'j',
Ķ: 'K',
ķ: 'k',
Ḱ: 'K',
ḱ: 'k',
K̆: 'K',
k̆: 'k',
Ĺ: 'L',
ĺ: 'l',
Ļ: 'L',
ļ: 'l',
Ľ: 'L',
ľ: 'l',
Ŀ: 'L',
ŀ: 'l',
Ł: 'l',
ł: 'l',
Ḿ: 'M',
ḿ: 'm',
M̆: 'M',
m̆: 'm',
Ń: 'N',
ń: 'n',
Ņ: 'N',
ņ: 'n',
Ň: 'N',
ň: 'n',
ʼn: 'n',
N̆: 'N',
n̆: 'n',
Ō: 'O',
ō: 'o',
Ŏ: 'O',
ŏ: 'o',
Ő: 'O',
ő: 'o',
Œ: 'OE',
œ: 'oe',
P̆: 'P',
p̆: 'p',
Ŕ: 'R',
ŕ: 'r',
Ŗ: 'R',
ŗ: 'r',
Ř: 'R',
ř: 'r',
R̆: 'R',
r̆: 'r',
Ȓ: 'R',
ȓ: 'r',
Ś: 'S',
ś: 's',
Ŝ: 'S',
ŝ: 's',
Ş: 'S',
Ș: 'S',
ș: 's',
ş: 's',
Š: 'S',
š: 's',
Ţ: 'T',
ţ: 't',
ț: 't',
Ț: 'T',
Ť: 'T',
ť: 't',
Ŧ: 'T',
ŧ: 't',
T̆: 'T',
t̆: 't',
Ũ: 'U',
ũ: 'u',
Ū: 'U',
ū: 'u',
Ŭ: 'U',
ŭ: 'u',
Ů: 'U',
ů: 'u',
Ű: 'U',
ű: 'u',
Ų: 'U',
ų: 'u',
Ȗ: 'U',
ȗ: 'u',
V̆: 'V',
v̆: 'v',
Ŵ: 'W',
ŵ: 'w',
Ẃ: 'W',
ẃ: 'w',
X̆: 'X',
x̆: 'x',
Ŷ: 'Y',
ŷ: 'y',
Ÿ: 'Y',
Y̆: 'Y',
y̆: 'y',
Ź: 'Z',
ź: 'z',
Ż: 'Z',
ż: 'z',
Ž: 'Z',
ž: 'z',
ſ: 's',
ƒ: 'f',
Ơ: 'O',
ơ: 'o',
Ư: 'U',
ư: 'u',
Ǎ: 'A',
ǎ: 'a',
Ǐ: 'I',
ǐ: 'i',
Ǒ: 'O',
ǒ: 'o',
Ǔ: 'U',
ǔ: 'u',
Ǖ: 'U',
ǖ: 'u',
Ǘ: 'U',
ǘ: 'u',
Ǚ: 'U',
ǚ: 'u',
Ǜ: 'U',
ǜ: 'u',
Ứ: 'U',
ứ: 'u',
Ṹ: 'U',
ṹ: 'u',
Ǻ: 'A',
ǻ: 'a',
Ǽ: 'AE',
ǽ: 'ae',
Ǿ: 'O',
ǿ: 'o',
Þ: 'TH',
þ: 'th',
Ṕ: 'P',
ṕ: 'p',
Ṥ: 'S',
ṥ: 's',
X́: 'X',
x́: 'x',
Ѓ: 'Г',
ѓ: 'г',
Ќ: 'К',
ќ: 'к',
A̋: 'A',
a̋: 'a',
E̋: 'E',
e̋: 'e',
I̋: 'I',
i̋: 'i',
Ǹ: 'N',
ǹ: 'n',
Ồ: 'O',
ồ: 'o',
Ṑ: 'O',
ṑ: 'o',
Ừ: 'U',
ừ: 'u',
Ẁ: 'W',
ẁ: 'w',
Ỳ: 'Y',
ỳ: 'y',
Ȁ: 'A',
ȁ: 'a',
Ȅ: 'E',
ȅ: 'e',
Ȉ: 'I',
ȉ: 'i',
Ȍ: 'O',
ȍ: 'o',
Ȑ: 'R',
ȑ: 'r',
Ȕ: 'U',
ȕ: 'u',
B̌: 'B',
b̌: 'b',
Č̣: 'C',
č̣: 'c',
Ê̌: 'E',
ê̌: 'e',
F̌: 'F',
f̌: 'f',
Ǧ: 'G',
ǧ: 'g',
Ȟ: 'H',
ȟ: 'h',
J̌: 'J',
ǰ: 'j',
Ǩ: 'K',
ǩ: 'k',
M̌: 'M',
m̌: 'm',
P̌: 'P',
p̌: 'p',
Q̌: 'Q',
q̌: 'q',
Ř̩: 'R',
ř̩: 'r',
Ṧ: 'S',
ṧ: 's',
V̌: 'V',
v̌: 'v',
W̌: 'W',
w̌: 'w',
X̌: 'X',
x̌: 'x',
Y̌: 'Y',
y̌: 'y',
A̧: 'A',
a̧: 'a',
B̧: 'B',
b̧: 'b',
Ḑ: 'D',
ḑ: 'd',
Ȩ: 'E',
ȩ: 'e',
Ɛ̧: 'E',
ɛ̧: 'e',
Ḩ: 'H',
ḩ: 'h',
I̧: 'I',
i̧: 'i',
Ɨ̧: 'I',
ɨ̧: 'i',
M̧: 'M',
m̧: 'm',
O̧: 'O',
o̧: 'o',
Q̧: 'Q',
q̧: 'q',
U̧: 'U',
u̧: 'u',
X̧: 'X',
x̧: 'x',
Z̧: 'Z',
z̧: 'z'
};
const chars = Object.keys(characterMap).join('|');
const allAccents = new RegExp(chars, 'g');
function removeAccents(str) {
return str.replace(allAccents, match => {
return characterMap[match];
});
}
/**
* @name match-sorter
* @license MIT license.
* @copyright (c) 2099 Kent C. Dodds
* @author Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com)
*/
const rankings = {
CASE_SENSITIVE_EQUAL: 7,
EQUAL: 6,
STARTS_WITH: 5,
WORD_STARTS_WITH: 4,
CONTAINS: 3,
ACRONYM: 2,
MATCHES: 1,
NO_MATCH: 0
};
/**
* 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 {String} value - the value to rank against
* @param {Object} options - options to control the ranking
* @return {{rank: Number, accessorIndex: Number, accessorThreshold: Number}} - the highest ranking
*/
function rankItem(item, value, options) {
var _options$threshold;
options = options || {};
options.threshold = (_options$threshold = options.threshold) != null ? _options$threshold : rankings.MATCHES;
if (!options.accessors) {
// if keys is not specified, then we assume the item given is ready to be matched
const rank = getMatchRanking(item, value, options);
return {
// ends up being duplicate of 'item' in matches but consistent
rankedValue: item,
rank,
accessorIndex: -1,
accessorThreshold: options.threshold,
passed: rank >= options.threshold
};
}
const valuesToRank = getAllValuesToRank(item, options.accessors);
const rankingInfo = {
rankedValue: item,
rank: rankings.NO_MATCH,
accessorIndex: -1,
accessorThreshold: options.threshold,
passed: false
};
for (let i = 0; i < valuesToRank.length; i++) {
const rankValue = valuesToRank[i];
let newRank = getMatchRanking(rankValue.itemValue, value, options);
const {
minRanking,
maxRanking,
threshold = options.threshold
} = rankValue.attributes;
if (newRank < minRanking && newRank >= rankings.MATCHES) {
newRank = minRanking;
} else if (newRank > maxRanking) {
newRank = maxRanking;
}
newRank = Math.min(newRank, maxRanking);
if (newRank >= threshold && newRank > rankingInfo.rank) {
rankingInfo.rank = newRank;
rankingInfo.passed = true;
rankingInfo.accessorIndex = i;
rankingInfo.accessorThreshold = threshold;
rankingInfo.rankedValue = rankValue.itemValue;
}
}
return rankingInfo;
}
/**
* 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) {
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;
}
// Lower casing before further comparison
testString = testString.toLowerCase();
stringToRank = stringToRank.toLowerCase();
// case insensitive equals
if (testString === stringToRank) {
return rankings.EQUAL;
}
// starts with
if (testString.startsWith(stringToRank)) {
return rankings.STARTS_WITH;
}
// word starts with
if (testString.includes(` ${stringToRank}`)) {
return rankings.WORD_STARTS_WITH;
}
// contains
if (testString.includes(stringToRank)) {
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).includes(stringToRank)) {
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) {
let acronym = '';
const wordsInString = string.split(' ');
wordsInString.forEach(wordInString => {
const splitByHyphenWords = wordInString.split('-');
splitByHyphenWords.forEach(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 tighter 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) {
let matchingInOrderCharCount = 0;
let charNumber = 0;
function findMatchingCharacter(matchChar, string, index) {
for (let j = index, J = string.length; j < J; j++) {
const stringChar = string[j];
if (stringChar === matchChar) {
matchingInOrderCharCount += 1;
return j + 1;
}
}
return -1;
}
function getRanking(spread) {
const spreadPercentage = 1 / spread;
const inOrderPercentage = matchingInOrderCharCount / stringToRank.length;
const ranking = rankings.MATCHES + inOrderPercentage * spreadPercentage;
return ranking;
}
const firstIndex = findMatchingCharacter(stringToRank[0], testString, 0);
if (firstIndex < 0) {
return rankings.NO_MATCH;
}
charNumber = firstIndex;
for (let i = 1, I = stringToRank.length; i < I; i++) {
const matchChar = stringToRank[i];
charNumber = findMatchingCharacter(matchChar, testString, charNumber);
const found = charNumber > -1;
if (!found) {
return rankings.NO_MATCH;
}
}
const spread = charNumber - firstIndex;
return getRanking(spread);
}
/**
* Sorts items that have a rank, index, and accessorIndex
* @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, 0 if equal
*/
function compareItems(a, b) {
return a.rank === b.rank ? 0 : a.rank > b.rank ? -1 : 1;
}
/**
* 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, _ref) {
let {
keepDiacritics
} = _ref;
// value might not actually be a string at this point (we don't get to choose)
// so part of preparing the value for comparison is ensure that it is a string
value = `${value}`; // toString
if (!keepDiacritics) {
value = removeAccents(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, accessor) {
let accessorFn = accessor;
if (typeof accessor === 'object') {
accessorFn = accessor.accessor;
}
const value = accessorFn(item);
// because `value` can also be undefined
if (value == null) {
return [];
}
if (Array.isArray(value)) {
return value;
}
return [String(value)];
}
/**
* Gets all the values for the given keys in the given item and returns an array of those values
* @param item - the item from which the values will be retrieved
* @param keys - the keys to use to retrieve the values
* @return objects with {itemValue, attributes}
*/
function getAllValuesToRank(item, accessors) {
const allValues = [];
for (let j = 0, J = accessors.length; j < J; j++) {
const accessor = accessors[j];
const attributes = getAccessorAttributes(accessor);
const itemValues = getItemValues(item, accessor);
for (let i = 0, I = itemValues.length; i < I; i++) {
allValues.push({
itemValue: itemValues[i],
attributes
});
}
}
return allValues;
}
const defaultKeyAttributes = {
maxRanking: Infinity,
minRanking: -Infinity
};
/**
* Gets all the attributes for the given accessor
* @param accessor - the accessor from which the attributes will be retrieved
* @return object containing the accessor's attributes
*/
function getAccessorAttributes(accessor) {
if (typeof accessor === 'function') {
return defaultKeyAttributes;
}
return {
...defaultKeyAttributes,
...accessor
};
}
export { compareItems, rankItem, rankings };
//# sourceMappingURL=index.mjs.map