From 24ac5931f829ec45f1ae3aa24234a9c4a624b891 Mon Sep 17 00:00:00 2001 From: Roman Priesol Date: Wed, 9 Sep 2026 13:44:37 +0200 Subject: [PATCH] Link POI popups to offline Wikipedia via kiwix-serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/maptool/kiwix.rb: a small reachability check for an external kiwix-serve instance. GET /api/kiwix reports it (same optional- integration pattern as offline-tiles/terrain-tiles); the frontend adds a "Nearby on Wikipedia" link to a point's popup pointing straight at kiwix-serve's own geo search (GET /search?latitude=&longitude= &distance=, confirmed from libkiwix's actual source rather than guessed) — linked to directly, not proxied/embedded, since it already has a full search UI of its own. docker-compose.yaml: new `kiwix` service (ghcr.io/kiwix/kiwix-serve) on the default network, KIWIX_URL wired to reach it by service name. The ZIM itself isn't part of the repo (large, self-contained archive) — data/kiwix/ gitignored same as the *.pmtiles files, setup steps in docs/development.adoc. No kiwix-serve/ZIM available here to test end-to-end — verified the app side against a stand-in local HTTP server (GET /api/kiwix flips available correctly) and confirmed the generated popup link matches the real geo-search query shape. docker-compose.yaml validated with `docker-compose config`. Full test suite: 118 runs, 0 failures. --- .gitignore | 5 +++++ TODO.adoc | 2 +- app.rb | 14 ++++++++++++++ data/kiwix/.gitkeep | 0 docker-compose.yaml | 23 +++++++++++++++++++++++ docs/deployment.adoc | 8 +++++--- docs/development.adoc | 26 ++++++++++++++++++++++++++ lib/maptool/kiwix.rb | 21 +++++++++++++++++++++ public/css/style.css | 5 +++++ public/js/gpx.js | 25 +++++++++++++++++++++---- public/js/map.js | 7 ++++++- test/test_app.rb | 5 +++++ test/test_kiwix.rb | 18 ++++++++++++++++++ 13 files changed, 150 insertions(+), 9 deletions(-) create mode 100644 data/kiwix/.gitkeep create mode 100644 lib/maptool/kiwix.rb create mode 100644 test/test_kiwix.rb diff --git a/.gitignore b/.gitignore index fe3e277..3008a4a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,11 @@ # see docs/development.adoc) /data/*.pmtiles +# Offline Wikipedia ZIM for kiwix-serve (also large, also local-only — +# same story, see docs/development.adoc) +/data/kiwix/* +!/data/kiwix/.gitkeep + # User-uploaded GPX files /data/gpx/* !/data/gpx/.gitkeep diff --git a/TODO.adoc b/TODO.adoc index 943ca2b..37dca45 100644 --- a/TODO.adoc +++ b/TODO.adoc @@ -14,7 +14,7 @@ independence from connectivity. * [ ] Contour lines from OSM for hikes and mountain passes * [x] Overpass/OSM scripts to extract POIs (drinking water, dump stations, LPG, public toilets, laundromats, shops, springs) — `rake overpass:pois`, see docs/development.adoc; writes a `.geojson` to drop in `data/gpx/` or upload, not called by the running app itself * [ ] Import Park4Night / iOverlander / Freecampsites data -* [ ] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates +* [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — a "Nearby on Wikipedia" popup link straight to kiwix-serve's own geo search (`GET /search?latitude=&longitude=&distance=`, confirmed from its actual source, `libkiwix/internalServer.cpp`), not proxied/embedded — it already has a full search UI. `KIWIX_URL` + `GET /api/kiwix` reachability check, same optional-integration pattern as offline.pmtiles/terrain.pmtiles; `docker-compose.yaml` now runs a `kiwix` service, ZIM not included (see docs/development.adoc for where to get one). Not live-tested against a real ZIM (none available here) — verified via a stand-in local HTTP server + real popup-link generation * [x] Cache weather forecast when temporarily connected — Open-Meteo 3-day forecast in the Radar flyout, last successful fetch cached in localStorage as a fallback when offline ** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb diff --git a/app.rb b/app.rb index d81b06a..e6f6f51 100644 --- a/app.rb +++ b/app.rb @@ -10,6 +10,7 @@ require_relative "lib/maptool/gpx_store" require_relative "lib/maptool/gpsd_client" require_relative "lib/maptool/livetrack_store" require_relative "lib/maptool/livetrack_client" +require_relative "lib/maptool/kiwix" module Maptool class App < Sinatra::Base @@ -69,6 +70,12 @@ module Maptool # OAuth2 secrets: fine outside production, nothing works without it set. LIVETRACK_WEBHOOK_SECRET = ENV.fetch("LIVETRACK_WEBHOOK_SECRET", "") + # An external kiwix-serve instance (see docs/development.adoc), not + # something this app runs itself — blank disables the "Nearby on + # Wikipedia" popup link the same way the other optional-integration + # blanks above do. + KIWIX_URL = ENV.fetch("KIWIX_URL", "") + # Login delegates to the Gitea instance the user already has an account # on, rather than maintaining a local username/password — see # docs/architecture.adoc. Only users listed in ALLOWED_GITEA_USERS may @@ -199,6 +206,13 @@ module Maptool send_file TERRAIN_PMTILES_PATH, type: "application/octet-stream" end + # Pinged once per page load (public/js/map.js) — url is handed back so + # the frontend can build the geo-search link itself; this server never + # proxies to kiwix-serve, just says whether it's there. + get "/api/kiwix" do + json available: Kiwix.reachable?(KIWIX_URL), url: KIWIX_URL + end + get "/api/gpx/tree" do json GPX_STORE.tree end diff --git a/data/kiwix/.gitkeep b/data/kiwix/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yaml b/docker-compose.yaml index e0432a9..a1ede78 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,6 +21,13 @@ services: # Optional: blank disables the LiveTrack webhook endpoint rather than # failing to start, since it's not needed for the app's core function. LIVETRACK_WEBHOOK_SECRET: ${LIVETRACK_WEBHOOK_SECRET:-} + # The kiwix service below, reached by its compose service name (both + # are on the same default compose network) — port matches its own + # --port=8080 in the command below. Not made conditional on the ZIM + # actually being present: GET /api/kiwix pings it and just reports + # unavailable if the container isn't up/reachable, same story as + # every other optional-local-file feature (offline.pmtiles, etc). + KIWIX_URL: "http://kiwix:8080" volumes: # Uploaded GPX files and the offline PMTiles extract — kept outside # the image so a rebuild/redeploy doesn't wipe them (see @@ -43,3 +50,19 @@ services: restart: unless-stopped network_mode: "service:maptool" command: ["ruby", "bin/gnss_simulator.rb"] + + # Serves an offline Wikipedia ZIM for the popup's "Nearby on Wikipedia" + # link (see docs/development.adoc for where to get one and drop it in — + # same "not part of the repo, place it under ./data locally" story as + # offline.pmtiles/terrain.pmtiles). On the default compose network (not + # network_mode: "service:maptool" — unlike gnss-simulator this doesn't + # need a hardcoded localhost port, KIWIX_URL above reaches it by this + # service's own name), so maptool and kiwix stay independent containers. + # Keeps restarting (harmlessly) if data/kiwix/wikipedia.zim isn't there + # yet — GET /api/kiwix just reports unavailable until it comes up. + kiwix: + image: ghcr.io/kiwix/kiwix-serve:latest + restart: unless-stopped + command: ["--port=8080", "/data/wikipedia.zim"] + volumes: + - ./data/kiwix:/data diff --git a/docs/deployment.adoc b/docs/deployment.adoc index c90e722..5ca49d1 100644 --- a/docs/deployment.adoc +++ b/docs/deployment.adoc @@ -35,9 +35,11 @@ expect this to flip the other way, `/srv/docker` symlinked to below) — `Dockerfile`, `docker-compose.yaml`, application code. `podman compose` reads the same `docker-compose.yaml` — no Podman-specific compose file needed. -* `data/`, *not* part of that sync — uploaded GPX files and - `offline.pmtiles`. Persisted via a bind mount (`./data:/app/data` in - `docker-compose.yaml`), so a redeploy never touches it. +* `data/`, *not* part of that sync — uploaded GPX files, + `offline.pmtiles`, `terrain.pmtiles`, and `kiwix/wikipedia.zim` (see + `docs/development.adoc` for each). Persisted via a bind mount + (`./data:/app/data` in `docker-compose.yaml`), so a redeploy never + touches it. == The native deploy runner diff --git a/docs/development.adoc b/docs/development.adoc index 3e88afe..7dce4ac 100644 --- a/docs/development.adoc +++ b/docs/development.adoc @@ -72,6 +72,32 @@ something the running app calls itself — drop the output into mapped as a way/relation, e.g. a supermarket traced as a building outline rather than a single point). +== Offline Wikipedia (kiwix-serve) + +A point popup's "Nearby on Wikipedia" link needs +https://github.com/kiwix/kiwix-tools[kiwix-serve] running with a +Wikipedia https://library.kiwix.org[ZIM] loaded — set `KIWIX_URL` to +wherever it's reachable (`docker-compose.yaml` already runs a `kiwix` +service pointed at `data/kiwix/wikipedia.zim` and wires `KIWIX_URL` to +it; blank/unset disables the link, same as every other optional +integration here). Not part of the repo — a ZIM is a large, +self-contained archive, obtain one locally: + +* https://library.kiwix.org/#lang=eng&category=wikipedia[Kiwix's own + library] has pre-built Wikipedia ZIMs — a `_maxi` variant per + language/region includes images, `_nopic` is much smaller if + browsing images offline doesn't matter here. +* Download it to `data/kiwix/wikipedia.zim` (the exact filename + `docker-compose.yaml`'s `kiwix` service expects — rename after + downloading, Kiwix's own filenames are much more specific). + +Once it's there, `docker compose up -d kiwix` (or a full restart) +picks it up — `GET /api/kiwix` reports `available: true` and the +"Nearby on Wikipedia" link appears. kiwix-serve's own geo search +(`GET /search?pattern=&latitude=&longitude=&distance=`, +`lib/maptool/kiwix.rb`'s docstring) is linked to directly, not +proxied/embedded — it already has a full search UI of its own. + == GNSS position (gpsd) The "GNSS" control needs https://gpsd.io[gpsd] running and pointed at diff --git a/lib/maptool/kiwix.rb b/lib/maptool/kiwix.rb new file mode 100644 index 0000000..0edb26a --- /dev/null +++ b/lib/maptool/kiwix.rb @@ -0,0 +1,21 @@ +require "net/http" + +module Maptool + # A thin reachability check for an external kiwix-serve instance serving + # an offline Wikipedia ZIM (see docs/development.adoc for how to set one + # up) — the app itself never talks to it beyond this ping. Linking a POI + # popup to a geo search there (public/js/gpx.js) is a plain browser straight to kiwix-serve, not something this server proxies — + # kiwix-serve already has its own full search UI, no reason to rebuild it. + module Kiwix + def self.reachable?(url, open_timeout: 1, read_timeout: 2) + return false if url.to_s.strip.empty? + + uri = URI(url) + Net::HTTP.start(uri.host, uri.port, open_timeout: open_timeout, read_timeout: read_timeout) { |http| http.head("/") } + true + rescue StandardError + false + end + end +end diff --git a/public/css/style.css b/public/css/style.css index 3f3ca3f..55a945a 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1008,6 +1008,11 @@ html, body { font-size: 12px; } +.gpx-popup__wiki { + margin-top: 6px; + font-size: 12px; +} + .gpx-popup__category { color: #888; font-size: 12px; diff --git a/public/js/gpx.js b/public/js/gpx.js index 9c288b1..8560458 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -702,7 +702,7 @@ export class GpxPanelControl { const editable = feature.properties.category === "Note" && !!feature.properties.id && isLoggedIn(); const popup = new Popup({ closeButton: true, maxWidth: "260px" }) .setLngLat(event.lngLat) - .setHTML(popupHtml(feature.properties, { editable })) + .setHTML(popupHtml(feature.properties, { editable, lngLat: event.lngLat })) .addTo(this._map); if (editable) this._wireNoteActions(popup, path, feature); }); @@ -754,7 +754,7 @@ export class GpxPanelControl { const bodyInput = content.querySelector(".gpx-popup__edit-body"); titleInput.focus(); content.querySelector(".gpx-popup__edit-cancel").addEventListener("click", () => { - content.innerHTML = popupHtml(feature.properties, { editable: true }); + content.innerHTML = popupHtml(feature.properties, { editable: true, lngLat: popup.getLngLat() }); this._wireNoteActions(popup, path, feature); }); content.querySelector(".gpx-popup__edit-save").addEventListener("click", async () => { @@ -1077,13 +1077,26 @@ function gpxFileUrl(path) { return `/api/gpx/file/${path.split("/").map(encodeURIComponent).join("/")}`; } +// Set once from map.js after its one-time GET /api/kiwix check (same +// fetch-once-at-load story as the offline-tiles/terrain-tiles checks) — +// module-level rather than threaded through every popupHtml call, since +// it's the same answer for the whole page's lifetime. +let kiwixConfig = { available: false, url: "" }; + +export function setKiwixConfig(config) { + kiwixConfig = config; +} + // properties come straight from an uploaded GPX file — untrusted input — // so every value is escaped before landing in the popup's HTML. // editable (only ever true for a "Note" feature the viewer is logged in // for — see _registerPointInteractivity) adds Edit/Delete buttons; the // click handling for them lives in _wireNoteActions, not here, since this -// only builds markup, it has no map/store access of its own. -function popupHtml({ name, description, time, category, photos, icon }, { editable = false } = {}) { +// only builds markup, it has no map/store access of its own. lngLat (the +// popup's own anchor point) drives the "Nearby on Wikipedia" link — kiwix- +// serve's own geo search, linked to directly rather than proxied/embedded +// (it already has a full search UI of its own, see docs/development.adoc). +function popupHtml({ name, description, time, category, photos, icon }, { editable = false, lngLat = null } = {}) { const parts = []; if (name) { const glyph = icon && POI_ICONS[icon] ? `${POI_ICONS[icon]}` : ""; @@ -1104,6 +1117,10 @@ function popupHtml({ name, description, time, category, photos, icon }, { editab parts.push(`

${escapeHtml(parsed.toLocaleString())}

`); } } + if (kiwixConfig.available && lngLat) { + const wikiUrl = `${kiwixConfig.url}/search?pattern=&latitude=${lngLat.lat}&longitude=${lngLat.lng}&distance=2000`; + parts.push(`

Nearby on Wikipedia →

`); + } if (editable) { parts.push( `
`, diff --git a/public/js/map.js b/public/js/map.js index d3f6c39..2ac3239 100644 --- a/public/js/map.js +++ b/public/js/map.js @@ -5,7 +5,7 @@ import { addProtocol, } from "/vendor/maplibre-gl/maplibre-gl.mjs"; import { RadarControl } from "/js/radar.js"; -import { GpxPanelControl } from "/js/gpx.js"; +import { GpxPanelControl, setKiwixConfig } from "/js/gpx.js"; import { GnssControl } from "/js/gnss.js"; import { LiveTrackPanelControl } from "/js/livetrack.js"; import { TrackProfilePanel } from "/js/track_profile.js"; @@ -609,6 +609,11 @@ fetch("/api/terrain-tiles") .then(({ available }) => hillshadeControl.setOfflineAvailable(available)) .catch(() => {}); +fetch("/api/kiwix") + .then((response) => response.json()) + .then(setKiwixConfig) + .catch(() => {}); + // Alt+click anywhere shows every rendered feature at that point — base map // (OSM tags via OpenFreeMap/protomaps), GPX/FIT layers, radar, all of it — // with its raw properties, inspired by NPMap4's infoboxControl (a generic diff --git a/test/test_app.rb b/test/test_app.rb index a13a229..558b08b 100644 --- a/test/test_app.rb +++ b/test/test_app.rb @@ -23,6 +23,11 @@ class AppTest < Minitest::Test assert_equal({ "available" => false }, json_body(response)) end + def test_kiwix_reports_unavailable_when_no_url_configured + response = request.get("/api/kiwix") + assert_equal({ "available" => false, "url" => "" }, json_body(response)) + end + def test_gnss_position_unavailable_with_no_fix response = request.get("/api/gnss/position", **logged_in_env) assert_equal({ "available" => false }, json_body(response)) diff --git a/test/test_kiwix.rb b/test/test_kiwix.rb new file mode 100644 index 0000000..bb5ea52 --- /dev/null +++ b/test/test_kiwix.rb @@ -0,0 +1,18 @@ +require_relative "test_helper" +require "maptool/kiwix" + +# No real network calls — a blank URL and a guaranteed-local connection +# refusal are both fully offline/deterministic; simulating a genuine +# reachable kiwix-serve would need spinning up a real HTTP server for what +# is, at heart, a two-line Net::HTTP wrapper — not worth it, same +# restraint LiveTrackClientTest already applies to its own network path. +class KiwixTest < Minitest::Test + def test_reachable_is_false_for_a_blank_url + refute Maptool::Kiwix.reachable?("") + refute Maptool::Kiwix.reachable?(nil) + end + + def test_reachable_is_false_when_nothing_is_listening + refute Maptool::Kiwix.reachable?("http://127.0.0.1:1", open_timeout: 1) + end +end