diff --git a/src/Hooks/useGenerateSearchEntries.ts b/src/Hooks/useGenerateSearchEntries.ts index 9a08e18..95c38f5 100644 --- a/src/Hooks/useGenerateSearchEntries.ts +++ b/src/Hooks/useGenerateSearchEntries.ts @@ -1,21 +1,16 @@ import { useEffect, useState } from 'react'; import { ISpecification } from 'spock-react-types'; -import { - IMinimizedSummaryEntry, - ISearchEntry, -} from 'spock-react/components/search-types'; +import { IMinimizedSummaryEntry } from 'spock-react/components/search-types'; import { IGenerateSearchEntries } from 'spock-react/hooks-types'; -import { cleanedSearchData } from '../components/Search/generateSearchEntries'; - export const useGenerateSearchEntries = ( props: IGenerateSearchEntries -): ISearchEntry[] | null => { +): IMinimizedSummaryEntry[] | null => { const { summary } = props; - const [searchEntries, setSearchEntries] = useState( - null - ); + const [searchEntries, setSearchEntries] = useState< + IMinimizedSummaryEntry[] | null + >(null); useEffect(() => { const minimizedSummary = summary?.specifications.map( @@ -27,8 +22,7 @@ export const useGenerateSearchEntries = ( }) ); - minimizedSummary !== undefined && - setSearchEntries(cleanedSearchData(minimizedSummary)); + minimizedSummary !== undefined && setSearchEntries(minimizedSummary); }, [summary]); return searchEntries; diff --git a/src/components/Search/Search.tsx b/src/components/Search/Search.tsx index 39367e5..3de420c 100644 --- a/src/components/Search/Search.tsx +++ b/src/components/Search/Search.tsx @@ -2,7 +2,7 @@ import './styles.css'; import { useState } from 'react'; import { Dialog, DialogBackdrop, Separator, useDialogState } from 'reakit'; -import { ISearch, ISearchHit } from 'spock-react/components/search-types'; +import { IScore, ISearch } from 'spock-react/components/search-types'; import { SearchButton } from './SearchButton'; import { SearchFooter } from './SearchFooter'; @@ -14,7 +14,7 @@ export const Search = (props: ISearch): JSX.Element => { const dialog = useDialogState(); - const [searchHits, setSearchHits] = useState(null); + const [searchHits, setSearchHits] = useState(null); const [searchInput, setSearchInput] = useState(''); return ( diff --git a/src/components/Search/SearchHits/SearchCard/SearchCard.tsx b/src/components/Search/SearchHits/SearchCard/SearchCard.tsx index 0190841..e6c7a9f 100644 --- a/src/components/Search/SearchHits/SearchCard/SearchCard.tsx +++ b/src/components/Search/SearchHits/SearchCard/SearchCard.tsx @@ -13,7 +13,7 @@ export const SearchCard = (props: ISearchCard): JSX.Element => { return ( diff --git a/src/components/Search/SearchHits/SearchCard/types.d.ts b/src/components/Search/SearchHits/SearchCard/types.d.ts index 6c2d59c..03f25a1 100644 --- a/src/components/Search/SearchHits/SearchCard/types.d.ts +++ b/src/components/Search/SearchHits/SearchCard/types.d.ts @@ -1,10 +1,10 @@ declare module 'spock-react/components/search-card-types' { import { IExecutedFeatures, ISpecification } from 'spock-react-types'; - import { ISearchHit } from 'spock-react/components/search-types'; + import { IScore } from 'spock-react/components/search-types'; interface ISearchCard { onClick: (e?: any) => void; - hit: ISearchHit; + hit: IScore; spec?: ISpecification; feature?: IExecutedFeatures; } diff --git a/src/components/Search/SearchHits/SearchHits.tsx b/src/components/Search/SearchHits/SearchHits.tsx index dedf054..547fae4 100644 --- a/src/components/Search/SearchHits/SearchHits.tsx +++ b/src/components/Search/SearchHits/SearchHits.tsx @@ -20,11 +20,11 @@ export const SearchHits = (props: ISearchHits): JSX.Element => { {searchHits !== null ? ( searchHits.map((hit) => { const spec = summary.specifications.find( - (spec) => spec.className === hit.key + (spec) => spec.className === hit.id ); const feature = summary.specifications.map((spec) => spec.executedFeatures.find( - (feature) => feature.id === hit.key + (feature) => feature.id === hit.id ) )[0]; diff --git a/src/components/Search/SearchHits/types.d.ts b/src/components/Search/SearchHits/types.d.ts index 3c63edf..8ef1469 100644 --- a/src/components/Search/SearchHits/types.d.ts +++ b/src/components/Search/SearchHits/types.d.ts @@ -1,11 +1,11 @@ declare module 'spock-react/components/search-hits-types' { import { DialogStateReturn } from 'reakit/ts'; import { ISummary } from 'spock-react-types'; - import { ISearchHit } from 'spock-react/components/search-types'; + import { IScore } from 'spock-react/components/search-types'; interface ISearchHits { - searchHits: ISearchHit[] | null; - setSearchHits: (searchHits: ISearchHit[] | null) => void; + searchHits: IScore[] | null; + setSearchHits: (searchHits: IScore[] | null) => void; summary: ISummary; setSearchInput: (input: string) => void; dialog: DialogStateReturn; diff --git a/src/components/Search/SearchInput/SearchInput.tsx b/src/components/Search/SearchInput/SearchInput.tsx index 0faa442..deb452f 100644 --- a/src/components/Search/SearchInput/SearchInput.tsx +++ b/src/components/Search/SearchInput/SearchInput.tsx @@ -2,13 +2,12 @@ import './styles.css'; import { Input } from 'reakit'; import { ISearchInput } from 'spock-react/components/search-input-types'; -import { ISearchHit } from 'spock-react/components/search-types'; import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { useGenerateSearchEntries } from '../../../Hooks/useGenerateSearchEntries'; -import { getSearchScore } from '../getSearchScore'; +import { getSearchScoreV2TS } from '../getSearchScore'; export const SearchInput = (props: ISearchInput): JSX.Element => { const { summary, setSearchHits, setSearchInput, searchInput } = props; @@ -21,23 +20,8 @@ export const SearchInput = (props: ISearchInput): JSX.Element => { } else if (searchInput === '') { setSearchHits(null); } else { - const toLowerSearchInput = searchInput.trim().toLowerCase(); - - const scoreEntries: ISearchHit[] = searchEntries.map((entry) => { - const score = getSearchScore( - toLowerSearchInput, - entry.keywords - ); - return { - key: entry.key, - score, - }; - }); - - const hits = scoreEntries.filter((entry) => entry.score > 0); - hits.length > 0 - ? setSearchHits(hits.sort((a, b) => b.score - a.score)) - : setSearchHits(null); + const hits = getSearchScoreV2TS(searchInput, searchEntries); + hits.length > 0 ? setSearchHits(hits) : setSearchHits(null); } }; diff --git a/src/components/Search/SearchInput/types.d.ts b/src/components/Search/SearchInput/types.d.ts index b0598c3..a0aa3d0 100644 --- a/src/components/Search/SearchInput/types.d.ts +++ b/src/components/Search/SearchInput/types.d.ts @@ -1,10 +1,10 @@ declare module 'spock-react/components/search-input-types' { import { ISummary } from 'spock-react-types'; - import { ISearchHit } from 'spock-react/components/search-types'; + import { IScore } from 'spock-react/components/search-types'; interface ISearchInput { summary: ISummary; - setSearchHits: (searchHits: ISearchHit[] | null) => void; + setSearchHits: (searchHits: IScore[] | null) => void; setSearchInput: (input: string) => void; searchInput: string; } diff --git a/src/components/Search/generateSearchEntries.ts b/src/components/Search/generateSearchEntries.ts deleted file mode 100644 index 7fef9a2..0000000 --- a/src/components/Search/generateSearchEntries.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - IMinimizedSummaryEntry, - ISearchEntry, -} from 'spock-react/components/search-types'; - -const fillWords = [ - 'the', - 'a', - 'of', - 'and', - 'or', - 'in', - 'on', - 'at', - 'to', - 'with', - 'by', - 'as', - 'from', - 'is', - 'of', - 'and', - 'in', - 'on', - 'at', - 'with', - 'by', - 'for', - 'an', - 'be', - 'can', - 'will', - 'there', - 'this', - 'so', - 'use', -]; - -const replaceCharSet = ['.', '`', ',', '"', '?', '_']; - -const re = new RegExp(`[${replaceCharSet.join('')}]`, 'gi'); - -const searchWords = (sentence: string) => - sentence - .toLowerCase() - .trim() - .replace(re, ' ') - .replace(/\s\s/gi, ' ') - .split(' ') - .filter((word) => !fillWords.includes(word) && word?.length > 0) - .sort(); - -export const cleanedSearchData = ( - data: IMinimizedSummaryEntry[] -): ISearchEntry[] => { - const searchEntries: ISearchEntry[] = []; - data.forEach((entry) => { - const classNameArr = searchWords(entry.className); - const narrativeArr = searchWords(entry.narrative); - const titleArr = searchWords(entry.title); - - searchEntries.push({ - key: entry.className, - href: undefined, - keywords: [ - ...new Set(classNameArr.concat(narrativeArr).concat(titleArr)), - ], - }); - - entry.features.forEach((feature): void => { - searchEntries.push({ - key: entry.className, - href: feature.id, - keywords: [...new Set(searchWords(feature.id))], - }); - }); - }); - - return searchEntries; -}; diff --git a/src/components/Search/getSearchScore.ts b/src/components/Search/getSearchScore.ts index eab6f64..34c2fc8 100644 --- a/src/components/Search/getSearchScore.ts +++ b/src/components/Search/getSearchScore.ts @@ -1,18 +1,194 @@ -export const getSearchScore = ( +import { + IFeatureScore, + IMinimizedSummaryEntry, + IScore, +} from 'spock-react/components/search-types'; + +export const getSearchScoreV2TS = ( searchInput: string, - keywords: string[] -): number => { - let score = 0; + specifications: IMinimizedSummaryEntry[] +) => { + const scores: IScore[] = []; - const searchInputs = searchInput - .split(' ') - .filter((word) => word?.length > 0); + const searchTerm = normalizeString(searchInput); + + specifications.forEach((specification) => { + const normalizedClassName = normalizeString(specification.className); + const normalizedTitle = normalizeString(specification.title); + const normalizedNarrative = normalizeString(specification.narrative); + let score = 0.0; + const featureScores: IFeatureScore[] = []; + + score += searchScore( + searchTerm, + normalizedClassName.replace('.', ' ').replace('_', ' ') + ); + score += searchScore(searchTerm, normalizedTitle); + score += searchScore(searchTerm, normalizedNarrative); + + specification.features.forEach((feature) => { + const normalizedFeature = normalizeString(feature.id); + + const featureScore = featureSearchScore( + searchTerm, + normalizedFeature + ); + featureScores.push({ + id: feature.id, + score: featureScore, + }); + + score += featureScore; + }); + + const sortedFeatureScores = featureScores.sort(sortScore); + scores.push({ + id: specification.className, + score, + featureScores: sortedFeatureScores, + }); + }); + + const sortedScores = scores.sort(sortScore); + const chosen = getTop25(sortedScores); + + return chosen; +}; + +const getTop25 = (sortedScores: IScore[]) => sortedScores.slice(0, 25); + +const sortScore = ( + scoreA: IScore | IFeatureScore, + scoreB: IScore | IFeatureScore +) => scoreB.score - scoreA.score; + +const normalizeString = (term: string) => { + return removeFillWords(term.toLowerCase().trim()); +}; + +const getSearchWordsScore = (searchWords: string[], searchTerm: string) => { + let searchWordsScore = 0; + searchWords.forEach((word) => { + if (word.includes(searchTerm)) { + searchWordsScore += 0.25; + } + }); + + return searchWordsScore; +}; - searchInputs.forEach((word) => { - if (keywords.includes(word)) { - score += keywords.indexOf(word); +const getWordsScore = (words: string[], searchTerm: string) => { + let wordScore = 0; + words.forEach((word) => { + if (word.includes(searchTerm)) { + wordScore += 1; } }); + return wordScore; +}; + +const getWords = (sentence: string) => + sentence.split(' ').filter((word) => word.length > 0); + +const featureSearchScore = (searchTerm: string, sentence: string) => { + let score = 0; + + if (searchTerm.length === 0) { + return 0.0; + } + + if (sentence.length === 0) { + return 0.0; + } + + const words = getWords(sentence); + const searchWords = getWords(searchTerm); + + const searchWordsScore = getSearchWordsScore(searchWords, searchTerm); + if (searchWordsScore > 0) { + score += searchWordsScore / searchWords.length; + } + + const wordScore = getWordsScore(words, searchTerm); + if (wordScore === 0) { + return score; + } + score += wordScore / words.length; + + return score; +}; + +const searchScore = (searchTerm: string, sentence: string) => { + let score = 0; + + if (searchTerm.length === 0) { + return 0.0; + } + + if (sentence.length === 0) { + return 0.0; + } + + if (sentence.includes(searchTerm)) { + score += 1.0; + } + + const words = getWords(sentence); + const searchWords = getWords(searchTerm); + + const searchWordsScore = getSearchWordsScore(searchWords, searchTerm); + if (searchWordsScore > 0) { + score += searchWordsScore / searchWords.length; + } + + const wordScore = getWordsScore(words, searchTerm); + if (wordScore === 0) { + return score; + } + score += wordScore / words.length; + return score; }; + +const removeFillWords = (sentence: string) => + sentence + .replace(regex, ' ') + .replace(/\s\s/gi, ' ') + .split(' ') + .filter( + (word) => + !fillWords.includes(word) && word?.length > 0 && word !== ' ' + ) + .join(' ') + .trim(); + +const fillWords = [ + 'the', + 'a', + 'an', + 'of', + 'and', + 'or', + 'in', + 'on', + 'at', + 'to', + 'with', + 'by', + 'as', + 'from', + 'is', + 'of', + 'and', + 'in', + 'on', + 'at', + 'with', + 'by', + 'for', +]; + +const replaceCharSet = ['.', '`', ',', '"', '?', '_']; + +const regex = new RegExp(`[${replaceCharSet.join('')}]`, 'gi'); diff --git a/src/components/Search/types.d.ts b/src/components/Search/types.d.ts index 9e372f5..100e3bb 100644 --- a/src/components/Search/types.d.ts +++ b/src/components/Search/types.d.ts @@ -1,11 +1,6 @@ declare module 'spock-react/components/search-types' { import { ISummary } from 'spock-react-types'; - export interface ISearchHit { - score: number; - key: string; - } - interface ISearch { summary: ISummary; } @@ -17,9 +12,14 @@ declare module 'spock-react/components/search-types' { features: Array<{ id: string }>; } - export interface ISearchEntry { - key: string; - href?: string; - keywords: string[]; + interface IScore { + id: string; + score: number; + featureScores: IFeatureScore[]; + } + + interface IFeatureScore { + id: string; + score: number; } }