This commit is contained in:
2025-08-07 18:52:21 -07:00
parent f41316c8cf
commit 3dc3ec5c5c
15 changed files with 826 additions and 295 deletions

64
internal/config/config.go Normal file
View 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
View 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
View 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
}

View 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
View 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
}