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 { 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(
|
||||||
|
|||||||
@@ -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
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 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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user