馃尫 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}