馃尫 the cutsie hackatime helper
1// Package wakatime provides a Go client for interacting with the WakaTime API.
2// WakaTime is a time tracking service for programmers that automatically tracks
3// how much time is spent on coding projects.
4package wakatime
5
6import (
7 "bytes"
8 "encoding/base64"
9 "encoding/json"
10 "fmt"
11 "net/http"
12 "runtime"
13 "time"
14)
15
16// Default API URL for the WakaTime API v1
17const (
18 DefaultAPIURL = "https://api.wakatime.com/api/v1"
19)
20
21// Error types returned by the client
22var (
23 // ErrMarshalingHeartbeat occurs when a heartbeat can't be marshaled to JSON
24 ErrMarshalingHeartbeat = fmt.Errorf("failed to marshal heartbeat to JSON")
25 // ErrCreatingRequest occurs when the HTTP request cannot be created
26 ErrCreatingRequest = fmt.Errorf("failed to create HTTP request")
27 // ErrSendingRequest occurs when the HTTP request fails to send
28 ErrSendingRequest = fmt.Errorf("failed to send HTTP request")
29 // ErrInvalidStatusCode occurs when the API returns a non-success status code
30 ErrInvalidStatusCode = fmt.Errorf("received invalid status code from API")
31 // ErrDecodingResponse occurs when the API response can't be decoded
32 ErrDecodingResponse = fmt.Errorf("failed to decode API response")
33 // ErrUnauthorized occurs when the API rejects the provided credentials
34 ErrUnauthorized = fmt.Errorf("unauthorized: invalid API key or insufficient permissions")
35)
36
37// Client represents a WakaTime API client with authentication and connection settings.
38type Client struct {
39 // APIKey is the user's WakaTime API key used for authentication
40 APIKey string
41 // APIURL is the base URL for the WakaTime API
42 APIURL string
43 // HTTPClient is the HTTP client used to make requests to the WakaTime API
44 HTTPClient *http.Client
45}
46
47// NewClient creates a new WakaTime API client with the provided API key
48// and a default HTTP client with a 10-second timeout.
49func NewClient(apiKey string) *Client {
50 return &Client{
51 APIKey: apiKey,
52 APIURL: DefaultAPIURL,
53 HTTPClient: &http.Client{Timeout: 10 * time.Second},
54 }
55}
56
57// NewClientWithOptions creates a new WakaTime API client with the provided API key,
58// custom API URL and a default HTTP client with a 10-second timeout.
59func NewClientWithOptions(apiKey string, apiURL string) *Client {
60 return &Client{
61 APIKey: apiKey,
62 APIURL: apiURL,
63 HTTPClient: &http.Client{Timeout: 10 * time.Second},
64 }
65}
66
67// Heartbeat represents a coding activity heartbeat sent to the WakaTime API.
68// Heartbeats are the core data structure for tracking time spent coding.
69type Heartbeat struct {
70 // Entity is the file path or resource being worked on
71 Entity string `json:"entity"`
72 // Type specifies the entity type (usually "file")
73 Type string `json:"type"`
74 // Time is the timestamp of the heartbeat in UNIX epoch format
75 Time float64 `json:"time"`
76 // Project is the optional project name associated with the entity
77 Project string `json:"project,omitempty"`
78 // Language is the optional programming language of the entity
79 Language string `json:"language,omitempty"`
80 // IsWrite indicates if the file was being written to (vs. just viewed)
81 IsWrite bool `json:"is_write,omitempty"`
82 // EditorName is the optional name of the editor or IDE being used
83 EditorName string `json:"editor_name,omitempty"`
84 // Branch is the optional git branch name
85 Branch string `json:"branch,omitempty"`
86 // Category is the optional activity category
87 Category string `json:"category,omitempty"`
88 // LineCount is the optional number of lines in the file
89 LineCount int `json:"lines,omitempty"`
90 // LineNo is the current line number
91 LineNo int `json:"lineno,omitempty"`
92 // CursorPos is the current column of text the cursor is on
93 CursorPos int `json:"cursorpos,omitempty"`
94 // UserAgent is the optional user agent string
95 UserAgent string `json:"user_agent,omitempty"`
96 // EntityType is the optional entity type (usually redundant with Type)
97 EntityType string `json:"entity_type,omitempty"`
98 // Dependencies is an optional list of project dependencies
99 Dependencies []string `json:"dependencies,omitempty"`
100 // ProjectRootCount is the optional number of directories in the project root path
101 ProjectRootCount int `json:"project_root_count,omitempty"`
102}
103
104// StatusBarResponse represents the response from the WakaTime Status Bar API endpoint.
105// This contains summary information about a user's coding activity for a specific time period.
106type StatusBarResponse struct {
107 // Data contains coding duration information
108 Data struct {
109 // GrandTotal contains the aggregated coding time information
110 GrandTotal struct {
111 // Text is the human-readable representation of the total coding time
112 // Example: "3 hrs 42 mins"
113 Text string `json:"text"`
114 // TotalSeconds is the total time spent coding in seconds
115 // This can be used for precise calculations or custom formatting
116 TotalSeconds int `json:"total_seconds"`
117 } `json:"grand_total"`
118 } `json:"data"`
119}
120
121// SendHeartbeat sends a coding activity heartbeat to the WakaTime API.
122// It returns an error if the request fails or returns a non-success status code.
123func (c *Client) SendHeartbeat(heartbeat Heartbeat) error {
124 // Set the user agent in the heartbeat data
125 if heartbeat.UserAgent == "" {
126 heartbeat.UserAgent = "wakatime/unset (" + runtime.GOOS + "-" + runtime.GOARCH + ") akami-wakatime/1.0.0"
127 }
128
129 data, err := json.Marshal(heartbeat)
130 if err != nil {
131 return fmt.Errorf("%w: %v", ErrMarshalingHeartbeat, err)
132 }
133
134 req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/statusbar/today", c.APIURL), bytes.NewBuffer(data))
135 if err != nil {
136 return fmt.Errorf("%w: %v", ErrCreatingRequest, err)
137 }
138
139 req.Header.Set("Content-Type", "application/json")
140 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(c.APIKey)))
141 // Set the user agent in the request header as well
142 req.Header.Set("User-Agent", "wakatime/unset ("+runtime.GOOS+"-"+runtime.GOARCH+") akami-wakatime/1.0.0")
143
144 resp, err := c.HTTPClient.Do(req)
145 if err != nil {
146 return fmt.Errorf("%w: %v", ErrSendingRequest, err)
147 }
148 defer resp.Body.Close()
149
150 // Read and log the response
151 var respBody bytes.Buffer
152 _, err = respBody.ReadFrom(resp.Body)
153 if err != nil {
154 return fmt.Errorf("failed to read response body: %v", err)
155 }
156
157 respContent := respBody.String()
158
159 if resp.StatusCode == http.StatusUnauthorized {
160 return fmt.Errorf("%w: %s", ErrUnauthorized, respContent)
161 } else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
162 return fmt.Errorf("%w: status code %d, response: %s", ErrInvalidStatusCode, resp.StatusCode, respContent)
163 }
164
165 return nil
166}
167
168// GetStatusBar retrieves a user's current day coding activity summary from the WakaTime API.
169// It returns an error if the request fails or returns a non-success status code.
170func (c *Client) GetStatusBar() (StatusBarResponse, error) {
171 req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/statusbar/today", c.APIURL), nil)
172 if err != nil {
173 return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrCreatingRequest, err)
174 }
175
176 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(c.APIKey)))
177
178 resp, err := c.HTTPClient.Do(req)
179 if err != nil {
180 return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrSendingRequest, err)
181 }
182 defer resp.Body.Close()
183
184 // Read the response body for potential error messages
185 var respBody bytes.Buffer
186 _, err = respBody.ReadFrom(resp.Body)
187 if err != nil {
188 return StatusBarResponse{}, fmt.Errorf("failed to read response body: %v", err)
189 }
190
191 respContent := respBody.String()
192
193 if resp.StatusCode == http.StatusUnauthorized {
194 return StatusBarResponse{}, fmt.Errorf("%w: %s", ErrUnauthorized, respContent)
195 } else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
196 return StatusBarResponse{}, fmt.Errorf("%w: status code %d, response: %s", ErrInvalidStatusCode, resp.StatusCode, respContent)
197 }
198
199 var durationResp StatusBarResponse
200 if err := json.Unmarshal(respBody.Bytes(), &durationResp); err != nil {
201 return StatusBarResponse{}, fmt.Errorf("%w: %v, response: %s", ErrDecodingResponse, err, respContent)
202 }
203
204 return durationResp, nil
205}