Repository: JanDeDobbeleer/oh-my-posh
Pull request #1418 (2021-12-13 07:44:48)

feat(wakatime): add segment
### Prerequisites

- [x] I have read and understood the `CONTRIBUTING` guide
- [x] The commit message follows the [conventional commits][cc] guidelines
- [x] Tests for the changes have been added (for bug fixes/features)
- [x] Docs have been added / updated (for bug fixes/features)

### Description

Different approach, leverages temple functions (which can be reused by the `executiontime` segment in templates)
<!---

Tips:

If you're not comfortable with working with Git, we're working a guide (https://ohmyposh.dev/docs/contributing_git) to help you out.
Oh My Posh advises GitKraken (https://www.gitkraken.com/invite/nQmDPR9D) as your preferred cross platform Git GUI power tool.

-->

[cc]: https://www.conventionalcommits.org/en/v1.0.0/#summary


JanDeDobbeleer: @Khaos66 this has a few advantages:

- no longer using the complex settings (just add the URL with your params)
- leverage a template function to format the Seconds
Khaos66: @JanDeDobbeleer I get your point now ;D
Yes that's simpler on this side. Though, the user has to read the API docs and build the URL on its own now.
But we're serving devs here, so this should be ok
Khaos66: @JanDeDobbeleer This segment is kind of generic now. Apart from the response structs there is not much left, that is very wakatime specific...
Would it be possible to remove these structs as well? Then this would be a generic `webapi` segment and could be used for ANY rest api =)
I'm not sure how to document wakatime then. Because I would want to point users on that great service ;D 
Maybe a sub-section in the docs where all kinds of webapis are documented, so the user can copy&paste an api easily?
What do you think?
JanDeDobbeleer: @Khaos66 there's a difference between architecture for oh-my-posh and how we expose that to the outside. I would wait to streamline this once we have generics as the parsing of the json is going to be cumbersome otherwise. But you're right, we can reuse this logic.

Patch:
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_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/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": {


Test patch:
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_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")
+	}
+}