🌷 the cutsie hackatime helper

feat: add key validation

dunkirk.sh 0f6173e1 137700a0

verified
Changed files
+200
wakatime
+40
main.go
···
import (
"context"
"errors"
+
"fmt"
"os"
"runtime"
"github.com/charmbracelet/fang"
"github.com/charmbracelet/lipgloss/v2"
"github.com/spf13/cobra"
+
"github.com/taciturnaxolotl/akami/wakatime"
"gopkg.in/ini.v1"
)
···
// add our lipgloss styles
fancy := lipgloss.NewStyle().Foreground(lipgloss.Magenta).Bold(true).Italic(true)
muted := lipgloss.NewStyle().Foreground(lipgloss.BrightBlue).Italic(true)
+
bad := lipgloss.NewStyle().Foreground(lipgloss.BrightRed).Bold(true)
// root diagnose command
cmd.AddCommand(&cobra.Command{
···
}
if api_url != "https://hackatime.hackclub.com/api/hackatime/v1" {
+
if api_url == "https://api.wakatime.com/api/v1" {
+
client := wakatime.NewClient(api_key)
+
_, err := client.GetStatusBar()
+
+
if !errors.Is(err, wakatime.ErrUnauthorized) {
+
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")
+
} else {
+
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")
+
}
+
}
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")
}
+
+
client := wakatime.NewClientWithOptions(api_key, api_url)
+
duration, err := client.GetStatusBar()
+
if err != nil {
+
if errors.Is(err, wakatime.ErrUnauthorized) {
+
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?")
+
}
+
+
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()))
+
}
+
+
// Convert seconds to a formatted time string (hours, minutes, seconds)
+
totalSeconds := duration.Data.GrandTotal.TotalSeconds
+
hours := totalSeconds / 3600
+
minutes := (totalSeconds % 3600) / 60
+
seconds := totalSeconds % 60
+
+
formattedTime := ""
+
if hours > 0 {
+
formattedTime += fmt.Sprintf("%d hours, ", hours)
+
}
+
if minutes > 0 || hours > 0 {
+
formattedTime += fmt.Sprintf("%d minutes, ", minutes)
+
}
+
formattedTime += fmt.Sprintf("%d seconds", seconds)
+
+
c.Println("\nSweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for", fancy.Render(formattedTime))
return nil
},
+160
wakatime/main.go
···
+
// Package wakatime provides a Go client for interacting with the WakaTime API.
+
// WakaTime is a time tracking service for programmers that automatically tracks
+
// how much time is spent on coding projects.
+
package wakatime
+
+
import (
+
"bytes"
+
"encoding/json"
+
"fmt"
+
"net/http"
+
"time"
+
)
+
+
// Default API URL for the WakaTime API v1
+
const (
+
DefaultAPIURL = "https://api.wakatime.com/api/v1"
+
)
+
+
// Error types returned by the client
+
var (
+
// ErrMarshalingHeartbeat occurs when a heartbeat can't be marshaled to JSON
+
ErrMarshalingHeartbeat = fmt.Errorf("failed to marshal heartbeat to JSON")
+
// ErrCreatingRequest occurs when the HTTP request cannot be created
+
ErrCreatingRequest = fmt.Errorf("failed to create HTTP request")
+
// ErrSendingRequest occurs when the HTTP request fails to send
+
ErrSendingRequest = fmt.Errorf("failed to send HTTP request")
+
// ErrInvalidStatusCode occurs when the API returns a non-success status code
+
ErrInvalidStatusCode = fmt.Errorf("received invalid status code from API")
+
// ErrDecodingResponse occurs when the API response can't be decoded
+
ErrDecodingResponse = fmt.Errorf("failed to decode API response")
+
// ErrUnauthorized occurs when the API rejects the provided credentials
+
ErrUnauthorized = fmt.Errorf("unauthorized: invalid API key or insufficient permissions")
+
)
+
+
// Client represents a WakaTime API client with authentication and connection settings.
+
type Client struct {
+
// APIKey is the user's WakaTime API key used for authentication
+
APIKey string
+
// APIURL is the base URL for the WakaTime API
+
APIURL string
+
// HTTPClient is the HTTP client used to make requests to the WakaTime API
+
HTTPClient *http.Client
+
}
+
+
// NewClient creates a new WakaTime API client with the provided API key
+
// and a default HTTP client with a 10-second timeout.
+
func NewClient(apiKey string) *Client {
+
return &Client{
+
APIKey: apiKey,
+
APIURL: DefaultAPIURL,
+
HTTPClient: &http.Client{Timeout: 10 * time.Second},
+
}
+
}
+
+
// NewClientWithOptions creates a new WakaTime API client with the provided API key,
+
// custom API URL and a default HTTP client with a 10-second timeout.
+
func NewClientWithOptions(apiKey string, apiURL string) *Client {
+
return &Client{
+
APIKey: apiKey,
+
APIURL: apiURL,
+
HTTPClient: &http.Client{Timeout: 10 * time.Second},
+
}
+
}
+
+
// Heartbeat represents a coding activity heartbeat sent to the WakaTime API.
+
// Heartbeats are the core data structure for tracking time spent coding.
+
type Heartbeat struct {
+
// Entity is the file path or resource being worked on
+
Entity string `json:"entity"`
+
// Type specifies the entity type (usually "file")
+
Type string `json:"type"`
+
// Time is the timestamp of the heartbeat in UNIX epoch format
+
Time float64 `json:"time"`
+
// Project is the optional project name associated with the entity
+
Project string `json:"project,omitempty"`
+
// Language is the optional programming language of the entity
+
Language string `json:"language,omitempty"`
+
// IsWrite indicates if the file was being written to (vs. just viewed)
+
IsWrite bool `json:"is_write,omitempty"`
+
// EditorName is the optional name of the editor or IDE being used
+
EditorName string `json:"editor_name,omitempty"`
+
}
+
+
// StatusBarResponse represents the response from the WakaTime Status Bar API endpoint.
+
// This contains summary information about a user's coding activity for a specific time period.
+
type StatusBarResponse struct {
+
// Data contains coding duration information
+
Data struct {
+
// GrandTotal contains the aggregated coding time information
+
GrandTotal struct {
+
// Text is the human-readable representation of the total coding time
+
// Example: "3 hrs 42 mins"
+
Text string `json:"text"`
+
// TotalSeconds is the total time spent coding in seconds
+
// This can be used for precise calculations or custom formatting
+
TotalSeconds int `json:"total_seconds"`
+
} `json:"grand_total"`
+
} `json:"data"`
+
}
+
+
// SendHeartbeat sends a coding activity heartbeat to the WakaTime API.
+
// It returns an error if the request fails or returns a non-success status code.
+
func (c *Client) SendHeartbeat(heartbeat Heartbeat) error {
+
data, err := json.Marshal(heartbeat)
+
if err != nil {
+
return fmt.Errorf("%w: %v", ErrMarshalingHeartbeat, err)
+
}
+
+
req, err := http.NewRequest("POST", c.APIURL+"/users/current/heartbeats", bytes.NewBuffer(data))
+
if err != nil {
+
return fmt.Errorf("%w: %v", ErrCreatingRequest, err)
+
}
+
+
req.Header.Set("Content-Type", "application/json")
+
req.Header.Set("Authorization", "Basic "+c.APIKey)
+
+
resp, err := c.HTTPClient.Do(req)
+
if err != nil {
+
return fmt.Errorf("%w: %v", ErrSendingRequest, err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode == http.StatusUnauthorized {
+
return ErrUnauthorized
+
} else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+
return fmt.Errorf("%w: status code %d", ErrInvalidStatusCode, resp.StatusCode)
+
}
+
+
return nil
+
}
+
+
// GetStatusBar retrieves a user's current day coding activity summary from the WakaTime API.
+
// It returns an error if the request fails or returns a non-success status code.
+
func (c *Client) GetStatusBar() (StatusBarResponse, error) {
+
req, err := http.NewRequest("GET", fmt.Sprintf("%s/users/current/statusbar/today", c.APIURL), nil)
+
if err != nil {
+
return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrCreatingRequest, err)
+
}
+
+
req.Header.Set("Authorization", "Basic "+c.APIKey)
+
+
resp, err := c.HTTPClient.Do(req)
+
if err != nil {
+
return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrSendingRequest, err)
+
}
+
defer resp.Body.Close()
+
+
if resp.StatusCode == http.StatusUnauthorized {
+
return StatusBarResponse{}, ErrUnauthorized
+
} else if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+
return StatusBarResponse{}, fmt.Errorf("%w: status code %d", ErrInvalidStatusCode, resp.StatusCode)
+
}
+
+
var durationResp StatusBarResponse
+
if err := json.NewDecoder(resp.Body).Decode(&durationResp); err != nil {
+
return StatusBarResponse{}, fmt.Errorf("%w: %v", ErrDecodingResponse, err)
+
}
+
+
return durationResp, nil
+
}