馃尫 the cutsie hackatime helper
at main 12 kB view raw
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}