@tanstack/match-sorter-utils
Version:
A fork of match-sorter with separated filtering and sorting phases
208 lines (206 loc) • 7.5 kB
JavaScript
import { removeAccents } from "./remove-accents.js";
//#region src/index.ts
/**
* @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 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) {
options = options || {};
options.threshold = options.threshold ?? rankings.MATCHES;
if (!options.accessors) {
const rank = getMatchRanking(item, value, options);
return {
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);
if (stringToRank.length > testString.length) return rankings.NO_MATCH;
if (testString === stringToRank) return rankings.CASE_SENSITIVE_EQUAL;
testString = testString.toLowerCase();
stringToRank = stringToRank.toLowerCase();
if (testString === stringToRank) return rankings.EQUAL;
if (testString.startsWith(stringToRank)) return rankings.STARTS_WITH;
if (testString.includes(` ${stringToRank}`)) return rankings.WORD_STARTS_WITH;
if (testString.includes(stringToRank)) return rankings.CONTAINS;
else if (stringToRank.length === 1) return rankings.NO_MATCH;
if (getAcronym(testString).includes(stringToRank)) return rankings.ACRONYM;
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 = "";
string.split(" ").forEach((wordInString) => {
wordInString.split("-").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++) if (string[j] === matchChar) {
matchingInOrderCharCount += 1;
return j + 1;
}
return -1;
}
function getRanking(spread) {
const spreadPercentage = 1 / spread;
const inOrderPercentage = matchingInOrderCharCount / stringToRank.length;
return rankings.MATCHES + inOrderPercentage * spreadPercentage;
}
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);
if (!(charNumber > -1)) return rankings.NO_MATCH;
}
return getRanking(charNumber - firstIndex);
}
/**
* 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, { keepDiacritics }) {
value = `${value}`;
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);
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
};
}
//#endregion
export { compareItems, rankItem, rankings };