generated from ztimson/template
62 lines
2.3 KiB
JavaScript
62 lines
2.3 KiB
JavaScript
export function levenshtein(a, b) {
|
|
const m = a.length, n = b.length;
|
|
if (!m) return n;
|
|
if (!n) return m;
|
|
const dp = Array.from({length: m + 1}, (_, i) => [i, ...Array(n).fill(0)]);
|
|
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
for (let i = 1; i <= m; i++) {
|
|
for (let j = 1; j <= n; j++) {
|
|
dp[i][j] = a[i - 1] === b[j - 1]
|
|
? dp[i - 1][j - 1]
|
|
: 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
|
|
}
|
|
}
|
|
return dp[m][n];
|
|
}
|
|
|
|
/** Normalized similarity in [0,1]: 1 - editDistance / maxLength. */
|
|
export function similarity(a, b) {
|
|
a = a.toLowerCase(); b = b.toLowerCase();
|
|
return 1 - levenshtein(a, b) / Math.max(a.length, b.length, 1);
|
|
}
|
|
|
|
/**
|
|
* Scores `text` against a single lowercased `term`. Substring containment always wins.
|
|
* Otherwise, only rescues genuine typos: requires the same leading letter and a tight
|
|
* absolute edit-distance cap, so unrelated words can't win purely on coincidental
|
|
* letter overlap (e.g. "Viennese" vs "diannes").
|
|
*/
|
|
function scoreAgainst(text, term) {
|
|
if (text.includes(term)) return 1 - (text.length - term.length) / text.length * 0.3;
|
|
if (!text.length || !term.length || text[0] !== term[0]) return 0;
|
|
const dist = levenshtein(text, term);
|
|
const maxAllowed = Math.max(1, Math.ceil(term.length * 0.34));
|
|
if (dist > maxAllowed) return 0;
|
|
return 1 - dist / Math.max(text.length, term.length);
|
|
}
|
|
|
|
/** Compares `target` against one or more search terms; returns avg/max/per-term similarity. */
|
|
export function fuzzyMatch(target, ...terms) {
|
|
if (!terms.length) throw new Error('Requires at least 1 term to compare');
|
|
const lowerTarget = String(target).toLowerCase();
|
|
const words = lowerTarget.split(/\W+/).filter(Boolean);
|
|
|
|
const similarities = terms.map(term => {
|
|
const t = term.toLowerCase();
|
|
return Math.max(scoreAgainst(lowerTarget, t), ...words.map(w => scoreAgainst(w, t)));
|
|
});
|
|
|
|
return {
|
|
avg: similarities.reduce((acc, s) => acc + s, 0) / similarities.length,
|
|
max: Math.max(...similarities),
|
|
similarities,
|
|
};
|
|
}
|
|
|
|
/** Derives a readable pseudo-title from a URL's last path segment, e.g. ".../diannes-southwest-salad/" -> "Diannes Southwest Salad". */
|
|
export function titleFromUrl(url) {
|
|
const slug = String(url).replace(/\/$/, '').split('/').pop() || url;
|
|
const clean = slug.replace(/\.(zim|meta4|html?|md)$/i, '').replace(/[-_.]+/g, ' ');
|
|
return clean.replace(/\b\w/g, c => c.toUpperCase());
|
|
}
|