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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+25
-6
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Camper POIs (Overpass)",
|
||||
"command": "./run.rb"
|
||||
}
|
||||
Executable
+16
@@ -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))
|
||||
@@ -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;
|
||||
}
|
||||
@@ -56,6 +56,15 @@ export const UPLOAD_ICON = `
|
||||
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
|
||||
</svg>`;
|
||||
|
||||
// The inverse of UPLOAD_ICON (arrow into the tray, not out of it) — used
|
||||
// for the import plugins panel (plugins.js), which pulls data in from
|
||||
// somewhere else rather than sending a file out.
|
||||
export const PLUGIN_ICON = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 4v11M8 11l4 4 4-4"/>
|
||||
<path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/>
|
||||
</svg>`;
|
||||
|
||||
// Shown next to each file in the GPX tree, indicating whether it has
|
||||
// waypoints, a track, or both.
|
||||
export const WAYPOINT_ICON = `
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LiveTrackPanelControl } from "/js/livetrack.js";
|
||||
import { TrackProfilePanel } from "/js/track_profile.js";
|
||||
import { MeasureControl } from "/js/measure.js";
|
||||
import { NotesControl } from "/js/notes.js";
|
||||
import { PluginsControl } from "/js/plugins.js";
|
||||
import { LocateControl } from "/js/locate.js";
|
||||
import { LAYERS_ICON, HILLSHADE_ICON } from "/js/icons.js";
|
||||
import { notifySidePanelOpen, onlyOneSidePanelOpen } from "/js/panels.js";
|
||||
@@ -557,6 +558,9 @@ sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title=
|
||||
new GpxPanelControl(map, profilePanel).mount(sidebar);
|
||||
const hillshadeControl = new HillshadeControl(map);
|
||||
hillshadeControl.mount(sidebar);
|
||||
// Running a plugin needs a login (POST /api/plugins/:name/run) — same
|
||||
// "hide the write-only affordance" story as NotesControl above.
|
||||
if (isLoggedIn()) new PluginsControl(map).mount(sidebar);
|
||||
|
||||
sidebar.insertAdjacentHTML("beforeend", `<div class="sidebar-group-label" title="Offline hub"></div>`);
|
||||
layerToggle.mount(sidebar);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { PLUGIN_ICON } from "/js/icons.js";
|
||||
import { notifyGpxStoreChanged } from "/js/panels.js";
|
||||
|
||||
// A sidebar flyout listing whatever import plugins are on disk (see
|
||||
// lib/maptool/plugins.rb for the contract) — each one just a "Run for
|
||||
// this area" button using the current map viewport as the bbox, so there's
|
||||
// no separate area-picker to build: pan/zoom to where you want, click Run.
|
||||
// Only ever mounted when logged in (see map.js) — same "hide the
|
||||
// write-only affordance" story as NotesControl, since the only thing this
|
||||
// panel does is a login-gated write.
|
||||
export class PluginsControl {
|
||||
constructor(map) {
|
||||
this._map = map;
|
||||
}
|
||||
|
||||
mount(sidebar) {
|
||||
this._container = document.createElement("div");
|
||||
this._container.className = "sidebar-item";
|
||||
this._container.innerHTML = `
|
||||
<button type="button" class="sidebar-icon" title="Import plugins" aria-label="Import plugins">${PLUGIN_ICON}</button>
|
||||
<div class="sidebar-flyout" hidden>
|
||||
<p class="plugins-panel__hint">Runs for whatever area is currently on screen.</p>
|
||||
<ul class="plugins-panel__list"></ul>
|
||||
</div>
|
||||
`;
|
||||
this._icon = this._container.querySelector(".sidebar-icon");
|
||||
this._flyout = this._container.querySelector(".sidebar-flyout");
|
||||
this._list = this._container.querySelector(".plugins-panel__list");
|
||||
|
||||
this._icon.addEventListener("click", () => {
|
||||
const opening = this._flyout.hidden;
|
||||
this._flyout.hidden = !opening;
|
||||
this._icon.classList.toggle("active", opening);
|
||||
if (opening) this._loadPlugins();
|
||||
});
|
||||
|
||||
sidebar.appendChild(this._container);
|
||||
}
|
||||
|
||||
async _loadPlugins() {
|
||||
this._list.innerHTML = `<li class="plugins-panel__empty">Loading…</li>`;
|
||||
let plugins;
|
||||
try {
|
||||
plugins = await (await fetch("/api/plugins")).json();
|
||||
} catch (err) {
|
||||
this._list.innerHTML = `<li class="plugins-panel__empty">Could not load plugins</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (plugins.length === 0) {
|
||||
this._list.innerHTML = `<li class="plugins-panel__empty">No plugins installed — see docs/development.adoc</li>`;
|
||||
return;
|
||||
}
|
||||
this._list.innerHTML = "";
|
||||
plugins.forEach((plugin) => this._list.appendChild(this._renderItem(plugin)));
|
||||
}
|
||||
|
||||
_renderItem(plugin) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "plugins-panel__item";
|
||||
li.innerHTML = `
|
||||
<span class="plugins-panel__label"></span>
|
||||
<button type="button" class="plugins-panel__run">Run for this area</button>
|
||||
<span class="plugins-panel__status"></span>
|
||||
`;
|
||||
li.querySelector(".plugins-panel__label").textContent = plugin.label;
|
||||
|
||||
const button = li.querySelector(".plugins-panel__run");
|
||||
const status = li.querySelector(".plugins-panel__status");
|
||||
button.addEventListener("click", () => this._run(plugin, button, status));
|
||||
return li;
|
||||
}
|
||||
|
||||
async _run(plugin, button, status) {
|
||||
button.disabled = true;
|
||||
status.textContent = "Running…";
|
||||
try {
|
||||
const bounds = this._map.getBounds();
|
||||
const body = new URLSearchParams({
|
||||
south: bounds.getSouth(),
|
||||
west: bounds.getWest(),
|
||||
north: bounds.getNorth(),
|
||||
east: bounds.getEast(),
|
||||
});
|
||||
const response = await fetch(`/api/plugins/${encodeURIComponent(plugin.name)}/run`, { method: "POST", body });
|
||||
if (!response.ok) throw new Error(`run failed: ${response.status}`);
|
||||
const { path } = await response.json();
|
||||
notifyGpxStoreChanged(path);
|
||||
status.textContent = "Done";
|
||||
} catch (err) {
|
||||
status.textContent = "Failed — see server log";
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"label": "Test plugin",
|
||||
"command": "./run.sh"
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# A fixture plugin for test_app.rb's /api/plugins route tests — no
|
||||
# network, just echoes a single fixed point back as GeoJSON so those tests
|
||||
# stay deterministic and offline like the rest of the suite.
|
||||
echo '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"name":"Test POI"},"geometry":{"type":"Point","coordinates":[17.0,48.0]}}]}'
|
||||
@@ -49,6 +49,7 @@ class AppTest < Minitest::Test
|
||||
assert_equal 401, request.delete("/api/gpx/file/x.gpx").status
|
||||
assert_equal 401, request.delete("/api/gpx/folder/x").status
|
||||
assert_equal 401, request.post("/api/gpx/move").status
|
||||
assert_equal 401, request.post("/api/plugins/test-plugin/run").status
|
||||
end
|
||||
|
||||
# --- GPX/FIT upload and the tree ---
|
||||
@@ -290,6 +291,42 @@ class AppTest < Minitest::Test
|
||||
assert_equal 404, response.status
|
||||
end
|
||||
|
||||
# --- Import plugins (lib/maptool/plugins.rb) ---
|
||||
|
||||
def test_plugins_lists_whatever_is_on_disk
|
||||
response = request.get("/api/plugins")
|
||||
assert_equal 200, response.status
|
||||
assert_equal [{ "name" => "test-plugin", "label" => "Test plugin" }], json_body(response)
|
||||
end
|
||||
|
||||
def test_plugins_run_saves_the_output_as_a_new_geojson_file
|
||||
response = request.post(
|
||||
"/api/plugins/test-plugin/run",
|
||||
params: { south: "48.0", west: "17.0", north: "48.1", east: "17.1" },
|
||||
**logged_in_env,
|
||||
)
|
||||
assert_equal 200, response.status
|
||||
body = json_body(response)
|
||||
assert_equal "test-plugin.geojson", body["path"]
|
||||
|
||||
saved = json_body(request.get("/api/gpx/file/#{body["path"]}"))
|
||||
assert_equal "Test POI", saved["features"][0]["properties"]["name"]
|
||||
end
|
||||
|
||||
def test_plugins_run_requires_a_bbox
|
||||
response = request.post("/api/plugins/test-plugin/run", **logged_in_env)
|
||||
assert_equal 400, response.status
|
||||
end
|
||||
|
||||
def test_plugins_run_unknown_plugin_is_502
|
||||
response = request.post(
|
||||
"/api/plugins/nope/run",
|
||||
params: { south: "48.0", west: "17.0", north: "48.1", east: "17.1" },
|
||||
**logged_in_env,
|
||||
)
|
||||
assert_equal 502, response.status
|
||||
end
|
||||
|
||||
# --- LiveTrack webhook ---
|
||||
|
||||
def test_livetrack_webhook_requires_the_configured_secret
|
||||
|
||||
@@ -14,6 +14,11 @@ require "json"
|
||||
ENV["MAPTOOL_DATA_DIR"] = Dir.mktmpdir("maptool-test-data")
|
||||
Minitest.after_run { FileUtils.remove_entry(ENV["MAPTOOL_DATA_DIR"]) }
|
||||
|
||||
# A fixture plugin (test/fixtures/plugins/test-plugin), not the real
|
||||
# plugins/ — keeps /api/plugins route tests offline/deterministic instead
|
||||
# of actually running plugins/overpass-pois against the real Overpass API.
|
||||
ENV["MAPTOOL_PLUGINS_DIR"] = File.join(__dir__, "fixtures", "plugins")
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||
require_relative "../app"
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
require_relative "test_helper"
|
||||
require "maptool/plugins"
|
||||
require "tmpdir"
|
||||
require "fileutils"
|
||||
|
||||
class PluginsTest < Minitest::Test
|
||||
def setup
|
||||
@dir = Dir.mktmpdir("maptool-plugins-test")
|
||||
@plugin_dir = File.join(@dir, "hello")
|
||||
FileUtils.mkdir_p(@plugin_dir)
|
||||
File.write(File.join(@plugin_dir, "plugin.json"), JSON.generate(label: "Hello", command: "./run.sh"))
|
||||
File.write(File.join(@plugin_dir, "run.sh"), "#!/bin/sh\necho '{\"type\":\"FeatureCollection\",\"features\":[]}'\n")
|
||||
File.chmod(0o755, File.join(@plugin_dir, "run.sh"))
|
||||
end
|
||||
|
||||
def teardown
|
||||
FileUtils.remove_entry(@dir)
|
||||
end
|
||||
|
||||
def test_list_finds_plugins_by_their_manifest
|
||||
assert_equal [{ "name" => "hello", "label" => "Hello" }], Maptool::Plugins.list(@dir)
|
||||
end
|
||||
|
||||
def test_list_is_empty_for_a_directory_with_no_plugins
|
||||
assert_empty Maptool::Plugins.list(Dir.mktmpdir)
|
||||
end
|
||||
|
||||
def test_run_returns_the_plugins_geojson_output
|
||||
result = Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4])
|
||||
assert_equal({ "type" => "FeatureCollection", "features" => [] }, result)
|
||||
end
|
||||
|
||||
def test_run_raises_for_an_unknown_plugin
|
||||
assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "nope", [1, 2, 3, 4]) }
|
||||
end
|
||||
|
||||
# A directory-name-shaped ".." would otherwise resolve one level above
|
||||
# dir via File.join — the same class of path-escape GpxStore guards
|
||||
# against for uploaded file paths.
|
||||
def test_run_rejects_a_path_traversal_attempt_as_the_name
|
||||
assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "..", [1, 2, 3, 4]) }
|
||||
end
|
||||
|
||||
def test_run_raises_when_the_script_exits_non_zero
|
||||
File.write(File.join(@plugin_dir, "run.sh"), "#!/bin/sh\nexit 1\n")
|
||||
File.chmod(0o755, File.join(@plugin_dir, "run.sh"))
|
||||
assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) }
|
||||
end
|
||||
|
||||
def test_run_raises_when_the_script_does_not_print_a_feature_collection
|
||||
File.write(File.join(@plugin_dir, "run.sh"), "#!/bin/sh\necho 'not json'\n")
|
||||
File.chmod(0o755, File.join(@plugin_dir, "run.sh"))
|
||||
assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) }
|
||||
end
|
||||
end
|
||||
@@ -30,11 +30,16 @@
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"/js/auth.js": "/js/auth.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/gnss.js": "/js/gnss.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/gpx.js": "/js/gpx.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/icons.js": "/js/icons.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/livetrack.js": "/js/livetrack.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/locate.js": "/js/locate.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/measure.js": "/js/measure.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/notes.js": "/js/notes.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/panels.js": "/js/panels.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/plugins.js": "/js/plugins.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/radar.js": "/js/radar.js?v=<%= ASSET_VERSION %>",
|
||||
"/js/track_profile.js": "/js/track_profile.js?v=<%= ASSET_VERSION %>"
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user