feat: Add Levenshtein distance utility for fuzzy matching of item names

This commit is contained in:
MayaTheShy
2026-02-20 02:32:43 -05:00
parent ac6dd5da95
commit 11c289a3ed

View File

@@ -0,0 +1,109 @@
/**
* Levenshtein distance utility
* Used for fuzzy matching block/item names for inventory operations.
* Inspired by runi95/turtle-control-panel utility.
*/
/**
* Compute the Levenshtein edit distance between two strings.
* @param {string} a - First string
* @param {string} b - Second string
* @returns {number} The edit distance
*/
export function levenshtein(a, b) {
if (a === b) return 0;
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
// Use a single-row approach for memory efficiency
const aLen = a.length;
const bLen = b.length;
const row = new Array(bLen + 1);
// Initialize the first row (distance from empty string to b[0..j])
for (let j = 0; j <= bLen; j++) {
row[j] = j;
}
for (let i = 1; i <= aLen; i++) {
let prev = i; // row[0] for this iteration = i
for (let j = 1; j <= bLen; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
const val = Math.min(
row[j] + 1, // deletion
prev + 1, // insertion
row[j - 1] + cost // substitution
);
row[j - 1] = prev;
prev = val;
}
row[bLen] = prev;
}
return row[bLen];
}
/**
* Find the best match for a query string among candidates using Levenshtein distance.
* @param {string} query - The search term
* @param {string[]} candidates - Array of candidate strings
* @param {number} maxDistance - Maximum acceptable distance (default: Infinity)
* @returns {{ match: string|null, distance: number }} Best match and its distance
*/
export function findBestMatch(query, candidates, maxDistance = Infinity) {
let bestMatch = null;
let bestDistance = Infinity;
const lowerQuery = query.toLowerCase();
for (const candidate of candidates) {
const lowerCandidate = candidate.toLowerCase();
// Exact substring match is distance 0
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
const dist = Math.abs(lowerCandidate.length - lowerQuery.length);
if (dist < bestDistance) {
bestDistance = dist;
bestMatch = candidate;
}
continue;
}
const dist = levenshtein(lowerQuery, lowerCandidate);
if (dist < bestDistance && dist <= maxDistance) {
bestDistance = dist;
bestMatch = candidate;
}
}
return { match: bestMatch, distance: bestDistance };
}
/**
* Find all matches within a given distance.
* @param {string} query - The search term
* @param {string[]} candidates - Array of candidate strings
* @param {number} maxDistance - Maximum acceptable distance
* @returns {Array<{match: string, distance: number}>} Matches sorted by distance
*/
export function findAllMatches(query, candidates, maxDistance = 3) {
const lowerQuery = query.toLowerCase();
const matches = [];
for (const candidate of candidates) {
const lowerCandidate = candidate.toLowerCase();
// Exact substring match
if (lowerCandidate.includes(lowerQuery) || lowerQuery.includes(lowerCandidate)) {
matches.push({ match: candidate, distance: 0 });
continue;
}
const dist = levenshtein(lowerQuery, lowerCandidate);
if (dist <= maxDistance) {
matches.push({ match: candidate, distance: dist });
}
}
return matches.sort((a, b) => a.distance - b.distance);
}