Skip to content

Instantly share code, notes, and snippets.

@Mikulas
Created December 27, 2021 23:53
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Mikulas/155b1fa36b3aab10cd0857f281ae85ca to your computer and use it in GitHub Desktop.
Save Mikulas/155b1fa36b3aab10cd0857f281ae85ca to your computer and use it in GitHub Desktop.
fitbod to hevy import export sync
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"`
}
@Mikulas
Copy link
Author

Mikulas commented Dec 27, 2021

This script takes CSV exported from Fitbod and imports it into Hevy. You need to find out what your auth token is and what is x-api-key of Hevy.

You will also most likely need to update the exercise mapping.

I used this successfully to do a one time migration, but your results may vary. I strongly recommend not running this against Hevy accounts with actual data you care abou. I run this sync on an empty Hevy account.

@Mikulas
Copy link
Author

Mikulas commented Dec 28, 2021

You can also find the credentials by inspecting headers for web UI requests once you login at https://www.hevy.com/login

@antoineco
Copy link

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment