mirror of
https://github.com/sstent/go-garth-cli.git
synced 2026-01-26 17:12:05 +00:00
sync
This commit is contained in:
416
cmd/garth/activities.go
Normal file
416
cmd/garth/activities.go
Normal 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
183
cmd/garth/auth.go
Normal 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
|
||||
}
|
||||
67
cmd/garth/cmd/activities.go
Normal file
67
cmd/garth/cmd/activities.go
Normal 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
102
cmd/garth/cmd/data.go
Normal 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
38
cmd/garth/cmd/root.go
Normal 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
87
cmd/garth/cmd/stats.go
Normal 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
55
cmd/garth/cmd/tokens.go
Normal 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
56
cmd/garth/config.go
Normal 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
911
cmd/garth/health.go
Normal 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
5
cmd/garth/main.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package main
|
||||
|
||||
func main() {
|
||||
Execute()
|
||||
}
|
||||
117
cmd/garth/root.go
Normal file
117
cmd/garth/root.go
Normal 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
238
cmd/garth/stats.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user