diff --git a/fitness-tui/fitness-tui b/fitness-tui/fitness-tui index fb5c032..764daa2 100755 Binary files a/fitness-tui/fitness-tui and b/fitness-tui/fitness-tui differ diff --git a/fitness-tui/internal/garmin/auth.go b/fitness-tui/internal/garmin/auth.go index b2c6050..30efe4c 100644 --- a/fitness-tui/internal/garmin/auth.go +++ b/fitness-tui/internal/garmin/auth.go @@ -2,8 +2,10 @@ package garmin import ( "fmt" + "os" + "path/filepath" - "github.com/sstent/fitness-tui/internal/garmin/garth" + "github.com/sstent/fitness-tui/internal/garmin/garth/client" ) // Authenticate performs Garmin Connect authentication @@ -11,14 +13,34 @@ func (c *Client) Authenticate(logger Logger) error { logger.Infof("Authenticating with username: %s", c.username) // Initialize Garth client - garthClient := garth.New() - - // Perform authentication - if err := garthClient.Authenticate(c.username, c.password); err != nil { - logger.Errorf("Authentication failed: %v", err) - return fmt.Errorf("authentication failed: %w", err) + garthClient, err := client.NewClient("garmin.com") + if err != nil { + logger.Errorf("Failed to create Garmin client: %v", err) + return fmt.Errorf("failed to create client: %w", err) } + // Try to load existing session + sessionFile := filepath.Join(os.Getenv("HOME"), ".fitness-tui", "garmin_session.json") + if err := garthClient.LoadSession(sessionFile); err != nil { + logger.Infof("No existing session found, logging in with credentials") + + // Perform authentication if no session exists + if err := garthClient.Login(c.username, c.password); err != nil { + logger.Errorf("Authentication failed: %v", err) + return fmt.Errorf("authentication failed: %w", err) + } + + // Save session for future use + if err := garthClient.SaveSession(sessionFile); err != nil { + logger.Warnf("Failed to save session: %v", err) + } + } else { + logger.Infof("Loaded existing session") + } + + // Store the authenticated client + c.garthClient = garthClient + logger.Infof("Authentication successful") return nil } diff --git a/fitness-tui/internal/garmin/client.go b/fitness-tui/internal/garmin/client.go index c9ffbb2..a805e42 100644 --- a/fitness-tui/internal/garmin/client.go +++ b/fitness-tui/internal/garmin/client.go @@ -7,8 +7,8 @@ import ( "path/filepath" "time" - garth "garmin-connect/garth" - + "github.com/sstent/fitness-tui/internal/garmin/garth" + "github.com/sstent/fitness-tui/internal/garmin/garth/client" "github.com/sstent/fitness-tui/internal/tui/models" ) @@ -21,7 +21,7 @@ type Client struct { username string password string storagePath string - garthClient *garth.Client + garthClient *client.Client } func NewClient(username, password, storagePath string) *Client { @@ -92,9 +92,9 @@ func (c *Client) GetActivities(ctx context.Context, limit int, logger Logger) ([ // Convert to our internal model activities := make([]*models.Activity, 0, len(garthActivities)) for _, ga := range garthActivities { - startTime, err := time.Parse(time.RFC3339, ga.StartTimeGMT) - if err != nil { - logger.Warnf("Failed to parse activity time: %v", err) + // Use the already parsed time from CustomTime struct + if ga.StartTimeGMT.IsZero() { + logger.Warnf("Activity %d has invalid start time", ga.ActivityID) continue } @@ -102,7 +102,7 @@ func (c *Client) GetActivities(ctx context.Context, limit int, logger Logger) ([ ID: fmt.Sprintf("%d", ga.ActivityID), Name: ga.ActivityName, Type: ga.ActivityType.TypeKey, - Date: startTime, + Date: ga.StartTimeGMT.Time, // Access the parsed time directly Distance: ga.Distance, Duration: time.Duration(ga.Duration) * time.Second, Elevation: ga.ElevationGain, diff --git a/fitness-tui/internal/garmin/garth/client/client.go b/fitness-tui/internal/garmin/garth/client/client.go index eb02e4d..baa3cb9 100644 --- a/fitness-tui/internal/garmin/garth/client/client.go +++ b/fitness-tui/internal/garmin/garth/client/client.go @@ -224,9 +224,11 @@ func (c *Client) GetActivities(limit int) ([]types.Activity, error) { // SaveSession saves the current session to a file func (c *Client) SaveSession(filename string) error { session := types.SessionData{ - Domain: c.Domain, - Username: c.Username, - AuthToken: c.AuthToken, + Domain: c.Domain, + Username: c.Username, + AuthToken: c.AuthToken, + OAuth1Token: c.OAuth1Token, + OAuth2Token: c.OAuth2Token, } data, err := json.MarshalIndent(session, "", " ") @@ -276,6 +278,8 @@ func (c *Client) LoadSession(filename string) error { c.Domain = session.Domain c.Username = session.Username c.AuthToken = session.AuthToken + c.OAuth1Token = session.OAuth1Token + c.OAuth2Token = session.OAuth2Token return nil } diff --git a/fitness-tui/internal/garmin/garth/client/profile.go b/fitness-tui/internal/garmin/garth/client/profile.go index 647a073..221383f 100644 --- a/fitness-tui/internal/garmin/garth/client/profile.go +++ b/fitness-tui/internal/garmin/garth/client/profile.go @@ -1,71 +1,97 @@ package client import ( + "strings" "time" ) type UserProfile struct { - ID int `json:"id"` - ProfileID int `json:"profileId"` - GarminGUID string `json:"garminGuid"` - DisplayName string `json:"displayName"` - FullName string `json:"fullName"` - UserName string `json:"userName"` - ProfileImageType *string `json:"profileImageType"` - ProfileImageURLLarge *string `json:"profileImageUrlLarge"` - ProfileImageURLMedium *string `json:"profileImageUrlMedium"` - ProfileImageURLSmall *string `json:"profileImageUrlSmall"` - Location *string `json:"location"` - FacebookURL *string `json:"facebookUrl"` - TwitterURL *string `json:"twitterUrl"` - PersonalWebsite *string `json:"personalWebsite"` - Motivation *string `json:"motivation"` - Bio *string `json:"bio"` - PrimaryActivity *string `json:"primaryActivity"` - FavoriteActivityTypes []string `json:"favoriteActivityTypes"` - RunningTrainingSpeed float64 `json:"runningTrainingSpeed"` - CyclingTrainingSpeed float64 `json:"cyclingTrainingSpeed"` - FavoriteCyclingActivityTypes []string `json:"favoriteCyclingActivityTypes"` - CyclingClassification *string `json:"cyclingClassification"` - CyclingMaxAvgPower float64 `json:"cyclingMaxAvgPower"` - SwimmingTrainingSpeed float64 `json:"swimmingTrainingSpeed"` - ProfileVisibility string `json:"profileVisibility"` - ActivityStartVisibility string `json:"activityStartVisibility"` - ActivityMapVisibility string `json:"activityMapVisibility"` - CourseVisibility string `json:"courseVisibility"` - ActivityHeartRateVisibility string `json:"activityHeartRateVisibility"` - ActivityPowerVisibility string `json:"activityPowerVisibility"` - BadgeVisibility string `json:"badgeVisibility"` - ShowAge bool `json:"showAge"` - ShowWeight bool `json:"showWeight"` - ShowHeight bool `json:"showHeight"` - ShowWeightClass bool `json:"showWeightClass"` - ShowAgeRange bool `json:"showAgeRange"` - ShowGender bool `json:"showGender"` - ShowActivityClass bool `json:"showActivityClass"` - ShowVO2Max bool `json:"showVo2Max"` - ShowPersonalRecords bool `json:"showPersonalRecords"` - ShowLast12Months bool `json:"showLast12Months"` - ShowLifetimeTotals bool `json:"showLifetimeTotals"` - ShowUpcomingEvents bool `json:"showUpcomingEvents"` - ShowRecentFavorites bool `json:"showRecentFavorites"` - ShowRecentDevice bool `json:"showRecentDevice"` - ShowRecentGear bool `json:"showRecentGear"` - ShowBadges bool `json:"showBadges"` - OtherActivity *string `json:"otherActivity"` - OtherPrimaryActivity *string `json:"otherPrimaryActivity"` - OtherMotivation *string `json:"otherMotivation"` - UserRoles []string `json:"userRoles"` - NameApproved bool `json:"nameApproved"` - UserProfileFullName string `json:"userProfileFullName"` - MakeGolfScorecardsPrivate bool `json:"makeGolfScorecardsPrivate"` - AllowGolfLiveScoring bool `json:"allowGolfLiveScoring"` - AllowGolfScoringByConnections bool `json:"allowGolfScoringByConnections"` - UserLevel int `json:"userLevel"` - UserPoint int `json:"userPoint"` - LevelUpdateDate time.Time `json:"levelUpdateDate"` - LevelIsViewed bool `json:"levelIsViewed"` - LevelPointThreshold int `json:"levelPointThreshold"` - UserPointOffset int `json:"userPointOffset"` - UserPro bool `json:"userPro"` + ID int `json:"id"` + ProfileID int `json:"profileId"` + GarminGUID string `json:"garminGuid"` + DisplayName string `json:"displayName"` + FullName string `json:"fullName"` + UserName string `json:"userName"` + ProfileImageType *string `json:"profileImageType"` + ProfileImageURLLarge *string `json:"profileImageUrlLarge"` + ProfileImageURLMedium *string `json:"profileImageUrlMedium"` + ProfileImageURLSmall *string `json:"profileImageUrlSmall"` + Location *string `json:"location"` + FacebookURL *string `json:"facebookUrl"` + TwitterURL *string `json:"twitterUrl"` + PersonalWebsite *string `json:"personalWebsite"` + Motivation *string `json:"motivation"` + Bio *string `json:"bio"` + PrimaryActivity *string `json:"primaryActivity"` + FavoriteActivityTypes []string `json:"favoriteActivityTypes"` + RunningTrainingSpeed float64 `json:"runningTrainingSpeed"` + CyclingTrainingSpeed float64 `json:"cyclingTrainingSpeed"` + FavoriteCyclingActivityTypes []string `json:"favoriteCyclingActivityTypes"` + CyclingClassification *string `json:"cyclingClassification"` + CyclingMaxAvgPower float64 `json:"cyclingMaxAvgPower"` + SwimmingTrainingSpeed float64 `json:"swimmingTrainingSpeed"` + ProfileVisibility string `json:"profileVisibility"` + ActivityStartVisibility string `json:"activityStartVisibility"` + ActivityMapVisibility string `json:"activityMapVisibility"` + CourseVisibility string `json:"courseVisibility"` + ActivityHeartRateVisibility string `json:"activityHeartRateVisibility"` + ActivityPowerVisibility string `json:"activityPowerVisibility"` + BadgeVisibility string `json:"badgeVisibility"` + ShowAge bool `json:"showAge"` + ShowWeight bool `json:"showWeight"` + ShowHeight bool `json:"showHeight"` + ShowWeightClass bool `json:"showWeightClass"` + ShowAgeRange bool `json:"showAgeRange"` + ShowGender bool `json:"showGender"` + ShowActivityClass bool `json:"showActivityClass"` + ShowVO2Max bool `json:"showVo2Max"` + ShowPersonalRecords bool `json:"showPersonalRecords"` + ShowLast12Months bool `json:"showLast12Months"` + ShowLifetimeTotals bool `json:"showLifetimeTotals"` + ShowUpcomingEvents bool `json:"showUpcomingEvents"` + ShowRecentFavorites bool `json:"showRecentFavorites"` + ShowRecentDevice bool `json:"showRecentDevice"` + ShowRecentGear bool `json:"showRecentGear"` + ShowBadges bool `json:"showBadges"` + OtherActivity *string `json:"otherActivity"` + OtherPrimaryActivity *string `json:"otherPrimaryActivity"` + OtherMotivation *string `json:"otherMotivation"` + UserRoles []string `json:"userRoles"` + NameApproved bool `json:"nameApproved"` + UserProfileFullName string `json:"userProfileFullName"` + MakeGolfScorecardsPrivate bool `json:"makeGolfScorecardsPrivate"` + AllowGolfLiveScoring bool `json:"allowGolfLiveScoring"` + AllowGolfScoringByConnections bool `json:"allowGolfScoringByConnections"` + UserLevel int `json:"userLevel"` + UserPoint int `json:"userPoint"` + LevelUpdateDate CustomTime `json:"levelUpdateDate"` + LevelIsViewed bool `json:"levelIsViewed"` + LevelPointThreshold int `json:"levelPointThreshold"` + UserPointOffset int `json:"userPointOffset"` + UserPro bool `json:"userPro"` +} + +// CustomTime handles Garmin's timestamp format +type CustomTime struct { + time.Time +} + +func (ct *CustomTime) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), "\"") + if s == "null" || s == "" { + return nil + } + + // Try parsing with fractional seconds + t, err := time.Parse("2006-01-02T15:04:05.999", s) + if err != nil { + // Fallback to parsing without fractional seconds + t, err = time.Parse("2006-01-02T15:04:05", s) + if err != nil { + return err + } + } + + ct.Time = t + return nil } diff --git a/fitness-tui/internal/garmin/garth/sso/sso.go b/fitness-tui/internal/garmin/garth/sso/sso.go index 6d95c0b..3d53f13 100644 --- a/fitness-tui/internal/garmin/garth/sso/sso.go +++ b/fitness-tui/internal/garmin/garth/sso/sso.go @@ -150,7 +150,9 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext, }, nil } + // Debug: Log full response body when title is unexpected if title != "Success" { + fmt.Printf("Unexpected login response body: %s\n", string(body)) return nil, nil, fmt.Errorf("login failed, unexpected title: %s", title) } diff --git a/fitness-tui/internal/garmin/garth/types/types.go b/fitness-tui/internal/garmin/garth/types/types.go index 6726ea0..b9bbfe5 100644 --- a/fitness-tui/internal/garmin/garth/types/types.go +++ b/fitness-tui/internal/garmin/garth/types/types.go @@ -2,6 +2,7 @@ package types import ( "net/http" + "strings" "time" ) @@ -17,9 +18,11 @@ type Client struct { // SessionData represents saved session information type SessionData struct { - Domain string `json:"domain"` - Username string `json:"username"` - AuthToken string `json:"auth_token"` + Domain string `json:"domain"` + Username string `json:"username"` + AuthToken string `json:"auth_token"` + OAuth1Token *OAuth1Token `json:"oauth1_token,omitempty"` + OAuth2Token *OAuth2Token `json:"oauth2_token,omitempty"` } // ActivityType represents the type of activity @@ -40,8 +43,8 @@ type Activity struct { ActivityID int64 `json:"activityId"` ActivityName string `json:"activityName"` Description string `json:"description"` - StartTimeLocal string `json:"startTimeLocal"` - StartTimeGMT string `json:"startTimeGMT"` + StartTimeLocal CustomTime `json:"startTimeLocal"` + StartTimeGMT CustomTime `json:"startTimeGMT"` ActivityType ActivityType `json:"activityType"` EventType EventType `json:"eventType"` Distance float64 `json:"distance"` @@ -65,6 +68,37 @@ type OAuth1Token struct { Domain string `json:"domain"` } +// CustomTime handles Garmin's timestamp formats +type CustomTime struct { + time.Time +} + +func (ct *CustomTime) UnmarshalJSON(b []byte) error { + s := strings.Trim(string(b), "\"") + if s == "null" || s == "" { + return nil + } + + // Try different timestamp formats + formats := []string{ + "2006-01-02T15:04:05.999", + "2006-01-02 15:04:05", + time.RFC3339, + } + + var t time.Time + var err error + for _, format := range formats { + t, err = time.Parse(format, s) + if err == nil { + ct.Time = t + return nil + } + } + + return err +} + // OAuth2Token represents OAuth2 token response type OAuth2Token struct { AccessToken string `json:"access_token"` diff --git a/go-garth/main b/go-garth/main new file mode 100755 index 0000000..31debcf Binary files /dev/null and b/go-garth/main differ