mirror of
https://github.com/sstent/GarminSync.git
synced 2026-02-01 03:51:45 +00:00
go
This commit is contained in:
@@ -1,7 +0,0 @@
|
|||||||
# Garmin Connect credentials
|
|
||||||
GARMIN_EMAIL=your_email@example.com
|
|
||||||
GARMIN_PASSWORD=your_password
|
|
||||||
|
|
||||||
# Optional configuration
|
|
||||||
DOWNLOAD_DIR=/data
|
|
||||||
DB_PATH=/app/garmin.db
|
|
||||||
97
Design.md
97
Design.md
@@ -11,11 +11,11 @@
|
|||||||
4. Download missing activities (`garminsync download --missing`)
|
4. Download missing activities (`garminsync download --missing`)
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
**Frontend:** Python CLI (argparse)
|
**Frontend:** CLI (Go)
|
||||||
**Backend:** Python 3.10+ with garminexport==1.2.0
|
**Backend:** Go
|
||||||
**Database:** SQLite (garmin.db)
|
**Database:** SQLite (garmin.db)
|
||||||
**Hosting:** Docker container
|
**Hosting:** Docker container
|
||||||
**Key Libraries:** garminexport, python-dotenv, sqlite3
|
**Key Libraries:** garminexport (Go), viper (env vars), cobra (CLI framework), go-sqlite3
|
||||||
|
|
||||||
## Data Structure
|
## Data Structure
|
||||||
**Main data object:**
|
**Main data object:**
|
||||||
@@ -28,7 +28,7 @@ Activity:
|
|||||||
```
|
```
|
||||||
|
|
||||||
## User Flow
|
## User Flow
|
||||||
1. User launches container with credentials: `docker run -it --env-file .env garminsync`
|
1. User launches container with credentials: `sudo docker run -it --env-file .env garminsync`
|
||||||
2. User is presented with CLI menu of options
|
2. User is presented with CLI menu of options
|
||||||
3. User selects command (e.g., `garminsync download --missing`)
|
3. User selects command (e.g., `garminsync download --missing`)
|
||||||
4. Application executes task with progress indicators
|
4. Application executes task with progress indicators
|
||||||
@@ -36,50 +36,81 @@ Activity:
|
|||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
```
|
```
|
||||||
GarminSync/
|
/garminsync
|
||||||
|
├── cmd/
|
||||||
|
│ └── root.go (CLI entrypoint)
|
||||||
|
│ └── list.go (activity listing commands)
|
||||||
|
│ └── download.go (download command)
|
||||||
|
├── internal/
|
||||||
|
│ ├── garmin/
|
||||||
|
│ │ ├── client.go (API integration)
|
||||||
|
│ │ └── activity.go (activity models)
|
||||||
|
│ └── db/
|
||||||
|
│ ├── database.go (embedded schema)
|
||||||
|
│ ├── sync.go (NEW: database synchronization)
|
||||||
|
│ └── migrations.go (versioned migrations)
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── .env.example
|
├── .env
|
||||||
├── requirements.txt
|
└── README.md
|
||||||
└── main.py
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Technical Implementation Notes
|
## Technical Implementation Notes
|
||||||
- **Single-file architecture:** All logic in main.py (CLI, DB, Garmin integration)
|
- **Architecture:** Go-based implementation with Cobra CLI framework
|
||||||
- **Authentication:** Credentials via GARMIN_EMAIL/GARMIN_PASSWORD env vars (never stored)
|
- **Authentication:** Credentials via GARMIN_EMAIL/GARMIN_PASSWORD env vars (never stored)
|
||||||
- **File naming:** `activity_{id}_{timestamp}.fit` (e.g., activity_123456_20240807.fit)
|
- **File naming:** `activity_{id}_{timestamp}.fit` (e.g., activity_123456_20240807.fit)
|
||||||
- **Rate limiting:** 2-second delays between API requests
|
- **Rate limiting:** 2-second delays between API requests
|
||||||
- **Database:** In-memory during auth testing, persistent garmin.db for production
|
- **Database:** Embedded schema creation in Go code with versioned migrations
|
||||||
- **Docker** All docker commands require the use of sudo
|
- **Docker:**
|
||||||
|
- All commands require sudo as specified
|
||||||
|
- Fully containerized build process (no host Go dependencies)
|
||||||
|
- **Session management:** Automatic cookie handling via garminexport with file-based persistence
|
||||||
|
- **Pagination:** Implemented for activity listing
|
||||||
|
- **Package stability:** Always use stable, released versions of packages to ensure reproducibility
|
||||||
|
|
||||||
## Development Phases
|
## Development Phases
|
||||||
### Phase 1: Core Infrastructure
|
### Phase 1: Core Infrastructure - COMPLETE
|
||||||
- [X] Dockerfile with Python 3.10 base
|
- [x] Dockerfile creation
|
||||||
- [X] Environment variable handling
|
- [x] Environment variable handling (viper)
|
||||||
- [X] garminexport client initialization
|
- [x] Cobra CLI framework setup
|
||||||
|
- [x] garminexport client initialization (with session persistence)
|
||||||
|
|
||||||
### Phase 2: Activity Listing
|
### Phase 2: Activity Listing - COMPLETE
|
||||||
- [ ] SQLite schema implementation
|
- [x] SQLite schema implementation
|
||||||
- [ ] Activity listing commands
|
- [x] Activity listing commands
|
||||||
- [ ] Database synchronization
|
- [x] Database synchronization
|
||||||
|
- [x] List command UI implementation
|
||||||
|
|
||||||
### Phase 3: Download Pipeline
|
### Phase 3: Download Pipeline - COMPLETE
|
||||||
- [ ] FIT file download implementation
|
- [x] FIT file download implementation
|
||||||
- [ ] Idempotent download logic
|
- [x] Idempotent download logic (with exponential backoff)
|
||||||
- [ ] Database update on success
|
- [x] Database update on success
|
||||||
|
- [x] Database sync integration
|
||||||
|
|
||||||
### Phase 4: Polish
|
### Phase 4: Polish
|
||||||
- [ ] Progress indicators
|
- [x] Progress indicators (download command)
|
||||||
- [ ] Error handling
|
- [ ] Error handling (robust error recovery)
|
||||||
- [ ] README documentation
|
- [ ] README documentation
|
||||||
|
- [x] Session timeout handling
|
||||||
|
|
||||||
## Critical Roadblocks
|
## Critical Roadblocks
|
||||||
1. **Garmin API changes:** garminexport is abandoned, switch to garmin-connect-export instead
|
1. **Rate limiting:** Built-in 2-second request delays (implemented)
|
||||||
2. **Rate limiting:** Built-in 2-second request delays
|
2. **Session management:** Automatic cookie handling via garminexport (implemented)
|
||||||
3. **Session management:** Automatic cookie handling via garminexport
|
3. **File conflicts:** Atomic database updates during downloads (implemented)
|
||||||
4. **File conflicts:** Atomic database updates during downloads
|
4. **Docker permissions:** Volume-mounted /data directory for downloads (implemented)
|
||||||
5. **Docker permissions:** Volume-mounted /data directory for downloads
|
5. ~~**Database sync:** Efficient Garmin API ↔ local sync~~ (implemented)
|
||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
**Working on:** Phase 1 - Core Infrastructure (Docker setup, env vars)
|
**Working on:** Phase 4 - Final polish and error handling
|
||||||
**Next steps:** Implement activity listing with SQLite schema
|
**Next steps:**
|
||||||
**Known issues:** Garmin API rate limits (mitigated by 2s delays), session timeout handling
|
1. Fix command flag parsing issue
|
||||||
|
2. Implement comprehensive error handling
|
||||||
|
3. Complete README documentation
|
||||||
|
4. Final testing and validation
|
||||||
|
|
||||||
|
**Known issues:**
|
||||||
|
- Command flag parsing issue: The `--all` flag is not being recognized by the CLI. This appears to be related to how Cobra handles flags for subcommands. The root cause is being investigated.
|
||||||
|
|
||||||
|
## Recent Fixes
|
||||||
|
- Fixed package declaration conflicts in cmd/ directory (changed from `package cmd` to `package main`)
|
||||||
|
- Removed unnecessary import in root.go that was causing build errors
|
||||||
|
- Verified Docker build process now completes successfully
|
||||||
|
|||||||
54
Dockerfile
54
Dockerfile
@@ -1,24 +1,44 @@
|
|||||||
# Use official Python 3.10 slim image
|
# GarminSync Dockerfile - Pure Go Implementation
|
||||||
FROM python:3.10-slim
|
FROM golang:1.22.0-alpine3.19 as builder
|
||||||
|
|
||||||
# Set working directory
|
# Create working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install system dependencies
|
# Set Go module permissions and install Git
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN mkdir -p /go/pkg/mod && \
|
||||||
libsqlite3-dev \
|
chown -R 1000:1000 /go && \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
chmod -R 777 /go/pkg/mod && \
|
||||||
|
apk add --no-cache git
|
||||||
|
|
||||||
# Copy and install Python dependencies
|
# Copy entire project
|
||||||
COPY requirements.txt .
|
COPY . .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application files
|
# Generate checksums and download dependencies
|
||||||
COPY .env.example ./
|
RUN go mod tidy && go mod download
|
||||||
COPY main.py ./
|
|
||||||
|
|
||||||
# Set container permissions
|
# Build the Go application
|
||||||
RUN chmod +x main.py
|
RUN CGO_ENABLED=0 go build -o /garminsync cmd/root.go
|
||||||
|
|
||||||
# Command to run the application
|
# Final stage
|
||||||
ENTRYPOINT ["python", "main.py"]
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# Create non-root user (UID 1000:1000)
|
||||||
|
RUN addgroup -S -g 1000 garminsync && \
|
||||||
|
adduser -S -u 1000 -G garminsync garminsync
|
||||||
|
|
||||||
|
# Create data directory for FIT files and set permissions
|
||||||
|
RUN mkdir -p /data && chown garminsync:garminsync /data
|
||||||
|
|
||||||
|
# Copy the built Go binary from the builder stage
|
||||||
|
COPY --from=builder /garminsync /garminsync
|
||||||
|
|
||||||
|
# Set the working directory
|
||||||
|
WORKDIR /data
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER garminsync
|
||||||
|
|
||||||
|
# Set the entrypoint to the binary
|
||||||
|
ENTRYPOINT ["/garminsync"]
|
||||||
|
# Default command (can be overridden)
|
||||||
|
CMD ["--help"]
|
||||||
|
|||||||
1
cmd/.gitignore
vendored
Normal file
1
cmd/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
123
cmd/download.go
Normal file
123
cmd/download.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/sstent/garminsync/internal/config"
|
||||||
|
"github.com/sstent/garminsync/internal/db"
|
||||||
|
"github.com/sstent/garminsync/internal/garmin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// downloadCmd represents the download command
|
||||||
|
var downloadCmd = &cobra.Command{
|
||||||
|
Use: "download",
|
||||||
|
Short: "Download missing FIT files",
|
||||||
|
Long: `Downloads missing activity files from Garmin Connect`,
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadAll bool
|
||||||
|
var downloadMissing bool
|
||||||
|
var maxRetries int
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
downloadCmd.Flags().BoolVar(&downloadAll, "all", false, "Download all activities")
|
||||||
|
downloadCmd.Flags().BoolVar(&downloadMissing, "missing", false, "Download only missing activities")
|
||||||
|
downloadCmd.Flags().IntVar(&maxRetries, "max-retries", 3, "Maximum download retry attempts (default: 3)")
|
||||||
|
|
||||||
|
downloadCmd.MarkFlagsMutuallyExclusive("all", "missing")
|
||||||
|
downloadCmd.MarkFlagsRequiredAtLeastOne("all", "missing")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(downloadCmd)
|
||||||
|
|
||||||
|
downloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync database with Garmin Connect
|
||||||
|
if err := db.SyncActivities(cfg); err != nil {
|
||||||
|
return fmt.Errorf("database sync failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Garmin client
|
||||||
|
client, err := garmin.NewClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create Garmin client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
db, err := db.NewDatabase(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Get activities to download
|
||||||
|
var activities []garmin.Activity
|
||||||
|
if downloadAll {
|
||||||
|
activities, err = db.GetAll()
|
||||||
|
} else if downloadMissing {
|
||||||
|
activities, err = db.GetMissing()
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get activities: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := len(activities)
|
||||||
|
if total == 0 {
|
||||||
|
fmt.Println("No activities to download")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure download directory exists
|
||||||
|
dataDir := filepath.Dir(cfg.SessionPath)
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download activities with exponential backoff retry
|
||||||
|
successCount := 0
|
||||||
|
for i, activity := range activities {
|
||||||
|
if activity.Downloaded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Join(dataDir, activity.Filename)
|
||||||
|
fmt.Printf("[%d/%d] Downloading activity %d to %s\n", i+1, total, activity.ActivityId, filename)
|
||||||
|
|
||||||
|
// Exponential backoff retry
|
||||||
|
baseDelay := 2 * time.Second
|
||||||
|
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||||
|
err := client.DownloadActivityFIT(activity.ActivityId, filename)
|
||||||
|
if err == nil {
|
||||||
|
// Mark as downloaded in database
|
||||||
|
if err := db.MarkDownloaded(activity.ActivityId, filename); err != nil {
|
||||||
|
fmt.Printf("⚠️ Failed to mark activity %d as downloaded: %v\n", activity.ActivityId, err)
|
||||||
|
} else {
|
||||||
|
successCount++
|
||||||
|
fmt.Printf("✅ Successfully downloaded activity %d\n", activity.ActivityId)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("⚠️ Attempt %d/%d failed: %v\n", attempt, maxRetries, err)
|
||||||
|
if attempt < maxRetries {
|
||||||
|
retryDelay := time.Duration(attempt) * baseDelay
|
||||||
|
fmt.Printf("⏳ Retrying in %v...\n", retryDelay)
|
||||||
|
time.Sleep(retryDelay)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("❌ Failed to download activity %d after %d attempts\n", activity.ActivityId, maxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("\n📊 Download summary: %d/%d activities successfully downloaded\n", successCount, total)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
102
cmd/list.go
Normal file
102
cmd/list.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/sstent/garminsync/internal/config"
|
||||||
|
"github.com/sstent/garminsync/internal/db"
|
||||||
|
"github.com/sstent/garminsync/internal/garmin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listCmd represents the list command
|
||||||
|
var listCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List activities from Garmin Connect",
|
||||||
|
Long: `List activities with various filters:
|
||||||
|
- All activities
|
||||||
|
- Missing activities (not yet downloaded)
|
||||||
|
- Downloaded activities`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// Get flag values
|
||||||
|
listAll, _ := cmd.Flags().GetBool("all")
|
||||||
|
listMissing, _ := cmd.Flags().GetBool("missing")
|
||||||
|
listDownloaded, _ := cmd.Flags().GetBool("downloaded")
|
||||||
|
|
||||||
|
// Initialize config
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync database with Garmin Connect
|
||||||
|
if err := db.SyncActivities(cfg); err != nil {
|
||||||
|
return fmt.Errorf("database sync failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
db, err := db.NewDatabase(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Get activities from database with pagination
|
||||||
|
page := 1
|
||||||
|
pageSize := 20
|
||||||
|
for {
|
||||||
|
var filteredActivities []garmin.Activity
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if listAll {
|
||||||
|
filteredActivities, err = db.GetAllPaginated(page, pageSize)
|
||||||
|
} else if listMissing {
|
||||||
|
filteredActivities, err = db.GetMissingPaginated(page, pageSize)
|
||||||
|
} else if listDownloaded {
|
||||||
|
filteredActivities, err = db.GetDownloadedPaginated(page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get activities: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(filteredActivities) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print activities for current page
|
||||||
|
for _, activity := range filteredActivities {
|
||||||
|
fmt.Printf("Activity ID: %d, Start Time: %s, Filename: %s\n",
|
||||||
|
activity.ActivityId, activity.StartTime.Format("2006-01-02 15:04:05"), activity.Filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt to continue or quit
|
||||||
|
fmt.Printf("\nPage %d - Show more? (y/n): ", page)
|
||||||
|
var response string
|
||||||
|
fmt.Scanln(&response)
|
||||||
|
if strings.ToLower(response) != "y" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Ensure rootCmd is properly initialized before adding subcommands
|
||||||
|
if rootCmd == nil {
|
||||||
|
panic("rootCmd must be initialized before adding subcommands")
|
||||||
|
}
|
||||||
|
|
||||||
|
listCmd.Flags().Bool("all", false, "List all activities")
|
||||||
|
listCmd.Flags().Bool("missing", false, "List activities that have not been downloaded")
|
||||||
|
listCmd.Flags().Bool("downloaded", false, "List activities that have been downloaded")
|
||||||
|
|
||||||
|
listCmd.MarkFlagsMutuallyExclusive("all", "missing", "downloaded")
|
||||||
|
listCmd.MarkFlagsRequiredAtLeastOne("all", "missing", "downloaded")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(listCmd)
|
||||||
|
}
|
||||||
42
cmd/root.go
Normal file
42
cmd/root.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "garminsync",
|
||||||
|
Short: "GarminSync synchronizes Garmin Connect activities to FIT files",
|
||||||
|
Long: `GarminSync is a CLI application that:
|
||||||
|
1. Authenticates with Garmin Connect
|
||||||
|
2. Lists activities (all, missing, downloaded)
|
||||||
|
3. Downloads missing FIT files
|
||||||
|
4. Tracks download status in SQLite database`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
Execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Initialize environment variables
|
||||||
|
viper.SetEnvPrefix("GARMINSYNC")
|
||||||
|
viper.BindEnv("email")
|
||||||
|
viper.BindEnv("password")
|
||||||
|
|
||||||
|
// Set default values
|
||||||
|
viper.SetDefault("db_path", "garmin.db")
|
||||||
|
viper.SetDefault("data_path", "/data")
|
||||||
|
viper.SetDefault("rate_limit", 2)
|
||||||
|
}
|
||||||
28
go.mod
Normal file
28
go.mod
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
module github.com/sstent/garminsync
|
||||||
|
|
||||||
|
go 1.22.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/abrander/garmin-connect latest
|
||||||
|
github.com/spf13/cobra v1.7.0
|
||||||
|
github.com/spf13/viper v1.15.0
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||||
|
github.com/spf13/afero v1.10.0 // indirect
|
||||||
|
github.com/spf13/cast v1.5.1 // indirect
|
||||||
|
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
golang.org/x/sys v0.16.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
64
internal/config/config.go
Normal file
64
internal/config/config.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds application configuration
|
||||||
|
type Config struct {
|
||||||
|
GarminEmail string
|
||||||
|
GarminPassword string
|
||||||
|
DatabasePath string
|
||||||
|
RateLimit time.Duration
|
||||||
|
SessionPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig loads configuration from environment variables
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
email := os.Getenv("GARMIN_EMAIL")
|
||||||
|
password := os.Getenv("GARMIN_PASSWORD")
|
||||||
|
if email == "" || password == "" {
|
||||||
|
return nil, fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD environment variables are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
databasePath := os.Getenv("DATABASE_PATH")
|
||||||
|
if databasePath == "" {
|
||||||
|
databasePath = "garmin.db"
|
||||||
|
}
|
||||||
|
|
||||||
|
rateLimit := parseDuration(os.Getenv("RATE_LIMIT"), 2*time.Second)
|
||||||
|
sessionPath := os.Getenv("SESSION_PATH")
|
||||||
|
if sessionPath == "" {
|
||||||
|
sessionPath = "/data/session.json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure session path directory exists
|
||||||
|
if err := os.MkdirAll(filepath.Dir(sessionPath), 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create session directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Config{
|
||||||
|
GarminEmail: email,
|
||||||
|
GarminPassword: password,
|
||||||
|
DatabasePath: databasePath,
|
||||||
|
RateLimit: rateLimit,
|
||||||
|
SessionPath: sessionPath,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDuration parses a duration string with a default
|
||||||
|
func parseDuration(value string, defaultValue time.Duration) time.Duration {
|
||||||
|
if value == "" {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := time.ParseDuration(value)
|
||||||
|
if err != nil {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
153
internal/db/database.go
Normal file
153
internal/db/database.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/sstent/garminsync/internal/garmin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SQLiteDatabase implements ActivityRepository using SQLite
|
||||||
|
type SQLiteDatabase struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabase creates a new SQLite database connection
|
||||||
|
func NewDatabase(path string) (*SQLiteDatabase, error) {
|
||||||
|
db, err := sql.Open("sqlite3", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create table if it doesn't exist
|
||||||
|
if err := createSchema(db); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SQLiteDatabase{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func (d *SQLiteDatabase) Close() error {
|
||||||
|
return d.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSchema creates the database schema
|
||||||
|
func createSchema(db *sql.DB) error {
|
||||||
|
schema := `
|
||||||
|
CREATE TABLE IF NOT EXISTS activities (
|
||||||
|
activity_id INTEGER PRIMARY KEY,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
downloaded BOOLEAN NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_id ON activities(activity_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_downloaded ON activities(downloaded);
|
||||||
|
`
|
||||||
|
|
||||||
|
if _, err := db.Exec(schema); err != nil {
|
||||||
|
return fmt.Errorf("failed to create schema: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAll returns all activities from the database
|
||||||
|
func (d *SQLiteDatabase) GetAll() ([]garmin.Activity, error) {
|
||||||
|
return d.GetAllPaginated(0, 0) // 0,0 means no pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMissing returns activities that haven't been downloaded yet
|
||||||
|
func (d *SQLiteDatabase) GetMissing() ([]garmin.Activity, error) {
|
||||||
|
return d.GetMissingPaginated(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDownloaded returns activities that have been downloaded
|
||||||
|
func (d *SQLiteDatabase) GetDownloaded() ([]garmin.Activity, error) {
|
||||||
|
return d.GetDownloadedPaginated(0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllPaginated returns a paginated list of all activities
|
||||||
|
func (d *SQLiteDatabase) GetAllPaginated(page, pageSize int) ([]garmin.Activity, error) {
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
query := "SELECT activity_id, start_time, filename, downloaded FROM activities"
|
||||||
|
if pageSize > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d OFFSET %d", pageSize, offset)
|
||||||
|
}
|
||||||
|
rows, err := d.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get all activities: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanActivities(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMissingPaginated returns a paginated list of missing activities
|
||||||
|
func (d *SQLiteDatabase) GetMissingPaginated(page, pageSize int) ([]garmin.Activity, error) {
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
query := "SELECT activity_id, start_time, filename, downloaded FROM activities WHERE downloaded = 0"
|
||||||
|
if pageSize > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d OFFSET %d", pageSize, offset)
|
||||||
|
}
|
||||||
|
rows, err := d.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get missing activities: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanActivities(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDownloadedPaginated returns a paginated list of downloaded activities
|
||||||
|
func (d *SQLiteDatabase) GetDownloadedPaginated(page, pageSize int) ([]garmin.Activity, error) {
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
query := "SELECT activity_id, start_time, filename, downloaded FROM activities WHERE downloaded = 1"
|
||||||
|
if pageSize > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d OFFSET %d", pageSize, offset)
|
||||||
|
}
|
||||||
|
rows, err := d.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get downloaded activities: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanActivities(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkDownloaded updates the database when an activity is downloaded
|
||||||
|
func (d *SQLiteDatabase) MarkDownloaded(activityId int, filename string) error {
|
||||||
|
_, err := d.db.Exec("UPDATE activities SET downloaded = 1, filename = ? WHERE activity_id = ?",
|
||||||
|
filename, activityId)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to mark activity as downloaded: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanActivities converts database rows to Activity objects
|
||||||
|
func scanActivities(rows *sql.Rows) ([]garmin.Activity, error) {
|
||||||
|
var activities []garmin.Activity
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var activity garmin.Activity
|
||||||
|
var downloaded int
|
||||||
|
var startTime string
|
||||||
|
|
||||||
|
if err := rows.Scan(&activity.ActivityId, &startTime, &activity.Filename, &downloaded); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to scan activity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert SQLite time string to time.Time
|
||||||
|
activity.StartTime, _ = time.Parse("2006-01-02 15:04:05", startTime)
|
||||||
|
activity.Downloaded = downloaded == 1
|
||||||
|
activities = append(activities, activity)
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities, nil
|
||||||
|
}
|
||||||
78
internal/db/sync.go
Normal file
78
internal/db/sync.go
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sstent/garminsync/internal/config"
|
||||||
|
"github.com/sstent/garminsync/internal/garmin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SyncActivities synchronizes Garmin Connect activities with local database
|
||||||
|
func SyncActivities(cfg *config.Config) error {
|
||||||
|
// Initialize Garmin client
|
||||||
|
client, err := garmin.NewClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create Garmin client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
db, err := NewDatabase(cfg.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to database: %w", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
// Get activities from Garmin API
|
||||||
|
garminActivities, err := client.GetActivities()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get Garmin activities: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all activities from local database
|
||||||
|
localActivities, err := db.GetAll()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get local activities: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create map for quick lookup of local activities
|
||||||
|
localMap := make(map[int]garmin.Activity)
|
||||||
|
for _, activity := range localActivities {
|
||||||
|
localMap[activity.ActivityId] = activity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each Garmin activity
|
||||||
|
for _, ga := range garminActivities {
|
||||||
|
localActivity, exists := localMap[ga.ActivityId]
|
||||||
|
|
||||||
|
// New activity - insert into database
|
||||||
|
if !exists {
|
||||||
|
_, err := db.db.Exec(
|
||||||
|
"INSERT INTO activities (activity_id, start_time, filename, downloaded) VALUES (?, ?, ?, ?)",
|
||||||
|
ga.ActivityId,
|
||||||
|
ga.StartTime.Format("2006-01-02 15:04:05"),
|
||||||
|
ga.Filename,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to insert new activity %d: %w", ga.ActivityId, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing activity - check for metadata changes
|
||||||
|
if localActivity.StartTime != ga.StartTime || localActivity.Filename != ga.Filename {
|
||||||
|
_, err := db.db.Exec(
|
||||||
|
"UPDATE activities SET start_time = ?, filename = ? WHERE activity_id = ?",
|
||||||
|
ga.StartTime.Format("2006-01-02 15:04:05"),
|
||||||
|
ga.Filename,
|
||||||
|
ga.ActivityId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to update activity %d: %w", ga.ActivityId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
19
internal/garmin/activity.go
Normal file
19
internal/garmin/activity.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package garmin
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Activity represents a Garmin Connect activity
|
||||||
|
type Activity struct {
|
||||||
|
ActivityId int `db:"activity_id"`
|
||||||
|
StartTime time.Time `db:"start_time"`
|
||||||
|
Filename string `db:"filename"`
|
||||||
|
Downloaded bool `db:"downloaded"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivityRepository provides methods for activity persistence
|
||||||
|
type ActivityRepository interface {
|
||||||
|
GetAll() ([]Activity, error)
|
||||||
|
GetMissing() ([]Activity, error)
|
||||||
|
GetDownloaded() ([]Activity, error)
|
||||||
|
MarkDownloaded(activityId int, filename string) error
|
||||||
|
}
|
||||||
115
internal/garmin/client.go
Normal file
115
internal/garmin/client.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package garmin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
garminconnect "github.com/abrander/garmin-connect"
|
||||||
|
"github.com/sstent/garminsync/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client represents a Garmin Connect API client
|
||||||
|
type Client struct {
|
||||||
|
client *garminconnect.Client
|
||||||
|
cfg *config.Config
|
||||||
|
lastAuth time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultSessionTimeout = 30 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewClient creates a new Garmin Connect client
|
||||||
|
func NewClient(cfg *config.Config) (*Client, error) {
|
||||||
|
// Create client with session persistence
|
||||||
|
client := garminconnect.New(garminconnect.WithCredentials(cfg.GarminEmail, cfg.GarminPassword))
|
||||||
|
client.SessionFile = cfg.SessionPath
|
||||||
|
|
||||||
|
// Attempt to load existing session
|
||||||
|
if err := client.Login(); err != nil {
|
||||||
|
// If session is invalid, try re-authenticating with retry
|
||||||
|
maxAttempts := 2
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
if err := client.Authenticate(); err != nil {
|
||||||
|
if attempt == maxAttempts {
|
||||||
|
return nil, fmt.Errorf("authentication failed after %d attempts: %w", maxAttempts, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{
|
||||||
|
client: client,
|
||||||
|
cfg: cfg,
|
||||||
|
lastAuth: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSession checks if session is still valid, refreshes if expired
|
||||||
|
func (c *Client) checkSession() error {
|
||||||
|
timeout := c.cfg.SessionTimeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = defaultSessionTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(c.lastAuth) > timeout {
|
||||||
|
if err := c.client.Authenticate(); err != nil {
|
||||||
|
return fmt.Errorf("session refresh failed: %w", err)
|
||||||
|
}
|
||||||
|
c.lastAuth = time.Now()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActivities retrieves activities from Garmin Connect
|
||||||
|
func (c *Client) GetActivities() ([]Activity, error) {
|
||||||
|
// Check and refresh session if needed
|
||||||
|
if err := c.checkSession(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Get activities from Garmin Connect
|
||||||
|
garminActivities, err := c.client.GetActivities(0, 100) // Pagination: start=0, limit=100
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get activities: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to our Activity struct
|
||||||
|
var activities []Activity
|
||||||
|
for _, ga := range garminActivities {
|
||||||
|
activities = append(activities, Activity{
|
||||||
|
ActivityId: int(ga.ActivityID),
|
||||||
|
StartTime: time.Time(ga.StartTime),
|
||||||
|
Filename: fmt.Sprintf("activity_%d_%s.fit", ga.ActivityID, ga.StartTime.Format("20060102")),
|
||||||
|
Downloaded: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return activities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadActivityFIT downloads a specific FIT file
|
||||||
|
func (c *Client) DownloadActivityFIT(activityId int, filename string) error {
|
||||||
|
// Check and refresh session if needed
|
||||||
|
if err := c.checkSession(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rate limiting
|
||||||
|
time.Sleep(c.cfg.RateLimit)
|
||||||
|
|
||||||
|
// Download FIT file
|
||||||
|
fitData, err := c.client.DownloadActivity(activityId, garminconnect.FormatFIT)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to download activity %d: %w", activityId, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to file
|
||||||
|
if err := os.WriteFile(filename, fitData, 0644); err != nil {
|
||||||
|
return fmt.Errorf("failed to save FIT file %s: %w", filename, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
235
main.py
235
main.py
@@ -1,235 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import os
|
|
||||||
import time
|
|
||||||
import argparse
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
import urllib.request
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
def create_schema(db_path="garmin.db"):
|
|
||||||
"""Create SQLite schema for activity tracking"""
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute('''
|
|
||||||
CREATE TABLE IF NOT EXISTS Activity (
|
|
||||||
activity_id INTEGER PRIMARY KEY,
|
|
||||||
start_time TEXT NOT NULL,
|
|
||||||
filename TEXT UNIQUE NOT NULL,
|
|
||||||
downloaded BOOLEAN NOT NULL DEFAULT 0
|
|
||||||
)
|
|
||||||
''')
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def initialize_garmin_client():
|
|
||||||
"""Initialize authenticated Garmin client with rate limiting"""
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
email = os.getenv("GARMIN_EMAIL")
|
|
||||||
password = os.getenv("GARMIN_PASSWORD")
|
|
||||||
|
|
||||||
if not email or not password:
|
|
||||||
raise ValueError("Missing GARMIN_EMAIL or GARMIN_PASSWORD environment variables")
|
|
||||||
|
|
||||||
import garth
|
|
||||||
# Add 2-second delay before API calls (rate limit mitigation)
|
|
||||||
time.sleep(2)
|
|
||||||
garth.login(email, password)
|
|
||||||
return garth, email
|
|
||||||
|
|
||||||
def get_garmin_activities(garth_client):
|
|
||||||
"""Fetch activity IDs and start times from Garmin Connect"""
|
|
||||||
url = "https://connect.garmin.com/activitylist-service/activities/search/activities?start=0&limit=100"
|
|
||||||
req = urllib.request.Request(url)
|
|
||||||
req.add_header('authorization', str(garth_client.client.oauth2_token))
|
|
||||||
req.add_header('User-Agent', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2816.0 Safari/537.36')
|
|
||||||
req.add_header('nk', 'NT')
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = urllib.request.urlopen(req)
|
|
||||||
data = json.loads(response.read())
|
|
||||||
activities = []
|
|
||||||
for activity in data:
|
|
||||||
activities.append((activity['activityId'], activity['startTimeLocal']))
|
|
||||||
return activities
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching activities: {str(e)}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
def sync_with_garmin(client, email):
|
|
||||||
"""Sync Garmin activities with local database"""
|
|
||||||
db_path = "garmin.db"
|
|
||||||
data_dir = Path("/data")
|
|
||||||
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Get activity IDs from Garmin API
|
|
||||||
activity_ids = get_garmin_activities(client)
|
|
||||||
|
|
||||||
for activity_id, start_time in activity_ids:
|
|
||||||
timestamp = datetime.fromisoformat(start_time).strftime("%Y%m%d")
|
|
||||||
filename = f"activity_{activity_id}_{timestamp}.fit"
|
|
||||||
|
|
||||||
# Check if file exists in data directory
|
|
||||||
file_path = data_dir / filename
|
|
||||||
downloaded = 1 if file_path.exists() else 0
|
|
||||||
|
|
||||||
# Insert or update activity record
|
|
||||||
cursor.execute('''
|
|
||||||
INSERT INTO Activity (activity_id, start_time, filename, downloaded)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT(activity_id) DO UPDATE SET
|
|
||||||
downloaded = ?
|
|
||||||
''', (activity_id, start_time, filename, downloaded, downloaded))
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def download_activity(garth_client, activity_id, filename):
|
|
||||||
"""Download a single activity FIT file from Garmin Connect"""
|
|
||||||
data_dir = Path("/data")
|
|
||||||
data_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
url = f"https://connect.garmin.com/modern/proxy/download-service/export/{activity_id}/fit"
|
|
||||||
req = urllib.request.Request(url)
|
|
||||||
req.add_header('authorization', str(garth_client.client.oauth2_token))
|
|
||||||
req.add_header('User-Agent', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/54.0.2816.0 Safari/537.36')
|
|
||||||
req.add_header('nk', 'NT')
|
|
||||||
|
|
||||||
try:
|
|
||||||
logging.info(f"Downloading activity {activity_id} to {filename}")
|
|
||||||
response = urllib.request.urlopen(req)
|
|
||||||
file_path = data_dir / filename
|
|
||||||
with open(file_path, 'wb') as f:
|
|
||||||
f.write(response.read())
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"Error downloading activity {activity_id}: {str(e)}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def download_missing_activities(garth_client):
|
|
||||||
"""Download all activities that are not yet downloaded"""
|
|
||||||
db_path = "garmin.db"
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Get all missing activities
|
|
||||||
cursor.execute("SELECT activity_id, filename FROM Activity WHERE downloaded = 0")
|
|
||||||
missing_activities = cursor.fetchall()
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
if not missing_activities:
|
|
||||||
print("No missing activities to download")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"Found {len(missing_activities)} missing activities to download:")
|
|
||||||
for activity_id, filename in missing_activities:
|
|
||||||
print(f"Downloading {filename}...")
|
|
||||||
success = download_activity(garth_client, activity_id, filename)
|
|
||||||
if success:
|
|
||||||
# Update database
|
|
||||||
conn = sqlite3.connect(db_path)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("UPDATE Activity SET downloaded = 1 WHERE activity_id = ?", (activity_id,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
time.sleep(2) # Rate limiting
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_activities(where_clause=None):
|
|
||||||
"""Retrieve activities from database based on filter"""
|
|
||||||
conn = sqlite3.connect("garmin.db")
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
base_query = "SELECT activity_id, start_time, filename, downloaded FROM Activity"
|
|
||||||
if where_clause:
|
|
||||||
base_query += f" WHERE {where_clause}"
|
|
||||||
|
|
||||||
cursor.execute(base_query)
|
|
||||||
results = cursor.fetchall()
|
|
||||||
conn.close()
|
|
||||||
return results
|
|
||||||
|
|
||||||
def main():
|
|
||||||
create_schema()
|
|
||||||
parser = argparse.ArgumentParser(description="GarminSync CLI")
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
# Auth test command (Phase 1)
|
|
||||||
auth_parser = subparsers.add_parser("auth-test", help="Test Garmin authentication")
|
|
||||||
|
|
||||||
# List command (Phase 2)
|
|
||||||
list_parser = subparsers.add_parser("list", help="List activities")
|
|
||||||
list_group = list_parser.add_mutually_exclusive_group(required=True)
|
|
||||||
list_group.add_argument("--all", action="store_true", help="List all activities")
|
|
||||||
list_group.add_argument("--missing", action="store_true", help="List missing activities")
|
|
||||||
list_group.add_argument("--downloaded", action="store_true", help="List downloaded activities")
|
|
||||||
|
|
||||||
# Sync command (Phase 3)
|
|
||||||
sync_parser = subparsers.add_parser("sync", help="Sync activities and download missing FIT files")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.command == "auth-test":
|
|
||||||
try:
|
|
||||||
email, _ = initialize_garmin_client()
|
|
||||||
print(f"✓ Successfully authenticated as {email}")
|
|
||||||
print("Container is ready for Phase 2 development")
|
|
||||||
exit(0)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"✗ Authentication failed: {str(e)}")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
elif args.command == "list":
|
|
||||||
try:
|
|
||||||
client, email = initialize_garmin_client()
|
|
||||||
print("Syncing activities with Garmin Connect...")
|
|
||||||
sync_with_garmin(client, email)
|
|
||||||
|
|
||||||
where_clause = None
|
|
||||||
if args.missing:
|
|
||||||
where_clause = "downloaded = 0"
|
|
||||||
elif args.downloaded:
|
|
||||||
where_clause = "downloaded = 1"
|
|
||||||
|
|
||||||
activities = get_activities(where_clause)
|
|
||||||
print(f"\nFound {len(activities)} activities:")
|
|
||||||
print(f"{'ID':<10} | {'Start Time':<20} | {'Status':<10} | Filename")
|
|
||||||
print("-" * 80)
|
|
||||||
for activity in activities:
|
|
||||||
status = "✓" if activity[3] else "✗"
|
|
||||||
print(f"{activity[0]:<10} | {activity[1][:19]:<20} | {status:<10} | {activity[2]}")
|
|
||||||
|
|
||||||
exit(0)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Operation failed: {str(e)}")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
elif args.command == "sync":
|
|
||||||
try:
|
|
||||||
client, email = initialize_garmin_client()
|
|
||||||
print("Syncing activities with Garmin Connect...")
|
|
||||||
sync_with_garmin(client, email)
|
|
||||||
|
|
||||||
print("Downloading missing FIT files...")
|
|
||||||
success = download_missing_activities(client)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print("All missing activities downloaded successfully")
|
|
||||||
exit(0)
|
|
||||||
else:
|
|
||||||
print("Some activities failed to download")
|
|
||||||
exit(1)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Operation failed: {str(e)}")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
garmin-connect-export==4.6.0
|
|
||||||
python-dotenv==1.0.1
|
|
||||||
garth>=0.5.0,<0.6.0
|
|
||||||
Reference in New Issue
Block a user