馃尫 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 "strings"
14 "time"
15)
16
17// Default API URL for the WakaTime API v1
18const (
19 DefaultAPIURL = "https://api.wakatime.com/api/v1"
20)
21
22// Error types returned by the client
23var (
24 // ErrMarshalingHeartbeat occurs when a heartbeat can't be marshaled to JSON
25 ErrMarshalingHeartbeat = fmt.Errorf("failed to marshal heartbeat to JSON")
26 // ErrCreatingRequest occurs when the HTTP request cannot be created
27 ErrCreatingRequest = fmt.Errorf("failed to create HTTP request")
28 // ErrSendingRequest occurs when the HTTP request fails to send
29 ErrSendingRequest = fmt.Errorf("failed to send HTTP request")
30 // ErrInvalidStatusCode occurs when the API returns a non-success status code
31 ErrInvalidStatusCode = fmt.Errorf("received invalid status code from API")
32 // ErrDecodingResponse occurs when the API response can't be decoded
33 ErrDecodingResponse = fmt.Errorf("failed to decode API response")
34 // ErrUnauthorized occurs when the API rejects the provided credentials
35 ErrUnauthorized = fmt.Errorf("unauthorized: invalid API key or insufficient permissions")
36 // ErrNotFound occurs when the config file isn't found
37 ErrNotFound = fmt.Errorf("config file not found")
38 // ErrBrokenConfig occurs when there is no settings section in the config
39 ErrBrokenConfig = fmt.Errorf("invalid config file: missing settings section")
40 // ErrNoApiKey occurs when the api key is missing from the config
41 ErrNoApiKey = fmt.Errorf("no API key found in config file")
42 // ErrNoApiURL occurs when the api url is missing from the config
43 ErrNoApiURL = fmt.Errorf("no API URL found in config file")
44)
45
46// Client represents a WakaTime API client with authentication and connection settings.
47type Client struct {
48 // APIKey is the user's WakaTime API key used for authentication
49 APIKey string
50 // APIURL is the base URL for the WakaTime API
51 APIURL string
52 // HTTPClient is the HTTP client used to make requests to the WakaTime API
53 HTTPClient *http.Client
54}
55
56// NewClient creates a new WakaTime API client with the provided API key
57// and a default HTTP client with a 10-second timeout.
58func NewClient(apiKey string) *Client {
59 return &Client{
60 APIKey: apiKey,
61 APIURL: DefaultAPIURL,
62 HTTPClient: &http.Client{Timeout: 10 * time.Second},
63 }
64}
65
66// NewClientWithOptions creates a new WakaTime API client with the provided API key,
67// custom API URL and a default HTTP client with a 10-second timeout.
68func NewClientWithOptions(apiKey string, apiURL string) *Client {
69 return &Client{
70 APIKey: apiKey,
71 APIURL: strings.TrimSuffix(apiURL, "/"),
72 HTTPClient: &http.Client{Timeout: 10 * time.Second},
73 }
74}
75
76// Heartbeat represents a coding activity heartbeat sent to the WakaTime API.
77// Heartbeats are the core data structure for tracking time spent coding.
78type Heartbeat struct {
79 // Entity is the file path or resource being worked on
80 Entity string `json:"entity"`
81 // Type specifies the entity type (usually "file")
82 Type string `json:"type"`
83 // Time is the timestamp of the heartbeat in UNIX epoch format
84 Time float64 `json:"time"`
85 // Project is the optional project name associated with the entity
86 Project string `json:"project,omitempty"`
87 // Language is the optional programming language of the entity
88 Language string `json:"language,omitempty"`
89 // IsWrite indicates if the file was being written to (vs. just viewed)
90 IsWrite bool `json:"is_write,omitempty"`
91 // EditorName is the optional name of the editor or IDE being used
92 EditorName string `json:"editor_name,omitempty"`
93 // Branch is the optional git branch name
94 Branch string `json:"branch,omitempty"`
95 // Category is the optional activity category
96 Category string `json:"category,omitempty"`
97 // LineCount is the optional number of lines in the file
98 LineCount int `json:"lines,omitempty"`
99 // LineNo is the current line number
100 LineNo int `json:"lineno,omitempty"`
101 // CursorPos is the current column of text the cursor is on
102 CursorPos int `json:"cursorpos,omitempty"`
103 // UserAgent is the optional user agent string
104 UserAgent string `json:"user_agent,omitempty"`
105 // EntityType is the optional entity type (usually redundant with Type)
106 EntityType string `json:"entity_type,omitempty"`
107 // Dependencies is an optional list of project dependencies
108 Dependencies []string `json:"dependencies,omitempty"`
109 // ProjectRootCount is the optional number of directories in the project root path
110 ProjectRootCount int `json:"project_root_count,omitempty"`
111}
112
113// StatusBarResponse represents the response from the WakaTime Status Bar API endpoint.
114// This contains summary information about a user's coding activity for a specific time period.
115type StatusBarResponse struct {
116 // Data contains coding duration information
117 Data struct {
118 // GrandTotal contains the aggregated coding time information
119 GrandTotal struct {
120 // Text is the human-readable representation of the total coding time
121 // Example: "3 hrs 42 mins"
122 Text string `json:"text"`
123 // TotalSeconds is the total time spent coding in seconds
124 // This can be used for precise calculations or custom formatting
125 TotalSeconds int `json:"total_seconds"`
126 } `json:"grand_total"`
127 } `json:"data"`
128}
129
130// SendHeartbeat sends a coding activity heartbeat to the WakaTime API.
131// It returns an error if the request fails or returns a non-success status code.
132func (c *Client) SendHeartbeat(heartbeat Heartbeat) error {
133 // Set the user agent in the heartbeat data
134 if heartbeat.UserAgent == "" {
135 heartbeat.UserAgent = "wakatime/unset (" + runtime.GOOS + "-" + runtime.GOARCH + ") akami-wakatime/1.0.0"
136 }
137
138 data, err := json.Marshal(heartbeat)
139 if err != nil {
140 return fmt.Errorf("%w: %v", ErrMarshalingHeartbeat, err)
141 }
142
143 req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/statusbar/today", c.APIURL), bytes.NewBuffer(data))
144 if err != nil {
145 return fmt.Errorf("%w: %v", ErrCreatingRequest, err)
146 }
147
148 req.Header.Set("Content-Type", "application/json")
149 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(c.APIKey)))
150 // Set the user agent in the request header as well
151 req.Header.Set("User-Agent", "wakatime/unset ("+runtime.GOOS+"-"+runtime.GOARCH+") akami-wakatime/1.0.0")
152
153 resp, err := c.HTTPClient.Do(req)
154 if err != nil {
155 return fmt.Errorf("%w: %v", ErrSendingRequest, err)
156 }
157 defer resp.Body.Close()
158
159 // Read and log the response
160 var respBody bytes.Buffer
161 _, err = respBody.ReadFrom(resp.Body)
162 if err != nil {
163 return fmt.Errorf("failed to read response body: %v", err)
164 }
165
166 respContent := respBody.String()
167
168 if resp.StatusCode == http.StatusUnauthorized {
169 return fmt.Errorf("%w: %s", ErrUnauthorized, respContent)
170 } else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
171 return fmt.Errorf("%w: status code %d, response: %s", ErrInvalidStatusCode, resp.StatusCode, respContent)
172 }
173
174 return nil
175}
176
177// GetStatusBar retrieves a user's current day coding activity summary from the WakaTime API.
178// It returns an error if the request fails or returns a non-success status code.
179func (c *Client) GetStatusBar() (StatusBarResponse, error) {
180 req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/statusbar/today", c.APIURL), nil)
181 if err != nil {
182 return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrCreatingRequest, err)
183 }
184
185 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(c.APIKey)))
186
187 resp, err := c.HTTPClient.Do(req)
188 if err != nil {
189 return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrSendingRequest, err)
190 }
191 defer resp.Body.Close()
192
193 // Read the response body for potential error messages
194 var respBody bytes.Buffer
195 _, err = respBody.ReadFrom(resp.Body)
196 if err != nil {
197 return StatusBarResponse{}, fmt.Errorf("failed to read response body: %v", err)
198 }
199
200 respContent := respBody.String()
201
202 if resp.StatusCode == http.StatusUnauthorized {
203 return StatusBarResponse{}, fmt.Errorf("%w: %s", ErrUnauthorized, respContent)
204 } else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
205 return StatusBarResponse{}, fmt.Errorf("%w: status code %d, response: %s", ErrInvalidStatusCode, resp.StatusCode, respContent)
206 }
207
208 var durationResp StatusBarResponse
209 if err := json.Unmarshal(respBody.Bytes(), &durationResp); err != nil {
210 return StatusBarResponse{}, fmt.Errorf("%w: %v, response: %s", ErrDecodingResponse, err, respContent)
211 }
212
213 return durationResp, nil
214}
215
216// Last7DaysResponse represents the response from the WakaTime Last 7 Days API endpoint.
217// This contains detailed information about a user's coding activity over the past 7 days.
218type Last7DaysResponse struct {
219 // Data contains coding statistics for the last 7 days
220 Data struct {
221 // TotalSeconds is the total time spent coding in seconds
222 TotalSeconds float64 `json:"total_seconds"`
223 // HumanReadableTotal is the human-readable representation of the total coding time
224 HumanReadableTotal string `json:"human_readable_total"`
225 // DailyAverage is the average time spent coding per day in seconds
226 DailyAverage float64 `json:"daily_average"`
227 // HumanReadableDailyAverage is the human-readable representation of the daily average
228 HumanReadableDailyAverage string `json:"human_readable_daily_average"`
229 // Languages is a list of programming languages used with statistics
230 Languages []struct {
231 // Name is the programming language name
232 Name string `json:"name"`
233 // TotalSeconds is the time spent coding in this language in seconds
234 TotalSeconds float64 `json:"total_seconds"`
235 // Percent is the percentage of time spent in this language
236 Percent float64 `json:"percent"`
237 // Text is the human-readable representation of time spent in this language
238 Text string `json:"text"`
239 } `json:"languages"`
240 // Editors is a list of editors used with statistics
241 Editors []struct {
242 // Name is the editor name
243 Name string `json:"name"`
244 // TotalSeconds is the time spent using this editor in seconds
245 TotalSeconds float64 `json:"total_seconds"`
246 // Percent is the percentage of time spent using this editor
247 Percent float64 `json:"percent"`
248 // Text is the human-readable representation of time spent using this editor
249 Text string `json:"text"`
250 } `json:"editors"`
251 // Projects is a list of projects worked on with statistics
252 Projects []struct {
253 // Name is the project name
254 Name string `json:"name"`
255 // TotalSeconds is the time spent on this project in seconds
256 TotalSeconds float64 `json:"total_seconds"`
257 // Percent is the percentage of time spent on this project
258 Percent float64 `json:"percent"`
259 // Text is the human-readable representation of time spent on this project
260 Text string `json:"text"`
261 } `json:"projects"`
262 } `json:"data"`
263}
264
265// GetLast7Days retrieves a user's coding activity summary for the past 7 days from the WakaTime API.
266// It returns a Last7DaysResponse and an error if the request fails or returns a non-success status code.
267func (c *Client) GetLast7Days() (Last7DaysResponse, error) {
268 req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/stats/last_7_days", c.APIURL), nil)
269 if err != nil {
270 return Last7DaysResponse{}, fmt.Errorf("%w: %v", ErrCreatingRequest, err)
271 }
272
273 req.Header.Set("Accept", "application/json")
274 req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(c.APIKey)))
275
276 resp, err := c.HTTPClient.Do(req)
277 if err != nil {
278 return Last7DaysResponse{}, fmt.Errorf("%w: %v", ErrSendingRequest, err)
279 }
280 defer resp.Body.Close()
281
282 // Read the response body for potential error messages
283 var respBody bytes.Buffer
284 _, err = respBody.ReadFrom(resp.Body)
285 if err != nil {
286 return Last7DaysResponse{}, fmt.Errorf("failed to read response body: %v", err)
287 }
288
289 respContent := respBody.String()
290
291 if resp.StatusCode == http.StatusUnauthorized {
292 return Last7DaysResponse{}, fmt.Errorf("%w: %s", ErrUnauthorized, respContent)
293 } else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
294 return Last7DaysResponse{}, fmt.Errorf("%w: status code %d, response: %s", ErrInvalidStatusCode, resp.StatusCode, respContent)
295 }
296
297 var statsResp Last7DaysResponse
298 if err := json.Unmarshal(respBody.Bytes(), &statsResp); err != nil {
299 return Last7DaysResponse{}, fmt.Errorf("%w: %v, response: %s", ErrDecodingResponse, err, respContent)
300 }
301
302 return statsResp, nil
303}