From 38ba1601e5614d102827c1536f3a2d51907fd1e4 Mon Sep 17 00:00:00 2001 From: saihaj <44710980+saihaj@users.noreply.github.com> Date: Thu, 16 Jul 2020 17:57:27 -0500 Subject: [PATCH 01/10] refactor(frontend/controller): split up the search component Split search component so that results are their own component --- app/frontend/src/Controller/Search.js | 408 ------------------ app/frontend/src/Controller/Search/Results.js | 233 ++++++++++ .../{Search.css => Search/index.css} | 0 app/frontend/src/Controller/Search/index.js | 227 ++++++++++ 4 files changed, 460 insertions(+), 408 deletions(-) delete mode 100644 app/frontend/src/Controller/Search.js create mode 100644 app/frontend/src/Controller/Search/Results.js rename app/frontend/src/Controller/{Search.css => Search/index.css} (100%) create mode 100644 app/frontend/src/Controller/Search/index.js diff --git a/app/frontend/src/Controller/Search.js b/app/frontend/src/Controller/Search.js deleted file mode 100644 index 6c753b0e..00000000 --- a/app/frontend/src/Controller/Search.js +++ /dev/null @@ -1,408 +0,0 @@ -import React, { useRef, useState, useEffect, useCallback, useContext } from 'react' -import { func, string, oneOfType, number, instanceOf, shape } from 'prop-types' -import { useLocation, useHistory } from 'react-router-dom' -import classNames from 'classnames' - -import { - Input, - InputAdornment, - List, - ListItem, - IconButton, -} from '@material-ui/core' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faTimes } from '@fortawesome/free-solid-svg-icons' - -import { stringify } from 'querystring' -import { firstLetters, stripVishraams, stripAccents, toUnicode, toAscii } from 'gurmukhi-utils' - -import { - SEARCH_TYPES, - SEARCH_CHARS, - LANGUAGE_NAMES, - SEARCH_ANCHORS, - MIN_SEARCH_CHARS, - SOURCE_ABBREVIATIONS, -} from '../lib/consts' -import { - getUrlState, - getTranslation, - getTransliteration, - customiseLine, -} from '../lib/utils' -import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../lib/contexts' -import controller from '../lib/controller' - -import { withNavigationHotkeys } from '../shared/NavigationHotkeys' - -import './Search.css' - -// Generate the regex for capturing anchor chars, optionally -const searchRegex = new RegExp( `^([${Object.keys( SEARCH_ANCHORS ).map( anchor => `\\${anchor}` ).join( '' )}])?(.*)` ) - -const getSearchParams = searchQuery => { - // Extract anchors and search query - const [ , anchor, query ] = searchQuery.match( searchRegex ) - - const inputValue = query - - // Get search type from anchor char, if any - const type = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter - - const value = type === SEARCH_TYPES.firstLetter - ? inputValue.slice().replace( new RegExp( SEARCH_CHARS.wildcard, 'g' ), '_' ) - : inputValue - - return { anchor, value, type } -} - -const highlightFullWordMatches = ( line, query ) => { - const sanitisedQuery = query.trim() - - const foundPosition = line.search( sanitisedQuery ) - const matchStartPosition = line.lastIndexOf( ' ', foundPosition ) - - const wordEndPosition = line.indexOf( ' ', foundPosition + sanitisedQuery.length ) - // If the match finishes in the last word, no space will be deteced, and wordEndPosition - // will be -1. In this case, we want to end at the last position in the line. - const matchEndPosition = wordEndPosition === -1 ? line.length - 1 : wordEndPosition - - return [ - line.substring( 0, matchStartPosition ), - line.substring( matchStartPosition, matchEndPosition ), - line.substring( matchEndPosition ), - ] -} - -const highlightFirstLetterMatches = ( line, query ) => { - const baseLine = stripVishraams( line ) - - const letters = toAscii( firstLetters( stripAccents( toUnicode( baseLine ) ) ) ) - const words = baseLine.split( ' ' ) - - const startPosition = letters.search( stripAccents( query ) ) - const endPosition = startPosition + query.length - - return [ - `${words.slice( 0, startPosition ).join( ' ' )} `, - `${words.slice( startPosition, endPosition ).join( ' ' )} `, - `${words.slice( endPosition ).join( ' ' )} `, - ] -} - -/** - * Separates the line into words before the first match, the first match, and after the match. - * @param value The full line. - * @param input The string inputted by the user. - * @param mode The type of search being performed, either first word or full word. - * @return An array of [ beforeMatch, match, afterMatch ], - * with `match` being the highlighted section.`. - */ -const highlightMatches = gurmukhi => ( value, input, mode ) => { - if ( !value ) return [ '', '', '' ] - - // Account for wildcard characters - const sanitizedInput = input.replace( new RegExp( '_', 'g' ), '.' ) - - return mode === SEARCH_TYPES.fullWord - ? highlightFullWordMatches( gurmukhi, sanitizedInput ) - : highlightFirstLetterMatches( value, sanitizedInput ) -} - -/** - * Search Component. - * Converts ASCII to unicode on input. - * Displays results. - */ -const Search = ( { updateFocus, register, focused } ) => { - const { local: { - sources, - search: { - showResultCitations, - resultTransliterationLanguage, - resultTranslationLanguage, - lineEnding, - }, - } = {} } = useContext( SettingsContext ) - - // Set the initial search query from URL - const history = useHistory() - const { search } = useLocation() - const { query = '' } = getUrlState( search ) - - const [ searchedValue, setSearchedValue ] = useState( '' ) - - const { anchor: initialAnchor, value: initialInputValue } = getSearchParams( query ) - const inputValue = useRef( initialInputValue ) - const [ anchor, setAnchor ] = useState( initialAnchor ) - - const [ results, setResults ] = useState( [] ) - - const [ isInputFocused, setInputFocused ] = useState( false ) - - const inputRef = useRef( null ) - - /** - * Set the received results and update the searched vale. - * @param {Object[]} results An array of the returned results. - */ - const onResults = useCallback( results => { - setSearchedValue( inputValue.current ) - setResults( results ) - - updateFocus( 0 ) - }, [ updateFocus ] ) - /** - * Run on change of value in the search box. - * Converts ascii to unicode if need be. - * Sends the search through to the controller. - * @param {string} value The new value of the search box. - */ - const onChange = useCallback( ( { target: { value } } ) => { - const { anchor, type: searchType, value: searchValue } = getSearchParams( value ) - - // Search if enough letters - const doSearch = searchValue.length >= MIN_SEARCH_CHARS - - if ( doSearch ) { - controller.search( searchValue, searchType, { - translations: !!resultTranslationLanguage, - transliterations: !!resultTransliterationLanguage, - citations: !!showResultCitations, - } ) - } else setResults( [] ) - - inputValue.current = searchValue - setAnchor( anchor ) - - // Update URL with search - history.push( { search: `?${stringify( { - ...getUrlState( search ), - query: value, - } )}` } ) - }, [ - history, - search, - resultTranslationLanguage, - resultTransliterationLanguage, - showResultCitations, - ] ) - - const writers = useContext( WritersContext ) - const recommendedSources = useContext( RecommendedSourcesContext ) - - /** - * Renders a single result, highlighting the match. - * @param {string} gurmukhi The shabad line to display. - * @param {int} typeId The type id of line. - * @param {string} lineId The id of the line. - * @param {string} shabadId The id of the shabad. - * @param {Component} ref The ref to the component. - * @param {int} sourceId The id of source. - * @param {Object} shabad The object containng section information and other metadata. - * @param {int} sourcePage The page number of shabad in source. - * @param {string} translations The translations of shabad line to display. - * @param {string} transliterations The transliterations of shabad line to display. - */ - const Result = ( { - gurmukhi, - typeId, - id: lineId, - shabadId, - ref, - focused, - sourceId, - shabad, - sourcePage, - translations, - transliterations, - } ) => { - const transliteration = resultTransliterationLanguage && transliterations && customiseLine( - getTransliteration( - { transliterations }, - resultTransliterationLanguage, - ), - { lineEnding, typeId }, - ) - - const translation = resultTranslationLanguage && translations && customiseLine( - getTranslation( { - line: { translations }, - shabad: { sourceId }, - recommendedSources, - sources, - languageId: resultTranslationLanguage, - } ), - { lineEnding, typeId }, - ) - - // Grab the search mode or assume it's first letter - const mode = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter - - // Separate the line into words before the match, the match, and after the match - const getMatches = highlightMatches( gurmukhi ) - - const [ beforeMatch, match, afterMatch ] = getMatches( - gurmukhi, - searchedValue, - mode, - ) - const [ translitBeforeMatch, translitMatch, translitAfterMatch ] = getMatches( - transliteration, - searchedValue, - mode, - ) - - // Send the shabad id and line id to the server on click - const onClick = () => controller.shabad( { shabadId, lineId } ) - - // Helper render functions for citation - const showCitation = showResultCitations && shabad && shabad.section - const getEnglish = ( { nameEnglish } ) => nameEnglish - const getWriterName = () => getEnglish( writers[ shabad.writerId ] ) - const getPageName = () => recommendedSources[ shabad.sourceId ].pageNameEnglish - - return ( - -
- - {beforeMatch ? {beforeMatch} : null} - {match ? {match} : null} - {afterMatch ? {afterMatch} : null} - - - - - {translation && ( -
- {translation} -
- )} - - {transliteration && ( -
- {translitBeforeMatch ? {translitBeforeMatch} : null} - {translitMatch ? {translitMatch} : null} - {translitAfterMatch ? {translitAfterMatch} : null} -
- )} - -
- - {showCitation && ( - - {[ - getWriterName(), - SOURCE_ABBREVIATIONS[ sourceId ], - `${getPageName()} ${sourcePage}`, - ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} - - )} - -
-
- ) - } - - Result.propTypes = { - gurmukhi: string.isRequired, - id: string.isRequired, - typeId: string.isRequired, - shabadId: string.isRequired, - ref: instanceOf( Result ).isRequired, - sourceId: number.isRequired, - shabad: shape( { } ).isRequired, - sourcePage: number.isRequired, - translations: string.isRequired, - transliterations: string.isRequired, - } - - const filterInputKeys = event => { - const ignoreKeys = [ 'ArrowUp', 'ArrowDown' ] - - if ( ignoreKeys.includes( event.key ) ) event.preventDefault() - } - - const refocus = ( { target } ) => { - setInputFocused( false ) - target.focus() - } - - const highlightSearch = () => inputRef.current.select() - - useEffect( () => { - controller.on( 'results', onResults ) - return () => controller.off( 'results', onResults ) - }, [ onResults ] ) - - useEffect( () => { - if ( inputValue.current ) onChange( { target: { value: `${anchor || ''}${inputValue.current}` } } ) - }, [ - onChange, - anchor, - resultTransliterationLanguage, - resultTranslationLanguage, - showResultCitations, - ] ) - - useEffect( () => { highlightSearch() }, [] ) - - return ( -
- setInputFocused( true )} - onChange={onChange} - value={`${anchor || ''}${inputValue.current}`} - placeholder="Koj" - disableUnderline - autoFocus - endAdornment={inputValue.current && ( - - onChange( { target: { value: '' } } )}> - - - - )} - inputProps={{ - spellCheck: false, - autoCapitalize: 'off', - autoCorrect: 'off', - autoComplete: 'off', - }} - /> - - {results - ? results - .map( ( props, i ) => Result( { - ...props, - ref: c => register( i, c ), - focused: focused === i, - } ) ) - : ''} - -
- ) -} - -Search.propTypes = { - focused: oneOfType( [ string, number ] ), - register: func.isRequired, - updateFocus: func.isRequired, -} - -Search.defaultProps = { - focused: undefined, -} - -export default withNavigationHotkeys( { - keymap: { - next: [ 'down', 'tab' ], - previous: [ 'up', 'shift+tab' ], - first: null, - last: null, - }, -} )( Search ) diff --git a/app/frontend/src/Controller/Search/Results.js b/app/frontend/src/Controller/Search/Results.js new file mode 100644 index 00000000..9b674281 --- /dev/null +++ b/app/frontend/src/Controller/Search/Results.js @@ -0,0 +1,233 @@ +import React from 'react' +import classNames from 'classnames' +import { ListItem } from '@material-ui/core' +import { string, oneOfType, number, instanceOf, shape, bool } from 'prop-types' +import { firstLetters, stripVishraams, stripAccents, toUnicode, toAscii } from 'gurmukhi-utils' + +import controller from '../../lib/controller' +import { + SEARCH_TYPES, + LANGUAGE_NAMES, + SEARCH_ANCHORS, + SOURCE_ABBREVIATIONS, +} from '../../lib/consts' +import { + getTranslation, + getTransliteration, + customiseLine, +} from '../../lib/utils' + +const highlightFullWordMatches = ( line, query ) => { + const sanitisedQuery = query.trim() + + const foundPosition = line.search( sanitisedQuery ) + const matchStartPosition = line.lastIndexOf( ' ', foundPosition ) + + const wordEndPosition = line.indexOf( ' ', foundPosition + sanitisedQuery.length ) + // If the match finishes in the last word, no space will be deteced, and wordEndPosition + // will be -1. In this case, we want to end at the last position in the line. + const matchEndPosition = wordEndPosition === -1 ? line.length - 1 : wordEndPosition + + return [ + line.substring( 0, matchStartPosition ), + line.substring( matchStartPosition, matchEndPosition ), + line.substring( matchEndPosition ), + ] +} + +const highlightFirstLetterMatches = ( line, query ) => { + const baseLine = stripVishraams( line ) + + const letters = toAscii( firstLetters( stripAccents( toUnicode( baseLine ) ) ) ) + const words = baseLine.split( ' ' ) + + const startPosition = letters.search( stripAccents( query ) ) + const endPosition = startPosition + query.length + + return [ + `${words.slice( 0, startPosition ).join( ' ' )} `, + `${words.slice( startPosition, endPosition ).join( ' ' )} `, + `${words.slice( endPosition ).join( ' ' )} `, + ] +} + +/** + * Separates the line into words before the first match, the first match, and after the match. + * @param value The full line. + * @param input The string inputted by the user. + * @param mode The type of search being performed, either first word or full word. + * @return An array of [ beforeMatch, match, afterMatch ], + * with `match` being the highlighted section.`. + */ +const highlightMatches = gurmukhi => ( value, input, mode ) => { + if ( !value ) return [ '', '', '' ] + + // Account for wildcard characters + const sanitizedInput = input.replace( new RegExp( '_', 'g' ), '.' ) + + return mode === SEARCH_TYPES.fullWord + ? highlightFullWordMatches( gurmukhi, sanitizedInput ) + : highlightFirstLetterMatches( value, sanitizedInput ) +} + +/** + * Renders a single result, highlighting the match. + * @param {string} gurmukhi The shabad line to display. + * @param {int} typeId The type id of line. + * @param {string} lineId The id of the line. + * @param {string} shabadId The id of the shabad. + * @param {Component} ref The ref to the component. + * @param {string|int} focused + * @param {int} sourceId The id of source. + * @param {Object} shabad The object containng section information and other metadata. + * @param {int} sourcePage The page number of shabad in source. + * @param {string} translations The translations of shabad line to display. + * @param {string} transliterations The transliterations of shabad line to display. + * @param {string} searchedValue The input to search. + * @param {string} anchor Anchor for search mode. + * @param {Object} writers From the SettingsContext. + * @param {Object} sources From the ContentContext. + * @param {Object} recommendedSources From the RecommendedSourcesContext. + * @param {int|bool} resultTransliterationLanguage Language code for translits (SettingsContext). + * @param {int|bool} resultTranslationLanguage Language code for translations (SettingsContext). + * @param {bool} showResultCitations To show citations or not (SettingsContext). + * @param {bool} lineEnding To strip line endings or not (SettingsContext). + */ +const Result = ( { + gurmukhi, + typeId, + id: lineId, + shabadId, + ref, + focused, + sourceId, + shabad, + sourcePage, + translations, + transliterations, + searchedValue, + anchor, + writers, + sources, + recommendedSources, + resultTransliterationLanguage, + resultTranslationLanguage, + showResultCitations, + lineEnding, +} ) => { + const transliteration = resultTransliterationLanguage && transliterations && customiseLine( + getTransliteration( + { transliterations }, + resultTransliterationLanguage, + ), + { lineEnding, typeId }, + ) + + const translation = resultTranslationLanguage && translations && customiseLine( + getTranslation( { + line: { translations }, + shabad: { sourceId }, + recommendedSources, + sources, + languageId: resultTranslationLanguage, + } ), + { lineEnding, typeId }, + ) + + // Grab the search mode or assume it's first letter + const mode = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter + + // Separate the line into words before the match, the match, and after the match + const getMatches = highlightMatches( gurmukhi ) + + const [ beforeMatch, match, afterMatch ] = getMatches( + gurmukhi, + searchedValue, + mode, + ) + const [ translitBeforeMatch, translitMatch, translitAfterMatch ] = getMatches( + transliteration, + searchedValue, + mode, + ) + + // Send the shabad id and line id to the server on click + const onClick = () => controller.shabad( { shabadId, lineId } ) + + // Helper render functions for citation + const showCitation = showResultCitations && shabad && shabad.section + const getEnglish = ( { nameEnglish } ) => nameEnglish + const getWriterName = () => getEnglish( writers[ shabad.writerId ] ) + const getPageName = () => recommendedSources[ shabad.sourceId ].pageNameEnglish + + return ( + +
+ + + {beforeMatch ? {beforeMatch} : null} + {match ? {match} : null} + {afterMatch ? {afterMatch} : null} + + + + + {translation && ( +
+ {translation} +
+ )} + + {transliteration && ( +
+ {translitBeforeMatch ? {translitBeforeMatch} : null} + {translitMatch ? {translitMatch} : null} + {translitAfterMatch ? {translitAfterMatch} : null} +
+ )} + +
+ + {showCitation && ( + + {[ + getWriterName(), + SOURCE_ABBREVIATIONS[ sourceId ], + `${getPageName()} ${sourcePage}`, + ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} + + )} + +
+
+ ) +} + +Result.propTypes = { + gurmukhi: string.isRequired, + id: string.isRequired, + typeId: string.isRequired, + shabadId: string.isRequired, + ref: instanceOf( Result ).isRequired, + sourceId: number.isRequired, + shabad: shape( { } ).isRequired, + sourcePage: number.isRequired, + translations: string.isRequired, + transliterations: string.isRequired, + focused: oneOfType( [ string, number ] ), + searchedValue: string.isRequired, + anchor: string.isRequired, + writers: shape( {} ).isRequired, + sources: shape( {} ).isRequired, + recommendedSources: shape( {} ).isRequired, + resultTransliterationLanguage: oneOfType( [ bool, number ] ).isRequired, + resultTranslationLanguage: oneOfType( [ bool, number ] ).isRequired, + showResultCitations: bool.isRequired, + lineEnding: bool.isRequired, +} + +Result.defaultProps = { + focused: undefined, +} + +export default Result diff --git a/app/frontend/src/Controller/Search.css b/app/frontend/src/Controller/Search/index.css similarity index 100% rename from app/frontend/src/Controller/Search.css rename to app/frontend/src/Controller/Search/index.css diff --git a/app/frontend/src/Controller/Search/index.js b/app/frontend/src/Controller/Search/index.js new file mode 100644 index 00000000..556a11f0 --- /dev/null +++ b/app/frontend/src/Controller/Search/index.js @@ -0,0 +1,227 @@ +import React, { useRef, useState, useEffect, useCallback, useContext } from 'react' +import classNames from 'classnames' +import { func, string, oneOfType, number } from 'prop-types' +import { useLocation, useHistory } from 'react-router-dom' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faTimes } from '@fortawesome/free-solid-svg-icons' +import { stringify } from 'querystring' +import { + Input, + InputAdornment, + List, + IconButton, +} from '@material-ui/core' + +import { getUrlState } from '../../lib/utils' +import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' +import controller from '../../lib/controller' +import { withNavigationHotkeys } from '../../shared/NavigationHotkeys' +import { + SEARCH_TYPES, + SEARCH_CHARS, + SEARCH_ANCHORS, + MIN_SEARCH_CHARS, +} from '../../lib/consts' + +import Result from './Results' +import './index.css' + +// Generate the regex for capturing anchor chars, optionally +const searchRegex = new RegExp( `^([${Object.keys( SEARCH_ANCHORS ).map( anchor => `\\${anchor}` ).join( '' )}])?(.*)` ) + +const getSearchParams = searchQuery => { + // Extract anchors and search query + const [ , anchor, query ] = searchQuery.match( searchRegex ) + + const inputValue = query + + // Get search type from anchor char, if any + const type = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter + + const value = type === SEARCH_TYPES.firstLetter + ? inputValue.slice().replace( new RegExp( SEARCH_CHARS.wildcard, 'g' ), '_' ) + : inputValue + + return { anchor, value, type } +} + +/** + * Search Component. + * Converts ASCII to unicode on input. + * Displays results. + */ +const Search = ( { updateFocus, register, focused } ) => { + const { local: { + sources, + search: { + showResultCitations, + resultTransliterationLanguage, + resultTranslationLanguage, + lineEnding, + }, + } = {} } = useContext( SettingsContext ) + + const writers = useContext( WritersContext ) + const recommendedSources = useContext( RecommendedSourcesContext ) + + // Set the initial search query from URL + const history = useHistory() + const { search } = useLocation() + const { query = '' } = getUrlState( search ) + + const [ searchedValue, setSearchedValue ] = useState( '' ) + + const { anchor: initialAnchor, value: initialInputValue } = getSearchParams( query ) + const inputValue = useRef( initialInputValue ) + const [ anchor, setAnchor ] = useState( initialAnchor ) + + const [ results, setResults ] = useState( [] ) + + const [ isInputFocused, setInputFocused ] = useState( false ) + + const inputRef = useRef( null ) + + /** + * Set the received results and update the searched vale. + * @param {Object[]} results An array of the returned results. + */ + const onResults = useCallback( results => { + setSearchedValue( inputValue.current ) + setResults( results ) + + updateFocus( 0 ) + }, [ updateFocus ] ) + /** + * Run on change of value in the search box. + * Converts ascii to unicode if need be. + * Sends the search through to the controller. + * @param {string} value The new value of the search box. + */ + const onChange = useCallback( ( { target: { value } } ) => { + const { anchor, type: searchType, value: searchValue } = getSearchParams( value ) + + // Search if enough letters + const doSearch = searchValue.length >= MIN_SEARCH_CHARS + + if ( doSearch ) { + controller.search( searchValue, searchType, { + translations: !!resultTranslationLanguage, + transliterations: !!resultTransliterationLanguage, + citations: !!showResultCitations, + } ) + } else setResults( [] ) + + inputValue.current = searchValue + setAnchor( anchor ) + + // Update URL with search + history.push( { search: `?${stringify( { + ...getUrlState( search ), + query: value, + } )}` } ) + }, [ + history, + search, + resultTranslationLanguage, + resultTransliterationLanguage, + showResultCitations, + ] ) + + const filterInputKeys = event => { + const ignoreKeys = [ 'ArrowUp', 'ArrowDown' ] + + if ( ignoreKeys.includes( event.key ) ) event.preventDefault() + } + + const refocus = ( { target } ) => { + setInputFocused( false ) + target.focus() + } + + const highlightSearch = () => inputRef.current.select() + + useEffect( () => { + controller.on( 'results', onResults ) + return () => controller.off( 'results', onResults ) + }, [ onResults ] ) + + useEffect( () => { + if ( inputValue.current ) onChange( { target: { value: `${anchor || ''}${inputValue.current}` } } ) + }, [ + onChange, + anchor, + resultTransliterationLanguage, + resultTranslationLanguage, + showResultCitations, + ] ) + + useEffect( () => { highlightSearch() }, [] ) + + return ( +
+ setInputFocused( true )} + onChange={onChange} + value={`${anchor || ''}${inputValue.current}`} + placeholder="Koj" + disableUnderline + autoFocus + endAdornment={inputValue.current && ( + + onChange( { target: { value: '' } } )}> + + + + )} + inputProps={{ + spellCheck: false, + autoCapitalize: 'off', + autoCorrect: 'off', + autoComplete: 'off', + }} + /> + + {results + ? results + .map( ( props, i ) => Result( { + ...props, + searchedValue, + anchor, + sources, + writers, + recommendedSources, + resultTransliterationLanguage, + resultTranslationLanguage, + showResultCitations, + lineEnding, + ref: c => register( i, c ), + focused: focused === i, + } ) ) + : ''} + +
+ ) +} + +Search.propTypes = { + focused: oneOfType( [ string, number ] ), + register: func.isRequired, + updateFocus: func.isRequired, +} + +Search.defaultProps = { + focused: undefined, +} + +export default withNavigationHotkeys( { + keymap: { + next: [ 'down', 'tab' ], + previous: [ 'up', 'shift+tab' ], + first: null, + last: null, + }, +} )( Search ) From 3156102c213e37bb6ebd80c88b269d40fad6db53 Mon Sep 17 00:00:00 2001 From: saihaj <44710980+saihaj@users.noreply.github.com> Date: Fri, 17 Jul 2020 10:35:10 -0500 Subject: [PATCH 02/10] refactor(frontend/controller): move down the results list mapping to result component --- app/frontend/src/Controller/Search/Results.js | 76 +++++++++++++++---- app/frontend/src/Controller/Search/index.js | 40 +++------- 2 files changed, 69 insertions(+), 47 deletions(-) diff --git a/app/frontend/src/Controller/Search/Results.js b/app/frontend/src/Controller/Search/Results.js index 9b674281..c66852f6 100644 --- a/app/frontend/src/Controller/Search/Results.js +++ b/app/frontend/src/Controller/Search/Results.js @@ -1,21 +1,14 @@ -import React from 'react' +import React, { useContext } from 'react' import classNames from 'classnames' -import { ListItem } from '@material-ui/core' -import { string, oneOfType, number, instanceOf, shape, bool } from 'prop-types' +import { ListItem, List } from '@material-ui/core' +import { string, oneOfType, number, instanceOf, shape, bool, arrayOf, func } from 'prop-types' import { firstLetters, stripVishraams, stripAccents, toUnicode, toAscii } from 'gurmukhi-utils' + import controller from '../../lib/controller' -import { - SEARCH_TYPES, - LANGUAGE_NAMES, - SEARCH_ANCHORS, - SOURCE_ABBREVIATIONS, -} from '../../lib/consts' -import { - getTranslation, - getTransliteration, - customiseLine, -} from '../../lib/utils' +import { getTranslation, getTransliteration, customiseLine } from '../../lib/utils' +import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' +import { SEARCH_TYPES, LANGUAGE_NAMES, SEARCH_ANCHORS, SOURCE_ABBREVIATIONS } from '../../lib/consts' const highlightFullWordMatches = ( line, query ) => { const sanitisedQuery = query.trim() @@ -93,7 +86,7 @@ const highlightMatches = gurmukhi => ( value, input, mode ) => { * @param {bool} showResultCitations To show citations or not (SettingsContext). * @param {bool} lineEnding To strip line endings or not (SettingsContext). */ -const Result = ( { +const ResultList = ( { gurmukhi, typeId, id: lineId, @@ -203,7 +196,58 @@ const Result = ( { ) } +const Result = ( { results, searchedValue, anchor, register, focused } ) => { + const { local: { + sources, + search: { + showResultCitations, + resultTransliterationLanguage, + resultTranslationLanguage, + lineEnding, + }, + } = {} } = useContext( SettingsContext ) + + const writers = useContext( WritersContext ) + const recommendedSources = useContext( RecommendedSourcesContext ) + + return ( + + {results + ? results.map( ( props, i ) => ResultList( { + ...props, + searchedValue, + anchor, + sources, + writers, + recommendedSources, + resultTransliterationLanguage, + resultTranslationLanguage, + showResultCitations, + lineEnding, + ref: c => register( i, c ), + focused: focused === i, + } ) ) + : ''} + + ) +} + Result.propTypes = { + results: arrayOf( shape( {} ) ), + searchedValue: string, + anchor: string, + register: func.isRequired, + focused: oneOfType( [ string, number ] ), +} + +Result.defaultProps = { + results: [], + searchedValue: '', + anchor: '', + focused: undefined, +} + +ResultList.propTypes = { gurmukhi: string.isRequired, id: string.isRequired, typeId: string.isRequired, @@ -226,7 +270,7 @@ Result.propTypes = { lineEnding: bool.isRequired, } -Result.defaultProps = { +ResultList.defaultProps = { focused: undefined, } diff --git a/app/frontend/src/Controller/Search/index.js b/app/frontend/src/Controller/Search/index.js index 556a11f0..80485a9f 100644 --- a/app/frontend/src/Controller/Search/index.js +++ b/app/frontend/src/Controller/Search/index.js @@ -5,15 +5,10 @@ import { useLocation, useHistory } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faTimes } from '@fortawesome/free-solid-svg-icons' import { stringify } from 'querystring' -import { - Input, - InputAdornment, - List, - IconButton, -} from '@material-ui/core' +import { Input, InputAdornment, IconButton } from '@material-ui/core' import { getUrlState } from '../../lib/utils' -import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' +import { SettingsContext } from '../../lib/contexts' import controller from '../../lib/controller' import { withNavigationHotkeys } from '../../shared/NavigationHotkeys' import { @@ -52,18 +47,13 @@ const getSearchParams = searchQuery => { */ const Search = ( { updateFocus, register, focused } ) => { const { local: { - sources, search: { showResultCitations, resultTransliterationLanguage, resultTranslationLanguage, - lineEnding, }, } = {} } = useContext( SettingsContext ) - const writers = useContext( WritersContext ) - const recommendedSources = useContext( RecommendedSourcesContext ) - // Set the initial search query from URL const history = useHistory() const { search } = useLocation() @@ -184,25 +174,13 @@ const Search = ( { updateFocus, register, focused } ) => { autoComplete: 'off', }} /> - - {results - ? results - .map( ( props, i ) => Result( { - ...props, - searchedValue, - anchor, - sources, - writers, - recommendedSources, - resultTransliterationLanguage, - resultTranslationLanguage, - showResultCitations, - lineEnding, - ref: c => register( i, c ), - focused: focused === i, - } ) ) - : ''} - + ) } From 3b03075e13313fbab3f33878537710cc6e042057 Mon Sep 17 00:00:00 2001 From: saihaj <44710980+saihaj@users.noreply.github.com> Date: Fri, 17 Jul 2020 10:55:58 -0500 Subject: [PATCH 03/10] refactor(frontend/controller): bring resultsList down to results scope this allows to use variables in scope rather than having to pass them down --- app/frontend/src/Controller/Search/Results.js | 298 ++++++++---------- 1 file changed, 127 insertions(+), 171 deletions(-) diff --git a/app/frontend/src/Controller/Search/Results.js b/app/frontend/src/Controller/Search/Results.js index c66852f6..cc9697c7 100644 --- a/app/frontend/src/Controller/Search/Results.js +++ b/app/frontend/src/Controller/Search/Results.js @@ -1,10 +1,9 @@ import React, { useContext } from 'react' import classNames from 'classnames' import { ListItem, List } from '@material-ui/core' -import { string, oneOfType, number, instanceOf, shape, bool, arrayOf, func } from 'prop-types' +import { string, oneOfType, number, instanceOf, shape, arrayOf, func } from 'prop-types' import { firstLetters, stripVishraams, stripAccents, toUnicode, toAscii } from 'gurmukhi-utils' - import controller from '../../lib/controller' import { getTranslation, getTransliteration, customiseLine } from '../../lib/utils' import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' @@ -63,167 +62,151 @@ const highlightMatches = gurmukhi => ( value, input, mode ) => { : highlightFirstLetterMatches( value, sanitizedInput ) } -/** +const Result = ( { results, searchedValue, anchor, register, focused } ) => { + const { local: { + sources, + search: { + showResultCitations, + resultTransliterationLanguage, + resultTranslationLanguage, + lineEnding, + }, + } = {} } = useContext( SettingsContext ) + + const writers = useContext( WritersContext ) + const recommendedSources = useContext( RecommendedSourcesContext ) + + /** * Renders a single result, highlighting the match. * @param {string} gurmukhi The shabad line to display. * @param {int} typeId The type id of line. * @param {string} lineId The id of the line. * @param {string} shabadId The id of the shabad. * @param {Component} ref The ref to the component. - * @param {string|int} focused * @param {int} sourceId The id of source. * @param {Object} shabad The object containng section information and other metadata. * @param {int} sourcePage The page number of shabad in source. * @param {string} translations The translations of shabad line to display. * @param {string} transliterations The transliterations of shabad line to display. - * @param {string} searchedValue The input to search. - * @param {string} anchor Anchor for search mode. - * @param {Object} writers From the SettingsContext. - * @param {Object} sources From the ContentContext. - * @param {Object} recommendedSources From the RecommendedSourcesContext. - * @param {int|bool} resultTransliterationLanguage Language code for translits (SettingsContext). - * @param {int|bool} resultTranslationLanguage Language code for translations (SettingsContext). - * @param {bool} showResultCitations To show citations or not (SettingsContext). - * @param {bool} lineEnding To strip line endings or not (SettingsContext). */ -const ResultList = ( { - gurmukhi, - typeId, - id: lineId, - shabadId, - ref, - focused, - sourceId, - shabad, - sourcePage, - translations, - transliterations, - searchedValue, - anchor, - writers, - sources, - recommendedSources, - resultTransliterationLanguage, - resultTranslationLanguage, - showResultCitations, - lineEnding, -} ) => { - const transliteration = resultTransliterationLanguage && transliterations && customiseLine( - getTransliteration( - { transliterations }, - resultTransliterationLanguage, - ), - { lineEnding, typeId }, - ) - - const translation = resultTranslationLanguage && translations && customiseLine( - getTranslation( { - line: { translations }, - shabad: { sourceId }, - recommendedSources, - sources, - languageId: resultTranslationLanguage, - } ), - { lineEnding, typeId }, - ) - - // Grab the search mode or assume it's first letter - const mode = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter - - // Separate the line into words before the match, the match, and after the match - const getMatches = highlightMatches( gurmukhi ) - - const [ beforeMatch, match, afterMatch ] = getMatches( + const ResultList = ( { gurmukhi, - searchedValue, - mode, - ) - const [ translitBeforeMatch, translitMatch, translitAfterMatch ] = getMatches( - transliteration, - searchedValue, - mode, - ) - - // Send the shabad id and line id to the server on click - const onClick = () => controller.shabad( { shabadId, lineId } ) - - // Helper render functions for citation - const showCitation = showResultCitations && shabad && shabad.section - const getEnglish = ( { nameEnglish } ) => nameEnglish - const getWriterName = () => getEnglish( writers[ shabad.writerId ] ) - const getPageName = () => recommendedSources[ shabad.sourceId ].pageNameEnglish - - return ( - -
- - - {beforeMatch ? {beforeMatch} : null} - {match ? {match} : null} - {afterMatch ? {afterMatch} : null} - - - - - {translation && ( -
- {translation} -
- )} - - {transliteration && ( -
- {translitBeforeMatch ? {translitBeforeMatch} : null} - {translitMatch ? {translitMatch} : null} - {translitAfterMatch ? {translitAfterMatch} : null} -
+ typeId, + id: lineId, + shabadId, + ref, + sourceId, + shabad, + sourcePage, + translations, + transliterations, + } ) => { + const transliteration = resultTransliterationLanguage && transliterations && customiseLine( + getTransliteration( + { transliterations }, + resultTransliterationLanguage, + ), + { lineEnding, typeId }, + ) + + const translation = resultTranslationLanguage && translations && customiseLine( + getTranslation( { + line: { translations }, + shabad: { sourceId }, + recommendedSources, + sources, + languageId: resultTranslationLanguage, + } ), + { lineEnding, typeId }, + ) + + // Grab the search mode or assume it's first letter + const mode = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter + + // Separate the line into words before the match, the match, and after the match + const getMatches = highlightMatches( gurmukhi ) + + const [ beforeMatch, match, afterMatch ] = getMatches( + gurmukhi, + searchedValue, + mode, + ) + const [ translitBeforeMatch, translitMatch, translitAfterMatch ] = getMatches( + transliteration, + searchedValue, + mode, + ) + + // Send the shabad id and line id to the server on click + const onClick = () => controller.shabad( { shabadId, lineId } ) + + // Helper render functions for citation + const showCitation = showResultCitations && shabad && shabad.section + const getEnglish = ( { nameEnglish } ) => nameEnglish + const getWriterName = () => getEnglish( writers[ shabad.writerId ] ) + const getPageName = () => recommendedSources[ shabad.sourceId ].pageNameEnglish + + return ( + +
+ + + {beforeMatch ? {beforeMatch} : null} + {match ? {match} : null} + {afterMatch ? {afterMatch} : null} + + + + + {translation && ( +
+ {translation} +
+ )} + + {transliteration && ( +
+ {translitBeforeMatch ? {translitBeforeMatch} : null} + {translitMatch ? {translitMatch} : null} + {translitAfterMatch ? {translitAfterMatch} : null} +
+ )} + +
+ + {showCitation && ( + + {[ + getWriterName(), + SOURCE_ABBREVIATIONS[ sourceId ], + `${getPageName()} ${sourcePage}`, + ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} + )} - - - {showCitation && ( - - {[ - getWriterName(), - SOURCE_ABBREVIATIONS[ sourceId ], - `${getPageName()} ${sourcePage}`, - ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} - - )} - -
-
- ) -} - -const Result = ( { results, searchedValue, anchor, register, focused } ) => { - const { local: { - sources, - search: { - showResultCitations, - resultTransliterationLanguage, - resultTranslationLanguage, - lineEnding, - }, - } = {} } = useContext( SettingsContext ) - - const writers = useContext( WritersContext ) - const recommendedSources = useContext( RecommendedSourcesContext ) +
+
+ ) + } + + ResultList.propTypes = { + gurmukhi: string.isRequired, + id: string.isRequired, + typeId: string.isRequired, + shabadId: string.isRequired, + ref: instanceOf( Result ).isRequired, + sourceId: number.isRequired, + shabad: shape( { } ).isRequired, + sourcePage: number.isRequired, + translations: string.isRequired, + transliterations: string.isRequired, + } return ( {results ? results.map( ( props, i ) => ResultList( { ...props, - searchedValue, - anchor, - sources, - writers, - recommendedSources, - resultTransliterationLanguage, - resultTranslationLanguage, - showResultCitations, - lineEnding, ref: c => register( i, c ), focused: focused === i, } ) ) @@ -242,35 +225,8 @@ Result.propTypes = { Result.defaultProps = { results: [], - searchedValue: '', - anchor: '', - focused: undefined, -} - -ResultList.propTypes = { - gurmukhi: string.isRequired, - id: string.isRequired, - typeId: string.isRequired, - shabadId: string.isRequired, - ref: instanceOf( Result ).isRequired, - sourceId: number.isRequired, - shabad: shape( { } ).isRequired, - sourcePage: number.isRequired, - translations: string.isRequired, - transliterations: string.isRequired, - focused: oneOfType( [ string, number ] ), - searchedValue: string.isRequired, - anchor: string.isRequired, - writers: shape( {} ).isRequired, - sources: shape( {} ).isRequired, - recommendedSources: shape( {} ).isRequired, - resultTransliterationLanguage: oneOfType( [ bool, number ] ).isRequired, - resultTranslationLanguage: oneOfType( [ bool, number ] ).isRequired, - showResultCitations: bool.isRequired, - lineEnding: bool.isRequired, -} - -ResultList.defaultProps = { + searchedValue: undefined, + anchor: undefined, focused: undefined, } From 8573b3e412accf1ebe95a78d337f00e15b4aecbd Mon Sep 17 00:00:00 2001 From: Harjot Singh Date: Sun, 25 Oct 2020 20:26:07 +0000 Subject: [PATCH 04/10] refactor(frontend/controller): move search highlighter into seperate file --- app/frontend/src/Controller/Search/Results.js | 94 ++++--------------- .../Controller/Search/highlight-matches.js | 57 +++++++++++ 2 files changed, 76 insertions(+), 75 deletions(-) create mode 100644 app/frontend/src/Controller/Search/highlight-matches.js diff --git a/app/frontend/src/Controller/Search/Results.js b/app/frontend/src/Controller/Search/Results.js index cc9697c7..b5b0b9cb 100644 --- a/app/frontend/src/Controller/Search/Results.js +++ b/app/frontend/src/Controller/Search/Results.js @@ -2,65 +2,13 @@ import React, { useContext } from 'react' import classNames from 'classnames' import { ListItem, List } from '@material-ui/core' import { string, oneOfType, number, instanceOf, shape, arrayOf, func } from 'prop-types' -import { firstLetters, stripVishraams, stripAccents, toUnicode, toAscii } from 'gurmukhi-utils' import controller from '../../lib/controller' import { getTranslation, getTransliteration, customiseLine } from '../../lib/utils' import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' import { SEARCH_TYPES, LANGUAGE_NAMES, SEARCH_ANCHORS, SOURCE_ABBREVIATIONS } from '../../lib/consts' -const highlightFullWordMatches = ( line, query ) => { - const sanitisedQuery = query.trim() - - const foundPosition = line.search( sanitisedQuery ) - const matchStartPosition = line.lastIndexOf( ' ', foundPosition ) - - const wordEndPosition = line.indexOf( ' ', foundPosition + sanitisedQuery.length ) - // If the match finishes in the last word, no space will be deteced, and wordEndPosition - // will be -1. In this case, we want to end at the last position in the line. - const matchEndPosition = wordEndPosition === -1 ? line.length - 1 : wordEndPosition - - return [ - line.substring( 0, matchStartPosition ), - line.substring( matchStartPosition, matchEndPosition ), - line.substring( matchEndPosition ), - ] -} - -const highlightFirstLetterMatches = ( line, query ) => { - const baseLine = stripVishraams( line ) - - const letters = toAscii( firstLetters( stripAccents( toUnicode( baseLine ) ) ) ) - const words = baseLine.split( ' ' ) - - const startPosition = letters.search( stripAccents( query ) ) - const endPosition = startPosition + query.length - - return [ - `${words.slice( 0, startPosition ).join( ' ' )} `, - `${words.slice( startPosition, endPosition ).join( ' ' )} `, - `${words.slice( endPosition ).join( ' ' )} `, - ] -} - -/** - * Separates the line into words before the first match, the first match, and after the match. - * @param value The full line. - * @param input The string inputted by the user. - * @param mode The type of search being performed, either first word or full word. - * @return An array of [ beforeMatch, match, afterMatch ], - * with `match` being the highlighted section.`. - */ -const highlightMatches = gurmukhi => ( value, input, mode ) => { - if ( !value ) return [ '', '', '' ] - - // Account for wildcard characters - const sanitizedInput = input.replace( new RegExp( '_', 'g' ), '.' ) - - return mode === SEARCH_TYPES.fullWord - ? highlightFullWordMatches( gurmukhi, sanitizedInput ) - : highlightFirstLetterMatches( value, sanitizedInput ) -} +import highlightMatches from './highlight-matches' const Result = ( { results, searchedValue, anchor, register, focused } ) => { const { local: { @@ -149,41 +97,37 @@ const Result = ( { results, searchedValue, anchor, register, focused } ) => { return (
- - {beforeMatch ? {beforeMatch} : null} - {match ? {match} : null} - {afterMatch ? {afterMatch} : null} + {beforeMatch && {beforeMatch}} + {match && {match}} + {afterMatch && {afterMatch}} - {translation && ( -
- {translation} -
+
+ {translation} +
)} {transliteration && ( -
- {translitBeforeMatch ? {translitBeforeMatch} : null} - {translitMatch ? {translitMatch} : null} - {translitAfterMatch ? {translitAfterMatch} : null} -
+
+ {translitBeforeMatch && {translitBeforeMatch}} + {translitMatch && {translitMatch}} + {translitAfterMatch && {translitAfterMatch}} +
)} -
{showCitation && ( - - {[ - getWriterName(), - SOURCE_ABBREVIATIONS[ sourceId ], - `${getPageName()} ${sourcePage}`, - ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} - + + {[ + getWriterName(), + SOURCE_ABBREVIATIONS[ sourceId ], + `${getPageName()} ${sourcePage}`, + ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} + )} -
) diff --git a/app/frontend/src/Controller/Search/highlight-matches.js b/app/frontend/src/Controller/Search/highlight-matches.js new file mode 100644 index 00000000..b5146471 --- /dev/null +++ b/app/frontend/src/Controller/Search/highlight-matches.js @@ -0,0 +1,57 @@ +const { stripVishraams, toAscii, firstLetters, stripAccents, toUnicode } = require( 'gurmukhi-utils' ) +const { SEARCH_TYPES } = require( '../../lib/consts' ) + +const fullWordMatches = ( line, query ) => { + const sanitisedQuery = query.trim() + + const foundPosition = line.search( sanitisedQuery ) + const matchStartPosition = line.lastIndexOf( ' ', foundPosition ) + + const wordEndPosition = line.indexOf( ' ', foundPosition + sanitisedQuery.length ) + // If the match finishes in the last word, no space will be deteced, and wordEndPosition + // will be -1. In this case, we want to end at the last position in the line. + const matchEndPosition = wordEndPosition === -1 ? line.length - 1 : wordEndPosition + + return [ + line.substring( 0, matchStartPosition ), + line.substring( matchStartPosition, matchEndPosition ), + line.substring( matchEndPosition ), + ] +} + +const firstLetterMatches = ( line, query ) => { + const baseLine = stripVishraams( line ) + + const letters = toAscii( firstLetters( stripAccents( toUnicode( baseLine ) ) ) ) + const words = baseLine.split( ' ' ) + + const startPosition = letters.search( stripAccents( query ) ) + const endPosition = startPosition + query.length + + return [ + `${words.slice( 0, startPosition ).join( ' ' )} `, + `${words.slice( startPosition, endPosition ).join( ' ' )} `, + `${words.slice( endPosition ).join( ' ' )} `, + ] +} + +/** + * Separates the line into words before the first match, the first match, and after the match. + * @param value The full line. + * @param input The string inputted by the user. + * @param mode The type of search being performed, either first word or full word. + * @return An array of [ beforeMatch, match, afterMatch ], + * with `match` being the highlighted section.`. + */ +const highlightMatches = gurmukhi => ( value, input, mode ) => { + if ( !value ) return [ '', '', '' ] + + // Account for wildcard characters + const sanitizedInput = input.replace( new RegExp( '_', 'g' ), '.' ) + + return mode === SEARCH_TYPES.fullWord + ? fullWordMatches( gurmukhi, sanitizedInput ) + : firstLetterMatches( value, sanitizedInput ) +} + +export default highlightMatches From cb24bfcdd1ebcc44bd279d3495657211a7ac4f9f Mon Sep 17 00:00:00 2001 From: Harjot Singh Date: Mon, 26 Oct 2020 02:00:02 +0000 Subject: [PATCH 05/10] fix(frontend/controller): fix search highlighting transliterations Refactors Results into single result component --- app/frontend/src/Controller/Search/Result.js | 136 ++++++++++++++ app/frontend/src/Controller/Search/Results.js | 177 ------------------ .../Controller/Search/highlight-matches.js | 57 ------ app/frontend/src/Controller/Search/index.js | 28 ++- .../Controller/Search/match-highlighter.js | 77 ++++++++ 5 files changed, 232 insertions(+), 243 deletions(-) create mode 100644 app/frontend/src/Controller/Search/Result.js delete mode 100644 app/frontend/src/Controller/Search/Results.js delete mode 100644 app/frontend/src/Controller/Search/highlight-matches.js create mode 100644 app/frontend/src/Controller/Search/match-highlighter.js diff --git a/app/frontend/src/Controller/Search/Result.js b/app/frontend/src/Controller/Search/Result.js new file mode 100644 index 00000000..869c1327 --- /dev/null +++ b/app/frontend/src/Controller/Search/Result.js @@ -0,0 +1,136 @@ +import React, { forwardRef, useContext } from 'react' +import { string, number, shape, bool, func } from 'prop-types' +import classNames from 'classnames' +import { ListItem } from '@material-ui/core' + +import controller from '../../lib/controller' +import { getTranslation, getTransliteration, customiseLine } from '../../lib/utils' +import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' +import { LANGUAGE_NAMES, SOURCE_ABBREVIATIONS } from '../../lib/consts' + +/** + * Renders a single result, highlighting the match. + * @param {string} gurmukhi The shabad line to display. + * @param {int} typeId The type id of line. + * @param {string} lineId The id of the line. + * @param {string} shabadId The id of the shabad. + * @param {Component} ref The ref to the component. + * @param {int} sourceId The id of source. + * @param {Object} shabad The object containng section information and other metadata. + * @param {int} sourcePage The page number of shabad in source. + * @param {string} translations The translations of shabad line to display. + * @param {string} transliterations The transliterations of shabad line to display. + */ +const Result = forwardRef( ( { + gurmukhi, + typeId, + id: lineId, + shabadId, + sourceId, + shabad, + focused, + highlighter, + sourcePage, + translations, + transliterations, +}, ref ) => { + const { local: { + sources, + search: { + showResultCitations, + resultTransliterationLanguage, + resultTranslationLanguage, + lineEnding, + }, + } = {} } = useContext( SettingsContext ) + + const writers = useContext( WritersContext ) + const recommendedSources = useContext( RecommendedSourcesContext ) + + const transliteration = resultTransliterationLanguage && transliterations && customiseLine( + getTransliteration( + { transliterations }, + resultTransliterationLanguage, + ), + { lineEnding, typeId }, + ) + + const translation = resultTranslationLanguage && translations && customiseLine( + getTranslation( { + line: { translations }, + shabad: { sourceId }, + recommendedSources, + sources, + languageId: resultTranslationLanguage, + } ), + { lineEnding, typeId }, + ) + + // Separate the line into words before the match, the match, and after the match + const highlight = highlighter( { gurmukhi } ) + const [ beforeMatch, match, afterMatch ] = highlight( gurmukhi ) + const [ translitBeforeMatch, translitMatch, translitAfterMatch ] = highlight( transliteration ) + + // Send the shabad id and line id to the server on click + const onClick = () => controller.shabad( { shabadId, lineId } ) + + // Helper render functions for citation + const showCitation = showResultCitations && shabad && shabad.section + const getEnglish = ( { nameEnglish } ) => nameEnglish + const getWriterName = () => getEnglish( writers[ shabad.writerId ] ) + const getPageName = () => recommendedSources[ shabad.sourceId ].pageNameEnglish + + return ( + +
+ + {beforeMatch && {beforeMatch}} + {match && {match}} + {afterMatch && {afterMatch}} + + + + {translation && ( +
+ {translation} +
+ )} + + {transliteration && ( +
+ {translitBeforeMatch && {translitBeforeMatch}} + {translitMatch && {translitMatch}} + {translitAfterMatch && {translitAfterMatch}} +
+ )} +
+ + {showCitation && ( + + {[ + getWriterName(), + SOURCE_ABBREVIATIONS[ sourceId ], + `${getPageName()} ${sourcePage}`, + ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} + + )} +
+
+ ) +} ) + +Result.propTypes = { + gurmukhi: string.isRequired, + id: string.isRequired, + typeId: string.isRequired, + shabadId: string.isRequired, + focused: bool.isRequired, + highlighter: func.isRequired, + sourceId: number.isRequired, + shabad: shape( { } ).isRequired, + sourcePage: number.isRequired, + translations: string.isRequired, + transliterations: string.isRequired, +} + +export default Result diff --git a/app/frontend/src/Controller/Search/Results.js b/app/frontend/src/Controller/Search/Results.js deleted file mode 100644 index b5b0b9cb..00000000 --- a/app/frontend/src/Controller/Search/Results.js +++ /dev/null @@ -1,177 +0,0 @@ -import React, { useContext } from 'react' -import classNames from 'classnames' -import { ListItem, List } from '@material-ui/core' -import { string, oneOfType, number, instanceOf, shape, arrayOf, func } from 'prop-types' - -import controller from '../../lib/controller' -import { getTranslation, getTransliteration, customiseLine } from '../../lib/utils' -import { WritersContext, RecommendedSourcesContext, SettingsContext } from '../../lib/contexts' -import { SEARCH_TYPES, LANGUAGE_NAMES, SEARCH_ANCHORS, SOURCE_ABBREVIATIONS } from '../../lib/consts' - -import highlightMatches from './highlight-matches' - -const Result = ( { results, searchedValue, anchor, register, focused } ) => { - const { local: { - sources, - search: { - showResultCitations, - resultTransliterationLanguage, - resultTranslationLanguage, - lineEnding, - }, - } = {} } = useContext( SettingsContext ) - - const writers = useContext( WritersContext ) - const recommendedSources = useContext( RecommendedSourcesContext ) - - /** - * Renders a single result, highlighting the match. - * @param {string} gurmukhi The shabad line to display. - * @param {int} typeId The type id of line. - * @param {string} lineId The id of the line. - * @param {string} shabadId The id of the shabad. - * @param {Component} ref The ref to the component. - * @param {int} sourceId The id of source. - * @param {Object} shabad The object containng section information and other metadata. - * @param {int} sourcePage The page number of shabad in source. - * @param {string} translations The translations of shabad line to display. - * @param {string} transliterations The transliterations of shabad line to display. - */ - const ResultList = ( { - gurmukhi, - typeId, - id: lineId, - shabadId, - ref, - sourceId, - shabad, - sourcePage, - translations, - transliterations, - } ) => { - const transliteration = resultTransliterationLanguage && transliterations && customiseLine( - getTransliteration( - { transliterations }, - resultTransliterationLanguage, - ), - { lineEnding, typeId }, - ) - - const translation = resultTranslationLanguage && translations && customiseLine( - getTranslation( { - line: { translations }, - shabad: { sourceId }, - recommendedSources, - sources, - languageId: resultTranslationLanguage, - } ), - { lineEnding, typeId }, - ) - - // Grab the search mode or assume it's first letter - const mode = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter - - // Separate the line into words before the match, the match, and after the match - const getMatches = highlightMatches( gurmukhi ) - - const [ beforeMatch, match, afterMatch ] = getMatches( - gurmukhi, - searchedValue, - mode, - ) - const [ translitBeforeMatch, translitMatch, translitAfterMatch ] = getMatches( - transliteration, - searchedValue, - mode, - ) - - // Send the shabad id and line id to the server on click - const onClick = () => controller.shabad( { shabadId, lineId } ) - - // Helper render functions for citation - const showCitation = showResultCitations && shabad && shabad.section - const getEnglish = ( { nameEnglish } ) => nameEnglish - const getWriterName = () => getEnglish( writers[ shabad.writerId ] ) - const getPageName = () => recommendedSources[ shabad.sourceId ].pageNameEnglish - - return ( - -
- - {beforeMatch && {beforeMatch}} - {match && {match}} - {afterMatch && {afterMatch}} - - - - {translation && ( -
- {translation} -
- )} - - {transliteration && ( -
- {translitBeforeMatch && {translitBeforeMatch}} - {translitMatch && {translitMatch}} - {translitAfterMatch && {translitAfterMatch}} -
- )} -
- - {showCitation && ( - - {[ - getWriterName(), - SOURCE_ABBREVIATIONS[ sourceId ], - `${getPageName()} ${sourcePage}`, - ].reduce( ( prev, curr ) => [ prev, ' - ', curr ] )} - - )} -
-
- ) - } - - ResultList.propTypes = { - gurmukhi: string.isRequired, - id: string.isRequired, - typeId: string.isRequired, - shabadId: string.isRequired, - ref: instanceOf( Result ).isRequired, - sourceId: number.isRequired, - shabad: shape( { } ).isRequired, - sourcePage: number.isRequired, - translations: string.isRequired, - transliterations: string.isRequired, - } - - return ( - - {results - ? results.map( ( props, i ) => ResultList( { - ...props, - ref: c => register( i, c ), - focused: focused === i, - } ) ) - : ''} - - ) -} - -Result.propTypes = { - results: arrayOf( shape( {} ) ), - searchedValue: string, - anchor: string, - register: func.isRequired, - focused: oneOfType( [ string, number ] ), -} - -Result.defaultProps = { - results: [], - searchedValue: undefined, - anchor: undefined, - focused: undefined, -} - -export default Result diff --git a/app/frontend/src/Controller/Search/highlight-matches.js b/app/frontend/src/Controller/Search/highlight-matches.js deleted file mode 100644 index b5146471..00000000 --- a/app/frontend/src/Controller/Search/highlight-matches.js +++ /dev/null @@ -1,57 +0,0 @@ -const { stripVishraams, toAscii, firstLetters, stripAccents, toUnicode } = require( 'gurmukhi-utils' ) -const { SEARCH_TYPES } = require( '../../lib/consts' ) - -const fullWordMatches = ( line, query ) => { - const sanitisedQuery = query.trim() - - const foundPosition = line.search( sanitisedQuery ) - const matchStartPosition = line.lastIndexOf( ' ', foundPosition ) - - const wordEndPosition = line.indexOf( ' ', foundPosition + sanitisedQuery.length ) - // If the match finishes in the last word, no space will be deteced, and wordEndPosition - // will be -1. In this case, we want to end at the last position in the line. - const matchEndPosition = wordEndPosition === -1 ? line.length - 1 : wordEndPosition - - return [ - line.substring( 0, matchStartPosition ), - line.substring( matchStartPosition, matchEndPosition ), - line.substring( matchEndPosition ), - ] -} - -const firstLetterMatches = ( line, query ) => { - const baseLine = stripVishraams( line ) - - const letters = toAscii( firstLetters( stripAccents( toUnicode( baseLine ) ) ) ) - const words = baseLine.split( ' ' ) - - const startPosition = letters.search( stripAccents( query ) ) - const endPosition = startPosition + query.length - - return [ - `${words.slice( 0, startPosition ).join( ' ' )} `, - `${words.slice( startPosition, endPosition ).join( ' ' )} `, - `${words.slice( endPosition ).join( ' ' )} `, - ] -} - -/** - * Separates the line into words before the first match, the first match, and after the match. - * @param value The full line. - * @param input The string inputted by the user. - * @param mode The type of search being performed, either first word or full word. - * @return An array of [ beforeMatch, match, afterMatch ], - * with `match` being the highlighted section.`. - */ -const highlightMatches = gurmukhi => ( value, input, mode ) => { - if ( !value ) return [ '', '', '' ] - - // Account for wildcard characters - const sanitizedInput = input.replace( new RegExp( '_', 'g' ), '.' ) - - return mode === SEARCH_TYPES.fullWord - ? fullWordMatches( gurmukhi, sanitizedInput ) - : firstLetterMatches( value, sanitizedInput ) -} - -export default highlightMatches diff --git a/app/frontend/src/Controller/Search/index.js b/app/frontend/src/Controller/Search/index.js index 80485a9f..935f5573 100644 --- a/app/frontend/src/Controller/Search/index.js +++ b/app/frontend/src/Controller/Search/index.js @@ -5,7 +5,7 @@ import { useLocation, useHistory } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faTimes } from '@fortawesome/free-solid-svg-icons' import { stringify } from 'querystring' -import { Input, InputAdornment, IconButton } from '@material-ui/core' +import { Input, InputAdornment, IconButton, List } from '@material-ui/core' import { getUrlState } from '../../lib/utils' import { SettingsContext } from '../../lib/contexts' @@ -18,7 +18,8 @@ import { MIN_SEARCH_CHARS, } from '../../lib/consts' -import Result from './Results' +import Result from './Result' +import getHighlighter from './match-highlighter' import './index.css' // Generate the regex for capturing anchor chars, optionally @@ -147,6 +148,10 @@ const Search = ( { updateFocus, register, focused } ) => { useEffect( () => { highlightSearch() }, [] ) + // Get match highlighter for the current search mode + const searchMode = SEARCH_ANCHORS[ anchor ] || SEARCH_TYPES.firstLetter + const highlighter = getHighlighter( searchedValue, searchMode ) + return (
{ autoComplete: 'off', }} /> - + + + {results && results.map( ( result, index ) => ( + register( index, ref )} + focused={focused === index} + highlighter={highlighter} + /> + ) )} +
) } diff --git a/app/frontend/src/Controller/Search/match-highlighter.js b/app/frontend/src/Controller/Search/match-highlighter.js new file mode 100644 index 00000000..1fab8181 --- /dev/null +++ b/app/frontend/src/Controller/Search/match-highlighter.js @@ -0,0 +1,77 @@ +import { stripVishraams, toAscii, firstLetters, stripAccents, toUnicode } from 'gurmukhi-utils' + +import { SEARCH_TYPES } from '../../lib/consts' + +const fullWordMatches = query => ( { target, gurmukhi } ) => { + const baseGurmukhi = stripVishraams( gurmukhi ) + const baseTarget = stripVishraams( target ) + + const sanitisedQuery = query.trim() + + const foundPosition = baseGurmukhi.search( sanitisedQuery ) + const matchStartPosition = baseGurmukhi.lastIndexOf( ' ', foundPosition ) + + const wordEndPosition = baseGurmukhi.indexOf( ' ', foundPosition + sanitisedQuery.length ) + // If the match finishes in the last word, no space will be deteced, and wordEndPosition + // will be -1. In this case, we want to end at the last position in the line. + const matchEndPosition = wordEndPosition === -1 ? baseGurmukhi.length - 1 : wordEndPosition + + // Grab the word indexes in gurmukhi + const [ wordMatchStart, wordMatchLength ] = [ + gurmukhi.substring( 0, matchStartPosition ).trim().split( ' ' ).length - 1, + gurmukhi.substring( matchStartPosition, matchEndPosition ).trim().split( ' ' ).length, + ] + + const words = baseTarget.split( ' ' ) + + return [ + `${words.slice( 0, wordMatchStart ).join( ' ' )} `, + `${words.slice( wordMatchStart, wordMatchStart + wordMatchLength ).join( ' ' )} `, + `${words.slice( wordMatchStart + wordMatchLength ).join( ' ' )} `, + ] +} + +const firstLetterMatches = query => ( { target, gurmukhi } ) => { + const baseGurmukhi = stripVishraams( gurmukhi ) + const baseLine = stripVishraams( target ) + + const letters = toAscii( firstLetters( stripAccents( toUnicode( baseGurmukhi ) ) ) ) + const words = baseLine.split( ' ' ) + + const startPosition = letters.search( stripAccents( query ) ) + const endPosition = startPosition + query.length + + return [ + `${words.slice( 0, startPosition ).join( ' ' )} `, + `${words.slice( startPosition, endPosition ).join( ' ' )} `, + `${words.slice( endPosition ).join( ' ' )} `, + ] +} + +const highlighters = { + [ SEARCH_TYPES.fullWord ]: fullWordMatches, + [ SEARCH_TYPES.firstLetter ]: firstLetterMatches, +} + +/** + * Separates the line into words before the first match, the first match, and after the match. + * @param target The text to highlight. + * @param context Contains gurmukhi and other contextual information required by all highlighters. + * @param searchQuery The string inputted by the user. + * @param searchMode The type of search being performed, either first word or full word. + * @return An array of [ beforeMatch, match, afterMatch ], + * with `match` being the highlighted section.`. + */ +const getHighlighter = ( searchQuery, searchMode ) => context => target => { + if ( !target ) return [ '', '', '' ] + + // Account for wildcard characters + const sanitizedQuery = searchQuery.replace( new RegExp( '_', 'g' ), '.' ) + + // Select the right higlighter + const highlight = highlighters[ searchMode ] + + return highlight( sanitizedQuery )( { target, ...context } ) +} + +export default getHighlighter From f8fdffb41b19e71fb7be0fc3d3bb682c3b1dfd75 Mon Sep 17 00:00:00 2001 From: Harjot Singh Date: Mon, 26 Oct 2020 18:40:46 +0000 Subject: [PATCH 06/10] refactor(frontend/controller): fix spelling in comment Co-authored-by: Saihajpreet Singh --- app/frontend/src/Controller/Search/match-highlighter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/Controller/Search/match-highlighter.js b/app/frontend/src/Controller/Search/match-highlighter.js index 1fab8181..bf3fafb7 100644 --- a/app/frontend/src/Controller/Search/match-highlighter.js +++ b/app/frontend/src/Controller/Search/match-highlighter.js @@ -68,7 +68,7 @@ const getHighlighter = ( searchQuery, searchMode ) => context => target => { // Account for wildcard characters const sanitizedQuery = searchQuery.replace( new RegExp( '_', 'g' ), '.' ) - // Select the right higlighter + // Select the right highlighter const highlight = highlighters[ searchMode ] return highlight( sanitizedQuery )( { target, ...context } ) From 264a7d52a58f65ca3737c436546c66e78df23bfd Mon Sep 17 00:00:00 2001 From: Harjot Singh Date: Mon, 26 Oct 2020 18:40:58 +0000 Subject: [PATCH 07/10] refactor(frontend/controller): fix spelling in comment Co-authored-by: Saihajpreet Singh --- app/frontend/src/Controller/Search/Result.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/Controller/Search/Result.js b/app/frontend/src/Controller/Search/Result.js index 869c1327..216d18ee 100644 --- a/app/frontend/src/Controller/Search/Result.js +++ b/app/frontend/src/Controller/Search/Result.js @@ -16,7 +16,7 @@ import { LANGUAGE_NAMES, SOURCE_ABBREVIATIONS } from '../../lib/consts' * @param {string} shabadId The id of the shabad. * @param {Component} ref The ref to the component. * @param {int} sourceId The id of source. - * @param {Object} shabad The object containng section information and other metadata. + * @param {Object} shabad The object containing section information and other metadata. * @param {int} sourcePage The page number of shabad in source. * @param {string} translations The translations of shabad line to display. * @param {string} transliterations The transliterations of shabad line to display. From 8a8fd11636bf61ef6a98dfefc3afbacd2806ccad Mon Sep 17 00:00:00 2001 From: Harjot Singh Date: Mon, 26 Oct 2020 18:41:07 +0000 Subject: [PATCH 08/10] refactor(frontend/controller): fix spelling in comment Co-authored-by: Saihajpreet Singh --- app/frontend/src/Controller/Search/match-highlighter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/frontend/src/Controller/Search/match-highlighter.js b/app/frontend/src/Controller/Search/match-highlighter.js index bf3fafb7..6620f010 100644 --- a/app/frontend/src/Controller/Search/match-highlighter.js +++ b/app/frontend/src/Controller/Search/match-highlighter.js @@ -12,7 +12,7 @@ const fullWordMatches = query => ( { target, gurmukhi } ) => { const matchStartPosition = baseGurmukhi.lastIndexOf( ' ', foundPosition ) const wordEndPosition = baseGurmukhi.indexOf( ' ', foundPosition + sanitisedQuery.length ) - // If the match finishes in the last word, no space will be deteced, and wordEndPosition + // If the match finishes in the last word, no space will be detected, and wordEndPosition // will be -1. In this case, we want to end at the last position in the line. const matchEndPosition = wordEndPosition === -1 ? baseGurmukhi.length - 1 : wordEndPosition From abec67a2fb82d1beee0f1b55ac19f6cf9ec2d9ae Mon Sep 17 00:00:00 2001 From: Harjot Singh Date: Mon, 26 Oct 2020 21:51:31 +0000 Subject: [PATCH 09/10] docs(frontend/controller): add more information about match highlighting --- .../Controller/Search/match-highlighter.js | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/app/frontend/src/Controller/Search/match-highlighter.js b/app/frontend/src/Controller/Search/match-highlighter.js index 6620f010..da038762 100644 --- a/app/frontend/src/Controller/Search/match-highlighter.js +++ b/app/frontend/src/Controller/Search/match-highlighter.js @@ -2,21 +2,32 @@ import { stripVishraams, toAscii, firstLetters, stripAccents, toUnicode } from ' import { SEARCH_TYPES } from '../../lib/consts' +/** + * Highlights a full word query against a matched line. + * Finds the position to highlight in the target string using the Gurmukhi matched line, + * and using the position of the highlighted words, highlights the same words in the target + * string. + */ const fullWordMatches = query => ( { target, gurmukhi } ) => { + // Remove vishraams to prevent query from not matching const baseGurmukhi = stripVishraams( gurmukhi ) + // Remove vishraams from target to prevent vishraams in output const baseTarget = stripVishraams( target ) + // Trailing spaces can cause mismatches const sanitisedQuery = query.trim() + // Find the match position, and then backtrack to find the beginning of the word const foundPosition = baseGurmukhi.search( sanitisedQuery ) const matchStartPosition = baseGurmukhi.lastIndexOf( ' ', foundPosition ) + // Search forward to find the end of the match const wordEndPosition = baseGurmukhi.indexOf( ' ', foundPosition + sanitisedQuery.length ) // If the match finishes in the last word, no space will be detected, and wordEndPosition // will be -1. In this case, we want to end at the last position in the line. const matchEndPosition = wordEndPosition === -1 ? baseGurmukhi.length - 1 : wordEndPosition - // Grab the word indexes in gurmukhi + // Grab the start index and length of the entire matching words const [ wordMatchStart, wordMatchLength ] = [ gurmukhi.substring( 0, matchStartPosition ).trim().split( ' ' ).length - 1, gurmukhi.substring( matchStartPosition, matchEndPosition ).trim().split( ' ' ).length, @@ -31,13 +42,22 @@ const fullWordMatches = query => ( { target, gurmukhi } ) => { ] } +/** + * Highlights a first letter query against a matched line. + * Finds the words to match in the Gurmukhi string, and highlights + * the corresponding target string. + */ const firstLetterMatches = query => ( { target, gurmukhi } ) => { + // Remove vishraams to prevent query from not matching const baseGurmukhi = stripVishraams( gurmukhi ) + // Remove vishraams from target to prevent vishraams in output const baseLine = stripVishraams( target ) + // Get only letters, so that simple first letters can be matched const letters = toAscii( firstLetters( stripAccents( toUnicode( baseGurmukhi ) ) ) ) const words = baseLine.split( ' ' ) + // Find the start and end positions of the match, including the entire end word const startPosition = letters.search( stripAccents( query ) ) const endPosition = startPosition + query.length @@ -48,6 +68,12 @@ const firstLetterMatches = query => ( { target, gurmukhi } ) => { ] } +/** + * Supported search mode match highlighters. + * Highlighters must support highlighting against a 1-1 transliteration or gurmukhi string. + * Highlighters all receive the same parameters. + * Highlights must return a tuple of [ beforeMatch, match, afterMatch ] + */ const highlighters = { [ SEARCH_TYPES.fullWord ]: fullWordMatches, [ SEARCH_TYPES.firstLetter ]: firstLetterMatches, From 2edeaccd5d09aef27c324d1ddf9de9325a934f44 Mon Sep 17 00:00:00 2001 From: Harjot Singh Date: Mon, 26 Oct 2020 21:54:44 +0000 Subject: [PATCH 10/10] docs(frontend/controller): update JSDoc of Result component --- app/frontend/src/Controller/Search/Result.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/frontend/src/Controller/Search/Result.js b/app/frontend/src/Controller/Search/Result.js index 216d18ee..5ee2e7e3 100644 --- a/app/frontend/src/Controller/Search/Result.js +++ b/app/frontend/src/Controller/Search/Result.js @@ -14,9 +14,10 @@ import { LANGUAGE_NAMES, SOURCE_ABBREVIATIONS } from '../../lib/consts' * @param {int} typeId The type id of line. * @param {string} lineId The id of the line. * @param {string} shabadId The id of the shabad. - * @param {Component} ref The ref to the component. * @param {int} sourceId The id of source. * @param {Object} shabad The object containing section information and other metadata. + * @param {Boolean} focused Whether the line is focused or not. + * @param {Function} highlighter The match highlighter. * @param {int} sourcePage The page number of shabad in source. * @param {string} translations The translations of shabad line to display. * @param {string} transliterations The transliterations of shabad line to display.