🌷 the cutsie hackatime helper

feat: add key validation

dunkirk.sh 0f6173e1 137700a0

verified
+200
+40
main.go
··· 3 3 import ( 4 4 "context" 5 5 "errors" 6 + "fmt" 6 7 "os" 7 8 "runtime" 8 9 9 10 "github.com/charmbracelet/fang" 10 11 "github.com/charmbracelet/lipgloss/v2" 11 12 "github.com/spf13/cobra" 13 + "github.com/taciturnaxolotl/akami/wakatime" 12 14 "gopkg.in/ini.v1" 13 15 ) 14 16 ··· 22 24 // add our lipgloss styles 23 25 fancy := lipgloss.NewStyle().Foreground(lipgloss.Magenta).Bold(true).Italic(true) 24 26 muted := lipgloss.NewStyle().Foreground(lipgloss.BrightBlue).Italic(true) 27 + bad := lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true) 25 28 26 29 // root diagnose command 27 30 cmd.AddCommand(&cobra.Command{ ··· 72 75 } 73 76 74 77 if api_url != "https://hackatime.hackclub.com/api/hackatime/v1" { 78 + if api_url == "https://api.wakatime.com/api/v1" { 79 + client := wakatime.NewClient(api_key) 80 + _, err := client.GetStatusBar() 81 + 82 + if !errors.Is(err, wakatime.ErrUnauthorized) { 83 + 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") 84 + } else { 85 + 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") 86 + } 87 + } 75 88 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") 76 89 } 90 + 91 + client := wakatime.NewClientWithOptions(api_key, api_url) 92 + duration, err := client.GetStatusBar() 93 + if err != nil { 94 + if errors.Is(err, wakatime.ErrUnauthorized) { 95 + 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?") 96 + } 97 + 98 + 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())) 99 + } 100 + 101 + // Convert seconds to a formatted time string (hours, minutes, seconds) 102 + totalSeconds := duration.Data.GrandTotal.TotalSeconds 103 + hours := totalSeconds / 3600 104 + minutes := (totalSeconds % 3600) / 60 105 + seconds := totalSeconds % 60 106 + 107 + formattedTime := "" 108 + if hours > 0 { 109 + formattedTime += fmt.Sprintf("%d hours, ", hours) 110 + } 111 + if minutes > 0 || hours > 0 { 112 + formattedTime += fmt.Sprintf("%d minutes, ", minutes) 113 + } 114 + formattedTime += fmt.Sprintf("%d seconds", seconds) 115 + 116 + c.Println("\nSweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for", fancy.Render(formattedTime)) 77 117 78 118 return nil 79 119 },
+160
wakatime/main.go
··· 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. 4 + package wakatime 5 + 6 + import ( 7 + "bytes" 8 + "encoding/json" 9 + "fmt" 10 + "net/http" 11 + "time" 12 + ) 13 + 14 + // Default API URL for the WakaTime API v1 15 + const ( 16 + DefaultAPIURL = "https://api.wakatime.com/api/v1" 17 + ) 18 + 19 + // Error types returned by the client 20 + var ( 21 + // ErrMarshalingHeartbeat occurs when a heartbeat can't be marshaled to JSON 22 + ErrMarshalingHeartbeat = fmt.Errorf("failed to marshal heartbeat to JSON") 23 + // ErrCreatingRequest occurs when the HTTP request cannot be created 24 + ErrCreatingRequest = fmt.Errorf("failed to create HTTP request") 25 + // ErrSendingRequest occurs when the HTTP request fails to send 26 + ErrSendingRequest = fmt.Errorf("failed to send HTTP request") 27 + // ErrInvalidStatusCode occurs when the API returns a non-success status code 28 + ErrInvalidStatusCode = fmt.Errorf("received invalid status code from API") 29 + // ErrDecodingResponse occurs when the API response can't be decoded 30 + ErrDecodingResponse = fmt.Errorf("failed to decode API response") 31 + // ErrUnauthorized occurs when the API rejects the provided credentials 32 + ErrUnauthorized = fmt.Errorf("unauthorized: invalid API key or insufficient permissions") 33 + ) 34 + 35 + // Client represents a WakaTime API client with authentication and connection settings. 36 + type Client struct { 37 + // APIKey is the user's WakaTime API key used for authentication 38 + APIKey string 39 + // APIURL is the base URL for the WakaTime API 40 + APIURL string 41 + // HTTPClient is the HTTP client used to make requests to the WakaTime API 42 + HTTPClient *http.Client 43 + } 44 + 45 + // NewClient creates a new WakaTime API client with the provided API key 46 + // and a default HTTP client with a 10-second timeout. 47 + func NewClient(apiKey string) *Client { 48 + return &Client{ 49 + APIKey: apiKey, 50 + APIURL: DefaultAPIURL, 51 + HTTPClient: &http.Client{Timeout: 10 * time.Second}, 52 + } 53 + } 54 + 55 + // NewClientWithOptions creates a new WakaTime API client with the provided API key, 56 + // custom API URL and a default HTTP client with a 10-second timeout. 57 + func NewClientWithOptions(apiKey string, apiURL string) *Client { 58 + return &Client{ 59 + APIKey: apiKey, 60 + APIURL: apiURL, 61 + HTTPClient: &http.Client{Timeout: 10 * time.Second}, 62 + } 63 + } 64 + 65 + // Heartbeat represents a coding activity heartbeat sent to the WakaTime API. 66 + // Heartbeats are the core data structure for tracking time spent coding. 67 + type Heartbeat struct { 68 + // Entity is the file path or resource being worked on 69 + Entity string `json:"entity"` 70 + // Type specifies the entity type (usually "file") 71 + Type string `json:"type"` 72 + // Time is the timestamp of the heartbeat in UNIX epoch format 73 + Time float64 `json:"time"` 74 + // Project is the optional project name associated with the entity 75 + Project string `json:"project,omitempty"` 76 + // Language is the optional programming language of the entity 77 + Language string `json:"language,omitempty"` 78 + // IsWrite indicates if the file was being written to (vs. just viewed) 79 + IsWrite bool `json:"is_write,omitempty"` 80 + // EditorName is the optional name of the editor or IDE being used 81 + EditorName string `json:"editor_name,omitempty"` 82 + } 83 + 84 + // StatusBarResponse represents the response from the WakaTime Status Bar API endpoint. 85 + // This contains summary information about a user's coding activity for a specific time period. 86 + type StatusBarResponse struct { 87 + // Data contains coding duration information 88 + Data struct { 89 + // GrandTotal contains the aggregated coding time information 90 + GrandTotal struct { 91 + // Text is the human-readable representation of the total coding time 92 + // Example: "3 hrs 42 mins" 93 + Text string `json:"text"` 94 + // TotalSeconds is the total time spent coding in seconds 95 + // This can be used for precise calculations or custom formatting 96 + TotalSeconds int `json:"total_seconds"` 97 + } `json:"grand_total"` 98 + } `json:"data"` 99 + } 100 + 101 + // SendHeartbeat sends a coding activity heartbeat to the WakaTime API. 102 + // It returns an error if the request fails or returns a non-success status code. 103 + func (c *Client) SendHeartbeat(heartbeat Heartbeat) error { 104 + data, err := json.Marshal(heartbeat) 105 + if err != nil { 106 + return fmt.Errorf("%w: %v", ErrMarshalingHeartbeat, err) 107 + } 108 + 109 + req, err := http.NewRequest("POST", c.APIURL+"/users/current/heartbeats", bytes.NewBuffer(data)) 110 + if err != nil { 111 + return fmt.Errorf("%w: %v", ErrCreatingRequest, err) 112 + } 113 + 114 + req.Header.Set("Content-Type", "application/json") 115 + req.Header.Set("Authorization", "Basic "+c.APIKey) 116 + 117 + resp, err := c.HTTPClient.Do(req) 118 + if err != nil { 119 + return fmt.Errorf("%w: %v", ErrSendingRequest, err) 120 + } 121 + defer resp.Body.Close() 122 + 123 + if resp.StatusCode == http.StatusUnauthorized { 124 + return ErrUnauthorized 125 + } else if resp.StatusCode < 200 || resp.StatusCode >= 300 { 126 + return fmt.Errorf("%w: status code %d", ErrInvalidStatusCode, resp.StatusCode) 127 + } 128 + 129 + return nil 130 + } 131 + 132 + // GetStatusBar retrieves a user's current day coding activity summary from the WakaTime API. 133 + // It returns an error if the request fails or returns a non-success status code. 134 + func (c *Client) GetStatusBar() (StatusBarResponse, error) { 135 + req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/statusbar/today", c.APIURL), nil) 136 + if err != nil { 137 + return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrCreatingRequest, err) 138 + } 139 + 140 + req.Header.Set("Authorization", "Basic "+c.APIKey) 141 + 142 + resp, err := c.HTTPClient.Do(req) 143 + if err != nil { 144 + return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrSendingRequest, err) 145 + } 146 + defer resp.Body.Close() 147 + 148 + if resp.StatusCode == http.StatusUnauthorized { 149 + return StatusBarResponse{}, ErrUnauthorized 150 + } else if resp.StatusCode < 200 || resp.StatusCode >= 300 { 151 + return StatusBarResponse{}, fmt.Errorf("%w: status code %d", ErrInvalidStatusCode, resp.StatusCode) 152 + } 153 + 154 + var durationResp StatusBarResponse 155 + if err := json.NewDecoder(resp.Body).Decode(&durationResp); err != nil { 156 + return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrDecodingResponse, err) 157 + } 158 + 159 + return durationResp, nil 160 + }