diff --git a/docs/docs/segment-strava.mdx b/docs/docs/segment-strava.mdx new file mode 100644 index 00000000..1f3a07a6 --- /dev/null +++ b/docs/docs/segment-strava.mdx @@ -0,0 +1,90 @@ +--- +id: strava +title: Strava +sidebar_label: Strava +--- + +import StravaConnect from '/img/strava_connect.svg'; + +## What + +[Strava][strava] ia a popular activity tracker for bike, run or any other traning. +To keep up with your training goals it is important to be constantly reminded about it. +A Strava Oh My Posh segment would show your last activity, +and also indicate by a color if it is time to get away from your computer and do some workout. + +## Accessing your Strava data + +To allow Oh My Posh access your Strava data you need to grant access to read your public activities. +This will give you an access and a refresh token. Paste the tokens into your strava segment configuration. + +Click the following link to connect with Strava: + + + + + +## Sample Configuration + +This configuration sets the background green if you have an activity the last two days, +orange if you have one last 5 days, and red else. The `foreground_templates` example below could be set to just a single color, +if that color is visible against any of your backgrounds. + +```json +{ + "type": "strava", + "style": "powerline", + "powerline_symbol": "\uE0B0", + "foreground": "#ffffff", + "background": "#000000", + "background_templates": [ + "{{ if gt .Hours 100 }}#dc3545{{ end }}", + "{{ if and (lt .Hours 100) (gt .Hours 50) }}#ffc107{{ end }}", + "{{ if lt .Hours 50 }}#28a745{{ end }}" + ], + "foreground_templates": [ + "{{ if gt .Hours 100 }}#FFFFFF{{ end }}", + "{{ if and (lt .Hours 100) (gt .Hours 50) }}#343a40{{ end }}", + "{{ if lt .Hours 50 }}#FFFFFF{{ end }}" + ], + "properties": { + "access_token":"11111111111111111", + "refresh_token":"1111111111111111", + "http_timeout": 1500, + "template": "{{.Name}} {{.Ago}} {{.ActivityIcon}}" + } +} +``` + +## Properties + +- access_token: `string` - token from Strava login, see login link in section above. It has the following format: `1111111111111111111111111` +- refresh_token: `string` - token from Strava login, see login link in section above. It has the following format: `1111111111111111111111111` +- http_timeout: `int` - how long do you want to wait before you want to see your prompt more than your strava data? - defaults to 500ms +- template: `string` - a go [text/template][go-text-template] template extended with [sprig][sprig] utilizing the properties below. +See the example above. Make sure your NerdFont has the glyph you want or search for one at nerdfonts.com +- CacheTimeout: `int` in minutes - How long do you want your numbers cached? - defaults to 5 min +- RideIcon - defaults to `\uf5a2` +- RunIcon - defaults to `\ufc0c` +- SkiingIcon - defaults to `\ue213` +- WorkOutIcon - defaults to `\ue213` +- UnknownActivityIcon - defaults to `\ue213` + +## Template Properties + +The properties below are availible for use in your template + +- `.DateString`: `time` - The timestamp of the entry +- `.Type`: `string` - Activity types as used in strava +- `.UtcOffset`: `int` - The UTC offset +- `.Hours`: `int` - Number of hours since last activity +- `.Name`: `string` - The name of the activity +- `.Duration`: `float64` - Total duration in seconds +- `.Distance`: `float64` - Toatal distance in meters + +Now, go out and have a fun ride or run! + +[go-text-template]: https://golang.org/pkg/text/template/ +[sprig]: https://masterminds.github.io/sprig/ +[strava]: http://www.strava.com/ +[strava-connect]: https://www.strava.com/oauth/authorize?client_id=76033&response_type=code&redirect_uri=https://ohmyposh.dev/api/auth&approval_prompt=force&scope=read,activity:read diff --git a/docs/sidebars.js b/docs/sidebars.js index 4f980f42..95b0d003 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -70,6 +70,7 @@ module.exports = { "session", "shell", "spotify", + "strava", "sysinfo", "terraform", "text", diff --git a/docs/static/img/strava_connect.svg b/docs/static/img/strava_connect.svg new file mode 100644 index 00000000..04f0e813 --- /dev/null +++ b/docs/static/img/strava_connect.svg @@ -0,0 +1,14 @@ + + + + btn_strava_connectwith_orange + Created with Sketch. + + + + + + + + + \ No newline at end of file diff --git a/src/properties.go b/src/properties.go index 4a56277f..dc0dab54 100644 --- a/src/properties.go +++ b/src/properties.go @@ -35,6 +35,10 @@ const ( DisplayError Property = "display_error" // DisplayDefault hides or shows the default DisplayDefault Property = "display_default" + // AccessToken is the access token to use for an API + AccessToken Property = "access_token" + // RefreshToken is the refresh token to use for an API + RefreshToken Property = "refresh_token" ) type properties map[Property]interface{} diff --git a/src/segment.go b/src/segment.go index 12d18fcf..8a57f8fd 100644 --- a/src/segment.go +++ b/src/segment.go @@ -148,6 +148,8 @@ const ( PHP SegmentType = "php" // Nightscout is an open source diabetes system Nightscout SegmentType = "nightscout" + // Strava is a sports activity tracker + Strava SegmentType = "strava" // Wakatime writes tracked time spend in dev editors Wakatime SegmentType = "wakatime" // WiFi writes details about the current WiFi connection @@ -287,6 +289,7 @@ func (segment *Segment) mapSegmentWithWriter(env Environment) error { Angular: &angular{}, PHP: &php{}, Nightscout: &nightscout{}, + Strava: &strava{}, Wakatime: &wakatime{}, WiFi: &wifi{}, WinReg: &winreg{}, diff --git a/src/segment_strava.go b/src/segment_strava.go new file mode 100644 index 00000000..2bb64c4f --- /dev/null +++ b/src/segment_strava.go @@ -0,0 +1,237 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "net/http" + "time" +) + +// segment struct, makes templating easier +type strava struct { + props Properties + env Environment + + StravaData + ActivityIcon string + Ago string + Hours int + Authenticate bool + Error string +} + +const ( + RideIcon Property = "ride_icon" + RunIcon Property = "run_icon" + SkiingIcon Property = "skiing_icon" + WorkOutIcon Property = "workout_icon" + UnknownActivityIcon Property = "unknown_activity_icon" + + StravaAccessToken = "strava_access_token" + StravaRefreshToken = "strava_refresh_token" + + Timeout = "timeout" + InvalidRefreshToken = "invalid refresh token" + TokenRefreshFailed = "token refresh error" +) + +// StravaData struct contains the API data +type StravaData struct { + Type string `json:"type"` + StartDate time.Time `json:"start_date"` + Name string `json:"name"` + Distance float64 `json:"distance"` + Duration float64 `json:"moving_time"` +} + +type TokenExchange struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` +} + +type AuthError struct { + message string +} + +func (a *AuthError) Error() string { + return a.message +} + +func (s *strava) enabled() bool { + data, err := s.getResult() + if err == nil { + s.StravaData = *data + s.ActivityIcon = s.getActivityIcon() + s.Hours = s.getHours() + s.Ago = s.getAgo() + return true + } + if _, s.Authenticate = err.(*AuthError); s.Authenticate { + s.Error = err.(*AuthError).Error() + return true + } + return false +} + +func (s *strava) getHours() int { + hours := time.Since(s.StartDate).Hours() + return int(math.Floor(hours)) +} + +func (s *strava) getAgo() string { + if s.Hours > 24 { + days := int32(math.Floor(float64(s.Hours) / float64(24))) + return fmt.Sprintf("%d", days) + string('d') + } + return fmt.Sprintf("%d", s.Hours) + string("h") +} + +func (s *strava) getActivityIcon() string { + switch s.Type { + case "Ride": + return s.props.getString(RideIcon, "\uf5a2") + case "Run": + return s.props.getString(RunIcon, "\ufc0c") + case "NordicSki": + case "AlpineSki": + case "BackcountrySki": + return s.props.getString(SkiingIcon, "\ue213") + case "WorkOut": + return s.props.getString(WorkOutIcon, "\ue213") + default: + return s.props.getString(UnknownActivityIcon, "\ue213") + } + return s.props.getString(UnknownActivityIcon, "\ue213") +} + +func (s *strava) string() string { + if s.Error != "" { + return s.Error + } + segmentTemplate := s.props.getString(SegmentTemplate, "{{ .Ago }}") + template := &textTemplate{ + Template: segmentTemplate, + Context: s, + Env: s.env, + } + text, err := template.render() + if err != nil { + return err.Error() + } + return text +} + +func (s *strava) getAccessToken() (string, error) { + // get directly from cache + if acccessToken, OK := s.env.cache().get(StravaAccessToken); OK { + return acccessToken, nil + } + // use cached refersh token to get new access token + if refreshToken, OK := s.env.cache().get(StravaRefreshToken); OK { + if acccessToken, err := s.refreshToken(refreshToken); err == nil { + return acccessToken, nil + } + } + // use initial refresh token from property + refreshToken := s.props.getString(RefreshToken, "") + if len(refreshToken) == 0 { + return "", &AuthError{ + message: InvalidRefreshToken, + } + } + // no need to let the user provide access token, we'll always verify the refresh token + acccessToken, err := s.refreshToken(refreshToken) + return acccessToken, err +} + +func (s *strava) refreshToken(refreshToken string) (string, error) { + httpTimeout := s.props.getInt(HTTPTimeout, DefaultHTTPTimeout) + url := fmt.Sprintf("https://ohmyposh.dev/api/refresh?segment=strava&token=%s", refreshToken) + body, err := s.env.HTTPRequest(url, httpTimeout) + if err != nil { + return "", &AuthError{ + // This might happen if /api was asleep. Assume the user will just retry + message: Timeout, + } + } + tokens := &TokenExchange{} + err = json.Unmarshal(body, &tokens) + if err != nil { + return "", &AuthError{ + message: TokenRefreshFailed, + } + } + // add tokens to cache + s.env.cache().set(StravaAccessToken, tokens.AccessToken, tokens.ExpiresIn/60) + s.env.cache().set(StravaRefreshToken, tokens.RefreshToken, 2*525960) // it should never expire unless revoked, default to 2 year + return tokens.AccessToken, nil +} + +func (s *strava) getResult() (*StravaData, error) { + parseSingleElement := func(data []byte) (*StravaData, error) { + var result []*StravaData + err := json.Unmarshal(data, &result) + if err != nil { + return nil, err + } + if len(result) == 0 { + return nil, errors.New("no elements in the array") + } + return result[0], nil + } + getCacheValue := func(key string) (*StravaData, error) { + val, found := s.env.cache().get(key) + // we got something from the cache + if found { + if data, err := parseSingleElement([]byte(val)); err == nil { + return data, nil + } + } + return nil, errors.New("no data in cache") + } + + // We only want the last activity + url := "https://www.strava.com/api/v3/athlete/activities?page=1&per_page=1" + httpTimeout := s.props.getInt(HTTPTimeout, DefaultHTTPTimeout) + + // No need to check more the every 30 min + cacheTimeout := s.props.getInt(CacheTimeout, 30) + if cacheTimeout > 0 { + if data, err := getCacheValue(url); err == nil { + return data, nil + } + } + accessToken, err := s.getAccessToken() + if err != nil { + return nil, err + } + addAuthHeader := func(request *http.Request) { + request.Header.Add("Authorization", "Bearer "+accessToken) + } + body, err := s.env.HTTPRequest(url, httpTimeout, addAuthHeader) + if err != nil { + return nil, err + } + var arr []*StravaData + err = json.Unmarshal(body, &arr) + if err != nil { + return nil, err + } + data, err := parseSingleElement(body) + if err != nil { + return nil, err + } + if cacheTimeout > 0 { + // persist new sugars in cache + s.env.cache().set(url, string(body), cacheTimeout) + } + return data, nil +} + +func (s *strava) init(props Properties, env Environment) { + s.props = props + s.env = env +} diff --git a/src/segment_strava_test.go b/src/segment_strava_test.go new file mode 100644 index 00000000..1a46bd52 --- /dev/null +++ b/src/segment_strava_test.go @@ -0,0 +1,198 @@ +package main + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestStravaSegment(t *testing.T) { + h, _ := time.ParseDuration("6h") + sixHoursAgo := time.Now().Add(-h).Format(time.RFC3339) + h, _ = time.ParseDuration("100h") + fourDaysAgo := time.Now().Add(-h).Format(time.RFC3339) + + cases := []struct { + Case string + JSONResponse string + AccessToken string + RefreshToken string + AccessTokenCacheFoundFail bool + RefreshTokenCacheFoundFail bool + InitialAccessToken string + InitialRefreshToken string + TokenRefreshToken string + TokenResponse string + TokenTest bool + ExpectedString string + ExpectedEnabled bool + CacheTimeout int + CacheFoundFail bool + Template string + Error error + AuthDebugMsg string + }{ + { + Case: "No initial tokens", + InitialAccessToken: "", + AccessTokenCacheFoundFail: true, + RefreshTokenCacheFoundFail: true, + TokenTest: true, + AuthDebugMsg: "invalid refresh token", + }, + { + Case: "Use initial tokens", + AccessToken: "NEW_ACCESSTOKEN", + InitialAccessToken: "INITIAL ACCESSTOKEN", + InitialRefreshToken: "INITIAL REFRESHTOKEN", + TokenRefreshToken: "INITIAL REFRESHTOKEN", + TokenResponse: `{ "access_token":"NEW_ACCESSTOKEN","refresh_token":"NEW_REFRESHTOKEN", "expires_in":1234 }`, + AccessTokenCacheFoundFail: true, + RefreshTokenCacheFoundFail: true, + TokenTest: true, + }, + { + Case: "Access token from cache", + AccessToken: "ACCESSTOKEN", + TokenTest: true, + }, + { + Case: "Refresh token from cache", + AccessTokenCacheFoundFail: true, + RefreshTokenCacheFoundFail: false, + RefreshToken: "REFRESHTOKEN", + TokenRefreshToken: "REFRESHTOKEN", + TokenTest: true, + AuthDebugMsg: "invalid refresh token", + }, + { + Case: "Ride 6", + JSONResponse: ` + [{"type":"Ride","start_date":"` + sixHoursAgo + `","name":"Sesongens første på tjukkas","distance":16144.0}]`, + Template: "{{.Ago}} {{.ActivityIcon}}", + ExpectedString: "6h \uf5a2", + ExpectedEnabled: true, + }, + { + Case: "Run 100", + JSONResponse: ` + [{"type":"Run","start_date":"` + fourDaysAgo + `","name":"Sesongens første på tjukkas","distance":16144.0,"moving_time":7665}]`, + Template: "{{.Ago}} {{.ActivityIcon}}", + ExpectedString: "4d \ufc0c", + ExpectedEnabled: true, + }, + { + Case: "Error in retrieving data", + JSONResponse: "nonsense", + Error: errors.New("Something went wrong"), + ExpectedEnabled: false, + }, + { + Case: "Empty array", + JSONResponse: "[]", + ExpectedEnabled: false, + }, + { + Case: "Run from cache", + JSONResponse: ` + [{"type":"Run","start_date":"` + fourDaysAgo + `","name":"Sesongens første på tjukkas","distance":16144.0,"moving_time":7665}]`, + Template: "{{.Ago}} {{.ActivityIcon}}", + ExpectedString: "4d \ufc0c", + ExpectedEnabled: true, + CacheTimeout: 10, + }, + { + Case: "Run from not found cache", + JSONResponse: ` + [{"type":"Run","start_date":"` + fourDaysAgo + `","name":"Morning ride","distance":16144.0,"moving_time":7665}]`, + Template: "{{.Ago}} {{.ActivityIcon}} {{.Name}} {{.Hours}}h ago", + ExpectedString: "4d \ufc0c Morning ride 100h ago", + ExpectedEnabled: true, + CacheTimeout: 10, + CacheFoundFail: true, + }, + { + Case: "Error parsing response", + JSONResponse: ` + 4tffgt4e4567`, + Template: "{{.Ago}}{{.ActivityIcon}}", + ExpectedString: "50", + ExpectedEnabled: false, + CacheTimeout: 10, + }, + { + Case: "Faulty template", + JSONResponse: ` + [{"sgv":50,"direction":"DoubleDown"}]`, + Template: "{{.Ago}}{{.Burp}}", + ExpectedString: incorrectTemplate, + ExpectedEnabled: true, + CacheTimeout: 10, + }, + } + + for _, tc := range cases { + env := &MockedEnvironment{} + url := "https://www.strava.com/api/v3/athlete/activities?page=1&per_page=1" + tokenURL := fmt.Sprintf("https://ohmyposh.dev/api/refresh?segment=strava&token=%s", tc.TokenRefreshToken) + var props properties = map[Property]interface{}{ + CacheTimeout: tc.CacheTimeout, + } + cache := &MockedCache{} + cache.On("get", url).Return(tc.JSONResponse, !tc.CacheFoundFail) + cache.On("set", url, tc.JSONResponse, tc.CacheTimeout).Return() + + cache.On("get", StravaAccessToken).Return(tc.AccessToken, !tc.AccessTokenCacheFoundFail) + cache.On("get", StravaRefreshToken).Return(tc.RefreshToken, !tc.RefreshTokenCacheFoundFail) + + cache.On("set", StravaRefreshToken, "NEW_REFRESHTOKEN", 2*525960) + cache.On("set", StravaAccessToken, "NEW_ACCESSTOKEN", 20) + + env.On("HTTPRequest", url).Return([]byte(tc.JSONResponse), tc.Error) + env.On("HTTPRequest", tokenURL).Return([]byte(tc.TokenResponse), tc.Error) + env.On("cache", nil).Return(cache) + + if tc.Template != "" { + props[SegmentTemplate] = tc.Template + } + if tc.InitialAccessToken != "" { + props[AccessToken] = tc.InitialAccessToken + } + if tc.InitialRefreshToken != "" { + props[RefreshToken] = tc.InitialRefreshToken + } + + ns := &strava{ + props: props, + env: env, + } + + if tc.TokenTest { + // continue + at, err := ns.getAccessToken() + if err != nil { + if authErr, ok := err.(*AuthError); ok { + assert.Equal(t, tc.AuthDebugMsg, authErr.Error(), tc.Case) + } else { + assert.Equal(t, tc.Error, err, tc.Case) + } + } else { + assert.Equal(t, tc.AccessToken, at, tc.Case) + } + continue + } + + enabled := ns.enabled() + assert.Equal(t, tc.ExpectedEnabled, enabled, tc.Case) + if !enabled { + continue + } + + var a = ns.string() + + assert.Equal(t, tc.ExpectedString, a, tc.Case) + } +} diff --git a/themes/larserikfinholt.omp.json b/themes/larserikfinholt.omp.json new file mode 100644 index 00000000..85a31e04 --- /dev/null +++ b/themes/larserikfinholt.omp.json @@ -0,0 +1,112 @@ +{ + "$schema": "https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json", + "blocks": [ + { + "type": "prompt", + "alignment": "left", + "segments": [ + { + "type": "session", + "style": "diamond", + "foreground": "#ffffff", + "background": "#c386f1", + "leading_diamond": "", + "trailing_diamond": "\uE0B0", + "properties": { + "template": "{{ .UserName }}" + } + }, + { + "type": "path", + "style": "powerline", + "powerline_symbol": "\uE0B0", + "foreground": "#ffffff", + "background": "#ff479c", + "properties": { + "prefix": "  ", + "home_icon": "~", + "folder_separator_icon": " \uE0b1 ", + "style": "folder" + } + }, + { + "type": "git", + "style": "powerline", + "powerline_symbol": "\uE0B0", + "foreground": "#193549", + "background": "#fffb38", + "background_templates": [ + "{{ if or (.Working.Changed) (.Staging.Changed) }}#FF9248{{ end }}", + "{{ if and (gt .Ahead 0) (gt .Behind 0) }}#ff4500{{ end }}", + "{{ if gt .Ahead 0 }}#B388FF{{ end }}", + "{{ if gt .Behind 0 }}#B388FF{{ end }}" + ], + "leading_diamond": "", + "trailing_diamond": "", + "properties": { + "fetch_status": true, + "fetch_stash_count": true, + "fetch_upstream_icon": true, + "branch_max_length": 25, + "template": "{{ .UpstreamIcon }}{{ .HEAD }}{{ .BranchStatus }}{{ if .Working.Changed }} \uF044 {{ .Working.String }}{{ end }}{{ if and (.Working.Changed) (.Staging.Changed) }} |{{ end }}{{ if .Staging.Changed }} \uF046 {{ .Staging.String }}{{ end }}{{ if gt .StashCount 0 }} \uF692 {{ .StashCount }}{{ end }}" + } + }, + { + "type": "executiontime", + "style": "plain", + "foreground": "#ffffff", + "background": "#83769c", + "leading_diamond": "", + "trailing_diamond": "", + "properties": { + "always_enabled": true, + "prefix": "\uE0B0 \ufbab", + "postfix": "\u2800" + } + }, + { + "type": "strava", + "style": "powerline", + "foreground": "#ffffff", + "background": "#000000", + "background_templates": [ + "{{ if gt .Hours 100 }}#dc3545{{ end }}", + "{{ if and (lt .Hours 100) (gt .Hours 50) }}#ffc107{{ end }}", + "{{ if lt .Hours 50 }}#28a745{{ end }}" + ], + "foreground_templates": [ + "{{ if gt .Hours 100 }}#FFFFFF{{ end }}", + "{{ if and (lt .Hours 100) (gt .Hours 50) }}#343a40{{ end }}", + "{{ if lt .Hours 50 }}#FFFFFF{{ end }}" + ], + "leading_diamond": "", + "trailing_diamond": "", + "properties": { + "access_token": "0ccbd2ac1e37a5b84101468df3d367177fe02ab3", + "refresh_token": "111111111111111111111111111111", + "http_timeout": 1500, + "template": "{{.Name}} {{.Ago}} {{.ActivityIcon}}" + } + }, + { + "type": "exit", + "style": "diamond", + "foreground": "#ffffff", + "background": "#00897b", + "background_templates": ["{{ if gt .Code 0 }}#e91e63{{ end }}"], + "leading_diamond": "", + "trailing_diamond": "\uE0B4", + "properties": { + "always_enabled": true, + "template": "\uE23A", + "prefix": "\uE0B0 " + } + } + ] + } + ], + "final_space": true, + "console_title": true, + "console_title_style": "template", + "console_title_template": "{{ .Shell }} in {{ .Folder }}" +} diff --git a/themes/schema.json b/themes/schema.json index c0f8ad10..a4df40c1 100644 --- a/themes/schema.json +++ b/themes/schema.json @@ -178,6 +178,7 @@ "rust", "owm", "sysinfo", + "strava", "angular", "php", "wakatime", @@ -1604,6 +1605,71 @@ } } }, + { + "if": { + "properties": { + "type": { "const": "strava" } + } + }, + "then": { + "title": "Display training data from Strava", + "description": "https://ohmyposh.dev/docs/strava", + "properties": { + "properties": { + "properties": { + "url": { + "type": "string", + "title": "URL of API with Strava data", + "description": "Url of your api provinding a Strava activity", + "default": "" + }, + "ride_icon": { + "type": "string", + "title": "Alternative icon", + "description": "Alternative icon for this activity type", + "default": "\uf5a2" + }, + "run_icon": { + "type": "string", + "title": "Alternative icon", + "description": "Alternative icon for this activity type", + "default": "\ufc0c" + }, + "skiing_icon": { + "type": "string", + "title": "Alternative icon", + "description": "Alternative icon for this activity type", + "default": "\ue213" + }, + "workout_icon": { + "type": "string", + "title": "Alternative icon", + "description": "Alternative icon for this activity type", + "default": "\ue213" + }, + "unknown_activity_icon": { + "type": "string", + "title": "Fallback icon", + "description": "Fallback icon for other activity types", + "default": "\ue213" + }, + "http_timeout": { + "$ref": "#/definitions/http_timeout" + }, + "cache_timeout": { + "type": "integer", + "title": "cache timeout", + "description": "The number of minutes the response is cached. A value of 0 disables the cache.", + "default": 10 + }, + "template": { + "$ref": "#/definitions/template" + } + } + } + } + } + }, { "if": { "properties": {