diff --git a/server/helpers/levenshtein.js b/server/helpers/levenshtein.js new file mode 100644 index 0000000..94e2fad --- /dev/null +++ b/server/helpers/levenshtein.js @@ -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); +}