add fzf-style fuzzy search to exercises, recipes, and prayers
All checks were successful
CI / update (push) Successful in 2m1s
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:
@@ -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(
|
||||
|
||||
@@ -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
42
src/lib/js/fuzzy.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user