Support uploading .geojson files directly
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:
1 parent
44d235bbcf
commit
b4f205667b
8 files changed
+110
-19
No files matched your search
@@ -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
@@ -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>`
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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…`);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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">×</button>
|
<button type="button" class="gpx-panel__close" aria-label="Close">×</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>
|
||||||
|
|||||||
Reference in new issue
Block a user