~hokiegeek/fitstrava

d0b40af9cce60bf015e8eee65f96200784dc9433 — HokieGeek 3 months ago 57feda0
The Strava API is kinda ugly
4 files changed, 293 insertions(+), 86 deletions(-)

M cmd/fitstrava/main.go
M fitbit.go
M strava.go
M utils.go
M cmd/fitstrava/main.go => cmd/fitstrava/main.go +80 -2
@@ 3,17 3,38 @@ package main
import (
	"fmt"
	"os"
	"strconv"
	"time"

	"git.sr.ht/~hokiegeek/fitstrava"
)

// https://developers.strava.com/docs/reference/#api-models-ActivityType
var fitbitActivityTypeToStrava = map[string]string{
	"Yoga":           "Yoga",
	"Bike":           "Ride",
	"Elliptical":     "Elliptical",
	"Hike":           "Hike",
	"Rowing Machine": "Rowing",
	"Walk":           "Walk",
	"Weights":        "WeightTraining",
	"Workout":        "Workout",

	"Sport":        "Workout",
	"Jumping rope": "Workout",
}

func stravaActivityTypeFromFitbit(fitbitName string) string {
	return ""
}

func main() {
	fitbitClientID := os.Getenv("FITBIT_CLIENT_ID")
	fitbitClientSecret := os.Getenv("FITBIT_CLIENT_SECRET")
	stravaClientID := os.Getenv("STRAVA_CLIENT_ID")
	stravaClientSecret := os.Getenv("STRAVA_CLIENT_SECRET")

	fmt.Println("- Authorizing clients (check browser)")
	fitbit, err := fitstrava.NewFitbitClient(fitbitClientID, fitbitClientSecret)
	if err != nil {
		panic(err)


@@ 27,6 48,7 @@ func main() {
	// fmt.Printf("fitbit token: %v\n", fitbit.Token.AccessToken)
	// fmt.Printf("strava token: %v\n", strava.Token.AccessToken)

	fmt.Println("- Retrieving activities")
	after, err := time.Parse("2006-Jan-02", "2021-Dec-31")
	if err != nil {
		panic(err)


@@ 37,12 59,68 @@ func main() {
		panic(err)
	}

	fmt.Printf("> found %d Fitbit activities in 2022\n", len(fitbitActivities))
	fmt.Printf("... found %d Fitbit activities in 2022\n", len(fitbitActivities))

	stravaActivities, err := fitstrava.StravaGetActivities(strava, after)
	if err != nil {
		panic(err)
	}

	fmt.Printf("> found %d Strava activities in 2022\n", len(stravaActivities))
	fmt.Printf("... found %d Strava activities in 2022\n", len(stravaActivities))

	alreadyInStrava := make(map[string]bool)
	for _, a := range stravaActivities {
		// map strava external ids to identify those already uploaded by Fitbit
		alreadyInStrava[a.ExternalID[:len(a.ExternalID)-4]] = true
		// pull in ids from the description from those this app synched
		// TODO: read description
		if a.Description != "" {
			fmt.Println("woo!", a.Description)
		}
	}

	unsynched := make([]fitstrava.FitbitActivity, 0)
	for _, a := range fitbitActivities {
		if _, inStrava := alreadyInStrava[strconv.FormatInt(a.LogID, 10)]; !inStrava {
			unsynched = append(unsynched, a)
		}
	}

	fmt.Printf("... %d Fitbit activities not in Strava\n", len(unsynched))

	type syncError struct {
		err      error
		activity fitstrava.FitbitActivity
	}

	errors := make([]syncError, 0)
	for _, a := range unsynched {
		fmt.Printf("> %s,%s,%s,%d\n", a.ActivityName, a.StartTime, a.OriginalStartTime, a.LogID)

		/*
			//                               2022-03-18T06:18:24.000-04:00
			if starttime, err := time.Parse("2006-01-02T15:04:05.999-07:00", a.StartTime); err == nil {
				if err := fitstrava.StravaCreateActivity(strava, fitstrava.StravaNewActivity{
					Name:           a.ActivityName,
					Type:           a.ActivityTypeName(fitbit),
					Description:    fmt.Sprintf(`{"fitbit_id": %d}`, a.LogID),
					StartDate:      starttime,
					ElapsedSeconds: a.Duration,
				}); err != nil {
					errors = append(errors, syncError{err: err, activity: a})
				}
			} else {
				errors = append(errors, syncError{err: fmt.Errorf("could not parse StartTime: %v", err), activity: a})
			}
		*/
	}
	if err := fitstrava.StravaCreateActivity(strava, fitstrava.StravaNewActivity{
		Name:           "testing (42)",
		Type:           "Workout",
		Description:    fmt.Sprintf(`{"fitbit_id": %d}`, 42),
		StartDate:      time.Now(),
		ElapsedSeconds: 1,
	}); err != nil {
		errors = append(errors, syncError{err: err, activity: fitstrava.FitbitActivity{}})
	}
}

M fitbit.go => fitbit.go +42 -1
@@ 39,7 39,7 @@ func genCodeChallenge() (string, string) {

func fitbitCodeFromWeb(clientID, codeChallenge string) string {
	return codeFromWeb(8585,
		fmt.Sprintf("https://www.fitbit.com/oauth2/authorize?client_id=%s&response_type=code&code_challenge=%s&code_challenge_method=S256&scope=activity", clientID, codeChallenge))
		fmt.Sprintf("https://www.fitbit.com/oauth2/authorize?client_id=%s&response_type=code&code_challenge=%s&code_challenge_method=S256&scope=%s", clientID, codeChallenge, "activity%20heartrate%20location"))
}

type fitbitAuthToken struct {


@@ 160,6 160,47 @@ type FitbitActivity struct {
	TcxLink               string                      `json:"tcxLink"`
}

type fitbitActivityType struct {
	Activity activityType `json:"activity"`
}

type activityType struct {
	AccessLevel    string                `json:"accessLevel"`
	ActivityLevels []fitbitActivityLevel `json:"activityLevels"`
	HasSpeed       bool                  `json:"hasSpeed"`
	ID             int64                 `json:"id"`
	Name           string                `json:"name"`
}

type fitbitActivityLevel struct {
	ID          int64   `json:"id"`
	MaxSpeedMPH float64 `json:"maxSpeedMPH"`
	Mets        float64 `json:"mets"`
	MinSpeedMPH int64   `json:"minSpeedMPH"`
	Name        string  `json:"name"`
}

var fitbitActivityTypes = make(map[int64]fitbitActivityType, 0)

func (a FitbitActivity) ActivityTypeName(fitbit FitbitClient) string {
	t, ok := fitbitActivityTypes[a.ActivityTypeID]
	if !ok {
		resp, err := fitbitGet(fitbit, fmt.Sprintf("https://api.fitbit.com/1/activities/%d.json", a.ActivityTypeID))
		if err != nil {
			return err.Error()
		}
		defer resp.Body.Close()

		if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
			return err.Error()
		}

		fitbitActivityTypes[a.ActivityTypeID] = t
	}

	return t.Activity.Name
}

type FitbitActivityLevel struct {
	Minutes int64  `json:"minutes"`
	Name    string `json:"name"`

M strava.go => strava.go +169 -82
@@ 132,58 132,88 @@ func stravaGet(strava StravaClient, url string) (*http.Response, error) {
	return stravaHttp(strava, http.MethodGet, url, nil)
}

func stravaPostForm(strava StravaClient, url string, params url.Values) (*http.Response, error) {
	return stravaHttp(strava, http.MethodPost, url+"?"+params.Encode(), nil)
}

type stravaAthleteActivitiesResponse []StravaActivity

type StravaActivity struct {
	ResourceState        int64       `json:"resource_state"`
	Athlete              Athlete     `json:"athlete"`
	Name                 string      `json:"name"`
	Distance             float64     `json:"distance"`
	MovingTime           int64       `json:"moving_time"`
	ElapsedTime          int64       `json:"elapsed_time"`
	TotalElevationGain   float64     `json:"total_elevation_gain"`
	Type                 string      `json:"type"`
	WorkoutType          interface{} `json:"workout_type"`
	ID                   int64       `json:"id"`
	ExternalID           string      `json:"external_id"`
	UploadID             int64       `json:"upload_id"`
	StartDate            string      `json:"start_date"`
	StartDateLocal       string      `json:"start_date_local"`
	Timezone             string      `json:"timezone"`
	UTCOffset            float64     `json:"utc_offset"`
	StartLatlng          interface{} `json:"start_latlng"`
	EndLatlng            interface{} `json:"end_latlng"`
	LocationCity         interface{} `json:"location_city"`
	LocationState        interface{} `json:"location_state"`
	LocationCountry      string      `json:"location_country"`
	AchievementCount     int64       `json:"achievement_count"`
	KudosCount           int64       `json:"kudos_count"`
	CommentCount         int64       `json:"comment_count"`
	AthleteCount         int64       `json:"athlete_count"`
	PhotoCount           int64       `json:"photo_count"`
	Map                  Map         `json:"map"`
	Trainer              bool        `json:"trainer"`
	Commute              bool        `json:"commute"`
	Manual               bool        `json:"manual"`
	Private              bool        `json:"private"`
	Flagged              bool        `json:"flagged"`
	GearID               string      `json:"gear_id"`
	FromAcceptedTag      bool        `json:"from_accepted_tag"`
	AverageSpeed         float64     `json:"average_speed"`
	MaxSpeed             float64     `json:"max_speed"`
	AverageCadence       float64     `json:"average_cadence"`
	AverageWatts         float64     `json:"average_watts"`
	WeightedAverageWatts int64       `json:"weighted_average_watts"`
	Kilojoules           float64     `json:"kilojoules"`
	DeviceWatts          bool        `json:"device_watts"`
	HasHeartrate         bool        `json:"has_heartrate"`
	AverageHeartrate     float64     `json:"average_heartrate"`
	MaxHeartrate         float64     `json:"max_heartrate"`
	MaxWatts             int64       `json:"max_watts"`
	PRCount              int64       `json:"pr_count"`
	TotalPhotoCount      int64       `json:"total_photo_count"`
	HasKudoed            bool        `json:"has_kudoed"`
	SufferScore          int64       `json:"suffer_score"`
	ResourceState              int64             `json:"resource_state"`
	Athlete                    Athlete           `json:"athlete"`
	Name                       string            `json:"name"`
	Distance                   float64           `json:"distance"`
	MovingTime                 int64             `json:"moving_time"`
	ElapsedTime                int64             `json:"elapsed_time"`
	TotalElevationGain         float64           `json:"total_elevation_gain"`
	Type                       string            `json:"type"`
	WorkoutType                interface{}       `json:"workout_type,omitempty"`
	ID                         int64             `json:"id"`
	ExternalID                 string            `json:"external_id,omitempty"`
	UploadID                   int64             `json:"upload_id,omitempty"`
	StartDate                  string            `json:"start_date"`
	StartDateLocal             string            `json:"start_date_local"`
	Timezone                   string            `json:"timezone"`
	UTCOffset                  float64           `json:"utc_offset"`
	StartLatlng                interface{}       `json:"start_latlng"`
	EndLatlng                  interface{}       `json:"end_latlng"`
	LocationCity               interface{}       `json:"location_city"`
	LocationState              interface{}       `json:"location_state"`
	LocationCountry            string            `json:"location_country"`
	AchievementCount           int64             `json:"achievement_count"`
	KudosCount                 int64             `json:"kudos_count"`
	CommentCount               int64             `json:"comment_count"`
	AthleteCount               int64             `json:"athlete_count"`
	PhotoCount                 int64             `json:"photo_count"`
	Map                        Map               `json:"map"`
	Trainer                    bool              `json:"trainer"`
	Commute                    bool              `json:"commute"`
	Manual                     bool              `json:"manual"`
	Private                    bool              `json:"private"`
	Visibility                 string            `json:"visibility"`
	Flagged                    bool              `json:"flagged"`
	GearID                     interface{}       `json:"gear_id"`
	StartLatitude              interface{}       `json:"start_latitude"`
	StartLongitude             interface{}       `json:"start_longitude"`
	AverageSpeed               float64           `json:"average_speed"`
	MaxSpeed                   float64           `json:"max_speed"`
	AverageCadence             float64           `json:"average_cadence,omitempty"`
	AverageWatts               float64           `json:"average_watts,omitempty"`
	WeightedAverageWatts       int64             `json:"weighted_average_watts,omitempty"`
	Kilojoules                 float64           `json:"kilojoules,omitempty"`
	DeviceWatts                bool              `json:"device_watts,omitempty"`
	HasHeartrate               bool              `json:"has_heartrate"`
	AverageHeartrate           float64           `json:"average_heartrate,omitempty"`
	MaxHeartrate               float64           `json:"max_heartrate,omitempty"`
	MaxWatts                   int64             `json:"max_watts,omitempty"`
	HeartrateOptOut            bool              `json:"heartrate_opt_out"`
	DisplayHideHeartrateOption bool              `json:"display_hide_heartrate_option"`
	FromAcceptedTag            bool              `json:"from_accepted_tag"`
	PRCount                    int64             `json:"pr_count"`
	TotalPhotoCount            int64             `json:"total_photo_count"`
	HasKudoed                  bool              `json:"has_kudoed"`
	SufferScore                int64             `json:"suffer_score,omitempty"`
	Description                string            `json:"description"`
	Calories                   int64             `json:"calories"`
	PerceivedExertion          interface{}       `json:"perceived_exertion"`
	PreferPerceivedExertion    interface{}       `json:"prefer_perceived_exertion"`
	SegmentEfforts             []interface{}     `json:"segment_efforts"`
	Photos                     Photos            `json:"photos"`
	StatsVisibility            []StatsVisibility `json:"stats_visibility"`
	HideFromHome               bool              `json:"hide_from_home"`
	EmbedToken                 string            `json:"embed_token"`
	PrivateNote                interface{}       `json:"private_note"`
	AvailableZones             []interface{}     `json:"available_zones"`
}

type Photos struct {
	Primary interface{} `json:"primary"`
	Count   int64       `json:"count"`
}

type StatsVisibility struct {
	Type       string `json:"type"`
	Visibility string `json:"visibility"`
}

type Map struct {


@@ 192,6 222,14 @@ type Map struct {
	ResourceState   int64       `json:"resource_state"`
}

func stravaHttpError(body io.ReadCloser, msg string) error {
	var serr stravaErrorResponse
	if err := json.NewDecoder(body).Decode(&serr); err != nil {
		return fmt.Errorf("%s", msg)
	}
	return fmt.Errorf("%s: %s", msg, serr.Message)
}

func StravaGetActivities(strava StravaClient, after time.Time) ([]StravaActivity, error) {
	activities := make([]StravaActivity, 0)



@@ 209,11 247,14 @@ func StravaGetActivities(strava StravaClient, after time.Time) ([]StravaActivity
		defer resp.Body.Close()

		if resp.StatusCode != http.StatusOK {
			var serr stravaErrorResponse
			if err := json.NewDecoder(resp.Body).Decode(&serr); err != nil {
				return nil, fmt.Errorf("could not get Strava activities: %s", resp.Status)
			}
			return nil, fmt.Errorf("could not get Strava activities: %s: %s", resp.Status, serr.Message)
			return nil, stravaHttpError(resp.Body, fmt.Sprintf("could not get Strava activities: %s", resp.Status))
			/*
				var serr stravaErrorResponse
				if err := json.NewDecoder(resp.Body).Decode(&serr); err != nil {
					return nil, fmt.Errorf("could not get Strava activities: %s", resp.Status)
				}
				return nil, fmt.Errorf("could not get Strava activities: %s: %s", resp.Status, serr.Message)
			*/
		}

		var stravaResp stravaAthleteActivitiesResponse


@@ 232,45 273,91 @@ func StravaGetActivities(strava StravaClient, after time.Time) ([]StravaActivity
	return activities, nil
}

func StravaCreateActivity(strava StravaClient) error {
	/*
		client := gostrava.NewClient(accessToken)
		service := gostrava.NewActivitiesService(client)
/*
	name required String, in form 	The name of the activity.
	type required String, in form 	Type of activity. For example - Run, Ride etc.
	start_date_local required Date, in form 	ISO 8601 formatted date time.
	elapsed_time required Integer, in form 	In seconds.
	description String, in form 	Description of the activity.
	distance Float, in form 	In meters.
*/
type StravaNewActivity struct {
	Name, Type, Description string
	StartDate               time.Time
	ElapsedSeconds          int64
	// Distance float64
}

		fmt.Printf("Uploading data...\n")
func StravaCreateActivity(strava StravaClient, activity StravaNewActivity) error {
	p := url.Values{}
	p.Set("name", activity.Name)
	p.Set("type", activity.Type)
	p.Set("description", activity.Description)
	p.Set("start_date_local", activity.StartDate.Format(time.RFC3339))
	p.Set("elapsed_time", strconv.FormatInt(activity.ElapsedSeconds, 10))
	p.Set("hide_from_home", "true")
	p.Set("external_id", "42")

		created, err := service.
			Create(name, gostrava.ActivityType.Yoga, startDateLocal time.Time, elapsedTime int)
			Description(string).
			Distance(float64).
			Private().
			Do()
		if err != nil {
			if e, ok := err.(strava.Error); ok && e.Message == "Authorization Error" {
				log.Printf("Make sure your token has 'write' permissions. You'll need implement the oauth process to get one")
			}
	// p.Set("perceived_exertion", "2")
	// p.Set("private_note", "this is private")

			log.Fatal(err)
		}
	resp, err := stravaPostForm(strava, "https://www.strava.com/api/v3/activities", p)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusCreated {
		/*
			var serr stravaErrorResponse
			if err := json.NewDecoder(resp.Body).Decode(&serr); err != nil {
				return fmt.Errorf("could not create Strava activity: %s", resp.Status)
			}
			return fmt.Errorf("could not create Strava activity: %s: %s", resp.Status, serr.Message)
		*/
		return stravaHttpError(resp.Body, fmt.Sprintf("could not create Strava activity: %s", resp.Status))
	}

		log.Printf("Upload Complete...")
		jsonForDisplay, _ := json.Marshal(created)
		log.Printf(string(jsonForDisplay))
	var newactivity StravaActivity
	if err := json.NewDecoder(resp.Body).Decode(&newactivity); err != nil {
		return fmt.Errorf("could not parse new Strava activity: %v", err)
	}

		log.Printf("Waiting a 5 seconds so the upload will finish (might not)")
		time.Sleep(5 * time.Second)
	/*
		p = url.Values{}
		p.Set("_method", "patch")
		p.Set("perceived_exertion", "2")
		p.Set("private_note", "this is private")

		uploadSummary, err := service.Get(upload.Id).Do()
		jsonForDisplay, _ = json.Marshal(uploadSummary)
		log.Printf(string(jsonForDisplay))
		// https://www.strava.com/activities/6786571466
		resp, err = stravaPut(strava, fmt.Sprintf("https://www.strava.com/api/v3/activities/%d", newactivity.ID), p)
		if err != nil {
			return err
		}

		log.Printf("Your new activity is id %d", uploadSummary.ActivityId)
		log.Printf("You can view it at http://www.strava.com/activities/%d", uploadSummary.ActivityId)
		if resp.StatusCode != http.StatusFound {
			return fmt.Errorf("could not post a patch: %s", resp.Status)
		}
	*/

	return nil
}

func StravaCreateYogaActivity() {
func StravaGetActivityByID(strava StravaClient, id int64) (StravaActivity, error) {
	resp, err := stravaGet(strava, fmt.Sprintf("https://www.strava.com/api/v3/activities/%d", id))
	if err != nil {
		return StravaActivity{}, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return StravaActivity{}, stravaHttpError(resp.Body, fmt.Sprintf("could not create Strava activity: %s", resp.Status))
	}

	var activity StravaActivity
	if err := json.NewDecoder(resp.Body).Decode(&activity); err != nil {
		return StravaActivity{}, fmt.Errorf("could not parse Strava activity: %v", err)
	}

	return activity, nil
}

M utils.go => utils.go +2 -1
@@ 29,7 29,8 @@ func codeFromWeb(port int, authURL string) string {
		ch <- code[0]

		rw.WriteHeader(http.StatusOK)
		rw.Write([]byte("<html><head><script>setTimeout(function(){window.close();}, 1000);</script></head><body>Done</body></html>"))
		rw.Write([]byte("<html><head><script>setInterval(function(){window.close();}, 1000);</script></head><body>Done</body></html>"))
		// rw.Write([]byte("<html><head><script>window.onload = function() { setInterval(function(){window.close();}, 1000); }</script></head><body>Done</body></html>"))

	}))