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

+3 -2
View File
@@ -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
+37
View File
@@ -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
View File
@@ -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)
+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
+4
View File
@@ -0,0 +1,4 @@
{
"label": "Camper POIs (Overpass)",
"command": "./run.rb"
}
+16
View File
@@ -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))
+57
View File
@@ -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;
}
+9
View File
@@ -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 = `
+4
View File
@@ -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);
+96
View File
@@ -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;
}
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"label": "Test plugin",
"command": "./run.sh"
}
+5
View File
@@ -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]}}]}'
+37
View File
@@ -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
+5
View File
@@ -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"
+55
View File
@@ -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
+5
View File
@@ -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 %>"
}