feat: Add fuzzy search to patches search (#229)

* feat: Fuzzy Search

Co-authored-by: Kendell R <KTibow@users.noreply.github.com>

* slightly change the init logic

* fix behavior

* fix sort behavior
i am so good at reading docs

* update the search results on load

* switch to fuse js

* lower the threshold per @oSumAtrIX request

---------

Co-authored-by: Kendell R <KTibow@users.noreply.github.com>
Co-authored-by: afn <hey@afn.im>
This commit is contained in:
Ushie 2024-04-27 20:29:52 +03:00 committed by GitHub
parent f86456cb4c
commit 9275193333
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 64 additions and 63 deletions

View File

@ -21,6 +21,7 @@
"eslint": "^8.56.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1", "eslint-plugin-svelte": "^2.35.1",
"fuse.js": "^7.0.0",
"imagetools-core": "^6.0.3", "imagetools-core": "^6.0.3",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2", "prettier-plugin-svelte": "^3.1.2",

8
pnpm-lock.yaml generated
View File

@ -43,6 +43,9 @@ devDependencies:
eslint-plugin-svelte: eslint-plugin-svelte:
specifier: ^2.35.1 specifier: ^2.35.1
version: 2.35.1(eslint@8.56.0)(svelte@4.2.8) version: 2.35.1(eslint@8.56.0)(svelte@4.2.8)
fuse.js:
specifier: ^7.0.0
version: 7.0.0
imagetools-core: imagetools-core:
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.0.3 version: 6.0.3
@ -1493,6 +1496,11 @@ packages:
dev: true dev: true
optional: true optional: true
/fuse.js@7.0.0:
resolution: {integrity: sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q==}
engines: {node: '>=10'}
dev: true
/get-port@3.2.0: /get-port@3.2.0:
resolution: {integrity: sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==} resolution: {integrity: sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==}
engines: {node: '>=4'} engines: {node: '>=4'}

View File

@ -6,11 +6,11 @@
export let title: string; export let title: string;
export let searchTerm: string | null; export let searchTerm: string | null;
export let searchTermFiltered: string | undefined; export let displayedTerm: string | undefined;
function clear() { function clear() {
searchTerm = ''; searchTerm = '';
searchTermFiltered = ''; displayedTerm = '';
const url = new URL($page.url); const url = new URL($page.url);
url.searchParams.delete('s'); url.searchParams.delete('s');

View File

@ -10,7 +10,6 @@
import { createQuery } from '@tanstack/svelte-query'; import { createQuery } from '@tanstack/svelte-query';
import { queries } from '$data/api'; import { queries } from '$data/api';
import { JsonLd } from 'svelte-meta-tags';
import Head from '$lib/components/Head.svelte'; import Head from '$lib/components/Head.svelte';
import PackageMenu from './PackageMenu.svelte'; import PackageMenu from './PackageMenu.svelte';
import Package from './Package.svelte'; import Package from './Package.svelte';
@ -20,9 +19,13 @@
import FilterChip from '$lib/components/FilterChip.svelte'; import FilterChip from '$lib/components/FilterChip.svelte';
import Dialogue from '$lib/components/Dialogue.svelte'; import Dialogue from '$lib/components/Dialogue.svelte';
import Query from '$lib/components/Query.svelte'; import Query from '$lib/components/Query.svelte';
import Fuse from 'fuse.js';
import { onMount } from 'svelte';
const query = createQuery(['patches'], queries.patches); const query = createQuery(['patches'], queries.patches);
let searcher: Fuse<Patch> | undefined;
let searchParams: Readable<URLSearchParams>; let searchParams: Readable<URLSearchParams>;
if (building) { if (building) {
searchParams = readable(new URLSearchParams()); searchParams = readable(new URLSearchParams());
@ -31,15 +34,8 @@
} }
$: selectedPkg = $searchParams.get('pkg'); $: selectedPkg = $searchParams.get('pkg');
let searchTerm = $searchParams.get('s'); let searchTerm = $searchParams.get('s') || '';
let searchTermFiltered = searchTerm
?.replace(/\./g, '')
.replace(/\s/g, '')
.replace(/-/g, '')
.replace(/_/g, '')
.toLowerCase();
let timeout: ReturnType<typeof setTimeout>;
let mobilePackages = false; let mobilePackages = false;
let showAllVersions = false; let showAllVersions = false;
@ -50,61 +46,58 @@
return !!patch.compatiblePackages?.find((compat) => compat.name === pkg); return !!patch.compatiblePackages?.find((compat) => compat.name === pkg);
} }
function searchString(str?: string, term: string, filter: RegExp) {
return str?.toLowerCase().replace(filter, '').includes(term);
}
function filter(patches: Patch[], pkg: string, search?: string): Patch[] { function filter(patches: Patch[], pkg: string, search?: string): Patch[] {
if (search === undefined && pkg === '') { if (!search) {
return patches; if (pkg) return patches.filter((patch) => checkCompatibility(patch, pkg));
else return patches;
} }
return patches.filter((patch) => { if (!searcher) {
// Don't show if the patch doesn't support the selected package searcher = new Fuse(patches, {
if (pkg && !checkCompatibility(patch, pkg)) { keys: ['name', 'description', 'compatiblePackages.name', 'compatiblePackages.versions'],
return false; shouldSort: true,
} threshold: 0.3
});
}
// Filter based on the search term. const result = searcher
if (search !== undefined) { .search(search)
return ( .map(({ item }) => item)
searchString(patch.description, search, /\s/g) || .filter((item) => {
searchString(patch.name, search, /\s/g) || // Don't show if the patch doesn't support the selected package
patch.compatiblePackages?.find((x) => searchString(x.name, search, /\./g)) if (pkg && !checkCompatibility(item, pkg)) {
); return false;
} }
return true; return true;
}); });
return result;
} }
// Make sure we don't have to filter the patches after every key press // Make sure we don't have to filter the patches after every key press
const debounce = () => { let displayedTerm = '';
clearTimeout(timeout); const debounce = <T extends any[]>(f: (...args: T) => void) => {
timeout = setTimeout(() => { let timeout: number;
// Filter search term for better results (i.e. " Unl O-ck" and "unlock" gives the same results) return (...args: T) => {
searchTermFiltered = searchTerm clearTimeout(timeout);
?.replace(/\./g, '') timeout = setTimeout(() => f(...args), 350);
.replace(/\s/g, '') };
.replace(/-/g, '')
.replace(/_/g, '')
.toLowerCase();
// Update search URL params
// must use history.pushState instead of goto(), as goto() unselects the search bar
const url = new URL(window.location.href);
url.pathname = '/patches';
const params = new URLSearchParams();
if (selectedPkg) {
params.set('pkg', selectedPkg);
}
if (searchTerm) {
params.set('s', searchTerm);
}
url.search = params.toString();
window.history.pushState(null, '', url.toString());
}, 500);
}; };
const update = () => {
displayedTerm = searchTerm;
const url = new URL(window.location.href);
url.pathname = '/patches';
if (searchTerm) {
url.searchParams.set('s', searchTerm);
} else {
url.searchParams.delete('s');
}
window.history.pushState(null, '', url);
};
onMount(update);
</script> </script>
<Head <Head
@ -135,12 +128,11 @@
<div class="search"> <div class="search">
<div class="search-contain"> <div class="search-contain">
<!-- Must bind both variables: we get searchTerm from the text input, --> <!-- Must bind both variables: we get searchTerm from the text input, -->
<!-- and searchTermFiltered gets cleared with the clear button -->
<Search <Search
bind:searchTerm bind:searchTerm
bind:searchTermFiltered bind:displayedTerm
title="Search for patches" title="Search for patches"
on:keyup={debounce} on:keyup={debounce(update)}
/> />
</div> </div>
</div> </div>
@ -192,9 +184,9 @@
</aside> </aside>
<div class="patches-container"> <div class="patches-container">
{#each filter(data.patches, selectedPkg || '', searchTermFiltered) as patch} {#each filter(data.patches, selectedPkg || '', displayedTerm) as patch}
<!-- Trigger new animations when package or search changes (I love Svelte) --> <!-- Trigger new animations when package or search changes (I love Svelte) -->
{#key selectedPkg || searchTermFiltered} {#key selectedPkg || displayedTerm}
<div in:fly={{ y: 10, easing: quintOut, duration: 750 }}> <div in:fly={{ y: 10, easing: quintOut, duration: 750 }}>
<PatchItem {patch} bind:showAllVersions /> <PatchItem {patch} bind:showAllVersions />
</div> </div>