aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--config.example.yaml2
-rw-r--r--config/config.go8
-rw-r--r--db/migrations/20260828000000_add_fit_path.sql29
-rw-r--r--db/schema.sql5
-rw-r--r--go.mod9
-rw-r--r--go.sum63
-rw-r--r--ride/fit.go215
-rw-r--r--ride/store.go49
-rw-r--r--strava/sync.go107
-rw-r--r--strava/sync_test.go17
-rw-r--r--web/server.go2
-rw-r--r--web/server_test.go13
-rw-r--r--web/sync.go33
14 files changed, 519 insertions, 34 deletions
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 = `<?xml version="1.0"?><gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"><trk><trkseg></trkseg></trk></gpx>`
@@ -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,