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
# A .zip (Garmin's bulk/FIT export format) is expanded into its
# .gpx/.fit entries instead of being stored itself — nothing downstream
# (tree listing, map rendering) knows what to do with a zip.
# .gpx/.fit/.geojson entries instead of being stored itself — nothing
# downstream (tree listing, map rendering) knows what to do with a zip.
post "/api/gpx" do
require_login!
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
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
upload GPX or FIT files by dragging them in, organize them into
folders (also by dragging), and toggle each one on the map
upload GPX, FIT or GeoJSON files by dragging them in, organize them
into folders (also by dragging), and toggle each one on the map
individually.
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).
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
top level, or click it to browse instead. 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` 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`/`.fit`/`.geojson` files, or a `.zip` (`POST /api/gpx`,
multipart) into the top level, or click it to browse instead. A
`.geojson` file needs no format conversion — it's already the shape
`gpx.js` expects — so `GpxStore#geojson` just serves it straight from
disk (see "Rendering" below); useful for a file already in this app's
own schema, e.g. one exported elsewhere in that shape, or a previous
`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
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
@@ -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
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.
`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`
carried through as properties), each `<trk>` becomes one
`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_relative "gpx"
require_relative "fit"
require_relative "geojson"
module Maptool
# 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
# visible. Cached alongside the source file and invalidated by mtime,
# 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)
path = resolve_file(relative_path)
raise Errno::ENOENT, path unless File.file?(path)
return File.read(path) if path.downcase.end_with?(".geojson")
cache = geojson_cache_path(path)
unless geojson_cached?(path)
@@ -139,12 +145,16 @@ module Maptool
"#{path}.geojson"
end
# A .geojson source needs no separate cache file — it already is the
# target format, so it's always "ready".
def geojson_cached?(path)
return true if path.downcase.end_with?(".geojson")
cache = geojson_cache_path(path)
File.file?(cache) && File.mtime(cache) >= File.mtime(path)
end
TRACK_EXTENSIONS = %w[.gpx .fit].freeze
TRACK_EXTENSIONS = %w[.gpx .fit .geojson].freeze
def safe_filename(filename)
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) }
elsif entry.downcase.end_with?(".fit")
{ "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
+6 -3
View File
@@ -620,9 +620,12 @@ export class GpxPanelControl {
async _uploadFiles(fileList, folder) {
// .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
// never shows a .zip itself.
const files = [...fileList].filter((file) => /\.(gpx|fit|zip)$/i.test(file.name));
// its .gpx/.fit/.geojson entries, so it's accepted here even though the
// tree never shows a .zip itself. .geojson needs no server-side
// 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;
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
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
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"]
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
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
end
def sample_geojson
'{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"kind":"waypoint","name":"Spot"},"geometry":{"type":"Point","coordinates":[17.1,48.1]}}]}'
end
end
+2 -2
View File
@@ -48,12 +48,12 @@
<strong>Tracks</strong>
<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="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>
</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>
<div class="gpx-tree"></div>