From 2a9fa987987c6f4524b56c58559a32ff6f183cc5 Mon Sep 17 00:00:00 2001 From: Martin Marosi Date: Wed, 22 May 2024 13:56:01 +0200 Subject: [PATCH] Use local search DB in search input. --- src/components/Header/Header.tsx | 6 +- src/components/Search/SearchInput.scss | 9 ++ src/components/Search/SearchInput.tsx | 116 ++++--------------------- src/components/Search/SearchTitle.tsx | 2 +- src/components/Search/SearchTypes.ts | 42 --------- src/utils/localSearch.ts | 109 +++++++++++++++++++++++ 6 files changed, 138 insertions(+), 146 deletions(-) delete mode 100644 src/components/Search/SearchTypes.ts create mode 100644 src/utils/localSearch.ts diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 808d4baa5..a8e4dc5e8 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,4 +1,4 @@ -import React, { Fragment, useContext, useState } from 'react'; +import React, { Fragment, Suspense, useContext, useState } from 'react'; import ReactDOM from 'react-dom'; import Tools from './Tools'; import UnAuthtedHeader from './UnAuthtedHeader'; @@ -82,7 +82,9 @@ export const Header = ({ breadcrumbsProps }: { breadcrumbsProps?: Breadcrumbspro )} - + + + { if (!menuElement) { return 0; @@ -82,22 +44,6 @@ type SearchInputListener = { onStateChange: (isOpen: boolean) => void; }; -function parseSuggestions(suggestions: SearchAutoSuggestionResultItem[] = []) { - return suggestions.map((suggestion) => { - const [allTitle, bundleTitle, abstract] = suggestion.term.split(AUTOSUGGEST_TERM_DELIMITER); - const url = new URL(suggestion.payload); - return { - item: { - title: allTitle, - bundleTitle, - description: abstract, - pathname: url.pathname, - }, - allTitle, - }; - }); -} - const SearchInput = ({ onStateChange }: SearchInputListener) => { const [isOpen, setIsOpen] = useState(false); const [searchValue, setSearchValue] = useState(''); @@ -105,6 +51,9 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { const [searchItems, setSearchItems] = useState([]); const { ready, analytics } = useSegment(); const blockCloseEvent = useRef(false); + const asyncLocalOramaData = useAtomValue(asyncLocalOrama); + + const debouncedTrack = useCallback(analytics ? debounce(analytics.track, 1000) : () => null, [analytics]); const isMounted = useRef(false); const toggleRef = useRef(null); @@ -183,10 +132,9 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { window.removeEventListener('resize', handleWindowResize); isMounted.current = false; }; - }, []); + }, [isOpen, menuRef, resultCount]); useEffect(() => { - handleWindowResize(); window.addEventListener('keydown', handleMenuKeys); window.addEventListener('click', handleClickOutside); return () => { @@ -195,51 +143,17 @@ const SearchInput = ({ onStateChange }: SearchInputListener) => { }; }, [isOpen, menuRef]); - const handleFetch = async (value = '') => { - const response = (await fetch(SUGGEST_SEARCH_QUERY.replaceAll(REPLACE_TAG, value)).then((r) => r.json())) as SearchAutoSuggestionResponseType; - - // parse default suggester - // parse improved suggester - let items: { item: SearchItem; allTitle: string }[] = []; - items = items - .concat( - parseSuggestions(response?.suggest?.default[value]?.suggestions), - parseSuggestions(response?.suggest?.improvedInfixSuggester[value]?.suggestions) - ) - .slice(0, 10); - const suggests = uniq(items.map(({ allTitle }) => allTitle.replace(/(|<\/b>)/gm, '').trim())); - let searchItems = items.map(({ item }) => item); - if (items.length < 10) { - const altTitleResults = (await fetch( - BASE_URL.toString() - .replaceAll(REPLACE_TAG, `(${suggests.length > 0 ? suggests.join(' OR ') + ' OR ' : ''}${value})`) - .replaceAll(REPLACE_COUNT_TAG, '10') - ).then((r) => r.json())) as { response: SearchResponseType }; - searchItems = searchItems.concat( - altTitleResults.response.docs.map((doc) => ({ - pathname: doc.relative_uri, - bundleTitle: doc.bundle_title[0], - title: doc.allTitle, - description: doc.abstract, - })) - ); - } - searchItems = uniqWith(searchItems, (a, b) => a.title.replace(/(|<\/b>)/gm, '').trim() === b.title.replace(/(|<\/b>)/gm, '').trim()); - setSearchItems(searchItems.slice(0, 10)); + const handleChange: SearchInputProps['onChange'] = async (_e, value) => { + setSearchValue(value); + setIsFetching(true); + const results = await localQuery(asyncLocalOramaData, value); + setSearchItems(results ?? []); isMounted.current && setIsFetching(false); if (ready && analytics) { - analytics.track('chrome.search-query', { query: value }); + debouncedTrack('chrome.search-query', { query: value }); } }; - const debouncedFetch = useCallback(debounce(handleFetch, 500), []); - - const handleChange: SearchInputProps['onChange'] = (_e, value) => { - setSearchValue(value); - setIsFetching(true); - debouncedFetch(value); - }; - const [isExpanded, setIsExpanded] = React.useState(false); const onToggleExpand = (_event: React.SyntheticEvent, isExpanded: boolean) => { diff --git a/src/components/Search/SearchTitle.tsx b/src/components/Search/SearchTitle.tsx index 445e42f81..7d67965df 100644 --- a/src/components/Search/SearchTitle.tsx +++ b/src/components/Search/SearchTitle.tsx @@ -5,7 +5,7 @@ const SearchTitle = ({ title, bundleTitle }: { title: string; bundleTitle: strin const showBundleTitle = bundleTitle.replace(/\s/g, '').length > 0; return ( - + {showBundleTitle && ( | diff --git a/src/components/Search/SearchTypes.ts b/src/components/Search/SearchTypes.ts deleted file mode 100644 index 7b44d7b20..000000000 --- a/src/components/Search/SearchTypes.ts +++ /dev/null @@ -1,42 +0,0 @@ -export type SearchResultItem = { - abstract: string; - allTitle: string; - bundle: string[]; - bundle_title: string[]; - documentKind: string; - id: string; - relative_uri: string; - view_uri: string; -}; - -export type SearchAutoSuggestionResultItem = { - term: string; - weight: string; - payload: string; -}; - -export type SearchResponseType = { - docs: SearchResultItem[]; - start: number; - numFound: number; - maxScore: number; -}; - -export type SearchAutoSuggestionResponseType = { - suggest: { - improvedInfixSuggester: { - [recordId: string]: { - numFound: number; - suggestions: SearchAutoSuggestionResultItem[]; - }; - }; - default: { - [recordId: string]: { - numFound: number; - suggestions: SearchAutoSuggestionResultItem[]; - }; - }; - }; -}; - -export const AUTOSUGGEST_TERM_DELIMITER = '|'; diff --git a/src/utils/localSearch.ts b/src/utils/localSearch.ts new file mode 100644 index 000000000..fb0b5952a --- /dev/null +++ b/src/utils/localSearch.ts @@ -0,0 +1,109 @@ +import { search } from '@orama/orama'; +import { fuzzySearch } from './levenshtein-search'; + +type HighlightCategories = 'title' | 'description'; + +const matchCache: { + [key in HighlightCategories]: { + [termKey: string]: string; + }; +} = { + title: {}, + description: {}, +}; + +type ResultItem = { + title: string; + description: string; + bundleTitle: string; + pathname: string; +}; + +const resultCache: { + [term: string]: ResultItem[]; +} = {}; + +const START_MARK_LENGTH = 6; +const END_MARK_LENGTH = START_MARK_LENGTH + 1; +const OFFSET_BASE = START_MARK_LENGTH + END_MARK_LENGTH; + +function markText(text: string, start: number, end: number, offset: number) { + const markStart = OFFSET_BASE * offset + start + offset * 2 - 1; + const markEnd = OFFSET_BASE * offset + end + offset * 2; + return `${text.substring(0, markStart)}${text.substring(markStart, markEnd)}${text.substring(markEnd)}`; +} + +function highlightText(term: string, text: string, category: HighlightCategories) { + const key = `${term}-${text}`; + // check cache + if (matchCache[category]?.[key]) { + return matchCache[category][key]; + } + let internalText = text; + // generate fuzzy matches + const res = fuzzySearch(term, internalText, 2); + const marks = [...res].sort((a, b) => a.start! - b.start!); + // merge overlapping marks into smaller sets + // example: start: 1, end: 5, start: 3, end: 7 => start: 1, end: 7 + const merged = marks.reduce<{ start: number; end: number }[]>((acc, { start, end }) => { + if (!start || !end) return acc; + const bounded = acc.findIndex((o) => { + return (o.start >= start && o.start <= end) || (start >= o.start && start <= o.end); + }); + if (bounded >= 0) { + acc[bounded] = { start: Math.min(start, acc[bounded].start), end: Math.max(end, acc[bounded].end) }; + } else { + acc.push({ start, end }); + } + return acc; + }, []); + // mark text from reduced match set + merged.forEach(({ start, end }, index) => { + internalText = markText(internalText, start!, end, index); + }); + + // cache result + matchCache[category][key] = internalText; + return internalText; +} + +export const localQuery = async (db: any, term: string) => { + try { + let results: ResultItem[] | undefined = resultCache[term]; + if (results) { + return results; + } + + const r = await search(db, { + term, + threshold: 0.5, + tolerance: 1.5, + properties: ['title', 'description', 'altTitle'], + limit: 10, + boost: { + title: 10, + altTitle: 5, + description: 3, + }, + }); + + results = r.hits.map(({ document: { title, description, bundleTitle, pathname } }) => { + let matchedTitle = title; + let matchedDescription = description; + matchedTitle = highlightText(term, matchedTitle, 'title'); + matchedDescription = highlightText(term, matchedDescription, 'description'); + + return { + title: matchedTitle, + description: matchedDescription, + bundleTitle, + pathname, + }; + }); + resultCache[term] = results; + return results; + } catch (error) { + console.log(error); + return []; + } +};