Compare commits

..
4 Commits
Author SHA1 Message Date
randy b4f205667b Support uploading .geojson files directly
CI / deploy (push) Successful in 15s
CI / check (push) Successful in 45s
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.
2026-09-07 22:48:58 +02:00
randy 44d235bbcf Show a GeoJSON icon next to files with a ready cache
GpxStore#tree now reports a "cached" flag per file (reusing the same
mtime freshness check geojson() already does); the file tree shows a
small curly-braces icon next to any file whose conversion is already
on disk, so it's visible which files will toggle on instantly versus
which still need to parse.
2026-09-07 22:44:04 +02:00
randy dd0ad6d845 Show waypoint photos from newer p4n2gpx exports
A newer p4n2gpx run (Ruby rewrite, ~/test/mapping/p4n2gpx) started
emitting one plain GPX <link href> per park4night listing photo,
which this app parsed but never displayed. Gpx.waypoint_photos
collects them into a photos property; the waypoint popup renders them
as a row of thumbnails above the description, each opening full-size
in a new tab. Absent on older exports and plain GPS waypoints, so the
row just doesn't render for those.
2026-09-07 22:38:22 +02:00
randy dd272ffb8a Convert GPX/FIT to GeoJSON at upload time, not first view
GpxStore#save now calls geojson() itself right after writing the
file, so the (potentially tens-of-seconds, for a large POI export)
conversion cost is paid during the upload — covered by the frontend's
existing "Uploading…" status — instead of on whichever checkbox
toggle happens to be first, which otherwise reads as a silent hang
indistinguishable from broken. An unparseable file still saves fine;
the error just surfaces on first view same as before.
2026-09-07 22:33:29 +02:00
13 changed files with 271 additions and 32 deletions

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]
+39 -13
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
@@ -269,8 +274,18 @@ know which format it got. `GpxStore#geojson` caches that conversion
alongside the source file (`<path>.geojson`, invalidated by mtime) —
REXML parsing a several-thousand-waypoint POI export (a park4night
area export, say) can take tens of seconds, and without a cache that
cost repeats on every single toggle rather than just the first one
after a save. `Gpx` parses the GPX with REXML: `<wpt>`
cost repeats on every single toggle. `GpxStore#save` also calls
`geojson` itself right after writing the file, so that cost is paid
once during the upload (covered by the frontend's own "Uploading…"
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.
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>`
@@ -409,7 +424,8 @@ Like the radar overlay, GPX layers are removed by `map.setStyle()`
whichever files were checked.
Waypoint popups:: Clicking a waypoint badge opens a MapLibre popup
with its name, category (if any), description and time. Click/hover handling is
with its name, category (if any), photos (if any), description and
time. Click/hover handling is
registered once per file (via `map.on(type, layerId, handler)`, a
delegated listener that survives `setStyle()`) rather than on every
`style.load` re-add, which would otherwise stack duplicate handlers
@@ -436,6 +452,16 @@ before, one paragraph with line breaks. So this only ever adds
structure on top of recognized lines; it never loses or hides text from
files that don't have any.
+
A newer p4n2gpx export also emits one plain GPX `<link href>` per
listing photo (not a vendor extension like `waypoint_style`'s
`<osmand:*>` — `<link>` is base GPX 1.1, any number allowed per
`<wpt>`/`<trk>`/`<rte>`). `Gpx.waypoint_photos` collects their `href`s
into a `photos` property; `formatPhotos` renders them as a row of
64px thumbnails above the description, each linking to the full-size
image in a new tab. A plain GPS waypoint or an older export has none,
so the row just doesn't render — same "only ever adds, never assumes"
approach as the description formatting above.
+
`Services`/`Activities` get one further step (`formatMetaRow`,
`formatTags`): the value is park4night's own comma-separated codes
(`animaux, point_eau, wc_public`), which on their own told a reader
+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
+10
View File
@@ -51,11 +51,21 @@ module Maptool
"category" => child_text(wpt, "type"),
"color" => ext[:color],
"icon" => ext[:icon],
"photos" => waypoint_photos(wpt),
},
}
end
private_class_method :waypoint_feature
# <link> is plain GPX 1.1 (a wpt/trk/rte can carry any number of them),
# not a vendor extension like waypoint_style's <osmand:*> — a newer
# p4n2gpx export started emitting one per park4night listing photo. A
# plain GPS waypoint has none, so this is almost always empty.
def self.waypoint_photos(wpt)
wpt.get_elements("link").filter_map { |link| link.attributes["href"] }
end
private_class_method :waypoint_photos
OSMAND_NAMESPACE = "https://osmand.net"
private_constant :OSMAND_NAMESPACE
+43 -5
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
@@ -22,7 +23,10 @@ module Maptool
end
# Nested { type:, name:, path:, children: } hash describing every
# folder and .gpx/.fit file under the store root.
# folder and .gpx/.fit file under the store root. Each file also
# carries "cached" — whether its GeoJSON conversion is ready on disk
# (see `geojson` below) — so the tree UI can show a file's ready to
# view instantly, without a round trip just to find out.
def tree
{ "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") }
end
@@ -30,12 +34,26 @@ module Maptool
# Saves `io`'s contents as a .gpx/.fit file under `folder` (created if
# needed), deriving a safe filename from `filename`. Returns the
# stored file's path relative to the store root.
#
# Converts to GeoJSON and caches it right away (see `geojson` below)
# rather than waiting for the first time someone checks the file's box —
# a several-thousand-waypoint POI export is slow enough to parse that
# doing it lazily meant the *first* toggle silently hung (indistinguishable
# from broken), instead of the upload's own "Uploading…" status covering
# that wait like it already does for the upload itself. A file that fails
# to parse still saves fine — same as before, it just surfaces the error
# on first view instead of here.
def save(folder:, filename:, io:)
dir = resolve_dir(folder.to_s)
FileUtils.mkdir_p(dir)
name = unique_filename(dir, safe_filename(filename))
File.binwrite(File.join(dir, name), io.read)
relative = folder.to_s.empty? ? name : "#{folder}/#{name}"
begin
geojson(relative)
rescue Gpx::ParseError, Fit::ParseError
nil
end
relative
end
@@ -93,12 +111,17 @@ 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)
if !File.file?(cache) || File.mtime(cache) < File.mtime(path)
unless geojson_cached?(path)
data = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path)
File.write(cache, JSON.generate(data))
end
@@ -122,7 +145,16 @@ module Maptool
"#{path}.geojson"
end
TRACK_EXTENSIONS = %w[.gpx .fit].freeze
# 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 .geojson].freeze
def safe_filename(filename)
base = File.basename(filename.to_s)
@@ -151,9 +183,15 @@ module Maptool
if File.directory?(full)
{ "type" => "folder", "name" => entry, "path" => rel, "children" => list_children(full, rel) }
elsif entry.downcase.end_with?(".gpx")
{ "type" => "file", "name" => entry, "path" => rel, "kinds" => Gpx.kinds(full) }
{ "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) }
{ "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
+15
View File
@@ -582,6 +582,21 @@ html, body {
line-height: 1.4;
}
.gpx-popup__photos {
display: flex;
gap: 4px;
margin: 4px 0;
overflow-x: auto;
}
.gpx-popup__photo {
width: 64px;
height: 64px;
flex-shrink: 0;
object-fit: cover;
border-radius: 4px;
}
.gpx-popup__meta {
margin-top: 8px;
padding-top: 6px;
+34 -4
View File
@@ -6,6 +6,7 @@ import {
WAYPOINT_ICON,
TRACK_ICON,
ROUTE_ICON,
GEOJSON_ICON,
MAP_MARKER_ICON,
POI_ICONS,
PARK4NIGHT_TAGS,
@@ -276,6 +277,7 @@ export class GpxPanelControl {
name.title = file.name;
label.appendChild(checkbox);
(file.kinds || []).forEach((kind) => label.appendChild(this._kindIcon(kind)));
if (file.cached) label.appendChild(this._cachedIcon());
label.appendChild(name);
row.appendChild(label);
row.appendChild(this._deleteButton(() => this._deleteFile(file.path)));
@@ -299,6 +301,14 @@ export class GpxPanelControl {
return icon;
}
_cachedIcon() {
const icon = document.createElement("span");
icon.className = "gpx-tree__kind-icon";
icon.innerHTML = GEOJSON_ICON;
icon.title = "Ready to view instantly (GeoJSON cached)";
return icon;
}
_deleteButton(onClick) {
const button = document.createElement("button");
button.type = "button";
@@ -610,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…`);
@@ -754,10 +767,11 @@ function gpxFileUrl(path) {
// properties come straight from an uploaded GPX file — untrusted input —
// so every value is escaped before landing in the popup's HTML.
function popupHtml({ name, description, time, category }) {
function popupHtml({ name, description, time, category, photos }) {
const parts = [];
if (name) parts.push(`<strong>${escapeHtml(name)}</strong>`);
if (category) parts.push(`<p class="gpx-popup__category">${escapeHtml(category)}</p>`);
if (photos?.length > 0) parts.push(formatPhotos(photos));
if (description) parts.push(formatDescription(description));
if (time) {
const parsed = new Date(time);
@@ -808,6 +822,22 @@ function escapeHtml(text) {
const DESCRIPTION_META_LINE = /^(Rating|Services|Activities):\s*(.+)$/i;
const DESCRIPTION_LINK_LINE = /^https?:\/\/\S+$/;
// <link> photo URLs (a newer p4n2gpx export adds one per park4night listing
// photo — see lib/maptool/gpx.rb's waypoint_photos) as a row of thumbnails,
// each opening the full-size image in a new tab. Untrusted URLs from an
// uploaded file, same as everywhere else in this popup — escaped for use as
// both an href and an img src.
function formatPhotos(photos) {
const thumbs = photos
.map((url) => escapeHtml(url))
.map(
(url) =>
`<a href="${url}" target="_blank" rel="noopener noreferrer"><img class="gpx-popup__photo" src="${url}" loading="lazy" alt=""></a>`,
)
.join("");
return `<div class="gpx-popup__photos">${thumbs}</div>`;
}
function formatDescription(description) {
const body = [];
const meta = [];
+10
View File
@@ -54,6 +54,16 @@ export const WAYPOINT_ICON = `
export const TRACK_ICON = GPX_ICON;
// Shown next to a file whose GeoJSON conversion is already cached on disk
// (GpxStore#geojson) — ready to view instantly instead of paying REXML's
// parse cost on click. Curly braces read as "structured data ready", the
// same shorthand most editors/tools use for JSON.
export const GEOJSON_ICON = `
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 4c-1.5 0-2 .8-2 2v3c0 1-.5 1.5-1.5 1.5 1 0 1.5.5 1.5 1.5v3c0 1.2.5 2 2 2"/>
<path d="M15 4c1.5 0 2 .8 2 2v3c0 1 .5 1.5 1.5 1.5-1 0-1.5.5-1.5 1.5v3c0 1.2-.5 2-2 2"/>
</svg>`;
// Same shape as TRACK_ICON, dashed — a route is planned rather than
// recorded, the same visual convention most map apps use for the
// distinction.
+5
View File
@@ -20,4 +20,9 @@
<locus:icon xmlns:locus="http://www.locusmap.eu">res/drawable-hdpi/ic_parking_small.png</locus:icon>
</extensions>
</wpt>
<wpt lat="48.103" lon="17.203">
<name>With photos</name>
<link href="https://cdn3.park4night.com/lieu/1_100/1_gd.jpg"><text>Photo</text></link>
<link href="https://cdn3.park4night.com/lieu/1_100/2_gd.jpg"><text>Photo</text></link>
</wpt>
</gpx>
+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)
+12
View File
@@ -74,6 +74,18 @@ class GpxTest < Minitest::Test
assert_nil plain["properties"]["icon"]
end
def test_waypoint_captures_link_hrefs_as_photos
geojson = Maptool::Gpx.to_geojson(fixture_path("sample_poi.gpx"))
with_photos = geojson["features"].find { |f| f["properties"]["name"] == "With photos" }
plain = geojson["features"].find { |f| f["properties"]["name"] == "Plain waypoint" }
assert_equal(
["https://cdn3.park4night.com/lieu/1_100/1_gd.jpg", "https://cdn3.park4night.com/lieu/1_100/2_gd.jpg"],
with_photos["properties"]["photos"]
)
assert_empty plain["properties"]["photos"]
end
def test_kinds_detects_routes
assert_equal ["route"], Maptool::Gpx.kinds(fixture_path("sample_route.gpx"))
end
+69 -6
View File
@@ -68,20 +68,26 @@ class GpxStoreTest < Minitest::Test
assert_empty @store.tree["children"].reject { |c| c["name"] == "archive" }
end
def test_geojson_is_cached_alongside_the_source_file
def test_save_eagerly_caches_the_geojson_conversion
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
cache_path = @store.resolve_file(path) + ".geojson"
refute File.file?(cache_path)
first = @store.geojson(path)
assert File.file?(cache_path)
# Corrupt the cache directly to prove the second call reused it instead
# of re-parsing the (still-valid) source file.
first = @store.geojson(path)
# Corrupt the cache directly to prove the call above reused it instead of
# re-parsing the (still-valid) source file.
File.write(cache_path, first.sub("waypoint", "cached-waypoint"))
assert_includes @store.geojson(path), "cached-waypoint"
end
def test_save_of_an_unparseable_file_still_succeeds
path = @store.save(folder: "", filename: "bad.gpx", io: StringIO.new("not gpx at all"))
refute File.file?(@store.resolve_file(path) + ".geojson")
assert_raises(Maptool::Gpx::ParseError) { @store.geojson(path) }
end
def test_geojson_cache_is_invalidated_when_the_file_is_resaved
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
@store.geojson(path)
@@ -115,6 +121,59 @@ class GpxStoreTest < Minitest::Test
assert_raises(Errno::ENOENT) { @store.geojson("nope.gpx") }
end
def test_tree_reports_cached_true_after_a_successful_save
@store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
file = @store.tree["children"].find { |c| c["name"] == "ride.gpx" }
assert file["cached"]
end
def test_tree_reports_cached_false_for_an_unparseable_file
@store.save(folder: "", filename: "bad.gpx", io: StringIO.new("not gpx at all"))
file = @store.tree["children"].find { |c| c["name"] == "bad.gpx" }
refute file["cached"]
end
def test_tree_reports_cached_false_once_the_source_is_resaved
path = @store.save(folder: "", filename: "ride.gpx", io: StringIO.new(sample_gpx))
FileUtils.touch(@store.resolve_file(path) + ".geojson", mtime: Time.now - 10)
File.write(@store.resolve_file(path), sample_gpx)
FileUtils.touch(@store.resolve_file(path), mtime: Time.now)
file = @store.tree["children"].find { |c| c["name"] == "ride.gpx" }
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
@@ -122,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>