diff --git a/src/lib/components/recipes/Search.svelte b/src/lib/components/recipes/Search.svelte index c0af6b8..879951d 100644 --- a/src/lib/components/recipes/Search.svelte +++ b/src/lib/components/recipes/Search.svelte @@ -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( diff --git a/src/lib/data/exercises.ts b/src/lib/data/exercises.ts index bd418c6..7014e87 100644 --- a/src/lib/data/exercises.ts +++ b/src/lib/data/exercises.ts @@ -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; diff --git a/src/lib/js/fuzzy.ts b/src/lib/js/fuzzy.ts new file mode 100644 index 0000000..e22c7d5 --- /dev/null +++ b/src/lib/js/fuzzy.ts @@ -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; +} diff --git a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.svelte b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.svelte index e5be559..45a9e6c 100644 --- a/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.svelte +++ b/src/routes/[faithLang=faithLang]/[prayers=prayersLang]/+page.svelte @@ -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'); } }