diff --git a/docs/docs/segment-wakatime.md b/docs/docs/segment-wakatime.md new file mode 100644 index 00000000..4fa65048 --- /dev/null +++ b/docs/docs/segment-wakatime.md @@ -0,0 +1,57 @@ +--- +id: wakatime +title: Wakatime +sidebar_label: Wakatime +--- + +## What + +Shows the tracked time on [wakatime][wt] of the current day + +:::caution + +You **must** request an API key at the [wakatime][wt] website. +The free tier for is sufficient. You'll find the API key in your profile settings page. + +::: + +## Sample Configuration + +```json +{ + "type": "wakatime", + "style": "powerline", + "powerline_symbol": "\uE0B0", + "foreground": "#ffffff", + "background": "#007acc", + "properties": { + "prefix": " \uf7d9 ", + "url": "https://wakatime.com/api/v1/users/current/summaries?start=today&end=today&api_key=API_KEY", + "cache_timeout": 10, + "http_timeout": 500 + } +}, +``` + +## Properties + +- url: `string` - Your Wakatime [summaries][wk-summaries] URL, including the API key. Example above. You'll know this +works if you can curl it yourself and a result. - defaults to `` +- http_timeout: `int` - The default timeout for http request is 20ms. If no segment is shown, try increasing this timeout. +- cache_timeout: `int` - The default timeout for request caching is 10m. A value of 0 disables the cache. +- template: `string` - A go [text/template][go-text-template] template extended with [sprig][sprig] utilizing the +properties below - defaults to `{{ secondsRound .CummulativeTotal.Seconds }}` + +## Template Properties + +- `.CummulativeTotal`: `wtTotals` - object holding total tracked time values + +### wtTotal Properties + +- `.Seconds`: `int` - a number reprecenting the total tracked time in seconds +- `.Text`: `string` - a string with human readable tracked time (eg: "2 hrs 30 mins") + +[wt]: https://wakatime.com +[wk-summaries]: https://wakatime.com/developers#summaries +[go-text-template]: https://golang.org/pkg/text/template/ +[sprig]: https://masterminds.github.io/sprig/ diff --git a/docs/sidebars.js b/docs/sidebars.js index 6cfeed7a..d8783f9c 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -73,6 +73,7 @@ module.exports = { "terraform", "text", "time", + "wakatime", "wifi", "winreg", "ytm", diff --git a/src/segment.go b/src/segment.go index 1fa36808..0aedb5d4 100644 --- a/src/segment.go +++ b/src/segment.go @@ -133,6 +133,8 @@ const ( PHP SegmentType = "php" // Nightscout is an open source diabetes system Nightscout SegmentType = "nightscout" + // Wakatime writes tracked time spend in dev editors + Wakatime SegmentType = "wakatime" // WiFi writes details about the current WiFi connection WiFi SegmentType = "wifi" // WinReg queries the Windows registry. @@ -266,6 +268,7 @@ func (segment *Segment) mapSegmentWithWriter(env environmentInfo) error { Angular: &angular{}, PHP: &php{}, Nightscout: &nightscout{}, + Wakatime: &wakatime{}, WiFi: &wifi{}, WinReg: &winreg{}, } diff --git a/src/segment_nightscout_test.go b/src/segment_nightscout_test.go index 3117e42d..2d7f5ddd 100644 --- a/src/segment_nightscout_test.go +++ b/src/segment_nightscout_test.go @@ -8,7 +8,7 @@ import ( ) const ( - NSAPIURL = "FAKE" + FAKEAPIURL = "FAKE" ) func TestNSSegment(t *testing.T) { @@ -136,10 +136,10 @@ func TestNSSegment(t *testing.T) { } cache := &MockedCache{} - cache.On("get", NSAPIURL).Return(tc.JSONResponse, !tc.CacheFoundFail) - cache.On("set", NSAPIURL, tc.JSONResponse, tc.CacheTimeout).Return() + cache.On("get", FAKEAPIURL).Return(tc.JSONResponse, !tc.CacheFoundFail) + cache.On("set", FAKEAPIURL, tc.JSONResponse, tc.CacheTimeout).Return() - env.On("doGet", NSAPIURL).Return([]byte(tc.JSONResponse), tc.Error) + env.On("doGet", FAKEAPIURL).Return([]byte(tc.JSONResponse), tc.Error) env.On("cache", nil).Return(cache) if tc.Template != "" { diff --git a/src/segment_wakatime.go b/src/segment_wakatime.go new file mode 100644 index 00000000..124f8c0f --- /dev/null +++ b/src/segment_wakatime.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" +) + +type wakatime struct { + props properties + env environmentInfo + + wtData +} + +type wtTotals struct { + Seconds float64 `json:"seconds"` + Text string `json:"text"` +} + +type wtData struct { + CummulativeTotal wtTotals `json:"cummulative_total"` + Start string `json:"start"` + End string `json:"end"` +} + +func (w *wakatime) enabled() bool { + err := w.setAPIData() + return err == nil +} + +func (w *wakatime) string() string { + segmentTemplate := w.props.getString(SegmentTemplate, "{{ secondsRound .CummulativeTotal.Seconds }}") + template := &textTemplate{ + Template: segmentTemplate, + Context: w, + Env: w.env, + } + text, err := template.render() + if err != nil { + return err.Error() + } + + return text +} + +func (w *wakatime) setAPIData() error { + url := w.props.getString(URL, "") + cacheTimeout := w.props.getInt(CacheTimeout, DefaultCacheTimeout) + if cacheTimeout > 0 { + // check if data stored in cache + if val, found := w.env.cache().get(url); found { + err := json.Unmarshal([]byte(val), &w.wtData) + if err != nil { + return err + } + return nil + } + } + + httpTimeout := w.props.getInt(HTTPTimeout, DefaultHTTPTimeout) + + body, err := w.env.doGet(url, httpTimeout) + if err != nil { + return err + } + err = json.Unmarshal(body, &w.wtData) + if err != nil { + return err + } + + if cacheTimeout > 0 { + w.env.cache().set(url, string(body), cacheTimeout) + } + return nil +} + +func (w *wakatime) init(props properties, env environmentInfo) { + w.props = props + w.env = env +} diff --git a/src/segment_wakatime_test.go b/src/segment_wakatime_test.go new file mode 100644 index 00000000..e2016f40 --- /dev/null +++ b/src/segment_wakatime_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWTTrackedTime(t *testing.T) { + cases := []struct { + Case string + Seconds int + Expected string + Template string + CacheTimeout int + CacheFoundFail bool + Error error + }{ + { + Case: "nothing tracked", + Seconds: 0, + Expected: "0s", + }, + { + Case: "25 minutes", + Seconds: 1500, + Expected: "25m", + }, + { + Case: "2 hours", + Seconds: 7200, + Expected: "2h", + }, + { + Case: "2h 45m", + Seconds: 9900, + Expected: "2h 45m", + }, + { + Case: "negative number", + Seconds: -9900, + Expected: "2h 45m", + }, + { + Case: "cache 2h 45m", + Seconds: 9900, + Expected: "2h 45m", + CacheTimeout: 20, + }, + { + Case: "no cache 2h 45m", + Seconds: 9900, + Expected: "2h 45m", + CacheTimeout: 20, + CacheFoundFail: true, + }, + { + Case: "api error", + Seconds: 2, + Expected: "0s", + CacheTimeout: 20, + CacheFoundFail: true, + Error: errors.New("api error"), + }, + } + + for _, tc := range cases { + env := &MockedEnvironment{} + + response := fmt.Sprintf(`{"cummulative_total": {"seconds": %.2f, "text": "x"}}`, float64(tc.Seconds)) + + env.On("doGet", FAKEAPIURL).Return([]byte(response), tc.Error) + + cache := &MockedCache{} + cache.On("get", FAKEAPIURL).Return(response, !tc.CacheFoundFail) + cache.On("set", FAKEAPIURL, response, tc.CacheTimeout).Return() + env.On("cache", nil).Return(cache) + + w := &wakatime{ + props: map[Property]interface{}{ + APIKey: "key", + CacheTimeout: tc.CacheTimeout, + URL: FAKEAPIURL, + }, + env: env, + } + + assert.ErrorIs(t, tc.Error, w.setAPIData(), tc.Case+" - Error") + assert.Equal(t, tc.Expected, w.string(), tc.Case+" - String") + } +} diff --git a/src/template.go b/src/template.go index 2e982e1a..276b2e66 100644 --- a/src/template.go +++ b/src/template.go @@ -6,8 +6,6 @@ import ( "reflect" "strings" "text/template" - - "github.com/Masterminds/sprig/v3" ) const ( @@ -48,7 +46,7 @@ func (t *textTemplate) renderPlainContextTemplate(context map[string]interface{} } func (t *textTemplate) render() (string, error) { - tmpl, err := template.New("title").Funcs(sprig.TxtFuncMap()).Parse(t.Template) + tmpl, err := template.New("title").Funcs(funcMap()).Parse(t.Template) if err != nil { return "", errors.New(invalidTemplate) } diff --git a/src/template_func.go b/src/template_func.go new file mode 100644 index 00000000..acd062ff --- /dev/null +++ b/src/template_func.go @@ -0,0 +1,81 @@ +package main + +import ( + "errors" + "strconv" + "strings" + "text/template" + + "github.com/Masterminds/sprig/v3" +) + +var funcMapCache map[string]interface{} + +func funcMap() template.FuncMap { + if funcMapCache != nil { + return template.FuncMap(funcMapCache) + } + funcMapCache = map[string]interface{}{ + "secondsRound": secondsRound, + } + for key, fun := range sprig.TxtFuncMap() { + if _, ok := funcMapCache[key]; !ok { + funcMapCache[key] = fun + } + } + return template.FuncMap(funcMapCache) +} + +func parseSeconds(seconds interface{}) (int, error) { + switch seconds := seconds.(type) { + default: + return 0, errors.New("invalid seconds type") + case string: + return strconv.Atoi(seconds) + case int: + return seconds, nil + case int64: + return int(seconds), nil + case float64: + return int(seconds), nil + } +} + +func secondsRound(seconds interface{}) string { + s, err := parseSeconds(seconds) + if err != nil { + return err.Error() + } + if s == 0 { + return "0s" + } + neg := s < 0 + if neg { + s = -s + } + + var ( + second = 1 + minute = 60 + hour = minute * 60 + day = hour * 24 + month = day * 30 + year = day * 365 + ) + var builder strings.Builder + writePart := func(unit int, name string) { + if s >= unit { + builder.WriteString(" ") + builder.WriteString(strconv.Itoa(s / unit)) + builder.WriteString(name) + s %= unit + } + } + writePart(year, "y") + writePart(month, "mo") + writePart(day, "d") + writePart(hour, "h") + writePart(minute, "m") + writePart(second, "s") + return strings.Trim(builder.String(), " ") +} diff --git a/themes/schema.json b/themes/schema.json index 028e5bcc..a47853f2 100644 --- a/themes/schema.json +++ b/themes/schema.json @@ -181,6 +181,7 @@ "sysinfo", "angular", "php", + "wakatime", "wifi", "winreg", "plastic" @@ -1649,6 +1650,41 @@ } } }, + { + "if": { + "properties": { + "type": { "const": "wakatime" } + } + }, + "then": { + "title": "Wakatime", + "description": "Displays the tracked time on wakatime.com", + "properties": { + "properties": { + "properties": { + "apikey": { + "type": "string", + "title": "apikey", + "description": "The apikey used for the api call (Required)", + "default": "." + }, + "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": {