mirror of
https://github.com/sstent/go-garth.git
synced 2026-01-25 08:35:06 +00:00
feat(refactor): Implement 1A.1 Package Structure Refactoring
This commit implements the package structure refactoring as outlined in phase1.md (Task 1A.1). Key changes include: - Reorganized packages into `pkg/garmin` for public API and `internal/` for internal implementations. - Updated all import paths to reflect the new structure. - Consolidated types and client logic into their respective new packages. - Updated `cmd/garth/main.go` to use the new public API. - Fixed various compilation and test issues encountered during the refactoring process. - Converted `internal/api/client/auth_test.go` to a functional test. This establishes a solid foundation for future enhancements and improves maintainability.
This commit is contained in:
1
cmd/garth/activities.go
Normal file
1
cmd/garth/activities.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package main
|
||||||
1
cmd/garth/auth.go
Normal file
1
cmd/garth/auth.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package main
|
||||||
1
cmd/garth/health.go
Normal file
1
cmd/garth/health.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package main
|
||||||
@@ -8,18 +8,19 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth"
|
"garmin-connect/pkg/garmin"
|
||||||
"garmin-connect/garth/credentials"
|
"garmin-connect/internal/auth/credentials"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Parse command line flags
|
// Parse command line flags
|
||||||
outputTokens := flag.Bool("tokens", false, "Output OAuth tokens in JSON format")
|
var outputTokens = flag.Bool("tokens", false, "Output OAuth tokens in JSON format")
|
||||||
dataType := flag.String("data", "", "Data type to fetch (bodybattery, sleep, hrv, weight)")
|
var dataType = flag.String("data", "", "Data type to fetch (bodybattery, sleep, hrv, weight)")
|
||||||
statsType := flag.String("stats", "", "Stats type to fetch (steps, stress, hydration, intensity, sleep, hrv)")
|
var statsType = flag.String("stats", "", "Stats type to fetch (steps, stress, hydration, intensity, sleep, hrv)")
|
||||||
dateStr := flag.String("date", "", "Date in YYYY-MM-DD format (default: yesterday)")
|
|
||||||
days := flag.Int("days", 1, "Number of days to fetch")
|
var dateStr = flag.String("date", "", "Date in YYYY-MM-DD format (default: yesterday)")
|
||||||
outputFile := flag.String("output", "", "Output file for JSON results")
|
var days = flag.Int("days", 1, "Number of days to fetch")
|
||||||
|
var outputFile = flag.String("output", "", "Output file for JSON results")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Load credentials from .env file
|
// Load credentials from .env file
|
||||||
@@ -29,7 +30,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create client
|
// Create client
|
||||||
garminClient, err := garth.NewClient(domain)
|
garminClient, err := garmin.NewClient(domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create client: %v", err)
|
log.Fatalf("Failed to create client: %v", err)
|
||||||
}
|
}
|
||||||
@@ -77,13 +78,13 @@ func main() {
|
|||||||
displayActivities(activities)
|
displayActivities(activities)
|
||||||
}
|
}
|
||||||
|
|
||||||
func outputTokensJSON(c *garth.Client) {
|
func outputTokensJSON(c *garmin.Client) {
|
||||||
tokens := struct {
|
tokens := struct {
|
||||||
OAuth1 *garth.OAuth1Token `json:"oauth1"`
|
OAuth1 *garmin.OAuth1Token `json:"oauth1"`
|
||||||
OAuth2 *garth.OAuth2Token `json:"oauth2"`
|
OAuth2 *garmin.OAuth2Token `json:"oauth2"`
|
||||||
}{
|
}{
|
||||||
OAuth1: c.OAuth1Token,
|
OAuth1: c.OAuth1Token(),
|
||||||
OAuth2: c.OAuth2Token,
|
OAuth2: c.OAuth2Token(),
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(tokens, "", " ")
|
jsonBytes, err := json.MarshalIndent(tokens, "", " ")
|
||||||
@@ -93,7 +94,7 @@ func outputTokensJSON(c *garth.Client) {
|
|||||||
fmt.Println(string(jsonBytes))
|
fmt.Println(string(jsonBytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDataRequest(c *garth.Client, dataType, dateStr string, days int, outputFile string) {
|
func handleDataRequest(c *garmin.Client, dataType, dateStr string, days int, outputFile string) {
|
||||||
endDate := time.Now().AddDate(0, 0, -1) // default to yesterday
|
endDate := time.Now().AddDate(0, 0, -1) // default to yesterday
|
||||||
if dateStr != "" {
|
if dateStr != "" {
|
||||||
parsedDate, err := time.Parse("2006-01-02", dateStr)
|
parsedDate, err := time.Parse("2006-01-02", dateStr)
|
||||||
@@ -108,17 +109,13 @@ func handleDataRequest(c *garth.Client, dataType, dateStr string, days int, outp
|
|||||||
|
|
||||||
switch dataType {
|
switch dataType {
|
||||||
case "bodybattery":
|
case "bodybattery":
|
||||||
bb := &garth.BodyBatteryData{}
|
result, err = c.GetBodyBattery(endDate)
|
||||||
result, err = bb.Get(endDate, c)
|
|
||||||
case "sleep":
|
case "sleep":
|
||||||
sleep := &garth.SleepData{}
|
result, err = c.GetSleep(endDate)
|
||||||
result, err = sleep.Get(endDate, c)
|
|
||||||
case "hrv":
|
case "hrv":
|
||||||
hrv := &garth.HRVData{}
|
result, err = c.GetHRV(endDate)
|
||||||
result, err = hrv.Get(endDate, c)
|
|
||||||
case "weight":
|
case "weight":
|
||||||
weight := &garth.WeightData{}
|
result, err = c.GetWeight(endDate)
|
||||||
result, err = weight.Get(endDate, c)
|
|
||||||
default:
|
default:
|
||||||
log.Fatalf("Unknown data type: %s", dataType)
|
log.Fatalf("Unknown data type: %s", dataType)
|
||||||
}
|
}
|
||||||
@@ -130,7 +127,7 @@ func handleDataRequest(c *garth.Client, dataType, dateStr string, days int, outp
|
|||||||
outputResult(result, outputFile)
|
outputResult(result, outputFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleStatsRequest(c *garth.Client, statsType, dateStr string, days int, outputFile string) {
|
func handleStatsRequest(c *garmin.Client, statsType, dateStr string, days int, outputFile string) {
|
||||||
endDate := time.Now().AddDate(0, 0, -1) // default to yesterday
|
endDate := time.Now().AddDate(0, 0, -1) // default to yesterday
|
||||||
if dateStr != "" {
|
if dateStr != "" {
|
||||||
parsedDate, err := time.Parse("2006-01-02", dateStr)
|
parsedDate, err := time.Parse("2006-01-02", dateStr)
|
||||||
@@ -140,25 +137,25 @@ func handleStatsRequest(c *garth.Client, statsType, dateStr string, days int, ou
|
|||||||
endDate = parsedDate
|
endDate = parsedDate
|
||||||
}
|
}
|
||||||
|
|
||||||
var stats garth.Stats
|
var stats garmin.Stats
|
||||||
switch statsType {
|
switch statsType {
|
||||||
case "steps":
|
case "steps":
|
||||||
stats = garth.NewDailySteps()
|
stats = garmin.NewDailySteps()
|
||||||
case "stress":
|
case "stress":
|
||||||
stats = garth.NewDailyStress()
|
stats = garmin.NewDailyStress()
|
||||||
case "hydration":
|
case "hydration":
|
||||||
stats = garth.NewDailyHydration()
|
stats = garmin.NewDailyHydration()
|
||||||
case "intensity":
|
case "intensity":
|
||||||
stats = garth.NewDailyIntensityMinutes()
|
stats = garmin.NewDailyIntensityMinutes()
|
||||||
case "sleep":
|
case "sleep":
|
||||||
stats = garth.NewDailySleep()
|
stats = garmin.NewDailySleep()
|
||||||
case "hrv":
|
case "hrv":
|
||||||
stats = garth.NewDailyHRV()
|
stats = garmin.NewDailyHRV()
|
||||||
default:
|
default:
|
||||||
log.Fatalf("Unknown stats type: %s", statsType)
|
log.Fatalf("Unknown stats type: %s", statsType)
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := stats.List(endDate, days, c)
|
result, err := stats.List(endDate, days, c.Client)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to get %s stats: %v", statsType, err)
|
log.Fatalf("Failed to get %s stats: %v", statsType, err)
|
||||||
}
|
}
|
||||||
@@ -182,7 +179,7 @@ func outputResult(data interface{}, outputFile string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayActivities(activities []garth.Activity) {
|
func displayActivities(activities []garmin.Activity) {
|
||||||
fmt.Printf("\n=== Recent Activities ===\n")
|
fmt.Printf("\n=== Recent Activities ===\n")
|
||||||
for i, activity := range activities {
|
for i, activity := range activities {
|
||||||
fmt.Printf("%d. %s\n", i+1, activity.ActivityName)
|
fmt.Printf("%d. %s\n", i+1, activity.ActivityName)
|
||||||
@@ -197,4 +194,4 @@ func displayActivities(activities []garth.Activity) {
|
|||||||
}
|
}
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
cmd/garth/root.go
Normal file
1
cmd/garth/root.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package main
|
||||||
1
cmd/garth/stats.go
Normal file
1
cmd/garth/stats.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package main
|
||||||
BIN
garmin-connect
BIN
garmin-connect
Binary file not shown.
@@ -1,42 +0,0 @@
|
|||||||
package garth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"garmin-connect/garth/client"
|
|
||||||
"garmin-connect/garth/data"
|
|
||||||
"garmin-connect/garth/errors"
|
|
||||||
"garmin-connect/garth/stats"
|
|
||||||
"garmin-connect/garth/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Re-export main types for convenience
|
|
||||||
type Client = client.Client
|
|
||||||
|
|
||||||
// Data types
|
|
||||||
type BodyBatteryData = data.DailyBodyBatteryStress
|
|
||||||
type HRVData = data.HRVData
|
|
||||||
type SleepData = data.DailySleepDTO
|
|
||||||
type WeightData = data.WeightData
|
|
||||||
|
|
||||||
// Stats types
|
|
||||||
type DailySteps = stats.DailySteps
|
|
||||||
type DailyStress = stats.DailyStress
|
|
||||||
type DailyHRV = stats.DailyHRV
|
|
||||||
type DailyHydration = stats.DailyHydration
|
|
||||||
type DailyIntensityMinutes = stats.DailyIntensityMinutes
|
|
||||||
type DailySleep = stats.DailySleep
|
|
||||||
|
|
||||||
// Activity type
|
|
||||||
type Activity = types.Activity
|
|
||||||
|
|
||||||
// Error types
|
|
||||||
type APIError = errors.APIError
|
|
||||||
type IOError = errors.IOError
|
|
||||||
type AuthError = errors.AuthenticationError
|
|
||||||
type OAuthError = errors.OAuthError
|
|
||||||
type ValidationError = errors.ValidationError
|
|
||||||
|
|
||||||
// Main functions
|
|
||||||
var (
|
|
||||||
NewClient = client.NewClient
|
|
||||||
Login = client.Login
|
|
||||||
)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package client_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"garmin-connect/garth/testutils"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
|
||||||
"garmin-connect/garth/errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestClient_Login_Success(t *testing.T) {
|
|
||||||
// Create mock SSO server
|
|
||||||
ssoServer := testutils.MockJSONResponse(http.StatusOK, `{
|
|
||||||
"access_token": "test_token",
|
|
||||||
"token_type": "Bearer",
|
|
||||||
"expires_in": 3600
|
|
||||||
}`)
|
|
||||||
defer ssoServer.Close()
|
|
||||||
|
|
||||||
// Create client with test configuration
|
|
||||||
c, err := client.NewClient("example.com")
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Set domain to just the host (without scheme)
|
|
||||||
u, _ := url.Parse(ssoServer.URL)
|
|
||||||
c.Domain = u.Host
|
|
||||||
|
|
||||||
// Perform login
|
|
||||||
err = c.Login("test@example.com", "password")
|
|
||||||
|
|
||||||
// Verify login
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, "Bearer test_token", c.AuthToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClient_Login_Failure(t *testing.T) {
|
|
||||||
// Create mock SSO server returning error
|
|
||||||
ssoServer := testutils.MockJSONResponse(http.StatusUnauthorized, `{
|
|
||||||
"error": "invalid_credentials"
|
|
||||||
}`)
|
|
||||||
defer ssoServer.Close()
|
|
||||||
|
|
||||||
// Create client with test configuration
|
|
||||||
c, err := client.NewClient("example.com")
|
|
||||||
require.NoError(t, err)
|
|
||||||
c.Domain = ssoServer.URL
|
|
||||||
|
|
||||||
// Perform login
|
|
||||||
err = c.Login("test@example.com", "wrongpassword")
|
|
||||||
|
|
||||||
// Verify error
|
|
||||||
require.Error(t, err)
|
|
||||||
assert.IsType(t, &errors.AuthenticationError{}, err)
|
|
||||||
assert.Contains(t, err.Error(), "SSO login failed")
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package garth
|
|
||||||
|
|
||||||
import (
|
|
||||||
"garmin-connect/garth/client"
|
|
||||||
"garmin-connect/garth/data"
|
|
||||||
"garmin-connect/garth/errors"
|
|
||||||
"garmin-connect/garth/stats"
|
|
||||||
"garmin-connect/garth/types"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client is the main Garmin Connect client type
|
|
||||||
type Client = client.Client
|
|
||||||
|
|
||||||
// OAuth1Token represents OAuth 1.0 token
|
|
||||||
type OAuth1Token = types.OAuth1Token
|
|
||||||
|
|
||||||
// OAuth2Token represents OAuth 2.0 token
|
|
||||||
type OAuth2Token = types.OAuth2Token
|
|
||||||
|
|
||||||
// Data types
|
|
||||||
type (
|
|
||||||
BodyBatteryData = data.DailyBodyBatteryStress
|
|
||||||
HRVData = data.HRVData
|
|
||||||
SleepData = data.DailySleepDTO
|
|
||||||
WeightData = data.WeightData
|
|
||||||
)
|
|
||||||
|
|
||||||
// Stats types
|
|
||||||
type (
|
|
||||||
Stats = stats.Stats
|
|
||||||
DailySteps = stats.DailySteps
|
|
||||||
DailyStress = stats.DailyStress
|
|
||||||
DailyHRV = stats.DailyHRV
|
|
||||||
DailyHydration = stats.DailyHydration
|
|
||||||
DailyIntensityMinutes = stats.DailyIntensityMinutes
|
|
||||||
DailySleep = stats.DailySleep
|
|
||||||
)
|
|
||||||
|
|
||||||
// Activity represents a Garmin activity
|
|
||||||
type Activity = types.Activity
|
|
||||||
|
|
||||||
// Error types
|
|
||||||
type (
|
|
||||||
APIError = errors.APIError
|
|
||||||
IOError = errors.IOError
|
|
||||||
AuthError = errors.AuthenticationError
|
|
||||||
OAuthError = errors.OAuthError
|
|
||||||
ValidationError = errors.ValidationError
|
|
||||||
)
|
|
||||||
|
|
||||||
// Main functions
|
|
||||||
var (
|
|
||||||
NewClient = client.NewClient
|
|
||||||
)
|
|
||||||
|
|
||||||
// Stats constructor functions
|
|
||||||
var (
|
|
||||||
NewDailySteps = stats.NewDailySteps
|
|
||||||
NewDailyStress = stats.NewDailyStress
|
|
||||||
NewDailyHydration = stats.NewDailyHydration
|
|
||||||
NewDailyIntensityMinutes = stats.NewDailyIntensityMinutes
|
|
||||||
NewDailySleep = stats.NewDailySleep
|
|
||||||
NewDailyHRV = stats.NewDailyHRV
|
|
||||||
)
|
|
||||||
37
internal/api/client/auth_test.go
Normal file
37
internal/api/client/auth_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package client_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"garmin-connect/internal/api/client"
|
||||||
|
"garmin-connect/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")
|
||||||
|
}
|
||||||
@@ -14,9 +14,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/errors"
|
"garmin-connect/internal/errors"
|
||||||
"garmin-connect/garth/sso"
|
"garmin-connect/internal/auth/sso"
|
||||||
"garmin-connect/garth/types"
|
"garmin-connect/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client represents the Garmin Connect API client
|
// Client represents the Garmin Connect API client
|
||||||
@@ -121,9 +121,31 @@ func (c *Client) Login(email, password string) error {
|
|||||||
return nil
|
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
|
// GetUserProfile retrieves the current user's full profile
|
||||||
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
||||||
profileURL := fmt.Sprintf("https://connectapi.%s/userprofile-service/socialProfile", c.Domain)
|
scheme := "https"
|
||||||
|
if strings.HasPrefix(c.Domain, "127.0.0.1") {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
profileURL := fmt.Sprintf("%s://%s/userprofile-service/socialProfile", scheme, c.Domain)
|
||||||
|
|
||||||
req, err := http.NewRequest("GET", profileURL, nil)
|
req, err := http.NewRequest("GET", profileURL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -181,9 +203,13 @@ func (c *Client) GetUserProfile() (*types.UserProfile, error) {
|
|||||||
|
|
||||||
// ConnectAPI makes a raw API request to the Garmin Connect API
|
// 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) {
|
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{
|
u := &url.URL{
|
||||||
Scheme: "https",
|
Scheme: scheme,
|
||||||
Host: fmt.Sprintf("connectapi.%s", c.Domain),
|
Host: c.Domain,
|
||||||
Path: path,
|
Path: path,
|
||||||
RawQuery: params.Encode(),
|
RawQuery: params.Encode(),
|
||||||
}
|
}
|
||||||
@@ -445,4 +471,4 @@ func (c *Client) LoadSession(filename string) error {
|
|||||||
c.AuthToken = session.AuthToken
|
c.AuthToken = session.AuthToken
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,16 @@ package client_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/testutils"
|
"garmin-connect/internal/testutils"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestClient_GetUserProfile(t *testing.T) {
|
func TestClient_GetUserProfile(t *testing.T) {
|
||||||
@@ -24,11 +25,11 @@ func TestClient_GetUserProfile(t *testing.T) {
|
|||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
// Create client with test configuration
|
// Create client with test configuration
|
||||||
c := &client.Client{
|
u, _ := url.Parse(server.URL)
|
||||||
Domain: server.URL,
|
c, err := client.NewClient(u.Host)
|
||||||
HTTPClient: &http.Client{Timeout: 5 * time.Second},
|
require.NoError(t, err)
|
||||||
AuthToken: "Bearer testtoken",
|
c.HTTPClient = &http.Client{Timeout: 5 * time.Second}
|
||||||
}
|
c.AuthToken = "Bearer testtoken"
|
||||||
|
|
||||||
// Get user profile
|
// Get user profile
|
||||||
profile, err := c.GetUserProfile()
|
profile, err := c.GetUserProfile()
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/types"
|
"garmin-connect/internal/types"
|
||||||
"garmin-connect/garth/utils"
|
"garmin-connect/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
// GetOAuth1Token retrieves an OAuth1 token using the provided ticket
|
||||||
@@ -9,8 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/oauth"
|
"garmin-connect/internal/auth/oauth"
|
||||||
"garmin-connect/garth/types"
|
"garmin-connect/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
"garmin-connect/garth/utils"
|
"garmin-connect/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Data defines the interface for Garmin Connect data types.
|
// Data defines the interface for Garmin Connect data types.
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DailyBodyBatteryStress represents complete daily Body Battery and stress data
|
// DailyBodyBatteryStress represents complete daily Body Battery and stress data
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
"garmin-connect/garth/utils"
|
"garmin-connect/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HRVSummary represents Heart Rate Variability summary data
|
// HRVSummary represents Heart Rate Variability summary data
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SleepScores represents sleep scoring data
|
// SleepScores represents sleep scoring data
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WeightData represents weight measurement data
|
// WeightData represents weight measurement data
|
||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
"garmin-connect/garth/utils"
|
"garmin-connect/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Stats interface {
|
type Stats interface {
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MockClient simulates API client for tests
|
// MockClient simulates API client for tests
|
||||||
28
internal/types/auth.go
Normal file
28
internal/types/auth.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// OAuthConsumer represents OAuth consumer credentials
|
||||||
|
type OAuthConsumer struct {
|
||||||
|
ConsumerKey string `json:"consumer_key"`
|
||||||
|
ConsumerSecret string `json:"consumer_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth1Token represents OAuth1 token response
|
||||||
|
type OAuth1Token struct {
|
||||||
|
OAuthToken string `json:"oauth_token"`
|
||||||
|
OAuthTokenSecret string `json:"oauth_token_secret"`
|
||||||
|
MFAToken string `json:"mfa_token,omitempty"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth2Token represents OAuth2 token response
|
||||||
|
type OAuth2Token struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
CreatedAt time.Time // Used for expiration tracking
|
||||||
|
ExpiresAt time.Time // Computed expiration time
|
||||||
|
}
|
||||||
@@ -1,40 +1,12 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||||
type GarminTime struct {
|
type GarminTime struct {
|
||||||
time.Time
|
time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnmarshalJSON handles Garmin's timestamp format
|
|
||||||
func (gt *GarminTime) UnmarshalJSON(b []byte) error {
|
|
||||||
var s string
|
|
||||||
if err := json.Unmarshal(b, &s); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t, err := time.Parse("2006-01-02T15:04:05", s)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
gt.Time = t
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client represents the Garmin Connect client
|
|
||||||
type Client struct {
|
|
||||||
Domain string
|
|
||||||
HTTPClient *http.Client
|
|
||||||
Username string
|
|
||||||
AuthToken string
|
|
||||||
OAuth1Token *OAuth1Token
|
|
||||||
OAuth2Token *OAuth2Token
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionData represents saved session information
|
// SessionData represents saved session information
|
||||||
type SessionData struct {
|
type SessionData struct {
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
@@ -77,14 +49,6 @@ type Activity struct {
|
|||||||
MaxHR float64 `json:"maxHR"`
|
MaxHR float64 `json:"maxHR"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth1Token represents OAuth1 token response
|
|
||||||
type OAuth1Token struct {
|
|
||||||
OAuthToken string `json:"oauth_token"`
|
|
||||||
OAuthTokenSecret string `json:"oauth_token_secret"`
|
|
||||||
MFAToken string `json:"mfa_token,omitempty"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserProfile represents a Garmin user profile
|
// UserProfile represents a Garmin user profile
|
||||||
type UserProfile struct {
|
type UserProfile struct {
|
||||||
UserName string `json:"userName"`
|
UserName string `json:"userName"`
|
||||||
@@ -92,20 +56,3 @@ type UserProfile struct {
|
|||||||
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
LevelUpdateDate GarminTime `json:"levelUpdateDate"`
|
||||||
// Add other fields as needed from API response
|
// Add other fields as needed from API response
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth2Token represents OAuth2 token response
|
|
||||||
type OAuth2Token struct {
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
TokenType string `json:"token_type"`
|
|
||||||
ExpiresIn int `json:"expires_in"`
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
CreatedAt time.Time // Used for expiration tracking
|
|
||||||
ExpiresAt time.Time // Computed expiration time
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAuthConsumer represents OAuth consumer credentials
|
|
||||||
type OAuthConsumer struct {
|
|
||||||
ConsumerKey string `json:"consumer_key"`
|
|
||||||
ConsumerSecret string `json:"consumer_secret"`
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ package users
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PowerFormat struct {
|
type PowerFormat struct {
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"garmin-connect/garth/types"
|
"garmin-connect/internal/types"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
6
main.go
6
main.go
@@ -5,9 +5,9 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
"garmin-connect/garth/credentials"
|
"garmin-connect/internal/auth/credentials"
|
||||||
"garmin-connect/garth/types"
|
types "garmin-connect/pkg/garmin"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
529
phase1.md
Normal file
529
phase1.md
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
# Phase 1: Core Functionality Implementation Plan
|
||||||
|
**Duration: 2-3 weeks**
|
||||||
|
**Goal: Establish solid foundation with enhanced CLI and core missing features**
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Phase 1 focuses on building the essential functionality that users need immediately while establishing the foundation for future enhancements. This phase prioritizes user-facing features and basic API improvements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subphase 1A: Package Reorganization & CLI Foundation (Days 1-3)
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Restructure packages for better maintainability
|
||||||
|
- Set up cobra-based CLI framework
|
||||||
|
- Establish consistent naming conventions
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 1A.1: Package Structure Refactoring
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```
|
||||||
|
Current Structure → New Structure
|
||||||
|
garth/ pkg/garmin/
|
||||||
|
├── client/ ├── client.go # Main client interface
|
||||||
|
├── data/ ├── activities.go # Activity operations
|
||||||
|
├── stats/ ├── health.go # Health data operations
|
||||||
|
├── sso/ ├── stats.go # Statistics operations
|
||||||
|
├── oauth/ ├── auth.go # Authentication
|
||||||
|
└── ... └── types.go # Public types
|
||||||
|
|
||||||
|
internal/
|
||||||
|
├── api/ # Low-level API client
|
||||||
|
├── auth/ # Auth implementation
|
||||||
|
├── data/ # Data processing
|
||||||
|
└── utils/ # Internal utilities
|
||||||
|
|
||||||
|
cmd/garth/
|
||||||
|
├── main.go # CLI entry point
|
||||||
|
├── root.go # Root command
|
||||||
|
├── auth.go # Auth commands
|
||||||
|
├── activities.go # Activity commands
|
||||||
|
├── health.go # Health commands
|
||||||
|
└── stats.go # Stats commands
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] New package structure implemented
|
||||||
|
- [ ] All imports updated
|
||||||
|
- [ ] No breaking changes to existing functionality
|
||||||
|
- [ ] Package documentation updated
|
||||||
|
|
||||||
|
#### 1A.2: CLI Framework Setup
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// cmd/garth/root.go
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "garth",
|
||||||
|
Short: "Garmin Connect CLI tool",
|
||||||
|
Long: `A comprehensive CLI tool for interacting with Garmin Connect`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global flags
|
||||||
|
var (
|
||||||
|
configFile string
|
||||||
|
outputFormat string // json, table, csv
|
||||||
|
verbose bool
|
||||||
|
dateFrom string
|
||||||
|
dateTo string
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Install and configure cobra
|
||||||
|
- [ ] Create root command with global flags
|
||||||
|
- [ ] Implement configuration file loading
|
||||||
|
- [ ] Add output formatting infrastructure
|
||||||
|
- [ ] Create help text and usage examples
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] Working CLI framework with `garth --help`
|
||||||
|
- [ ] Configuration file support
|
||||||
|
- [ ] Output formatting (JSON, table, CSV)
|
||||||
|
|
||||||
|
#### 1A.3: Configuration Management
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/config/config.go
|
||||||
|
type Config struct {
|
||||||
|
Auth struct {
|
||||||
|
Email string `yaml:"email"`
|
||||||
|
Domain string `yaml:"domain"`
|
||||||
|
Session string `yaml:"session_file"`
|
||||||
|
} `yaml:"auth"`
|
||||||
|
|
||||||
|
Output struct {
|
||||||
|
Format string `yaml:"format"`
|
||||||
|
File string `yaml:"file"`
|
||||||
|
} `yaml:"output"`
|
||||||
|
|
||||||
|
Cache struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
TTL string `yaml:"ttl"`
|
||||||
|
Dir string `yaml:"dir"`
|
||||||
|
} `yaml:"cache"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Design configuration schema
|
||||||
|
- [ ] Implement config file loading/saving
|
||||||
|
- [ ] Add environment variable support
|
||||||
|
- [ ] Create config validation
|
||||||
|
- [ ] Add config commands (`garth config init`, `garth config show`)
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] Configuration system working
|
||||||
|
- [ ] Default config file created
|
||||||
|
- [ ] Config commands implemented
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subphase 1B: Enhanced CLI Commands (Days 4-7)
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Implement all major CLI commands
|
||||||
|
- Add interactive features
|
||||||
|
- Ensure consistent user experience
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 1B.1: Authentication Commands
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Target CLI interface
|
||||||
|
garth auth login # Interactive login
|
||||||
|
garth auth login --email user@example.com --password-stdin
|
||||||
|
garth auth logout # Clear session
|
||||||
|
garth auth status # Show auth status
|
||||||
|
garth auth refresh # Refresh tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// cmd/garth/auth.go
|
||||||
|
var authCmd = &cobra.Command{
|
||||||
|
Use: "auth",
|
||||||
|
Short: "Authentication management",
|
||||||
|
}
|
||||||
|
|
||||||
|
var loginCmd = &cobra.Command{
|
||||||
|
Use: "login",
|
||||||
|
Short: "Login to Garmin Connect",
|
||||||
|
RunE: runLogin,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Implement `auth login` with interactive prompts
|
||||||
|
- [ ] Add `auth logout` functionality
|
||||||
|
- [ ] Create `auth status` command
|
||||||
|
- [ ] Implement secure password input
|
||||||
|
- [ ] Add MFA support (prepare for future)
|
||||||
|
- [ ] Session validation and refresh
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] All auth commands working
|
||||||
|
- [ ] Secure credential handling
|
||||||
|
- [ ] Session persistence working
|
||||||
|
|
||||||
|
#### 1B.2: Activity Commands
|
||||||
|
**Duration: 2 days**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Target CLI interface
|
||||||
|
garth activities list # Recent activities
|
||||||
|
garth activities list --limit 50 --type running
|
||||||
|
garth activities get 12345678 # Activity details
|
||||||
|
garth activities download 12345678 --format gpx
|
||||||
|
garth activities search --query "morning run"
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/garmin/activities.go
|
||||||
|
type ActivityOptions struct {
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
ActivityType string
|
||||||
|
DateFrom time.Time
|
||||||
|
DateTo time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActivityDetail struct {
|
||||||
|
BasicInfo Activity
|
||||||
|
Summary ActivitySummary
|
||||||
|
Laps []Lap
|
||||||
|
Metrics []Metric
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Enhanced activity listing with filters
|
||||||
|
- [ ] Activity detail fetching
|
||||||
|
- [ ] Search functionality
|
||||||
|
- [ ] Table formatting for activity lists
|
||||||
|
- [ ] Activity download preparation (basic structure)
|
||||||
|
- [ ] Date range filtering
|
||||||
|
- [ ] Activity type filtering
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] `activities list` with all filtering options
|
||||||
|
- [ ] `activities get` showing detailed info
|
||||||
|
- [ ] `activities search` functionality
|
||||||
|
- [ ] Proper error handling and user feedback
|
||||||
|
|
||||||
|
#### 1B.3: Health Data Commands
|
||||||
|
**Duration: 2 days**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Target CLI interface
|
||||||
|
garth health sleep --from 2024-01-01 --to 2024-01-07
|
||||||
|
garth health hrv --days 30
|
||||||
|
garth health stress --week
|
||||||
|
garth health bodybattery --yesterday
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Implement all health data commands
|
||||||
|
- [ ] Add date range parsing utilities
|
||||||
|
- [ ] Create consistent output formatting
|
||||||
|
- [ ] Add data aggregation options
|
||||||
|
- [ ] Implement caching for expensive operations
|
||||||
|
- [ ] Error handling for missing data
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] All health commands working
|
||||||
|
- [ ] Consistent date filtering across commands
|
||||||
|
- [ ] Proper data formatting and display
|
||||||
|
|
||||||
|
#### 1B.4: Statistics Commands
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Target CLI interface
|
||||||
|
garth stats steps --month
|
||||||
|
garth stats distance --year
|
||||||
|
garth stats calories --from 2024-01-01
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Implement statistics commands
|
||||||
|
- [ ] Add aggregation periods (day, week, month, year)
|
||||||
|
- [ ] Create summary statistics
|
||||||
|
- [ ] Add trend analysis
|
||||||
|
- [ ] Implement data export options
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] All stats commands working
|
||||||
|
- [ ] Multiple aggregation options
|
||||||
|
- [ ] Export functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subphase 1C: Activity Download Implementation (Days 8-12)
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Implement activity file downloading
|
||||||
|
- Support multiple formats (GPX, TCX, FIT)
|
||||||
|
- Add batch download capabilities
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 1C.1: Core Download Infrastructure
|
||||||
|
**Duration: 2 days**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/garmin/activities.go
|
||||||
|
type DownloadOptions struct {
|
||||||
|
Format string // "gpx", "tcx", "fit", "csv"
|
||||||
|
Original bool // Download original uploaded file
|
||||||
|
OutputDir string
|
||||||
|
Filename string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) DownloadActivity(id string, opts *DownloadOptions) error {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Research Garmin's download endpoints
|
||||||
|
- [ ] Implement format detection and conversion
|
||||||
|
- [ ] Add file writing with proper naming
|
||||||
|
- [ ] Implement progress indication
|
||||||
|
- [ ] Add download validation
|
||||||
|
- [ ] Error handling for failed downloads
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] Working download for at least GPX format
|
||||||
|
- [ ] Progress indication during download
|
||||||
|
- [ ] Proper error handling
|
||||||
|
|
||||||
|
#### 1C.2: Multi-Format Support
|
||||||
|
**Duration: 2 days**
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Implement TCX format download
|
||||||
|
- [ ] Implement FIT format download (if available)
|
||||||
|
- [ ] Add CSV export for activity summaries
|
||||||
|
- [ ] Format validation and conversion
|
||||||
|
- [ ] Add format-specific options
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] Support for GPX, TCX, and CSV formats
|
||||||
|
- [ ] Format auto-detection
|
||||||
|
- [ ] Format-specific download options
|
||||||
|
|
||||||
|
#### 1C.3: Batch Download Features
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Target functionality
|
||||||
|
garth activities download --all --type running --format gpx
|
||||||
|
garth activities download --from 2024-01-01 --to 2024-01-31
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Implement batch download with filtering
|
||||||
|
- [ ] Add parallel download support
|
||||||
|
- [ ] Progress bars for multiple downloads
|
||||||
|
- [ ] Resume interrupted downloads
|
||||||
|
- [ ] Duplicate detection and handling
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] Batch download working
|
||||||
|
- [ ] Parallel processing implemented
|
||||||
|
- [ ] Resume capability
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subphase 1D: Missing Health Data Types (Days 13-15)
|
||||||
|
|
||||||
|
### Objectives
|
||||||
|
- Implement VO2 max data fetching
|
||||||
|
- Add heart rate zones
|
||||||
|
- Complete missing health metrics
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### 1D.1: VO2 Max Implementation
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/garmin/health.go
|
||||||
|
type VO2MaxData struct {
|
||||||
|
Running *VO2MaxReading `json:"running"`
|
||||||
|
Cycling *VO2MaxReading `json:"cycling"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
|
History []VO2MaxHistory `json:"history"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VO2MaxReading struct {
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Research VO2 max API endpoints
|
||||||
|
- [ ] Implement data fetching
|
||||||
|
- [ ] Add historical data support
|
||||||
|
- [ ] Create CLI command
|
||||||
|
- [ ] Add data validation
|
||||||
|
- [ ] Format output appropriately
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] `garth health vo2max` command working
|
||||||
|
- [ ] Historical data support
|
||||||
|
- [ ] Both running and cycling metrics
|
||||||
|
|
||||||
|
#### 1D.2: Heart Rate Zones
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```go
|
||||||
|
type HeartRateZones struct {
|
||||||
|
RestingHR int `json:"resting_hr"`
|
||||||
|
MaxHR int `json:"max_hr"`
|
||||||
|
LactateThreshold int `json:"lactate_threshold"`
|
||||||
|
Zones []HRZone `json:"zones"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HRZone struct {
|
||||||
|
Zone int `json:"zone"`
|
||||||
|
MinBPM int `json:"min_bpm"`
|
||||||
|
MaxBPM int `json:"max_bpm"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Implement HR zones API calls
|
||||||
|
- [ ] Add zone calculation logic
|
||||||
|
- [ ] Create CLI command
|
||||||
|
- [ ] Add zone analysis features
|
||||||
|
- [ ] Implement zone updates (if possible)
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] `garth health hr-zones` command
|
||||||
|
- [ ] Zone calculation and display
|
||||||
|
- [ ] Integration with other health metrics
|
||||||
|
|
||||||
|
#### 1D.3: Additional Health Metrics
|
||||||
|
**Duration: 1 day**
|
||||||
|
|
||||||
|
```go
|
||||||
|
type WellnessData struct {
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
RestingHR *int `json:"resting_hr"`
|
||||||
|
Weight *float64 `json:"weight"`
|
||||||
|
BodyFat *float64 `json:"body_fat"`
|
||||||
|
BMI *float64 `json:"bmi"`
|
||||||
|
BodyWater *float64 `json:"body_water"`
|
||||||
|
BoneMass *float64 `json:"bone_mass"`
|
||||||
|
MuscleMass *float64 `json:"muscle_mass"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tasks:**
|
||||||
|
- [ ] Research additional wellness endpoints
|
||||||
|
- [ ] Implement body composition data
|
||||||
|
- [ ] Add resting heart rate trends
|
||||||
|
- [ ] Create comprehensive wellness command
|
||||||
|
- [ ] Add data correlation features
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- [ ] Additional health metrics available
|
||||||
|
- [ ] Wellness overview command
|
||||||
|
- [ ] Data trend analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 Testing & Quality Assurance (Days 14-15)
|
||||||
|
|
||||||
|
### Tasks
|
||||||
|
|
||||||
|
#### Integration Testing
|
||||||
|
- [ ] End-to-end CLI testing
|
||||||
|
- [ ] Authentication flow testing
|
||||||
|
- [ ] Data fetching validation
|
||||||
|
- [ ] Error handling verification
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
- [ ] Update README with new CLI commands
|
||||||
|
- [ ] Add usage examples
|
||||||
|
- [ ] Document configuration options
|
||||||
|
- [ ] Create troubleshooting guide
|
||||||
|
|
||||||
|
#### Performance Testing
|
||||||
|
- [ ] Concurrent operation testing
|
||||||
|
- [ ] Memory usage validation
|
||||||
|
- [ ] Download performance testing
|
||||||
|
- [ ] Large dataset handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 Deliverables Checklist
|
||||||
|
|
||||||
|
### CLI Tool
|
||||||
|
- [ ] Complete CLI with all major commands
|
||||||
|
- [ ] Configuration file support
|
||||||
|
- [ ] Multiple output formats (JSON, table, CSV)
|
||||||
|
- [ ] Interactive authentication
|
||||||
|
- [ ] Progress indicators for long operations
|
||||||
|
|
||||||
|
### Core Functionality
|
||||||
|
- [ ] Activity listing with filtering
|
||||||
|
- [ ] Activity detail fetching
|
||||||
|
- [ ] Activity downloading (GPX, TCX, CSV)
|
||||||
|
- [ ] All existing health data accessible via CLI
|
||||||
|
- [ ] VO2 max and heart rate zone data
|
||||||
|
|
||||||
|
### Code Quality
|
||||||
|
- [ ] Reorganized package structure
|
||||||
|
- [ ] Consistent error handling
|
||||||
|
- [ ] Comprehensive logging
|
||||||
|
- [ ] Basic test coverage (>60%)
|
||||||
|
- [ ] Documentation updated
|
||||||
|
|
||||||
|
### User Experience
|
||||||
|
- [ ] Intuitive command structure
|
||||||
|
- [ ] Helpful error messages
|
||||||
|
- [ ] Progress feedback
|
||||||
|
- [ ] Consistent data formatting
|
||||||
|
- [ ] Working examples and documentation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. **CLI Completeness**: All major Garmin data types accessible via CLI
|
||||||
|
2. **Usability**: New users can get started within 5 minutes
|
||||||
|
3. **Reliability**: Commands work consistently without errors
|
||||||
|
4. **Performance**: Downloads and data fetching perform well
|
||||||
|
5. **Documentation**: Clear examples and troubleshooting available
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|------------|
|
||||||
|
| API endpoint changes | High | Create abstraction layer, add endpoint validation |
|
||||||
|
| Authentication issues | High | Implement robust error handling and retry logic |
|
||||||
|
| Download format limitations | Medium | Start with GPX, add others incrementally |
|
||||||
|
| Performance with large datasets | Medium | Implement pagination and caching |
|
||||||
|
| Package reorganization complexity | Medium | Do incrementally with thorough testing |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Cobra CLI framework
|
||||||
|
- Garmin Connect API stability
|
||||||
|
- OAuth flow reliability
|
||||||
|
- File system permissions for downloads
|
||||||
|
- Network connectivity for API calls
|
||||||
|
|
||||||
|
This phase establishes the foundation for all subsequent development while delivering immediate value to users through a comprehensive CLI tool.
|
||||||
1
pkg/garmin/activities.go
Normal file
1
pkg/garmin/activities.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package garmin
|
||||||
1
pkg/garmin/auth.go
Normal file
1
pkg/garmin/auth.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package garmin
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
package garth_test
|
package garmin_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
"garmin-connect/garth/data"
|
"garmin-connect/internal/data"
|
||||||
"garmin-connect/garth/testutils"
|
"garmin-connect/internal/testutils"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
50
pkg/garmin/client.go
Normal file
50
pkg/garmin/client.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package garmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
internalClient "garmin-connect/internal/api/client"
|
||||||
|
"garmin-connect/internal/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is the main Garmin Connect client type
|
||||||
|
type Client struct {
|
||||||
|
Client *internalClient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates a new Garmin Connect client
|
||||||
|
func NewClient(domain string) (*Client, error) {
|
||||||
|
c, err := internalClient.NewClient(domain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Client{Client: c}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates to Garmin Connect
|
||||||
|
func (c *Client) Login(email, password string) error {
|
||||||
|
return c.Client.Login(email, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSession loads a session from a file
|
||||||
|
func (c *Client) LoadSession(filename string) error {
|
||||||
|
return c.Client.LoadSession(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveSession saves the current session to a file
|
||||||
|
func (c *Client) SaveSession(filename string) error {
|
||||||
|
return c.Client.SaveSession(filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActivities retrieves recent activities
|
||||||
|
func (c *Client) GetActivities(limit int) ([]Activity, error) {
|
||||||
|
return c.Client.GetActivities(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth1Token returns the OAuth1 token
|
||||||
|
func (c *Client) OAuth1Token() *types.OAuth1Token {
|
||||||
|
return c.Client.OAuth1Token
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth2Token returns the OAuth2 token
|
||||||
|
func (c *Client) OAuth2Token() *types.OAuth2Token {
|
||||||
|
return c.Client.OAuth2Token
|
||||||
|
}
|
||||||
@@ -43,4 +43,4 @@
|
|||||||
// - Steps List (7 days): 1216x faster
|
// - Steps List (7 days): 1216x faster
|
||||||
//
|
//
|
||||||
// See README.md for additional usage examples and CLI tool documentation.
|
// See README.md for additional usage examples and CLI tool documentation.
|
||||||
package garth
|
package garmin
|
||||||
58
pkg/garmin/health.go
Normal file
58
pkg/garmin/health.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package garmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"garmin-connect/internal/data"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BodyBatteryData represents Body Battery data.
|
||||||
|
type BodyBatteryData = data.DailyBodyBatteryStress
|
||||||
|
|
||||||
|
// SleepData represents sleep data.
|
||||||
|
type SleepData = data.DailySleepDTO
|
||||||
|
|
||||||
|
// HRVData represents HRV data.
|
||||||
|
type HRVData = data.HRVData
|
||||||
|
|
||||||
|
// WeightData represents weight data.
|
||||||
|
type WeightData = data.WeightData
|
||||||
|
|
||||||
|
// GetBodyBattery retrieves Body Battery data for a given date.
|
||||||
|
func (c *Client) GetBodyBattery(date time.Time) (*BodyBatteryData, error) {
|
||||||
|
bb := &data.DailyBodyBatteryStress{}
|
||||||
|
result, err := bb.Get(date, c.Client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.(*BodyBatteryData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSleep retrieves sleep data for a given date.
|
||||||
|
func (c *Client) GetSleep(date time.Time) (*SleepData, error) {
|
||||||
|
sleep := &data.DailySleepDTO{}
|
||||||
|
result, err := sleep.Get(date, c.Client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.(*SleepData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHRV retrieves HRV data for a given date.
|
||||||
|
func (c *Client) GetHRV(date time.Time) (*HRVData, error) {
|
||||||
|
hrv := &data.HRVData{}
|
||||||
|
result, err := hrv.Get(date, c.Client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.(*HRVData), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWeight retrieves weight data for a given date.
|
||||||
|
func (c *Client) GetWeight(date time.Time) (*WeightData, error) {
|
||||||
|
weight := &data.WeightData{}
|
||||||
|
result, err := weight.Get(date, c.Client)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.(*WeightData), nil
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package garth_test
|
package garmin_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"garmin-connect/garth/client"
|
"garmin-connect/internal/api/client"
|
||||||
"garmin-connect/garth/data"
|
"garmin-connect/internal/data"
|
||||||
"garmin-connect/garth/stats"
|
"garmin-connect/internal/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBodyBatteryIntegration(t *testing.T) {
|
func TestBodyBatteryIntegration(t *testing.T) {
|
||||||
38
pkg/garmin/stats.go
Normal file
38
pkg/garmin/stats.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package garmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"garmin-connect/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stats is an interface for stats data types.
|
||||||
|
type Stats = stats.Stats
|
||||||
|
|
||||||
|
// NewDailySteps creates a new DailySteps stats type.
|
||||||
|
func NewDailySteps() Stats {
|
||||||
|
return stats.NewDailySteps()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDailyStress creates a new DailyStress stats type.
|
||||||
|
func NewDailyStress() Stats {
|
||||||
|
return stats.NewDailyStress()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDailyHydration creates a new DailyHydration stats type.
|
||||||
|
func NewDailyHydration() Stats {
|
||||||
|
return stats.NewDailyHydration()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDailyIntensityMinutes creates a new DailyIntensityMinutes stats type.
|
||||||
|
func NewDailyIntensityMinutes() Stats {
|
||||||
|
return stats.NewDailyIntensityMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDailySleep creates a new DailySleep stats type.
|
||||||
|
func NewDailySleep() Stats {
|
||||||
|
return stats.NewDailySleep()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDailyHRV creates a new DailyHRV stats type.
|
||||||
|
func NewDailyHRV() Stats {
|
||||||
|
return stats.NewDailyHRV()
|
||||||
|
}
|
||||||
27
pkg/garmin/types.go
Normal file
27
pkg/garmin/types.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package garmin
|
||||||
|
|
||||||
|
import "garmin-connect/internal/types"
|
||||||
|
|
||||||
|
// GarminTime represents Garmin's timestamp format with custom JSON parsing
|
||||||
|
type GarminTime = types.GarminTime
|
||||||
|
|
||||||
|
// SessionData represents saved session information
|
||||||
|
type SessionData = types.SessionData
|
||||||
|
|
||||||
|
// ActivityType represents the type of activity
|
||||||
|
type ActivityType = types.ActivityType
|
||||||
|
|
||||||
|
// EventType represents the event type of an activity
|
||||||
|
type EventType = types.EventType
|
||||||
|
|
||||||
|
// Activity represents a Garmin Connect activity
|
||||||
|
type Activity = types.Activity
|
||||||
|
|
||||||
|
// UserProfile represents a Garmin user profile
|
||||||
|
type UserProfile = types.UserProfile
|
||||||
|
|
||||||
|
// OAuth1Token represents OAuth1 token response
|
||||||
|
type OAuth1Token = types.OAuth1Token
|
||||||
|
|
||||||
|
// OAuth2Token represents OAuth2 token response
|
||||||
|
type OAuth2Token = types.OAuth2Token
|
||||||
Reference in New Issue
Block a user