Compare commits

..
4 Commits
Author SHA1 Message Date
randy 47e3f9c7e8 Update TODO.adoc for this session's work
CI / deploy (push) Successful in 13s
CI / check (push) Successful in 41s
Marks Park4Night (private plugin), the category-filter polygon/line
bug, and the overpass-pois icon/description work as done.
2026-09-09 18:22:28 +02:00
randy ea9cd5fc40 Fix three bugs found reviewing this session's changes
- notes.js's ICON_LABELS wasn't extended for the shop_supermarket/
  shop_convenience keys icons.js added — the note icon picker showed
  the raw internal key as the title instead of a real label.
- Plugins.run mislabeled a malformed plugin.json as "plugin did not
  print valid JSON" — a message written for bad script stdout, not a
  bad manifest, since both paths shared one rescue.
- overpass_pois.rb's description_for joined raw OSM tag values with
  "\n" — an operator/opening_hours tag with an embedded newline could
  inject an extra line into the popup that gpx.js's formatDescription
  (built for p4n2gpx's own well-formed lines) would render as its own
  independent, legitimate-looking link. Tag values are stripped of
  embedded newlines before joining.
2026-09-09 18:21:47 +02:00
randy 7c664578af Fix category filter not hiding polygon/line notes, only points
TODO.adoc had this flagged: toggling a category off in the legend only
re-sliced the points source (_applyCategoryFilter), so a polygon or
line note (Measure's "Save as note") sharing that category stayed
visible. Added _linesByPath alongside the existing _pointsByPath,
tracked/cleaned up the same way, and reused for both the legend's
category scan and the filter's re-slice.
2026-09-09 18:21:39 +02:00
randy d9cc468478 Show the actual plugin run error instead of a generic message
A failed run showed "Failed — see server log" regardless of why —
misleading, since a Plugins::RunError never actually reaches any log
(app.rb turns it straight into a 502 JSON response). Found this the
hard way debugging the p4n2gpx plugin, having to SSH in and reproduce
the run by hand each time just to see why it failed. Now shows the
response's actual error message (e.g. "plugin exited with status 1"),
wrapping instead of overflowing the flyout for a longer one.
2026-09-09 18:17:24 +02:00
9 changed files with 91 additions and 12 deletions

No files matched your search

+4 -3
View File
@@ -12,9 +12,10 @@ 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 * [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) ** [ ] Terrain slope readout for parking (numeric, not just visual shading)
* [ ] Contour lines from OSM for hikes and mountain passes * [ ] 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. Also runnable from inside the app itself now, see the import plugins entry below (`plugins/overpass-pois` wraps this same query) * [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). Each category now carries a real `POI_ICONS` glyph and color (not p4n2gpx's plain red marker) plus a popup description built from opening_hours/operator/website tags
* [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 * [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] Park4Night — private plugin (`data/plugins/p4n2gpx/`, outside this repo per the "ToS-sensitive scraper" note above); iOverlander/Freecampsites still open, same story
** [x] A private plugin's own dependencies now have somewhere to live too: `data/plugins/` is under the persistent `data/` bind mount (survives a redeploy, unlike `build/`), and `docs/deployment.adoc` documents the convention
* [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] 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 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 ** [x] Cache the radar tile *images* too — `public/sw.js`, network-falling-back-to-cache for RainViewer tile requests, registered from layout.erb
@@ -51,7 +52,7 @@ independence from connectivity.
** [x] The clicked point stays marked on the map while the note form is open (a plain `Marker`, removed on save/cancel/deactivate) — it was disappearing back into the flat map with nothing showing where the note would land ** [x] The clicked point stays marked on the map while the note form is open (a plain `Marker`, removed on save/cancel/deactivate) — it was disappearing back into the flat map with nothing showing where the note would land
** [x] Icon picker for point notes (a grid in the save form) — extends `POI_ICONS` with more of the symbols `PARK4NIGHT_TAGS` already vendors (water, dump station, toilets, playground, viewpoint, swimming, fishing, climbing, hiking, …), reusable as real marker-badge glyphs, not just inline tag icons. Hidden for a Measure-drawn line/polygon note — no single spot for a badge to sit at ** [x] Icon picker for point notes (a grid in the save form) — extends `POI_ICONS` with more of the symbols `PARK4NIGHT_TAGS` already vendors (water, dump station, toilets, playground, viewpoint, swimming, fishing, climbing, hiking, …), reusable as real marker-badge glyphs, not just inline tag icons. Hidden for a Measure-drawn line/polygon note — no single spot for a badge to sit at
** [x] Fixed: the Save button's own blue background was losing to `.notes-panel__actions button`'s gray — same class-vs-class+element specificity trap as `.gpx-panel__action[hidden]` already has a comment about elsewhere in this file, just not yet applied here ** [x] Fixed: the Save button's own blue background was losing to `.notes-panel__actions button`'s gray — same class-vs-class+element specificity trap as `.gpx-panel__action[hidden]` already has a comment about elsewhere in this file, just not yet applied here
** [ ] The Unigrid Matrix category filter only re-slices the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hides point notes but not polygon/line ones sharing that category ** [x] Fixed: the Unigrid Matrix category filter only re-sliced the *points* source (`_applyCategoryFilter`) — toggling the "Note" category off hid point notes but not polygon/line ones sharing that category. `_linesByPath` now tracks those alongside `_pointsByPath`, re-sliced/scanned the same way
* [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping * [ ] Offline satellite tile caching (e.g. ESRI) for a selected route area — key for judging surface quality and obstacles when wild camping
== Nice to have (later) == Nice to have (later)
+13 -2
View File
@@ -106,11 +106,22 @@ module Maptool
private_class_method :category_for private_class_method :category_for
def self.description_for(tags) def self.description_for(tags)
lines = DESCRIPTION_TAGS.filter_map { |tag, label| "#{label}: #{tags[tag]}" if tags[tag] } lines = DESCRIPTION_TAGS.filter_map { |tag, label| "#{label}: #{clean_tag(tags[tag])}" if tags[tag] }
website = tags["website"] || tags["contact:website"] website = tags["website"] || tags["contact:website"]
lines << website if website lines << clean_tag(website) if website
lines.empty? ? nil : lines.join("\n") lines.empty? ? nil : lines.join("\n")
end end
private_class_method :description_for private_class_method :description_for
# An OSM tag value is free text from a world-editable map — an embedded
# newline could otherwise inject an extra "line" of its own into the
# description, e.g. an operator tag ending in "\nhttps://evil.example"
# would render as its own separate, legitimate-looking link line once
# gpx.js's formatDescription (written for p4n2gpx's own well-formed
# "Label: value" lines) splits on "\n".
def self.clean_tag(value)
value.to_s.tr("\n\r", " ").strip
end
private_class_method :clean_tag
end end
end end
+5 -1
View File
@@ -55,7 +55,11 @@ module Maptool
manifest_path = File.join(plugin_dir, "plugin.json") manifest_path = File.join(plugin_dir, "plugin.json")
command = JSON.parse(File.read(manifest_path))["command"] command = begin
JSON.parse(File.read(manifest_path))["command"]
rescue JSON::ParserError
raise RunError, "plugin has an invalid plugin.json"
end
raise RunError, "plugin has no command" unless command raise RunError, "plugin has no command" unless command
south, west, north, east = bbox south, west, north, east = bbox
+9
View File
@@ -1487,8 +1487,17 @@ html, body {
.plugins-panel__status { .plugins-panel__status {
min-width: 3.5em; min-width: 3.5em;
max-width: 200px;
color: #888; color: #888;
font-size: 11px; font-size: 11px;
/* Overrides the parent's nowrap: "Running…"/"Done" never wrap either
way, but a real RunError message (e.g. "plugin exited with status 1")
needs to, or it just overflows the flyout unreadably. */
white-space: normal;
}
.plugins-panel__status--error {
color: #c62828;
} }
.plugins-panel__empty { .plugins-panel__empty {
+15 -2
View File
@@ -43,6 +43,7 @@ export class GpxPanelControl {
this._visible = new Set(); // file paths currently shown on the map this._visible = new Set(); // file paths currently shown on the map
this._interactiveLayers = new Set(); // point layer ids with click/hover wired up this._interactiveLayers = new Set(); // point layer ids with click/hover wired up
this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend this._pointsByPath = new Map(); // path -> its waypoints FeatureCollection, for the category legend
this._linesByPath = new Map(); // path -> its lines/polygons FeatureCollection, same reason (a polygon/line note also carries a category)
this._hiddenCategories = new Set(); // category names toggled off in the legend this._hiddenCategories = new Set(); // category names toggled off in the legend
this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any this._openDownloadMenu = null; // the one gpx-tree__download-menu currently open, if any
@@ -597,6 +598,7 @@ export class GpxPanelControl {
this._addClusterLayers(pointsId, path); this._addClusterLayers(pointsId, path);
if (points.features.length > 0) this._pointsByPath.set(path, points); if (points.features.length > 0) this._pointsByPath.set(path, points);
if (lines.features.length > 0) this._linesByPath.set(path, lines);
this._updateLegend(); this._updateLegend();
this._applyCategoryFilter(); this._applyCategoryFilter();
@@ -843,6 +845,7 @@ export class GpxPanelControl {
if (this._map.getSource(pointsId)) this._map.removeSource(pointsId); if (this._map.getSource(pointsId)) this._map.removeSource(pointsId);
this._pointsByPath.delete(path); this._pointsByPath.delete(path);
this._linesByPath.delete(path);
this._updateLegend(); this._updateLegend();
} }
@@ -853,8 +856,8 @@ export class GpxPanelControl {
// on the map, not just a read-only color key. // on the map, not just a read-only color key.
_updateLegend() { _updateLegend() {
const categories = new Map(); // category name -> { color, icon } const categories = new Map(); // category name -> { color, icon }
for (const points of this._pointsByPath.values()) { for (const collection of [...this._pointsByPath.values(), ...this._linesByPath.values()]) {
for (const feature of points.features) { for (const feature of collection.features) {
const category = feature.properties.category; const category = feature.properties.category;
if (category && !categories.has(category)) { if (category && !categories.has(category)) {
categories.set(category, { color: feature.properties.color || TRACK_COLOR, icon: feature.properties.icon }); categories.set(category, { color: feature.properties.color || TRACK_COLOR, icon: feature.properties.icon });
@@ -909,6 +912,16 @@ export class GpxPanelControl {
const visible = points.features.filter((f) => !this._hiddenCategories.has(f.properties.category)); const visible = points.features.filter((f) => !this._hiddenCategories.has(f.properties.category));
source.setData({ type: "FeatureCollection", features: visible }); source.setData({ type: "FeatureCollection", features: visible });
} }
// Same re-slicing for polygon/line notes sharing the "lines" source with
// plain tracks/routes — those have no `category` of their own, so
// `_hiddenCategories.has(undefined)` is always false and they're never
// affected by this, only a hidden Note (or other categorized) shape is.
for (const [path, lines] of this._linesByPath) {
const source = this._map.getSource(linesSourceId(path));
if (!source) continue;
const visible = lines.features.filter((f) => !this._hiddenCategories.has(f.properties.category));
source.setData({ type: "FeatureCollection", features: visible });
}
} }
async _uploadFiles(fileList, folder) { async _uploadFiles(fileList, folder) {
+2
View File
@@ -24,6 +24,8 @@ const ICON_LABELS = {
leisure_fishing: "Fishing", leisure_fishing: "Fishing",
sport_climbing: "Climbing", sport_climbing: "Climbing",
route_hiking: "Hiking", route_hiking: "Hiking",
shop_supermarket: "Supermarket",
shop_convenience: "Convenience store",
}; };
const NEW_LAYER_VALUE = "__new__"; const NEW_LAYER_VALUE = "__new__";
+15 -4
View File
@@ -83,12 +83,23 @@ export class PluginsControl {
east: bounds.getEast(), east: bounds.getEast(),
}); });
const response = await fetch(`/api/plugins/${encodeURIComponent(plugin.name)}/run`, { method: "POST", body }); const response = await fetch(`/api/plugins/${encodeURIComponent(plugin.name)}/run`, { method: "POST", body });
if (!response.ok) throw new Error(`run failed: ${response.status}`); // Every app.rb response on this route is JSON, success or error —
const { path } = await response.json(); // the catch below is only for an infra-level failure in front of it
notifyGpxStoreChanged(path); // (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.textContent = "Done";
status.classList.remove("plugins-panel__status--error");
} catch (err) { } catch (err) {
status.textContent = "Failed — see server log"; // 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 { } finally {
button.disabled = false; button.disabled = false;
} }
+17
View File
@@ -56,6 +56,23 @@ class OverpassPoisTest < Minitest::Test
assert_equal "Opening hours: 24/7\nOperator: Town Council\nhttps://example.com", description assert_equal "Opening hours: 24/7\nOperator: Town Council\nhttps://example.com", description
end end
def test_to_geojson_strips_embedded_newlines_from_tag_values
response = {
"elements" => [
{
"type" => "node",
"lat" => 48.1,
"lon" => 17.2,
"tags" => { "amenity" => "toilets", "operator" => "Acme Co\nhttps://evil.example" },
},
],
}
description = Maptool::OverpassPois.to_geojson(response)["features"].first["properties"]["description"]
assert_equal "Operator: Acme Co https://evil.example", description, "an embedded newline must not inject a second, independently-rendered line"
end
def test_to_geojson_omits_description_when_no_recognized_tags_are_present def test_to_geojson_omits_description_when_no_recognized_tags_are_present
response = { "elements" => [{ "type" => "node", "lat" => 48.1, "lon" => 17.2, "tags" => { "amenity" => "toilets" } }] } response = { "elements" => [{ "type" => "node", "lat" => 48.1, "lon" => 17.2, "tags" => { "amenity" => "toilets" } }] }
+11
View File
@@ -53,6 +53,17 @@ class PluginsTest < Minitest::Test
assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) } assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) }
end end
# Distinct from the "did not print valid JSON" message above — that one's
# about the script's stdout, this is about plugin.json itself, before the
# script is ever run. Same exception class either way, but a clear
# message matters since this is what a "Failed" plugin run shows the user
# (see public/js/plugins.js).
def test_run_raises_a_distinct_message_for_an_invalid_manifest
File.write(File.join(@plugin_dir, "plugin.json"), "{not valid json")
error = assert_raises(Maptool::Plugins::RunError) { Maptool::Plugins.run(@dir, "hello", [1, 2, 3, 4]) }
assert_match(/invalid plugin\.json/, error.message)
end
# Multiple dirs (app.rb's PLUGINS_DIRS: the built-in plugins/ plus an # Multiple dirs (app.rb's PLUGINS_DIRS: the built-in plugins/ plus an
# optional private one under data/plugins/) — list/run both accept an # optional private one under data/plugins/) — list/run both accept an
# array, not just the single-dir form the tests above exercise. # array, not just the single-dir form the tests above exercise.