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

View File

@@ -970,6 +970,8 @@ export function getFilterOptions(): {
}; };
} }
import { fuzzyScore } from '$lib/js/fuzzy';
export function searchExercises(opts: { export function searchExercises(opts: {
search?: string; search?: string;
bodyPart?: string; bodyPart?: string;
@@ -989,14 +991,14 @@ export function searchExercises(opts: {
} }
if (opts.search) { if (opts.search) {
const query = opts.search.toLowerCase(); const query = opts.search.toLowerCase();
results = results.filter( const scored: { exercise: Exercise; score: number }[] = [];
(e) => for (const e of results) {
e.name.toLowerCase().includes(query) || const text = `${e.name} ${e.target} ${e.bodyPart} ${e.equipment} ${e.secondaryMuscles.join(' ')}`.toLowerCase();
e.target.toLowerCase().includes(query) || const score = fuzzyScore(query, text);
e.bodyPart.toLowerCase().includes(query) || if (score > 0) scored.push({ exercise: e, score });
e.equipment.toLowerCase().includes(query) || }
e.secondaryMuscles.some((m) => m.toLowerCase().includes(query)) scored.sort((a, b) => b.score - a.score);
); results = scored.map((s) => s.exercise);
} }
return results; 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 Gebet from "./Gebet.svelte";
import LanguageToggle from "$lib/components/faith/LanguageToggle.svelte"; import LanguageToggle from "$lib/components/faith/LanguageToggle.svelte";
import SearchInput from "$lib/components/SearchInput.svelte"; import SearchInput from "$lib/components/SearchInput.svelte";
import { fuzzyScore } from '$lib/js/fuzzy';
import Kreuzzeichen from "$lib/components/faith/prayers/Kreuzzeichen.svelte"; import Kreuzzeichen from "$lib/components/faith/prayers/Kreuzzeichen.svelte";
import GloriaPatri from "$lib/components/faith/prayers/GloriaPatri.svelte"; import GloriaPatri from "$lib/components/faith/prayers/GloriaPatri.svelte";
import Paternoster from "$lib/components/faith/prayers/Paternoster.svelte"; import Paternoster from "$lib/components/faith/prayers/Paternoster.svelte";
@@ -218,23 +219,23 @@
prayers.forEach(prayer => { prayers.forEach(prayer => {
const name = getPrayerName(prayer.id); const name = getPrayerName(prayer.id);
// Check name match // Check name match (fuzzy)
if (normalize(name).includes(normalizedQuery)) { if (fuzzyScore(normalizedQuery, normalize(name)) > 0) {
newResults.set(prayer.id, 'primary'); newResults.set(prayer.id, 'primary');
return; return;
} }
// Check search terms match // Check search terms match (fuzzy)
if (prayer.searchTerms.some(term => normalize(term).includes(normalizedQuery))) { if (prayer.searchTerms.some(term => fuzzyScore(normalizedQuery, normalize(term)) > 0)) {
newResults.set(prayer.id, 'primary'); newResults.set(prayer.id, 'primary');
return; return;
} }
// Check DOM text content // Check DOM text content (fuzzy)
const element = document.querySelector(`[data-prayer-id="${prayer.id}"]`); const element = document.querySelector(`[data-prayer-id="${prayer.id}"]`);
if (element) { if (element) {
const textContent = normalize(element.textContent || ''); const textContent = normalize(element.textContent || '');
if (textContent.includes(normalizedQuery)) { if (fuzzyScore(normalizedQuery, textContent) > 0) {
newResults.set(prayer.id, 'secondary'); newResults.set(prayer.id, 'secondary');
} }
} }