Support uploading .geojson files directly
CI / check (push) Successful in 45s
CI / deploy (push) Successful in 15s

A .geojson file needs no GPX/FIT conversion — it's already the shape
gpx.js expects — so GpxStore#geojson now just reads and returns it
as-is, no cache sidecar. save/tree/the upload dropzone and its zip
handling all pick it up through the existing TRACK_EXTENSIONS list.

Also fixes a bug the new "cached" tree icon would have shipped with:
a GPX/FIT's own .geojson cache sidecar (e.g. "ride.gpx.geojson") also
matches the new .geojson branch in list_children, which would have
listed it as a second, phantom tree entry — excluded explicitly, with
a regression test.
This commit is contained in:
randy committed 2026-09-07 22:48:58 +02:00
1 parent 44d235bbcf
commit b4f205667b
8 files changed
+110 -19

No files matched your search

+2 -2
View File
@@ -169,8 +169,8 @@ module Maptool
end end
# A .zip (Garmin's bulk/FIT export format) is expanded into its # A .zip (Garmin's bulk/FIT export format) is expanded into its
# .gpx/.fit entries instead of being stored itself — nothing downstream # .gpx/.fit/.geojson entries instead of being stored itself — nothing
# (tree listing, map rendering) knows what to do with a zip. # downstream (tree listing, map rendering) knows what to do with a zip.
post "/api/gpx" do post "/api/gpx" do
require_login! require_login!
file = params[:file] file = params[:file]
+20 -11
View File
@@ -221,11 +221,11 @@ This is a live, online-only overlay — it always needs internet access
and is unrelated to the offline PMTiles basemap above. Per RainViewer's and is unrelated to the offline PMTiles basemap above. Per RainViewer's
terms, the API is free for personal/non-commercial use. terms, the API is free for personal/non-commercial use.
== GPX/FIT tracks and waypoints == GPX/FIT/GeoJSON tracks and waypoints
The "Tracks" map control (`public/js/gpx.js`) opens a side panel to The "Tracks" map control (`public/js/gpx.js`) opens a side panel to
upload GPX or FIT files by dragging them in, organize them into upload GPX, FIT or GeoJSON files by dragging them in, organize them
folders (also by dragging), and toggle each one on the map into folders (also by dragging), and toggle each one on the map
individually. individually.
Storage:: Files are stored as-is under `data/gpx/`, in a Storage:: Files are stored as-is under `data/gpx/`, in a
@@ -243,13 +243,18 @@ which the panel renders recursively (folders as `<details>`, files as
a checkbox, both with a delete button). a checkbox, both with a delete button).
Drag & drop:: The dropzone at the top of the panel accepts one or more Drag & drop:: The dropzone at the top of the panel accepts one or more
`.gpx`/`.fit` files, or a `.zip` (`POST /api/gpx`, multipart) into the `.gpx`/`.fit`/`.geojson` files, or a `.zip` (`POST /api/gpx`,
top level, or click it to browse instead. A `.zip` — Garmin's bulk/FIT multipart) into the top level, or click it to browse instead. A
export format — isn't stored itself: `save_zip_entries` (`app.rb`) `.geojson` file needs no format conversion — it's already the shape
expands it with `rubyzip` and saves each `.gpx`/`.fit` entry it `gpx.js` expects — so `GpxStore#geojson` just serves it straight from
contains individually (flattened — any folder structure inside the disk (see "Rendering" below); useful for a file already in this app's
zip is discarded), skipping anything else (readme files, etc.) it own schema, e.g. one exported elsewhere in that shape, or a previous
might hold. An already-uploaded file (a `GET /api/gpx/file` response saved and re-uploaded. A `.zip` —
Garmin's bulk/FIT export format — isn't stored itself:
`save_zip_entries` (`app.rb`) expands it with `rubyzip` and saves each
`.gpx`/`.fit`/`.geojson` entry it contains individually (flattened —
any folder structure inside the zip is discarded), skipping anything
else (readme files, etc.) it might hold. An already-uploaded file (a
`.gpx-tree__file` row, `draggable`) can be dragged onto a folder to `.gpx-tree__file` row, `draggable`) can be dragged onto a folder to
move it there (`POST /api/gpx/move`), or dropped anywhere else in the move it there (`POST /api/gpx/move`), or dropped anywhere else in the
tree to move it back to the top level; an OS file can also be dropped tree to move it back to the top level; an OS file can also be dropped
@@ -276,7 +281,11 @@ status) instead of on whichever toggle happens to be first — a silent
multi-second hang on a checkbox click reads as broken in a way the multi-second hang on a checkbox click reads as broken in a way the
same wait during an upload doesn't. A file that fails to parse still same wait during an upload doesn't. A file that fails to parse still
saves fine either way; the error just surfaces on first view instead. saves fine either way; the error just surfaces on first view instead.
`Gpx` parses the GPX with REXML: `<wpt>` A `.geojson` upload skips this path entirely — `geojson` reads and
returns it directly, no cache file, no `Gpx`/`Fit` involved; the file
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
waiting on. `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>`
+18
View File
@@ -0,0 +1,18 @@
module Maptool
# A directly-uploaded .geojson file needs no conversion — GpxStore serves
# it straight from disk (see GpxStore#geojson) — so unlike Gpx/Fit there's
# no to_geojson here. Only kind-detection lives here, for the tree's badge
# icons, mirroring Gpx.kinds' cheap substring-search rather than a full
# JSON parse: a POI export can be tens of MB, geojson or not, and a full
# parse just to answer "does this have any points/lines" would cost the
# same either way.
module Geojson
def self.kinds(path)
content = File.read(path)
kinds = []
kinds << "waypoint" if content.include?('"Point"')
kinds << "track" if content.include?('"LineString"') # matches MultiLineString too
kinds
end
end
end
+17 -1
View File
@@ -2,6 +2,7 @@ require "fileutils"
require "json" require "json"
require_relative "gpx" require_relative "gpx"
require_relative "fit" require_relative "fit"
require_relative "geojson"
module Maptool module Maptool
# Filesystem-backed store for uploaded GPX/FIT files, organized in # Filesystem-backed store for uploaded GPX/FIT files, organized in
@@ -110,9 +111,14 @@ module Maptool
# can take tens of seconds, every single time the file is toggled # can take tens of seconds, every single time the file is toggled
# visible. Cached alongside the source file and invalidated by mtime, # visible. Cached alongside the source file and invalidated by mtime,
# so only the first read after a save (or edit) pays that cost. # so only the first read after a save (or edit) pays that cost.
#
# A directly-uploaded .geojson file skips all of this — it's already in
# the shape gpx.js expects, so there's nothing to convert or cache;
# it's just read and served as-is.
def geojson(relative_path) def geojson(relative_path)
path = resolve_file(relative_path) path = resolve_file(relative_path)
raise Errno::ENOENT, path unless File.file?(path) raise Errno::ENOENT, path unless File.file?(path)
return File.read(path) if path.downcase.end_with?(".geojson")
cache = geojson_cache_path(path) cache = geojson_cache_path(path)
unless geojson_cached?(path) unless geojson_cached?(path)
@@ -139,12 +145,16 @@ module Maptool
"#{path}.geojson" "#{path}.geojson"
end end
# A .geojson source needs no separate cache file — it already is the
# target format, so it's always "ready".
def geojson_cached?(path) def geojson_cached?(path)
return true if path.downcase.end_with?(".geojson")
cache = geojson_cache_path(path) cache = geojson_cache_path(path)
File.file?(cache) && File.mtime(cache) >= File.mtime(path) File.file?(cache) && File.mtime(cache) >= File.mtime(path)
end end
TRACK_EXTENSIONS = %w[.gpx .fit].freeze TRACK_EXTENSIONS = %w[.gpx .fit .geojson].freeze
def safe_filename(filename) def safe_filename(filename)
base = File.basename(filename.to_s) base = File.basename(filename.to_s)
@@ -176,6 +186,12 @@ module Maptool
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Gpx.kinds(full), "cached" => geojson_cached?(full) } { "type" => "file", "name" => entry, "path" => rel, "kinds" => Gpx.kinds(full), "cached" => geojson_cached?(full) }
elsif entry.downcase.end_with?(".fit") elsif entry.downcase.end_with?(".fit")
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Fit.kinds(full), "cached" => geojson_cached?(full) } { "type" => "file", "name" => entry, "path" => rel, "kinds" => Fit.kinds(full), "cached" => geojson_cached?(full) }
# A GPX/FIT's own cache sidecar (e.g. "ride.gpx.geojson", written by
# `geojson` above) also ends in ".geojson" — excluded here so it
# doesn't show up as a second, phantom tree entry next to the file
# it was derived from.
elsif entry.downcase.end_with?(".geojson") && !entry.downcase.end_with?(".gpx.geojson", ".fit.geojson")
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Geojson.kinds(full), "cached" => true }
end end
end end
end end
+6 -3
View File
@@ -620,9 +620,12 @@ export class GpxPanelControl {
async _uploadFiles(fileList, folder) { async _uploadFiles(fileList, folder) {
// .zip: Garmin's bulk/FIT export format — the server expands it into // .zip: Garmin's bulk/FIT export format — the server expands it into
// its .gpx/.fit entries, so it's accepted here even though the tree // its .gpx/.fit/.geojson entries, so it's accepted here even though the
// never shows a .zip itself. // tree never shows a .zip itself. .geojson needs no server-side
const files = [...fileList].filter((file) => /\.(gpx|fit|zip)$/i.test(file.name)); // conversion (GpxStore#geojson serves it as-is) — accepted here so a
// file already in this app's own shape (e.g. a previous /api/gpx/file
// response, saved and re-uploaded) can skip GPX/FIT entirely.
const files = [...fileList].filter((file) => /\.(gpx|fit|geojson|zip)$/i.test(file.name));
if (files.length === 0) return; if (files.length === 0) return;
this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${files.length} files…`); this._setStatus(files.length === 1 ? "Uploading…" : `Uploading ${files.length} files…`);
+12
View File
@@ -60,6 +60,18 @@ class AppTest < Minitest::Test
assert_operator track["properties"]["distance_meters"], :>, 0 assert_operator track["properties"]["distance_meters"], :>, 0
end end
def test_geojson_upload_is_served_back_unchanged
content = '{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}'
response = post_file("/api/gpx", filename: "spots.geojson", content: content, env: logged_in_env)
assert_equal 200, response.status
assert_equal ["spots.geojson"], json_body(response)["paths"]
file_response = request.get("/api/gpx/file/spots.geojson")
assert_equal 200, file_response.status
assert_equal "Spot", json_body(file_response)["features"][0]["properties"]["name"]
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)
+33
View File
@@ -145,6 +145,35 @@ class GpxStoreTest < Minitest::Test
refute file["cached"] refute file["cached"]
end end
def test_geojson_extension_is_preserved_not_forced_to_gpx
path = @store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson))
assert_equal "spots.geojson", path
end
def test_uploaded_geojson_is_served_as_is_with_no_cache_file
path = @store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson))
assert_equal sample_geojson, @store.geojson(path)
refute File.file?(@store.resolve_file(path) + ".geojson"), "a .geojson source needs no separate cache sidecar"
end
def test_uploaded_geojson_is_always_reported_cached
@store.save(folder: "", filename: "spots.geojson", io: StringIO.new(sample_geojson))
file = @store.tree["children"].find { |c| c["name"] == "spots.geojson" }
assert file["cached"]
assert_equal ["waypoint"], file["kinds"]
end
# A GPX/FIT's own GeoJSON cache sidecar (e.g. "ride.gpx.geojson") also
# ends in ".geojson" — regression test for it not showing up as its own
# second, phantom tree entry alongside "ride.gpx".
def test_geojson_cache_sidecar_is_not_listed_as_its_own_tree_entry
@store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
assert_equal ["ride.gpx"], @store.tree["children"].map { |c| c["name"] }
end
private private
def sample_gpx def sample_gpx
@@ -152,4 +181,8 @@ class GpxStoreTest < Minitest::Test
<gpx><wpt lat="48.1" lon="17.1"><name>Waypoint</name></wpt></gpx> <gpx><wpt lat="48.1" lon="17.1"><name>Waypoint</name></wpt></gpx>
GPX GPX
end end
def sample_geojson
'{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}'
end
end end
+2 -2
View File
@@ -48,12 +48,12 @@
<strong>Tracks</strong> <strong>Tracks</strong>
<div class="gpx-panel__actions"> <div class="gpx-panel__actions">
<button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button> <button type="button" class="gpx-panel__action" data-action="new-folder" title="New folder" aria-label="New folder"></button>
<button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX/FIT file" aria-label="Upload GPX/FIT file"></button> <button type="button" class="gpx-panel__action" data-action="upload" title="Upload GPX/FIT/GeoJSON file" aria-label="Upload GPX/FIT/GeoJSON file"></button>
<button type="button" class="gpx-panel__close" aria-label="Close">&times;</button> <button type="button" class="gpx-panel__close" aria-label="Close">&times;</button>
</div> </div>
</div> </div>
<input type="file" class="gpx-file-input" accept=".gpx,.fit,.zip" multiple hidden> <input type="file" class="gpx-file-input" accept=".gpx,.fit,.geojson,.zip" multiple hidden>
<p class="gpx-upload__status" hidden></p> <p class="gpx-upload__status" hidden></p>
<div class="gpx-tree"></div> <div class="gpx-tree"></div>