Support uploading FIT files (and zipped exports) alongside GPX
CI / check (push) Successful in 2m11s
CI / deploy (push) Successful in 2m27s

Maptool::Fit reads Garmin's binary FIT format directly rather than
adding a gem — the one Ruby option depends on activesupport, unmaintained
since 2022, for a single-purpose binary parser. Only "record" messages
are read (position + altitude; everything else is skipped by byte
length, never interpreted); field numbers and scale/offset are
Garmin's own (fit-python-sdk's profile.py), and the framing plus all
extracted values were checked byte-for-byte against 5 real device
files from python-fitparse's test suite before this shipped.

A .zip upload (Garmin's bulk/FIT export format) is expanded into its
.gpx/.fit entries via rubyzip — that one's an actual dependency, since
ZIP's own format isn't the kind of narrow, single-purpose parsing FIT
above is.
This commit is contained in:
randy committed 2026-09-07 12:23:37 +02:00
1 parent 8d3c126a14
commit fc174d4267
8 files changed
+338 -29

No files matched your search

+30 -5
View File
@@ -2,7 +2,10 @@ require "sinatra/base"
require "sinatra/json"
require "oauth2"
require "securerandom"
require "zip"
require "stringio"
require_relative "lib/maptool/gpx"
require_relative "lib/maptool/fit"
require_relative "lib/maptool/gpx_store"
require_relative "lib/maptool/gpsd_client"
require_relative "lib/maptool/livetrack_store"
@@ -69,6 +72,18 @@ module Maptool
def callback_url
"#{request.base_url}/auth/callback"
end
def save_zip_entries(folder:, io:)
paths = []
Zip::File.open_buffer(io) do |zip|
zip.each do |entry|
next unless entry.file? && GpxStore::TRACK_EXTENSIONS.any? { |ext| entry.name.downcase.end_with?(ext) }
paths << GPX_STORE.save(folder: folder, filename: File.basename(entry.name), io: StringIO.new(entry.get_input_stream.read))
end
end
paths
end
end
get "/login" do
@@ -149,18 +164,27 @@ module Maptool
json GPX_STORE.tree
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.
post "/api/gpx" do
require_login!
file = params[:file]
halt 400, json(error: "file is required") unless file && file[:tempfile]
begin
path = GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile])
paths = if file[:filename].to_s.downcase.end_with?(".zip")
save_zip_entries(folder: params[:folder].to_s, io: file[:tempfile])
else
[GPX_STORE.save(folder: params[:folder].to_s, filename: file[:filename], io: file[:tempfile])]
end
rescue GpxStore::InvalidPath
halt 400, json(error: "invalid folder")
rescue Zip::Error
halt 400, json(error: "invalid zip file")
end
json path: path, tree: GPX_STORE.tree
json paths: paths, tree: GPX_STORE.tree
end
get "/api/gpx/file/*" do
@@ -172,9 +196,10 @@ module Maptool
halt 404 unless File.file?(path)
begin
json Gpx.to_geojson(path)
rescue Gpx::ParseError
halt 422, json(error: "could not parse GPX file")
geojson = path.downcase.end_with?(".fit") ? Fit.to_geojson(path) : Gpx.to_geojson(path)
json geojson
rescue Gpx::ParseError, Fit::ParseError
halt 422, json(error: "could not parse file")
end
end