checkpoint 1

This commit is contained in:
2025-08-24 18:16:04 -07:00
parent c550f7d0df
commit 91493446b7
20 changed files with 4439 additions and 758 deletions

View File

@@ -1,323 +1,35 @@
// internal/parser/activity.go
package parser
import (
"encoding/xml"
"fmt"
"math"
"os"
"time"
)
import "time"
// ActivityMetrics contains all metrics extracted from activity files
type ActivityMetrics struct {
ActivityType string
Duration int // seconds
Distance float64 // meters
MaxHR int
AvgHR int
AvgPower float64
Calories int
StartTime time.Time
ActivityType string
StartTime time.Time
Duration time.Duration
Distance float64 // in meters
MaxHeartRate int
AvgHeartRate int
AvgPower int
Calories int
Steps int
ElevationGain float64 // in meters
ElevationLoss float64 // in meters
MinTemperature float64 // in °C
MaxTemperature float64 // in °C
AvgTemperature float64 // in °C
}
// Parser defines the interface for activity file parsers
type Parser interface {
ParseFile(filepath string) (*ActivityMetrics, error)
ParseFile(filename 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
}
}
// FileType represents supported file formats
type FileType string
// 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
}
const (
FIT FileType = "fit"
TCX FileType = "tcx"
GPX FileType = "gpx"
)

View File

@@ -1,55 +1,31 @@
// internal/parser/detector.go
package parser
import (
"bytes"
"os"
"bytes"
"errors"
)
type FileType string
const (
FileTypeFIT FileType = "fit"
FileTypeTCX FileType = "tcx"
FileTypeGPX FileType = "gpx"
FileTypeUnknown FileType = "unknown"
var (
// FIT file signature
fitSignature = []byte{0x0E, 0x10} // .FIT files start with 0x0E 0x10
)
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
}
// DetectFileType detects the file type based on its content
func DetectFileType(data []byte) (string, error) {
// Check FIT file signature
if len(data) >= 2 && bytes.Equal(data[:2], fitSignature) {
return ".fit", 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
// Check TCX file signature (XML with TrainingCenterDatabase root)
if bytes.Contains(data, []byte("<TrainingCenterDatabase")) {
return ".tcx", nil
}
// Check GPX file signature (XML with <gpx> root)
if bytes.Contains(data, []byte("<gpx")) {
return ".gpx", nil
}
return "", errors.New("unrecognized file format")
}

View File

@@ -0,0 +1,57 @@
package parser
import (
"fmt"
"path/filepath"
)
// NewParser creates a parser based on file extension or content
func NewParser(filename string) (Parser, error) {
// First try by extension
ext := filepath.Ext(filename)
switch ext {
case ".fit":
return NewFITParser(), nil
case ".tcx":
return NewTCXParser(), nil // To be implemented
case ".gpx":
return NewGPXParser(), nil // To be implemented
}
// If extension doesn't match, detect by content
fileType, err := DetectFileTypeFromFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to detect file type: %w", err)
}
switch fileType {
case FIT:
return NewFITParser(), nil
case TCX:
return NewTCXParser(), nil
case GPX:
return NewGPXParser(), nil
default:
return nil, fmt.Errorf("unsupported file type: %s", fileType)
}
}
// NewParserFromData creates a parser based on file content
func NewParserFromData(data []byte) (Parser, error) {
fileType := DetectFileTypeFromData(data)
switch fileType {
case FIT:
return NewFITParser(), nil
case TCX:
return NewTCXParser(), nil
case GPX:
return NewGPXParser(), nil
default:
return nil, fmt.Errorf("unsupported file type: %s", fileType)
}
}
// Placeholder implementations (will create these next)
func NewTCXParser() Parser { return nil }
func NewGPXParser() Parser { return nil }

View File

@@ -0,0 +1,91 @@
package parser
import (
"fmt"
"io"
"os"
"time"
"github.com/tormoder/fit"
)
type FITParser struct{}
func NewFITParser() *FITParser {
return &FITParser{}
}
func (p *FITParser) ParseFile(filename string) (*ActivityMetrics, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
return nil, err
}
return p.ParseData(data)
}
func (p *FITParser) ParseData(data []byte) (*ActivityMetrics, error) {
fitFile, err := fit.Decode(data)
if err != nil {
return nil, fmt.Errorf("failed to decode FIT file: %w", err)
}
activity, err := fitFile.Activity()
if err != nil {
return nil, fmt.Errorf("failed to get activity from FIT: %w", err)
}
if len(activity.Sessions) == 0 {
return nil, fmt.Errorf("no sessions found in FIT file")
}
session := activity.Sessions[0]
metrics := &ActivityMetrics{}
// Basic activity metrics
metrics.StartTime = session.StartTime
metrics.Duration = time.Duration(session.TotalTimerTime) * time.Second
metrics.Distance = session.TotalDistance
// Heart rate
if session.AvgHeartRate != nil {
metrics.AvgHeartRate = int(*session.AvgHeartRate)
}
if session.MaxHeartRate != nil {
metrics.MaxHeartRate = int(*session.MaxHeartRate)
}
// Power
if session.AvgPower != nil {
metrics.AvgPower = int(*session.AvgPower)
}
// Calories
if session.TotalCalories != nil {
metrics.Calories = int(*session.TotalCalories)
}
// Elevation
if session.TotalAscent != nil {
metrics.ElevationGain = *session.TotalAscent
}
if session.TotalDescent != nil {
metrics.ElevationLoss = *session.TotalDescent
}
// Steps
if session.Steps != nil {
metrics.Steps = int(*session.Steps)
}
// Temperature - FIT typically doesn't store temp in session summary
// We'll leave temperature fields as 0 for FIT files
return metrics, nil
}

View File

@@ -0,0 +1,100 @@
package parser
import (
"encoding/xml"
"math"
"time"
"github.com/yourusername/garminsync/internal/parser/activity"
)
// GPX represents the root element of a GPX file
type GPX struct {
XMLName xml.Name `xml:"gpx"`
Trk Trk `xml:"trk"`
}
// Trk represents a track in a GPX file
type Trk struct {
Name string `xml:"name"`
TrkSeg []TrkSeg `xml:"trkseg"`
}
// TrkSeg represents a track segment in a GPX file
type TrkSeg struct {
TrkPt []TrkPt `xml:"trkpt"`
}
// TrkPt represents a track point in a GPX file
type TrkPt struct {
Lat float64 `xml:"lat,attr"`
Lon float64 `xml:"lon,attr"`
Ele float64 `xml:"ele"`
Time string `xml:"time"`
}
// GPXParser implements the Parser interface for GPX files
type GPXParser struct{}
func (p *GPXParser) Parse(data []byte) (*activity.Activity, error) {
var gpx GPX
if err := xml.Unmarshal(data, &gpx); err != nil {
return nil, err
}
if len(gpx.Trk.TrkSeg) == 0 || len(gpx.Trk.TrkSeg[0].TrkPt) == 0 {
return nil, ErrNoTrackData
}
// Process track points
points := gpx.Trk.TrkSeg[0].TrkPt
startTime, _ := time.Parse(time.RFC3339, points[0].Time)
endTime, _ := time.Parse(time.RFC3339, points[len(points)-1].Time)
activity := &activity.Activity{
ActivityType: "hiking",
StartTime: startTime,
Duration: int(endTime.Sub(startTime).Seconds()),
StartLatitude: points[0].Lat,
StartLongitude: points[0].Lon,
}
// Calculate distance and elevation
var totalDistance, elevationGain float64
prev := points[0]
for i := 1; i < len(points); i++ {
curr := points[i]
totalDistance += haversine(prev.Lat, prev.Lon, curr.Lat, curr.Lon)
if curr.Ele > prev.Ele {
elevationGain += curr.Ele - prev.Ele
}
prev = curr
}
activity.Distance = totalDistance
activity.ElevationGain = elevationGain
return activity, nil
}
// haversine calculates the distance between two points on Earth
func haversine(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371000 // Earth radius in meters
φ1 := lat1 * math.Pi / 180
φ2 := lat2 * math.Pi / 180
Δφ := (lat2 - lat1) * math.Pi / 180
Δλ := (lon2 - lon1) * math.Pi / 180
a := math.Sin(Δφ/2)*math.Sin(Δφ/2) +
math.Cos(φ1)*math.Cos(φ2)*
math.Sin(Δλ/2)*math.Sin(Δλ/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
func init() {
RegisterParser(".gpx", &GPXParser{})
}