Let a cached file's GeoJSON be downloaded by clicking its tree name

Once a .gpx/.fit is cached, the tree shows it under its converted name
("ride.gpx" -> "ride.gpx.geojson", matching the real cache file next
to it on disk) instead of still reading as un-converted -- the
GeoJSON is what's actually on the map from that point on, and the
original is never deleted, just not re-shown as itself. The name is
now a real link (GET /api/gpx/file/<path>?download=1) instead of
plain text, the only way to get that converted form out of the app.

Verified in a real browser that an <a> nested in a <label> claims its
own click rather than the label forwarding it to the checkbox, so
downloading and toggling visibility don't fight over the same click.
This commit is contained in:
randy committed 2026-09-07 22:56:46 +02:00
1 parent b4f205667b
commit cd92dadf6a
5 files changed
+70 -4

No files matched your search

+13
View File
@@ -191,6 +191,10 @@ module Maptool
json paths: paths, tree: GPX_STORE.tree json paths: paths, tree: GPX_STORE.tree
end end
# ?download=1 exports the file's GeoJSON as an attachment instead of
# rendering it on the map — the only way to get a .gpx/.fit's converted
# form out of the app, since GpxStore#geojson's cache is otherwise an
# internal implementation detail with no filename of its own to expose.
get "/api/gpx/file/*" do get "/api/gpx/file/*" do
begin begin
body = GPX_STORE.geojson(params[:splat].first) body = GPX_STORE.geojson(params[:splat].first)
@@ -203,6 +207,15 @@ module Maptool
end end
content_type :json content_type :json
if params[:download]
# Matches the tree's own display name for a cached .gpx/.fit
# (public/js/gpx.js's _renderFile) — "ride.gpx" downloads as
# "ride.gpx.geojson", not "ride.geojson", so the two never disagree.
# A .geojson source is already named right and is left alone.
original_name = File.basename(params[:splat].first)
download_name = original_name.downcase.end_with?(".geojson") ? original_name : "#{original_name}.geojson"
attachment download_name
end
body body
end end
+17 -1
View File
@@ -285,7 +285,23 @@ A `.geojson` upload skips this path entirely — `geojson` reads and
returns it directly, no cache file, no `Gpx`/`Fit` involved; the file returns it directly, no cache file, no `Gpx`/`Fit` involved; the file
tree's cached-file icon (`GEOJSON_ICON`, `public/js/icons.js`) always tree's cached-file icon (`GEOJSON_ICON`, `public/js/icons.js`) always
shows for one, since there's no conversion step it could ever be shows for one, since there's no conversion step it could ever be
waiting on. `Gpx` parses the GPX with REXML: `<wpt>` waiting on.
+
Once a `.gpx`/`.fit` is cached, the tree shows it under its converted
name instead — `"ride.gpx"` reads as `"ride.gpx.geojson"`, matching
the real cache file sitting next to it on disk — since the GeoJSON,
not the original, is what's actually rendered on the map from that
point on. The original is never deleted or re-parsed once cached, just
not re-shown as itself; the tree's file name is a real link
(`GET /api/gpx/file/<path>?download=1`), the only way to get that
converted form out of the app as an actual file, since the cache is
otherwise an implementation detail with no name of its own to expose.
It's an `<a>`, not a `<span>`, specifically so clicking it downloads
instead of also toggling the row's checkbox — a native `<a>`/`<button>`
nested inside a `<label>` claims its own click instead of the label
forwarding it to the associated control, so the two behaviors don't
fight over the same click. A directly-uploaded `.geojson` keeps its
own name (nothing to convert) and downloads unchanged. `Gpx` parses the GPX with REXML: `<wpt>`
waypoints become `Point` features (with `name`, `desc` and `time` waypoints become `Point` features (with `name`, `desc` and `time`
carried through as properties), each `<trk>` becomes one carried through as properties), each `<trk>` becomes one
`MultiLineString` feature (one line per `<trkseg>`), and each `<rte>` `MultiLineString` feature (one line per `<trkseg>`), and each `<rte>`
+6
View File
@@ -360,6 +360,12 @@ html, body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
color: inherit;
text-decoration: none;
}
.gpx-tree__file-name:hover {
text-decoration: underline;
} }
.gpx-tree__kind-icon { .gpx-tree__kind-icon {
+16 -3
View File
@@ -271,10 +271,23 @@ export class GpxPanelControl {
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.checked = this._visible.has(file.path); checkbox.checked = this._visible.has(file.path);
checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked, checkbox)); checkbox.addEventListener("change", () => this._toggleFile(file.path, checkbox.checked, checkbox));
const name = document.createElement("span"); // Once a .gpx/.fit is cached, its converted GeoJSON is the thing that's
// actually shown on the map from here on (see GpxStore#geojson) — the
// name reflects that (matching the real "<path>.geojson" cache file on
// disk) rather than still reading as a plain, un-converted .gpx/.fit.
// A direct .geojson upload already ends in .geojson, so it's left
// alone. It's an <a>, not a <span>: clicking it downloads that GeoJSON
// (?download=1) — the only way to get it out of the app, since the
// original .gpx/.fit is never deleted but also never re-shown once
// converted. An <a> nested in a <label> doesn't also toggle the
// checkbox on click (unlike a plain element), so this doesn't fight
// the row's own toggle-on-map behavior.
const displayName = file.cached && !file.name.toLowerCase().endsWith(".geojson") ? `${file.name}.geojson` : file.name;
const name = document.createElement("a");
name.className = "gpx-tree__file-name"; name.className = "gpx-tree__file-name";
name.textContent = file.name; name.href = `${gpxFileUrl(file.path)}?download=1`;
name.title = file.name; name.textContent = displayName;
name.title = `Download ${displayName}`;
label.appendChild(checkbox); label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind))); (file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (file.cached) label.appendChild(this._cachedIcon()); if (file.cached) label.appendChild(this._cachedIcon());
+18
View File
@@ -72,6 +72,24 @@ class AppTest < Minitest::Test
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"] assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
end end
def test_download_of_a_converted_gpx_names_it_gpx_geojson
content = File.read(fixture_path("sample.gpx"))
post_file("/api/gpx", filename: "ride.gpx", content: content, env: logged_in_env)
response = request.get("/api/gpx/file/ride.gpx?download=1")
assert_equal 200, response.status
assert_includes response.headers["Content-Disposition"], 'filename="ride.gpx.geojson"'
end
def test_download_of_a_native_geojson_keeps_its_own_name
content = '{"type":"FeatureCollection","features":[]}'
post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
response = request.get("/api/gpx/file/spots.geojson?download=1")
assert_equal 200, response.status
assert_includes response.headers["Content-Disposition"], 'filename="spots.geojson"'
end
def test_unparseable_gpx_returns_422 def test_unparseable_gpx_returns_422
post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env) post_file("/api/gpx", filename: "bad.gpx", content: "<gpx><trk unclosed>", env: logged_in_env)