mirror of
https://github.com/sstent/garminsync-go.git
synced 2026-01-26 17:11:53 +00:00
checkpoint 2
This commit is contained in:
@@ -2,8 +2,7 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
|
||||
@@ -124,6 +124,16 @@ func (s *SQLiteDB) GetActivities(limit, offset int) ([]Activity, error) {
|
||||
return activities, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) ActivityExists(activityID int) (bool, error) {
|
||||
query := `SELECT COUNT(*) FROM activities WHERE activity_id = ?`
|
||||
var count int
|
||||
err := s.db.QueryRow(query, activityID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteDB) GetActivity(activityID int) (*Activity, error) {
|
||||
query := `
|
||||
SELECT id, activity_id, start_time, activity_type, duration, distance,
|
||||
|
||||
@@ -2,16 +2,14 @@
|
||||
package garmin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
65
internal/parser/parser.go
Normal 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
|
||||
}
|
||||
@@ -9,48 +9,43 @@ import (
|
||||
|
||||
"github.com/sstent/garminsync-go/internal/database"
|
||||
"github.com/sstent/garminsync-go/internal/garmin"
|
||||
"github.com/sstent/garminsync-go/internal/models"
|
||||
"github.com/sstent/garminsync-go/internal/parser"
|
||||
)
|
||||
|
||||
type SyncService struct {
|
||||
type Syncer struct {
|
||||
garminClient *garmin.Client
|
||||
db *database.SQLiteDB
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewSyncService(garminClient *garmin.Client, db *database.SQLiteDB, dataDir string) *SyncService {
|
||||
return &SyncService{
|
||||
func NewSyncer(garminClient *garmin.Client, db *database.SQLiteDB, dataDir string) *Syncer {
|
||||
return &Syncer{
|
||||
garminClient: garminClient,
|
||||
db: db,
|
||||
dataDir: dataDir,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SyncService) Sync(ctx context.Context) error {
|
||||
startTime := time.Now()
|
||||
fmt.Printf("Starting sync at %s\n", startTime.Format(time.RFC3339))
|
||||
defer func() {
|
||||
fmt.Printf("Sync completed in %s\n", time.Since(startTime))
|
||||
}()
|
||||
func (s *Syncer) FullSync(ctx context.Context) error {
|
||||
fmt.Println("Starting full sync...")
|
||||
defer fmt.Println("Sync completed")
|
||||
|
||||
// 1. Fetch latest activities from Garmin
|
||||
// 1. Fetch activities from Garmin
|
||||
activities, err := s.garminClient.GetActivities(0, 100)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get activities: %w", err)
|
||||
}
|
||||
fmt.Printf("Found %d activities on Garmin\n", len(activities))
|
||||
fmt.Printf("Found %d activities\n", len(activities))
|
||||
|
||||
// 2. Sync each activity
|
||||
for i, activity := range activities {
|
||||
// 2. Process each activity
|
||||
for _, activity := range activities {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
fmt.Printf("[%d/%d] Processing activity %d...\n", i+1, len(activities), activity.ActivityID)
|
||||
fmt.Printf("Processing activity %d...\n", activity.ActivityID)
|
||||
if err := s.syncActivity(&activity); err != nil {
|
||||
fmt.Printf("Error syncing activity %d: %v\n", activity.ActivityID, err)
|
||||
// Continue with next activity on error
|
||||
fmt.Printf("Error syncing activity: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,103 +53,67 @@ func (s *SyncService) Sync(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SyncService) syncActivity(activity *garmin.GarminActivity) error {
|
||||
// Check if activity exists in database
|
||||
dbActivity, err := s.db.GetActivity(activity.ActivityID)
|
||||
if err == nil {
|
||||
// Activity exists - check if already downloaded
|
||||
if dbActivity.Downloaded {
|
||||
fmt.Printf("Activity %d already downloaded\n", activity.ActivityID)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
// Activity not in database - create new record
|
||||
dbActivity = &database.Activity{
|
||||
ActivityID: activity.ActivityID,
|
||||
StartTime: parseTime(activity.StartTimeLocal),
|
||||
}
|
||||
|
||||
// Add basic info if available
|
||||
if activityType, ok := activity.ActivityType["typeKey"]; ok {
|
||||
dbActivity.ActivityType = activityType.(string)
|
||||
}
|
||||
dbActivity.Duration = int(activity.Duration)
|
||||
dbActivity.Distance = activity.Distance
|
||||
|
||||
if err := s.db.CreateActivity(dbActivity); err != nil {
|
||||
return fmt.Errorf("failed to create activity: %w", err)
|
||||
}
|
||||
func (s *Syncer) syncActivity(activity *garmin.GarminActivity) error {
|
||||
// Skip if already downloaded
|
||||
if exists, _ := s.db.ActivityExists(activity.ActivityID); exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download the activity file (FIT format)
|
||||
fileData, err := s.garminClient.DownloadActivity(activity.ActivityID, "fit")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download activity: %w", err)
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
filename := filepath.Join(
|
||||
s.dataDir,
|
||||
"activities",
|
||||
fmt.Sprintf("%d_%s.fit", activity.ActivityID, activity.StartTimeLocal[:10]),
|
||||
)
|
||||
|
||||
// Create directories if needed
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
// Save file
|
||||
filename := filepath.Join(s.dataDir, "activities", fmt.Sprintf("%d.fit", activity.ActivityID))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0755); err != nil {
|
||||
return fmt.Errorf("directory creation failed: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(filename, fileData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write file: %w", err)
|
||||
return fmt.Errorf("file write failed: %w", err)
|
||||
}
|
||||
|
||||
// Parse the file to extract additional metrics
|
||||
metrics, err := s.parseActivityFile(fileData, "fit")
|
||||
// Parse the file
|
||||
fileParser := parser.NewParser()
|
||||
metrics, err := fileParser.ParseData(fileData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse activity file: %w", err)
|
||||
return fmt.Errorf("parsing failed: %w", err)
|
||||
}
|
||||
|
||||
// Update activity with parsed metrics
|
||||
dbActivity.Duration = int(metrics.Duration.Seconds())
|
||||
dbActivity.Distance = metrics.Distance
|
||||
dbActivity.MaxHeartRate = metrics.MaxHeartRate
|
||||
dbActivity.AvgHeartRate = metrics.AvgHeartRate
|
||||
dbActivity.AvgPower = metrics.AvgPower
|
||||
dbActivity.Calories = metrics.Calories
|
||||
dbActivity.Downloaded = true
|
||||
dbActivity.Filename = filename
|
||||
dbActivity.FileType = "fit"
|
||||
|
||||
// Save updated activity
|
||||
if err := s.db.UpdateActivity(dbActivity); err != nil {
|
||||
return fmt.Errorf("failed to update activity: %w", err)
|
||||
// Parse start time
|
||||
startTime, err := time.Parse("2006-01-02 15:04:05", activity.StartTimeLocal)
|
||||
if err != nil {
|
||||
startTime = time.Now()
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully synced activity %d\n", activity.ActivityID)
|
||||
// Save to database
|
||||
if err := s.db.CreateActivity(&database.Activity{
|
||||
ActivityID: activity.ActivityID,
|
||||
StartTime: startTime,
|
||||
ActivityType: getActivityType(activity),
|
||||
Distance: metrics.Distance,
|
||||
Duration: int(metrics.Duration.Seconds()),
|
||||
MaxHeartRate: metrics.MaxHeartRate,
|
||||
AvgHeartRate: metrics.AvgHeartRate,
|
||||
AvgPower: float64(metrics.AvgPower),
|
||||
Calories: metrics.Calories,
|
||||
Filename: filename,
|
||||
FileType: "fit",
|
||||
Downloaded: true,
|
||||
ElevationGain: metrics.ElevationGain,
|
||||
Steps: metrics.Steps,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("database error: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Synced activity %d\n", activity.ActivityID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SyncService) parseActivityFile(fileData []byte, fileType string) (*models.ActivityMetrics, error) {
|
||||
switch fileType {
|
||||
case "fit":
|
||||
return parser.ParseFITData(fileData)
|
||||
case "tcx":
|
||||
// TODO: Implement TCX parsing
|
||||
return nil, fmt.Errorf("TCX parsing not implemented yet")
|
||||
case "gpx":
|
||||
// TODO: Implement GPX parsing
|
||||
return nil, fmt.Errorf("GPX parsing not implemented yet")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported file type: %s", fileType)
|
||||
func getActivityType(activity *garmin.GarminActivity) string {
|
||||
if activityType, ok := activity.ActivityType["typeKey"]; ok {
|
||||
return activityType.(string)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTime(timeStr string) time.Time {
|
||||
// Garmin time format: "2023-08-15 12:30:45"
|
||||
t, err := time.Parse("2006-01-02 15:04:05", timeStr)
|
||||
if err != nil {
|
||||
return time.Now()
|
||||
}
|
||||
return t
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
@@ -1,103 +1,89 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"html/template"
|
||||
"path/filepath"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sstent/garminsync-go/internal/database"
|
||||
"github.com/sstent/garminsync-go/internal/garmin"
|
||||
"github.com/sstent/garminsync-go/internal/sync"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
db *database.SQLiteDB
|
||||
templates map[string]*template.Template
|
||||
db *database.SQLiteDB
|
||||
syncer *sync.Syncer
|
||||
garmin *garmin.Client
|
||||
templates map[string]interface{} // Placeholder for template handling
|
||||
}
|
||||
|
||||
func NewWebHandler(db *database.SQLiteDB) *WebHandler {
|
||||
func NewWebHandler(db *database.SQLiteDB, syncer *sync.Syncer, garmin *garmin.Client) *WebHandler {
|
||||
return &WebHandler{
|
||||
db: db,
|
||||
templates: make(map[string]*template.Template),
|
||||
db: db,
|
||||
syncer: syncer,
|
||||
garmin: garmin,
|
||||
templates: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebHandler) LoadTemplates(templateDir string) error {
|
||||
layouts, err := filepath.Glob(filepath.Join(templateDir, "layouts", "*.html"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
partials, err := filepath.Glob(filepath.Join(templateDir, "partials", "*.html"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pages, err := filepath.Glob(filepath.Join(templateDir, "pages", "*.html"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, page := range pages {
|
||||
name := filepath.Base(page)
|
||||
|
||||
files := append([]string{page}, layouts...)
|
||||
files = append(files, partials...)
|
||||
|
||||
h.templates[name], err = template.ParseFiles(files...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
func (h *WebHandler) RegisterRoutes(router *gin.Engine) {
|
||||
router.GET("/", h.Index)
|
||||
router.GET("/activities", h.ActivityList)
|
||||
router.GET("/activities/:id", h.ActivityDetail)
|
||||
router.POST("/sync", h.Sync)
|
||||
}
|
||||
|
||||
func (h *WebHandler) Index(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *WebHandler) Index(c *gin.Context) {
|
||||
stats, err := h.db.GetStats()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.renderTemplate(w, "index.html", stats)
|
||||
|
||||
// Placeholder for template rendering
|
||||
c.JSON(http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func (h *WebHandler) ActivityList(w http.ResponseWriter, r *http.Request) {
|
||||
activities, err := h.db.GetActivities(50, 0)
|
||||
func (h *WebHandler) ActivityList(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.Query("limit"))
|
||||
offset, _ := strconv.Atoi(c.Query("offset"))
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
activities, err := h.db.GetActivities(limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.renderTemplate(w, "activity_list.html", activities)
|
||||
|
||||
c.JSON(http.StatusOK, activities)
|
||||
}
|
||||
|
||||
func (h *WebHandler) ActivityDetail(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract activity ID from URL params
|
||||
activityID, err := strconv.Atoi(r.URL.Query().Get("id"))
|
||||
func (h *WebHandler) ActivityDetail(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid activity ID", http.StatusBadRequest)
|
||||
c.AbortWithStatus(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
activity, err := h.db.GetActivity(activityID)
|
||||
|
||||
activity, err := h.db.GetActivity(id)
|
||||
if err != nil {
|
||||
http.Error(w, "Activity not found", http.StatusNotFound)
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
h.renderTemplate(w, "activity_detail.html", activity)
|
||||
|
||||
c.JSON(http.StatusOK, activity)
|
||||
}
|
||||
|
||||
func (h *WebHandler) renderTemplate(w http.ResponseWriter, name string, data interface{}) {
|
||||
tmpl, ok := h.templates[name]
|
||||
if !ok {
|
||||
http.Error(w, "Template not found", http.StatusInternalServerError)
|
||||
func (h *WebHandler) Sync(c *gin.Context) {
|
||||
err := h.syncer.FullSync(context.Background())
|
||||
if err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "base", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sstent/garminsync-go/internal/database"
|
||||
)
|
||||
|
||||
type WebHandler struct {
|
||||
templates *template.Template
|
||||
db *database.SQLiteDB
|
||||
}
|
||||
|
||||
func NewWebHandler(db *database.SQLiteDB) *WebHandler {
|
||||
return &WebHandler{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WebHandler) LoadTemplates(templatesDir string) error {
|
||||
tmpl := template.New("base")
|
||||
tmpl = tmpl.Funcs(template.FuncMap{})
|
||||
|
||||
// Load layouts
|
||||
layouts, err := filepath.Glob(filepath.Join(templatesDir, "layouts/*.html"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load pages
|
||||
pages, err := filepath.Glob(filepath.Join(templatesDir, "pages/*.html"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Combine all templates
|
||||
files := append(layouts, pages...)
|
||||
|
||||
h.templates, err = tmpl.ParseFiles(files...)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *WebHandler) renderTemplate(w io.Writer, name string, data interface{}) error {
|
||||
return h.templates.ExecuteTemplate(w, name, data)
|
||||
}
|
||||
Reference in New Issue
Block a user