From c726654de96e338254be71409ccf7bcd55afde2a Mon Sep 17 00:00:00 2001 From: Roland Date: Sat, 11 Apr 2026 20:09:30 -0600 Subject: [PATCH] Map search added to global search. Preview cards are displayed for all results in new Map tab. Map viewer has simple search. Map search databases can be downloaded from CDN, or built locally. Map searches can be built offline, but will not have enriched things like state names downloaded from Wikidata. --- README.md | 2 +- app/src/Events.js | 8 + app/src/api.js | 38 ++++ app/src/components/Map.js | 26 ++- app/src/components/MapSearchView.js | 133 ++++++++++++ app/src/components/MapViewer.js | 192 ++++++++++++++++- app/src/components/Search.js | 29 ++- controller/templates/index.html | 20 +- docker/api/Dockerfile | 9 +- modules/map/api.py | 62 +++++- modules/map/downloader.py | 115 +++++++++- modules/map/lib.py | 11 +- modules/map/search.py | 295 +++++++++++++++++++++++++ modules/map/static/embed.html | 66 ++++++ modules/map/static/index.html | 218 ++++++++++++++++--- modules/map/test/test_search.py | 166 ++++++++++++++ scripts/install_protomaps.sh | 27 +++ wrolpi/events.py | 12 ++ wrolpi/flags.py | 4 + wrolpi/scripts/build_map_regions.py | 139 ++++++++++++ wrolpi/scripts/build_map_search.py | 321 ++++++++++++++++++++++++++++ 21 files changed, 1831 insertions(+), 62 deletions(-) create mode 100644 app/src/components/MapSearchView.js create mode 100644 modules/map/search.py create mode 100644 modules/map/static/embed.html create mode 100644 modules/map/test/test_search.py create mode 100755 wrolpi/scripts/build_map_search.py diff --git a/README.md b/README.md index bd2f80e8..9b16faa0 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ network so that any user with a laptop/tablet/phone can connect and use the libr | Web Archives | yes | yes | yes | [Singlefile](https://github.com/gildas-lormeau/SingleFile) | | Wikipedia | yes | yes | yes | [Kiwix](https://www.kiwix.org) | | eBooks | yes | yes | planned | EPUB/PDF | -| Map | yes | planned | yes | [Protomaps](https://protomaps.com/) | +| Map | yes | yes | yes | [Protomaps](https://protomaps.com/) | | Podcasts/Audio | yes | planned | planned | | # Demo diff --git a/app/src/Events.js b/app/src/Events.js index fc9990bf..be4b6795 100644 --- a/app/src/Events.js +++ b/app/src/Events.js @@ -54,6 +54,14 @@ function handleEvents(events) { eventToast('Map import failed', message, 'error'); } + if (event === 'map_search_complete') { + eventToast('Map Search Index', message, 'success'); + } + + if (event === 'map_search_failed') { + eventToast('Map Search Index Failed', message, 'error'); + } + if (event === 'user_notify_message') { console.log(message, url); eventToast( diff --git a/app/src/api.js b/app/src/api.js index a0e87203..cb795d77 100644 --- a/app/src/api.js +++ b/app/src/api.js @@ -1580,6 +1580,44 @@ export async function setMapDefaultLocation(lat, lon, zoom) { } } +export async function searchMap(query, limit = 12, offset = 0, lat = null, lon = null) { + let url = `${API_URI}/map/search?q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`; + if (lat !== null && lon !== null) { + url += `&lat=${lat}&lon=${lon}`; + } + const response = await apiGet(url); + if (!response.ok) { + return {results: [], total: 0}; + } + return await response.json(); +} + +export async function getMapSearchStatus() { + const response = await apiGet(`${API_URI}/map/search/status`); + if (!response.ok) { + return {indexed: [], missing: []}; + } + return await response.json(); +} + +export async function searchEstimateMap(search_str) { + if (!search_str) return {mapPlaces: 0}; + const response = await apiGet(`${API_URI}/map/search/estimate?q=${encodeURIComponent(search_str)}`); + if (!response.ok) return {mapPlaces: 0}; + const data = await response.json(); + return {mapPlaces: data.map_places || 0}; +} + +export async function rebuildMapSearchIndex(filename) { + const response = await apiPost(`${API_URI}/map/search/rebuild/${encodeURIComponent(filename)}`); + if (!response.ok) { + const message = await getErrorMessage(response, 'Could not rebuild search index'); + toast({type: 'error', title: 'Map Error', description: message, time: 5000}); + } else { + toast({type: 'success', title: 'Search Index', description: 'Search index build started', time: 3000}); + } +} + export async function clearCompletedDownloads() { const response = await apiPost(`${API_URI}/download/clear_completed`); if (!response.ok) { diff --git a/app/src/components/Map.js b/app/src/components/Map.js index 20b02f18..8bbc10de 100644 --- a/app/src/components/Map.js +++ b/app/src/components/Map.js @@ -13,7 +13,7 @@ import { import {Route, Routes, useNavigate} from "react-router"; import { deleteMapFile, deleteMapPin, fetchMapSubscriptions, getMapFiles, getMapPins, - mapSubscribe, mapUnsubscribe, updateMapPin + mapSubscribe, mapUnsubscribe, rebuildMapSearchIndex, updateMapPin } from "../api"; import { Button, @@ -25,7 +25,7 @@ import { import {SortableTable} from "./SortableTable"; import Message from "semantic-ui-react/dist/commonjs/collections/Message"; import {Header} from "./Theme"; -import {Media} from "../contexts/contexts"; +import {Media, StatusContext} from "../contexts/contexts"; import {MAP_VIEWER_URI} from "./Vars"; import {Modal} from "./Theme"; import MapViewer from "./MapViewer"; @@ -265,6 +265,8 @@ function ManageMap() { const [catalog, setCatalog] = useState([]); const [subscriptions, setSubscriptions] = useState([]); const [allRegionsOpen, setAllRegionsOpen] = useState(false); + const {status} = React.useContext(StatusContext); + const searchBuilding = status?.flags?.map_search_building; const fetchFiles = async () => { try { @@ -306,13 +308,31 @@ function ManageMap() { const fileHeaders = [ {key: 'name', text: 'File', sortBy: i => i.name.toLowerCase()}, {key: 'size', text: 'Size', sortBy: i => i.size || 0}, + {key: 'search', text: 'Search', sortBy: null}, {key: 'actions', text: '', sortBy: null}, ]; + const handleBuildSearch = async (filename) => { + await rebuildMapSearchIndex(filename); + await fetchFiles(); + }; + const fileRowFunc = (file) => { return {file.name} {humanFileSize(file.size)} + + {file.name.startsWith('terrain') + ? null + : file.has_search_index + ?