feat: Add Levenshtein distance utility for fuzzy matching of item names
This commit is contained in:
109
server/helpers/levenshtein.js
Normal file
109
server/helpers/levenshtein.js
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user