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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user