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. --- ride/store.go | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 5 deletions(-) (limited to 'ride/store.go') 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 } -- cgit v1.3