diff --git a/app/src/DashboardPage.js b/app/src/DashboardPage.js index a3ac7352..66af8fae 100644 --- a/app/src/DashboardPage.js +++ b/app/src/DashboardPage.js @@ -156,19 +156,34 @@ export function Getters() { export function DashboardPage() { const navigate = useNavigate(); + // The search the user submitted. const {searchStr, setSearchStr, activeTags} = useSearch(); + // The search that the user is typing. + const [localSearchStr, setLocalSearchStr] = React.useState(searchStr); + console.log('searchStr', searchStr, 'localSearchStr', localSearchStr); const { suggestionsResults, handleResultSelect, resultRenderer, loading, - setSearchStr: setSuggestionSearchStr + setSearchStr: setSuggestionSearchStr, + setSearchTags, } = useContext(SearchSuggestionsContext); const {status} = useContext(StatusContext); React.useEffect(() => { - setSuggestionSearchStr(searchStr); - }, [searchStr]) + setSuggestionSearchStr(localSearchStr); + }, [localSearchStr]); + + React.useEffect(() => { + setLocalSearchStr(searchStr); + }, [searchStr]); + + React.useEffect(() => { + if (activeTags) { + setSearchTags(activeTags); + } + }, [JSON.stringify(activeTags)]); let title = 'Dashboard'; if (searchStr) { @@ -192,24 +207,28 @@ export function DashboardPage() { body = ; } + const getSearchResultsInput = (props) => { + return navigate('/')} + style={{marginBottom: '2em'}} + results={suggestionsResults} + handleResultSelect={handleResultSelect} + resultRenderer={resultRenderer} + loading={loading} + {...props} + />; + }; + return - navigate('/')} - style={{marginBottom: '2em'}} - results={suggestionsResults} - handleResultSelect={handleResultSelect} - resultRenderer={resultRenderer} - loading={loading} - /> + {getSearchResultsInput({size: 'large'})} @@ -221,19 +240,7 @@ export function DashboardPage() { - navigate('/')} - style={{marginBottom: '2em'}} - results={suggestionsResults} - handleResultSelect={handleResultSelect} - resultRenderer={resultRenderer} - loading={loading} - /> + {getSearchResultsInput({size: 'big'})} diff --git a/app/src/api.js b/app/src/api.js index 142b4c2c..0808aed8 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1003,36 +1003,23 @@ export async function fetchDecoded(vinNumber) { } } -export async function searchSuggestions(search_str) { - const body = {search_str: search_str}; +export async function searchSuggestions(search_str, tagNames) { + const body = {search_str: search_str, tag_names: tagNames}; const response = await apiPost(`${API_URI}/search_suggestions`, body); if (response.ok) { const content = await response.json(); const fileGroups = content.file_groups; - const zimsEstimates = content.zims_estimates; const channels = content.channels; const domains = content.domains; return { fileGroups, - zimsEstimates, channels, domains, } } } -export async function searchEstimateFiles(search_str, tagNames) { - const body = {search_str: search_str, tag_names: tagNames}; - const response = await apiPost(`${API_URI}/files/search_estimates`, body); - if (response.ok) { - const content = await response.json(); - return { - fileGroups: content.file_groups, - } - } -} - export async function searchEstimateZims(search_str, tagNames) { const body = {search_str: search_str, tag_names: tagNames}; const response = await apiPost(`${API_URI}/zim/search_estimates`, body); diff --git a/app/src/components/Common.js b/app/src/components/Common.js index db313488..31021fb2 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1396,7 +1396,10 @@ export function TagIcon() { } export function normalizeEstimate(estimate) { - return estimate > 999 ? '>999' : estimate; + if (Number.isInteger(estimate)) { + return estimate > 999 ? '>999' : estimate.toString(); + } + return '?'; } export function useAPIButton( diff --git a/app/src/components/Search.js b/app/src/components/Search.js index ebe24404..7b5e0f99 100644 --- a/app/src/components/Search.js +++ b/app/src/components/Search.js @@ -3,7 +3,7 @@ import {createSearchParams, Route, Routes, useNavigate} from "react-router-dom"; import {FileSearchFilterButton, FilesSearchView} from "./Files"; import {useLatestRequest, usePages, useQuery} from "../hooks/customHooks"; import {ZimSearchView} from "./Zim"; -import {searchEstimateFiles, searchEstimateZims, searchSuggestions} from "../api"; +import {searchEstimateZims, searchSuggestions} from "../api"; import {fuzzyMatch, normalizeEstimate, SearchResultsInput, TabLinks} from "./Common"; import _ from "lodash"; import {TagsContext} from "../Tags"; @@ -11,35 +11,28 @@ import {Header as SHeader, Label} from "semantic-ui-react"; import {Button, Modal, ModalContent} from "./Theme"; import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; -export const SearchSuggestionsContext = React.createContext({ - suggestionsResults: {}, - suggestions: {}, - suggestionsSums: {}, - handleResultSelect: null, - resultRenderer: null, - loading: false, - searchStr: '', - setSearchStr: null, -}); - - -export const SearchSuggestionsProvider = (props) => { - const value = useSearchSuggestions(); - - return - {props.children} - -} - const SUGGESTED_APPS = [ - {location: '/more/otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'}, - {location: '/inventory', title: 'Inventory', description: 'Track and organize your food storage'}, - {location: '/more/vin', title: 'Vin Decoder', description: 'Decode and analyze vehicle VIN numbers'}, - {location: '/admin', title: 'Downloads', description: 'View your downloads'}, + {location: '/admin', title: 'Downloads', description: 'View and control your downloads'}, + {location: '/admin/settings', title: 'Hotspot', description: 'Control your hotspot'}, + {location: '/admin/settings', title: 'Restart', description: 'Restart your WROLPi'}, {location: '/admin/settings', title: 'Settings', description: 'View and modify your settings'}, + {location: '/admin/settings', title: 'Shutdown', description: 'Shutdown your WROLPi'}, {location: '/admin/status', title: 'Status', description: 'View the status of this WROLPi server'}, {location: '/admin/wrol', title: 'WROL Mode', description: 'Enable or disable WROL Mode'}, + {location: '/archive', title: 'Archives', description: 'View your Archives'}, + {location: '/archive/domains', title: 'Domains', description: 'View the domains of your Archives'}, + {location: '/files', title: 'Files', description: 'View your files'}, {location: '/help', title: 'Help', description: 'Help documents for WROLPi'}, + {location: '/inventory', title: 'Inventory', description: 'Track and organize your food storage'}, + {location: '/map', title: 'Map', description: 'View your Map'}, + {location: '/map/manage', title: 'Manage Map', description: 'Manage your Map'}, + {location: '/more/otp', title: 'One Time Pad', description: 'Encrypt and Decrypt messages'}, + {location: '/more/statistics', title: 'Statistics', description: 'View the statistics of your WROLPi'}, + {location: '/more/vin', title: 'Vin Decoder', description: 'Decode and analyze vehicle VIN numbers'}, + {location: '/videos', title: 'Videos', description: 'View your Videos'}, + {location: '/videos/channel', title: 'Channels', description: 'View the Channels of your Videos'}, + {location: '/zim', title: 'Zim', description: 'View your Zims'}, + {location: '/zim/manage', title: 'Manage Zim', description: 'Manage your Zims'}, ]; export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false, model) => { @@ -101,27 +94,65 @@ export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false } } -export function useSuggestions(searchStr) { - const {data, sendRequest, loading} = useLatestRequest(500); +export const SearchSuggestionsContext = React.createContext({ + suggestions: {}, // from the api + suggestionsResults: {}, // suggestions converted for results. + suggestionsSums: {}, // suggestions from the api summarized. + handleResultSelect: null, // user clicks "result" + resultRenderer: null, + loading: false, + searchStr: '', + setSearchStr: null, + setSearchTags: null, +}); + +export function useSuggestions(searchStr, tagNames) { + const defaultSuggestions = { + fileGroups: [], + channels: [], + domains: [], + zimsEstimates: [], + } + const [suggestions, setSuggestions] = React.useState(defaultSuggestions); + // fileGroups/channels/domains. + const {data, sendRequest, loading} = useLatestRequest(500, true); + // Zims are slow, so they are separate. + const {data: zimData, sendRequest: sendZimRequest, loading: zimLoading} = useLatestRequest(500, true); React.useEffect(() => { - if (!searchStr || searchStr.length === 0) { - return; + if ((searchStr && searchStr.length > 0) || (tagNames && tagNames.length > 0)) { + sendRequest(async () => await searchSuggestions(searchStr, tagNames)); + sendZimRequest(async () => await searchEstimateZims(searchStr, tagNames)); } + }, [searchStr, sendRequest, sendZimRequest, JSON.stringify(tagNames)]); - // Use the `useLatestRequest` to handle user typing. - sendRequest(async () => await searchSuggestions(searchStr)); - }, [searchStr, sendRequest]); + React.useEffect(() => { + if (!_.isEmpty(data)) { + setSuggestions({ + ...suggestions, + channels: data.channels, + fileGroups: data.fileGroups, + domains: data.domains, + }); + } + }, [JSON.stringify(data)]); - return {suggestions: data, loading} + React.useEffect(() => { + if (!_.isEmpty(zimData)) { + setSuggestions({...suggestions, zimsEstimates: zimData.zimsEstimates}); + } + }, [JSON.stringify(zimData)]); + + return {suggestions, loading: loading || zimLoading} } -export function useSearchSuggestions(defaultSearchStr) { +export function useSearchSuggestions(defaultSearchStr, defaultTagNames) { const navigate = useNavigate(); const [searchStr, setSearchStr] = React.useState(defaultSearchStr || ''); + const [searchTags, setSearchTags] = React.useState(defaultTagNames); const {SingleTag, fuzzyMatchTagsByName} = React.useContext(TagsContext); - const {suggestions, loading} = useSuggestions(searchStr); + const {suggestions, loading} = useSuggestions(searchStr, searchTags); // The results that will be displayed by . const [suggestionsResults, setSuggestionsResults] = useState({}); @@ -130,7 +161,7 @@ export function useSearchSuggestions(defaultSearchStr) { const normalizeSuggestionsResults = (newSuggestions) => { // Convert the suggestions from the Backend to what the Semantic expects. - const lowerSearchStr = searchStr.toLowerCase(); + const lowerSearchStr = searchStr ? searchStr.toLowerCase() : ''; let results = {}; @@ -147,8 +178,10 @@ export function useSearchSuggestions(defaultSearchStr) { }; } - const zimSum = newSuggestions.zimsEstimates.reduce((i, j) => i + j, 0); - if (zimSum > 0) { + const zimSum = newSuggestions.zimsEstimates && newSuggestions.zimsEstimates.length > 0 + ? newSuggestions.zimsEstimates.reduce((i, j) => i + j.estimate, 0) + : null; + if (newSuggestions && zimSum > 0) { results.zimsSum = { name: 'Zims', results: [ {title: zimSum.toString(), type: 'zims', location: `/search/zim?q=${encodeURIComponent(searchStr)}`} @@ -176,7 +209,8 @@ export function useSearchSuggestions(defaultSearchStr) { } } - const matchingTags = fuzzyMatchTagsByName(searchStr); + // Match at most 5 Tags. + const matchingTags = searchStr ? fuzzyMatchTagsByName(searchStr).slice(0, 5) : null; if (matchingTags && matchingTags.length > 0) { results.tags = { name: 'Tags', results: matchingTags.map(i => { @@ -189,7 +223,8 @@ export function useSearchSuggestions(defaultSearchStr) { i.title.toLowerCase().includes(lowerSearchStr) || fuzzyMatch(i.title.toLowerCase(), lowerSearchStr)); if (matchingApps && matchingApps.length > 0) { - results.apps = {name: 'Apps', results: matchingApps}; + // Match at most 5 apps. + results.apps = {name: 'Apps', results: matchingApps.slice(0, 5)}; } setSuggestionsResults(results); @@ -198,8 +233,8 @@ export function useSearchSuggestions(defaultSearchStr) { zims: zimSum, channels: newSuggestions.channels.length, domains: newSuggestions.domains.length, - tags: matchingTags.length, - apps: matchingApps.length, + tags: matchingTags?.length, + apps: matchingApps?.length, }); } @@ -236,6 +271,7 @@ export function useSearchSuggestions(defaultSearchStr) { suggestionsSums, searchStr, setSearchStr, + setSearchTags, handleResultSelect, resultRenderer, loading @@ -243,67 +279,20 @@ export function useSearchSuggestions(defaultSearchStr) { } -export function useSearchEstimate(searchStr, tagNames) { - const {data: filesData, sendRequest: sendFilesRequest, loading: loadingFiles} = useLatestRequest(250); - const {data: zimsData, sendRequest: sendZimsRequest, loading: loadingZims} = useLatestRequest(250); +export const SearchSuggestionsProvider = (props) => { + const value = useSearchSuggestions(); - // The estimates from the response. - const [estimates, setEstimates] = React.useState({ - fileGroups: [], - zims: [], - }); - // The estimates summarized. - const [estimatesSums, setEstimatesSums] = React.useState({ - fileGroups: 0, - zims: 0, - }); - - React.useEffect(() => { - // Handle results from `sendRequest`. - if (_.isEmpty(filesData)) { - return; - } - - setEstimates({...estimates, fileGroups: filesData.fileGroups}); - setEstimatesSums({...estimatesSums, fileGroups: filesData.fileGroups}); - }, [JSON.stringify(filesData)]); - - React.useEffect(() => { - sendFilesRequest(async () => await searchEstimateFiles(searchStr, tagNames)); - }, [searchStr, JSON.stringify(tagNames)], sendFilesRequest); - - React.useEffect(() => { - // Handle results from `sendRequest`. - if (_.isEmpty(zimsData)) { - return; - } - - setEstimates({...estimates, zimsEstimates: zimsData.zimsEstimates}); - setEstimatesSums({...estimatesSums, zims: zimsData.zimsEstimates.reduce((i, j) => i + j['estimate'], 0)}); - }, [JSON.stringify(zimsData)]); - - React.useEffect(() => { - sendZimsRequest(async () => await searchEstimateZims(searchStr, tagNames)); - }, [searchStr, JSON.stringify(tagNames)], sendZimsRequest); - - return { - estimates, - estimatesSums, - loading: loadingFiles || loadingZims, - } + return + {props.children} + } export function SearchView() { - const {searchStr, activeTags} = useSearch(); - const {estimates, estimatesSums, loading} = useSearchEstimate(searchStr, activeTags); + const {suggestions, suggestionsSums, loading} = useContext(SearchSuggestionsContext); - let filesTabName = Files ; - let zimsTabName = Zims; - if (!_.isEmpty(estimatesSums)) { - filesTabName = Files ; - zimsTabName = Zims ; - } + const filesTabName = Files ; + const zimsTabName = Zims ; const links = [ {text: filesTabName, to: '/search', key: 'filesSearch', end: true}, @@ -314,7 +303,7 @@ export function SearchView() { }/> - }/> + }/> } diff --git a/app/src/components/Zim.js b/app/src/components/Zim.js index 00e00b92..bb42c5a6 100644 --- a/app/src/components/Zim.js +++ b/app/src/components/Zim.js @@ -287,10 +287,10 @@ const ZimsRefreshWarning = () => { ; } -export const ZimSearchView = ({estimates, loading}) => { +export const ZimSearchView = ({suggestions, loading}) => { const [activeIndex, setActiveIndex] = React.useState(null); const {searchStr, activeTags, setTags} = useSearch(); - const {zimsEstimates} = estimates; + const {zimsEstimates} = suggestions; const handleClick = (index, activeIndex_) => { setActiveIndex(index === activeIndex_ ? -1 : index); @@ -308,7 +308,11 @@ export const ZimSearchView = ({estimates, loading}) => { onClick={handleClick} />); } else if (loading) { - body = ; + body = + + + + ; } else if (_.isEmpty(zimsEstimates)) { body = ; } diff --git a/app/src/hooks/customHooks.js b/app/src/hooks/customHooks.js index 0d9abbb6..8e8bb4bb 100644 --- a/app/src/hooks/customHooks.js +++ b/app/src/hooks/customHooks.js @@ -63,7 +63,7 @@ export const useRecurringTimeout = (callback, delay) => { }, [delay]) } -export const useLatestRequest = (delay = 300) => { +export const useLatestRequest = (delay = 300, defaultLoading = false) => { // A hook which ignores older requests and will only set `data` to the latest response's data. // usage: sendRequest(async () => await yourAPICall(...args)); @@ -72,7 +72,7 @@ export const useLatestRequest = (delay = 300) => { const latestRequestRef = React.useRef(0); const debounceTimerRef = React.useRef(null); // Loading while awaiting. - const [loading, setLoading] = React.useState(false); + const [loading, setLoading] = React.useState(defaultLoading); const sendRequest = React.useCallback((fetchFunction) => { if (debounceTimerRef.current) { diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 23d0b071..232a8170 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -509,25 +509,18 @@ async def post_shutdown(_: Request): @api_bp.post('/search_suggestions') @validate(json=schema.SearchSuggestionsRequest) async def post_search_suggestions(_: Request, body: schema.SearchSuggestionsRequest): - """Used by the Global search to suggest related Channels/Domains/etc. to the user. - - @note: Does not handle Tags, unlike search_estimates""" + """Used by the Global search to suggest related Channels/Domains/etc. to the user.""" from modules.videos.channel.lib import search_channels_by_name from modules.archive.lib import search_domains_by_name - from modules.zim import lib as zim_lib file_groups, channels, domains = await asyncio.gather( - estimate_search(body.search_str, []), + estimate_search(body.search_str, body.tag_names), search_channels_by_name(body.search_str), search_domains_by_name(body.search_str), ) - # Get estimates using libzim. - zims_estimates = zim_lib.get_estimates(body.search_str) - ret = dict( file_groups=file_groups, - zims_estimates=zims_estimates, channels=channels, domains=domains, ) diff --git a/wrolpi/schema.py b/wrolpi/schema.py index 99aab86f..e73b8711 100644 --- a/wrolpi/schema.py +++ b/wrolpi/schema.py @@ -140,9 +140,4 @@ class VINDecoderResponse: @dataclass class SearchSuggestionsRequest: search_str: Optional[str] = None - - -@dataclass -class SearchEstimateRequest: - search_str: Optional[str] = None tag_names: List[str] = field(default_factory=lambda: list()) diff --git a/wrolpi/test/test_root_api.py b/wrolpi/test/test_root_api.py index 90d0cb96..83e9ac39 100644 --- a/wrolpi/test/test_root_api.py +++ b/wrolpi/test/test_root_api.py @@ -468,15 +468,13 @@ async def test_post_vin_number_decoder(test_async_client): @pytest.mark.asyncio -async def test_search_suggestions(test_session, test_async_client, channel_factory, archive_factory, tag_factory, - zim_factory): +async def test_search_suggestions(test_session, test_async_client, channel_factory, archive_factory, tag_factory): # WARNING results are cached, this test uses unique queries to avoid conflicts. channel_factory(name='Foo') channel_factory(name='Fool') channel_factory(name='Bar') archive_factory(domain='foo.com', contents='contents of foo') archive_factory(domain='bar.com', contents='contents of bar') - zim_factory('test zim') test_session.commit() body = dict(search_str='foo') @@ -489,8 +487,6 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto 'url': 'https://example.com/Fool'}] assert response.json['domains'] == [{'directory': 'archive/foo.com', 'domain': 'foo.com', 'id': 1}] assert response.json['file_groups'] == 1 - assert 'zims_estimates' in response.json and len(response.json['zims_estimates']) == 1 - assert response.json['zims_estimates'][0] == 0 body = dict(search_str='bar') request, response = await test_async_client.post('/api/search_suggestions', json=body) @@ -498,7 +494,6 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto assert response.json['channels'] == [{'directory': 'Bar', 'id': 3, 'name': 'Bar', 'url': 'https://example.com/Bar'}] assert response.json['domains'] == [{'directory': 'archive/bar.com', 'domain': 'bar.com', 'id': 2}] assert response.json['file_groups'] == 1 - assert response.json['zims_estimates'][0] == 0 body = dict(search_str='one') request, response = await test_async_client.post('/api/search_suggestions', json=body) @@ -506,7 +501,6 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto assert response.json['channels'] == [] assert response.json['domains'] == [] assert response.json['file_groups'] == 0 - assert response.json['zims_estimates'][0] == 2 def test_recursive_errors():