馃尫 the cutsie hackatime helper
1package handler 2 3import ( 4 "context" 5 "errors" 6 "fmt" 7 "math/rand" 8 "os" 9 "path/filepath" 10 "runtime" 11 "strings" 12 "time" 13 14 "github.com/spf13/cobra" 15 "github.com/taciturnaxolotl/akami/styles" 16 "github.com/taciturnaxolotl/akami/wakatime" 17 "gopkg.in/ini.v1" 18) 19 20// Task status indicators 21var spinnerChars = []string{"[|]", "[/]", "[-]", "[\\]"} 22var TaskCompleted = "[*]" 23 24// taskState holds shared state for the currently running task 25type taskState struct { 26 cancel context.CancelFunc 27 message string 28} 29 30// printTask prints a task with a spinning animation 31func printTask(c *cobra.Command, message string) { 32 // Create a cancellable context for this spinner 33 ctx, cancel := context.WithCancel(c.Context()) 34 35 // Store cancel function so we can stop the spinner later 36 if taskCtx, ok := c.Context().Value("taskState").(*taskState); ok { 37 // Cancel any previously running spinner first 38 if taskCtx.cancel != nil { 39 taskCtx.cancel() 40 // Small delay to ensure previous spinner is stopped 41 time.Sleep(10 * time.Millisecond) 42 } 43 taskCtx.message = message 44 taskCtx.cancel = cancel 45 } else { 46 // First task, create the state and store it 47 state := &taskState{ 48 message: message, 49 cancel: cancel, 50 } 51 c.SetContext(context.WithValue(c.Context(), "taskState", state)) 52 } 53 54 // Start spinner in background 55 go func() { 56 ticker := time.NewTicker(100 * time.Millisecond) 57 defer ticker.Stop() 58 i := 0 59 for { 60 select { 61 case <-ctx.Done(): 62 return 63 case <-ticker.C: 64 // Clear line and print spinner with current character 65 spinner := styles.Muted.Render(spinnerChars[i%len(spinnerChars)]) 66 c.Printf("\r\033[K%s %s", spinner, message) 67 i++ 68 } 69 } 70 }() 71 72 // Add a small random delay between 200-400ms to make spinner animation visible 73 randomDelay := 200 + time.Duration(rand.Intn(201)) // 300-500ms 74 time.Sleep(randomDelay * time.Millisecond) 75} 76 77// completeTask marks a task as completed 78func completeTask(c *cobra.Command, message string) { 79 // Cancel spinner 80 if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil { 81 state.cancel() 82 // Small delay to ensure spinner is stopped 83 time.Sleep(10 * time.Millisecond) 84 } 85 86 // Clear line and display success message 87 c.Printf("\r\033[K%s %s\n", styles.Success.Render(TaskCompleted), message) 88} 89 90// errorTask marks a task as failed 91func errorTask(c *cobra.Command, message string) { 92 // Cancel spinner 93 if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil { 94 state.cancel() 95 // Small delay to ensure spinner is stopped 96 time.Sleep(10 * time.Millisecond) 97 } 98 99 // Clear line and display error message 100 c.Printf("\r\033[K%s %s\n", styles.Bad.Render("[ ! ]"), message) 101} 102 103// warnTask marks a task as a warning 104func warnTask(c *cobra.Command, message string) { 105 // Cancel spinner 106 if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil { 107 state.cancel() 108 // Small delay to ensure spinner is stopped 109 time.Sleep(10 * time.Millisecond) 110 } 111 112 // Clear line and display warning message 113 c.Printf("\r\033[K%s %s\n", styles.Warn.Render("[?]"), message) 114} 115 116var user_dir, err = os.UserHomeDir() 117 118var testHeartbeat = wakatime.Heartbeat{ 119 Branch: "main", 120 Category: "coding", 121 CursorPos: 1, 122 Entity: filepath.Join(user_dir, "akami.txt"), 123 Type: "file", 124 IsWrite: true, 125 Language: "Go", 126 LineNo: 1, 127 LineCount: 4, 128 Project: "example", 129 ProjectRootCount: 3, 130 Time: float64(time.Now().Unix()), 131} 132 133func Doctor(c *cobra.Command, _ []string) error { 134 // Initialize a new context with task state 135 c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{})) 136 137 // check our os 138 printTask(c, "Checking operating system") 139 140 os_name := runtime.GOOS 141 142 user_dir, err := os.UserHomeDir() 143 if err != nil { 144 errorTask(c, "Checking operating system") 145 return errors.New("somehow your user doesn't exist? fairly sure this should never happen; plz report this to @krn on slack or via email at me@dunkirk.sh") 146 } 147 hackatime_path := filepath.Join(user_dir, ".wakatime.cfg") 148 149 if os_name != "linux" && os_name != "darwin" && os_name != "windows" { 150 errorTask(c, "Checking operating system") 151 return errors.New("hmm you don't seem to be running a recognized os? you are listed as running " + styles.Fancy.Render(os_name) + "; can you plz report this to @krn on slack or via email at me@dunkirk.sh?") 152 } 153 completeTask(c, "Checking operating system") 154 155 c.Printf("Looks like you are running %s so lets take a look at %s for your config\n\n", styles.Fancy.Render(os_name), styles.Muted.Render(hackatime_path)) 156 157 printTask(c, "Checking wakatime config file") 158 159 rawCfg, err := os.ReadFile(hackatime_path) 160 if errors.Is(err, os.ErrNotExist) { 161 errorTask(c, "Checking wakatime config file") 162 return errors.New("you don't have a wakatime config file! go check " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " for the instructions and then try this again") 163 } 164 165 cfg, err := ini.Load(rawCfg) 166 if err != nil { 167 errorTask(c, "Checking wakatime config file") 168 return errors.New(err.Error()) 169 } 170 171 settings, err := cfg.GetSection("settings") 172 if err != nil { 173 errorTask(c, "Checking wakatime config file") 174 return errors.New("wow! your config file seems to be messed up and doesn't have a settings heading; can you follow the instructions at " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " to regenerate it?\n\nThe raw error we got was: " + err.Error()) 175 } 176 completeTask(c, "Checking wakatime config file") 177 178 printTask(c, "Verifying API credentials") 179 180 api_key := settings.Key("api_key").String() 181 api_url := settings.Key("api_url").String() 182 if api_key == "" { 183 errorTask(c, "Verifying API credentials") 184 return errors.New("hmm 馃 looks like you don't have an api_key in your config file? are you sure you have followed the setup instructions at " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " correctly?") 185 } 186 if api_url == "" { 187 errorTask(c, "Verifying API credentials") 188 return errors.New("hmm 馃 looks like you don't have an api_url in your config file? are you sure you have followed the setup instructions at " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " correctly?") 189 } 190 completeTask(c, "Verifying API credentials") 191 192 printTask(c, "Validating API URL") 193 194 correctApiUrl := "https://hackatime.hackclub.com/api/hackatime/v1" 195 if api_url != correctApiUrl { 196 if api_url == "https://api.wakatime.com/api/v1" { 197 client := wakatime.NewClient(api_key) 198 _, err := client.GetStatusBar() 199 200 if !errors.Is(err, wakatime.ErrUnauthorized) { 201 errorTask(c, "Validating API URL") 202 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 " + styles.Muted.Render("https://github.com/JasonLovesDoggo/multitime") + " or you can wait for " + styles.Muted.Render("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 " + styles.Muted.Render("https://waka.hackclub.com") + ") and then click the import from hackatime v1 button at " + styles.Muted.Render("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") 203 } else { 204 errorTask(c, "Validating API URL") 205 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 " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + " to run the setup script and fix your config file") 206 } 207 } 208 warnTask(c, "Validating API URL") 209 c.Printf("\nYour api url %s doesn't match the expected url of %s however if you are using a custom forwarder or are sure you know what you are doing then you are probably fine\n\n", styles.Muted.Render(api_url), styles.Muted.Render(correctApiUrl)) 210 } else { 211 completeTask(c, "Validating API URL") 212 } 213 214 client := wakatime.NewClientWithOptions(api_key, api_url) 215 printTask(c, "Checking your coding stats for today") 216 217 duration, err := client.GetStatusBar() 218 if err != nil { 219 errorTask(c, "Checking your coding stats for today") 220 if errors.Is(err, wakatime.ErrUnauthorized) { 221 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 " + styles.Muted.Render("https://hackatime.hackclub.com/my/wakatime_setup") + "?") 222 } 223 224 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" + styles.Bad.Render("Full error: "+err.Error())) 225 } 226 completeTask(c, "Checking your coding stats for today") 227 228 // Convert seconds to a formatted time string (hours, minutes, seconds) 229 totalSeconds := duration.Data.GrandTotal.TotalSeconds 230 hours := totalSeconds / 3600 231 minutes := (totalSeconds % 3600) / 60 232 seconds := totalSeconds % 60 233 234 formattedTime := "" 235 if hours > 0 { 236 formattedTime += fmt.Sprintf("%d hours, ", hours) 237 } 238 if minutes > 0 || hours > 0 { 239 formattedTime += fmt.Sprintf("%d minutes, ", minutes) 240 } 241 formattedTime += fmt.Sprintf("%d seconds", seconds) 242 243 c.Printf("Sweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for %s\n\n", styles.Fancy.Render(formattedTime)) 244 245 printTask(c, "Sending test heartbeat") 246 247 err = client.SendHeartbeat(testHeartbeat) 248 if err != nil { 249 errorTask(c, "Sending test heartbeat") 250 return errors.New("oh dear; looks like something went wrong when sending that heartbeat. " + styles.Bad.Render("Full error: \""+strings.TrimSpace(err.Error())+"\"")) 251 } 252 completeTask(c, "Sending test heartbeat") 253 254 c.Println("馃コ it worked! you are good to go! Happy coding 馃憢") 255 256 return nil 257} 258 259func TestHeartbeat(c *cobra.Command, args []string) error { 260 // Initialize a new context with task state 261 c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{})) 262 263 printTask(c, "Validating arguments") 264 265 configApiKey, _ := c.Flags().GetString("key") 266 configApiURL, _ := c.Flags().GetString("url") 267 268 // If either value is missing, try to load from config file 269 if configApiKey == "" || configApiURL == "" { 270 userDir, err := os.UserHomeDir() 271 if err != nil { 272 errorTask(c, "Validating arguments") 273 return err 274 } 275 wakatimePath := filepath.Join(userDir, ".wakatime.cfg") 276 277 cfg, err := ini.Load(wakatimePath) 278 if err != nil { 279 errorTask(c, "Validating arguments") 280 return errors.New("config file not found and you haven't passed all arguments") 281 } 282 283 settings, err := cfg.GetSection("settings") 284 if err != nil { 285 errorTask(c, "Validating arguments") 286 return errors.New("no settings section in your config") 287 } 288 289 // Only load from config if not provided as parameter 290 if configApiKey == "" { 291 configApiKey = settings.Key("api_key").String() 292 if configApiKey == "" { 293 errorTask(c, "Validating arguments") 294 return errors.New("couldn't find an api_key in your config") 295 } 296 } 297 298 if configApiURL == "" { 299 configApiURL = settings.Key("api_url").String() 300 if configApiURL == "" { 301 errorTask(c, "Validating arguments") 302 return errors.New("couldn't find an api_url in your config") 303 } 304 } 305 } 306 307 completeTask(c, "Arguments look fine!") 308 309 printTask(c, "Loading api client") 310 311 client := wakatime.NewClientWithOptions(configApiKey, configApiURL) 312 _, err := client.GetStatusBar() 313 if err != nil { 314 errorTask(c, "Loading api client") 315 return err 316 } 317 318 completeTask(c, "Loading api client") 319 320 c.Println("Sending a test heartbeat to", styles.Muted.Render(configApiURL)) 321 322 printTask(c, "Sending test heartbeat") 323 324 err = client.SendHeartbeat(testHeartbeat) 325 326 if err != nil { 327 errorTask(c, "Sending test heartbeat") 328 return err 329 } 330 331 completeTask(c, "Sending test heartbeat") 332 333 c.Println("鉂囷笍 test heartbeat sent!") 334 335 return nil 336}