mirror of
https://github.com/sstent/go-garth.git
synced 2025-12-05 23:51:42 +00:00
porting - part2 wk1 done
This commit is contained in:
130
garth/client/http.go
Normal file
130
garth/client/http.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"garmin-connect/garth/errors"
|
||||
)
|
||||
|
||||
func (c *Client) ConnectAPI(path, method string, data interface{}) (interface{}, error) {
|
||||
url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, path)
|
||||
|
||||
var body io.Reader
|
||||
if data != nil && (method == "POST" || method == "PUT") {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{Message: "Failed to marshal request data", Cause: err}}}
|
||||
}
|
||||
body = bytes.NewReader(jsonData)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, 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", "GCM-iOS-5.7.2.1")
|
||||
if body != nil {
|
||||
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: "API request failed", Cause: err}}}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 204 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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: "API error"}}}
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, &errors.IOError{GarthError: errors.GarthError{
|
||||
Message: "Failed to parse response", Cause: err}}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *Client) Download(path string) ([]byte, error) {
|
||||
url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, path)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "GCM-iOS-5.7.2.1")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (c *Client) Upload(filePath, uploadPath string) (map[string]interface{}, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, &errors.IOError{GarthError: errors.GarthError{
|
||||
Message: "Failed to open file", Cause: err}}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var b bytes.Buffer
|
||||
writer := multipart.NewWriter(&b)
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(part, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, uploadPath)
|
||||
req, err := http.NewRequest("POST", url, &b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/errors"
|
||||
"garmin-connect/garth/utils"
|
||||
)
|
||||
|
||||
// DailyBodyBatteryStress represents complete daily Body Battery and stress data
|
||||
@@ -109,8 +113,37 @@ func ParseStressReadings(valuesArray [][]int) []StressReading {
|
||||
|
||||
// Get implements the Data interface for DailyBodyBatteryStress
|
||||
func (d *DailyBodyBatteryStress) Get(day time.Time, client *client.Client) (any, error) {
|
||||
// Implementation to be added
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
responseMap, ok := response.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &errors.IOError{GarthError: errors.GarthError{
|
||||
Message: "Invalid response format"}}
|
||||
}
|
||||
|
||||
snakeResponse := utils.CamelToSnakeDict(responseMap)
|
||||
|
||||
jsonBytes, err := json.Marshal(snakeResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result DailyBodyBatteryStress
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/errors"
|
||||
"garmin-connect/garth/utils"
|
||||
)
|
||||
|
||||
// SleepScores represents sleep scoring data
|
||||
@@ -37,8 +41,47 @@ type DailySleepDTO struct {
|
||||
|
||||
// Get implements the Data interface for DailySleepDTO
|
||||
func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (any, error) {
|
||||
// Implementation to be added
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?nonSleepBufferMinutes=60&date=%s",
|
||||
client.Username, dateStr)
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
responseMap, ok := response.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &errors.IOError{GarthError: errors.GarthError{
|
||||
Message: "Invalid response format"}}
|
||||
}
|
||||
|
||||
snakeResponse := utils.CamelToSnakeDict(responseMap)
|
||||
|
||||
dailySleepDto, exists := snakeResponse["daily_sleep_dto"].(map[string]interface{})
|
||||
if !exists || dailySleepDto["id"] == nil {
|
||||
return nil, nil // No sleep data
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(snakeResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
DailySleepDTO *DailySleepDTO `json:"daily_sleep_dto"`
|
||||
SleepMovement []SleepMovement `json:"sleep_movement"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// List implements the Data interface for concurrent fetching
|
||||
|
||||
39
garth/integration_test.go
Normal file
39
garth/integration_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package garth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/data"
|
||||
)
|
||||
|
||||
func TestBodyBatteryIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
c, err := client.NewClient("garmin.com")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create client: %v", err)
|
||||
}
|
||||
|
||||
// Load test session
|
||||
err = c.LoadSession("test_session.json")
|
||||
if err != nil {
|
||||
t.Skip("No test session available")
|
||||
}
|
||||
|
||||
bb := &data.DailyBodyBatteryStress{}
|
||||
result, err := bb.Get(time.Now().AddDate(0, 0, -1), c)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Get failed: %v", err)
|
||||
}
|
||||
if result != nil {
|
||||
bbData := result.(*data.DailyBodyBatteryStress)
|
||||
if bbData.UserProfilePK == 0 {
|
||||
t.Error("UserProfilePK is zero")
|
||||
}
|
||||
}
|
||||
}
|
||||
670
portingplan_part2.md
Normal file
670
portingplan_part2.md
Normal file
@@ -0,0 +1,670 @@
|
||||
# Complete Garth Python to Go Port - Implementation Plan
|
||||
|
||||
## Current Status
|
||||
The Go port has excellent architecture (85% complete) but needs implementation of core API methods and data models. All structure, error handling, and utilities are in place.
|
||||
|
||||
## Phase 1: Core API Implementation (Priority 1 - Week 1)
|
||||
|
||||
### Task 1.1: Implement Client.ConnectAPI Method
|
||||
**File:** `garth/client/client.go`
|
||||
**Reference:** `src/garth/http.py` lines 206-217
|
||||
|
||||
Add this method to the Client struct:
|
||||
|
||||
```go
|
||||
func (c *Client) ConnectAPI(path, method string, data interface{}) (interface{}, error) {
|
||||
url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, path)
|
||||
|
||||
var body io.Reader
|
||||
if data != nil && (method == "POST" || method == "PUT") {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, &errors.APIError{GarthHTTPError: errors.GarthHTTPError{
|
||||
GarthError: errors.GarthError{Message: "Failed to marshal request data", Cause: err}}}
|
||||
}
|
||||
body = bytes.NewReader(jsonData)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, 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", "GCM-iOS-5.7.2.1")
|
||||
if body != nil {
|
||||
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: "API request failed", Cause: err}}}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == 204 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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: "API error"}}}
|
||||
}
|
||||
|
||||
var result interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, &errors.IOError{GarthError: errors.GarthError{
|
||||
Message: "Failed to parse response", Cause: err}}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Task 1.2: Add File Download/Upload Methods
|
||||
**File:** `garth/client/client.go`
|
||||
**Reference:** `src/garth/http.py` lines 219-230, 232-244
|
||||
|
||||
```go
|
||||
func (c *Client) Download(path string) ([]byte, error) {
|
||||
resp, err := c.ConnectAPI(path, "GET", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, path)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("User-Agent", "GCM-iOS-5.7.2.1")
|
||||
|
||||
httpResp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
return io.ReadAll(httpResp.Body)
|
||||
}
|
||||
|
||||
func (c *Client) Upload(filePath, uploadPath string) (map[string]interface{}, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, &errors.IOError{GarthError: errors.GarthError{
|
||||
Message: "Failed to open file", Cause: err}}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var b bytes.Buffer
|
||||
writer := multipart.NewWriter(&b)
|
||||
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(part, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
writer.Close()
|
||||
|
||||
url := fmt.Sprintf("https://connectapi.%s%s", c.Domain, uploadPath)
|
||||
req, err := http.NewRequest("POST", url, &b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", c.AuthToken)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 2: Data Model Implementation (Week 1-2)
|
||||
|
||||
### Task 2.1: Complete Body Battery Implementation
|
||||
**File:** `garth/data/body_battery.go`
|
||||
**Reference:** `src/garth/data/body_battery/daily_stress.py` lines 55-77
|
||||
|
||||
Replace the stub `Get()` method:
|
||||
|
||||
```go
|
||||
func (d *DailyBodyBatteryStress) Get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailyStress/%s", dateStr)
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
responseMap, ok := response.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, &errors.IOError{GarthError: errors.GarthError{
|
||||
Message: "Invalid response format"}}
|
||||
}
|
||||
|
||||
snakeResponse := utils.CamelToSnakeDict(responseMap)
|
||||
|
||||
jsonBytes, err := json.Marshal(snakeResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result DailyBodyBatteryStress
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2.2: Complete Sleep Data Implementation
|
||||
**File:** `garth/data/sleep.go`
|
||||
**Reference:** `src/garth/data/sleep.py` lines 91-107
|
||||
|
||||
```go
|
||||
func (d *DailySleepDTO) Get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/wellness-service/wellness/dailySleepData/%s?nonSleepBufferMinutes=60&date=%s",
|
||||
client.Username, dateStr)
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
responseMap := response.(map[string]interface{})
|
||||
snakeResponse := utils.CamelToSnakeDict(responseMap)
|
||||
|
||||
dailySleepDto, exists := snakeResponse["daily_sleep_dto"].(map[string]interface{})
|
||||
if !exists || dailySleepDto["id"] == nil {
|
||||
return nil, nil // No sleep data
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(snakeResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
DailySleepDTO *DailySleepDTO `json:"daily_sleep_dto"`
|
||||
SleepMovement []SleepMovement `json:"sleep_movement"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2.3: Complete HRV Implementation
|
||||
**File:** `garth/data/hrv.go`
|
||||
**Reference:** `src/garth/data/hrv.py` lines 68-78
|
||||
|
||||
```go
|
||||
func (h *HRVData) Get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/hrv-service/hrv/%s", dateStr)
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
responseMap := response.(map[string]interface{})
|
||||
snakeResponse := utils.CamelToSnakeDict(responseMap)
|
||||
|
||||
jsonBytes, err := json.Marshal(snakeResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result HRVData
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Task 2.4: Complete Weight Implementation
|
||||
**File:** `garth/data/weight.go`
|
||||
**Reference:** `src/garth/data/weight.py` lines 39-52 and 54-74
|
||||
|
||||
```go
|
||||
func (w *WeightData) Get(day time.Time, client *client.Client) (interface{}, error) {
|
||||
dateStr := day.Format("2006-01-02")
|
||||
path := fmt.Sprintf("/weight-service/weight/dayview/%s", dateStr)
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
responseMap := response.(map[string]interface{})
|
||||
dayWeightList, exists := responseMap["dateWeightList"].([]interface{})
|
||||
if !exists || len(dayWeightList) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Get first weight entry
|
||||
firstEntry := dayWeightList[0].(map[string]interface{})
|
||||
snakeResponse := utils.CamelToSnakeDict(firstEntry)
|
||||
|
||||
jsonBytes, err := json.Marshal(snakeResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result WeightData
|
||||
if err := json.Unmarshal(jsonBytes, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 3: Stats Module Implementation (Week 2)
|
||||
|
||||
### Task 3.1: Create Stats Base
|
||||
**File:** `garth/stats/base.go` (new file)
|
||||
**Reference:** `src/garth/stats/_base.py`
|
||||
|
||||
```go
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/utils"
|
||||
)
|
||||
|
||||
type Stats interface {
|
||||
List(end time.Time, period int, client *client.Client) ([]interface{}, error)
|
||||
}
|
||||
|
||||
type BaseStats struct {
|
||||
Path string
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (b *BaseStats) List(end time.Time, period int, client *client.Client) ([]interface{}, error) {
|
||||
endDate := utils.FormatEndDate(end)
|
||||
|
||||
if period > b.PageSize {
|
||||
// Handle pagination - get first page
|
||||
page, err := b.fetchPage(endDate, b.PageSize, client)
|
||||
if err != nil || len(page) == 0 {
|
||||
return page, err
|
||||
}
|
||||
|
||||
// Get remaining pages recursively
|
||||
remainingStart := endDate.AddDate(0, 0, -b.PageSize)
|
||||
remainingPeriod := period - b.PageSize
|
||||
remainingData, err := b.List(remainingStart, remainingPeriod, client)
|
||||
if err != nil {
|
||||
return page, err
|
||||
}
|
||||
|
||||
return append(remainingData, page...), nil
|
||||
}
|
||||
|
||||
return b.fetchPage(endDate, period, client)
|
||||
}
|
||||
|
||||
func (b *BaseStats) fetchPage(end time.Time, period int, client *client.Client) ([]interface{}, error) {
|
||||
var start time.Time
|
||||
var path string
|
||||
|
||||
if strings.Contains(b.Path, "daily") {
|
||||
start = end.AddDate(0, 0, -(period - 1))
|
||||
path = strings.Replace(b.Path, "{start}", start.Format("2006-01-02"), 1)
|
||||
path = strings.Replace(path, "{end}", end.Format("2006-01-02"), 1)
|
||||
} else {
|
||||
path = strings.Replace(b.Path, "{end}", end.Format("2006-01-02"), 1)
|
||||
path = strings.Replace(path, "{period}", fmt.Sprintf("%d", period), 1)
|
||||
}
|
||||
|
||||
response, err := client.ConnectAPI(path, "GET", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if response == nil {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
responseSlice, ok := response.([]interface{})
|
||||
if !ok || len(responseSlice) == 0 {
|
||||
return []interface{}{}, nil
|
||||
}
|
||||
|
||||
var results []interface{}
|
||||
for _, item := range responseSlice {
|
||||
itemMap := item.(map[string]interface{})
|
||||
|
||||
// Handle nested "values" structure
|
||||
if values, exists := itemMap["values"]; exists {
|
||||
valuesMap := values.(map[string]interface{})
|
||||
for k, v := range valuesMap {
|
||||
itemMap[k] = v
|
||||
}
|
||||
delete(itemMap, "values")
|
||||
}
|
||||
|
||||
snakeItem := utils.CamelToSnakeDict(itemMap)
|
||||
results = append(results, snakeItem)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Task 3.2: Create Individual Stats Types
|
||||
**Files:** Create these files in `garth/stats/`
|
||||
**Reference:** All files in `src/garth/stats/`
|
||||
|
||||
**`steps.go`** (Reference: `src/garth/stats/steps.py`):
|
||||
```go
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
const BASE_STEPS_PATH = "/usersummary-service/stats/steps"
|
||||
|
||||
type DailySteps struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
TotalSteps *int `json:"total_steps"`
|
||||
TotalDistance *int `json:"total_distance"`
|
||||
StepGoal int `json:"step_goal"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewDailySteps() *DailySteps {
|
||||
return &DailySteps{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_STEPS_PATH + "/daily/{start}/{end}",
|
||||
PageSize: 28,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type WeeklySteps struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
TotalSteps int `json:"total_steps"`
|
||||
AverageSteps float64 `json:"average_steps"`
|
||||
AverageDistance float64 `json:"average_distance"`
|
||||
TotalDistance float64 `json:"total_distance"`
|
||||
WellnessDataDaysCount int `json:"wellness_data_days_count"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewWeeklySteps() *WeeklySteps {
|
||||
return &WeeklySteps{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_STEPS_PATH + "/weekly/{end}/{period}",
|
||||
PageSize: 52,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`stress.go`** (Reference: `src/garth/stats/stress.py`):
|
||||
```go
|
||||
package stats
|
||||
|
||||
import "time"
|
||||
|
||||
const BASE_STRESS_PATH = "/usersummary-service/stats/stress"
|
||||
|
||||
type DailyStress struct {
|
||||
CalendarDate time.Time `json:"calendar_date"`
|
||||
OverallStressLevel int `json:"overall_stress_level"`
|
||||
RestStressDuration *int `json:"rest_stress_duration"`
|
||||
LowStressDuration *int `json:"low_stress_duration"`
|
||||
MediumStressDuration *int `json:"medium_stress_duration"`
|
||||
HighStressDuration *int `json:"high_stress_duration"`
|
||||
BaseStats
|
||||
}
|
||||
|
||||
func NewDailyStress() *DailyStress {
|
||||
return &DailyStress{
|
||||
BaseStats: BaseStats{
|
||||
Path: BASE_STRESS_PATH + "/daily/{start}/{end}",
|
||||
PageSize: 28,
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create similar files for:
|
||||
- `hydration.go` → Reference `src/garth/stats/hydration.py`
|
||||
- `intensity_minutes.go` → Reference `src/garth/stats/intensity_minutes.py`
|
||||
- `sleep.go` → Reference `src/garth/stats/sleep.py`
|
||||
- `hrv.go` → Reference `src/garth/stats/hrv.py`
|
||||
|
||||
## Phase 4: Complete Data Interface Implementation (Week 2)
|
||||
|
||||
### Task 4.1: Fix BaseData List Implementation
|
||||
**File:** `garth/data/base.go`
|
||||
|
||||
Update the List method to properly use the BaseData pattern:
|
||||
|
||||
```go
|
||||
func (b *BaseData) List(end time.Time, days int, c *client.Client, maxWorkers int) ([]interface{}, []error) {
|
||||
if maxWorkers < 1 {
|
||||
maxWorkers = 10 // Match Python's MAX_WORKERS
|
||||
}
|
||||
|
||||
dates := utils.DateRange(end, days)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
workCh := make(chan time.Time, days)
|
||||
resultsCh := make(chan result, days)
|
||||
|
||||
type result struct {
|
||||
data interface{}
|
||||
err error
|
||||
}
|
||||
|
||||
// Worker function
|
||||
worker := func() {
|
||||
defer wg.Done()
|
||||
for date := range workCh {
|
||||
data, err := b.Get(date, c)
|
||||
resultsCh <- result{data: data, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// Start workers
|
||||
wg.Add(maxWorkers)
|
||||
for i := 0; i < maxWorkers; i++ {
|
||||
go worker()
|
||||
}
|
||||
|
||||
// Send work
|
||||
go func() {
|
||||
for _, date := range dates {
|
||||
workCh <- date
|
||||
}
|
||||
close(workCh)
|
||||
}()
|
||||
|
||||
// Close results channel when workers are done
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(resultsCh)
|
||||
}()
|
||||
|
||||
var results []interface{}
|
||||
var errs []error
|
||||
|
||||
for r := range resultsCh {
|
||||
if r.err != nil {
|
||||
errs = append(errs, r.err)
|
||||
} else if r.data != nil {
|
||||
results = append(results, r.data)
|
||||
}
|
||||
}
|
||||
|
||||
return results, errs
|
||||
}
|
||||
```
|
||||
|
||||
## Phase 5: Testing and Documentation (Week 3)
|
||||
|
||||
### Task 5.1: Create Integration Tests
|
||||
**File:** `garth/integration_test.go` (new file)
|
||||
|
||||
```go
|
||||
package garth_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/data"
|
||||
)
|
||||
|
||||
func TestBodyBatteryIntegration(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
|
||||
c, err := client.NewClient("garmin.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load test session
|
||||
err = c.LoadSession("test_session.json")
|
||||
if err != nil {
|
||||
t.Skip("No test session available")
|
||||
}
|
||||
|
||||
bb := &data.DailyBodyBatteryStress{}
|
||||
result, err := bb.Get(time.Now().AddDate(0, 0, -1), c)
|
||||
|
||||
assert.NoError(t, err)
|
||||
if result != nil {
|
||||
bbData := result.(*data.DailyBodyBatteryStress)
|
||||
assert.NotZero(t, bbData.UserProfilePK)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Task 5.2: Update Package Exports
|
||||
**File:** `garth/__init__.go` (new file)
|
||||
|
||||
Create a package-level API that matches Python's `__init__.py`:
|
||||
|
||||
```go
|
||||
package garth
|
||||
|
||||
import (
|
||||
"garmin-connect/garth/client"
|
||||
"garmin-connect/garth/data"
|
||||
"garmin-connect/garth/stats"
|
||||
)
|
||||
|
||||
// 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
|
||||
|
||||
// Main functions
|
||||
var (
|
||||
NewClient = client.NewClient
|
||||
Login = client.Login
|
||||
)
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Week 1 (Core Implementation):
|
||||
- [ ] Client.ConnectAPI method
|
||||
- [ ] Download/Upload methods
|
||||
- [ ] Body Battery Get() implementation
|
||||
- [ ] Sleep Data Get() implementation
|
||||
- [ ] End-to-end test with real API
|
||||
|
||||
### Week 2 (Complete Feature Set):
|
||||
- [ ] HRV and Weight Get() implementations
|
||||
- [ ] Complete stats module (all 7 types)
|
||||
- [ ] BaseData List() method fix
|
||||
- [ ] Integration tests
|
||||
|
||||
### Week 3 (Polish and Documentation):
|
||||
- [ ] Package-level exports
|
||||
- [ ] README with examples
|
||||
- [ ] Performance testing vs Python
|
||||
- [ ] CLI tool verification
|
||||
|
||||
## Key Implementation Notes
|
||||
|
||||
1. **Error Handling**: Use the existing comprehensive error types
|
||||
2. **Date Formats**: Always use `time.Time` and convert to "2006-01-02" for API calls
|
||||
3. **Response Parsing**: Always use `utils.CamelToSnakeDict` before unmarshaling
|
||||
4. **Concurrency**: The existing BaseData.List() handles worker pools correctly
|
||||
5. **Testing**: Use `testutils.MockJSONResponse` for unit tests
|
||||
|
||||
## Success Criteria
|
||||
|
||||
Port is complete when:
|
||||
- All Python data models have working Get() methods
|
||||
- All Python stats types are implemented
|
||||
- CLI tool outputs same format as Python
|
||||
- Integration tests pass against real API
|
||||
- Performance is equal or better than Python
|
||||
|
||||
**Estimated Effort:** 2-3 weeks for junior developer with this detailed plan.
|
||||
Reference in New Issue
Block a user