diff --git a/app/src/api.js b/app/src/api.js index 216c45a7..3b4d9628 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1022,6 +1022,21 @@ export async function searchSuggestions(search_str) { } } +export async function searchEstimate(search_str, tagNames) { + const body = {search_str: search_str, tag_names: tagNames}; + const response = await apiPost(`${API_URI}/search_estimates`, body); + if (response.ok) { + const content = await response.json(); + + const fileGroups = content.file_groups; + const zimsEstimates = content.zims_estimates; + return { + fileGroups, + zimsEstimates, + } + } +} + export async function getOutdatedZims() { const response = await apiGet(`${API_URI}/zim/outdated`); if (response.status === 200) { diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 86539253..db313488 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -70,7 +70,7 @@ export function Paginator({activePage, onPageChange, totalPages, showFirstAndLas onPageChange={handlePageChange} size={size} siblingRange={2} - totalPages={totalPages} + totalPages={totalPages || 1} firstItem={showFirstAndLast ? undefined : null} lastItem={showFirstAndLast ? undefined : null} /> @@ -82,7 +82,7 @@ export function Paginator({activePage, onPageChange, totalPages, showFirstAndLas onPageChange={handlePageChange} size={size} siblingRange={5} - totalPages={totalPages} + totalPages={totalPages || 1} firstItem={showFirstAndLast ? undefined : null} lastItem={showFirstAndLast ? undefined : null} /> diff --git a/app/src/components/Search.js b/app/src/components/Search.js index c9040700..4b33e81e 100644 --- a/app/src/components/Search.js +++ b/app/src/components/Search.js @@ -1,4 +1,4 @@ -import React, {useContext, useEffect, useState} from "react"; +import React, {useState} from "react"; import {createSearchParams, Route, Routes, useNavigate} from "react-router-dom"; import {FilesSearchView} from "./Files"; import {useLatestRequest, usePages, useQuery} from "../hooks/customHooks"; @@ -7,7 +7,7 @@ import {searchEstimate, searchSuggestions} from "../api"; import {fuzzyMatch, normalizeEstimate, TabLinks} from "./Common"; import _ from "lodash"; import {TagsContext} from "../Tags"; -import {Header as SHeader} from "semantic-ui-react"; +import {Header as SHeader, Label} from "semantic-ui-react"; export const SearchSuggestionsContext = React.createContext({ suggestionsResults: {}, @@ -18,6 +18,15 @@ export const SearchSuggestionsContext = React.createContext({ loading: false, }); + +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'}, @@ -29,142 +38,6 @@ const SUGGESTED_APPS = [ {location: '/help', title: 'Help', description: 'Help documents for WROLPi'}, ]; - -export function useSearchSuggestions() { - const navigate = useNavigate(); - const {searchParams} = useQuery(); - const {SingleTag, fuzzyMatchTagsByName} = React.useContext(TagsContext); - - const searchStr = searchParams.get('q'); - - const {data, sendRequest, loading} = useLatestRequest(500); - const [suggestionsResults, setSuggestionsResults] = useState({}); - const [suggestions, setSuggestions] = useState({}); - const [suggestionsSums, setSuggestionsSums] = useState({}); - - const normalizeSuggestionsResults = (newSuggestions) => { - // Convert the suggestions from the Backend to what the Semantic expects. - const lowerSearchStr = searchStr.toLowerCase(); - - let results = {}; - - // Suggested results are ordered. - if (newSuggestions.fileGroups > 0) { - results.fileGroups = { - name: 'Files', results: [ - { - title: newSuggestions.fileGroups.toString(), - type: 'files', - location: `/search?q=${encodeURIComponent(searchStr)}` - } - ] - }; - } - - const zimSum = newSuggestions.zimsEstimates.reduce((i, j) => i + j['estimate'], 0); - if (zimSum > 0) { - results.zimsSum = { - name: 'Zims', results: [ - {title: zimSum.toString(), type: 'zims', location: `/search/zim?q=${encodeURIComponent(searchStr)}`} - ], - } - } - if (newSuggestions.channels && newSuggestions.channels.length > 0) { - results.channels = { - name: 'Channels', results: newSuggestions.channels.map(i => { - return {type: 'channel', title: i['name'], id: i['id'], location: `/videos/channel/${i.id}/video`} - }) - } - } - if (newSuggestions.domains && newSuggestions.domains.length > 0) { - results.domains = { - name: 'Domains', results: newSuggestions.domains.map(i => { - return { - type: 'domain', - title: i.domain, - id: i.id, - domain: i.domain, - location: `/archive?domain=${i.domain}` - } - }) - } - } - - const matchingTags = fuzzyMatchTagsByName(searchStr); - if (matchingTags && matchingTags.length > 0) { - results.tags = { - name: 'Tags', results: matchingTags.map(i => { - return {type: 'tag', title: i.name, location: `/search?tag=${encodeURIComponent(i.name)}`} - }) - } - } - - const matchingApps = SUGGESTED_APPS.filter(i => - i.title.toLowerCase().includes(lowerSearchStr) - || fuzzyMatch(i.title.toLowerCase(), lowerSearchStr)); - if (matchingApps && matchingApps.length > 0) { - results.apps = {name: 'Apps', results: matchingApps}; - } - - setSuggestionsResults(results); - setSuggestionsSums({ - fileGroups: newSuggestions.fileGroups, - zims: zimSum, - channels: newSuggestions.channels.length, - domains: newSuggestions.domains.length, - tags: matchingTags.length, - apps: matchingApps.length, - }) - } - - React.useEffect(() => { - if (data) { - setSuggestions(data); - normalizeSuggestionsResults(data); - } - }, [JSON.stringify(data)]); - - React.useEffect(() => { - if (!searchStr || searchStr.length === 0) { - console.debug('Not getting suggestions because there is no search.'); - return; - } - setSuggestions({}); - setSuggestionsSums({}); - setSuggestionsResults({}); - - // Use the useLatestRequest to handle user typing. - sendRequest(async () => await searchSuggestions(searchStr)); - }, [searchStr, sendRequest]); - - const handleResultSelect = ({result}) => navigate(result.location); - - const resultRenderer = ({type, title, description}) => { - if (type === 'tag') { - return ; - } - - // No specific renderer, use the generic. - if (description) { - return <> - {title} - {description} - - } - return {title} - }; - - return {suggestions, suggestionsResults, suggestionsSums, searchStr, handleResultSelect, resultRenderer, loading} -} - -export const SearchSuggestionsProvider = (props) => { - const value = useSearchSuggestions(); - - return - {props.children} - -} - export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false, model) => { const navigate = useNavigate(); @@ -224,15 +97,178 @@ export const useSearch = (defaultLimit = 48, totalPages = 0, emptySearch = false } } +export function useSuggestions(searchStr) { + const {data, sendRequest, loading} = useLatestRequest(500); + + React.useEffect(() => { + if (!searchStr || searchStr.length === 0) { + return; + } + + // Use the `useLatestRequest` to handle user typing. + sendRequest(async () => await searchSuggestions(searchStr)); + }, [searchStr, sendRequest]); + + return {suggestions: data, loading} +} + + +export function useSearchSuggestions() { + const navigate = useNavigate(); + const {SingleTag, fuzzyMatchTagsByName} = React.useContext(TagsContext); + const {searchStr} = useSearch(); + const {suggestions, loading} = useSuggestions(searchStr); + + // The results that will be displayed by . + const [suggestionsResults, setSuggestionsResults] = useState({}); + // The results summarized. + const [suggestionsSums, setSuggestionsSums] = useState({}); + + const normalizeSuggestionsResults = (newSuggestions) => { + // Convert the suggestions from the Backend to what the Semantic expects. + const lowerSearchStr = searchStr.toLowerCase(); + + let results = {}; + + // Suggested results are ordered. + if (newSuggestions.fileGroups > 0) { + results.fileGroups = { + name: 'Files', results: [ + { + title: newSuggestions.fileGroups.toString(), + type: 'files', + location: `/search?q=${encodeURIComponent(searchStr)}` + } + ] + }; + } + + const zimSum = newSuggestions.zimsEstimates.reduce((i, j) => i + j, 0); + if (zimSum > 0) { + results.zimsSum = { + name: 'Zims', results: [ + {title: zimSum.toString(), type: 'zims', location: `/search/zim?q=${encodeURIComponent(searchStr)}`} + ], + } + } + if (newSuggestions.channels && newSuggestions.channels.length > 0) { + results.channels = { + name: 'Channels', results: newSuggestions.channels.map(i => { + return {type: 'channel', title: i['name'], id: i['id'], location: `/videos/channel/${i.id}/video`} + }) + } + } + if (newSuggestions.domains && newSuggestions.domains.length > 0) { + results.domains = { + name: 'Domains', results: newSuggestions.domains.map(i => { + return { + type: 'domain', + title: i.domain, + id: i.id, + domain: i.domain, + location: `/archive?domain=${i.domain}` + } + }) + } + } + + const matchingTags = fuzzyMatchTagsByName(searchStr); + if (matchingTags && matchingTags.length > 0) { + results.tags = { + name: 'Tags', results: matchingTags.map(i => { + return {type: 'tag', title: i.name, location: `/search?tag=${encodeURIComponent(i.name)}`} + }) + } + } + + const matchingApps = SUGGESTED_APPS.filter(i => + i.title.toLowerCase().includes(lowerSearchStr) + || fuzzyMatch(i.title.toLowerCase(), lowerSearchStr)); + if (matchingApps && matchingApps.length > 0) { + results.apps = {name: 'Apps', results: matchingApps}; + } + + setSuggestionsResults(results); + setSuggestionsSums({ + fileGroups: newSuggestions.fileGroups, + zims: zimSum, + channels: newSuggestions.channels.length, + domains: newSuggestions.domains.length, + tags: matchingTags.length, + apps: matchingApps.length, + }); + } + + React.useEffect(() => { + setSuggestionsSums({}); + setSuggestionsResults({}); + + if (!_.isEmpty(suggestions)) { + normalizeSuggestionsResults(suggestions); + } + }, [JSON.stringify(suggestions)]); + + // User clicked on a result in the dropdown. + const handleResultSelect = ({result}) => navigate(result.location); + + const resultRenderer = ({type, title, description}) => { + if (type === 'tag') { + return ; + } + + if (description) { + return <> + {title} + {description} + + } + // No specific renderer, use the generic. + return {title} + }; + + return {suggestions, suggestionsResults, suggestionsSums, searchStr, handleResultSelect, resultRenderer, loading} +} + + +export function useSearchEstimate(searchStr, tagNames) { + const {data, sendRequest, loading} = useLatestRequest(250); + + // The estimates from the response. + const [estimates, setEstimates] = React.useState({}); + // The estimates summarized. + const [estimatesSums, setEstimatesSums] = React.useState({}); + + React.useEffect(() => { + // Handle results from `sendRequest`. + if (_.isEmpty(data)) { + return; + } + + setEstimates(data); + setEstimatesSums({ + fileGroups: data.fileGroups, + zims: data.zimsEstimates.reduce((i, j) => i + j['estimate'], 0), + }); + }, [JSON.stringify(data)]); + + React.useEffect(() => { + // Use the `useLatestRequest` to handle user typing. + sendRequest(async () => await searchEstimate(searchStr, tagNames)); + }, [searchStr, JSON.stringify(tagNames)], sendRequest); + + return {estimates, estimatesSums, loading} +} + export function SearchView() { - const {suggestionsSums, suggestions} = useContext(SearchSuggestionsContext); + const {searchStr, activeTags} = useSearch(); + const {estimates, estimatesSums} = useSearchEstimate(searchStr, activeTags); - let filesTabName = 'Files'; - let zimsTabName = 'Zims'; - if (!_.isEmpty(suggestionsSums)) { - filesTabName = `Files (${normalizeEstimate(suggestionsSums.fileGroups)})`; - zimsTabName = `Zims (${normalizeEstimate(suggestionsSums.zims)})`; + let filesTabName = Files ; + let zimsTabName = Zims; + if (!_.isEmpty(estimatesSums)) { + filesTabName = Files ; + zimsTabName = Zims ; } const links = [ @@ -244,7 +280,7 @@ export function SearchView() { }/> - }/> + }/> } diff --git a/modules/zim/lib.py b/modules/zim/lib.py index efa377cb..09e518b6 100644 --- a/modules/zim/lib.py +++ b/modules/zim/lib.py @@ -5,6 +5,7 @@ import re from datetime import datetime from typing import List, Tuple, Dict +import cachetools.func from libzim import Entry from sqlalchemy.orm import Session from sqlalchemy.orm.exc import NoResultFound # noqa @@ -208,6 +209,13 @@ def headline_zim(search_str: str, zim_id: int, tag_names: List[str] = None, offs return results +@cachetools.func.mru_cache(maxsize=1_000) +def get_estimates(search_str: str) -> List[int]: + zims = Zims.get_all() + estimates = [i.estimate(search_str) for i in zims] + return estimates + + @optional_session def get_zim(zim_id: int, session: Session = None) -> Zim: """Return the Zim record of the provided path. diff --git a/requirements.txt b/requirements.txt index eadaf584..676c5779 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,7 @@ beautifulsoup4==4.12.2 cachetools==5.3.0 ebooklib==0.18 feedparser==6.0.10 -libzim==3.1.0 +libzim==3.3.0.post0 mock==5.0.2 psutil==5.9.5 psycopg2-binary==2.9.6 diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py index 3771fb45..66d5be17 100644 --- a/wrolpi/root_api.py +++ b/wrolpi/root_api.py @@ -507,21 +507,43 @@ async def post_shutdown(_: Request): @api_bp.post('/search_suggestions') -@validate(schema.SearchSuggestionsRequest) +@validate(json=schema.SearchSuggestionsRequest) async def post_search_suggestions(_: Request, body: schema.SearchSuggestionsRequest): - """Used by the Global search to suggest links to the user.""" + """Used by the Global search to suggest related Channels/Domains/etc. to the user. + + @note: Does not handle Tags, unlike search_estimates""" 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, []), + 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, + ) + return json_response(ret) + + +@api_bp.post('/search_estimates') +@validate(json=schema.SearchEstimateRequest) +async def post_search_estimates(_: Request, body: schema.SearchEstimateRequest): + """Get an estimated count of FileGroups/Zims which may or may not have been tagged.""" from modules.zim.models import Zims if not body.search_str and not body.tag_names: return response.empty(HTTPStatus.BAD_REQUEST) - file_groups, channels, domains = await asyncio.gather( - estimate_search(body.search_str, body.tag_names), - search_channels_by_name(body.search_str), - search_domains_by_name(body.search_str), - ) + file_groups = await estimate_search(body.search_str, body.tag_names) if body.tag_names: # Get actual count of entries tagged with the tag names. @@ -544,9 +566,7 @@ async def post_search_suggestions(_: Request, body: schema.SearchSuggestionsRequ ret = dict( file_groups=file_groups, - zims_estimates=zims_estimates, - channels=channels, - domains=domains, + zims_estimates=zims_estimates ) return json_response(ret) diff --git a/wrolpi/schema.py b/wrolpi/schema.py index 0655f384..99aab86f 100644 --- a/wrolpi/schema.py +++ b/wrolpi/schema.py @@ -138,12 +138,11 @@ class VINDecoderResponse: @dataclass -class SearchEstimateRequest: +class SearchSuggestionsRequest: search_str: Optional[str] = None - tag_names: List[str] = field(default_factory=lambda: list()) @dataclass -class SearchSuggestionsRequest: +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 f96fc46a..2abb484b 100644 --- a/wrolpi/test/test_root_api.py +++ b/wrolpi/test/test_root_api.py @@ -470,6 +470,7 @@ 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): + # WARNING results are cached, this test uses unique queries to avoid conflicts. channel_factory(name='Foo') channel_factory(name='Fool') channel_factory(name='Bar') @@ -489,7 +490,7 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto 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]['estimate'] == 0 + assert response.json['zims_estimates'][0] == 0 body = dict(search_str='bar') request, response = await test_async_client.post('/api/search_suggestions', json=body) @@ -497,7 +498,7 @@ 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]['estimate'] == 0 + assert response.json['zims_estimates'][0] == 0 body = dict(search_str='one') request, response = await test_async_client.post('/api/search_suggestions', json=body) @@ -505,6 +506,21 @@ 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 + + +@pytest.mark.asyncio +async def test_search_estimates(test_session, test_async_client, archive_factory, zim_factory): + 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='one') + request, response = await test_async_client.post('/api/search_estimates', json=body) + assert response.status_code == HTTPStatus.OK + assert response.json['file_groups'] == 0 + assert len(response.json['zims_estimates']) == 1 assert response.json['zims_estimates'][0]['estimate'] == 2 diff --git a/wrolpi/version.txt b/wrolpi/version.txt index 39e8174c..c2191180 100644 --- a/wrolpi/version.txt +++ b/wrolpi/version.txt @@ -1 +1 @@ -0.12.2-beta \ No newline at end of file +0.12.3-beta \ No newline at end of file