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-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"fuse.js": "^7.0.0",
"imagetools-core": "^6.0.3",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",

8
pnpm-lock.yaml generated
View File

@ -43,6 +43,9 @@ devDependencies:
eslint-plugin-svelte:
specifier: ^2.35.1
version: 2.35.1(eslint@8.56.0)(svelte@4.2.8)
fuse.js:
specifier: ^7.0.0
version: 7.0.0
imagetools-core:
specifier: ^6.0.3
version: 6.0.3
@ -1493,6 +1496,11 @@ packages:
dev: 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:
resolution: {integrity: sha512-x5UJKlgeUiNT8nyo/AcnwLnZuZNcSjSw0kogRB+Whd1fjjFq4B1hySFxSFWWSn4mIBzg3sRNUDFYc4g5gjPoLg==}
engines: {node: '>=4'}

View File

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

View File

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