Created
December 27, 2021 23:53
-
-
Save Mikulas/155b1fa36b3aab10cd0857f281ae85ca to your computer and use it in GitHub Desktop.
fitbod to hevy import export sync
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package main | |
import ( | |
"bytes" | |
"encoding/csv" | |
"encoding/json" | |
"fmt" | |
"io/ioutil" | |
"log" | |
"net/http" | |
"os" | |
"sort" | |
"strconv" | |
"strings" | |
"time" | |
"github.com/google/uuid" | |
) | |
var Fitbod2Hevy = map[string]HevyExercise{ | |
"Rowing": {"Rowing Machine", "0222DB42"}, | |
"Dumbbell Incline Bench Press": {"Incline Bench Press (Dumbbell)", "07B38369"}, | |
"Deadlift": {"Deadlift (Barbell)", "C6272009"}, | |
"Back Squat": {"Squat (Barbell)", "D04AC939"}, | |
"Dumbbell Bicep Curl": {"Bicep Curl (Dumbbell)", "37FCC2BB"}, | |
"Barbell Bench Press": {"Bench Press (Barbell)", "79D0BB3A"}, | |
"Decline Crunch": {"Decline Crunch", "BC10A922"}, | |
"Dumbbell Skullcrusher": {"Skullcrusher (Dumbbell)", "68F8A292"}, | |
"Plank": {"Plank", "C6C9B8A0"}, | |
"Dumbbell Shoulder Press": {"Shoulder Press (Dumbbell)", "878CD1D0"}, | |
"Lying Hamstrings Curl": {"Lying Leg Curl (Machine)", "B8127AD1"}, | |
"Machine Leg Press": {"Leg Press Horizontal (Machine)", "0EB695C9"}, | |
"Leg Extension": {"Leg Extension (Machine)", "75A4F6C4"}, | |
"Dumbbell Shrug": {"Shrug (Dumbbell)", "ABEC557F"}, | |
"Lat Pulldown": {"Lat Pulldown (Cable)", "6A6C31A5"}, | |
"Cable Face Pull": {"Face Pull", "BE640BA0"}, | |
"Ab Crunch Machine": {"Crunch (Machine)", "EB43ADD4"}, | |
"Preacher Curl": {"Preacher Curl (Barbell)", "4F942934"}, | |
"Hammer Curls": {"Hammer Curl (Dumbbell)", "7E3BC8B6"}, | |
"Cable Hip Adduction": {"Hip Adduction (cable)", "f726fb99-5251-40e8-9b0a-fed06e93d97b"}, | |
"Cable Rope Tricep Extension": {"Triceps Rope Pushdown", "94B7239B"}, | |
"Cable Rear Delt Fly": {"Rear Delt Fly (Cable)", "b0bcfcca-c5bf-4a84-860c-fd48a1441697"}, | |
"Cable Hip Abduction": {"Hip Abduction (Cable)", "2ad037da-feb1-48db-97da-bb92c9716363"}, | |
"Cable Lateral Raise": {"Lateral Raise (Cable)", "BE289E45"}, | |
"Dumbbell Row": {"Dumbbell Row", "F1E57334"}, | |
"Zottman Curl": {"Zottman Curl (Dumbbell)", "123EE239"}, | |
"Cable Crossover Fly": {"Cable Fly Crossovers", "651F844C"}, | |
"Single Leg Cable Kickback": {"Standing Cable Glute Kickbacks", "ACB2751D"}, | |
"Pull Up": {"Pull Up", "1B2B1E7C"}, | |
"Good Morning": {"Good Morning (Barbell)", "4180C405"}, | |
"Dumbbell Bulgarian Split Squat": {"Bulgarian Split Squat", "B5D3A742"}, | |
"Smith Machine Calf Raise": {"Standing Calf Raise (Barbell)", "E53CCBE5"}, | |
"Calf Press": {"Calf Press (Machine)", "91237BDD"}, | |
"Standing Machine Calf Press": {"Calf Press (Machine)", "91237BDD"}, | |
"Barbell Hip Thrust": {"Hip Thrust (Barbell)", "D57C2EC7"}, | |
"Dumbbell Fly": {"Reverse Fly (Dumbbell)", "B582299E"}, | |
"Front Squat": {"Front Squat", "5046D0A9"}, | |
"Smith Machine Incline Bench Press": {"Incline Bench Press (Smith Machine)", "3A6FA3D1"}, | |
"Leg Press": {"Leg Press (Machine)", "C7973E0E"}, | |
"Dumbbell Bench Press": {"Bench Press (Dumbbell)", "3601968B"}, | |
"Palms-Up Dumbbell Wrist Curl": {"Seated Palms Up Wrist Curl", "1006DF48"}, | |
"Barbell Shoulder Press": {"Overhead Press (Barbell)", "7B8D84E8"}, | |
} | |
type HevyExercise struct { | |
Name string | |
ID string | |
} | |
type FitbodEntry struct { | |
Date time.Time | |
Exercise string | |
Reps int | |
WeightKg float32 | |
DurationS float32 | |
DistanceM float32 | |
Incline float32 | |
Resistance float32 | |
isWarmup bool | |
Note string | |
multiplier float32 | |
} | |
type FitbodWorkout struct { | |
Exercises map[string]*FitbodExercise // keyed by FitbodEntry.Exercise | |
} | |
func (fw FitbodWorkout) Date() string { | |
for _, ex := range fw.Exercises { | |
for _, set := range ex.Sets { | |
return set.Date.Format("2006-01-02") | |
} | |
} | |
panic("assumed at least one entry") | |
} | |
func (fw FitbodWorkout) Start() time.Time { | |
min := time.Now() | |
for _, ex := range fw.Exercises { | |
for _, set := range ex.Sets { | |
if set.Date.Before(min) { | |
min = set.Date | |
} | |
} | |
} | |
return min | |
} | |
func (fw FitbodWorkout) End() time.Time { | |
// fitbod export only has start times for workout so | |
// this just an estimate, but makes the data nicer | |
return fw.Start().Add(1 * time.Hour) | |
} | |
func (fw FitbodWorkout) Dump() { | |
first := true | |
for _, ex := range fw.Exercises { | |
if first { | |
fmt.Printf("%s\n", ex.Sets[0].Date.Format("2006-01-02")) | |
first = false | |
} | |
fmt.Printf(" %s ", ex.Sets[0].Exercise) | |
hevy, ok := Fitbod2Hevy[ex.Sets[0].Exercise] | |
if !ok { | |
fmt.Printf("!!! DOES NOT MAP\n") | |
} else { | |
fmt.Printf(" -> %s (%s)\n", hevy.Name, hevy.ID) | |
} | |
for _, set := range ex.Sets { | |
fmt.Printf(" %#v\n", set) | |
} | |
} | |
} | |
type FitbodExercise struct { | |
Sets []FitbodEntry | |
} | |
func main() { | |
entries, err := loadFitbodBackup() | |
if err != nil { | |
log.Fatal(err.Error()) | |
} | |
fbWorkouts := map[string]FitbodWorkout{} | |
// assuming at most 1 workout per day | |
for _, entry := range entries { | |
key := entry.Date.Format("2006-01-02 15") | |
if _, ok := fbWorkouts[key]; !ok { | |
fbWorkouts[key] = FitbodWorkout{ | |
Exercises: map[string]*FitbodExercise{}, | |
} | |
} | |
ex := entry.Exercise | |
if _, ok := fbWorkouts[key].Exercises[ex]; !ok { | |
fbWorkouts[key].Exercises[ex] = &FitbodExercise{} | |
} | |
fbWorkouts[key].Exercises[ex].Sets = append(fbWorkouts[key].Exercises[ex].Sets, entry) | |
} | |
//printMissingMappings(fbWorkouts) | |
//var answer string | |
for _, key := range sortedKeys(fbWorkouts) { | |
fbWorkout := fbWorkouts[key] | |
fbWorkout.Dump() | |
wr := WorkoutRoot{ | |
EmptyResponse: true, | |
UpdateRoutineValues: false, | |
Workout: Workout{ | |
ClientId: uuid.NewString(), | |
Name: fmt.Sprintf("%s", fbWorkout.Date()), | |
Description: "Imported from Fitbod", | |
ImageUrls: nil, | |
Exercises: []Exercise{}, | |
StartTime: fbWorkout.Start().Unix(), | |
EndTime: fbWorkout.End().Unix(), | |
Weight: 0, | |
UseAutoDurationTimer: false, | |
TrackWorkoutAsRoutine: false, | |
AppleWatch: false, | |
}, | |
} | |
for key, fbEx := range fbWorkout.Exercises { | |
mapping, ok := Fitbod2Hevy[key] | |
if !ok { | |
continue | |
} | |
ex := Exercise{ | |
Title: mapping.Name, | |
Id: mapping.ID, | |
AutoRestTimerSeconds: 0, | |
Notes: "", | |
RoutineNotes: "", | |
Sets: []Set{}, | |
} | |
for i, set := range fbEx.Sets { | |
indicator := "normal" | |
if set.isWarmup { | |
indicator = "warmup" | |
} | |
weight := int(set.WeightKg) | |
weightp := &weight | |
if weight == 0 { | |
weightp = nil | |
} | |
reps := set.Reps | |
repsp := &reps | |
if set.Reps == 0 { | |
repsp = nil | |
} | |
dist := int(set.DistanceM) | |
distp := &dist | |
if dist == 0 { | |
distp = nil | |
} | |
duration := int(set.DurationS) | |
durationp := &duration | |
if duration == 0 { | |
durationp = nil | |
} | |
ex.Sets = append(ex.Sets, Set{ | |
Index: i, | |
Completed: true, | |
Indicator: indicator, | |
Weight: weightp, | |
Reps: repsp, | |
Distance: distp, | |
Duration: durationp, | |
}) | |
} | |
if len(ex.Sets) == 0 { | |
continue | |
} | |
wr.Workout.Exercises = append(wr.Workout.Exercises, ex) | |
} | |
if len(wr.Workout.Exercises) == 0 { | |
continue | |
} | |
// TODO review, ask for permissions and upload | |
dump, _ := json.MarshalIndent(wr, "", " ") | |
fmt.Printf("%s\n", dump) | |
//fmt.Printf("duration = %s", time.Unix(wr.Workout.EndTime, 0).Sub(time.Unix(wr.Workout.StartTime, 0))) | |
//fmt.Printf("\n\nPublish to Hevy? (Ctrl-C to cancel, anything else to continue)") | |
//fmt.Scanln(&answer) | |
err = publish(wr) | |
if err != nil { | |
log.Fatal(err) | |
} | |
//fmt.Printf("\n\nContinue with next workout? (Ctrl-C to cancel, anything else to continue)") | |
//fmt.Scanln(&answer) | |
} | |
} | |
func publish(root WorkoutRoot) error { | |
payload, err := json.Marshal(root) | |
if err != nil { | |
return err | |
} | |
client := &http.Client{} | |
req, err := http.NewRequest("POST", "https://api.hevyapp.com/workout", bytes.NewReader(payload)) | |
req.Header.Add("User-Agent", "Hevy/1175 CFNetwork/1312 Darwin/21.0.0 " + todoIncludeYourEmailOrSomethingInHevyWantsToContactYou) | |
req.Header.Add("Content-Type", "application/json") | |
req.Header.Add("auth-token", todoYouHaveToFillThisInYourself) | |
req.Header.Add("x-api-key", todoYouHaveToFillThisInYourself) | |
req.Header.Add("Accept", "application/json, text/plain, */*") | |
resp, err := client.Do(req) | |
defer resp.Body.Close() | |
fmt.Printf("%#v\n", resp) | |
body, _ := ioutil.ReadAll(resp.Body) | |
fmt.Printf("----\n%s\n----\n", body) | |
return err | |
} | |
func printMissingMappings(workouts map[string]FitbodWorkout) { | |
missingEx := map[string]int{} | |
for _, workout := range workouts { | |
for ex, vals := range workout.Exercises { | |
// ignore exercises that I didn't do last 3 months | |
if vals.Sets[0].Date.Before(time.Now().Add(-90 * 24 * time.Hour)) { | |
continue | |
} | |
if _, ok := Fitbod2Hevy[ex]; !ok { | |
if _, ok2 := missingEx[ex]; !ok2 { | |
missingEx[ex] = 0 | |
} | |
missingEx[ex] += 1 | |
} | |
} | |
} | |
fmt.Printf("MISSING MAPPINGS:\n") | |
for ex, count := range missingEx { | |
fmt.Printf(" %s (%d)\n", ex, count) | |
} | |
} | |
func sortedKeys(slice map[string]FitbodWorkout) []string { | |
keys := []string{} | |
for key := range slice { | |
keys = append(keys, key) | |
} | |
sort.Strings(keys) | |
return keys | |
} | |
func loadFitbodBackup() ([]FitbodEntry, error) { | |
f, err := os.Open("WorkoutExport.csv") | |
if err != nil { | |
return nil, fmt.Errorf("Unable to read input file %w", err) | |
} | |
defer f.Close() | |
csvReader := csv.NewReader(f) | |
records, err := csvReader.ReadAll() | |
if err != nil { | |
return nil, fmt.Errorf("Unable to parse file as CSV %w", err) | |
} | |
entries := []FitbodEntry{} | |
for _, rec := range records[1:] { // skip header | |
entry, err := parseFitbodEntry(rec) | |
if err != nil { | |
return nil, fmt.Errorf("cant parse %#v as fitbod entry", rec) | |
} | |
entries = append(entries, entry) | |
} | |
return entries, err | |
} | |
func parseFitbodEntry(row []string) (FitbodEntry, error) { | |
// 2021-12-27 10:02:51 +0000 | |
t, err := time.Parse("2006-01-02 15:04:05 -0700", row[0]) | |
if err != nil { | |
return FitbodEntry{}, err | |
} | |
strconv.ParseFloat(row[2], 32) | |
return FitbodEntry{ | |
Date: t, | |
Exercise: row[1], | |
Reps: mustParseInt(row[2]), | |
WeightKg: mustParseFloat32(row[3]), | |
DurationS: mustParseFloat32(row[4]), | |
DistanceM: mustParseFloat32(row[5]), | |
Incline: mustParseFloat32(row[6]), | |
Resistance: mustParseFloat32(row[7]), | |
isWarmup: row[8] != "false", | |
Note: row[9], | |
multiplier: mustParseFloat32(row[10]), | |
}, nil | |
} | |
func mustParseInt(in string) int { | |
in = strings.TrimSpace(in) // fitbod adds extra space for some reason | |
out, err := strconv.Atoi(in) | |
if err != nil { | |
panic(err) | |
} | |
return out | |
} | |
func mustParseFloat32(in string) float32 { | |
in = strings.TrimSpace(in) // fitbod adds extra space for some reason | |
out, err := strconv.ParseFloat(in, 32) | |
if err != nil { | |
panic(err) | |
} | |
return float32(out) | |
} | |
type WorkoutRoot struct { | |
Workout Workout `json:"workout"` | |
EmptyResponse bool `json:"emptyResponse"` | |
UpdateRoutineValues bool `json:"updateRoutineValues"` | |
// WorkoutId string `json:"workout_id"` // would be used for updates | |
} | |
type Workout struct { | |
ClientId string `json:"clientId"` | |
Name string `json:"name"` | |
Description string `json:"description"` | |
ImageUrls []interface{} `json:"imageUrls"` | |
Exercises []Exercise `json:"exercises"` | |
StartTime int64 `json:"startTime"` | |
EndTime int64 `json:"endTime"` | |
Weight int `json:"weight"` | |
UseAutoDurationTimer bool `json:"useAutoDurationTimer"` | |
TrackWorkoutAsRoutine bool `json:"trackWorkoutAsRoutine"` | |
AppleWatch bool `json:"appleWatch"` | |
} | |
type Exercise struct { | |
Title string `json:"title"` | |
Id string `json:"id"` | |
AutoRestTimerSeconds int `json:"autoRestTimerSeconds"` | |
Notes string `json:"notes"` | |
RoutineNotes string `json:"routineNotes"` | |
Sets []Set `json:"sets"` | |
} | |
type Set struct { | |
Index int `json:"index"` | |
Completed bool `json:"completed"` | |
Indicator string `json:"indicator"` | |
Weight *int `json:"weight"` | |
Reps *int `json:"reps"` | |
Distance *int `json:"distance"` | |
Duration *int `json:"duration"` | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This has been super useful, thanks @Mikulas!
I am myself writing a similar importer, also in Go, to migrate my workouts from Jefit, and I'm very close to having it working thanks to this Gist.
However, I am consistently getting a 404 response while POSTing to
/workout
. I'm unsure whether the API endpoint has changed, or whether Hevy simply obfuscates the actual error behind a generic 404 code.May I ask how you figured out the API contract to add a new workout, and how I could discover the API myself in case it changes in the future (my email is on my GitHub profile in case you prefer to reach privately).