diff --git a/TODO.adoc b/TODO.adoc
index fb207ff..ceb6b6e 100644
--- a/TODO.adoc
+++ b/TODO.adoc
@@ -12,8 +12,9 @@ independence from connectivity.
* [x] Shaded relief / DEM layer — hillshade toggle in the sidebar, sourced from public Terrarium tiles online; reads `data/terrain.pmtiles` for offline if one's been generated (see docs/development.adoc), otherwise the toggle is online-only for now
** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] 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
+* [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. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query)
+* [x] Import plugins: a pluggable way to add more data sources (Park4Night, iOverlander, Freecampsites, ...) without their scrapers necessarily living in this (public) repo — sidebar icon (logged in only) lists whatever's under `plugins/`, "Run for this area" uses the current map viewport as the bbox, no separate area-picker needed. Contract: a `plugin.json` manifest + a script that reads `--south`/`--west`/`--north`/`--east` and prints a GeoJSON `FeatureCollection` to stdout (`lib/maptool/plugins.rb`, `GET /api/plugins`, `POST /api/plugins/:name/run`) — output saved the same way an upload would be. `plugins/overpass-pois` ships as the reference example; a private/ToS-sensitive scraper just drops its own directory in locally and is never `git add`ed
+** [ ] Park4Night / iOverlander / Freecampsites data itself — no plugin written yet for any of these, the infra above just makes adding one (in a private repo if needed) straightforward whenever that happens
* [x] Kiwix/ZIM offline Wikipedia linked to POI GPS coordinates — a "Nearby on Wikipedia" popup link to kiwix-serve's own geo search (`GET /kiwix/search?latitude=&longitude=&distance=`), proxied through this app (`GET /kiwix/*` in app.rb) rather than a direct link to kiwix-serve — production sits behind a reverse proxy that only forwards to this app, so no address exists for a browser to reach kiwix-serve at directly; kiwix-serve runs with `--urlRootLocation=/kiwix` so its own emitted links already match. `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)
* [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 e4e48e7..2a7a216 100644
--- a/app.rb
+++ b/app.rb
@@ -11,6 +11,7 @@ require_relative "lib/maptool/gpsd_client"
require_relative "lib/maptool/livetrack_store"
require_relative "lib/maptool/livetrack_client"
require_relative "lib/maptool/kiwix"
+require_relative "lib/maptool/plugins"
module Maptool
class App < Sinatra::Base
@@ -63,6 +64,14 @@ module Maptool
GPX_STORE = GpxStore.new(File.join(DATA_DIR, "gpx"))
LIVETRACK_STORE = LiveTrackStore.new(File.join(DATA_DIR, "livetrack.json"))
+ # Not under DATA_DIR: unlike everything else there (uploads, PMTiles,
+ # the kiwix ZIM), a plugin is code, not local data — plugins/overpass-pois
+ # ships checked into this repo as the reference example of the
+ # contract. A private/scraper plugin just lives in its own directory
+ # here too, simply never `git add`ed, no special-casing needed.
+ # Overridable so tests can point it at a throwaway fixture directory.
+ PLUGINS_DIR = ENV.fetch("MAPTOOL_PLUGINS_DIR") { File.join(__dir__, "plugins") }
+
# Shared secret for the Garmin LiveTrack webhook (a Google Apps Script
# watching Gmail for LiveTrack emails, see docs/deployment.adoc) — not a
# Gitea-authenticated user, so it can't use the OAuth2 login flow above.
@@ -329,6 +338,34 @@ module Maptool
json ok: true
end
+ # Lists whatever import plugins are on disk (see lib/maptool/plugins.rb
+ # for the contract) — read-only, doesn't need require_login! the way
+ # actually running one does below.
+ get "/api/plugins" do
+ json Plugins.list(PLUGINS_DIR)
+ end
+
+ # Runs one plugin for the given bbox (the current map viewport, from
+ # the frontend's "Run for this area" button) and saves its GeoJSON
+ # output as a new file the same way an uploaded .geojson would be
+ # saved — reusing GpxStore#save rather than #append_feature since this
+ # writes a whole FeatureCollection at once, not one feature into an
+ # existing one.
+ post "/api/plugins/:name/run" do
+ require_login!
+ bbox = %w[south west north east].map { |key| params[key].to_s }
+ halt 400, json(error: "south, west, north, east are required") if bbox.any?(&:empty?)
+
+ begin
+ geojson = Plugins.run(PLUGINS_DIR, params[:name], bbox.map(&:to_f))
+ rescue Plugins::RunError => e
+ halt 502, json(error: e.message)
+ end
+
+ path = GPX_STORE.save(folder: "", filename: "#{params[:name]}.geojson", io: StringIO.new(JSON.generate(geojson)))
+ json path: path, tree: GPX_STORE.tree
+ end
+
# ?download=geojson exports the file's GeoJSON as an attachment instead
# of rendering it on the map — the only way to get a .gpx/.fit's
# converted form out of the app, since GpxStore#geojson's cache is
diff --git a/docs/development.adoc b/docs/development.adoc
index b2e2e1f..0cce540 100644
--- a/docs/development.adoc
+++ b/docs/development.adoc
@@ -65,12 +65,31 @@ https://github.com/protomaps/go-pmtiles[go-pmtiles], same as
https://overpass-api.de[Overpass] for drinking water, dump stations,
LPG refill, public toilets, laundromats, supermarkets/convenience
stores, and springs within a bbox, and writes the result as a
-`.geojson` file (`lib/maptool/overpass_pois.rb`). A dev-time tool, not
-something the running app calls itself — drop the output into
-`data/gpx/` (or upload it through the Tracks panel) like any other
-`.geojson` layer once it's generated. Nodes only (nothing that's
-mapped as a way/relation, e.g. a supermarket traced as a building
-outline rather than a single point).
+`.geojson` file (`lib/maptool/overpass_pois.rb`). Nodes only (nothing
+that's mapped as a way/relation, e.g. a supermarket traced as a
+building outline rather than a single point). Also reachable from
+inside the running app itself, without the command line — see Import
+plugins below, which wraps this exact query as `plugins/overpass-pois`.
+
+== Import plugins
+
+The sidebar's import icon (logged in only) lists whatever's under
+`plugins/` and runs one for the current map viewport with a click — no
+area-picker to fill in, the visible area *is* the area. Each plugin is
+a directory with a `plugin.json` manifest (`{"label": "...", "command":
+"./run.sh"}`) plus whatever script that command names; the entire
+interface a plugin needs to know about maptool is "read
+`--south`/`--west`/`--north`/`--east`, print a GeoJSON
+`FeatureCollection` to stdout" (`lib/maptool/plugins.rb`) — the result
+is saved as a new `.geojson` file the same way an upload would be.
+
+`plugins/overpass-pois` ships as the reference example (a thin wrapper
+around the `rake overpass:pois` logic above). A plugin that scrapes a
+site whose terms of service forbid that — Park4Night, iOverlander,
+Freecampsites — doesn't have to live in this (public) repo to work
+here: drop its directory under `plugins/` locally and just don't
+`git add` it, same as any other local-only file this app already
+treats that way (`data/offline.pmtiles`, the kiwix ZIM, ...).
== Offline Wikipedia (kiwix-serve)
diff --git a/lib/maptool/overpass_pois.rb b/lib/maptool/overpass_pois.rb
index 636d973..740096e 100644
--- a/lib/maptool/overpass_pois.rb
+++ b/lib/maptool/overpass_pois.rb
@@ -2,12 +2,13 @@ require "net/http"
require "json"
module Maptool
- # A dev-time tool (see the "overpass:pois" Rake task), not something the
- # deployed app calls itself — same "generate locally, drop the result in
- # data/gpx/" story as offline.pmtiles/terrain.pmtiles (docs/development.adoc),
- # kept off the running server so it never depends on Overpass's public
- # instance being up, or eats into its (fairly strict, shared) rate limits
- # on every page load.
+ # Originally a dev-time-only tool (the "overpass:pois" Rake task) kept
+ # off the running server so it never depended on Overpass's public
+ # instance being up, or ate into its (fairly strict, shared) rate limits,
+ # on every page load. plugins/overpass-pois now also calls this from the
+ # running app — but only ever on an explicit, login-gated "Run for this
+ # area" click (see lib/maptool/plugins.rb), never automatically, so that
+ # concern doesn't apply there the way it would if this ran on page load.
module OverpassPois
class FetchError < StandardError; end
diff --git a/lib/maptool/plugins.rb b/lib/maptool/plugins.rb
new file mode 100644
index 0000000..60662e2
--- /dev/null
+++ b/lib/maptool/plugins.rb
@@ -0,0 +1,65 @@
+require "json"
+require "open3"
+
+module Maptool
+ # Runs an external "importer" script that turns a bbox into a GeoJSON
+ # FeatureCollection, and lists what's available. Each plugin is a
+ # directory under PLUGINS_DIR with a plugin.json manifest ({"label":
+ # "...", "command": "./run.rb"}) plus whatever script it names — the
+ # entire interface a plugin needs to know about maptool is "read
+ # --south/--west/--north/--east, print GeoJSON to stdout". Kept this thin
+ # on purpose: it's what lets an importer that scrapes a site whose ToS
+ # forbids that live in its own private repo, dropped into plugins/
+ # locally, never touching this (public) one — see plugins/overpass-pois
+ # for a real, in-repo example of the contract itself.
+ module Plugins
+ class RunError < StandardError; end
+
+ # A bare directory name, same rule Sinatra's own :name route param
+ # already enforces (no "/"), but ".." alone would still resolve one
+ # level up via File.join — reject that too before it ever reaches the
+ # filesystem, same spirit as GpxStore's own path-escape guard.
+ NAME_PATTERN = /\A[A-Za-z0-9_-]+\z/
+
+ def self.list(dir)
+ Dir.glob(File.join(dir, "*", "plugin.json")).filter_map do |manifest_path|
+ name = File.basename(File.dirname(manifest_path))
+ manifest = JSON.parse(File.read(manifest_path))
+ { "name" => name, "label" => manifest["label"] || name }
+ rescue JSON::ParserError
+ nil
+ end.sort_by { |p| p["label"] }
+ end
+
+ # bbox: [south, west, north, east]. Raises RunError with a short
+ # message on anything that isn't a clean run — an unknown plugin, a
+ # non-zero exit, or output that isn't a GeoJSON FeatureCollection — so
+ # the route can turn it into a single error response without knowing
+ # any of these details itself.
+ def self.run(dir, name, bbox)
+ raise RunError, "unknown plugin" unless name =~ NAME_PATTERN
+
+ plugin_dir = File.join(dir, name)
+ manifest_path = File.join(plugin_dir, "plugin.json")
+ raise RunError, "unknown plugin" unless File.file?(manifest_path)
+
+ command = JSON.parse(File.read(manifest_path))["command"]
+ raise RunError, "plugin has no command" unless command
+
+ south, west, north, east = bbox
+ args = ["--south=#{south}", "--west=#{west}", "--north=#{north}", "--east=#{east}"]
+
+ stdout, _stderr, status = Open3.capture3(command, *args, chdir: plugin_dir)
+ raise RunError, "plugin exited with status #{status.exitstatus}" unless status.success?
+
+ geojson = JSON.parse(stdout)
+ raise RunError, "plugin did not print a GeoJSON FeatureCollection" unless geojson.is_a?(Hash) && geojson["type"] == "FeatureCollection"
+
+ geojson
+ rescue Errno::ENOENT, Errno::EACCES => e
+ raise RunError, e.message
+ rescue JSON::ParserError
+ raise RunError, "plugin did not print valid JSON"
+ end
+ end
+end
diff --git a/plugins/overpass-pois/plugin.json b/plugins/overpass-pois/plugin.json
new file mode 100644
index 0000000..13e3440
--- /dev/null
+++ b/plugins/overpass-pois/plugin.json
@@ -0,0 +1,4 @@
+{
+ "label": "Camper POIs (Overpass)",
+ "command": "./run.rb"
+}
diff --git a/plugins/overpass-pois/run.rb b/plugins/overpass-pois/run.rb
new file mode 100755
index 0000000..1c846ee
--- /dev/null
+++ b/plugins/overpass-pois/run.rb
@@ -0,0 +1,16 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# The reference example of the plugin contract (see lib/maptool/plugins.rb):
+# read --south/--west/--north/--east, print a GeoJSON FeatureCollection to
+# stdout. Thin wrapper around lib/maptool/overpass_pois.rb, which already
+# did all the real work for the "rake overpass:pois" dev-time task this
+# plugin now sits alongside as a second way to run the same query.
+
+require_relative "../../lib/maptool/overpass_pois"
+
+args = ARGV.to_h { |arg| arg.sub(/\A--/, "").split("=", 2) }
+bbox = %w[south west north east].map { |key| Float(args.fetch(key)) }
+
+response = Maptool::OverpassPois.fetch(bbox)
+puts JSON.generate(Maptool::OverpassPois.to_geojson(response))
diff --git a/public/css/style.css b/public/css/style.css
index 4422f68..13246a3 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -1439,3 +1439,60 @@ html, body {
.profile-panel__tooltip-label {
color: #c3c2b7;
}
+
+.plugins-panel__hint {
+ margin: 0 0 6px;
+ max-width: 200px;
+ color: #888;
+ font-size: 11px;
+}
+
+.plugins-panel__list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.plugins-panel__item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
+ white-space: nowrap;
+}
+
+.plugins-panel__label {
+ flex: 1;
+}
+
+.plugins-panel__run {
+ /* See .notes-panel__actions button's own comment — without this some
+ browsers paint their own hover/active highlight over a custom
+ background. */
+ appearance: none;
+ padding: 4px 10px;
+ border: none;
+ border-radius: 4px;
+ background: #1a5fb4;
+ color: #fff;
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.plugins-panel__run:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.plugins-panel__status {
+ min-width: 3.5em;
+ color: #888;
+ font-size: 11px;
+}
+
+.plugins-panel__empty {
+ color: #888;
+ white-space: normal;
+ max-width: 200px;
+}
diff --git a/public/js/icons.js b/public/js/icons.js
index d3d061d..1187ae6 100644
--- a/public/js/icons.js
+++ b/public/js/icons.js
@@ -56,6 +56,15 @@ export const UPLOAD_ICON = `