Add an import plugin system, with the Overpass POI query as its first plugin
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:
1 parent
2b49eda7cb
commit
72ffd0ef18
17 files changed
+434
-14
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user