feat: extend equipment and workout models with service tracking
This commit is contained in:
144
internal/export/fit_encoder.go
Normal file
144
internal/export/fit_encoder.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/muktihari/fit/encoder"
|
||||
"github.com/muktihari/fit/profile/filedef"
|
||||
"github.com/muktihari/fit/profile/mesgdef"
|
||||
"github.com/muktihari/fit/profile/typedef"
|
||||
|
||||
"rideaware/internal/workout"
|
||||
)
|
||||
|
||||
// EncodeFITWorkout generates a FIT workout file from a Workout's structured segments.
|
||||
// userFTP is the user's Functional Threshold Power in watts, used to convert
|
||||
// %FTP power targets into absolute watt values for the device.
|
||||
func EncodeFITWorkout(w *workout.Workout, userFTP int) ([]byte, error) {
|
||||
if len(w.WorkoutData.Segments) == 0 {
|
||||
return nil, fmt.Errorf("workout has no segments")
|
||||
}
|
||||
if userFTP <= 0 {
|
||||
return nil, fmt.Errorf("user FTP must be greater than 0")
|
||||
}
|
||||
|
||||
wktFile := filedef.NewWorkout()
|
||||
|
||||
wktFile.FileId.
|
||||
SetType(typedef.FileWorkout).
|
||||
SetManufacturer(typedef.ManufacturerDevelopment).
|
||||
SetProduct(1).
|
||||
SetTimeCreated(time.Now())
|
||||
|
||||
name := w.WorkoutData.Name
|
||||
if name == "" {
|
||||
name = w.Title
|
||||
}
|
||||
|
||||
wktFile.Workout = mesgdef.NewWorkout(nil).
|
||||
SetWktName(name).
|
||||
SetSport(typedef.SportCycling).
|
||||
SetNumValidSteps(uint16(len(w.WorkoutData.Segments)))
|
||||
|
||||
steps := make([]*mesgdef.WorkoutStep, 0, len(w.WorkoutData.Segments))
|
||||
for i, seg := range w.WorkoutData.Segments {
|
||||
step := segmentToFITStep(seg, uint16(i), userFTP)
|
||||
steps = append(steps, step)
|
||||
}
|
||||
wktFile.WorkoutSteps = steps
|
||||
|
||||
fit := wktFile.ToFIT(nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := encoder.New(&buf)
|
||||
if err := enc.Encode(&fit); err != nil {
|
||||
return nil, fmt.Errorf("failed to encode FIT workout: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func segmentToFITStep(seg workout.WorkoutSegment, index uint16, userFTP int) *mesgdef.WorkoutStep {
|
||||
step := mesgdef.NewWorkoutStep(nil).
|
||||
SetMessageIndex(typedef.MessageIndex(index)).
|
||||
SetDurationType(typedef.WktStepDurationTime).
|
||||
SetDurationValue(uint32(seg.Duration) * 1000) // seconds to milliseconds
|
||||
|
||||
switch seg.Type {
|
||||
case "warmup":
|
||||
step.SetIntensity(typedef.IntensityWarmup)
|
||||
setPowerTarget(step, seg, userFTP)
|
||||
step.SetWktStepName("Warm Up")
|
||||
|
||||
case "steadystate":
|
||||
step.SetIntensity(typedef.IntensityActive)
|
||||
setPowerTargetSteady(step, seg, userFTP)
|
||||
step.SetWktStepName("Steady State")
|
||||
|
||||
case "interval":
|
||||
step.SetIntensity(typedef.IntensityInterval)
|
||||
setPowerTarget(step, seg, userFTP)
|
||||
step.SetWktStepName("Interval")
|
||||
|
||||
case "cooldown":
|
||||
step.SetIntensity(typedef.IntensityCooldown)
|
||||
setPowerTarget(step, seg, userFTP)
|
||||
step.SetWktStepName("Cool Down")
|
||||
|
||||
case "ramp":
|
||||
step.SetIntensity(typedef.IntensityActive)
|
||||
setPowerTarget(step, seg, userFTP)
|
||||
step.SetWktStepName("Ramp")
|
||||
|
||||
case "freeride":
|
||||
step.SetIntensity(typedef.IntensityActive)
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
step.SetWktStepName("Free Ride")
|
||||
|
||||
default:
|
||||
step.SetIntensity(typedef.IntensityActive)
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
}
|
||||
|
||||
return step
|
||||
}
|
||||
|
||||
// setPowerTarget sets a power range target using PowerLow/PowerHigh.
|
||||
// Values are converted from %FTP fractions to absolute watts with +1000 offset.
|
||||
func setPowerTarget(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
|
||||
if seg.PowerLow == 0 && seg.PowerHigh == 0 && seg.Power == 0 {
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
return
|
||||
}
|
||||
|
||||
step.SetTargetType(typedef.WktStepTargetPower)
|
||||
step.SetTargetValue(0) // 0 = custom range
|
||||
|
||||
if seg.PowerLow != 0 || seg.PowerHigh != 0 {
|
||||
low := uint32(float64(userFTP)*seg.PowerLow) + 1000
|
||||
high := uint32(float64(userFTP)*seg.PowerHigh) + 1000
|
||||
step.SetCustomTargetValueLow(low)
|
||||
step.SetCustomTargetValueHigh(high)
|
||||
} else if seg.Power != 0 {
|
||||
watts := uint32(float64(userFTP) * seg.Power)
|
||||
step.SetCustomTargetValueLow(watts - 10 + 1000)
|
||||
step.SetCustomTargetValueHigh(watts + 10 + 1000)
|
||||
}
|
||||
}
|
||||
|
||||
// setPowerTargetSteady sets a power target for steady state segments using the Power field.
|
||||
func setPowerTargetSteady(step *mesgdef.WorkoutStep, seg workout.WorkoutSegment, userFTP int) {
|
||||
if seg.Power == 0 {
|
||||
step.SetTargetType(typedef.WktStepTargetOpen)
|
||||
return
|
||||
}
|
||||
|
||||
step.SetTargetType(typedef.WktStepTargetPower)
|
||||
step.SetTargetValue(0)
|
||||
|
||||
watts := uint32(float64(userFTP) * seg.Power)
|
||||
step.SetCustomTargetValueLow(watts - 10 + 1000)
|
||||
step.SetCustomTargetValueHigh(watts + 10 + 1000)
|
||||
}
|
||||
106
internal/export/handler.go
Normal file
106
internal/export/handler.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"rideaware/internal/config"
|
||||
"rideaware/internal/middleware"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
service: NewService(),
|
||||
}
|
||||
}
|
||||
|
||||
// ExportFIT GET /api/protected/workouts/export/fit?id=X
|
||||
func (h *Handler) ExportFIT(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "workout id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid workout id"})
|
||||
return
|
||||
}
|
||||
|
||||
data, filename, err := h.service.ExportFIT(uint(id), claims.UserID)
|
||||
if err != nil {
|
||||
log.Printf("FIT export error: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// ExportZWO GET /api/protected/workouts/export/zwo?id=X
|
||||
func (h *Handler) ExportZWO(w http.ResponseWriter, r *http.Request) {
|
||||
claims := r.Context().Value(middleware.UserContextKey).(*config.CustomClaims)
|
||||
if claims == nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
if idStr == "" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "workout id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "invalid workout id"})
|
||||
return
|
||||
}
|
||||
|
||||
data, filename, err := h.service.ExportZWO(uint(id), claims.UserID)
|
||||
if err != nil {
|
||||
log.Printf("ZWO export error: %v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/xml")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}
|
||||
82
internal/export/service.go
Normal file
82
internal/export/service.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"rideaware/internal/user"
|
||||
"rideaware/internal/workout"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
workoutRepo *workout.Repository
|
||||
userRepo *user.Repository
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{
|
||||
workoutRepo: workout.NewRepository(),
|
||||
userRepo: user.NewRepository(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ExportFIT(workoutID, userID uint) ([]byte, string, error) {
|
||||
w, err := s.workoutRepo.GetWorkoutByID(workoutID, userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("workout not found: %w", err)
|
||||
}
|
||||
|
||||
u, err := s.userRepo.GetUserByID(userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("user not found: %w", err)
|
||||
}
|
||||
|
||||
ftp := 0
|
||||
if u.Profile != nil {
|
||||
ftp = u.Profile.FTP
|
||||
}
|
||||
if ftp <= 0 {
|
||||
return nil, "", fmt.Errorf("FTP must be set in your profile before exporting FIT files")
|
||||
}
|
||||
|
||||
data, err := EncodeFITWorkout(w, ftp)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(w.Title) + ".fit"
|
||||
return data, filename, nil
|
||||
}
|
||||
|
||||
func (s *Service) ExportZWO(workoutID, userID uint) ([]byte, string, error) {
|
||||
w, err := s.workoutRepo.GetWorkoutByID(workoutID, userID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("workout not found: %w", err)
|
||||
}
|
||||
|
||||
data, err := GenerateZWO(w)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
filename := sanitizeFilename(w.Title) + ".zwo"
|
||||
return data, filename, nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
if name == "" {
|
||||
return "workout"
|
||||
}
|
||||
result := make([]byte, 0, len(name))
|
||||
for _, c := range name {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
||||
result = append(result, byte(c))
|
||||
case c == ' ' || c == '-' || c == '_':
|
||||
result = append(result, '_')
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return "workout"
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
169
internal/export/zwo_generator.go
Normal file
169
internal/export/zwo_generator.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package export
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
|
||||
"rideaware/internal/workout"
|
||||
)
|
||||
|
||||
// ZWO XML structures for marshalling (mirrors the parser types in workout/zwo_parser.go)
|
||||
|
||||
type zwoWorkoutFile struct {
|
||||
XMLName xml.Name `xml:"workout_file"`
|
||||
Author string `xml:"author"`
|
||||
Name string `xml:"name"`
|
||||
Description string `xml:"description"`
|
||||
SportType string `xml:"sportType"`
|
||||
Workout zwoWorkout `xml:"workout"`
|
||||
}
|
||||
|
||||
type zwoWorkout struct {
|
||||
Steps []interface{}
|
||||
}
|
||||
|
||||
type zwoWarmup struct {
|
||||
XMLName xml.Name `xml:"Warmup"`
|
||||
Duration int `xml:"Duration,attr"`
|
||||
PowerLow float64 `xml:"PowerLow,attr"`
|
||||
PowerHigh float64 `xml:"PowerHigh,attr"`
|
||||
Cadence int `xml:"Cadence,attr,omitempty"`
|
||||
}
|
||||
|
||||
type zwoSteadyState struct {
|
||||
XMLName xml.Name `xml:"SteadyState"`
|
||||
Duration int `xml:"Duration,attr"`
|
||||
Power float64 `xml:"Power,attr"`
|
||||
Cadence int `xml:"Cadence,attr,omitempty"`
|
||||
}
|
||||
|
||||
type zwoCooldown struct {
|
||||
XMLName xml.Name `xml:"Cooldown"`
|
||||
Duration int `xml:"Duration,attr"`
|
||||
PowerLow float64 `xml:"PowerLow,attr"`
|
||||
PowerHigh float64 `xml:"PowerHigh,attr"`
|
||||
Cadence int `xml:"Cadence,attr,omitempty"`
|
||||
}
|
||||
|
||||
type zwoInterval struct {
|
||||
XMLName xml.Name `xml:"IntervalsT"`
|
||||
Duration int `xml:"Duration,attr"`
|
||||
PowerLow float64 `xml:"PowerLow,attr"`
|
||||
PowerHigh float64 `xml:"PowerHigh,attr"`
|
||||
Cadence int `xml:"Cadence,attr,omitempty"`
|
||||
}
|
||||
|
||||
type zwoRamp struct {
|
||||
XMLName xml.Name `xml:"Ramp"`
|
||||
Duration int `xml:"Duration,attr"`
|
||||
PowerLow float64 `xml:"PowerLow,attr"`
|
||||
PowerHigh float64 `xml:"PowerHigh,attr"`
|
||||
Cadence int `xml:"Cadence,attr,omitempty"`
|
||||
}
|
||||
|
||||
type zwoFreeRide struct {
|
||||
XMLName xml.Name `xml:"FreeRide"`
|
||||
Duration int `xml:"Duration,attr"`
|
||||
Cadence int `xml:"Cadence,attr,omitempty"`
|
||||
}
|
||||
|
||||
// MarshalXML implements custom XML marshalling for zwoWorkout to preserve segment ordering.
|
||||
func (w zwoWorkout) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
start.Name = xml.Name{Local: "workout"}
|
||||
if err := e.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, step := range w.Steps {
|
||||
if err := e.Encode(step); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return e.EncodeToken(start.End())
|
||||
}
|
||||
|
||||
// GenerateZWO creates a .zwo XML file from a workout's structured segments.
|
||||
// Power values are stored as %FTP fractions (0.0-2.0) and pass through directly.
|
||||
func GenerateZWO(w *workout.Workout) ([]byte, error) {
|
||||
if len(w.WorkoutData.Segments) == 0 {
|
||||
return nil, fmt.Errorf("workout has no segments")
|
||||
}
|
||||
|
||||
name := w.WorkoutData.Name
|
||||
if name == "" {
|
||||
name = w.Title
|
||||
}
|
||||
|
||||
author := w.WorkoutData.Author
|
||||
if author == "" {
|
||||
author = "RideAware"
|
||||
}
|
||||
|
||||
zwo := zwoWorkoutFile{
|
||||
Author: author,
|
||||
Name: name,
|
||||
Description: w.Description,
|
||||
SportType: "bike",
|
||||
}
|
||||
|
||||
steps := make([]interface{}, 0, len(w.WorkoutData.Segments))
|
||||
for _, seg := range w.WorkoutData.Segments {
|
||||
step := segmentToZWOStep(seg)
|
||||
if step != nil {
|
||||
steps = append(steps, step)
|
||||
}
|
||||
}
|
||||
zwo.Workout = zwoWorkout{Steps: steps}
|
||||
|
||||
output, err := xml.MarshalIndent(zwo, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate ZWO XML: %w", err)
|
||||
}
|
||||
|
||||
return append([]byte(xml.Header), output...), nil
|
||||
}
|
||||
|
||||
func segmentToZWOStep(seg workout.WorkoutSegment) interface{} {
|
||||
switch seg.Type {
|
||||
case "warmup":
|
||||
return zwoWarmup{
|
||||
Duration: seg.Duration,
|
||||
PowerLow: seg.PowerLow,
|
||||
PowerHigh: seg.PowerHigh,
|
||||
Cadence: seg.Cadence,
|
||||
}
|
||||
case "steadystate":
|
||||
return zwoSteadyState{
|
||||
Duration: seg.Duration,
|
||||
Power: seg.Power,
|
||||
Cadence: seg.Cadence,
|
||||
}
|
||||
case "cooldown":
|
||||
return zwoCooldown{
|
||||
Duration: seg.Duration,
|
||||
PowerLow: seg.PowerLow,
|
||||
PowerHigh: seg.PowerHigh,
|
||||
Cadence: seg.Cadence,
|
||||
}
|
||||
case "interval":
|
||||
return zwoInterval{
|
||||
Duration: seg.Duration,
|
||||
PowerLow: seg.PowerLow,
|
||||
PowerHigh: seg.PowerHigh,
|
||||
Cadence: seg.Cadence,
|
||||
}
|
||||
case "ramp":
|
||||
return zwoRamp{
|
||||
Duration: seg.Duration,
|
||||
PowerLow: seg.PowerLow,
|
||||
PowerHigh: seg.PowerHigh,
|
||||
Cadence: seg.Cadence,
|
||||
}
|
||||
case "freeride":
|
||||
return zwoFreeRide{
|
||||
Duration: seg.Duration,
|
||||
Cadence: seg.Cadence,
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user