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 } }