This commit is contained in:
2025-09-21 11:03:52 -07:00
parent 667790030e
commit e04cd5160e
138 changed files with 17338 additions and 0 deletions

416
cmd/garth/activities.go Normal file
View File

@@ -0,0 +1,416 @@
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"github.com/rodaine/table"
"github.com/schollz/progressbar/v3"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go-garth/pkg/garmin"
)
var (
activitiesCmd = &cobra.Command{
Use: "activities",
Short: "Manage Garmin Connect activities",
Long: `Provides commands to list, get details, search, and download Garmin Connect activities.`,
}
listActivitiesCmd = &cobra.Command{
Use: "list",
Short: "List recent activities",
Long: `List recent Garmin Connect activities with optional filters.`,
RunE: runListActivities,
}
getActivitiesCmd = &cobra.Command{
Use: "get [activityID]",
Short: "Get activity details",
Long: `Get detailed information for a specific Garmin Connect activity.`,
Args: cobra.ExactArgs(1),
RunE: runGetActivity,
}
downloadActivitiesCmd = &cobra.Command{
Use: "download [activityID]",
Short: "Download activity data",
Args: cobra.RangeArgs(0, 1), RunE: runDownloadActivity,
}
searchActivitiesCmd = &cobra.Command{
Use: "search",
Short: "Search activities",
Long: `Search Garmin Connect activities by a query string.`,
RunE: runSearchActivities,
}
// Flags for listActivitiesCmd
activityLimit int
activityOffset int
activityType string
activityDateFrom string
activityDateTo string
// Flags for downloadActivitiesCmd
downloadFormat string
outputDir string
downloadOriginal bool
downloadAll bool
)
func init() {
rootCmd.AddCommand(activitiesCmd)
activitiesCmd.AddCommand(listActivitiesCmd)
listActivitiesCmd.Flags().IntVar(&activityLimit, "limit", 20, "Maximum number of activities to retrieve")
listActivitiesCmd.Flags().IntVar(&activityOffset, "offset", 0, "Offset for activities list")
listActivitiesCmd.Flags().StringVar(&activityType, "type", "", "Filter activities by type (e.g., running, cycling)")
listActivitiesCmd.Flags().StringVar(&activityDateFrom, "from", "", "Start date for filtering activities (YYYY-MM-DD)")
listActivitiesCmd.Flags().StringVar(&activityDateTo, "to", "", "End date for filtering activities (YYYY-MM-DD)")
activitiesCmd.AddCommand(getActivitiesCmd)
activitiesCmd.AddCommand(downloadActivitiesCmd)
downloadActivitiesCmd.Flags().StringVar(&downloadFormat, "format", "gpx", "Download format (gpx, tcx, fit, csv)")
downloadActivitiesCmd.Flags().StringVar(&outputDir, "output-dir", ".", "Output directory for downloaded files")
downloadActivitiesCmd.Flags().BoolVar(&downloadOriginal, "original", false, "Download original uploaded file")
downloadActivitiesCmd.Flags().BoolVar(&downloadAll, "all", false, "Download all activities matching filters")
downloadActivitiesCmd.Flags().StringVar(&activityType, "type", "", "Filter activities by type (e.g., running, cycling)")
downloadActivitiesCmd.Flags().StringVar(&activityDateFrom, "from", "", "Start date for filtering activities (YYYY-MM-DD)")
downloadActivitiesCmd.Flags().StringVar(&activityDateTo, "to", "", "End date for filtering activities (YYYY-MM-DD)")
activitiesCmd.AddCommand(searchActivitiesCmd)
searchActivitiesCmd.Flags().StringP("query", "q", "", "Query string to search for activities")
}
func runListActivities(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if err := garminClient.LoadSession(sessionFile); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
opts := garmin.ActivityOptions{
Limit: activityLimit,
Offset: activityOffset,
ActivityType: activityType,
}
if activityDateFrom != "" {
opts.DateFrom, err = time.Parse("2006-01-02", activityDateFrom)
if err != nil {
return fmt.Errorf("invalid date format for --from: %w", err)
}
}
if activityDateTo != "" {
opts.DateTo, err = time.Parse("2006-01-02", activityDateTo)
if err != nil {
return fmt.Errorf("invalid date format for --to: %w", err)
}
}
activities, err := garminClient.ListActivities(opts)
if err != nil {
return fmt.Errorf("failed to list activities: %w", err)
}
if len(activities) == 0 {
fmt.Println("No activities found.")
return nil
}
outputFormat := viper.GetString("output")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(activities, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal activities to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"ActivityID", "ActivityName", "ActivityType", "StartTime", "Distance(km)", "Duration(s)"})
for _, activity := range activities {
writer.Write([]string{
fmt.Sprintf("%d", activity.ActivityID),
activity.ActivityName,
activity.ActivityType.TypeKey,
activity.StartTimeLocal.Format("2006-01-02 15:04:05"),
fmt.Sprintf("%.2f", activity.Distance/1000),
fmt.Sprintf("%.0f", activity.Duration),
})
}
case "table":
tbl := table.New("ID", "Name", "Type", "Date", "Distance (km)", "Duration (s)")
for _, activity := range activities {
tbl.AddRow(
fmt.Sprintf("%d", activity.ActivityID),
activity.ActivityName,
activity.ActivityType.TypeKey,
activity.StartTimeLocal.Format("2006-01-02 15:04:05"),
fmt.Sprintf("%.2f", activity.Distance/1000),
fmt.Sprintf("%.0f", activity.Duration),
)
}
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runGetActivity(cmd *cobra.Command, args []string) error {
activityIDStr := args[0]
activityID, err := strconv.Atoi(activityIDStr)
if err != nil {
return fmt.Errorf("invalid activity ID: %w", err)
}
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if err := garminClient.LoadSession(sessionFile); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
activityDetail, err := garminClient.GetActivity(activityID)
if err != nil {
return fmt.Errorf("failed to get activity details: %w", err)
}
fmt.Printf("Activity Details (ID: %d):\n", activityDetail.ActivityID)
fmt.Printf(" Name: %s\n", activityDetail.ActivityName)
fmt.Printf(" Type: %s\n", activityDetail.ActivityType.TypeKey)
fmt.Printf(" Date: %s\n", activityDetail.StartTimeLocal.Format("2006-01-02 15:04:05"))
fmt.Printf(" Distance: %.2f km\n", activityDetail.Distance/1000)
fmt.Printf(" Duration: %.0f s\n", activityDetail.Duration)
fmt.Printf(" Description: %s\n", activityDetail.Description)
return nil
}
func runDownloadActivity(cmd *cobra.Command, args []string) error {
var wg sync.WaitGroup
const concurrencyLimit = 5 // Limit concurrent downloads
sem := make(chan struct{}, concurrencyLimit)
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if err := garminClient.LoadSession(sessionFile); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
var activitiesToDownload []garmin.Activity
if downloadAll || len(args) == 0 {
opts := garmin.ActivityOptions{
ActivityType: activityType,
}
if activityDateFrom != "" {
opts.DateFrom, err = time.Parse("2006-01-02", activityDateFrom)
if err != nil {
return fmt.Errorf("invalid date format for --from: %w", err)
}
}
if activityDateTo != "" {
opts.DateTo, err = time.Parse("2006-01-02", activityDateTo)
if err != nil {
return fmt.Errorf("invalid date format for --to: %w", err)
}
}
activitiesToDownload, err = garminClient.ListActivities(opts)
if err != nil {
return fmt.Errorf("failed to list activities for batch download: %w", err)
}
if len(activitiesToDownload) == 0 {
fmt.Println("No activities found matching the filters for download.")
return nil
}
} else if len(args) == 1 {
activityIDStr := args[0]
activityID, err := strconv.Atoi(activityIDStr)
if err != nil {
return fmt.Errorf("invalid activity ID: %w", err)
}
// For single download, we need to fetch the activity details to get its name and type
activityDetail, err := garminClient.GetActivity(activityID)
if err != nil {
return fmt.Errorf("failed to get activity details for download: %w", err)
}
activitiesToDownload = []garmin.Activity{activityDetail.Activity}
} else {
return fmt.Errorf("invalid arguments: specify an activity ID or use --all with filters")
}
fmt.Printf("Starting download of %d activities...\n", len(activitiesToDownload))
bar := progressbar.NewOptions(len(activitiesToDownload),
progressbar.OptionEnableColorCodes(true),
progressbar.OptionShowBytes(false),
progressbar.OptionSetWidth(15),
progressbar.OptionSetDescription("Downloading activities..."),
progressbar.OptionSetTheme(progressbar.Theme{
Saucer: "[green]=[reset]",
SaucerPadding: " ",
BarStart: "[ ",
BarEnd: " ]",
}),
)
for _, activity := range activitiesToDownload {
wg.Add(1)
sem <- struct{}{}
go func(activity garmin.Activity) {
defer wg.Done()
defer func() { <-sem }()
if downloadFormat == "csv" {
activityDetail, err := garminClient.GetActivity(int(activity.ActivityID))
if err != nil {
fmt.Printf("Warning: Failed to get activity details for CSV export for activity %d: %v\n", activity.ActivityID, err)
bar.Add(1)
return
}
filename := fmt.Sprintf("%d.csv", activity.ActivityID)
outputPath := filename
if outputDir != "" {
outputPath = filepath.Join(outputDir, filename)
}
file, err := os.Create(outputPath)
if err != nil {
fmt.Printf("Warning: Failed to create CSV file for activity %d: %v\n", activity.ActivityID, err)
bar.Add(1)
return
}
defer file.Close()
writer := csv.NewWriter(file)
defer writer.Flush()
// Write header
writer.Write([]string{"ActivityID", "ActivityName", "ActivityType", "StartTime", "Distance(km)", "Duration(s)", "Description"})
// Write data
writer.Write([]string{
fmt.Sprintf("%d", activityDetail.ActivityID),
activityDetail.ActivityName,
activityDetail.ActivityType.TypeKey,
activityDetail.StartTimeLocal.Format("2006-01-02 15:04:05"),
fmt.Sprintf("%.2f", activityDetail.Distance/1000),
fmt.Sprintf("%.0f", activityDetail.Duration),
activityDetail.Description,
})
fmt.Printf("Activity %d summary exported to %s\n", activity.ActivityID, outputPath)
} else {
filename := fmt.Sprintf("%d.%s", activity.ActivityID, downloadFormat)
if downloadOriginal {
filename = fmt.Sprintf("%d_original.fit", activity.ActivityID) // Assuming original is .fit
}
outputPath := filepath.Join(outputDir, filename)
// Check if file already exists
if _, err := os.Stat(outputPath); err == nil {
fmt.Printf("Skipping activity %d: file already exists at %s\n", activity.ActivityID, outputPath)
bar.Add(1)
return
} else if !os.IsNotExist(err) {
fmt.Printf("Warning: Failed to check existence of file %s for activity %d: %v\n", outputPath, activity.ActivityID, err)
bar.Add(1)
return
}
opts := garmin.DownloadOptions{
Format: downloadFormat,
OutputDir: outputDir,
Original: downloadOriginal,
Filename: filename, // Pass filename to opts
}
fmt.Printf("Downloading activity %d in %s format to %s...\n", activity.ActivityID, downloadFormat, outputPath)
if err := garminClient.DownloadActivity(int(activity.ActivityID), opts); err != nil {
fmt.Printf("Warning: Failed to download activity %d: %v\n", activity.ActivityID, err)
bar.Add(1)
return
}
fmt.Printf("Activity %d downloaded successfully.\n", activity.ActivityID)
}
bar.Add(1)
}(activity)
}
wg.Wait()
bar.Finish()
fmt.Println("All downloads finished.")
return nil
}
func runSearchActivities(cmd *cobra.Command, args []string) error {
query, err := cmd.Flags().GetString("query")
if err != nil || query == "" {
return fmt.Errorf("search query cannot be empty")
}
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if err := garminClient.LoadSession(sessionFile); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
activities, err := garminClient.SearchActivities(query)
if err != nil {
return fmt.Errorf("failed to search activities: %w", err)
}
if len(activities) == 0 {
fmt.Printf("No activities found for query '%s'.\n", query)
return nil
}
fmt.Printf("Activities matching '%s':\n", query)
for _, activity := range activities {
fmt.Printf("- ID: %d, Name: %s, Type: %s, Date: %s\n",
activity.ActivityID, activity.ActivityName, activity.ActivityType.TypeKey,
activity.StartTimeLocal.Format("2006-01-02"))
}
return nil
}

183
cmd/garth/auth.go Normal file
View File

@@ -0,0 +1,183 @@
package main
import (
"fmt"
"os"
"golang.org/x/term"
"go-garth/pkg/garmin"
"github.com/spf13/cobra"
)
var (
authCmd = &cobra.Command{
Use: "auth",
Short: "Authentication management",
Long: `Manage authentication with Garmin Connect, including login, logout, and status.`,
}
loginCmd = &cobra.Command{
Use: "login",
Short: "Login to Garmin Connect",
Long: `Login to Garmin Connect interactively or using provided credentials.`,
RunE: runLogin,
}
logoutCmd = &cobra.Command{
Use: "logout",
Short: "Logout from Garmin Connect",
Long: `Clear the current Garmin Connect session.`,
RunE: runLogout,
}
statusCmd = &cobra.Command{
Use: "status",
Short: "Show Garmin Connect authentication status",
Long: `Display the current authentication status and session information.`,
RunE: runStatus,
}
refreshCmd = &cobra.Command{
Use: "refresh",
Short: "Refresh Garmin Connect session tokens",
Long: `Refresh the authentication tokens for the current Garmin Connect session.`,
RunE: runRefresh,
}
loginEmail string
loginPassword string
passwordStdinFlag bool
)
func init() {
rootCmd.AddCommand(authCmd)
authCmd.AddCommand(loginCmd)
loginCmd.Flags().StringVarP(&loginEmail, "email", "e", "", "Email for Garmin Connect login")
loginCmd.Flags().BoolVarP(&passwordStdinFlag, "password-stdin", "p", false, "Read password from stdin")
authCmd.AddCommand(logoutCmd)
authCmd.AddCommand(statusCmd)
authCmd.AddCommand(refreshCmd)
}
func runLogin(cmd *cobra.Command, args []string) error {
var email, password string
var err error
if loginEmail != "" {
email = loginEmail
} else {
fmt.Print("Enter Garmin Connect email: ")
_, err = fmt.Scanln(&email)
if err != nil {
return fmt.Errorf("failed to read email: %w", err)
}
}
if passwordStdinFlag {
fmt.Print("Enter password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("failed to read password from stdin: %w", err)
}
password = string(passwordBytes)
fmt.Println() // Newline after password input
} else {
fmt.Print("Enter password: ")
passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
password = string(passwordBytes)
fmt.Println() // Newline after password input
}
// Create client
// TODO: Domain should be configurable
garminClient, err := garmin.NewClient("www.garmin.com")
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if err := garminClient.LoadSession(sessionFile); err != nil {
fmt.Println("No existing session found or session invalid, logging in with credentials...")
if err := garminClient.Login(email, password); err != nil {
return fmt.Errorf("login failed: %w", err)
}
// Save session for future use
if err := garminClient.SaveSession(sessionFile); err != nil {
fmt.Printf("Failed to save session: %v\n", err)
}
} else {
fmt.Println("Loaded existing session")
}
fmt.Println("Login successful!")
return nil
}
func runLogout(cmd *cobra.Command, args []string) error {
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if _, err := os.Stat(sessionFile); os.IsNotExist(err) {
fmt.Println("No active session to log out from.")
return nil
}
if err := os.Remove(sessionFile); err != nil {
return fmt.Errorf("failed to remove session file: %w", err)
}
fmt.Println("Logged out successfully. Session cleared.")
return nil
}
func runStatus(cmd *cobra.Command, args []string) error {
sessionFile := "garmin_session.json" // TODO: Make session file configurable
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(sessionFile); err != nil {
fmt.Println("Not logged in or session expired.")
return nil
}
fmt.Println("Logged in. Session is active.")
// TODO: Add more detailed status information, e.g., session expiry
return nil
}
func runRefresh(cmd *cobra.Command, args []string) error {
sessionFile := "garmin_session.json" // TODO: Make session file configurable
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(sessionFile); err != nil {
return fmt.Errorf("cannot refresh: no active session found: %w", err)
}
fmt.Println("Attempting to refresh session...")
if err := garminClient.RefreshSession(); err != nil {
return fmt.Errorf("failed to refresh session: %w", err)
}
if err := garminClient.SaveSession(sessionFile); err != nil {
fmt.Printf("Failed to save refreshed session: %v\n", err)
}
fmt.Println("Session refreshed successfully.")
return nil
}

View File

@@ -0,0 +1,67 @@
package cmd
import (
"fmt"
"log"
"time"
"go-garth/internal/auth/credentials"
"go-garth/pkg/garmin"
"github.com/spf13/cobra"
)
var activitiesCmd = &cobra.Command{
Use: "activities",
Short: "Display recent Garmin Connect activities",
Long: `Fetches and displays a list of recent activities from Garmin Connect.`,
Run: func(cmd *cobra.Command, args []string) {
// Load credentials from .env file
_, _, domain, err := credentials.LoadEnvCredentials()
if err != nil {
log.Fatalf("Failed to load credentials: %v", err)
}
// Create client
garminClient, err := garmin.NewClient(domain)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json"
if err := garminClient.LoadSession(sessionFile); err != nil {
log.Fatalf("No existing session found. Please run 'garth login' first.")
}
opts := garmin.ActivityOptions{
Limit: 5,
}
activities, err := garminClient.ListActivities(opts)
if err != nil {
log.Fatalf("Failed to get activities: %v", err)
}
displayActivities(activities)
},
}
func init() {
rootCmd.AddCommand(activitiesCmd)
}
func displayActivities(activities []garmin.Activity) {
fmt.Printf("\n=== Recent Activities ===\n")
for i, activity := range activities {
fmt.Printf("%d. %s\n", i+1, activity.ActivityName)
fmt.Printf(" Type: %s\n", activity.ActivityType.TypeKey)
fmt.Printf(" Date: %s\n", activity.StartTimeLocal)
if activity.Distance > 0 {
fmt.Printf(" Distance: %.2f km\n", activity.Distance/1000)
}
if activity.Duration > 0 {
duration := time.Duration(activity.Duration) * time.Second
fmt.Printf(" Duration: %v\n", duration.Round(time.Second))
}
fmt.Println()
}
}

102
cmd/garth/cmd/data.go Normal file
View File

@@ -0,0 +1,102 @@
package cmd
import (
"encoding/json"
"fmt"
"log"
"os"
"time"
"go-garth/internal/auth/credentials"
"go-garth/pkg/garmin"
"github.com/spf13/cobra"
)
var (
dataDateStr string
dataDays int
dataOutputFile string
)
var dataCmd = &cobra.Command{
Use: "data [type]",
Short: "Fetch various data types from Garmin Connect",
Long: `Fetch data such as bodybattery, sleep, HRV, and weight from Garmin Connect.`,
Args: cobra.ExactArgs(1), // Expects one argument: the data type
Run: func(cmd *cobra.Command, args []string) {
dataType := args[0]
// Load credentials from .env file
_, _, domain, err := credentials.LoadEnvCredentials()
if err != nil {
log.Fatalf("Failed to load credentials: %v", err)
}
// Create client
garminClient, err := garmin.NewClient(domain)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json"
if err := garminClient.LoadSession(sessionFile); err != nil {
log.Fatalf("No existing session found. Please run 'garth login' first.")
}
endDate := time.Now().AddDate(0, 0, -1) // default to yesterday
if dataDateStr != "" {
parsedDate, err := time.Parse("2006-01-02", dataDateStr)
if err != nil {
log.Fatalf("Invalid date format: %v", err)
}
endDate = parsedDate
}
var result interface{}
switch dataType {
case "bodybattery":
result, err = garminClient.GetBodyBatteryData(endDate)
case "sleep":
result, err = garminClient.GetSleepData(endDate)
case "hrv":
result, err = garminClient.GetHrvData(endDate)
// case "weight":
// result, err = garminClient.GetWeight(endDate)
default:
log.Fatalf("Unknown data type: %s", dataType)
}
if err != nil {
log.Fatalf("Failed to get %s data: %v", dataType, err)
}
outputResult(result, dataOutputFile)
},
}
func init() {
rootCmd.AddCommand(dataCmd)
dataCmd.Flags().StringVar(&dataDateStr, "date", "", "Date in YYYY-MM-DD format (default: yesterday)")
dataCmd.Flags().StringVar(&dataOutputFile, "output", "", "Output file for JSON results")
// dataCmd.Flags().IntVar(&dataDays, "days", 1, "Number of days to fetch") // Not used for single day data types
}
func outputResult(data interface{}, outputFile string) {
jsonBytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
log.Fatalf("Failed to marshal result: %v", err)
}
if outputFile != "" {
if err := os.WriteFile(outputFile, jsonBytes, 0644); err != nil {
log.Fatalf("Failed to write output file: %v", err)
}
fmt.Printf("Results saved to %s\n", outputFile)
} else {
fmt.Println(string(jsonBytes))
}
}

38
cmd/garth/cmd/root.go Normal file
View File

@@ -0,0 +1,38 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "garth",
Short: "garth is a CLI for interacting with Garmin Connect",
Long: `A command-line interface for Garmin Connect that allows you to
interact with your health and fitness data.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
func init() {
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here, will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.garth.yaml)")
// Cobra also supports local flags, which will only run when this action is called directly.
// rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

87
cmd/garth/cmd/stats.go Normal file
View File

@@ -0,0 +1,87 @@
package cmd
import (
"log"
"time"
"go-garth/internal/auth/credentials"
"go-garth/pkg/garmin"
"github.com/spf13/cobra"
)
var (
statsDateStr string
statsDays int
statsOutputFile string
)
var statsCmd = &cobra.Command{
Use: "stats [type]",
Short: "Fetch various stats types from Garmin Connect",
Long: `Fetch stats such as steps, stress, hydration, intensity, sleep, and HRV from Garmin Connect.`,
Args: cobra.ExactArgs(1), // Expects one argument: the stats type
Run: func(cmd *cobra.Command, args []string) {
statsType := args[0]
// Load credentials from .env file
_, _, domain, err := credentials.LoadEnvCredentials()
if err != nil {
log.Fatalf("Failed to load credentials: %v", err)
}
// Create client
garminClient, err := garmin.NewClient(domain)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json"
if err := garminClient.LoadSession(sessionFile); err != nil {
log.Fatalf("No existing session found. Please run 'garth login' first.")
}
endDate := time.Now().AddDate(0, 0, -1) // default to yesterday
if statsDateStr != "" {
parsedDate, err := time.Parse("2006-01-02", statsDateStr)
if err != nil {
log.Fatalf("Invalid date format: %v", err)
}
endDate = parsedDate
}
var stats garmin.Stats
switch statsType {
case "steps":
stats = garmin.NewDailySteps()
case "stress":
stats = garmin.NewDailyStress()
case "hydration":
stats = garmin.NewDailyHydration()
case "intensity":
stats = garmin.NewDailyIntensityMinutes()
case "sleep":
stats = garmin.NewDailySleep()
case "hrv":
stats = garmin.NewDailyHRV()
default:
log.Fatalf("Unknown stats type: %s", statsType)
}
result, err := stats.List(endDate, statsDays, garminClient.Client)
if err != nil {
log.Fatalf("Failed to get %s stats: %v", statsType, err)
}
outputResult(result, statsOutputFile)
},
}
func init() {
rootCmd.AddCommand(statsCmd)
statsCmd.Flags().StringVar(&statsDateStr, "date", "", "Date in YYYY-MM-DD format (default: yesterday)")
statsCmd.Flags().IntVar(&statsDays, "days", 1, "Number of days to fetch")
statsCmd.Flags().StringVar(&statsOutputFile, "output", "", "Output file for JSON results")
}

55
cmd/garth/cmd/tokens.go Normal file
View File

@@ -0,0 +1,55 @@
package cmd
import (
"encoding/json"
"fmt"
"log"
"go-garth/internal/auth/credentials"
"go-garth/pkg/garmin"
"github.com/spf13/cobra"
)
var tokensCmd = &cobra.Command{
Use: "tokens",
Short: "Output OAuth tokens in JSON format",
Long: `Output the OAuth1 and OAuth2 tokens in JSON format after a successful login.`,
Run: func(cmd *cobra.Command, args []string) {
// Load credentials from .env file
_, _, domain, err := credentials.LoadEnvCredentials()
if err != nil {
log.Fatalf("Failed to load credentials: %v", err)
}
// Create client
garminClient, err := garmin.NewClient(domain)
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}
// Try to load existing session first
sessionFile := "garmin_session.json"
if err := garminClient.LoadSession(sessionFile); err != nil {
log.Fatalf("No existing session found. Please run 'garth login' first.")
}
tokens := struct {
OAuth1 *garmin.OAuth1Token `json:"oauth1"`
OAuth2 *garmin.OAuth2Token `json:"oauth2"`
}{
OAuth1: garminClient.OAuth1Token(),
OAuth2: garminClient.OAuth2Token(),
}
jsonBytes, err := json.MarshalIndent(tokens, "", " ")
if err != nil {
log.Fatalf("Failed to marshal tokens: %v", err)
}
fmt.Println(string(jsonBytes))
},
}
func init() {
rootCmd.AddCommand(tokensCmd)
}

56
cmd/garth/config.go Normal file
View File

@@ -0,0 +1,56 @@
package main
import (
"fmt"
"path/filepath"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"go-garth/internal/config"
)
func init() {
rootCmd.AddCommand(configCmd)
configCmd.AddCommand(configInitCmd)
configCmd.AddCommand(configShowCmd)
}
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage garth configuration",
Long: `Allows you to initialize, show, and manage garth's configuration file.`,
}
var configInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize a default config file",
Long: `Creates a default garth configuration file in the standard location ($HOME/.config/garth/config.yaml) if one does not already exist.`,
RunE: func(cmd *cobra.Command, args []string) error {
configPath := filepath.Join(config.UserConfigDir(), "config.yaml")
_, err := config.InitConfig(configPath)
if err != nil {
return fmt.Errorf("error initializing config: %w", err)
}
fmt.Printf("Default config file initialized at: %s\n", configPath)
return nil
},
}
var configShowCmd = &cobra.Command{
Use: "show",
Short: "Show the current configuration",
Long: `Displays the currently loaded garth configuration, including values from the config file and environment variables.`,
RunE: func(cmd *cobra.Command, args []string) error {
if cfg == nil {
return fmt.Errorf("configuration not loaded")
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("error marshaling config to YAML: %w", err)
}
fmt.Println(string(data))
return nil
},
}

911
cmd/garth/health.go Normal file
View File

@@ -0,0 +1,911 @@
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"strconv"
"time"
"github.com/rodaine/table"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go-garth/internal/data" // Import the data package
types "go-garth/internal/models/types"
"go-garth/pkg/garmin"
)
var (
healthCmd = &cobra.Command{
Use: "health",
Short: "Manage Garmin Connect health data",
Long: `Provides commands to fetch various health metrics like sleep, HRV, stress, and body battery.`,
}
sleepCmd = &cobra.Command{
Use: "sleep",
Short: "Get sleep data",
Long: `Fetch sleep data for a specified date range.`,
RunE: runSleep,
}
hrvCmd = &cobra.Command{
Use: "hrv",
Short: "Get HRV data",
Long: `Fetch Heart Rate Variability (HRV) data.`,
RunE: runHrv,
}
stressCmd = &cobra.Command{
Use: "stress",
Short: "Get stress data",
Long: `Fetch stress data.`,
RunE: runStress,
}
bodyBatteryCmd = &cobra.Command{
Use: "bodybattery",
Short: "Get Body Battery data",
Long: `Fetch Body Battery data.`,
RunE: runBodyBattery,
}
vo2maxCmd = &cobra.Command{
Use: "vo2max",
Short: "Get VO2 Max data",
Long: `Fetch VO2 Max data for a specified date range.`,
RunE: runVO2Max,
}
hrZonesCmd = &cobra.Command{
Use: "hr-zones",
Short: "Get Heart Rate Zones data",
Long: `Fetch Heart Rate Zones data.`,
RunE: runHRZones,
}
trainingStatusCmd = &cobra.Command{
Use: "training-status",
Short: "Get Training Status data",
Long: `Fetch Training Status data.`,
RunE: runTrainingStatus,
}
trainingLoadCmd = &cobra.Command{
Use: "training-load",
Short: "Get Training Load data",
Long: `Fetch Training Load data.`,
RunE: runTrainingLoad,
}
fitnessAgeCmd = &cobra.Command{
Use: "fitness-age",
Short: "Get Fitness Age data",
Long: `Fetch Fitness Age data.`,
RunE: runFitnessAge,
}
wellnessCmd = &cobra.Command{
Use: "wellness",
Short: "Get comprehensive wellness data",
Long: `Fetch comprehensive wellness data including body composition and resting heart rate trends.`,
RunE: runWellness,
}
healthDateFrom string
healthDateTo string
healthDays int
healthWeek bool
healthYesterday bool
healthAggregate string
)
func init() {
rootCmd.AddCommand(healthCmd)
healthCmd.AddCommand(sleepCmd)
sleepCmd.Flags().StringVar(&healthDateFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)")
sleepCmd.Flags().StringVar(&healthDateTo, "to", "", "End date for data fetching (YYYY-MM-DD)")
sleepCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)")
healthCmd.AddCommand(hrvCmd)
hrvCmd.Flags().IntVar(&healthDays, "days", 0, "Number of past days to fetch data for")
hrvCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)")
healthCmd.AddCommand(stressCmd)
stressCmd.Flags().BoolVar(&healthWeek, "week", false, "Fetch data for the current week")
stressCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)")
healthCmd.AddCommand(bodyBatteryCmd)
bodyBatteryCmd.Flags().BoolVar(&healthYesterday, "yesterday", false, "Fetch data for yesterday")
bodyBatteryCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)")
healthCmd.AddCommand(vo2maxCmd)
vo2maxCmd.Flags().StringVar(&healthDateFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)")
vo2maxCmd.Flags().StringVar(&healthDateTo, "to", "", "End date for data fetching (YYYY-MM-DD)")
vo2maxCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)")
healthCmd.AddCommand(hrZonesCmd)
healthCmd.AddCommand(trainingStatusCmd)
trainingStatusCmd.Flags().StringVar(&healthDateFrom, "from", "", "Date for data fetching (YYYY-MM-DD, defaults to today)")
healthCmd.AddCommand(trainingLoadCmd)
trainingLoadCmd.Flags().StringVar(&healthDateFrom, "from", "", "Date for data fetching (YYYY-MM-DD, defaults to today)")
healthCmd.AddCommand(fitnessAgeCmd)
healthCmd.AddCommand(wellnessCmd)
wellnessCmd.Flags().StringVar(&healthDateFrom, "from", "", "Start date for data fetching (YYYY-MM-DD)")
wellnessCmd.Flags().StringVar(&healthDateTo, "to", "", "End date for data fetching (YYYY-MM-DD)")
wellnessCmd.Flags().StringVar(&healthAggregate, "aggregate", "", "Aggregate data by (day, week, month, year)")
}
func runSleep(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
var startDate, endDate time.Time
if healthDateFrom != "" {
startDate, err = time.Parse("2006-01-02", healthDateFrom)
if err != nil {
return fmt.Errorf("invalid date format for --from: %w", err)
}
} else {
startDate = time.Now().AddDate(0, 0, -7) // Default to last 7 days
}
if healthDateTo != "" {
endDate, err = time.Parse("2006-01-02", healthDateTo)
if err != nil {
return fmt.Errorf("invalid date format for --to: %w", err)
}
} else {
endDate = time.Now() // Default to today
}
var allSleepData []*data.DetailedSleepDataWithMethods
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
// Create a new instance of DetailedSleepDataWithMethods for each day
sleepDataFetcher := &data.DetailedSleepDataWithMethods{}
sleepData, err := sleepDataFetcher.Get(d, garminClient.InternalClient())
if err != nil {
return fmt.Errorf("failed to get sleep data for %s: %w", d.Format("2006-01-02"), err)
}
if sleepData != nil {
// Type assert the result back to DetailedSleepDataWithMethods
if sdm, ok := sleepData.(*data.DetailedSleepDataWithMethods); ok {
allSleepData = append(allSleepData, sdm)
} else {
return fmt.Errorf("unexpected type returned for sleep data: %T", sleepData)
}
}
}
if len(allSleepData) == 0 {
fmt.Println("No sleep data found.")
return nil
}
outputFormat := viper.GetString("output.format")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(allSleepData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal sleep data to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Date", "SleepScore", "TotalSleep", "Deep", "Light", "REM", "Awake", "AvgSpO2", "LowestSpO2", "AvgRespiration"})
for _, data := range allSleepData {
writer.Write([]string{
data.CalendarDate.Format("2006-01-02"),
fmt.Sprintf("%d", data.SleepScores.Overall),
(time.Duration(data.DeepSleepSeconds+data.LightSleepSeconds+data.RemSleepSeconds) * time.Second).String(),
(time.Duration(data.DeepSleepSeconds) * time.Second).String(),
(time.Duration(data.LightSleepSeconds) * time.Second).String(),
(time.Duration(data.RemSleepSeconds) * time.Second).String(),
(time.Duration(data.AwakeSleepSeconds) * time.Second).String(),
func() string {
if data.AverageSpO2Value != nil {
return fmt.Sprintf("%.2f", *data.AverageSpO2Value)
}
return "N/A"
}(),
func() string {
if data.LowestSpO2Value != nil {
return fmt.Sprintf("%d", *data.LowestSpO2Value)
}
return "N/A"
}(),
func() string {
if data.AverageRespirationValue != nil {
return fmt.Sprintf("%.2f", *data.AverageRespirationValue)
}
return "N/A"
}(),
})
}
case "table":
tbl := table.New("Date", "Score", "Total Sleep", "Deep", "Light", "REM", "Awake", "Avg SpO2", "Lowest SpO2", "Avg Resp")
for _, data := range allSleepData {
tbl.AddRow(
data.CalendarDate.Format("2006-01-02"),
fmt.Sprintf("%d", data.SleepScores.Overall),
(time.Duration(data.DeepSleepSeconds+data.LightSleepSeconds+data.RemSleepSeconds) * time.Second).String(),
(time.Duration(data.DeepSleepSeconds) * time.Second).String(),
(time.Duration(data.LightSleepSeconds) * time.Second).String(),
(time.Duration(data.RemSleepSeconds) * time.Second).String(),
(time.Duration(data.AwakeSleepSeconds) * time.Second).String(),
func() string {
if data.AverageSpO2Value != nil {
return fmt.Sprintf("%.2f", *data.AverageSpO2Value)
}
return "N/A"
}(),
func() string {
if data.LowestSpO2Value != nil {
return fmt.Sprintf("%d", *data.LowestSpO2Value)
}
return "N/A"
}(),
func() string {
if data.AverageRespirationValue != nil {
return fmt.Sprintf("%.2f", *data.AverageRespirationValue)
}
return "N/A"
}(),
)
}
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runHrv(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
days := healthDays
if days == 0 {
days = 7 // Default to 7 days if not specified
}
var allHrvData []*data.DailyHRVDataWithMethods
for d := time.Now().AddDate(0, 0, -days+1); !d.After(time.Now()); d = d.AddDate(0, 0, 1) {
hrvDataFetcher := &data.DailyHRVDataWithMethods{}
hrvData, err := hrvDataFetcher.Get(d, garminClient.InternalClient())
if err != nil {
return fmt.Errorf("failed to get HRV data for %s: %w", d.Format("2006-01-02"), err)
}
if hrvData != nil {
if hdm, ok := hrvData.(*data.DailyHRVDataWithMethods); ok {
allHrvData = append(allHrvData, hdm)
} else {
return fmt.Errorf("unexpected type returned for HRV data: %T", hrvData)
}
}
}
if len(allHrvData) == 0 {
fmt.Println("No HRV data found.")
return nil
}
outputFormat := viper.GetString("output.format")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(allHrvData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal HRV data to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Date", "WeeklyAvg", "LastNightAvg", "Status", "Feedback"})
for _, data := range allHrvData {
writer.Write([]string{
data.CalendarDate.Format("2006-01-02"),
func() string {
if data.WeeklyAvg != nil {
return fmt.Sprintf("%.2f", *data.WeeklyAvg)
}
return "N/A"
}(),
func() string {
if data.LastNightAvg != nil {
return fmt.Sprintf("%.2f", *data.LastNightAvg)
}
return "N/A"
}(),
data.Status,
data.FeedbackPhrase,
})
}
case "table":
tbl := table.New("Date", "Weekly Avg", "Last Night Avg", "Status", "Feedback")
for _, data := range allHrvData {
tbl.AddRow(
data.CalendarDate.Format("2006-01-02"),
func() string {
if data.WeeklyAvg != nil {
return fmt.Sprintf("%.2f", *data.WeeklyAvg)
}
return "N/A"
}(),
func() string {
if data.LastNightAvg != nil {
return fmt.Sprintf("%.2f", *data.LastNightAvg)
}
return "N/A"
}(),
data.Status,
data.FeedbackPhrase,
)
}
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runStress(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
var startDate, endDate time.Time
if healthWeek {
now := time.Now()
weekday := now.Weekday()
// Calculate the start of the current week (Sunday)
startDate = now.AddDate(0, 0, -int(weekday))
endDate = startDate.AddDate(0, 0, 6) // End of the current week (Saturday)
} else {
// Default to today if no specific range or week is given
startDate = time.Now()
endDate = time.Now()
}
stressData, err := garminClient.GetStressData(startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get stress data: %w", err)
}
if len(stressData) == 0 {
fmt.Println("No stress data found.")
return nil
}
// Apply aggregation if requested
if healthAggregate != "" {
aggregatedStress := make(map[string]struct {
StressLevel int
RestStressLevel int
Count int
})
for _, data := range stressData {
key := ""
switch healthAggregate {
case "day":
key = data.Date.Format("2006-01-02")
case "week":
year, week := data.Date.ISOWeek()
key = fmt.Sprintf("%d-W%02d", year, week)
case "month":
key = data.Date.Format("2006-01")
case "year":
key = data.Date.Format("2006")
default:
return fmt.Errorf("unsupported aggregation period: %s", healthAggregate)
}
entry := aggregatedStress[key]
entry.StressLevel += data.StressLevel
entry.RestStressLevel += data.RestStressLevel
entry.Count++
aggregatedStress[key] = entry
}
// Convert aggregated data back to a slice for output
stressData = []types.StressData{}
for key, entry := range aggregatedStress {
stressData = append(stressData, types.StressData{
Date: types.ParseAggregationKey(key, healthAggregate),
StressLevel: entry.StressLevel / entry.Count,
RestStressLevel: entry.RestStressLevel / entry.Count,
})
}
}
outputFormat := viper.GetString("output")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(stressData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal stress data to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Date", "StressLevel", "RestStressLevel"})
for _, data := range stressData {
writer.Write([]string{
data.Date.Format("2006-01-02"),
fmt.Sprintf("%d", data.StressLevel),
fmt.Sprintf("%d", data.RestStressLevel),
})
}
case "table":
tbl := table.New("Date", "Stress Level", "Rest Stress Level")
for _, data := range stressData {
tbl.AddRow(
data.Date.Format("2006-01-02"),
fmt.Sprintf("%d", data.StressLevel),
fmt.Sprintf("%d", data.RestStressLevel),
)
}
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runBodyBattery(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
var targetDate time.Time
if healthYesterday {
targetDate = time.Now().AddDate(0, 0, -1)
} else {
targetDate = time.Now()
}
bodyBatteryDataFetcher := &data.BodyBatteryDataWithMethods{}
result, err := bodyBatteryDataFetcher.Get(targetDate, garminClient.InternalClient())
if err != nil {
return fmt.Errorf("failed to get Body Battery data: %w", err)
}
bodyBatteryData, ok := result.(*data.BodyBatteryDataWithMethods)
if !ok {
return fmt.Errorf("unexpected type for Body Battery data: %T", result)
}
if bodyBatteryData == nil {
fmt.Println("No Body Battery data found.")
return nil
}
outputFormat := viper.GetString("output.format")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(bodyBatteryData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal Body Battery data to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Date", "CurrentLevel", "DayChange", "MaxStressLevel", "AvgStressLevel"})
writer.Write([]string{
bodyBatteryData.CalendarDate.Format("2006-01-02"),
fmt.Sprintf("%d", bodyBatteryData.GetCurrentLevel()),
fmt.Sprintf("%d", bodyBatteryData.GetDayChange()),
fmt.Sprintf("%d", bodyBatteryData.MaxStressLevel),
fmt.Sprintf("%d", bodyBatteryData.AvgStressLevel),
})
case "table":
tbl := table.New("Date", "Current Level", "Day Change", "Max Stress", "Avg Stress")
tbl.AddRow(
bodyBatteryData.CalendarDate.Format("2006-01-02"),
fmt.Sprintf("%d", bodyBatteryData.GetCurrentLevel()),
fmt.Sprintf("%d", bodyBatteryData.GetDayChange()),
fmt.Sprintf("%d", bodyBatteryData.MaxStressLevel),
fmt.Sprintf("%d", bodyBatteryData.AvgStressLevel),
)
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runVO2Max(cmd *cobra.Command, args []string) error {
client, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := client.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
profile, err := client.InternalClient().GetCurrentVO2Max()
if err != nil {
return fmt.Errorf("failed to get VO2 Max data: %w", err)
}
if profile.Running == nil && profile.Cycling == nil {
fmt.Println("No VO2 Max data found.")
return nil
}
outputFormat := viper.GetString("output.format")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(profile, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal VO2 Max data to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Type", "Value", "Date", "Source"})
if profile.Running != nil {
writer.Write([]string{
profile.Running.ActivityType,
fmt.Sprintf("%.2f", profile.Running.Value),
profile.Running.Date.Format("2006-01-02"),
profile.Running.Source,
})
}
if profile.Cycling != nil {
writer.Write([]string{
profile.Cycling.ActivityType,
fmt.Sprintf("%.2f", profile.Cycling.Value),
profile.Cycling.Date.Format("2006-01-02"),
profile.Cycling.Source,
})
}
case "table":
tbl := table.New("Type", "Value", "Date", "Source")
if profile.Running != nil {
tbl.AddRow(
profile.Running.ActivityType,
fmt.Sprintf("%.2f", profile.Running.Value),
profile.Running.Date.Format("2006-01-02"),
profile.Running.Source,
)
}
if profile.Cycling != nil {
tbl.AddRow(
profile.Cycling.ActivityType,
fmt.Sprintf("%.2f", profile.Cycling.Value),
fmt.Sprintf("%.2f", profile.Cycling.Value),
profile.Cycling.Date.Format("2006-01-02"),
profile.Cycling.Source,
)
}
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runHRZones(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
hrZonesData, err := garminClient.GetHeartRateZones()
if err != nil {
return fmt.Errorf("failed to get Heart Rate Zones data: %w", err)
}
if hrZonesData == nil {
fmt.Println("No Heart Rate Zones data found.")
return nil
}
outputFormat := viper.GetString("output")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(hrZonesData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal Heart Rate Zones data to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Zone", "MinBPM", "MaxBPM", "Name"})
for _, zone := range hrZonesData.Zones {
writer.Write([]string{
strconv.Itoa(zone.Zone),
strconv.Itoa(zone.MinBPM),
strconv.Itoa(zone.MaxBPM),
zone.Name,
})
}
case "table":
tbl := table.New("Resting HR", "Max HR", "Lactate Threshold", "Updated At")
tbl.AddRow(
strconv.Itoa(hrZonesData.RestingHR),
strconv.Itoa(hrZonesData.MaxHR),
strconv.Itoa(hrZonesData.LactateThreshold),
hrZonesData.UpdatedAt.Format("2006-01-02 15:04:05"),
)
tbl.Print()
fmt.Println()
zonesTable := table.New("Zone", "Min BPM", "Max BPM", "Name")
for _, zone := range hrZonesData.Zones {
zonesTable.AddRow(
strconv.Itoa(zone.Zone),
strconv.Itoa(zone.MinBPM),
strconv.Itoa(zone.MaxBPM),
zone.Name,
)
}
zonesTable.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runWellness(cmd *cobra.Command, args []string) error {
return fmt.Errorf("not implemented")
}
func runTrainingStatus(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
var targetDate time.Time
if healthDateFrom != "" {
targetDate, err = time.Parse("2006-01-02", healthDateFrom)
if err != nil {
return fmt.Errorf("invalid date format for --from: %w", err)
}
} else {
targetDate = time.Now()
}
trainingStatusFetcher := &data.TrainingStatusWithMethods{}
trainingStatus, err := trainingStatusFetcher.Get(targetDate, garminClient.InternalClient())
if err != nil {
return fmt.Errorf("failed to get training status: %w", err)
}
if trainingStatus == nil {
fmt.Println("No training status data found.")
return nil
}
tsm, ok := trainingStatus.(*data.TrainingStatusWithMethods)
if !ok {
return fmt.Errorf("unexpected type returned for training status: %T", trainingStatus)
}
outputFormat := viper.GetString("output.format")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(tsm, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal training status to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Date", "Status", "LoadRatio"})
writer.Write([]string{
tsm.CalendarDate.Format("2006-01-02"),
tsm.TrainingStatusKey,
fmt.Sprintf("%.2f", tsm.LoadRatio),
})
case "table":
tbl := table.New("Date", "Status", "Load Ratio")
tbl.AddRow(
tsm.CalendarDate.Format("2006-01-02"),
tsm.TrainingStatusKey,
fmt.Sprintf("%.2f", tsm.LoadRatio),
)
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runTrainingLoad(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
var targetDate time.Time
if healthDateFrom != "" {
targetDate, err = time.Parse("2006-01-02", healthDateFrom)
if err != nil {
return fmt.Errorf("invalid date format for --from: %w", err)
}
} else {
targetDate = time.Now()
}
trainingLoadFetcher := &data.TrainingLoadWithMethods{}
trainingLoad, err := trainingLoadFetcher.Get(targetDate, garminClient.InternalClient())
if err != nil {
return fmt.Errorf("failed to get training load: %w", err)
}
if trainingLoad == nil {
fmt.Println("No training load data found.")
return nil
}
tlm, ok := trainingLoad.(*data.TrainingLoadWithMethods)
if !ok {
return fmt.Errorf("unexpected type returned for training load: %T", trainingLoad)
}
outputFormat := viper.GetString("output.format")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(tlm, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal training load to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Date", "AcuteLoad", "ChronicLoad", "LoadRatio"})
writer.Write([]string{
tlm.CalendarDate.Format("2006-01-02"),
fmt.Sprintf("%.2f", tlm.AcuteTrainingLoad),
fmt.Sprintf("%.2f", tlm.ChronicTrainingLoad),
fmt.Sprintf("%.2f", tlm.TrainingLoadRatio),
})
case "table":
tbl := table.New("Date", "Acute Load", "Chronic Load", "Load Ratio")
tbl.AddRow(
tlm.CalendarDate.Format("2006-01-02"),
fmt.Sprintf("%.2f", tlm.AcuteTrainingLoad),
fmt.Sprintf("%.2f", tlm.ChronicTrainingLoad),
fmt.Sprintf("%.2f", tlm.TrainingLoadRatio),
)
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runFitnessAge(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient(viper.GetString("domain"))
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
if err := garminClient.LoadSession(viper.GetString("session_file")); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
fitnessAge, err := garminClient.GetFitnessAge()
if err != nil {
return fmt.Errorf("failed to get fitness age: %w", err)
}
if fitnessAge == nil {
fmt.Println("No fitness age data found.")
return nil
}
outputFormat := viper.GetString("output.format")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(fitnessAge, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal fitness age to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"FitnessAge", "ChronologicalAge", "VO2MaxRunning", "LastUpdated"})
writer.Write([]string{
fmt.Sprintf("%d", fitnessAge.FitnessAge),
fmt.Sprintf("%d", fitnessAge.ChronologicalAge),
fmt.Sprintf("%.2f", fitnessAge.VO2MaxRunning),
fitnessAge.LastUpdated.Format("2006-01-02"),
})
case "table":
tbl := table.New("Fitness Age", "Chronological Age", "VO2 Max Running", "Last Updated")
tbl.AddRow(
fmt.Sprintf("%d", fitnessAge.FitnessAge),
fmt.Sprintf("%d", fitnessAge.ChronologicalAge),
fmt.Sprintf("%.2f", fitnessAge.VO2MaxRunning),
fitnessAge.LastUpdated.Format("2006-01-02"),
)
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}

5
cmd/garth/main.go Normal file
View File

@@ -0,0 +1,5 @@
package main
func main() {
Execute()
}

117
cmd/garth/root.go Normal file
View File

@@ -0,0 +1,117 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go-garth/internal/config"
)
var (
cfgFile string
userConfigDir string
cfg *config.Config
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "garth",
Short: "Garmin Connect CLI tool",
Long: `A comprehensive CLI tool for interacting with Garmin Connect.
Garth allows you to fetch your Garmin Connect data, including activities,
health stats, and more, directly from your terminal.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Ensure config is loaded before any command runs
if cfg == nil {
return fmt.Errorf("configuration not loaded")
}
return nil
},
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.config/garth/config.yaml)")
rootCmd.PersistentFlags().StringVar(&userConfigDir, "config-dir", "", "config directory (default is $HOME/.config/garth)")
rootCmd.PersistentFlags().String("output", "table", "output format (json, table, csv)")
rootCmd.PersistentFlags().Bool("verbose", false, "enable verbose output")
rootCmd.PersistentFlags().String("date-from", "", "start date for data fetching (YYYY-MM-DD)")
rootCmd.PersistentFlags().String("date-to", "", "end date for data fetching (YYYY-MM-DD)")
// Bind flags to viper
_ = viper.BindPFlag("output.format", rootCmd.PersistentFlags().Lookup("output"))
_ = viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
_ = viper.BindPFlag("dateFrom", rootCmd.PersistentFlags().Lookup("date-from"))
_ = viper.BindPFlag("dateTo", rootCmd.PersistentFlags().Lookup("date-to"))
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if userConfigDir == "" {
userConfigDir = config.UserConfigDir()
}
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Search config in user's config directory with name "config" (without extension).
viper.AddConfigPath(userConfigDir)
viper.SetConfigName("config")
viper.SetConfigType("yaml")
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
} else {
// If config file not found, try to initialize a default one
defaultConfigPath := filepath.Join(userConfigDir, "config.yaml")
if _, statErr := os.Stat(defaultConfigPath); os.IsNotExist(statErr) {
fmt.Fprintln(os.Stderr, "No config file found. Initializing default config at:", defaultConfigPath)
var initErr error
cfg, initErr = config.InitConfig(defaultConfigPath)
if initErr != nil {
fmt.Fprintln(os.Stderr, "Error initializing default config:", initErr)
os.Exit(1)
}
} else if statErr != nil {
fmt.Fprintln(os.Stderr, "Error checking for config file:", statErr)
os.Exit(1)
}
}
// Unmarshal config into our struct
if cfg == nil { // Only unmarshal if not already initialized by InitConfig
cfg = config.DefaultConfig() // Start with defaults
if err := viper.Unmarshal(cfg); err != nil {
fmt.Fprintln(os.Stderr, "Error unmarshaling config:", err)
os.Exit(1)
}
}
// Override config with flag values
if rootCmd.PersistentFlags().Lookup("output").Changed {
cfg.Output.Format = viper.GetString("output.format")
}
// Add other flag overrides as needed
}

238
cmd/garth/stats.go Normal file
View File

@@ -0,0 +1,238 @@
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"os"
"time"
"github.com/rodaine/table"
"github.com/spf13/cobra"
"github.com/spf13/viper"
types "go-garth/internal/models/types"
"go-garth/pkg/garmin"
)
var (
statsYear bool
statsAggregate string
statsFrom string
)
func runDistance(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if err := garminClient.LoadSession(sessionFile); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
var startDate, endDate time.Time
if statsYear {
now := time.Now()
startDate = time.Date(now.Year(), time.January, 1, 0, 0, 0, 0, now.Location())
endDate = time.Date(now.Year(), time.December, 31, 0, 0, 0, 0, now.Location()) // Last day of the year
} else {
// Default to today if no specific range or year is given
startDate = time.Now()
endDate = time.Now()
}
distanceData, err := garminClient.GetDistanceData(startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get distance data: %w", err)
}
if len(distanceData) == 0 {
fmt.Println("No distance data found.")
return nil
}
// Apply aggregation if requested
if statsAggregate != "" {
aggregatedDistance := make(map[string]struct {
Distance float64
Count int
})
for _, data := range distanceData {
key := ""
switch statsAggregate {
case "day":
key = data.Date.Format("2006-01-02")
case "week":
year, week := data.Date.ISOWeek()
key = fmt.Sprintf("%d-W%02d", year, week)
case "month":
key = data.Date.Format("2006-01")
case "year":
key = data.Date.Format("2006")
default:
return fmt.Errorf("unsupported aggregation period: %s", statsAggregate)
}
entry := aggregatedDistance[key]
entry.Distance += data.Distance
entry.Count++
aggregatedDistance[key] = entry
}
// Convert aggregated data back to a slice for output
distanceData = []types.DistanceData{}
for key, entry := range aggregatedDistance {
distanceData = append(distanceData, types.DistanceData{
Date: types.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date
Distance: entry.Distance / float64(entry.Count),
})
}
}
outputFormat := viper.GetString("output")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(distanceData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal distance data to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Date", "Distance(km)"})
for _, data := range distanceData {
writer.Write([]string{
data.Date.Format("2006-01-02"),
fmt.Sprintf("%.2f", data.Distance/1000),
})
}
case "table":
tbl := table.New("Date", "Distance (km)")
for _, data := range distanceData {
tbl.AddRow(
data.Date.Format("2006-01-02"),
fmt.Sprintf("%.2f", data.Distance/1000),
)
}
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}
func runCalories(cmd *cobra.Command, args []string) error {
garminClient, err := garmin.NewClient("www.garmin.com") // TODO: Domain should be configurable
if err != nil {
return fmt.Errorf("failed to create client: %w", err)
}
sessionFile := "garmin_session.json" // TODO: Make session file configurable
if err := garminClient.LoadSession(sessionFile); err != nil {
return fmt.Errorf("not logged in: %w", err)
}
var startDate, endDate time.Time
if statsFrom != "" {
startDate, err = time.Parse("2006-01-02", statsFrom)
if err != nil {
return fmt.Errorf("invalid date format for --from: %w", err)
}
endDate = time.Now() // Default end date to today if only from is provided
} else {
// Default to today if no specific range is given
startDate = time.Now()
endDate = time.Now()
}
caloriesData, err := garminClient.GetCaloriesData(startDate, endDate)
if err != nil {
return fmt.Errorf("failed to get calories data: %w", err)
}
if len(caloriesData) == 0 {
fmt.Println("No calories data found.")
return nil
}
// Apply aggregation if requested
if statsAggregate != "" {
aggregatedCalories := make(map[string]struct {
Calories int
Count int
})
for _, data := range caloriesData {
key := ""
switch statsAggregate {
case "day":
key = data.Date.Format("2006-01-02")
case "week":
year, week := data.Date.ISOWeek()
key = fmt.Sprintf("%d-W%02d", year, week)
case "month":
key = data.Date.Format("2006-01")
case "year":
key = data.Date.Format("2006")
default:
return fmt.Errorf("unsupported aggregation period: %s", statsAggregate)
}
entry := aggregatedCalories[key]
entry.Calories += data.Calories
entry.Count++
aggregatedCalories[key] = entry
}
// Convert aggregated data back to a slice for output
caloriesData = []types.CaloriesData{}
for key, entry := range aggregatedCalories {
caloriesData = append(caloriesData, types.CaloriesData{
Date: types.ParseAggregationKey(key, statsAggregate), // Helper to parse key back to date
Calories: entry.Calories / entry.Count,
})
}
}
outputFormat := viper.GetString("output")
switch outputFormat {
case "json":
data, err := json.MarshalIndent(caloriesData, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal calories data to JSON: %w", err)
}
fmt.Println(string(data))
case "csv":
writer := csv.NewWriter(os.Stdout)
defer writer.Flush()
writer.Write([]string{"Date", "Calories"})
for _, data := range caloriesData {
writer.Write([]string{
data.Date.Format("2006-01-02"),
fmt.Sprintf("%d", data.Calories),
})
}
case "table":
tbl := table.New("Date", "Calories")
for _, data := range caloriesData {
tbl.AddRow(
data.Date.Format("2006-01-02"),
fmt.Sprintf("%d", data.Calories),
)
}
tbl.Print()
default:
return fmt.Errorf("unsupported output format: %s", outputFormat)
}
return nil
}