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