Add an import plugin system, with the Overpass POI query as its first plugin
CI / check (push) Successful in 40s
CI / deploy (push) Successful in 13s

A plugin is a directory under plugins/ with a plugin.json manifest
({"label": "...", "command": "./run.sh"}) plus a script that reads
--south/--west/--north/--east and prints a GeoJSON FeatureCollection
to stdout (lib/maptool/plugins.rb) — that's the entire interface a
plugin needs to know about this app. The sidebar's new import icon
(logged in only, GET /api/plugins + POST /api/plugins/:name/run) lists
whatever's found and runs one for the current map viewport, saving its
output as a new .geojson file the same way an upload would be.

The point: a scraper for a site whose ToS forbids that (Park4Night,
iOverlander, Freecampsites) doesn't need to live in this public repo
to work here — its own directory just goes under plugins/ locally and
is never git add'ed, no special-casing needed anywhere.

plugins/overpass-pois wraps the existing rake overpass:pois query as
the first, in-repo, reference example of the contract.

Also fills in four modules (auth.js, locate.js, measure.js, notes.js)
missing from layout.erb's cache-busting importmap alongside adding
plugins.js's own entry — a pre-existing gap that meant they could keep
serving a stale cached copy across a deploy despite the importmap
existing specifically to prevent that.
This commit is contained in:
randy committed 2026-09-09 16:23:31 +02:00
1 parent 2b49eda7cb
commit 72ffd0ef18
17 files changed
+434 -14

No files matched your search

+7 -6
View File
@@ -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
+65
View File
@@ -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