quickshell/utils/fuzzySearch.js
2025-09-09 12:03:05 +02:00

71 lines
2.7 KiB
JavaScript

/**
* Fuzzy search for list of objects.
* - Each object in `items` must have a `value`. Or set the search key in options.
* - Returns an array of original objects ordered by their relevance.
* - Implements a cutoff for non-matching strings based on minimum relevance. Can be set in the options.
*
* @param {string} query - The search string.
* @param {Object[]} items - The array of objects to search. Each must have a 'value' property (string).
* @param {object} [options] - Optional settings.
* @param {number} [options.cutoff=0.2] - Minimum relevance threshold (0 to 1).
* @param {string} [options.key='value'] - The property name to match against (default: 'value').
* @param {boolean} [options.allOnEmpty=true] - Return all items on empty query.
* @param {Object} [options.boostMap] - An object mapping item keys (by 'value') to normalized boost values (0 to 1).
* @param {number} [options.boostWeight=0.1] - The maximum boost to apply for boostMap value 1.0.
* @returns {Object[]} - Array of matching objects sorted by relevance.
*/
function fuzzySearch(query, items, options = {}) {
const cutoff = options.cutoff ?? 0.2;
const key = options.key ?? 'name';
const boostMap = options.boostMap || {};
const boostWeight = options.boostWeight ?? 0.1;
const allOnEmpty = options.allOnEmpty == undefined || options.allOnEmpty == true;
if (!query) return allOnEmpty ? items : []
const q = query.toLowerCase();
return items
.map(item => {
const text = (item[key] || '').toLowerCase();
let score = 0;
// Perfect match
if (text === q) score = 1.0;
// Starts with
else if (text.startsWith(q)) score = 0.9 + 0.01 * (1 - q.length / (text.length || 1));
// Substring match
else {
const idx = text.indexOf(q);
if (idx !== -1) {
const startScore = 0.7 - 0.1 * (idx / (text.length || 1));
const lengthScore = 0.2 * (q.length / (text.length || 1));
score = startScore + lengthScore;
} else {
// Fuzzy: all query chars in order
let lastIdx = -1;
let found = true;
for (let c of q) {
lastIdx = text.indexOf(c, lastIdx + 1);
if (lastIdx === -1) {
found = false;
break;
}
}
if (found) {
score = 0.5 * (q.length / (text.length || 1));
}
}
}
// Boost: expects normalized boostMap (values from 0 to 1)
const boost = Math.max(0, Math.min(1, boostMap[item[key]] || 0)) * boostWeight;
return { item, score: score + boost };
})
.filter(result => result.score >= cutoff)
.sort((a, b) => b.score - a.score)
.map(result => result.item);
}