feat(strava): new segment

This commit is contained in:
Lars Erik Finholt 2021-12-30 07:57:27 +00:00 committed by Jan De Dobbeleer
parent 88206ff9c9
commit d2bf556e94
9 changed files with 725 additions and 0 deletions

View file

@ -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:
<a href="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&state=strava">
<StravaConnect/>
</a>
## 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

View file

@ -70,6 +70,7 @@ module.exports = {
"session",
"shell",
"spotify",
"strava",
"sysinfo",
"terraform",
"text",

14
docs/static/img/strava_connect.svg vendored Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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{}

View file

@ -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{},

237
src/segment_strava.go Normal file
View file

@ -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
}

198
src/segment_strava_test.go Normal file
View file

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

View file

@ -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": "<transparent>\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": "<parentBackground>\uE0B0</> "
}
}
]
}
],
"final_space": true,
"console_title": true,
"console_title_style": "template",
"console_title_template": "{{ .Shell }} in {{ .Folder }}"
}

View file

@ -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": {