Add GPX upload with hierarchical folders and map display
Upload GPX files into a folder hierarchy (traversal-safe path resolution against the store root), browse them in a tree panel, and toggle each one's waypoints/tracks on the map. Waypoints render as clickable points showing name, description and time; bare URLs in the description are turned into safe links. GPX layers survive switching between the Online/Offline base map, same as the radar overlay. Adds rexml as an explicit dependency — it moved to a bundled gem in this Ruby version and isn't picked up by Bundler otherwise.
This commit is contained in:
1 parent
16e06b2ffe
commit
4e0d2abbd0
13 files changed
+629
-2
No files matched your search
@@ -0,0 +1,67 @@
|
||||
require "rexml/document"
|
||||
|
||||
module Maptool
|
||||
# Converts a GPX file to a GeoJSON FeatureCollection: waypoints (<wpt>)
|
||||
# become Point features, tracks (<trk>) become MultiLineString features
|
||||
# (one line per <trkseg>). Routes (<rte>) are not handled — GPX in
|
||||
# practice is either points or recorded tracks for this app.
|
||||
module Gpx
|
||||
class ParseError < StandardError; end
|
||||
|
||||
def self.to_geojson(path)
|
||||
root = REXML::Document.new(File.read(path)).root
|
||||
raise ParseError, "not a GPX file" unless root && root.name == "gpx"
|
||||
|
||||
features = []
|
||||
root.each_element("wpt") { |wpt| features << waypoint_feature(wpt) }
|
||||
root.each_element("trk") { |trk| features << track_feature(trk) }
|
||||
|
||||
{ "type" => "FeatureCollection", "features" => features }
|
||||
rescue REXML::ParseException => e
|
||||
raise ParseError, e.message
|
||||
end
|
||||
|
||||
def self.waypoint_feature(wpt)
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "Point", "coordinates" => coordinate(wpt) },
|
||||
"properties" => {
|
||||
"kind" => "waypoint",
|
||||
"name" => child_text(wpt, "name"),
|
||||
"description" => child_text(wpt, "desc"),
|
||||
"time" => child_text(wpt, "time"),
|
||||
},
|
||||
}
|
||||
end
|
||||
private_class_method :waypoint_feature
|
||||
|
||||
def self.track_feature(trk)
|
||||
lines = []
|
||||
trk.each_element("trkseg") do |seg|
|
||||
points = []
|
||||
seg.each_element("trkpt") { |pt| points << coordinate(pt) }
|
||||
lines << points unless points.empty?
|
||||
end
|
||||
|
||||
{
|
||||
"type" => "Feature",
|
||||
"geometry" => { "type" => "MultiLineString", "coordinates" => lines },
|
||||
"properties" => { "kind" => "track", "name" => child_text(trk, "name") },
|
||||
}
|
||||
end
|
||||
private_class_method :track_feature
|
||||
|
||||
def self.coordinate(point_el)
|
||||
lat = point_el.attributes["lat"].to_f
|
||||
lon = point_el.attributes["lon"].to_f
|
||||
ele = child_text(point_el, "ele")
|
||||
ele ? [lon, lat, ele.to_f] : [lon, lat]
|
||||
end
|
||||
private_class_method :coordinate
|
||||
|
||||
def self.child_text(el, name)
|
||||
el.elements[name]&.text&.strip
|
||||
end
|
||||
private_class_method :child_text
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
require "fileutils"
|
||||
|
||||
module Maptool
|
||||
# Filesystem-backed store for uploaded GPX 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.
|
||||
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 file under the store root.
|
||||
def tree
|
||||
{ "type" => "folder", "name" => "root", "path" => "", "children" => list_children(@root, "") }
|
||||
end
|
||||
|
||||
# Saves `io`'s contents as a .gpx file under `folder` (created if
|
||||
# needed), deriving a safe filename from `filename`. Returns the
|
||||
# stored file's path relative to the store root.
|
||||
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}"
|
||||
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
|
||||
|
||||
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 safe_filename(filename)
|
||||
base = File.basename(filename.to_s)
|
||||
base = "upload.gpx" if base.empty? || base == "." || base == ".."
|
||||
base = "#{base}.gpx" unless base.downcase.end_with?(".gpx")
|
||||
base
|
||||
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)
|
||||
Dir.children(dir).sort.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 }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user