add fzf-style fuzzy search to exercises, recipes, and prayers
All checks were successful
CI / update (push) Successful in 2m1s

Replace substring matching with a shared fuzzy scorer that matches
characters in order (non-contiguous) with bonuses for consecutive
and word-boundary hits. Results are ranked by match quality.
This commit is contained in:
2026-03-19 10:01:43 +01:00
parent 640a986763
commit 14da4064a5
4 changed files with 71 additions and 23 deletions

View File

@@ -3,6 +3,7 @@
import { browser } from '$app/environment';
import FilterPanel from './FilterPanel.svelte';
import { getCategories } from '$lib/js/categories';
import { fuzzyScore } from '$lib/js/fuzzy';
// Filter props for different contexts
let {
@@ -137,15 +138,15 @@
return;
}
// Normalize and split search query
// Normalize search query
const searchText = query.toLowerCase().trim()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ").filter((/** @type {string} */ term) => term.length > 0);
// Filter recipes by text
const matched = filteredByNonText.filter((/** @type {any} */ recipe) => {
// Build searchable string from recipe data
// Fuzzy match and score recipes
/** @type {{ recipe: any, score: number }[]} */
const scored = [];
for (const recipe of filteredByNonText) {
const searchString = [
recipe.name || '',
recipe.description || '',
@@ -154,11 +155,13 @@
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "")
.replace(/­|­/g, ''); // Remove soft hyphens
.replace(/­|­/g, '');
// All search terms must match
return searchTerms.every((/** @type {string} */ term) => searchString.includes(term));
});
const score = fuzzyScore(searchText, searchString);
if (score > 0) scored.push({ recipe, score });
}
scored.sort((a, b) => b.score - a.score);
const matched = scored.map(s => s.recipe);
// Return matched recipe IDs and categories
onSearchResults(

View File

@@ -970,6 +970,8 @@ export function getFilterOptions(): {
};
}
import { fuzzyScore } from '$lib/js/fuzzy';
export function searchExercises(opts: {
search?: string;
bodyPart?: string;
@@ -989,14 +991,14 @@ export function searchExercises(opts: {
}
if (opts.search) {
const query = opts.search.toLowerCase();
results = results.filter(
(e) =>
e.name.toLowerCase().includes(query) ||
e.target.toLowerCase().includes(query) ||
e.bodyPart.toLowerCase().includes(query) ||
e.equipment.toLowerCase().includes(query) ||
e.secondaryMuscles.some((m) => m.toLowerCase().includes(query))
);
const scored: { exercise: Exercise; score: number }[] = [];
for (const e of results) {
const text = `${e.name} ${e.target} ${e.bodyPart} ${e.equipment} ${e.secondaryMuscles.join(' ')}`.toLowerCase();
const score = fuzzyScore(query, text);
if (score > 0) scored.push({ exercise: e, score });
}
scored.sort((a, b) => b.score - a.score);
results = scored.map((s) => s.exercise);
}
return results;

42
src/lib/js/fuzzy.ts Normal file
View File

@@ -0,0 +1,42 @@
/**
* fzf-style fuzzy match: characters must appear in order (non-contiguous).
* Returns 0 for no match, higher scores for better matches.
*/
export function fuzzyScore(query: string, text: string): number {
if (query.length === 0) return 1;
let qi = 0;
let score = 0;
let consecutive = 0;
let prevMatchIdx = -2;
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
if (text[ti] === query[qi]) {
qi++;
score += 1;
// Bonus for consecutive matches
if (ti === prevMatchIdx + 1) {
consecutive++;
score += consecutive * 2;
} else {
consecutive = 0;
}
// Bonus for matching at word boundary (start, after space/punctuation)
if (ti === 0 || /[\s()\-_/]/.test(text[ti - 1])) {
score += 5;
}
prevMatchIdx = ti;
}
}
// All query chars must be found
if (qi < query.length) return 0;
// Bonus for closer match length to query length (tighter matches)
const span = prevMatchIdx - (text.indexOf(query[0]) ?? 0) + 1;
score += Math.max(0, query.length * 2 - span);
return score;
}

View File

@@ -5,6 +5,7 @@
import Gebet from "./Gebet.svelte";
import LanguageToggle from "$lib/components/faith/LanguageToggle.svelte";
import SearchInput from "$lib/components/SearchInput.svelte";
import { fuzzyScore } from '$lib/js/fuzzy';
import Kreuzzeichen from "$lib/components/faith/prayers/Kreuzzeichen.svelte";
import GloriaPatri from "$lib/components/faith/prayers/GloriaPatri.svelte";
import Paternoster from "$lib/components/faith/prayers/Paternoster.svelte";
@@ -218,23 +219,23 @@
prayers.forEach(prayer => {
const name = getPrayerName(prayer.id);
// Check name match
if (normalize(name).includes(normalizedQuery)) {
// Check name match (fuzzy)
if (fuzzyScore(normalizedQuery, normalize(name)) > 0) {
newResults.set(prayer.id, 'primary');
return;
}
// Check search terms match
if (prayer.searchTerms.some(term => normalize(term).includes(normalizedQuery))) {
// Check search terms match (fuzzy)
if (prayer.searchTerms.some(term => fuzzyScore(normalizedQuery, normalize(term)) > 0)) {
newResults.set(prayer.id, 'primary');
return;
}
// Check DOM text content
// Check DOM text content (fuzzy)
const element = document.querySelector(`[data-prayer-id="${prayer.id}"]`);
if (element) {
const textContent = normalize(element.textContent || '');
if (textContent.includes(normalizedQuery)) {
if (fuzzyScore(normalizedQuery, textContent) > 0) {
newResults.set(prayer.id, 'secondary');
}
}