From 422befea7259f837bf72da9b5d557657cd03cbd3 Mon Sep 17 00:00:00 2001 From: sstent Date: Sun, 7 Sep 2025 17:24:05 -0700 Subject: [PATCH] porting - part9 done --- garth/client/client.go | 25 ++++---- garth/client/profile.go | 71 +++++++++++++++++++++++ garth/client/settings.go | 122 +++++++++++++++++++++++++++++++++++++++ garth/users/profile.go | 71 +++++++++++++++++++++++ garth/users/settings.go | 95 ++++++++++++++++++++++++++++++ garth/utils/utils.go | 63 ++++++++++++++++++++ 6 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 garth/client/profile.go create mode 100644 garth/client/settings.go create mode 100644 garth/users/profile.go create mode 100644 garth/users/settings.go diff --git a/garth/client/client.go b/garth/client/client.go index 33661ee..2e79915 100644 --- a/garth/client/client.go +++ b/garth/client/client.go @@ -66,20 +66,22 @@ func (c *Client) Login(email, password string) error { c.AuthToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken) // Get user profile to set username - if err := c.GetUserProfile(); err != nil { + profile, err := c.GetUserProfile() + if err != nil { return fmt.Errorf("failed to get user profile after login: %w", err) } + c.Username = profile.UserName return nil } -// GetUserProfile retrieves the current user's profile -func (c *Client) GetUserProfile() error { +// GetUserProfile retrieves the current user's full profile +func (c *Client) GetUserProfile() (*UserProfile, error) { profileURL := fmt.Sprintf("https://connectapi.%s/userprofile-service/socialProfile", c.Domain) req, err := http.NewRequest("GET", profileURL, nil) if err != nil { - return fmt.Errorf("failed to create profile request: %w", err) + return nil, fmt.Errorf("failed to create profile request: %w", err) } req.Header.Set("Authorization", c.AuthToken) @@ -87,26 +89,21 @@ func (c *Client) GetUserProfile() error { resp, err := c.HTTPClient.Do(req) if err != nil { - return fmt.Errorf("failed to get user profile: %w", err) + return nil, fmt.Errorf("failed to get user profile: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("profile request failed with status %d: %s", resp.StatusCode, string(body)) + return nil, fmt.Errorf("profile request failed with status %d: %s", resp.StatusCode, string(body)) } - var profile map[string]interface{} + var profile UserProfile if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil { - return fmt.Errorf("failed to parse profile: %w", err) + return nil, fmt.Errorf("failed to parse profile: %w", err) } - if username, ok := profile["userName"].(string); ok { - c.Username = username - return nil - } - - return fmt.Errorf("username not found in profile response") + return &profile, nil } // GetActivities retrieves recent activities diff --git a/garth/client/profile.go b/garth/client/profile.go new file mode 100644 index 0000000..647a073 --- /dev/null +++ b/garth/client/profile.go @@ -0,0 +1,71 @@ +package client + +import ( + "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"` +} diff --git a/garth/client/settings.go b/garth/client/settings.go new file mode 100644 index 0000000..c40f1b6 --- /dev/null +++ b/garth/client/settings.go @@ -0,0 +1,122 @@ +package client + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type PowerFormat struct { + FormatID int `json:"formatId"` + FormatKey string `json:"formatKey"` + MinFraction int `json:"minFraction"` + MaxFraction int `json:"maxFraction"` + GroupingUsed bool `json:"groupingUsed"` + DisplayFormat *string `json:"displayFormat"` +} + +type FirstDayOfWeek struct { + DayID int `json:"dayId"` + DayName string `json:"dayName"` + SortOrder int `json:"sortOrder"` + IsPossibleFirstDay bool `json:"isPossibleFirstDay"` +} + +type WeatherLocation struct { + UseFixedLocation *bool `json:"useFixedLocation"` + Latitude *float64 `json:"latitude"` + Longitude *float64 `json:"longitude"` + LocationName *string `json:"locationName"` + ISOCountryCode *string `json:"isoCountryCode"` + PostalCode *string `json:"postalCode"` +} + +type UserData struct { + Gender string `json:"gender"` + Weight float64 `json:"weight"` + Height float64 `json:"height"` + TimeFormat string `json:"timeFormat"` + BirthDate time.Time `json:"birthDate"` + MeasurementSystem string `json:"measurementSystem"` + ActivityLevel *string `json:"activityLevel"` + Handedness string `json:"handedness"` + PowerFormat PowerFormat `json:"powerFormat"` + HeartRateFormat PowerFormat `json:"heartRateFormat"` + FirstDayOfWeek FirstDayOfWeek `json:"firstDayOfWeek"` + VO2MaxRunning *float64 `json:"vo2MaxRunning"` + VO2MaxCycling *float64 `json:"vo2MaxCycling"` + LactateThresholdSpeed *float64 `json:"lactateThresholdSpeed"` + LactateThresholdHeartRate *float64 `json:"lactateThresholdHeartRate"` + DiveNumber *int `json:"diveNumber"` + IntensityMinutesCalcMethod string `json:"intensityMinutesCalcMethod"` + ModerateIntensityMinutesHRZone int `json:"moderateIntensityMinutesHrZone"` + VigorousIntensityMinutesHRZone int `json:"vigorousIntensityMinutesHrZone"` + HydrationMeasurementUnit string `json:"hydrationMeasurementUnit"` + HydrationContainers []map[string]interface{} `json:"hydrationContainers"` + HydrationAutoGoalEnabled bool `json:"hydrationAutoGoalEnabled"` + FirstbeatMaxStressScore *float64 `json:"firstbeatMaxStressScore"` + FirstbeatCyclingLTTimestamp *int64 `json:"firstbeatCyclingLtTimestamp"` + FirstbeatRunningLTTimestamp *int64 `json:"firstbeatRunningLtTimestamp"` + ThresholdHeartRateAutoDetected bool `json:"thresholdHeartRateAutoDetected"` + FTPAutoDetected *bool `json:"ftpAutoDetected"` + TrainingStatusPausedDate *string `json:"trainingStatusPausedDate"` + WeatherLocation *WeatherLocation `json:"weatherLocation"` + GolfDistanceUnit *string `json:"golfDistanceUnit"` + GolfElevationUnit *string `json:"golfElevationUnit"` + GolfSpeedUnit *string `json:"golfSpeedUnit"` + ExternalBottomTime *float64 `json:"externalBottomTime"` +} + +type UserSleep struct { + SleepTime int `json:"sleepTime"` + DefaultSleepTime bool `json:"defaultSleepTime"` + WakeTime int `json:"wakeTime"` + DefaultWakeTime bool `json:"defaultWakeTime"` +} + +type UserSleepWindow struct { + SleepWindowFrequency string `json:"sleepWindowFrequency"` + StartSleepTimeSecondsFromMidnight int `json:"startSleepTimeSecondsFromMidnight"` + EndSleepTimeSecondsFromMidnight int `json:"endSleepTimeSecondsFromMidnight"` +} + +type UserSettings struct { + ID int `json:"id"` + UserData UserData `json:"userData"` + UserSleep UserSleep `json:"userSleep"` + ConnectDate *string `json:"connectDate"` + SourceType *string `json:"sourceType"` + UserSleepWindows []UserSleepWindow `json:"userSleepWindows,omitempty"` +} + +func (c *Client) GetUserSettings() (*UserSettings, error) { + settingsURL := fmt.Sprintf("https://connectapi.%s/userprofile-service/userprofile/user-settings", c.Domain) + + req, err := http.NewRequest("GET", settingsURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create settings request: %w", err) + } + + req.Header.Set("Authorization", c.AuthToken) + req.Header.Set("User-Agent", "com.garmin.android.apps.connectmobile") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get user settings: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("settings request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var settings UserSettings + if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil { + return nil, fmt.Errorf("failed to parse settings: %w", err) + } + + return &settings, nil +} diff --git a/garth/users/profile.go b/garth/users/profile.go new file mode 100644 index 0000000..77f041f --- /dev/null +++ b/garth/users/profile.go @@ -0,0 +1,71 @@ +package users + +import ( + "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"` +} diff --git a/garth/users/settings.go b/garth/users/settings.go new file mode 100644 index 0000000..0fd0f75 --- /dev/null +++ b/garth/users/settings.go @@ -0,0 +1,95 @@ +package users + +import ( + "time" + + "garmin-connect/garth/client" +) + +type PowerFormat struct { + FormatID int `json:"formatId"` + FormatKey string `json:"formatKey"` + MinFraction int `json:"minFraction"` + MaxFraction int `json:"maxFraction"` + GroupingUsed bool `json:"groupingUsed"` + DisplayFormat *string `json:"displayFormat"` +} + +type FirstDayOfWeek struct { + DayID int `json:"dayId"` + DayName string `json:"dayName"` + SortOrder int `json:"sortOrder"` + IsPossibleFirstDay bool `json:"isPossibleFirstDay"` +} + +type WeatherLocation struct { + UseFixedLocation *bool `json:"useFixedLocation"` + Latitude *float64 `json:"latitude"` + Longitude *float64 `json:"longitude"` + LocationName *string `json:"locationName"` + ISOCountryCode *string `json:"isoCountryCode"` + PostalCode *string `json:"postalCode"` +} + +type UserData struct { + Gender string `json:"gender"` + Weight float64 `json:"weight"` + Height float64 `json:"height"` + TimeFormat string `json:"timeFormat"` + BirthDate time.Time `json:"birthDate"` + MeasurementSystem string `json:"measurementSystem"` + ActivityLevel *string `json:"activityLevel"` + Handedness string `json:"handedness"` + PowerFormat PowerFormat `json:"powerFormat"` + HeartRateFormat PowerFormat `json:"heartRateFormat"` + FirstDayOfWeek FirstDayOfWeek `json:"firstDayOfWeek"` + VO2MaxRunning *float64 `json:"vo2MaxRunning"` + VO2MaxCycling *float64 `json:"vo2MaxCycling"` + LactateThresholdSpeed *float64 `json:"lactateThresholdSpeed"` + LactateThresholdHeartRate *float64 `json:"lactateThresholdHeartRate"` + DiveNumber *int `json:"diveNumber"` + IntensityMinutesCalcMethod string `json:"intensityMinutesCalcMethod"` + ModerateIntensityMinutesHRZone int `json:"moderateIntensityMinutesHrZone"` + VigorousIntensityMinutesHRZone int `json:"vigorousIntensityMinutesHrZone"` + HydrationMeasurementUnit string `json:"hydrationMeasurementUnit"` + HydrationContainers []map[string]interface{} `json:"hydrationContainers"` + HydrationAutoGoalEnabled bool `json:"hydrationAutoGoalEnabled"` + FirstbeatMaxStressScore *float64 `json:"firstbeatMaxStressScore"` + FirstbeatCyclingLTTimestamp *int64 `json:"firstbeatCyclingLtTimestamp"` + FirstbeatRunningLTTimestamp *int64 `json:"firstbeatRunningLtTimestamp"` + ThresholdHeartRateAutoDetected bool `json:"thresholdHeartRateAutoDetected"` + FTPAutoDetected *bool `json:"ftpAutoDetected"` + TrainingStatusPausedDate *string `json:"trainingStatusPausedDate"` + WeatherLocation *WeatherLocation `json:"weatherLocation"` + GolfDistanceUnit *string `json:"golfDistanceUnit"` + GolfElevationUnit *string `json:"golfElevationUnit"` + GolfSpeedUnit *string `json:"golfSpeedUnit"` + ExternalBottomTime *float64 `json:"externalBottomTime"` +} + +type UserSleep struct { + SleepTime int `json:"sleepTime"` + DefaultSleepTime bool `json:"defaultSleepTime"` + WakeTime int `json:"wakeTime"` + DefaultWakeTime bool `json:"defaultWakeTime"` +} + +type UserSleepWindow struct { + SleepWindowFrequency string `json:"sleepWindowFrequency"` + StartSleepTimeSecondsFromMidnight int `json:"startSleepTimeSecondsFromMidnight"` + EndSleepTimeSecondsFromMidnight int `json:"endSleepTimeSecondsFromMidnight"` +} + +type UserSettings struct { + ID int `json:"id"` + UserData UserData `json:"userData"` + UserSleep UserSleep `json:"userSleep"` + ConnectDate *string `json:"connectDate"` + SourceType *string `json:"sourceType"` + UserSleepWindows []UserSleepWindow `json:"userSleepWindows,omitempty"` +} + +func GetSettings(c *client.Client) (*UserSettings, error) { + // Implementation will be added in client.go + return nil, nil +} diff --git a/garth/utils/utils.go b/garth/utils/utils.go index 2b1b843..77d56a2 100644 --- a/garth/utils/utils.go +++ b/garth/utils/utils.go @@ -8,6 +8,7 @@ import ( "encoding/json" "net/http" "net/url" + "regexp" "sort" "strconv" "strings" @@ -152,3 +153,65 @@ func DateRange(end time.Time, days int) []time.Time { } return dates } + +// CamelToSnake converts a camelCase string to snake_case +func CamelToSnake(s string) string { + matchFirstCap := regexp.MustCompile("(.)([A-Z][a-z]+)") + matchAllCap := regexp.MustCompile("([a-z0-9])([A-Z])") + + snake := matchFirstCap.ReplaceAllString(s, "${1}_${2}") + snake = matchAllCap.ReplaceAllString(snake, "${1}_${2}") + return strings.ToLower(snake) +} + +// CamelToSnakeDict recursively converts map keys from camelCase to snake_case +func CamelToSnakeDict(m map[string]interface{}) map[string]interface{} { + snakeDict := make(map[string]interface{}) + for k, v := range m { + snakeKey := CamelToSnake(k) + // Handle nested maps + if nestedMap, ok := v.(map[string]interface{}); ok { + snakeDict[snakeKey] = CamelToSnakeDict(nestedMap) + } else if nestedSlice, ok := v.([]interface{}); ok { + // Handle slices of maps + var newSlice []interface{} + for _, item := range nestedSlice { + if itemMap, ok := item.(map[string]interface{}); ok { + newSlice = append(newSlice, CamelToSnakeDict(itemMap)) + } else { + newSlice = append(newSlice, item) + } + } + snakeDict[snakeKey] = newSlice + } else { + snakeDict[snakeKey] = v + } + } + return snakeDict +} + +// FormatEndDate converts various date formats to time.Time +func FormatEndDate(end interface{}) time.Time { + if end == nil { + return time.Now().UTC().Truncate(24 * time.Hour) + } + + switch v := end.(type) { + case string: + t, _ := time.Parse("2006-01-02", v) + return t + case time.Time: + return v + default: + return time.Now().UTC().Truncate(24 * time.Hour) + } +} + +// GetLocalizedDateTime converts GMT and local timestamps to localized time +func GetLocalizedDateTime(gmtTimestamp, localTimestamp int64) time.Time { + localDiff := localTimestamp - gmtTimestamp + offset := time.Duration(localDiff) * time.Millisecond + loc := time.FixedZone("", int(offset.Seconds())) + gmtTime := time.Unix(0, gmtTimestamp*int64(time.Millisecond)).UTC() + return gmtTime.In(loc) +}