checkpoint 2

This commit is contained in:
2025-08-25 15:24:23 -07:00
parent 99e69be74a
commit 8fb9028cf8
14 changed files with 299 additions and 533 deletions

View File

@@ -1,23 +0,0 @@
package parser
import (
"time"
"github.com/sstent/garminsync-go/internal/models"
)
// ActivityMetrics is now defined in internal/models
// Parser defines the interface for activity file parsers
type Parser interface {
ParseFile(filename string) (*models.ActivityMetrics, error)
}
// FileType represents supported file formats
type FileType string
const (
FIT FileType = "fit"
TCX FileType = "tcx"
GPX FileType = "gpx"
)

View File

@@ -1,31 +0,0 @@
package parser
import (
"bytes"
"errors"
)
var (
// FIT file signature
fitSignature = []byte{0x0E, 0x10} // .FIT files start with 0x0E 0x10
)
// 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
}
// 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

@@ -1,57 +0,0 @@
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

@@ -1,92 +0,0 @@
package parser
import (
"fmt"
"io"
"os"
"time"
"github.com/tormoder/fit"
"github.com/sstent/garminsync-go/internal/models"
)
type FITParser struct{}
func NewFITParser() *FITParser {
return &FITParser{}
}
func (p *FITParser) ParseFile(filename string) (*models.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) (*models.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 := &models.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

@@ -1,107 +0,0 @@
package parser
import (
"encoding/xml"
"math"
"time"
"github.com/sstent/garminsync-go/internal/models"
"os"
)
// 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) ParseFile(filename string) (*models.ActivityMetrics, error) {
// Read the file content
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
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)
metrics := &models.ActivityMetrics{
ActivityType: "hiking",
StartTime: startTime,
Duration: time.Duration(endTime.Sub(startTime).Seconds()) * time.Second,
}
// 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
}
metrics.Distance = totalDistance
metrics.ElevationGain = elevationGain
return metrics, 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{})
}

65
internal/parser/parser.go Normal file
View File

@@ -0,0 +1,65 @@
package parser
import (
"bytes"
"fmt"
"os"
"time"
"github.com/tormoder/fit"
"github.com/sstent/garminsync-go/internal/models"
)
// Parser handles FIT file parsing
type Parser struct{}
func NewParser() *Parser {
return &Parser{}
}
func (p *Parser) ParseFile(filename string) (*models.ActivityMetrics, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
return p.ParseData(data)
}
func (p *Parser) ParseData(data []byte) (*models.ActivityMetrics, error) {
fitFile, err := fit.Decode(bytes.NewReader(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 := &models.ActivityMetrics{
StartTime: session.StartTime,
Duration: time.Duration(session.TotalTimerTime) * time.Second,
Distance: float64(session.TotalDistance),
AvgHeartRate: int(session.AvgHeartRate),
MaxHeartRate: int(session.MaxHeartRate),
AvgPower: int(session.AvgPower),
Calories: int(session.TotalCalories),
ElevationGain: float64(session.TotalAscent),
ElevationLoss: float64(session.TotalDescent),
Steps: 0, // FIT sessions don't include steps
}
return metrics, nil
}