This commit is contained in:
2025-09-13 09:57:27 -07:00
parent fda7d7e54a
commit cc17c6ca84
8 changed files with 173 additions and 85 deletions

Binary file not shown.

View File

@@ -2,8 +2,10 @@ package garmin
import ( import (
"fmt" "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 // Authenticate performs Garmin Connect authentication
@@ -11,14 +13,34 @@ func (c *Client) Authenticate(logger Logger) error {
logger.Infof("Authenticating with username: %s", c.username) logger.Infof("Authenticating with username: %s", c.username)
// Initialize Garth client // Initialize Garth client
garthClient := garth.New() garthClient, err := client.NewClient("garmin.com")
if err != nil {
// Perform authentication logger.Errorf("Failed to create Garmin client: %v", err)
if err := garthClient.Authenticate(c.username, c.password); err != nil { return fmt.Errorf("failed to create client: %w", err)
logger.Errorf("Authentication failed: %v", err)
return fmt.Errorf("authentication failed: %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") logger.Infof("Authentication successful")
return nil return nil
} }

View File

@@ -7,8 +7,8 @@ import (
"path/filepath" "path/filepath"
"time" "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" "github.com/sstent/fitness-tui/internal/tui/models"
) )
@@ -21,7 +21,7 @@ type Client struct {
username string username string
password string password string
storagePath string storagePath string
garthClient *garth.Client garthClient *client.Client
} }
func NewClient(username, password, storagePath string) *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 // Convert to our internal model
activities := make([]*models.Activity, 0, len(garthActivities)) activities := make([]*models.Activity, 0, len(garthActivities))
for _, ga := range garthActivities { for _, ga := range garthActivities {
startTime, err := time.Parse(time.RFC3339, ga.StartTimeGMT) // Use the already parsed time from CustomTime struct
if err != nil { if ga.StartTimeGMT.IsZero() {
logger.Warnf("Failed to parse activity time: %v", err) logger.Warnf("Activity %d has invalid start time", ga.ActivityID)
continue continue
} }
@@ -102,7 +102,7 @@ func (c *Client) GetActivities(ctx context.Context, limit int, logger Logger) ([
ID: fmt.Sprintf("%d", ga.ActivityID), ID: fmt.Sprintf("%d", ga.ActivityID),
Name: ga.ActivityName, Name: ga.ActivityName,
Type: ga.ActivityType.TypeKey, Type: ga.ActivityType.TypeKey,
Date: startTime, Date: ga.StartTimeGMT.Time, // Access the parsed time directly
Distance: ga.Distance, Distance: ga.Distance,
Duration: time.Duration(ga.Duration) * time.Second, Duration: time.Duration(ga.Duration) * time.Second,
Elevation: ga.ElevationGain, Elevation: ga.ElevationGain,

View File

@@ -224,9 +224,11 @@ func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
// SaveSession saves the current session to a file // SaveSession saves the current session to a file
func (c *Client) SaveSession(filename string) error { func (c *Client) SaveSession(filename string) error {
session := types.SessionData{ session := types.SessionData{
Domain: c.Domain, Domain: c.Domain,
Username: c.Username, Username: c.Username,
AuthToken: c.AuthToken, AuthToken: c.AuthToken,
OAuth1Token: c.OAuth1Token,
OAuth2Token: c.OAuth2Token,
} }
data, err := json.MarshalIndent(session, "", " ") data, err := json.MarshalIndent(session, "", " ")
@@ -276,6 +278,8 @@ func (c *Client) LoadSession(filename string) error {
c.Domain = session.Domain c.Domain = session.Domain
c.Username = session.Username c.Username = session.Username
c.AuthToken = session.AuthToken c.AuthToken = session.AuthToken
c.OAuth1Token = session.OAuth1Token
c.OAuth2Token = session.OAuth2Token
return nil return nil
} }

View File

@@ -1,71 +1,97 @@
package client package client
import ( import (
"strings"
"time" "time"
) )
type UserProfile struct { type UserProfile struct {
ID int `json:"id"` ID int `json:"id"`
ProfileID int `json:"profileId"` ProfileID int `json:"profileId"`
GarminGUID string `json:"garminGuid"` GarminGUID string `json:"garminGuid"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
FullName string `json:"fullName"` FullName string `json:"fullName"`
UserName string `json:"userName"` UserName string `json:"userName"`
ProfileImageType *string `json:"profileImageType"` ProfileImageType *string `json:"profileImageType"`
ProfileImageURLLarge *string `json:"profileImageUrlLarge"` ProfileImageURLLarge *string `json:"profileImageUrlLarge"`
ProfileImageURLMedium *string `json:"profileImageUrlMedium"` ProfileImageURLMedium *string `json:"profileImageUrlMedium"`
ProfileImageURLSmall *string `json:"profileImageUrlSmall"` ProfileImageURLSmall *string `json:"profileImageUrlSmall"`
Location *string `json:"location"` Location *string `json:"location"`
FacebookURL *string `json:"facebookUrl"` FacebookURL *string `json:"facebookUrl"`
TwitterURL *string `json:"twitterUrl"` TwitterURL *string `json:"twitterUrl"`
PersonalWebsite *string `json:"personalWebsite"` PersonalWebsite *string `json:"personalWebsite"`
Motivation *string `json:"motivation"` Motivation *string `json:"motivation"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
PrimaryActivity *string `json:"primaryActivity"` PrimaryActivity *string `json:"primaryActivity"`
FavoriteActivityTypes []string `json:"favoriteActivityTypes"` FavoriteActivityTypes []string `json:"favoriteActivityTypes"`
RunningTrainingSpeed float64 `json:"runningTrainingSpeed"` RunningTrainingSpeed float64 `json:"runningTrainingSpeed"`
CyclingTrainingSpeed float64 `json:"cyclingTrainingSpeed"` CyclingTrainingSpeed float64 `json:"cyclingTrainingSpeed"`
FavoriteCyclingActivityTypes []string `json:"favoriteCyclingActivityTypes"` FavoriteCyclingActivityTypes []string `json:"favoriteCyclingActivityTypes"`
CyclingClassification *string `json:"cyclingClassification"` CyclingClassification *string `json:"cyclingClassification"`
CyclingMaxAvgPower float64 `json:"cyclingMaxAvgPower"` CyclingMaxAvgPower float64 `json:"cyclingMaxAvgPower"`
SwimmingTrainingSpeed float64 `json:"swimmingTrainingSpeed"` SwimmingTrainingSpeed float64 `json:"swimmingTrainingSpeed"`
ProfileVisibility string `json:"profileVisibility"` ProfileVisibility string `json:"profileVisibility"`
ActivityStartVisibility string `json:"activityStartVisibility"` ActivityStartVisibility string `json:"activityStartVisibility"`
ActivityMapVisibility string `json:"activityMapVisibility"` ActivityMapVisibility string `json:"activityMapVisibility"`
CourseVisibility string `json:"courseVisibility"` CourseVisibility string `json:"courseVisibility"`
ActivityHeartRateVisibility string `json:"activityHeartRateVisibility"` ActivityHeartRateVisibility string `json:"activityHeartRateVisibility"`
ActivityPowerVisibility string `json:"activityPowerVisibility"` ActivityPowerVisibility string `json:"activityPowerVisibility"`
BadgeVisibility string `json:"badgeVisibility"` BadgeVisibility string `json:"badgeVisibility"`
ShowAge bool `json:"showAge"` ShowAge bool `json:"showAge"`
ShowWeight bool `json:"showWeight"` ShowWeight bool `json:"showWeight"`
ShowHeight bool `json:"showHeight"` ShowHeight bool `json:"showHeight"`
ShowWeightClass bool `json:"showWeightClass"` ShowWeightClass bool `json:"showWeightClass"`
ShowAgeRange bool `json:"showAgeRange"` ShowAgeRange bool `json:"showAgeRange"`
ShowGender bool `json:"showGender"` ShowGender bool `json:"showGender"`
ShowActivityClass bool `json:"showActivityClass"` ShowActivityClass bool `json:"showActivityClass"`
ShowVO2Max bool `json:"showVo2Max"` ShowVO2Max bool `json:"showVo2Max"`
ShowPersonalRecords bool `json:"showPersonalRecords"` ShowPersonalRecords bool `json:"showPersonalRecords"`
ShowLast12Months bool `json:"showLast12Months"` ShowLast12Months bool `json:"showLast12Months"`
ShowLifetimeTotals bool `json:"showLifetimeTotals"` ShowLifetimeTotals bool `json:"showLifetimeTotals"`
ShowUpcomingEvents bool `json:"showUpcomingEvents"` ShowUpcomingEvents bool `json:"showUpcomingEvents"`
ShowRecentFavorites bool `json:"showRecentFavorites"` ShowRecentFavorites bool `json:"showRecentFavorites"`
ShowRecentDevice bool `json:"showRecentDevice"` ShowRecentDevice bool `json:"showRecentDevice"`
ShowRecentGear bool `json:"showRecentGear"` ShowRecentGear bool `json:"showRecentGear"`
ShowBadges bool `json:"showBadges"` ShowBadges bool `json:"showBadges"`
OtherActivity *string `json:"otherActivity"` OtherActivity *string `json:"otherActivity"`
OtherPrimaryActivity *string `json:"otherPrimaryActivity"` OtherPrimaryActivity *string `json:"otherPrimaryActivity"`
OtherMotivation *string `json:"otherMotivation"` OtherMotivation *string `json:"otherMotivation"`
UserRoles []string `json:"userRoles"` UserRoles []string `json:"userRoles"`
NameApproved bool `json:"nameApproved"` NameApproved bool `json:"nameApproved"`
UserProfileFullName string `json:"userProfileFullName"` UserProfileFullName string `json:"userProfileFullName"`
MakeGolfScorecardsPrivate bool `json:"makeGolfScorecardsPrivate"` MakeGolfScorecardsPrivate bool `json:"makeGolfScorecardsPrivate"`
AllowGolfLiveScoring bool `json:"allowGolfLiveScoring"` AllowGolfLiveScoring bool `json:"allowGolfLiveScoring"`
AllowGolfScoringByConnections bool `json:"allowGolfScoringByConnections"` AllowGolfScoringByConnections bool `json:"allowGolfScoringByConnections"`
UserLevel int `json:"userLevel"` UserLevel int `json:"userLevel"`
UserPoint int `json:"userPoint"` UserPoint int `json:"userPoint"`
LevelUpdateDate time.Time `json:"levelUpdateDate"` LevelUpdateDate CustomTime `json:"levelUpdateDate"`
LevelIsViewed bool `json:"levelIsViewed"` LevelIsViewed bool `json:"levelIsViewed"`
LevelPointThreshold int `json:"levelPointThreshold"` LevelPointThreshold int `json:"levelPointThreshold"`
UserPointOffset int `json:"userPointOffset"` UserPointOffset int `json:"userPointOffset"`
UserPro bool `json:"userPro"` 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
} }

View File

@@ -150,7 +150,9 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext,
}, nil }, nil
} }
// Debug: Log full response body when title is unexpected
if title != "Success" { if title != "Success" {
fmt.Printf("Unexpected login response body: %s\n", string(body))
return nil, nil, fmt.Errorf("login failed, unexpected title: %s", title) return nil, nil, fmt.Errorf("login failed, unexpected title: %s", title)
} }

View File

@@ -2,6 +2,7 @@ package types
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
) )
@@ -17,9 +18,11 @@ type Client struct {
// SessionData represents saved session information // SessionData represents saved session information
type SessionData struct { type SessionData struct {
Domain string `json:"domain"` Domain string `json:"domain"`
Username string `json:"username"` Username string `json:"username"`
AuthToken string `json:"auth_token"` AuthToken string `json:"auth_token"`
OAuth1Token *OAuth1Token `json:"oauth1_token,omitempty"`
OAuth2Token *OAuth2Token `json:"oauth2_token,omitempty"`
} }
// ActivityType represents the type of activity // ActivityType represents the type of activity
@@ -40,8 +43,8 @@ type Activity struct {
ActivityID int64 `json:"activityId"` ActivityID int64 `json:"activityId"`
ActivityName string `json:"activityName"` ActivityName string `json:"activityName"`
Description string `json:"description"` Description string `json:"description"`
StartTimeLocal string `json:"startTimeLocal"` StartTimeLocal CustomTime `json:"startTimeLocal"`
StartTimeGMT string `json:"startTimeGMT"` StartTimeGMT CustomTime `json:"startTimeGMT"`
ActivityType ActivityType `json:"activityType"` ActivityType ActivityType `json:"activityType"`
EventType EventType `json:"eventType"` EventType EventType `json:"eventType"`
Distance float64 `json:"distance"` Distance float64 `json:"distance"`
@@ -65,6 +68,37 @@ type OAuth1Token struct {
Domain string `json:"domain"` 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 // OAuth2Token represents OAuth2 token response
type OAuth2Token struct { type OAuth2Token struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`

BIN
go-garth/main Executable file

Binary file not shown.