Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4f205667b | ||
|
|
44d235bbcf | ||
|
|
dd0ad6d845 | ||
|
|
dd272ffb8a |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Vendored
+5
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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">×</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>
|
||||
|
||||
Reference in new issue
Block a user