first set of files

This commit is contained in:
2025-08-24 08:04:35 -07:00
commit c550f7d0df
9 changed files with 1371 additions and 0 deletions

11
go.mod Normal file
View File

@@ -0,0 +1,11 @@
// go.mod - Keep dependencies minimal
module garminsync
go 1.21
require (
github.com/mattn/go-sqlite3 v1.14.17
github.com/robfig/cron/v3 v3.0.1
github.com/gorilla/mux v1.8.0 // For HTTP routing
golang.org/x/net v0.12.0 // For HTTP client
)

View File

@@ -0,0 +1,74 @@
// internal/database/models.go
package database
import (
"database/sql"
"time"
)
type Activity struct {
ID int `json:"id"`
ActivityID int `json:"activity_id"`
StartTime time.Time `json:"start_time"`
ActivityType string `json:"activity_type"`
Duration int `json:"duration"` // seconds
Distance float64 `json:"distance"` // meters
MaxHeartRate int `json:"max_heart_rate"`
AvgHeartRate int `json:"avg_heart_rate"`
AvgPower float64 `json:"avg_power"`
Calories int `json:"calories"`
Filename string `json:"filename"`
FileType string `json:"file_type"`
FileSize int64 `json:"file_size"`
Downloaded bool `json:"downloaded"`
CreatedAt time.Time `json:"created_at"`
LastSync time.Time `json:"last_sync"`
}
type Stats struct {
Total int `json:"total"`
Downloaded int `json:"downloaded"`
Missing int `json:"missing"`
}
type DaemonConfig struct {
ID int `json:"id"`
Enabled bool `json:"enabled"`
ScheduleCron string `json:"schedule_cron"`
LastRun string `json:"last_run"`
Status string `json:"status"`
}
// Database interface
type Database interface {
// Activities
GetActivities(limit, offset int) ([]Activity, error)
GetActivity(activityID int) (*Activity, error)
CreateActivity(activity *Activity) error
UpdateActivity(activity *Activity) error
DeleteActivity(activityID int) error
// Stats
GetStats() (*Stats, error)
// Search and filter
FilterActivities(filters ActivityFilters) ([]Activity, error)
// Close connection
Close() error
}
type ActivityFilters struct {
ActivityType string
DateFrom *time.Time
DateTo *time.Time
MinDistance float64
MaxDistance float64
MinDuration int
MaxDuration int
Downloaded *bool
Limit int
Offset int
SortBy string
SortOrder string
}

282
internal/database/sqlite.go Normal file
View File

@@ -0,0 +1,282 @@
// internal/database/sqlite.go
package database
import (
"database/sql"
"fmt"
"strings"
"time"
)
type SQLiteDB struct {
db *sql.DB
}
func NewSQLiteDB(dbPath string) (*SQLiteDB, error) {
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on")
if err != nil {
return nil, err
}
sqlite := &SQLiteDB{db: db}
// Create tables
if err := sqlite.createTables(); err != nil {
return nil, err
}
return sqlite, nil
}
func (s *SQLiteDB) createTables() error {
schema := `
CREATE TABLE IF NOT EXISTS activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activity_id INTEGER UNIQUE NOT NULL,
start_time DATETIME NOT NULL,
activity_type TEXT,
duration INTEGER,
distance REAL,
max_heart_rate INTEGER,
avg_heart_rate INTEGER,
avg_power REAL,
calories INTEGER,
filename TEXT UNIQUE,
file_type TEXT,
file_size INTEGER,
downloaded BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_sync DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_activities_activity_id ON activities(activity_id);
CREATE INDEX IF NOT EXISTS idx_activities_start_time ON activities(start_time);
CREATE INDEX IF NOT EXISTS idx_activities_activity_type ON activities(activity_type);
CREATE INDEX IF NOT EXISTS idx_activities_downloaded ON activities(downloaded);
CREATE TABLE IF NOT EXISTS daemon_config (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN DEFAULT TRUE,
schedule_cron TEXT DEFAULT '0 * * * *',
last_run TEXT,
status TEXT DEFAULT 'stopped',
CONSTRAINT single_config CHECK (id = 1)
);
INSERT OR IGNORE INTO daemon_config (id) VALUES (1);
`
_, err := s.db.Exec(schema)
return err
}
func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) {
query := `
SELECT id, activity_id, start_time, activity_type, duration, distance,
max_heart_rate, avg_heart_rate, avg_power, calories, filename,
file_type, file_size, downloaded, created_at, last_sync
FROM activities
ORDER BY start_time DESC
LIMIT ? OFFSET ?`
rows, err := s.db.Query(query, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var activities []Activity
for rows.Next() {
var a Activity
var startTime, createdAt, lastSync string
err := rows.Scan(
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
&a.AvgPower, &a.Calories, &a.Filename, &a.FileType,
&a.FileSize, &a.Downloaded, &createdAt, &lastSync,
)
if err != nil {
return nil, err
}
// Parse time strings
if a.StartTime, err = time.Parse("2006-01-02 15:04:05", startTime); err != nil {
return nil, err
}
if a.CreatedAt, err = time.Parse("2006-01-02 15:04:05", createdAt); err != nil {
return nil, err
}
if a.LastSync, err = time.Parse("2006-01-02 15:04:05", lastSync); err != nil {
return nil, err
}
activities = append(activities, a)
}
return activities, nil
}
func (s *SQLiteDB) CreateActivity(activity *Activity) error {
query := `
INSERT INTO activities (
activity_id, start_time, activity_type, duration, distance,
max_heart_rate, avg_heart_rate, avg_power, calories,
filename, file_type, file_size, downloaded
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := s.db.Exec(query,
activity.ActivityID, activity.StartTime.Format("2006-01-02 15:04:05"),
activity.ActivityType, activity.Duration, activity.Distance,
activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower,
activity.Calories, activity.Filename, activity.FileType,
activity.FileSize, activity.Downloaded,
)
return err
}
func (s *SQLiteDB) UpdateActivity(activity *Activity) error {
query := `
UPDATE activities SET
activity_type = ?, duration = ?, distance = ?,
max_heart_rate = ?, avg_heart_rate = ?, avg_power = ?,
calories = ?, filename = ?, file_type = ?, file_size = ?,
downloaded = ?, last_sync = CURRENT_TIMESTAMP
WHERE activity_id = ?`
_, err := s.db.Exec(query,
activity.ActivityType, activity.Duration, activity.Distance,
activity.MaxHeartRate, activity.AvgHeartRate, activity.AvgPower,
activity.Calories, activity.Filename, activity.FileType,
activity.FileSize, activity.Downloaded, activity.ActivityID,
)
return err
}
func (s *SQLiteDB) GetStats() (*Stats, error) {
stats := &Stats{}
// Get total count
err := s.db.QueryRow("SELECT COUNT(*) FROM activities").Scan(&stats.Total)
if err != nil {
return nil, err
}
// Get downloaded count
err = s.db.QueryRow("SELECT COUNT(*) FROM activities WHERE downloaded = TRUE").Scan(&stats.Downloaded)
if err != nil {
return nil, err
}
stats.Missing = stats.Total - stats.Downloaded
return stats, nil
}
func (s *SQLiteDB) FilterActivities(filters ActivityFilters) ([]Activity, error) {
query := `
SELECT id, activity_id, start_time, activity_type, duration, distance,
max_heart_rate, avg_heart_rate, avg_power, calories, filename,
file_type, file_size, downloaded, created_at, last_sync
FROM activities WHERE 1=1`
var args []interface{}
var conditions []string
// Build WHERE conditions
if filters.ActivityType != "" {
conditions = append(conditions, "activity_type = ?")
args = append(args, filters.ActivityType)
}
if filters.DateFrom != nil {
conditions = append(conditions, "start_time >= ?")
args = append(args, filters.DateFrom.Format("2006-01-02 15:04:05"))
}
if filters.DateTo != nil {
conditions = append(conditions, "start_time <= ?")
args = append(args, filters.DateTo.Format("2006-01-02 15:04:05"))
}
if filters.MinDistance > 0 {
conditions = append(conditions, "distance >= ?")
args = append(args, filters.MinDistance)
}
if filters.MaxDistance > 0 {
conditions = append(conditions, "distance <= ?")
args = append(args, filters.MaxDistance)
}
if filters.Downloaded != nil {
conditions = append(conditions, "downloaded = ?")
args = append(args, *filters.Downloaded)
}
// Add conditions to query
if len(conditions) > 0 {
query += " AND " + strings.Join(conditions, " AND ")
}
// Add sorting
orderBy := "start_time"
if filters.SortBy != "" {
orderBy = filters.SortBy
}
order := "DESC"
if filters.SortOrder == "asc" {
order = "ASC"
}
query += fmt.Sprintf(" ORDER BY %s %s", orderBy, order)
// Add pagination
if filters.Limit > 0 {
query += " LIMIT ?"
args = append(args, filters.Limit)
if filters.Offset > 0 {
query += " OFFSET ?"
args = append(args, filters.Offset)
}
}
rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var activities []Activity
for rows.Next() {
var a Activity
var startTime, createdAt, lastSync string
err := rows.Scan(
&a.ID, &a.ActivityID, &startTime, &a.ActivityType,
&a.Duration, &a.Distance, &a.MaxHeartRate, &a.AvgHeartRate,
&a.AvgPower, &a.Calories, &a.Filename, &a.FileType,
&a.FileSize, &a.Downloaded, &createdAt, &lastSync,
)
if err != nil {
return nil, err
}
// Parse times
a.StartTime, _ = time.Parse("2006-01-02 15:04:05", startTime)
a.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
a.LastSync, _ = time.Parse("2006-01-02 15:04:05", lastSync)
activities = append(activities, a)
}
return activities, nil
}
func (s *SQLiteDB) Close() error {
return s.db.Close()
}

254
internal/garmin/client.go Normal file
View File

@@ -0,0 +1,254 @@
// internal/garmin/client.go
package garmin
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Client struct {
httpClient *http.Client
baseURL string
session *Session
}
type Session struct {
Username string
Password string
Cookies []*http.Cookie
UserAgent string
Authenticated bool
}
type GarminActivity struct {
ActivityID int `json:"activityId"`
ActivityName string `json:"activityName"`
StartTimeLocal string `json:"startTimeLocal"`
ActivityType map[string]interface{} `json:"activityType"`
Distance float64 `json:"distance"`
Duration float64 `json:"duration"`
MaxHR int `json:"maxHR"`
AvgHR int `json:"avgHR"`
AvgPower float64 `json:"avgPower"`
Calories int `json:"calories"`
}
func NewClient() *Client {
return &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: "https://connect.garmin.com",
session: &Session{
Username: os.Getenv("GARMIN_EMAIL"),
Password: os.Getenv("GARMIN_PASSWORD"),
UserAgent: "GarminSync/1.0",
},
}
}
func (c *Client) Login() error {
if c.session.Username == "" || c.session.Password == "" {
return fmt.Errorf("GARMIN_EMAIL and GARMIN_PASSWORD environment variables required")
}
// Step 1: Get login form
loginURL := c.baseURL + "/signin"
req, err := http.NewRequest("GET", loginURL, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", c.session.UserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Extract cookies
c.session.Cookies = resp.Cookies()
// Step 2: Submit login credentials
loginData := url.Values{}
loginData.Set("username", c.session.Username)
loginData.Set("password", c.session.Password)
loginData.Set("embed", "true")
req, err = http.NewRequest("POST", loginURL, strings.NewReader(loginData.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", c.session.UserAgent)
// Add cookies
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
}
resp, err = c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check if login was successful
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("login failed with status: %d", resp.StatusCode)
}
// Update cookies
for _, cookie := range resp.Cookies() {
c.session.Cookies = append(c.session.Cookies, cookie)
}
c.session.Authenticated = true
return nil
}
func (c *Client) GetActivities(start, limit int) ([]GarminActivity, error) {
if !c.session.Authenticated {
if err := c.Login(); err != nil {
return nil, err
}
}
url := fmt.Sprintf("%s/modern/proxy/activitylist-service/activities/search/activities?start=%d&limit=%d",
c.baseURL, start, limit)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.session.UserAgent)
req.Header.Set("Accept", "application/json")
// Add cookies
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get activities: status %d", resp.StatusCode)
}
var activities []GarminActivity
if err := json.NewDecoder(resp.Body).Decode(&activities); err != nil {
return nil, err
}
// Rate limiting
time.Sleep(2 * time.Second)
return activities, nil
}
func (c *Client) DownloadActivity(activityID int, format string) ([]byte, error) {
if !c.session.Authenticated {
if err := c.Login(); err != nil {
return nil, err
}
}
// Default to FIT format
if format == "" {
format = "fit"
}
url := fmt.Sprintf("%s/modern/proxy/download-service/export/%s/activity/%d",
c.baseURL, format, activityID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.session.UserAgent)
// Add cookies
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to download activity %d: status %d", activityID, resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Rate limiting
time.Sleep(2 * time.Second)
return data, nil
}
func (c *Client) GetActivityDetails(activityID int) (*GarminActivity, error) {
if !c.session.Authenticated {
if err := c.Login(); err != nil {
return nil, err
}
}
url := fmt.Sprintf("%s/modern/proxy/activity-service/activity/%d",
c.baseURL, activityID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", c.session.UserAgent)
req.Header.Set("Accept", "application/json")
// Add cookies
for _, cookie := range c.session.Cookies {
req.AddCookie(cookie)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get activity details: status %d", resp.StatusCode)
}
var activity GarminActivity
if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
return nil, err
}
// Rate limiting
time.Sleep(2 * time.Second)
return &activity, nil
}

323
internal/parser/activity.go Normal file
View File

@@ -0,0 +1,323 @@
// internal/parser/activity.go
package parser
import (
"encoding/xml"
"fmt"
"math"
"os"
"time"
)
type ActivityMetrics struct {
ActivityType string
Duration int // seconds
Distance float64 // meters
MaxHR int
AvgHR int
AvgPower float64
Calories int
StartTime time.Time
}
type Parser interface {
ParseFile(filepath string) (*ActivityMetrics, error)
}
func NewParser(fileType FileType) Parser {
switch fileType {
case FileTypeFIT:
return &FITParser{}
case FileTypeTCX:
return &TCXParser{}
case FileTypeGPX:
return &GPXParser{}
default:
return nil
}
}
// TCX Parser Implementation
type TCXParser struct{}
type TCXTrainingCenterDatabase struct {
Activities TCXActivities `xml:"Activities"`
}
type TCXActivities struct {
Activity []TCXActivity `xml:"Activity"`
}
type TCXActivity struct {
Sport string `xml:"Sport,attr"`
Laps []TCXLap `xml:"Lap"`
}
type TCXLap struct {
StartTime string `xml:"StartTime,attr"`
TotalTimeSeconds float64 `xml:"TotalTimeSeconds"`
DistanceMeters float64 `xml:"DistanceMeters"`
Calories int `xml:"Calories"`
MaximumSpeed float64 `xml:"MaximumSpeed"`
AverageHeartRate TCXHeartRate `xml:"AverageHeartRateBpm"`
MaximumHeartRate TCXHeartRate `xml:"MaximumHeartRateBpm"`
Track TCXTrack `xml:"Track"`
}
type TCXHeartRate struct {
Value int `xml:"Value"`
}
type TCXTrack struct {
Trackpoints []TCXTrackpoint `xml:"Trackpoint"`
}
type TCXTrackpoint struct {
Time string `xml:"Time"`
HeartRateBpm TCXHeartRate `xml:"HeartRateBpm"`
}
func (p *TCXParser) ParseFile(filepath string) (*ActivityMetrics, error) {
file, err := os.Open(filepath)
if err != nil {
return nil, err
}
defer file.Close()
var tcx TCXTrainingCenterDatabase
decoder := xml.NewDecoder(file)
if err := decoder.Decode(&tcx); err != nil {
return nil, err
}
if len(tcx.Activities.Activity) == 0 || len(tcx.Activities.Activity[0].Laps) == 0 {
return nil, fmt.Errorf("no activity data found")
}
activity := tcx.Activities.Activity[0]
firstLap := activity.Laps[0]
metrics := &ActivityMetrics{
ActivityType: mapTCXSportType(activity.Sport),
}
// Parse start time
if startTime, err := time.Parse(time.RFC3339, firstLap.StartTime); err == nil {
metrics.StartTime = startTime
}
// Aggregate data from all laps
var totalDuration, totalDistance float64
var maxHR, totalCalories int
var hrValues []int
for _, lap := range activity.Laps {
totalDuration += lap.TotalTimeSeconds
totalDistance += lap.DistanceMeters
totalCalories += lap.Calories
if lap.MaximumHeartRate.Value > maxHR {
maxHR = lap.MaximumHeartRate.Value
}
if lap.AverageHeartRate.Value > 0 {
hrValues = append(hrValues, lap.AverageHeartRate.Value)
}
// Collect HR data from trackpoints
for _, tp := range lap.Track.Trackpoints {
if tp.HeartRateBpm.Value > 0 {
hrValues = append(hrValues, tp.HeartRateBpm.Value)
}
}
}
metrics.Duration = int(totalDuration)
metrics.Distance = totalDistance
metrics.MaxHR = maxHR
metrics.Calories = totalCalories
// Calculate average HR
if len(hrValues) > 0 {
sum := 0
for _, hr := range hrValues {
sum += hr
}
metrics.AvgHR = sum / len(hrValues)
}
return metrics, nil
}
func mapTCXSportType(sport string) string {
switch sport {
case "Running":
return "running"
case "Biking":
return "cycling"
case "Swimming":
return "swimming"
default:
return "other"
}
}
// GPX Parser Implementation
type GPXParser struct{}
type GPX struct {
Tracks []GPXTrack `xml:"trk"`
}
type GPXTrack struct {
Name string `xml:"name"`
Segments []GPXSegment `xml:"trkseg"`
}
type GPXSegment struct {
Points []GPXPoint `xml:"trkpt"`
}
type GPXPoint struct {
Lat float64 `xml:"lat,attr"`
Lon float64 `xml:"lon,attr"`
Elevation float64 `xml:"ele"`
Time string `xml:"time"`
HR int `xml:"extensions>TrackPointExtension>hr"`
}
func (p *GPXParser) ParseFile(filepath string) (*ActivityMetrics, error) {
file, err := os.Open(filepath)
if err != nil {
return nil, err
}
defer file.Close()
var gpx GPX
decoder := xml.NewDecoder(file)
if err := decoder.Decode(&gpx); err != nil {
return nil, err
}
if len(gpx.Tracks) == 0 || len(gpx.Tracks[0].Segments) == 0 {
return nil, fmt.Errorf("no track data found")
}
metrics := &ActivityMetrics{
ActivityType: "other", // GPX doesn't specify activity type
}
var allPoints []GPXPoint
for _, track := range gpx.Tracks {
for _, segment := range track.Segments {
allPoints = append(allPoints, segment.Points...)
}
}
if len(allPoints) == 0 {
return nil, fmt.Errorf("no track points found")
}
// Calculate metrics from points
var startTime, endTime time.Time
var totalDistance float64
var hrValues []int
for i, point := range allPoints {
// Parse time
if point.Time != "" {
if t, err := time.Parse(time.RFC3339, point.Time); err == nil {
if i == 0 {
startTime = t
metrics.StartTime = t
}
endTime = t
}
}
// Calculate distance between consecutive points
if i > 0 {
prevPoint := allPoints[i-1]
distance := calculateDistance(prevPoint.Lat, prevPoint.Lon, point.Lat, point.Lon)
totalDistance += distance
}
// Collect heart rate data
if point.HR > 0 {
hrValues = append(hrValues, point.HR)
}
}
// Calculate duration
if !startTime.IsZero() && !endTime.IsZero() {
metrics.Duration = int(endTime.Sub(startTime).Seconds())
}
metrics.Distance = totalDistance
// Calculate heart rate metrics
if len(hrValues) > 0 {
sum := 0
maxHR := 0
for _, hr := range hrValues {
sum += hr
if hr > maxHR {
maxHR = hr
}
}
metrics.AvgHR = sum / len(hrValues)
metrics.MaxHR = maxHR
}
return metrics, nil
}
// Haversine formula for distance calculation
func calculateDistance(lat1, lon1, lat2, lon2 float64) float64 {
const earthRadius = 6371000 // Earth's radius in meters
dLat := (lat2 - lat1) * math.Pi / 180
dLon := (lon2 - lon1) * math.Pi / 180
lat1Rad := lat1 * math.Pi / 180
lat2Rad := lat2 * math.Pi / 180
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1Rad)*math.Cos(lat2Rad)*math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return earthRadius * c
}
// FIT Parser Implementation (simplified - would use FIT SDK in real implementation)
type FITParser struct{}
func (p *FITParser) ParseFile(filepath string) (*ActivityMetrics, error) {
// For now, return basic metrics - in real implementation, would use FIT SDK
// This is a placeholder that reads basic file info
file, err := os.Open(filepath)
if err != nil {
return nil, err
}
defer file.Close()
// Read FIT header to verify it's a valid FIT file
header := make([]byte, 14)
_, err = file.Read(header)
if err != nil {
return nil, err
}
// Verify FIT signature
if !bytes.Equal(header[8:12], []byte(".FIT")) {
return nil, fmt.Errorf("invalid FIT file signature")
}
// For now, return empty metrics - real implementation would parse FIT records
return &ActivityMetrics{
ActivityType: "other",
// Additional parsing would happen here using FIT SDK
}, nil
}

View File

@@ -0,0 +1,55 @@
// internal/parser/detector.go
package parser
import (
"bytes"
"os"
)
type FileType string
const (
FileTypeFIT FileType = "fit"
FileTypeTCX FileType = "tcx"
FileTypeGPX FileType = "gpx"
FileTypeUnknown FileType = "unknown"
)
func DetectFileType(filepath string) (FileType, error) {
file, err := os.Open(filepath)
if err != nil {
return FileTypeUnknown, err
}
defer file.Close()
// Read first 512 bytes for detection
header := make([]byte, 512)
n, err := file.Read(header)
if err != nil && n == 0 {
return FileTypeUnknown, err
}
header = header[:n]
return DetectFileTypeFromData(header), nil
}
func DetectFileTypeFromData(data []byte) FileType {
// Check for FIT file signature
if len(data) >= 8 && bytes.Equal(data[8:12], []byte(".FIT")) {
return FileTypeFIT
}
// Check for XML-based formats
if bytes.HasPrefix(data, []byte("<?xml")) {
if bytes.Contains(data[:200], []byte("<gpx")) ||
bytes.Contains(data[:200], []byte("topografix.com/GPX")) {
return FileTypeGPX
}
if bytes.Contains(data[:500], []byte("TrainingCenterDatabase")) {
return FileTypeTCX
}
}
return FileTypeUnknown
}

239
internal/web/routes.go Normal file
View File

@@ -0,0 +1,239 @@
// internal/web/routes.go
package web
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gorilla/mux"
"garminsync/internal/database"
)
type Server struct {
db database.Database
router *mux.Router
}
func NewServer(db database.Database) *Server {
s := &Server{
db: db,
router: mux.NewRouter(),
}
s.setupRoutes()
return s
}
func (s *Server) setupRoutes() {
// Static files (embedded)
s.router.HandleFunc("/", s.handleHome).Methods("GET")
s.router.HandleFunc("/health", s.handleHealth).Methods("GET")
// API routes
api := s.router.PathPrefix("/api").Subrouter()
// Activities
api.HandleFunc("/activities", s.handleGetActivities).Methods("GET")
api.HandleFunc("/activities/{id:[0-9]+}", s.handleGetActivity).Methods("GET")
api.HandleFunc("/activities/search", s.handleSearchActivities).Methods("GET")
// Stats
api.HandleFunc("/stats", s.handleGetStats).Methods("GET")
api.HandleFunc("/stats/summary", s.handleGetStatsSummary).Methods("GET")
// Sync operations
api.HandleFunc("/sync", s.handleTriggerSync).Methods("POST")
api.HandleFunc("/sync/status", s.handleGetSyncStatus).Methods("GET")
// Configuration
api.HandleFunc("/config", s.handleGetConfig).Methods("GET")
api.HandleFunc("/config", s.handleUpdateConfig).Methods("POST")
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
// Serve embedded HTML
html := getEmbeddedHTML()
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.writeJSON(w, map[string]string{
"status": "healthy",
"service": "GarminSync",
"timestamp": time.Now().Format(time.RFC3339),
})
}
func (s *Server) handleGetActivities(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
query := r.URL.Query()
limit, _ := strconv.Atoi(query.Get("limit"))
if limit <= 0 || limit > 100 {
limit = 50
}
offset, _ := strconv.Atoi(query.Get("offset"))
if offset < 0 {
offset = 0
}
// Build filters
filters := database.ActivityFilters{
Limit: limit,
Offset: offset,
}
if activityType := query.Get("activity_type"); activityType != "" {
filters.ActivityType = activityType
}
if dateFrom := query.Get("date_from"); dateFrom != "" {
if t, err := time.Parse("2006-01-02", dateFrom); err == nil {
filters.DateFrom = &t
}
}
if dateTo := query.Get("date_to"); dateTo != "" {
if t, err := time.Parse("2006-01-02", dateTo); err == nil {
filters.DateTo = &t
}
}
if minDistance := query.Get("min_distance"); minDistance != "" {
if d, err := strconv.ParseFloat(minDistance, 64); err == nil {
filters.MinDistance = d * 1000 // Convert km to meters
}
}
if sortBy := query.Get("sort_by"); sortBy != "" {
filters.SortBy = sortBy
}
if sortOrder := query.Get("sort_order"); sortOrder != "" {
filters.SortOrder = sortOrder
}
// Get activities
activities, err := s.db.FilterActivities(filters)
if err != nil {
s.writeError(w, "Failed to get activities", http.StatusInternalServerError)
return
}
// Convert to API response format
response := map[string]interface{}{
"activities": convertActivitiesToAPI(activities),
"limit": limit,
"offset": offset,
}
s.writeJSON(w, response)
}
func (s *Server) handleGetActivity(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
activityID, err := strconv.Atoi(vars["id"])
if err != nil {
s.writeError(w, "Invalid activity ID", http.StatusBadRequest)
return
}
activity, err := s.db.GetActivity(activityID)
if err != nil {
s.writeError(w, "Activity not found", http.StatusNotFound)
return
}
s.writeJSON(w, convertActivityToAPI(*activity))
}
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.db.GetStats()
if err != nil {
s.writeError(w, "Failed to get statistics", http.StatusInternalServerError)
return
}
s.writeJSON(w, stats)
}
func (s *Server) handleTriggerSync(w http.ResponseWriter, r *http.Request) {
// This would trigger the sync operation
// For now, return success
s.writeJSON(w, map[string]string{
"status": "sync_started",
"message": "Sync operation started in background",
})
}
// Utility functions
func (s *Server) writeJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func (s *Server) writeError(w http.ResponseWriter, message string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{
"error": message,
})
}
func convertActivitiesToAPI(activities []database.Activity) []map[string]interface{} {
result := make([]map[string]interface{}, len(activities))
for i, activity := range activities {
result[i] = convertActivityToAPI(activity)
}
return result
}
func convertActivityToAPI(activity database.Activity) map[string]interface{} {
return map[string]interface{}{
"id": activity.ID,
"activity_id": activity.ActivityID,
"start_time": activity.StartTime.Format("2006-01-02T15:04:05Z"),
"activity_type": activity.ActivityType,
"duration": activity.Duration,
"duration_formatted": formatDuration(activity.Duration),
"distance": activity.Distance,
"distance_km": roundFloat(activity.Distance/1000, 2),
"max_heart_rate": activity.MaxHeartRate,
"avg_heart_rate": activity.AvgHeartRate,
"avg_power": activity.AvgPower,
"calories": activity.Calories,
"file_type": activity.FileType,
"downloaded": activity.Downloaded,
"created_at": activity.CreatedAt.Format("2006-01-02T15:04:05Z"),
"last_sync": activity.LastSync.Format("2006-01-02T15:04:05Z"),
}
}
func formatDuration(seconds int) string {
if seconds <= 0 {
return "-"
}
hours := seconds / 3600
minutes := (seconds % 3600) / 60
secs := seconds % 60
if hours > 0 {
return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs)
}
return fmt.Sprintf("%d:%02d", minutes, secs)
}
func roundFloat(val float64, precision int) float64 {
ratio := math.Pow(10, float64(precision))
return math.Round(val*ratio) / ratio
}

View File

@@ -0,0 +1 @@

132
main.go Normal file
View File

@@ -0,0 +1,132 @@
// main.go - Entry point and dependency injection
package main
import (
"context"
"database/sql"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/robfig/cron/v3"
)
type App struct {
db *sql.DB
cron *cron.Cron
server *http.Server
garmin *GarminClient
shutdown chan os.Signal
}
func main() {
app := &App{
shutdown: make(chan os.Signal, 1),
}
// Initialize components
if err := app.init(); err != nil {
log.Fatal("Failed to initialize app:", err)
}
// Start services
app.start()
// Wait for shutdown signal
signal.Notify(app.shutdown, os.Interrupt, syscall.SIGTERM)
<-app.shutdown
// Graceful shutdown
app.stop()
}
func (app *App) init() error {
var err error
// Initialize database
app.db, err = initDatabase()
if err != nil {
return err
}
// Initialize Garmin client
app.garmin = NewGarminClient()
// Setup cron scheduler
app.cron = cron.New()
// Setup HTTP server
app.server = &http.Server{
Addr: ":8888",
Handler: app.setupRoutes(),
}
return nil
}
func (app *App) start() {
// Start cron scheduler
app.cron.AddFunc("@hourly", func() {
log.Println("Starting scheduled sync...")
app.syncActivities()
})
app.cron.Start()
// Start web server
go func() {
log.Println("Server starting on http://localhost:8888")
if err := app.server.ListenAndServe(); err != http.ErrServerClosed {
log.Printf("Server error: %v", err)
}
}()
}
func (app *App) stop() {
log.Println("Shutting down...")
// Stop cron
app.cron.Stop()
// Stop web server
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := app.server.Shutdown(ctx); err != nil {
log.Printf("Server shutdown error: %v", err)
}
// Close database
if app.db != nil {
app.db.Close()
}
log.Println("Shutdown complete")
}
// main.go - Database initialization
func initDatabase() (*sql.DB, error) {
// Get data directory from environment or use default
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "./data"
}
// Create data directory if it doesn't exist
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory: %v", err)
}
dbPath := filepath.Join(dataDir, "garmin.db")
// Initialize SQLite database
db, err := database.NewSQLiteDB(dbPath)
if err != nil {
return nil, fmt.Errorf("failed to initialize database: %v", err)
}
return db.db, nil // Return the underlying *sql.DB
}