···33import (
44 "context"
55 "errors"
66+ "fmt"
67 "os"
78 "runtime"
89910 "github.com/charmbracelet/fang"
1011 "github.com/charmbracelet/lipgloss/v2"
1112 "github.com/spf13/cobra"
1313+ "github.com/taciturnaxolotl/akami/wakatime"
1214 "gopkg.in/ini.v1"
1315)
1416···2224 // add our lipgloss styles
2325 fancy := lipgloss.NewStyle().Foreground(lipgloss.Magenta).Bold(true).Italic(true)
2426 muted := lipgloss.NewStyle().Foreground(lipgloss.BrightBlue).Italic(true)
2727+ bad := lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true)
25282629 // root diagnose command
2730 cmd.AddCommand(&cobra.Command{
···7275 }
73767477 if api_url != "https://hackatime.hackclub.com/api/hackatime/v1" {
7878+ if api_url == "https://api.wakatime.com/api/v1" {
7979+ client := wakatime.NewClient(api_key)
8080+ _, err := client.GetStatusBar()
8181+8282+ if !errors.Is(err, wakatime.ErrUnauthorized) {
8383+ return errors.New("turns out you were connected to wakatime.com instead of hackatime; since your key seems to work if you would like to keep syncing data to wakatime.com as well as to hackatime you can either setup a realy serve like " + muted.Render("https://github.com/JasonLovesDoggo/multitime") + " or you can wait for https://github.com/hackclub/hackatime/issues/85 to get merged in hackatime and have it synced there :)\n\nIf you want to import your wakatime.com data into hackatime then you can use hackatime v1 temporarily to connect your wakatime account and import (in settings under integrations at https://waka.hackclub.com) and then click the import from hackatime v1 button at https://hackatime.hackclub.com/my/settings.\n\n If you have more questions feel free to reach out to me (hackatime v1 creator) on slack (at @krn) or via email at me@dunkirk.sh")
8484+ } else {
8585+ return errors.New("turns out your config is connected to the wrong api url and is trying to use wakatime.com to sync time but you don't have a working api key from them. Go to https://hackatime.hackclub.com/my/wakatime_setup to run the setup script and fix your config file")
8686+ }
8787+ }
7588 c.Println("\nYour api url", muted.Render(api_url), "doesn't match the expected url of", muted.Render("https://hackatime.hackclub.com/api/hackatime/v1"), "however if you are using a custom forwarder or are sure you know what you are doing then you are probably fine")
7689 }
9090+9191+ client := wakatime.NewClientWithOptions(api_key, api_url)
9292+ duration, err := client.GetStatusBar()
9393+ if err != nil {
9494+ if errors.Is(err, wakatime.ErrUnauthorized) {
9595+ return errors.New("Your config file looks mostly correct and you have the correct api url but when we tested your api_key it looks like it is invalid? Can you double check if the key in your config file is the same as at https://hackatime.hackclub.com/my/wakatime_setup?")
9696+ }
9797+9898+ return errors.New("Something weird happened with the hackatime api; if the error doesn't make sense then please contact @krn on slack or via email at me@dunkirk.sh\n\n" + bad.Render("Full error: "+err.Error()))
9999+ }
100100+101101+ // Convert seconds to a formatted time string (hours, minutes, seconds)
102102+ totalSeconds := duration.Data.GrandTotal.TotalSeconds
103103+ hours := totalSeconds / 3600
104104+ minutes := (totalSeconds % 3600) / 60
105105+ seconds := totalSeconds % 60
106106+107107+ formattedTime := ""
108108+ if hours > 0 {
109109+ formattedTime += fmt.Sprintf("%d hours, ", hours)
110110+ }
111111+ if minutes > 0 || hours > 0 {
112112+ formattedTime += fmt.Sprintf("%d minutes, ", minutes)
113113+ }
114114+ formattedTime += fmt.Sprintf("%d seconds", seconds)
115115+116116+ c.Println("\nSweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for", fancy.Render(formattedTime))
7711778118 return nil
79119 },
+160
wakatime/main.go
···11+// Package wakatime provides a Go client for interacting with the WakaTime API.
22+// WakaTime is a time tracking service for programmers that automatically tracks
33+// how much time is spent on coding projects.
44+package wakatime
55+66+import (
77+ "bytes"
88+ "encoding/json"
99+ "fmt"
1010+ "net/http"
1111+ "time"
1212+)
1313+1414+// Default API URL for the WakaTime API v1
1515+const (
1616+ DefaultAPIURL = "https://api.wakatime.com/api/v1"
1717+)
1818+1919+// Error types returned by the client
2020+var (
2121+ // ErrMarshalingHeartbeat occurs when a heartbeat can't be marshaled to JSON
2222+ ErrMarshalingHeartbeat = fmt.Errorf("failed to marshal heartbeat to JSON")
2323+ // ErrCreatingRequest occurs when the HTTP request cannot be created
2424+ ErrCreatingRequest = fmt.Errorf("failed to create HTTP request")
2525+ // ErrSendingRequest occurs when the HTTP request fails to send
2626+ ErrSendingRequest = fmt.Errorf("failed to send HTTP request")
2727+ // ErrInvalidStatusCode occurs when the API returns a non-success status code
2828+ ErrInvalidStatusCode = fmt.Errorf("received invalid status code from API")
2929+ // ErrDecodingResponse occurs when the API response can't be decoded
3030+ ErrDecodingResponse = fmt.Errorf("failed to decode API response")
3131+ // ErrUnauthorized occurs when the API rejects the provided credentials
3232+ ErrUnauthorized = fmt.Errorf("unauthorized: invalid API key or insufficient permissions")
3333+)
3434+3535+// Client represents a WakaTime API client with authentication and connection settings.
3636+type Client struct {
3737+ // APIKey is the user's WakaTime API key used for authentication
3838+ APIKey string
3939+ // APIURL is the base URL for the WakaTime API
4040+ APIURL string
4141+ // HTTPClient is the HTTP client used to make requests to the WakaTime API
4242+ HTTPClient *http.Client
4343+}
4444+4545+// NewClient creates a new WakaTime API client with the provided API key
4646+// and a default HTTP client with a 10-second timeout.
4747+func NewClient(apiKey string) *Client {
4848+ return &Client{
4949+ APIKey: apiKey,
5050+ APIURL: DefaultAPIURL,
5151+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
5252+ }
5353+}
5454+5555+// NewClientWithOptions creates a new WakaTime API client with the provided API key,
5656+// custom API URL and a default HTTP client with a 10-second timeout.
5757+func NewClientWithOptions(apiKey string, apiURL string) *Client {
5858+ return &Client{
5959+ APIKey: apiKey,
6060+ APIURL: apiURL,
6161+ HTTPClient: &http.Client{Timeout: 10 * time.Second},
6262+ }
6363+}
6464+6565+// Heartbeat represents a coding activity heartbeat sent to the WakaTime API.
6666+// Heartbeats are the core data structure for tracking time spent coding.
6767+type Heartbeat struct {
6868+ // Entity is the file path or resource being worked on
6969+ Entity string `json:"entity"`
7070+ // Type specifies the entity type (usually "file")
7171+ Type string `json:"type"`
7272+ // Time is the timestamp of the heartbeat in UNIX epoch format
7373+ Time float64 `json:"time"`
7474+ // Project is the optional project name associated with the entity
7575+ Project string `json:"project,omitempty"`
7676+ // Language is the optional programming language of the entity
7777+ Language string `json:"language,omitempty"`
7878+ // IsWrite indicates if the file was being written to (vs. just viewed)
7979+ IsWrite bool `json:"is_write,omitempty"`
8080+ // EditorName is the optional name of the editor or IDE being used
8181+ EditorName string `json:"editor_name,omitempty"`
8282+}
8383+8484+// StatusBarResponse represents the response from the WakaTime Status Bar API endpoint.
8585+// This contains summary information about a user's coding activity for a specific time period.
8686+type StatusBarResponse struct {
8787+ // Data contains coding duration information
8888+ Data struct {
8989+ // GrandTotal contains the aggregated coding time information
9090+ GrandTotal struct {
9191+ // Text is the human-readable representation of the total coding time
9292+ // Example: "3 hrs 42 mins"
9393+ Text string `json:"text"`
9494+ // TotalSeconds is the total time spent coding in seconds
9595+ // This can be used for precise calculations or custom formatting
9696+ TotalSeconds int `json:"total_seconds"`
9797+ } `json:"grand_total"`
9898+ } `json:"data"`
9999+}
100100+101101+// SendHeartbeat sends a coding activity heartbeat to the WakaTime API.
102102+// It returns an error if the request fails or returns a non-success status code.
103103+func (c *Client) SendHeartbeat(heartbeat Heartbeat) error {
104104+ data, err := json.Marshal(heartbeat)
105105+ if err != nil {
106106+ return fmt.Errorf("%w: %v", ErrMarshalingHeartbeat, err)
107107+ }
108108+109109+ req, err := http.NewRequest("POST", c.APIURL+"/users/current/heartbeats", bytes.NewBuffer(data))
110110+ if err != nil {
111111+ return fmt.Errorf("%w: %v", ErrCreatingRequest, err)
112112+ }
113113+114114+ req.Header.Set("Content-Type", "application/json")
115115+ req.Header.Set("Authorization", "Basic "+c.APIKey)
116116+117117+ resp, err := c.HTTPClient.Do(req)
118118+ if err != nil {
119119+ return fmt.Errorf("%w: %v", ErrSendingRequest, err)
120120+ }
121121+ defer resp.Body.Close()
122122+123123+ if resp.StatusCode == http.StatusUnauthorized {
124124+ return ErrUnauthorized
125125+ } else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
126126+ return fmt.Errorf("%w: status code %d", ErrInvalidStatusCode, resp.StatusCode)
127127+ }
128128+129129+ return nil
130130+}
131131+132132+// GetStatusBar retrieves a user's current day coding activity summary from the WakaTime API.
133133+// It returns an error if the request fails or returns a non-success status code.
134134+func (c *Client) GetStatusBar() (StatusBarResponse, error) {
135135+ req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/statusbar/today", c.APIURL), nil)
136136+ if err != nil {
137137+ return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrCreatingRequest, err)
138138+ }
139139+140140+ req.Header.Set("Authorization", "Basic "+c.APIKey)
141141+142142+ resp, err := c.HTTPClient.Do(req)
143143+ if err != nil {
144144+ return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrSendingRequest, err)
145145+ }
146146+ defer resp.Body.Close()
147147+148148+ if resp.StatusCode == http.StatusUnauthorized {
149149+ return StatusBarResponse{}, ErrUnauthorized
150150+ } else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
151151+ return StatusBarResponse{}, fmt.Errorf("%w: status code %d", ErrInvalidStatusCode, resp.StatusCode)
152152+ }
153153+154154+ var durationResp StatusBarResponse
155155+ if err := json.NewDecoder(resp.Body).Decode(&durationResp); err != nil {
156156+ return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrDecodingResponse, err)
157157+ }
158158+159159+ return durationResp, nil
160160+}