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.
97 lines
3.5 KiB
JavaScript
97 lines
3.5 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
}
|