mirror of
https://github.com/sstent/go-garth.git
synced 2026-01-26 00:52:40 +00:00
reworked api interfaces
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// OAuth1Token represents OAuth 1.0a credentials
|
||||
type OAuth1Token struct {
|
||||
Token string
|
||||
TokenSecret string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Expired checks if token is expired (OAuth1 tokens typically don't expire but we'll implement for consistency)
|
||||
func (t *OAuth1Token) Expired() bool {
|
||||
return false // OAuth1 tokens don't typically expire
|
||||
}
|
||||
|
||||
// OAuth2Token represents OAuth 2.0 credentials
|
||||
type OAuth2Token struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
TokenType string
|
||||
ExpiresIn int
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
// Expired checks if token is expired
|
||||
func (t *OAuth2Token) Expired() bool {
|
||||
return time.Now().After(t.ExpiresAt)
|
||||
}
|
||||
|
||||
// RefreshIfNeeded refreshes token if expired (implementation pending)
|
||||
func (t *OAuth2Token) RefreshIfNeeded(client *Client) error {
|
||||
// Placeholder for token refresh logic
|
||||
return nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sstent/go-garth/internal/api/client"
|
||||
"github.com/sstent/go-garth/internal/auth/credentials"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClient_Login_Functional(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping functional test in short mode")
|
||||
}
|
||||
|
||||
// Load credentials from .env file
|
||||
email, password, domain, err := credentials.LoadEnvCredentials()
|
||||
require.NoError(t, err, "Failed to load credentials from .env file. Please ensure GARMIN_EMAIL, GARMIN_PASSWORD, and GARMIN_DOMAIN are set.")
|
||||
|
||||
// Create client
|
||||
c, err := client.NewClient(domain)
|
||||
require.NoError(t, err, "Failed to create client")
|
||||
|
||||
// Perform login
|
||||
err = c.Login(email, password)
|
||||
require.NoError(t, err, "Login failed")
|
||||
|
||||
// Verify login
|
||||
assert.NotEmpty(t, c.AuthToken, "AuthToken should not be empty after login")
|
||||
assert.NotEmpty(t, c.Username, "Username should not be empty after login")
|
||||
|
||||
// Logout for cleanup
|
||||
err = c.Logout()
|
||||
assert.NoError(t, err, "Logout failed")
|
||||
}
|
||||
@@ -1,964 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garth/internal/auth/sso"
|
||||
"github.com/sstent/go-garth/internal/errors"
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||
models "github.com/sstent/go-garth/shared/models"
|
||||
)
|
||||
|
||||
// Client represents the Garmin Connect API client
|
||||
type Client struct {
|
||||
Domain string
|
||||
HTTPClient *http.Client
|
||||
Username string
|
||||
AuthToken string
|
||||
OAuth1Token *types.OAuth1Token
|
||||
OAuth2Token *types.OAuth2Token
|
||||
}
|
||||
|
||||
// Verify that Client implements shared.APIClient
|
||||
var _ shared.APIClient = (*Client)(nil)
|
||||
|
||||
// GetUsername returns the authenticated username
|
||||
func (c *Client) GetUsername() string {
|
||||
return c.Username
|
||||
}
|
||||
|
||||
// GetUserSettings retrieves the current user's settings
|
||||
func (c *Client) GetUserSettings() (*models.UserSettings, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
host := c.Domain
|
||||
if !strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
host = "connectapi." + c.Domain
|
||||
}
|
||||
settingsURL := fmt.Sprintf("%s://%s/userprofile-service/userprofile/user-settings", scheme, host)
|
||||
|
||||
req, err := http.NewRequest("GET", settingsURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create user settings request",
|
||||
Cause: 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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get user settings",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "User settings request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var settings models.UserSettings
|
||||
if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse user settings",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &settings, nil
|
||||
}
|
||||
|
||||
// NewClient creates a new Garmin Connect client
|
||||
func NewClient(domain string) (*Client, error) {
|
||||
if domain == "" {
|
||||
domain = "garmin.com"
|
||||
}
|
||||
|
||||
// Extract host without scheme if present
|
||||
if strings.Contains(domain, "://") {
|
||||
if u, err := url.Parse(domain); err == nil {
|
||||
domain = u.Host
|
||||
}
|
||||
}
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create cookie jar",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &Client{
|
||||
Domain: domain,
|
||||
HTTPClient: &http.Client{
|
||||
Jar: jar,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Too many redirects",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login authenticates to Garmin Connect using SSO
|
||||
func (c *Client) Login(email, password string) error {
|
||||
// Extract host without scheme if present
|
||||
host := c.Domain
|
||||
if strings.Contains(host, "://") {
|
||||
if u, err := url.Parse(host); err == nil {
|
||||
host = u.Host
|
||||
}
|
||||
}
|
||||
|
||||
ssoClient := sso.NewClient(c.Domain)
|
||||
oauth2Token, mfaContext, err := ssoClient.Login(email, password)
|
||||
if err != nil {
|
||||
return &errors.AuthenticationError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "SSO login failed",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Handle MFA required
|
||||
if mfaContext != nil {
|
||||
return &errors.AuthenticationError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "MFA required - not implemented yet",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
c.OAuth2Token = oauth2Token
|
||||
c.AuthToken = fmt.Sprintf("%s %s", oauth2Token.TokenType, oauth2Token.AccessToken)
|
||||
|
||||
// Get user profile to set username
|
||||
profile, err := c.GetUserProfile()
|
||||
if err != nil {
|
||||
return &errors.AuthenticationError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get user profile after login",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
c.Username = profile.UserName
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout clears the current session and tokens.
|
||||
func (c *Client) Logout() error {
|
||||
c.AuthToken = ""
|
||||
c.Username = ""
|
||||
c.OAuth1Token = nil
|
||||
c.OAuth2Token = nil
|
||||
|
||||
// Clear cookies
|
||||
if c.HTTPClient != nil && c.HTTPClient.Jar != nil {
|
||||
// Create a dummy URL for the domain to clear all cookies associated with it
|
||||
dummyURL, err := url.Parse(fmt.Sprintf("https://%s", c.Domain))
|
||||
if err == nil {
|
||||
c.HTTPClient.Jar.SetCookies(dummyURL, []*http.Cookie{})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserProfile retrieves the current user's full profile
|
||||
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
host := c.Domain
|
||||
if !strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
host = "connectapi." + c.Domain
|
||||
}
|
||||
profileURL := fmt.Sprintf("%s://%s/userprofile-service/socialProfile", scheme, host)
|
||||
|
||||
req, err := http.NewRequest("GET", profileURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create profile request",
|
||||
Cause: 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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get user profile",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Profile request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var profile types.UserProfile
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse profile",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &profile, nil
|
||||
}
|
||||
|
||||
// ConnectAPI makes a raw API request to the Garmin Connect API
|
||||
func (c *Client) ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
u := &url.URL{
|
||||
Scheme: scheme,
|
||||
Host: c.Domain,
|
||||
Path: path,
|
||||
RawQuery: params.Encode(),
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, u.String(), body)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create request",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "garth-go-client/1.0")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if body != nil && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Request failed",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(bodyBytes),
|
||||
GarthError: errors.GarthError{
|
||||
Message: fmt.Sprintf("API request failed with status %d: %s",
|
||||
resp.StatusCode, tryReadErrorBody(bytes.NewReader(bodyBytes))),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func tryReadErrorBody(r io.Reader) string {
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "failed to read error response"
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
// Upload sends a file to Garmin Connect
|
||||
func (c *Client) Upload(filePath string) error {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to open file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create form file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, file); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to copy file content",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if err := writer.Close(); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to close multipart writer",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
_, err = c.ConnectAPI("/upload-service/upload", "POST", nil, body)
|
||||
if err != nil {
|
||||
return &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "File upload failed",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download retrieves a file from Garmin Connect
|
||||
func (c *Client) Download(activityID string, format string, filePath string) error {
|
||||
params := url.Values{}
|
||||
params.Add("activityId", activityID)
|
||||
// Add format parameter if provided and not empty
|
||||
if format != "" {
|
||||
params.Add("format", format)
|
||||
}
|
||||
|
||||
resp, err := c.ConnectAPI("/download-service/export", "GET", params, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, resp, 0644); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to save file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActivities retrieves recent activities
|
||||
func (c *Client) GetActivities(limit int) ([]types.Activity, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
activitiesURL := fmt.Sprintf("%s://connectapi.%s/activitylist-service/activities/search/activities?limit=%d&start=0", scheme, c.Domain, limit)
|
||||
|
||||
req, err := http.NewRequest("GET", activitiesURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create activities request",
|
||||
Cause: 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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get activities",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Activities request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var activities []types.Activity
|
||||
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse activities",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSleepData(startDate, endDate time.Time) ([]types.SleepData, error) {
|
||||
// TODO: Implement GetSleepData
|
||||
return nil, fmt.Errorf("GetSleepData not implemented")
|
||||
}
|
||||
|
||||
// GetHrvData retrieves HRV data for a specified number of days
|
||||
func (c *Client) GetHrvData(days int) ([]types.HrvData, error) {
|
||||
// TODO: Implement GetHrvData
|
||||
return nil, fmt.Errorf("GetHrvData not implemented")
|
||||
}
|
||||
|
||||
// GetStressData retrieves stress data
|
||||
func (c *Client) GetStressData(startDate, endDate time.Time) ([]types.StressData, error) {
|
||||
// TODO: Implement GetStressData
|
||||
return nil, fmt.Errorf("GetStressData not implemented")
|
||||
}
|
||||
|
||||
// GetBodyBatteryData retrieves Body Battery data
|
||||
func (c *Client) GetBodyBatteryData(startDate, endDate time.Time) ([]types.BodyBatteryData, error) {
|
||||
// TODO: Implement GetBodyBatteryData
|
||||
return nil, fmt.Errorf("GetBodyBatteryData not implemented")
|
||||
}
|
||||
|
||||
// GetStepsData retrieves steps data for a specified date range
|
||||
func (c *Client) GetStepsData(startDate, endDate time.Time) ([]types.StepsData, error) {
|
||||
// TODO: Implement GetStepsData
|
||||
return nil, fmt.Errorf("GetStepsData not implemented")
|
||||
}
|
||||
|
||||
// GetDistanceData retrieves distance data for a specified date range
|
||||
func (c *Client) GetDistanceData(startDate, endDate time.Time) ([]types.DistanceData, error) {
|
||||
// TODO: Implement GetDistanceData
|
||||
return nil, fmt.Errorf("GetDistanceData not implemented")
|
||||
}
|
||||
|
||||
// GetCaloriesData retrieves calories data for a specified date range
|
||||
func (c *Client) GetCaloriesData(startDate, endDate time.Time) ([]types.CaloriesData, error) {
|
||||
// TODO: Implement GetCaloriesData
|
||||
return nil, fmt.Errorf("GetCaloriesData not implemented")
|
||||
}
|
||||
|
||||
// GetVO2MaxData retrieves VO2 max data using the modern approach via user settings
|
||||
func (c *Client) GetVO2MaxData(startDate, endDate time.Time) ([]types.VO2MaxData, error) {
|
||||
// Get user settings which contains current VO2 max values
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
// Create VO2MaxData for the date range
|
||||
var results []types.VO2MaxData
|
||||
current := startDate
|
||||
for !current.After(endDate) {
|
||||
vo2Data := types.VO2MaxData{
|
||||
Date: current,
|
||||
UserProfilePK: settings.ID,
|
||||
}
|
||||
|
||||
// Set VO2 max values if available
|
||||
if settings.UserData.VO2MaxRunning != nil {
|
||||
vo2Data.VO2MaxRunning = settings.UserData.VO2MaxRunning
|
||||
}
|
||||
if settings.UserData.VO2MaxCycling != nil {
|
||||
vo2Data.VO2MaxCycling = settings.UserData.VO2MaxCycling
|
||||
}
|
||||
|
||||
results = append(results, vo2Data)
|
||||
current = current.AddDate(0, 0, 1)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCurrentVO2Max retrieves the current VO2 max values from user profile
|
||||
func (c *Client) GetCurrentVO2Max() (*types.VO2MaxProfile, error) {
|
||||
settings, err := c.GetUserSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user settings: %w", err)
|
||||
}
|
||||
|
||||
profile := &types.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Add running VO2 max if available
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
profile.Running = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: time.Now(),
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
// Add cycling VO2 max if available
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
profile.Cycling = &types.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: time.Now(),
|
||||
Source: "user_settings",
|
||||
}
|
||||
}
|
||||
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
// GetHeartRateZones retrieves heart rate zone data
|
||||
func (c *Client) GetHeartRateZones() (*types.HeartRateZones, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
hrzURL := fmt.Sprintf("%s://connectapi.%s/userprofile-service/userprofile/heartRateZones", scheme, c.Domain)
|
||||
|
||||
req, err := http.NewRequest("GET", hrzURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create HR zones request",
|
||||
Cause: 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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get HR zones data",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "HR zones request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var hrZones types.HeartRateZones
|
||||
if err := json.NewDecoder(resp.Body).Decode(&hrZones); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse HR zones data",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return &hrZones, nil
|
||||
}
|
||||
|
||||
// GetWellnessData retrieves comprehensive wellness data for a specified date range
|
||||
func (c *Client) GetWellnessData(startDate, endDate time.Time) ([]types.WellnessData, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("startDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
|
||||
wellnessURL := fmt.Sprintf("%s://connectapi.%s/wellness-service/wellness/daily/wellness?%s", scheme, c.Domain, params.Encode())
|
||||
|
||||
req, err := http.NewRequest("GET", wellnessURL, nil)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to create wellness data request",
|
||||
Cause: 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, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to get wellness data",
|
||||
Cause: err,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, &errors.APIError{
|
||||
GarthHTTPError: errors.GarthHTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Response: string(body),
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Wellness data request failed",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var wellnessData []types.WellnessData
|
||||
if err := json.NewDecoder(resp.Body).Decode(&wellnessData); err != nil {
|
||||
return nil, &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to parse wellness data",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return wellnessData, nil
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(session, "", " ")
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to marshal session",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filename, data, 0600); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to write session file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDetailedSleepData retrieves comprehensive sleep data for a date
|
||||
func (c *Client) GetDetailedSleepData(date time.Time) (*types.DetailedSleepData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?date=%s&nonSleepBufferMinutes=60",
|
||||
c.Username, dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get detailed sleep data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
WellnessEpochSPO2DataDTOList []interface{} `json:"wellnessEpochSPO2DataDTOList"`
|
||||
WellnessEpochRespirationDataDTOList []interface{} `json:"wellnessEpochRespirationDataDTOList"`
|
||||
SleepStress interface{} `json:"sleepStress"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse detailed sleep response: %w", err)
|
||||
}
|
||||
|
||||
if response.DailySleepDTO == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Populate additional data
|
||||
response.DailySleepDTO.SleepMovement = response.SleepMovement
|
||||
response.DailySleepDTO.SleepLevels = response.SleepLevels
|
||||
|
||||
return response.DailySleepDTO, nil
|
||||
}
|
||||
|
||||
// GetDailyHRVData retrieves comprehensive daily HRV data for a date
|
||||
func (c *Client) GetDailyHRVData(date time.Time) (*types.DailyHRVData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyHrvData/%s?date=%s",
|
||||
c.Username, dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get HRV data: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse HRV response: %w", err)
|
||||
}
|
||||
|
||||
// Combine summary and readings
|
||||
response.HRVSummary.HRVReadings = response.HRVReadings
|
||||
return &response.HRVSummary, nil
|
||||
}
|
||||
|
||||
// GetDetailedBodyBatteryData retrieves comprehensive Body Battery data for a date
|
||||
func (c *Client) GetDetailedBodyBatteryData(date time.Time) (*types.DetailedBodyBatteryData, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := c.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := c.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result types.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []types.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrainingStatus retrieves current training status
|
||||
func (c *Client) GetTrainingStatus(date time.Time) (*types.TrainingStatus, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingStatus/%s", dateStr)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training status: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result types.TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetTrainingLoad retrieves training load data
|
||||
func (c *Client) GetTrainingLoad(date time.Time) (*types.TrainingLoad, error) {
|
||||
dateStr := date.Format("2006-01-02")
|
||||
endDate := date.AddDate(0, 0, 6).Format("2006-01-02") // Get week of data
|
||||
path := fmt.Sprintf("/metrics-service/metrics/trainingLoad/%s/%s", dateStr, endDate)
|
||||
|
||||
data, err := c.ConnectAPI(path, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get training load: %w", err)
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []types.TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
if len(results) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &results[0], nil
|
||||
}
|
||||
|
||||
// LoadSession loads a session from a file
|
||||
func (c *Client) LoadSession(filename string) error {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to read session file",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var session types.SessionData
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
return &errors.IOError{
|
||||
GarthError: errors.GarthError{
|
||||
Message: "Failed to unmarshal session",
|
||||
Cause: err,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
c.Domain = session.Domain
|
||||
c.Username = session.Username
|
||||
c.AuthToken = session.AuthToken
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RefreshSession refreshes the authentication tokens
|
||||
func (c *Client) RefreshSession() error {
|
||||
// TODO: Implement token refresh logic
|
||||
return fmt.Errorf("RefreshSession not implemented")
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garth/internal/testutils"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/sstent/go-garth/internal/api/client"
|
||||
)
|
||||
|
||||
func TestClient_GetUserProfile(t *testing.T) {
|
||||
// Create mock server returning user profile
|
||||
server := testutils.MockJSONResponse(http.StatusOK, `{
|
||||
"userName": "testuser",
|
||||
"displayName": "Test User",
|
||||
"fullName": "Test User",
|
||||
"location": "Test Location"
|
||||
}`)
|
||||
defer server.Close()
|
||||
|
||||
// Create client with test configuration
|
||||
u, _ := url.Parse(server.URL)
|
||||
c, err := client.NewClient(u.Host)
|
||||
require.NoError(t, err)
|
||||
c.Domain = u.Host
|
||||
require.NoError(t, err)
|
||||
c.HTTPClient = &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
c.AuthToken = "Bearer testtoken"
|
||||
|
||||
// Get user profile
|
||||
profile, err := c.GetUserProfile()
|
||||
|
||||
// Verify response
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "testuser", profile.UserName)
|
||||
assert.Equal(t, "Test User", profile.DisplayName)
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
// Package client implements the low-level Garmin Connect HTTP client.
|
||||
// It is responsible for authentication (via SSO helpers), request construction,
|
||||
// header and cookie handling, error mapping, and JSON decoding. Higher-level
|
||||
// public APIs in pkg/garmin delegate to this package for actual network I/O.
|
||||
// Note: This is an internal package and not intended for direct external use.
|
||||
package client
|
||||
@@ -1,4 +0,0 @@
|
||||
package client
|
||||
|
||||
// This file intentionally left blank.
|
||||
// All HTTP client methods are now implemented in client.go.
|
||||
@@ -1,11 +0,0 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// HTTPClient defines the interface for HTTP operations
|
||||
type HTTPClient interface {
|
||||
ConnectAPI(path string, method string, params url.Values, body io.Reader) ([]byte, error)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garth/internal/models/types"
|
||||
"github.com/sstent/go-garth/internal/utils"
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
)
|
||||
|
||||
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
||||
func GetOAuth1Token(domain, ticket string) (*types.OAuth1Token, error) {
|
||||
func GetOAuth1Token(domain, ticket string) (*garth.OAuth1Token, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
@@ -85,7 +85,7 @@ func GetOAuth1Token(domain, ticket string) (*types.OAuth1Token, error) {
|
||||
return nil, fmt.Errorf("missing oauth_token or oauth_token_secret in response")
|
||||
}
|
||||
|
||||
return &types.OAuth1Token{
|
||||
return &garth.OAuth1Token{
|
||||
OAuthToken: oauthToken,
|
||||
OAuthTokenSecret: oauthTokenSecret,
|
||||
MFAToken: values.Get("mfa_token"),
|
||||
@@ -94,7 +94,7 @@ func GetOAuth1Token(domain, ticket string) (*types.OAuth1Token, error) {
|
||||
}
|
||||
|
||||
// ExchangeToken exchanges an OAuth1 token for an OAuth2 token
|
||||
func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
||||
func ExchangeToken(oauth1Token *garth.OAuth1Token) (*garth.OAuth2Token, error) {
|
||||
scheme := "https"
|
||||
if strings.HasPrefix(oauth1Token.Domain, "127.0.0.1") {
|
||||
scheme = "http"
|
||||
@@ -148,7 +148,7 @@ func ExchangeToken(oauth1Token *types.OAuth1Token) (*types.OAuth2Token, error) {
|
||||
return nil, fmt.Errorf("OAuth2 exchange failed with status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var oauth2Token types.OAuth2Token
|
||||
var oauth2Token garth.OAuth2Token
|
||||
if err := json.Unmarshal(body, &oauth2Token); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode OAuth2 token: %w", err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garth/internal/auth/oauth"
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -41,7 +41,7 @@ func NewClient(domain string) *Client {
|
||||
}
|
||||
|
||||
// Login performs the SSO authentication flow
|
||||
func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext, error) {
|
||||
func (c *Client) Login(email, password string) (*garth.OAuth2Token, *MFAContext, error) {
|
||||
fmt.Printf("Logging in to Garmin Connect (%s) using SSO flow...\n", c.Domain)
|
||||
|
||||
scheme := "https"
|
||||
@@ -185,7 +185,7 @@ func (c *Client) Login(email, password string) (*types.OAuth2Token, *MFAContext,
|
||||
}
|
||||
|
||||
// ResumeLogin completes authentication after MFA challenge
|
||||
func (c *Client) ResumeLogin(mfaCode string, ctx *MFAContext) (*types.OAuth2Token, error) {
|
||||
func (c *Client) ResumeLogin(mfaCode string, ctx *MFAContext) (*garth.OAuth2Token, error) {
|
||||
fmt.Println("Resuming login with MFA code...")
|
||||
|
||||
// Submit MFA form
|
||||
|
||||
7
internal/data/base.go
Normal file
7
internal/data/base.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package data
|
||||
|
||||
import shared "github.com/sstent/go-garth/shared/interfaces"
|
||||
|
||||
// Alias shared BaseData and Data into internal/data for backward compatibility with tests
|
||||
type BaseData = shared.BaseData
|
||||
type Data = shared.Data
|
||||
@@ -5,7 +5,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garth/internal/api/client"
|
||||
"github.com/sstent/go-garth/pkg/garth/client"
|
||||
interfaces "github.com/sstent/go-garth/shared/interfaces"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -28,7 +29,7 @@ func (mc *MockClient) Get(endpoint string) (interface{}, error) {
|
||||
func TestBaseData_List(t *testing.T) {
|
||||
// Setup mock data type
|
||||
mockData := &MockData{}
|
||||
mockData.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||
mockData.GetFunc = func(day time.Time, c interfaces.APIClient) (interface{}, error) {
|
||||
return "data for " + day.Format("2006-01-02"), nil
|
||||
}
|
||||
|
||||
@@ -51,7 +52,7 @@ func TestBaseData_List(t *testing.T) {
|
||||
func TestBaseData_List_ErrorHandling(t *testing.T) {
|
||||
// Setup mock data type that returns error on specific date
|
||||
mockData := &MockData{}
|
||||
mockData.GetFunc = func(day time.Time, c *client.Client) (interface{}, error) {
|
||||
mockData.GetFunc = func(day time.Time, c interfaces.APIClient) (interface{}, error) {
|
||||
if day.Day() == 13 {
|
||||
return nil, errors.New("bad luck day")
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
@@ -19,38 +19,92 @@ type BodyBatteryReading struct {
|
||||
}
|
||||
|
||||
// ParseBodyBatteryReadings converts body battery values array to structured readings
|
||||
// Accepts mixed numeric types (int, int64, float64, json.Number) for robustness.
|
||||
func ParseBodyBatteryReadings(valuesArray [][]any) []BodyBatteryReading {
|
||||
readings := make([]BodyBatteryReading, 0)
|
||||
readings := make([]BodyBatteryReading, 0, len(valuesArray))
|
||||
|
||||
toInt := func(v any) (int, bool) {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t, true
|
||||
case int32:
|
||||
return int(t), true
|
||||
case int64:
|
||||
return int(t), true
|
||||
case float32:
|
||||
return int(t), true
|
||||
case float64:
|
||||
return int(t), true
|
||||
case json.Number:
|
||||
i, err := t.Int64()
|
||||
if err == nil {
|
||||
return int(i), true
|
||||
}
|
||||
f, err := t.Float64()
|
||||
if err == nil {
|
||||
return int(f), true
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
toFloat64 := func(v any) (float64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float32:
|
||||
return float64(t), true
|
||||
case float64:
|
||||
return t, true
|
||||
case int:
|
||||
return float64(t), true
|
||||
case int32:
|
||||
return float64(t), true
|
||||
case int64:
|
||||
return float64(t), true
|
||||
case json.Number:
|
||||
f, err := t.Float64()
|
||||
if err == nil {
|
||||
return f, true
|
||||
}
|
||||
return 0, false
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
timestamp, ok1 := values[0].(float64)
|
||||
ts, ok1 := toInt(values[0])
|
||||
status, ok2 := values[1].(string)
|
||||
level, ok3 := values[2].(float64)
|
||||
version, ok4 := values[3].(float64)
|
||||
lvl, ok3 := toInt(values[2])
|
||||
ver, ok4 := toFloat64(values[3])
|
||||
|
||||
if !ok1 || !ok2 || !ok3 || !ok4 {
|
||||
continue
|
||||
}
|
||||
|
||||
readings = append(readings, BodyBatteryReading{
|
||||
Timestamp: int(timestamp),
|
||||
Timestamp: ts,
|
||||
Status: status,
|
||||
Level: int(level),
|
||||
Version: version,
|
||||
Level: lvl,
|
||||
Version: ver,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(readings, func(i, j int) bool {
|
||||
return readings[i].Timestamp < readings[j].Timestamp
|
||||
})
|
||||
|
||||
return readings
|
||||
}
|
||||
|
||||
// BodyBatteryDataWithMethods embeds types.DetailedBodyBatteryData and adds methods
|
||||
// BodyBatteryDataWithMethods embeds garth.DetailedBodyBatteryData and adds methods
|
||||
type BodyBatteryDataWithMethods struct {
|
||||
types.DetailedBodyBatteryData
|
||||
garth.DetailedBodyBatteryData
|
||||
}
|
||||
|
||||
func (d *BodyBatteryDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
@@ -71,14 +125,14 @@ func (d *BodyBatteryDataWithMethods) Get(day time.Time, c shared.APIClient) (int
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result types.DetailedBodyBatteryData
|
||||
var result garth.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []types.BodyBatteryEvent
|
||||
var events []garth.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
@@ -111,3 +165,48 @@ func (d *BodyBatteryDataWithMethods) GetDayChange() int {
|
||||
|
||||
return readings[len(readings)-1].Level - readings[0].Level
|
||||
}
|
||||
|
||||
// Added for test compatibility and public API alignment
|
||||
// DailyBodyBatteryStress wraps garth.DetailedBodyBatteryData and provides a Get method compatible with existing tests.
|
||||
// See [type DailyBodyBatteryStress](internal/data/body_battery.go:0) and [func (*DailyBodyBatteryStress).Get](internal/data/body_battery.go:0)
|
||||
type DailyBodyBatteryStress struct {
|
||||
garth.DetailedBodyBatteryData
|
||||
}
|
||||
|
||||
// Get retrieves Body Battery daily stress data and associated events for a given day.
|
||||
// Mirrors logic in BodyBatteryDataWithMethods.Get to maintain a consistent behavior.
|
||||
// Returns (*DailyBodyBatteryStress, nil) on success, (nil, nil) when no data available.
|
||||
func (d *DailyBodyBatteryStress) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
|
||||
// Get main Body Battery data
|
||||
path1 := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
data1, err := c.ConnectAPI(path1, "GET", nil, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get Body Battery stress data: %w", err)
|
||||
}
|
||||
|
||||
// Get Body Battery events
|
||||
path2 := fmt.Sprintf("/wellness-service/wellness/bodyBattery/%s", dateStr)
|
||||
data2, err := c.ConnectAPI(path2, "GET", nil, nil)
|
||||
if err != nil {
|
||||
// Events might not be available, continue without them
|
||||
data2 = []byte("[]")
|
||||
}
|
||||
|
||||
var result garth.DetailedBodyBatteryData
|
||||
if len(data1) > 0 {
|
||||
if err := json.Unmarshal(data1, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse Body Battery data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var events []garth.BodyBatteryEvent
|
||||
if len(data2) > 0 {
|
||||
if err := json.Unmarshal(data2, &events); err == nil {
|
||||
result.Events = events
|
||||
}
|
||||
}
|
||||
|
||||
return &DailyBodyBatteryStress{DetailedBodyBatteryData: result}, nil
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
"testing"
|
||||
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestParseBodyBatteryReadings(t *testing.T) {
|
||||
|
||||
// Test for GetCurrentLevel and GetDayChange methods
|
||||
func TestBodyBatteryDataWithMethods(t *testing.T) {
|
||||
mockData := types.DetailedBodyBatteryData{
|
||||
mockData := garth.DetailedBodyBatteryData{
|
||||
BodyBatteryValuesArray: [][]interface{}{
|
||||
{1000, "ACTIVE", 75, 1.0},
|
||||
{2000, "ACTIVE", 70, 1.0},
|
||||
@@ -72,7 +72,7 @@ func TestBodyBatteryDataWithMethods(t *testing.T) {
|
||||
})
|
||||
|
||||
// Test with empty data
|
||||
emptyData := types.DetailedBodyBatteryData{
|
||||
emptyData := garth.DetailedBodyBatteryData{
|
||||
BodyBatteryValuesArray: [][]interface{}{},
|
||||
}
|
||||
emptyBb := BodyBatteryDataWithMethods{DetailedBodyBatteryData: emptyData}
|
||||
@@ -86,7 +86,7 @@ func TestBodyBatteryDataWithMethods(t *testing.T) {
|
||||
})
|
||||
|
||||
// Test with single reading
|
||||
singleReadingData := types.DetailedBodyBatteryData{
|
||||
singleReadingData := garth.DetailedBodyBatteryData{
|
||||
BodyBatteryValuesArray: [][]interface{}{
|
||||
{1000, "ACTIVE", 80, 1.0},
|
||||
},
|
||||
|
||||
@@ -6,13 +6,13 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// DailyHRVDataWithMethods embeds types.DailyHRVData and adds methods
|
||||
// DailyHRVDataWithMethods embeds garth.DailyHRVData and adds methods
|
||||
type DailyHRVDataWithMethods struct {
|
||||
types.DailyHRVData
|
||||
garth.DailyHRVData
|
||||
}
|
||||
|
||||
// Get implements the Data interface for DailyHRVData
|
||||
@@ -31,8 +31,8 @@ func (h *DailyHRVDataWithMethods) Get(day time.Time, c shared.APIClient) (interf
|
||||
}
|
||||
|
||||
var response struct {
|
||||
HRVSummary types.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []types.HRVReading `json:"hrvReadings"`
|
||||
HRVSummary garth.DailyHRVData `json:"hrvSummary"`
|
||||
HRVReadings []garth.HRVReading `json:"hrvReadings"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
@@ -45,8 +45,8 @@ func (h *DailyHRVDataWithMethods) Get(day time.Time, c shared.APIClient) (interf
|
||||
}
|
||||
|
||||
// ParseHRVReadings converts body battery values array to structured readings
|
||||
func ParseHRVReadings(valuesArray [][]any) []types.HRVReading {
|
||||
readings := make([]types.HRVReading, 0, len(valuesArray))
|
||||
func ParseHRVReadings(valuesArray [][]any) []garth.HRVReading {
|
||||
readings := make([]garth.HRVReading, 0, len(valuesArray))
|
||||
for _, values := range valuesArray {
|
||||
if len(values) < 6 {
|
||||
continue
|
||||
@@ -60,7 +60,7 @@ func ParseHRVReadings(valuesArray [][]any) []types.HRVReading {
|
||||
status, _ := values[4].(string)
|
||||
signalQuality, _ := values[5].(float64)
|
||||
|
||||
readings = append(readings, types.HRVReading{
|
||||
readings = append(readings, garth.HRVReading{
|
||||
Timestamp: timestamp,
|
||||
StressLevel: stressLevel,
|
||||
HeartRate: heartRate,
|
||||
|
||||
@@ -5,17 +5,17 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// DailySleepDTO represents daily sleep data
|
||||
type DailySleepDTO struct {
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepScores types.SleepScore `json:"sleepScores"` // Using types.SleepScore
|
||||
UserProfilePK int `json:"userProfilePk"`
|
||||
CalendarDate time.Time `json:"calendarDate"`
|
||||
SleepStartTimestampGMT time.Time `json:"sleepStartTimestampGmt"`
|
||||
SleepEndTimestampGMT time.Time `json:"sleepEndTimestampGmt"`
|
||||
SleepScores garth.SleepScore `json:"sleepScores"` // Using garth.SleepScore
|
||||
shared.BaseData
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ func (d *DailySleepDTO) Get(day time.Time, c shared.APIClient) (any, error) {
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *DailySleepDTO `json:"dailySleepDto"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"` // Using types.SleepMovement
|
||||
DailySleepDTO *DailySleepDTO `json:"dailySleepDto"`
|
||||
SleepMovement []garth.SleepMovement `json:"sleepMovement"` // Using garth.SleepMovement
|
||||
}
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// DetailedSleepDataWithMethods embeds types.DetailedSleepData and adds methods
|
||||
// DetailedSleepDataWithMethods embeds garth.DetailedSleepData and adds methods
|
||||
type DetailedSleepDataWithMethods struct {
|
||||
types.DetailedSleepData
|
||||
garth.DetailedSleepData
|
||||
}
|
||||
|
||||
func (d *DetailedSleepDataWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
@@ -29,10 +29,10 @@ func (d *DetailedSleepDataWithMethods) Get(day time.Time, c shared.APIClient) (i
|
||||
}
|
||||
|
||||
var response struct {
|
||||
DailySleepDTO *types.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []types.SleepMovement `json:"sleepMovement"`
|
||||
DailySleepDTO *garth.DetailedSleepData `json:"dailySleepDTO"`
|
||||
SleepMovement []garth.SleepMovement `json:"sleepMovement"`
|
||||
RemSleepData bool `json:"remSleepData"`
|
||||
SleepLevels []types.SleepLevel `json:"sleepLevels"`
|
||||
SleepLevels []garth.SleepLevel `json:"sleepLevels"`
|
||||
SleepRestlessMoments []interface{} `json:"sleepRestlessMoments"`
|
||||
RestlessMomentsCount int `json:"restlessMomentsCount"`
|
||||
WellnessSpO2SleepSummaryDTO interface{} `json:"wellnessSpO2SleepSummaryDTO"`
|
||||
@@ -58,8 +58,8 @@ func (d *DetailedSleepDataWithMethods) Get(day time.Time, c shared.APIClient) (i
|
||||
|
||||
// GetSleepEfficiency calculates sleep efficiency percentage
|
||||
func (d *DetailedSleepDataWithMethods) GetSleepEfficiency() float64 {
|
||||
totalTime := d.SleepEndTimestampGMT.Sub(d.SleepStartTimestampGMT).Seconds()
|
||||
sleepTime := float64(d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds)
|
||||
totalTime := d.DetailedSleepData.SleepEndTimestampGMT.Sub(d.DetailedSleepData.SleepStartTimestampGMT).Seconds()
|
||||
sleepTime := float64(d.DetailedSleepData.DeepSleepSeconds + d.DetailedSleepData.LightSleepSeconds + d.DetailedSleepData.RemSleepSeconds)
|
||||
if totalTime == 0 {
|
||||
return 0
|
||||
}
|
||||
@@ -68,6 +68,6 @@ func (d *DetailedSleepDataWithMethods) GetSleepEfficiency() float64 {
|
||||
|
||||
// GetTotalSleepTime returns total sleep time in hours
|
||||
func (d *DetailedSleepDataWithMethods) GetTotalSleepTime() float64 {
|
||||
totalSeconds := d.DeepSleepSeconds + d.LightSleepSeconds + d.RemSleepSeconds
|
||||
totalSeconds := d.DetailedSleepData.DeepSleepSeconds + d.DetailedSleepData.LightSleepSeconds + d.DetailedSleepData.RemSleepSeconds
|
||||
return float64(totalSeconds) / 3600.0
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||
)
|
||||
|
||||
// TrainingStatusWithMethods embeds types.TrainingStatus and adds methods
|
||||
// TrainingStatusWithMethods embeds garth.TrainingStatus and adds methods
|
||||
type TrainingStatusWithMethods struct {
|
||||
types.TrainingStatus
|
||||
garth.TrainingStatus
|
||||
}
|
||||
|
||||
func (t *TrainingStatusWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
@@ -27,7 +27,7 @@ func (t *TrainingStatusWithMethods) Get(day time.Time, c shared.APIClient) (inte
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result types.TrainingStatus
|
||||
var result garth.TrainingStatus
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training status: %w", err)
|
||||
}
|
||||
@@ -35,9 +35,9 @@ func (t *TrainingStatusWithMethods) Get(day time.Time, c shared.APIClient) (inte
|
||||
return &TrainingStatusWithMethods{TrainingStatus: result}, nil
|
||||
}
|
||||
|
||||
// TrainingLoadWithMethods embeds types.TrainingLoad and adds methods
|
||||
// TrainingLoadWithMethods embeds garth.TrainingLoad and adds methods
|
||||
type TrainingLoadWithMethods struct {
|
||||
types.TrainingLoad
|
||||
garth.TrainingLoad
|
||||
}
|
||||
|
||||
func (t *TrainingLoadWithMethods) Get(day time.Time, c shared.APIClient) (interface{}, error) {
|
||||
@@ -54,7 +54,7 @@ func (t *TrainingLoadWithMethods) Get(day time.Time, c shared.APIClient) (interf
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []types.TrainingLoad
|
||||
var results []garth.TrainingLoad
|
||||
if err := json.Unmarshal(data, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse training load: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
shared "github.com/sstent/go-garth/shared/interfaces"
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
)
|
||||
|
||||
// VO2MaxData implements the Data interface for VO2 max retrieval
|
||||
@@ -29,14 +29,14 @@ func (v *VO2MaxData) get(day time.Time, c shared.APIClient) (interface{}, error)
|
||||
}
|
||||
|
||||
// Extract VO2 max data from user settings
|
||||
vo2Profile := &types.VO2MaxProfile{
|
||||
vo2Profile := &garth.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
// Add running VO2 max if available
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
vo2Profile.Running = &types.VO2MaxEntry{
|
||||
vo2Profile.Running = &garth.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: day,
|
||||
@@ -46,7 +46,7 @@ func (v *VO2MaxData) get(day time.Time, c shared.APIClient) (interface{}, error)
|
||||
|
||||
// Add cycling VO2 max if available
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
vo2Profile.Cycling = &types.VO2MaxEntry{
|
||||
vo2Profile.Cycling = &garth.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: day,
|
||||
@@ -77,14 +77,14 @@ func (v *VO2MaxData) List(end time.Time, days int, c shared.APIClient, maxWorker
|
||||
}
|
||||
|
||||
// GetCurrentVO2Max is a convenience method to get current VO2 max values
|
||||
func GetCurrentVO2Max(c shared.APIClient) (*types.VO2MaxProfile, error) {
|
||||
func GetCurrentVO2Max(c shared.APIClient) (*garth.VO2MaxProfile, error) {
|
||||
vo2Data := NewVO2MaxData()
|
||||
result, err := vo2Data.get(time.Now(), c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vo2Profile, ok := result.(*types.VO2MaxProfile)
|
||||
vo2Profile, ok := result.(*garth.VO2MaxProfile)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected result type")
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
types "github.com/sstent/go-garth/internal/models/types"
|
||||
garth "github.com/sstent/go-garth/pkg/garth/types"
|
||||
"github.com/sstent/go-garth/shared/interfaces"
|
||||
"github.com/sstent/go-garth/shared/models"
|
||||
|
||||
@@ -27,13 +27,13 @@ func TestVO2MaxData_Get(t *testing.T) {
|
||||
|
||||
// Mock the get function
|
||||
vo2Data.GetFunc = func(day time.Time, c interfaces.APIClient) (interface{}, error) {
|
||||
vo2Profile := &types.VO2MaxProfile{
|
||||
vo2Profile := &garth.VO2MaxProfile{
|
||||
UserProfilePK: settings.ID,
|
||||
LastUpdated: time.Now(),
|
||||
}
|
||||
|
||||
if settings.UserData.VO2MaxRunning != nil && *settings.UserData.VO2MaxRunning > 0 {
|
||||
vo2Profile.Running = &types.VO2MaxEntry{
|
||||
vo2Profile.Running = &garth.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxRunning,
|
||||
ActivityType: "running",
|
||||
Date: day,
|
||||
@@ -42,7 +42,7 @@ func TestVO2MaxData_Get(t *testing.T) {
|
||||
}
|
||||
|
||||
if settings.UserData.VO2MaxCycling != nil && *settings.UserData.VO2MaxCycling > 0 {
|
||||
vo2Profile.Cycling = &types.VO2MaxEntry{
|
||||
vo2Profile.Cycling = &garth.VO2MaxEntry{
|
||||
Value: *settings.UserData.VO2MaxCycling,
|
||||
ActivityType: "cycling",
|
||||
Date: day,
|
||||
@@ -59,7 +59,7 @@ func TestVO2MaxData_Get(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, result)
|
||||
|
||||
profile, ok := result.(*types.VO2MaxProfile)
|
||||
profile, ok := result.(*garth.VO2MaxProfile)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 12345, profile.UserProfilePK)
|
||||
assert.NotNil(t, profile.Running)
|
||||
|
||||
@@ -74,7 +74,7 @@ func (w *WeightDataWithMethods) Get(day time.Time, c shared.APIClient) (any, err
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
func (w *WeightDataWithMethods) List(end time.Time, days int, c shared.APIClient, maxWorkers int) ([]any, error) {
|
||||
// BaseData is not part of types.WeightData, so this line needs to be removed or re-evaluated.
|
||||
// BaseData is not part of garth.WeightData, so this line needs to be removed or re-evaluated.
|
||||
// For now, I will return an empty slice and no error, as this function is not directly related to the task.
|
||||
return []any{}, nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garth/internal/api/client"
|
||||
"github.com/sstent/go-garth/pkg/garth/client"
|
||||
"github.com/sstent/go-garth/internal/utils"
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
|
||||
"github.com/sstent/go-garth/internal/api/client"
|
||||
"github.com/sstent/go-garth/pkg/garth/client"
|
||||
)
|
||||
|
||||
// MockClient simulates API client for tests
|
||||
|
||||
@@ -3,7 +3,7 @@ package users
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sstent/go-garth/internal/api/client"
|
||||
"github.com/sstent/go-garth/pkg/garth/client"
|
||||
)
|
||||
|
||||
type PowerFormat struct {
|
||||
|
||||
Reference in New Issue
Block a user