From d7b93325d4f70eeab369fa8a3c4cce9f0c4e0081 Mon Sep 17 00:00:00 2001 From: Martin Kagamino Lehoux Date: Thu, 27 Aug 2026 19:03:34 +0200 Subject: Add dual GPX/FIT exporter and backfill for rides Phase 1 writes both .gpx and .fit on Strava import, adds fit_path column, renames storage.gpx_dir to storage.rides_dir, and backfills missing FIT files from existing GPX without breaking CLI progress. FIT files are gitignored. --- .gitignore | 1 + config.example.yaml | 2 +- config/config.go | 8 +- db/migrations/20260828000000_add_fit_path.sql | 29 ++++ db/schema.sql | 5 +- go.mod | 9 ++ go.sum | 63 ++++++++ ride/fit.go | 215 ++++++++++++++++++++++++++ ride/store.go | 49 +++++- strava/sync.go | 107 ++++++++++++- strava/sync_test.go | 17 +- web/server.go | 2 +- web/server_test.go | 13 +- web/sync.go | 33 ++-- 14 files changed, 519 insertions(+), 34 deletions(-) create mode 100644 db/migrations/20260828000000_add_fit_path.sql create mode 100644 ride/fit.go diff --git a/.gitignore b/.gitignore index f5e09aa..be64956 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ config.yaml *.db *.png data/rides/*.gpx +data/rides/*.fit debug_department_*.csv france-latest.osm.pbf biking_home diff --git a/config.example.yaml b/config.example.yaml index c4b22a2..b46bf1a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -6,7 +6,7 @@ server: public_url: http://localhost:8080 storage: - gpx_dir: data/rides + rides_dir: data/rides official_climb: match_radius_m: 100 diff --git a/config/config.go b/config/config.go index f95d3f8..8a61d43 100644 --- a/config/config.go +++ b/config/config.go @@ -29,7 +29,7 @@ type ServerConfig struct { } type StorageConfig struct { - GPXDir string `yaml:"gpx_dir"` + RidesDir string `yaml:"rides_dir"` } type StravaConfig struct { @@ -57,7 +57,7 @@ func Default() Config { Address: "127.0.0.1:8080", PublicURL: "http://localhost:8080", }, - Storage: StorageConfig{GPXDir: "data/rides"}, + Storage: StorageConfig{RidesDir: "data/rides"}, OfficialClimb: OfficialClimbConfig{MatchRadiusM: official_climb.DefaultMatchRadiusM}, Pyroscope: PyroscopeConfig{ ApplicationName: "biking_home", @@ -81,8 +81,8 @@ func Load(filename string) (Config, error) { if config.Server.Address == "" || config.Server.PublicURL == "" { return Config{}, fmt.Errorf("config %q: server.address and server.public_url are required", filename) } - if config.Storage.GPXDir == "" { - return Config{}, fmt.Errorf("config %q: storage.gpx_dir is required", filename) + if config.Storage.RidesDir == "" { + return Config{}, fmt.Errorf("config %q: storage.rides_dir is required", filename) } if config.Pyroscope.Enabled && config.Pyroscope.ApplicationName == "" { return Config{}, fmt.Errorf("config %q: pyroscope.application_name is required when pyroscope is enabled", filename) diff --git a/db/migrations/20260828000000_add_fit_path.sql b/db/migrations/20260828000000_add_fit_path.sql new file mode 100644 index 0000000..7f34ab8 --- /dev/null +++ b/db/migrations/20260828000000_add_fit_path.sql @@ -0,0 +1,29 @@ +-- migrate:up +ALTER TABLE rides ADD COLUMN fit_path TEXT NOT NULL DEFAULT ''; + +-- migrate:down +CREATE TABLE rides_new ( + id integer primary key, + external_id text unique not null, + gpx_path text not null, + name text not null, + type text not null, + start_date text not null, + distance_m real not null, + external_moving_time_s integer not null, + elapsed_time_s integer not null, + total_elevation_gain_m real not null, + average_speed_mps real not null, + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + cotacol_score real, + cotacol_algo_version text, + moving_time_s integer, + moving_time_algo_version text, + bike_type TEXT NOT NULL DEFAULT '' CHECK (bike_type IN ('', 'road', 'mtb')), + ride_type TEXT NOT NULL DEFAULT '' CHECK (ride_type IN ('', 'training', 'indoor', 'race', 'transport')) +); +INSERT INTO rides_new (id, external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps, created_at, updated_at, cotacol_score, cotacol_algo_version, moving_time_s, moving_time_algo_version, bike_type, ride_type) +SELECT id, external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, elapsed_time_s, total_elevation_gain_m, average_speed_mps, created_at, updated_at, cotacol_score, cotacol_algo_version, moving_time_s, moving_time_algo_version, bike_type, ride_type FROM rides; +DROP TABLE rides; +ALTER TABLE rides_new RENAME TO rides; diff --git a/db/schema.sql b/db/schema.sql index 8ae55f7..a22e98f 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -13,7 +13,7 @@ CREATE TABLE rides ( average_speed_mps real not null, created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -, cotacol_score real, cotacol_algo_version text, moving_time_s integer, moving_time_algo_version text, bike_type TEXT NOT NULL DEFAULT '' CHECK (bike_type IN ('', 'road', 'mtb')), ride_type TEXT NOT NULL DEFAULT '' CHECK (ride_type IN ('', 'training', 'indoor', 'race', 'transport'))); +, cotacol_score real, cotacol_algo_version text, moving_time_s integer, moving_time_algo_version text, bike_type TEXT NOT NULL DEFAULT '' CHECK (bike_type IN ('', 'road', 'mtb')), ride_type TEXT NOT NULL DEFAULT '' CHECK (ride_type IN ('', 'training', 'indoor', 'race', 'transport')), fit_path TEXT NOT NULL DEFAULT ''); CREATE TABLE official_climbs ( id integer primary key, name text not null, @@ -42,4 +42,5 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260820184307'), ('20260821000000'), ('20260823000000'), - ('20260827000000'); + ('20260827000000'), + ('20260828000000'); diff --git a/go.mod b/go.mod index 114b215..3f89299 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/andybalholm/cascadia v1.3.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cli/browser v1.3.0 // indirect + github.com/client9/misspell v0.3.4 // indirect github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -33,15 +34,19 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 // indirect github.com/grafana/pyroscope-go v1.4.2 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.11 // indirect github.com/joho/godotenv v1.4.0 // indirect github.com/kataras/i18n v0.0.8 // indirect + github.com/kisielk/errcheck v1.6.1 // indirect github.com/klauspost/compress v1.18.7 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lib/pq v1.10.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/paulmach/orb v0.13.0 // indirect @@ -51,6 +56,7 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/encoding v0.4.0 // indirect + github.com/tormoder/fit v0.15.0 // indirect github.com/urfave/cli/v2 v2.24.2 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.lsp.dev/jsonrpc2 v0.10.0 // indirect @@ -60,6 +66,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a // indirect golang.org/x/mod v0.39.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect @@ -71,6 +78,8 @@ require ( google.golang.org/protobuf v1.36.10 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/ini.v1 v1.67.0 // indirect + honnef.co/go/tools v0.4.2 // indirect + mvdan.cc/gofumpt v0.4.0 // indirect ) tool ( diff --git a/go.sum b/go.sum index c1513d2..8afb308 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,11 @@ +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/ClickHouse/clickhouse-go v1.5.4 h1:cKjXeYLNWVJIx2J1K6H2CqyRmfwVJVY1OV1coaaFcI0= github.com/ClickHouse/clickhouse-go v1.5.4/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= github.com/DataDog/czlib v0.0.0-20240814115052-86a9592b3985 h1:0nepyu+UcpcOt3rrr0G4PvNDuoEW2aoqtbh2NK0AQ3w= github.com/DataDog/czlib v0.0.0-20240814115052-86a9592b3985/go.mod h1:ROY4muaTWpoeQAx/oUkvxe9zKCmgU5xDGXsfEbA+omc= +github.com/OneOfOne/xxhash v1.2.5/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= github.com/a-h/parse v0.0.0-20240121214402-3caf7543159a h1:vlmAfVwFK9sRpDlJyuHY8htP+KfGHB2VH02u0SoIufk= @@ -20,12 +22,16 @@ github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x0 github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= github.com/bkaradzic/go-lz4 v1.0.0 h1:RXc4wYsyz985CkXXeX04y4VnZFGG8Rd43pRaHsOXAKk= github.com/bkaradzic/go-lz4 v1.0.0/go.mod h1:0YdlkowM3VswSROI7qDxhRvJ3sLhlFrRRwjwegp5jy4= +github.com/bradfitz/latlong v0.0.0-20170410180902-f3db6d0dff40/go.mod h1:ZcXX9BndVQx6Q/JM6B8x7dLE9sl20S+TQsv4KO7tEQk= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 h1:F1EaeKL/ta07PY/k9Os/UFtwERei2/XzGemhpGnBKNg= github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= @@ -36,6 +42,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -43,11 +50,16 @@ github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8 h1:PVRE9d4AQKmbelZ7emNig1+NT27DUmKZn5qXxfio54U= +github.com/gordonklaus/ineffassign v0.0.0-20210914165742-4cc7213b9bc8/go.mod h1:Qcp2HIAYhR7mNUVSIxZww3Guk4it82ghYcEXIAk+QT0= github.com/grafana/pyroscope-go v1.4.2 h1:0LW5HrUJXgGr9zF5gITP/HaFXN9/LsMiwlgVJAK75l0= github.com/grafana/pyroscope-go v1.4.2/go.mod h1:Ej13Jr05rRJrjWvrrFhfh6gGYXtfibuukOs3Tl3Y7QQ= github.com/grafana/pyroscope-go/godeltaprof v0.1.11 h1:el5LYpXissAiCKZ5/6yjlr6mhYVV6Cp5lahTocxraXM= @@ -57,13 +69,18 @@ github.com/jftuga/geodist v1.0.0/go.mod h1:BohEDxpZ8S5ADAxW/9EKPSKWOVl0+3wHENIT4 github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jonas-p/go-shp v0.1.1/go.mod h1:MRIhyxDQ6VVp0oYeD7yPGr5RSTNScUFKCDsI5DR7PtI= github.com/kataras/i18n v0.0.8 h1:thiDRqq4fN2sQOK5CwR5Yh1yT7swP6B3wnadILhqjhk= github.com/kataras/i18n v0.0.8/go.mod h1:M/yRAqQ3Y7z2oSpotxG/+nPrgsJLas6t0kEJfIJk98E= +github.com/kisielk/errcheck v1.6.1 h1:cErYo+J4SmEjdXZrVXGwLJCE2sB06s23LpkcyWNrT+s= +github.com/kisielk/errcheck v1.6.1/go.mod h1:nXw/i/MfnvRHqXa7XXmQMUB0oNFGuBrNI8d8NLy0LPw= github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kortschak/utter v0.0.0-20180609113506-364ec7d7a8f4/go.mod h1:oDr41C7kH9wvAikWyFhr6UFr8R7nelpmCF5XR5rL7I8= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -85,6 +102,8 @@ github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b h1:jdFI9paVi4E33U9TAExBpKPl1l5MnOn7VOLbb4Mvzzg= +github.com/mdempsky/unconvert v0.0.0-20230125054757-2661c2c99a9b/go.mod h1:mOq/NVYz3H5h7Av88ia14HIMF/UdGXj9dp8P/+b566A= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= @@ -102,6 +121,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -111,6 +131,7 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.4.0 h1:MEBYvRqiUB2nfR2criEXWqwdY6HJOUrCn5hboVOVmy8= github.com/segmentio/encoding v0.4.0/go.mod h1:/d03Cd8PoaDeceuhUUUQWjU0KhWjrmYrWPgtJHYZSnI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -118,12 +139,17 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tealeg/xlsx v1.0.3/go.mod h1:uxu5UY2ovkuRPWKQ8Q7JG0JbSivrISjdPzZQKeo74mA= github.com/tkrajina/gpxgo v1.4.0 h1:cSD5uSwy3VZuNFieTEZLyRnuIwhonQEkGPkPGW4XNag= github.com/tkrajina/gpxgo v1.4.0/go.mod h1:BXSMfUAvKiEhMEXAFM2NvNsbjsSvp394mOvdcNjettg= +github.com/tormoder/fit v0.15.0 h1:oW1dhvGqPIwBJdRJfWzW/jqYU705oBmLcJq4TJO7SqU= +github.com/tormoder/fit v0.15.0/go.mod h1:J+m0+sz5qljhPaP34CgJz8uFD8Vzdsf96D3Hj99DMLQ= github.com/urfave/cli/v2 v2.24.2 h1:q1VA+ofZ8SWfEKB9xXHUD4QZaeI9e+ItEqSbfH2JBXk= github.com/urfave/cli/v2 v2.24.2/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 h1:qXafrlZL1WsJW5OokjraLLRURHiw0OzKHD/RNdspp4w= github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04/go.mod h1:FiwNQxz6hGoNFBC4nIx+CxZhI3nne5RmIOlT/MXcSD4= @@ -142,37 +168,59 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8 go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a h1:Jw5wfR+h9mnIYH+OtGT2im5wV1YGGDora5vTv/aa5bE= +golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220819030929-7fc1605a5dde/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220829200755-d48e67d00261/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= @@ -183,6 +231,8 @@ golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= @@ -192,6 +242,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= @@ -199,13 +251,19 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.1-0.20221208213631-3f74d914ae6d/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= @@ -215,8 +273,13 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.4.2 h1:6qXr+R5w+ktL5UkwEbPp+fEvfyoMPche6GkOpGHZcLc= +honnef.co/go/tools v0.4.2/go.mod h1:36ZgoUOrqOk1GxwHhyryEkq8FQWkUO2xGuSMhUCcdvA= +mvdan.cc/gofumpt v0.4.0 h1:JVf4NN1mIpHogBj7ABpgOyZc65/UUOkKQFkoURsz4MM= +mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ= diff --git a/ride/fit.go b/ride/fit.go new file mode 100644 index 0000000..7c4ba2e --- /dev/null +++ b/ride/fit.go @@ -0,0 +1,215 @@ +package ride + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math" + "time" + + "github.com/jftuga/geodist" + "github.com/martinlehoux/kagamigo/kcore" + "github.com/tormoder/fit" +) + +// FITRideParser parses FIT files into Ride values. It is the FIT counterpart +// to GPXRideParser and keeps the same invariants. +type FITRideParser struct{} + +func (p FITRideParser) Parse(reader io.Reader) (Ride, error) { + data, err := io.ReadAll(reader) + if err != nil { + return Ride{}, err + } + file, err := fit.Decode(bytes.NewReader(data)) + if err != nil { + return Ride{}, err + } + activity, err := file.Activity() + if err != nil { + return Ride{}, err + } + if len(activity.Records) == 0 { + return Ride{}, ErrNoTrackPoints + } + + // Collect points discarding invalid positions — FIT encodes missing lat/lon + // as 0x7FFFFFFF (Invalid). We also require altitude. + records := activity.Records + distances := make([]float64, 0, len(records)) + elevations := make([]float64, 0, len(records)) + coords := make([]geodist.Coord, 0, len(records)) + timestamps := make([]time.Time, 0, len(records)) + heartRates := make([]float64, 0, len(records)) + cadences := make([]float64, 0, len(records)) + powers := make([]float64, 0, len(records)) + heartRateComplete := true + cadenceComplete := true + powerComplete := true + + distance := 0.0 + var previous *geodist.Coord + + for _, rec := range records { + if rec.PositionLat.Invalid() || rec.PositionLong.Invalid() { + continue + } + if rec.Altitude == 0xFFFF { + return Ride{}, errors.New("points without elevation") + } + altitude := rec.GetAltitudeScaled() + if math.IsNaN(altitude) { + return Ride{}, errors.New("points without elevation") + } + coord := geodist.Coord{Lat: rec.PositionLat.Degrees(), Lon: rec.PositionLong.Degrees()} + if previous != nil { + _, km := geodist.HaversineDistance(*previous, coord) + d := km * 1000 + if d == 0 { + continue + } + distance += d + } + previous = &coord + distances = append(distances, distance) + elevations = append(elevations, altitude) + coords = append(coords, coord) + // FIT timestamps are absolute; if missing they are zero time. + ts := rec.Timestamp + if ts.IsZero() { + ts = file.FileId.TimeCreated + } + timestamps = append(timestamps, ts) + + if rec.HeartRate == 0xFF { + heartRateComplete = false + heartRates = append(heartRates, 0) + } else { + heartRates = append(heartRates, float64(rec.HeartRate)) + } + if rec.Cadence == 0xFF { + cadenceComplete = false + cadences = append(cadences, 0) + } else { + cadences = append(cadences, float64(rec.Cadence)) + } + if rec.Power == 0xFFFF { + powerComplete = false + powers = append(powers, 0) + } else { + powers = append(powers, float64(rec.Power)) + } + } + + if len(distances) < 2 { + if len(distances) == 0 { + return Ride{}, ErrNoTrackPoints + } + return Ride{}, errors.New("zero distance") + } + + var heartRatesColumn, cadencesColumn, powersColumn []float64 + if heartRateComplete { + heartRatesColumn = heartRates + } + if cadenceComplete { + cadencesColumn = cadences + } + if powerComplete { + powersColumn = powers + } + + return fromColumns(distances, elevations, coords, timestamps, heartRatesColumn, cadencesColumn, powersColumn), nil +} + +// EncodeFIT writes the ride as a minimal FIT Activity file. It stores only the +// fields consumed by Ride (lat/lon, altitude, time, heart rate, cadence, power) +// as Record messages. +func EncodeFIT(ride Ride, w io.Writer) error { + kcore.Assert(ride.Len() > 0, "no points in ride") + h := fit.NewHeader(fit.V20, true) + file, err := fit.NewFile(fit.FileTypeActivity, h) + if err != nil { + return err + } + file.FileId.Manufacturer = fit.ManufacturerGarmin + file.FileId.Type = fit.FileTypeActivity + if len(ride.timestamps) > 0 { + file.FileId.TimeCreated = ride.timestamps[0] + } else { + file.FileId.TimeCreated = time.Now() + } + activity, err := file.Activity() + if err != nil { + return err + } + + heartRates, hasHR := ride.HeartRates() + cadences, hasCad := ride.Cadences() + powers, hasPower := ride.Powers() + + for i := 0; i < ride.Len(); i++ { + rec := fit.NewRecordMsg() + rec.Timestamp = ride.timestamps[i] + coord := ride.Coords[i] + rec.PositionLat = fit.NewLatitudeDegrees(coord.Lat) + rec.PositionLong = fit.NewLongitudeDegrees(coord.Lon) + // Altitude: scaled (value+500)*5, invalid 0xFFFF + elev := ride.Elevations[i] + scaled := int((elev + 500) * 5) + if scaled < 0 { + scaled = 0 + } + if scaled > 0xFFFE { + scaled = 0xFFFE + } + rec.Altitude = uint16(scaled) + rec.EnhancedAltitude = 0xFFFFFFFF + + if hasHR { + v := int(heartRates[i]) + if v < 0 { + v = 0 + } + if v > 254 { + v = 254 + } + rec.HeartRate = uint8(v) + } else { + rec.HeartRate = 0xFF + } + if hasCad { + v := int(cadences[i]) + if v < 0 { + v = 0 + } + if v > 254 { + v = 254 + } + rec.Cadence = uint8(v) + } else { + rec.Cadence = 0xFF + } + if hasPower { + v := int(powers[i]) + if v < 0 { + v = 0 + } + if v > 65534 { + v = 65534 + } + rec.Power = uint16(v) + } else { + rec.Power = 0xFFFF + } + // Explicitly mark unused fields as invalid to keep encoding minimal. + rec.Distance = 0xFFFFFFFF + rec.Speed = 0xFFFF + rec.Grade = 0x7FFF + + activity.Records = append(activity.Records, rec) + } + + return fit.Encode(w, file, binary.LittleEndian) +} diff --git a/ride/store.go b/ride/store.go index b906b42..f6de535 100644 --- a/ride/store.go +++ b/ride/store.go @@ -4,6 +4,8 @@ import ( "database/sql" "fmt" "log/slog" + "os" + "path/filepath" "strings" "time" @@ -15,6 +17,7 @@ type StoredRide struct { ID int64 ExternalID string GPXPath string + FitPath string Name string Type string StartDate time.Time @@ -46,7 +49,7 @@ const ( SortCotacolKm SortColumn = "cotacol_100km" ) -const columns = "id, external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type, created_at, updated_at" +const columns = "id, external_id, gpx_path, fit_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type, created_at, updated_at" func Save(db *sql.DB, r StoredRide) error { var cotacolScore any @@ -70,10 +73,11 @@ func Save(db *sql.DB, r StoredRide) error { return fmt.Errorf("invalid ride_type %q", r.RideType) } _, err := db.Exec(` - INSERT INTO rides (external_id, gpx_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO rides (external_id, gpx_path, fit_path, name, type, start_date, distance_m, external_moving_time_s, moving_time_s, moving_time_algo_version, elapsed_time_s, total_elevation_gain_m, average_speed_mps, cotacol_score, cotacol_algo_version, bike_type, ride_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(external_id) DO UPDATE SET gpx_path = excluded.gpx_path, + fit_path = excluded.fit_path, name = excluded.name, type = excluded.type, start_date = excluded.start_date, @@ -89,7 +93,7 @@ func Save(db *sql.DB, r StoredRide) error { bike_type = CASE WHEN excluded.bike_type = '' THEN rides.bike_type ELSE excluded.bike_type END, ride_type = CASE WHEN excluded.ride_type = '' THEN rides.ride_type ELSE excluded.ride_type END, updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') - `, r.ExternalID, r.GPXPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, cotacolAlgorithmVersion, string(r.BikeType), string(r.RideType)) + `, r.ExternalID, r.GPXPath, r.FitPath, r.Name, r.Type, r.StartDate.UTC().Format(time.RFC3339), r.DistanceM, r.ExternalMovingTimeS, movingTimeS, movingTimeAlgorithmVersion, r.ElapsedTimeS, r.TotalElevationGainM, r.AverageSpeedMps, cotacolScore, cotacolAlgorithmVersion, string(r.BikeType), string(r.RideType)) return err } @@ -124,6 +128,41 @@ func Backfill(db *sql.DB, reporters ...func(current, total int)) (int, error) { } for i, item := range pending { report(i+1, len(pending)) + if item.GPXPath != "" { + fitPath := item.FitPath + if fitPath == "" { + ext := filepath.Ext(item.GPXPath) + if ext == "" { + fitPath = item.GPXPath + ".fit" + } else { + fitPath = strings.TrimSuffix(item.GPXPath, ext) + ".fit" + } + } + if _, err := os.Stat(fitPath); os.IsNotExist(err) { + parsed, err := ParseFile(GPXRideParser{}, item.GPXPath) + if err != nil { + slog.Warn("Failed to backfill FIT", "ride", item.ExternalID, "file", item.GPXPath, "error", err) + } else { + f, err := os.Create(fitPath) + if err != nil { + slog.Warn("Failed to create FIT file", "ride", item.ExternalID, "file", fitPath, "error", err) + } else { + if err := EncodeFIT(parsed, f); err != nil { + _ = f.Close() + _ = os.Remove(fitPath) + slog.Warn("Failed to encode FIT", "ride", item.ExternalID, "file", fitPath, "error", err) + } else { + _ = f.Close() + item.FitPath = fitPath + } + } + } + } else if err == nil && item.FitPath == "" { + item.FitPath = fitPath + } else if err != nil && !os.IsNotExist(err) { + slog.Warn("Failed to stat FIT file", "ride", item.ExternalID, "file", fitPath, "error", err) + } + } if err := Save(db, item); err != nil { slog.Warn("Failed to backfill ride values", "ride", item.ExternalID, "file", item.GPXPath, "error", err) continue @@ -227,7 +266,7 @@ func scanRide(s scanner) (StoredRide, error) { createdAt string updatedAt string ) - err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &bikeType, &rideType, &createdAt, &updatedAt) + err := s.Scan(&ride.ID, &ride.ExternalID, &ride.GPXPath, &ride.FitPath, &ride.Name, &ride.Type, &startDate, &ride.DistanceM, &ride.ExternalMovingTimeS, &movingTimeS, &movingTimeAlgoVer, &ride.ElapsedTimeS, &ride.TotalElevationGainM, &ride.AverageSpeedMps, &cotacolScore, &cotacolAlgoVersion, &bikeType, &rideType, &createdAt, &updatedAt) if err != nil { return StoredRide{}, err } diff --git a/strava/sync.go b/strava/sync.go index b8fa428..4630a1d 100644 --- a/strava/sync.go +++ b/strava/sync.go @@ -1,12 +1,15 @@ package strava import ( + "bytes" "errors" "fmt" "net/url" "strconv" "time" + "github.com/jftuga/geodist" + "github.com/martinlehoux/biking_home/ride" "github.com/martinlehoux/kagamigo/kcore" "github.com/tkrajina/gpxgo/gpx" ) @@ -68,20 +71,24 @@ func (c *Client) list(from, to time.Time, types ...string) ([]Activity, error) { return all, nil } -func (c *Client) Get(id int64) (Activity, []byte, error) { +func (c *Client) Get(id int64) (Activity, []byte, []byte, error) { var activity Activity if err := c.GetJSON(fmt.Sprintf("/activities/%d", id), &activity); err != nil { - return Activity{}, nil, kcore.Wrap(err, "failed to get Strava activity") + return Activity{}, nil, nil, kcore.Wrap(err, "failed to get Strava activity") } streams, err := c.activityStreams(id) if err != nil { - return Activity{}, nil, err + return Activity{}, nil, nil, err } gpxData, err := activityGPX(activity, streams) if err != nil { - return activity, nil, err + return activity, nil, nil, err } - return activity, gpxData, nil + fitData, err := activityFIT(activity, streams) + if err != nil { + return activity, nil, nil, err + } + return activity, gpxData, fitData, nil } type activityStreams struct { @@ -139,6 +146,96 @@ func (c *Client) activityStreams(id int64) (activityStreams, error) { return result, nil } +func activityFIT(activity Activity, streams activityStreams) ([]byte, error) { + if len(streams.LatLng) == 0 { + return nil, ErrNoTrackPoints + } + n := len(streams.LatLng) + distances := make([]float64, 0, n) + elevations := make([]float64, 0, n) + coords := make([]geodist.Coord, 0, n) + timestamps := make([]time.Time, 0, n) + heartRates := make([]float64, 0, n) + cadences := make([]float64, 0, n) + powers := make([]float64, 0, n) + heartRateComplete := true + cadenceComplete := true + powerComplete := true + + distance := 0.0 + var previous *geodist.Coord + for i := 0; i < n; i++ { + coord := geodist.Coord{Lat: streams.LatLng[i][0], Lon: streams.LatLng[i][1]} + if previous != nil { + _, km := geodist.HaversineDistance(*previous, coord) + d := km * 1000 + if d == 0 { + continue + } + distance += d + } else { + distance = 0 + } + previous = &coord + + var elev float64 + if i < len(streams.Altitude) { + elev = streams.Altitude[i] + } + // If altitude missing and we have no value, use 0 to keep ride valid. + // The GPX path requires elevation; this mirrors that requirement. + distances = append(distances, distance) + elevations = append(elevations, elev) + coords = append(coords, coord) + var ts time.Time + if i < len(streams.Seconds) { + ts = activity.StartDate.Add(time.Duration(streams.Seconds[i]) * time.Second) + } else { + ts = activity.StartDate.Add(time.Duration(i) * time.Second) + } + timestamps = append(timestamps, ts) + + if hr := streamValue(streams.HeartRate, i); hr == nil { + heartRateComplete = false + heartRates = append(heartRates, 0) + } else { + heartRates = append(heartRates, *hr) + } + if cad := streamValue(streams.Cadence, i); cad == nil { + cadenceComplete = false + cadences = append(cadences, 0) + } else { + cadences = append(cadences, *cad) + } + if pow := streamValue(streams.Power, i); pow == nil { + powerComplete = false + powers = append(powers, 0) + } else { + powers = append(powers, *pow) + } + } + if len(distances) < 2 { + return nil, errors.New("zero distance") + } + var hrCol, cadCol, powCol []float64 + if heartRateComplete { + hrCol = heartRates + } + if cadenceComplete { + cadCol = cadences + } + if powerComplete { + powCol = powers + } + r := ride.FromColumnsWithMetrics(distances, elevations, coords, timestamps, hrCol, cadCol, powCol) + + var buf bytes.Buffer + if err := ride.EncodeFIT(r, &buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + func activityGPX(activity Activity, streams activityStreams) ([]byte, error) { if len(streams.LatLng) == 0 { return nil, ErrNoTrackPoints diff --git a/strava/sync_test.go b/strava/sync_test.go index abc3d0e..6d28e47 100644 --- a/strava/sync_test.go +++ b/strava/sync_test.go @@ -70,12 +70,13 @@ func TestGet(t *testing.T) { } }) - activity, gpxData, err := client.Get(42) + activity, gpxData, fitData, err := client.Get(42) require.NoError(t, err) assert.Equal(t, int64(42), activity.ID) assert.Equal(t, 42195.0, activity.DistanceM) assert.Equal(t, int64(7200), activity.MovingTimeS) assert.NotEmpty(t, gpxData) + assert.NotEmpty(t, fitData) parsed, err := gpx.ParseBytes(gpxData) require.NoError(t, err) require.Len(t, parsed.Tracks[0].Segments[0].Points, 2) @@ -113,6 +114,18 @@ func TestGet(t *testing.T) { require.True(t, ok) powerValue := powers[1] assert.Equal(t, 0.0, powerValue) + + parsedFIT, err := (ride.FITRideParser{}).Parse(bytes.NewReader(fitData)) + require.NoError(t, err) + assert.Equal(t, 2, parsedFIT.Len()) + heartRates, ok = parsedFIT.HeartRates() + require.True(t, ok) + assert.Equal(t, 91.0, heartRates[1]) + _, ok = parsedFIT.Cadences() + assert.False(t, ok) + powers, ok = parsedFIT.Powers() + require.True(t, ok) + assert.Equal(t, 0.0, powers[1]) } func TestGetRejectsActivityWithoutTrackPoints(t *testing.T) { @@ -130,7 +143,7 @@ func TestGetRejectsActivityWithoutTrackPoints(t *testing.T) { } }) - activity, _, err := client.Get(14701658670) + activity, _, _, err := client.Get(14701658670) assert.Equal(t, int64(14701658670), activity.ID) require.EqualError(t, err, "activity has no track points") diff --git a/web/server.go b/web/server.go index 06b4ace..bde45ca 100644 --- a/web/server.go +++ b/web/server.go @@ -29,7 +29,7 @@ type Server struct { type syncClient interface { List(from, to time.Time) ([]strava.Activity, error) - Get(id int64) (strava.Activity, []byte, error) + Get(id int64) (strava.Activity, []byte, []byte, error) } func NewServer(db *sql.DB, configPath string) *Server { diff --git a/web/server_test.go b/web/server_test.go index 7137031..e9444c7 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -53,6 +53,7 @@ type fakeSyncClient struct { type fakeSyncResult struct { activity strava.Activity data []byte + fitData []byte err error } @@ -60,12 +61,16 @@ func (c fakeSyncClient) List(time.Time, time.Time) ([]strava.Activity, error) { return c.activities, nil } -func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, error) { +func (c fakeSyncClient) Get(id int64) (strava.Activity, []byte, []byte, error) { result, ok := c.results[id] if !ok { - return strava.Activity{}, nil, fmt.Errorf("missing fake activity %d", id) + return strava.Activity{}, nil, nil, fmt.Errorf("missing fake activity %d", id) } - return result.activity, result.data, result.err + fitData := result.fitData + if fitData == nil && result.data != nil { + fitData = result.data + } + return result.activity, result.data, fitData, result.err } const emptyTrackGPX = `` @@ -684,7 +689,7 @@ func (c countingSyncClient) List(from, to time.Time) ([]strava.Activity, error) return c.inner.List(from, to) } -func (c countingSyncClient) Get(id int64) (strava.Activity, []byte, error) { +func (c countingSyncClient) Get(id int64) (strava.Activity, []byte, []byte, error) { *c.getCalled = true return c.inner.Get(id) } diff --git a/web/sync.go b/web/sync.go index f6dc7da..8ed306f 100644 --- a/web/sync.go +++ b/web/sync.go @@ -78,7 +78,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Content-Type", "text/event-stream") - progress, err := s.syncRides(client, appConfig.Storage.GPXDir, from, to, func(progress SyncProgress) error { + progress, err := s.syncRides(client, appConfig.Storage.RidesDir, from, to, func(progress SyncProgress) error { enrichProgressRateLimit(&progress, client) return writeSyncEvent(w, flusher, "progress", progress) }) @@ -104,7 +104,7 @@ func (s *Server) handleSync(w http.ResponseWriter, r *http.Request) { _ = writeSyncEvent(w, flusher, "complete", progress) } -func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) { +func (s *Server) syncRides(client syncClient, ridesDir string, from, to time.Time, report func(SyncProgress) error) (SyncProgress, error) { activities, err := client.List(from, to) if err != nil { if rateErr, ok := errors.AsType[*strava.RateLimitError](err); ok { @@ -117,8 +117,8 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, return progress, err } slog.Info("Started Strava sync", "from", from.Format(dateFormat), "to", to.Add(-24*time.Hour).Format(dateFormat), "total", progress.Total) - if err := os.MkdirAll(gpxDir, 0o755); err != nil { - return progress, fmt.Errorf("create GPX directory: %w", err) + if err := os.MkdirAll(ridesDir, 0o755); err != nil { + return progress, fmt.Errorf("create rides directory: %w", err) } for _, summary := range activities { externalID := fmt.Sprintf("strava:%d", summary.ID) @@ -146,15 +146,16 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, } continue } - activity, gpxData, err := client.Get(summary.ID) + activity, gpxData, fitData, err := client.Get(summary.ID) if err != nil { if rateErr, ok := errors.AsType[*strava.RateLimitError](err); ok { slog.Warn("Strava sync rate limited", "activity", summary.ID, "name", summary.Name, "retryAfter", rateErr.RetryAfter, "isDaily", rateErr.IsDaily, "completed", progress.Completed, "total", progress.Total) return progress, err } if errors.Is(err, strava.ErrNoTrackPoints) { - _ = os.Remove(filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", summary.ID))) - if saveErr := ride.Save(s.db, rideFromActivity(externalID, "", activity)); saveErr != nil { + _ = os.Remove(filepath.Join(ridesDir, fmt.Sprintf("activity_%d.gpx", summary.ID))) + _ = os.Remove(filepath.Join(ridesDir, fmt.Sprintf("activity_%d.fit", summary.ID))) + if saveErr := ride.Save(s.db, rideFromActivity(externalID, "", "", activity)); saveErr != nil { if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save metadata: %w", saveErr)); reportErr != nil { return progress, reportErr } @@ -173,16 +174,27 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, } continue } - gpxPath := filepath.Join(gpxDir, fmt.Sprintf("activity_%d.gpx", activity.ID)) + gpxPath := filepath.Join(ridesDir, fmt.Sprintf("activity_%d.gpx", activity.ID)) + fitPath := filepath.Join(ridesDir, fmt.Sprintf("activity_%d.fit", activity.ID)) if err := os.WriteFile(gpxPath, gpxData, 0o644); err != nil { _ = os.Remove(gpxPath) + _ = os.Remove(fitPath) if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write GPX: %w", err)); reportErr != nil { return progress, reportErr } continue } - if err := ride.Save(s.db, rideFromActivity(externalID, gpxPath, activity)); err != nil { + if err := os.WriteFile(fitPath, fitData, 0o644); err != nil { _ = os.Remove(gpxPath) + _ = os.Remove(fitPath) + if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("write FIT: %w", err)); reportErr != nil { + return progress, reportErr + } + continue + } + if err := ride.Save(s.db, rideFromActivity(externalID, gpxPath, fitPath, activity)); err != nil { + _ = os.Remove(gpxPath) + _ = os.Remove(fitPath) if reportErr := skipSyncActivity(&progress, report, summary, fmt.Errorf("save activity: %w", err)); reportErr != nil { return progress, reportErr } @@ -198,7 +210,7 @@ func (s *Server) syncRides(client syncClient, gpxDir string, from, to time.Time, return progress, nil } -func rideFromActivity(externalID, gpxPath string, activity strava.Activity) ride.StoredRide { +func rideFromActivity(externalID, gpxPath, fitPath string, activity strava.Activity) ride.StoredRide { activityType := activity.SportType if activityType == "" { activityType = activity.Type @@ -206,6 +218,7 @@ func rideFromActivity(externalID, gpxPath string, activity strava.Activity) ride return ride.StoredRide{ ExternalID: externalID, GPXPath: gpxPath, + FitPath: fitPath, Name: activity.Name, Type: activityType, StartDate: activity.StartDate, -- cgit v1.3