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 = ` `; 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 = `
  • Loading…
  • `; let plugins; try { plugins = await (await fetch("/api/plugins")).json(); } catch (err) { this._list.innerHTML = `
  • Could not load plugins
  • `; return; } if (plugins.length === 0) { this._list.innerHTML = `
  • No plugins installed — see docs/development.adoc
  • `; 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 = ` `; 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 }); // Every app.rb response on this route is JSON, success or error — // the catch below is only for an infra-level failure in front of it // (a proxy's own HTML error page, a dropped connection) that never // reached app.rb at all. const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data.error || `run failed: ${response.status}`); notifyGpxStoreChanged(data.path); status.textContent = "Done"; status.classList.remove("plugins-panel__status--error"); } catch (err) { // Plugins::RunError's message (e.g. "plugin exited with status 1") // reaches here as `data.error` above — nothing about a plugin run // failure gets logged server-side (app.rb turns it into a plain 502 // JSON response, not an exception), so showing that message directly // is the only way to see why, short of SSHing in to reproduce it. status.textContent = err.message; status.classList.add("plugins-panel__status--error"); } finally { button.disabled = false; } } }