From cff08b5387611e9497acdef8bcddae2e70f67ddf Mon Sep 17 00:00:00 2001 From: retool-bot Date: Tue, 31 Dec 2024 19:48:28 -0800 Subject: [PATCH] Faster searching with Fuse.js --- package.json | 2 +- src/js/listing/app.tsx | 17 +++-- src/js/listing/search.ts | 118 +++++++++++++++++++++--------- src/js/listing/search_results.tsx | 20 +++-- yarn.lock | 20 ++--- 5 files changed, 115 insertions(+), 62 deletions(-) diff --git a/package.json b/package.json index 4a6167a..3f1b669 100644 --- a/package.json +++ b/package.json @@ -84,9 +84,9 @@ "cli-progress": "^3.12.0", "commander": "^12.0.0", "date-fns": "^3.6.0", + "fuse.js": "7.0.0", "localforage": "^1.10.0", "lodash": "^4.17.21", - "match-sorter": "^6.3.1", "node-gzip": "^1.1.2", "polished": "^4.2.2", "react": "^18.2.0", diff --git a/src/js/listing/app.tsx b/src/js/listing/app.tsx index fb64b0f..9224fca 100644 --- a/src/js/listing/app.tsx +++ b/src/js/listing/app.tsx @@ -20,6 +20,7 @@ import { GlobalStyle } from "styles/global_style"; import { IndexStyles } from "styles/index_style"; import { theme } from "theme"; import { differenceInDays } from "date-fns"; +import React from "react"; const BtnSpan = styled.span` padding: 0.4rem 0.5rem; @@ -249,12 +250,16 @@ export function App(props: { scriptsFile: ScriptsFile }): JSX.Element { const baseThree = scripts.filter((s) => BaseThree.includes(s.pk)); baseThree.sort((s1, s2) => s1.pk - s2.pk); - const custom = scripts.filter((s) => { - if (BaseThree.includes(s.pk)) { - return false; - } - return authenticated || !s.allAmne; - }); + const custom = React.useMemo( + () => + scripts.filter((s) => { + if (BaseThree.includes(s.pk)) { + return false; + } + return authenticated || !s.allAmne; + }), + [scripts, authenticated], + ); function removePrefix(s: string, prefix: string): string { if (s.startsWith(prefix)) { diff --git a/src/js/listing/search.ts b/src/js/listing/search.ts index e18edbe..53cc430 100644 --- a/src/js/listing/search.ts +++ b/src/js/listing/search.ts @@ -1,6 +1,9 @@ +import Fuse from "fuse.js/basic"; +import type { DebouncedFunc } from "lodash"; +import debounce from "lodash.debounce"; +import React from "react"; import { nameToId, roles } from "../botc/roles"; import { ScriptData } from "../botc/script"; -import { matchSorter } from "match-sorter"; const FAVORITE_TITLES: Set = new Set([ "Reptiles II: Lizard in the City", @@ -25,43 +28,92 @@ function characterList(script: ScriptData): string[] { return characters; } -// match scripts that have a list of characters -function characterQueryMatches( - characters: string, +/** + * Fuzzily search through scripts by title, author, and characters + * + * @param scripts A corpus of scripts to search through + * @param query A user-provided search query + * @returns A Map from primary key to script, sorted with most relevant results first + */ +export function useQueryMatches( scripts: ScriptData[], -): ScriptData[] { - const terms = characters.split(" "); + query: string, +): Map { + const favorite_scripts = React.useMemo( + () => + new Map( + scripts + .filter((s) => FAVORITE_TITLES.has(s.title)) + .map((s) => [s.pk, s]), + ), + [scripts], + ); - return terms.reduceRight( - (results, char) => - matchSorter(results, char.replace("-", ""), { - keys: [characterList], - threshold: matchSorter.rankings.WORD_STARTS_WITH, + const title_searcher = React.useMemo( + () => + new Fuse(scripts, { + keys: [ + { + name: "title", + getFn: (script) => script.title.replace(/[^A-Za-z]/g, ""), + }, + { name: "author" }, + ], }), - scripts, + [scripts], ); -} -export function queryMatches( - scripts: ScriptData[], - query: string, -): ScriptData[] { - let matches: ScriptData[]; - if (query == "") { - matches = scripts.filter((s) => FAVORITE_TITLES.has(s.title)); - } else { - matches = matchSorter(scripts, query, { keys: ["title", "author"] }); - if (matches.length < 10) { - // fill in results with character-based search - matches.push( - ...characterQueryMatches( - query, - // start with non-matching scripts - scripts.filter((s) => !matches.some((m) => m.pk == s.pk)), - ), - ); - } - } + const character_searcher = React.useMemo( + () => + new Fuse(scripts, { + keys: [ + { name: "characters", getFn: (script) => characterList(script) }, + ], + }), + [scripts], + ); + + const [matches, setMatches] = React.useState(favorite_scripts); + + // Wait for the user to stop typing before searching + const debounceSearchRef = + React.useRef void>>(); + React.useEffect(() => { + const debounceSearch = debounce((query: string) => { + if (query === "") { + setMatches(favorite_scripts); + return; + } + + const matches = new Map(); + const scripts_with_titles_or_authors = title_searcher.search(query); + for (const match_result of scripts_with_titles_or_authors) { + const script = match_result.item; + matches.set(script.pk, script); + } + if (matches.size < 10) { + // fill in results with character-based search + const scripts_with_characters = character_searcher.search(query); + for (const match_result of scripts_with_characters) { + const script = match_result.item; + matches.set(script.pk, script); + } + } + + setMatches(matches); + }, 300); + debounceSearchRef.current = debounceSearch; + return () => { + debounceSearchRef.current = undefined; + debounceSearch.cancel(); + }; + }, [favorite_scripts, title_searcher, character_searcher]); + + // Run the debounced search when the query changes + React.useEffect(() => { + debounceSearchRef.current?.(query); + }, [query]); + return matches; } diff --git a/src/js/listing/search_results.tsx b/src/js/listing/search_results.tsx index ccef401..bae846c 100644 --- a/src/js/listing/search_results.tsx +++ b/src/js/listing/search_results.tsx @@ -1,5 +1,5 @@ import { ScriptList } from "./script_list"; -import { queryMatches, searchNormalize } from "./search"; +import { useQueryMatches, searchNormalize } from "./search"; import { css } from "@emotion/react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ScriptData } from "botc/script"; @@ -22,9 +22,15 @@ export function SearchResults(props: { window.location.hash = searchNormalize(newQuery); } - const allResults = queryMatches(scripts, query); - const results = allResults.slice(0, 20); - const extraResults = allResults.slice(20); + const allResults = useQueryMatches(scripts, query); + const results = []; + for (const result of allResults.values()) { + if (results.length >= 20) { + break; + } + results.push(result); + } + const numExtraResults = allResults.size - results.length; return ( <> @@ -63,11 +69,9 @@ export function SearchResults(props: { )} - {allResults.length == 0 && No results} + {allResults.size === 0 && No results} - {extraResults.length > 0 && ( - ... plus {extraResults.length} more - )} + {numExtraResults > 0 && ... plus {numExtraResults} more} ); } diff --git a/yarn.lock b/yarn.lock index db9d7df..d1ec593 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1101,7 +1101,7 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.23.8", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.8.4": version "7.25.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.7.tgz#7ffb53c37a8f247c8c4d335e89cdf16a2e0d0fb6" integrity sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w== @@ -4613,6 +4613,11 @@ functions-have-names@^1.2.3: resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +fuse.js@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-7.0.0.tgz#6573c9fcd4c8268e403b4fc7d7131ffcf99a9eb2" + integrity sha512-14F4hBIxqKvD4Zz/XjDc3y94mNZN6pRv3U13Udo0lNLCWRBUsrMv2xwcF/y/Z5sV6+FQW+/ow68cHpm4sunt8Q== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" @@ -5636,14 +5641,6 @@ map-obj@^4.0.0: resolved "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz" integrity sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ== -match-sorter@^6.3.1: - version "6.3.4" - resolved "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.4.tgz" - integrity sha512-jfZW7cWS5y/1xswZo8VBOdudUiSd9nifYRWphc9M5D/ee4w4AoXLgBEdRbgVaxbMuagBPeUC5y2Hi8DO6o9aDg== - dependencies: - "@babel/runtime" "^7.23.8" - remove-accents "0.5.0" - md5.js@^1.3.4: version "1.3.5" resolved "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz" @@ -6657,11 +6654,6 @@ regjsparser@^0.11.0: dependencies: jsesc "~3.0.2" -remove-accents@0.5.0: - version "0.5.0" - resolved "https://registry.npmjs.org/remove-accents/-/remove-accents-0.5.0.tgz" - integrity sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A== - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz"