diff --git a/app.rb b/app.rb index c5fd4ee..7abea43 100644 --- a/app.rb +++ b/app.rb @@ -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] diff --git a/docs/architecture.adoc b/docs/architecture.adoc index 2f595f8..5acda55 100644 --- a/docs/architecture.adoc +++ b/docs/architecture.adoc @@ -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 `
`, 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: `` +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: `` waypoints become `Point` features (with `name`, `desc` and `time` carried through as properties), each `` becomes one `MultiLineString` feature (one line per ``), and each `` diff --git a/lib/maptool/geojson.rb b/lib/maptool/geojson.rb new file mode 100644 index 0000000..d53c0b8 --- /dev/null +++ b/lib/maptool/geojson.rb @@ -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 diff --git a/lib/maptool/gpx_store.rb b/lib/maptool/gpx_store.rb index 80527d3..887ad46 100644 --- a/lib/maptool/gpx_store.rb +++ b/lib/maptool/gpx_store.rb @@ -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 diff --git a/public/js/gpx.js b/public/js/gpx.js index 5bc2109..b191088 100644 --- a/public/js/gpx.js +++ b/public/js/gpx.js @@ -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…`); diff --git a/test/test_app.rb b/test/test_app.rb index da061b1..7f9caf2 100644 --- a/test/test_app.rb +++ b/test/test_app.rb @@ -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: "", env: logged_in_env) diff --git a/test/test_gpx_store.rb b/test/test_gpx_store.rb index c6f714f..8b1b929 100644 --- a/test/test_gpx_store.rb +++ b/test/test_gpx_store.rb @@ -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 Waypoint 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 diff --git a/views/index.erb b/views/index.erb index cfaee76..b44fc25 100644 --- a/views/index.erb +++ b/views/index.erb @@ -48,12 +48,12 @@ Tracks
- +
- +