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

+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;
}
}
}