Measure already draws distance lines and area polygons by hand — a second click-to-draw UI in the notes tool (Point/Area mode, from the previous commit) duplicated that. Replaced it with a "Save as note" button on the Measure panel that saves whatever shape is currently drawn there; NotesControl goes back to being just a one-click point pin. Both share a single NoteSaveForm (notes.js) for the note-text + target-layer-file UI instead of two copies of it. gpx.js: the "lines" source now also renders a note-flagged LineString (dashed, popup-on-click) alongside the Polygon fill/outline from before — the plain track-line layer excludes category:"Note" features so a saved distance note doesn't also get the track hover-summary/ profile-panel treatment. Notes are now individually editable/deletable from their own popup (logged-in only): Edit swaps the popup content for an inline textarea, Delete confirms and removes. Both target the feature by a new client-generated properties.id rather than array position (new GpxStore#update_feature/#delete_feature, PUT/DELETE /api/gpx/notes), since neither array index nor the note's own text (the thing being edited) is a stable identifier. Verified live: Measure's Save button (disabled/enabled state, saves the exact on-screen shape), the popup's Edit/Delete buttons and inline edit form. Full test suite: 115 runs, 0 failures (new coverage for update_feature/delete_feature and the PUT/DELETE routes).
269 lines
11 KiB
Ruby
269 lines
11 KiB
Ruby
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
|
|
# user-chosen folders (arbitrary nesting). All paths coming from the
|
|
# outside (folder names, file paths in URLs) are resolved and checked
|
|
# against the store root before touching the filesystem, so path
|
|
# traversal (e.g. "../../etc") can't escape it.
|
|
#
|
|
# A .zip upload (Garmin exports FIT files zipped) isn't handled here —
|
|
# the caller (app.rb) unpacks it and calls save() once per extracted
|
|
# file, same as it would for several files dropped in at once.
|
|
class GpxStore
|
|
class InvalidPath < StandardError; end
|
|
|
|
def initialize(root)
|
|
@root = File.expand_path(root)
|
|
FileUtils.mkdir_p(@root)
|
|
end
|
|
|
|
# Nested { type:, name:, path:, children: } hash describing every
|
|
# 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
|
|
|
|
# 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
|
|
|
|
# Absolute filesystem path for a store-relative file path, after
|
|
# confirming it stays within the store root.
|
|
def resolve_file(relative_path)
|
|
path = File.expand_path(File.join(@root, relative_path.to_s))
|
|
raise InvalidPath unless within_root?(path)
|
|
|
|
path
|
|
end
|
|
|
|
def create_folder(path)
|
|
raise InvalidPath if path.to_s.strip.empty?
|
|
|
|
FileUtils.mkdir_p(resolve_dir(path.to_s))
|
|
end
|
|
|
|
def delete_file(relative_path)
|
|
path = resolve_file(relative_path)
|
|
raise Errno::ENOENT, path unless File.file?(path)
|
|
|
|
File.delete(path)
|
|
File.delete(geojson_cache_path(path)) if File.file?(geojson_cache_path(path))
|
|
end
|
|
|
|
def delete_folder(relative_path)
|
|
raise InvalidPath if relative_path.to_s.strip.empty?
|
|
|
|
dir = resolve_dir(relative_path.to_s)
|
|
raise InvalidPath if dir == @root
|
|
raise Errno::ENOENT, dir unless File.directory?(dir)
|
|
|
|
FileUtils.rm_rf(dir)
|
|
end
|
|
|
|
# Moves a file into `to_folder` (created if needed), disambiguating the
|
|
# filename on collision. Returns the new store-relative path.
|
|
def move(from:, to_folder:)
|
|
source = resolve_file(from)
|
|
raise Errno::ENOENT, source unless File.file?(source)
|
|
|
|
dest_dir = resolve_dir(to_folder.to_s)
|
|
FileUtils.mkdir_p(dest_dir)
|
|
name = unique_filename(dest_dir, File.basename(source))
|
|
dest = File.join(dest_dir, name)
|
|
FileUtils.mv(source, dest)
|
|
FileUtils.mv(geojson_cache_path(source), geojson_cache_path(dest)) if File.file?(geojson_cache_path(source))
|
|
to_folder.to_s.empty? ? name : "#{to_folder}/#{name}"
|
|
end
|
|
|
|
# The GeoJSON conversion (Gpx.to_geojson/Fit.to_geojson) re-parses the
|
|
# whole file with REXML every call — fine for a small track, but a
|
|
# several-thousand-waypoint POI export (e.g. a park4night area export)
|
|
# 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)
|
|
data = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path)
|
|
File.write(cache, JSON.generate(data))
|
|
end
|
|
File.read(cache)
|
|
end
|
|
|
|
# Appends one GeoJSON Feature (a hand-drawn point/note, see notes.js) to
|
|
# the FeatureCollection at `path` — or, if `path` is nil, to a new or
|
|
# existing file named `name` inside `folder`. Deliberately overwrites in
|
|
# place rather than disambiguating like save() does: repeat calls with
|
|
# the same path/name are meant to grow one running "layer" file, not
|
|
# spawn "notes-1.geojson", "notes-2.geojson", ... siblings. Returns the
|
|
# store-relative path written to, so a caller that didn't already know
|
|
# it (the new-file case) can show/select it afterwards.
|
|
def append_feature(feature:, path: nil, folder: "", name: "")
|
|
if path && !path.to_s.strip.empty?
|
|
file = resolve_file(path)
|
|
raise Errno::ENOENT, file unless File.file?(file)
|
|
else
|
|
dir = resolve_dir(folder.to_s)
|
|
FileUtils.mkdir_p(dir)
|
|
filename = safe_geojson_filename(name)
|
|
file = File.join(dir, filename)
|
|
path = folder.to_s.empty? ? filename : "#{folder}/#{filename}"
|
|
end
|
|
|
|
collection = File.file?(file) ? JSON.parse(File.read(file)) : { "type" => "FeatureCollection", "features" => [] }
|
|
(collection["features"] ||= []) << feature
|
|
File.write(file, JSON.generate(collection))
|
|
path
|
|
end
|
|
|
|
# Replaces the feature whose properties["id"] matches `id` (see
|
|
# append_feature/notes.js — every hand-drawn note gets a client-
|
|
# generated id specifically so it can be found again later, since nothing
|
|
# else here is a stable identifier: array position shifts as other
|
|
# features are added/removed, and a note's own text is exactly the thing
|
|
# being edited). Raises Errno::ENOENT for a missing file *or* a missing
|
|
# feature — same "nothing to act on" story either way.
|
|
def update_feature(path:, id:, feature:)
|
|
file = resolve_file(path)
|
|
raise Errno::ENOENT, file unless File.file?(file)
|
|
|
|
collection = JSON.parse(File.read(file))
|
|
index = (collection["features"] || []).find_index { |f| f.dig("properties", "id") == id }
|
|
raise Errno::ENOENT, "#{path}##{id}" unless index
|
|
|
|
collection["features"][index] = feature
|
|
File.write(file, JSON.generate(collection))
|
|
end
|
|
|
|
# Same id-matching story as update_feature above.
|
|
def delete_feature(path:, id:)
|
|
file = resolve_file(path)
|
|
raise Errno::ENOENT, file unless File.file?(file)
|
|
|
|
collection = JSON.parse(File.read(file))
|
|
before = (collection["features"] || []).length
|
|
collection["features"] = (collection["features"] || []).reject { |f| f.dig("properties", "id") == id }
|
|
raise Errno::ENOENT, "#{path}##{id}" if collection["features"].length == before
|
|
|
|
File.write(file, JSON.generate(collection))
|
|
end
|
|
|
|
private
|
|
|
|
def resolve_dir(folder)
|
|
path = File.expand_path(File.join(@root, folder))
|
|
raise InvalidPath unless within_root?(path)
|
|
|
|
path
|
|
end
|
|
|
|
def within_root?(path)
|
|
path == @root || path.start_with?(@root + File::SEPARATOR)
|
|
end
|
|
|
|
def geojson_cache_path(path)
|
|
"#{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 .geojson].freeze
|
|
|
|
def safe_filename(filename)
|
|
base = File.basename(filename.to_s)
|
|
base = "upload.gpx" if base.empty? || base == "." || base == ".."
|
|
base = "#{base}.gpx" unless TRACK_EXTENSIONS.any? { |ext| base.downcase.end_with?(ext) }
|
|
base
|
|
end
|
|
|
|
# Same path-safety idea as safe_filename above, but for a brand-new
|
|
# notes layer: always .geojson (never falls back to .gpx), and named
|
|
# after the user's own "Parking spots"-style label rather than an
|
|
# uploaded file's original name.
|
|
def safe_geojson_filename(name)
|
|
base = File.basename(name.to_s.strip)
|
|
base = "notes" if base.empty? || base == "." || base == ".."
|
|
base = base.sub(/\.geojson\z/i, "")
|
|
"#{base}.geojson"
|
|
end
|
|
|
|
def unique_filename(dir, name)
|
|
return name unless File.exist?(File.join(dir, name))
|
|
|
|
ext = File.extname(name)
|
|
stem = name[0...-ext.length]
|
|
n = 1
|
|
n += 1 while File.exist?(File.join(dir, "#{stem}-#{n}#{ext}"))
|
|
"#{stem}-#{n}#{ext}"
|
|
end
|
|
|
|
def list_children(dir, relative)
|
|
entries = Dir.children(dir).sort_by { |entry| [File.directory?(File.join(dir, entry)) ? 0 : 1, entry] }
|
|
|
|
entries.filter_map do |entry|
|
|
full = File.join(dir, entry)
|
|
rel = relative.empty? ? entry : "#{relative}/#{entry}"
|
|
|
|
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), "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
|
|
end
|
|
end
|