🌷 the cutsie hackatime helper
at main 16 kB view raw
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/utils" 17 "github.com/taciturnaxolotl/akami/wakatime" 18 "gopkg.in/ini.v1" 19) 20 21// Task status indicators 22var spinnerChars = []string{"[|]", "[/]", "[-]", "[\\]"} 23var TaskCompleted = "[*]" 24 25// taskState holds shared state for the currently running task 26type taskState struct { 27 cancel context.CancelFunc 28 message string 29} 30 31// printTask prints a task with a spinning animation 32func printTask(c *cobra.Command, message string) { 33 // Create a cancellable context for this spinner 34 ctx, cancel := context.WithCancel(c.Context()) 35 36 // Store cancel function so we can stop the spinner later 37 if taskCtx, ok := c.Context().Value("taskState").(*taskState); ok { 38 // Cancel any previously running spinner first 39 if taskCtx.cancel != nil { 40 taskCtx.cancel() 41 // Small delay to ensure previous spinner is stopped 42 time.Sleep(10 * time.Millisecond) 43 } 44 taskCtx.message = message 45 taskCtx.cancel = cancel 46 } else { 47 // First task, create the state and store it 48 state := &taskState{ 49 message: message, 50 cancel: cancel, 51 } 52 c.SetContext(context.WithValue(c.Context(), "taskState", state)) 53 } 54 55 // Start spinner in background 56 go func() { 57 ticker := time.NewTicker(100 * time.Millisecond) 58 defer ticker.Stop() 59 i := 0 60 for { 61 select { 62 case <-ctx.Done(): 63 return 64 case <-ticker.C: 65 // Clear line and print spinner with current character 66 spinner := styles.Muted.Render(spinnerChars[i%len(spinnerChars)]) 67 c.Printf("\r\033[K%s %s", spinner, message) 68 i++ 69 } 70 } 71 }() 72 73 // Add a small random delay between 200-400ms to make spinner animation visible 74 randomDelay := 200 + time.Duration(rand.Intn(201)) // 300-500ms 75 time.Sleep(randomDelay * time.Millisecond) 76} 77 78// completeTask marks a task as completed 79func completeTask(c *cobra.Command, message string) { 80 // Cancel spinner 81 if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil { 82 state.cancel() 83 // Small delay to ensure spinner is stopped 84 time.Sleep(10 * time.Millisecond) 85 } 86 87 // Clear line and display success message 88 c.Printf("\r\033[K%s %s\n", styles.Success.Render(TaskCompleted), message) 89} 90 91// errorTask marks a task as failed 92func errorTask(c *cobra.Command, message string) { 93 // Cancel spinner 94 if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil { 95 state.cancel() 96 // Small delay to ensure spinner is stopped 97 time.Sleep(10 * time.Millisecond) 98 } 99 100 // Clear line and display error message 101 c.Printf("\r\033[K%s %s\n", styles.Bad.Render("[ ! ]"), message) 102} 103 104// warnTask marks a task as a warning 105func warnTask(c *cobra.Command, message string) { 106 // Cancel spinner 107 if state, ok := c.Context().Value("taskState").(*taskState); ok && state.cancel != nil { 108 state.cancel() 109 // Small delay to ensure spinner is stopped 110 time.Sleep(10 * time.Millisecond) 111 } 112 113 // Clear line and display warning message 114 c.Printf("\r\033[K%s %s\n", styles.Warn.Render("[?]"), message) 115} 116 117var user_dir, err = os.UserHomeDir() 118 119var testHeartbeat = wakatime.Heartbeat{ 120 Branch: "main", 121 Category: "coding", 122 CursorPos: 1, 123 Entity: filepath.Join(user_dir, "akami.txt"), 124 Type: "file", 125 IsWrite: true, 126 Language: "Go", 127 LineNo: 1, 128 LineCount: 4, 129 Project: "example", 130 ProjectRootCount: 3, 131 Time: float64(time.Now().Unix()), 132} 133 134func getClientStuff(c *cobra.Command) (key string, url string, err error) { 135 configApiKey, _ := c.Flags().GetString("key") 136 configApiURL, _ := c.Flags().GetString("url") 137 138 // If either value is missing, try to load from config file 139 if configApiKey == "" || configApiURL == "" { 140 userDir, err := os.UserHomeDir() 141 if err != nil { 142 errorTask(c, "Validating arguments") 143 return configApiKey, configApiURL, err 144 } 145 wakatimePath := filepath.Join(userDir, ".wakatime.cfg") 146 147 cfg, err := ini.Load(wakatimePath) 148 if err != nil { 149 errorTask(c, "Validating arguments") 150 return configApiKey, configApiURL, errors.New("config file not found and you haven't passed all arguments") 151 } 152 153 settings, err := cfg.GetSection("settings") 154 if err != nil { 155 errorTask(c, "Validating arguments") 156 return configApiKey, configApiURL, errors.New("no settings section in your config") 157 } 158 159 // Only load from config if not provided as parameter 160 if configApiKey == "" { 161 configApiKey = settings.Key("api_key").String() 162 if configApiKey == "" { 163 errorTask(c, "Validating arguments") 164 return configApiKey, configApiURL, errors.New("couldn't find an api_key in your config") 165 } 166 } 167 168 if configApiURL == "" { 169 configApiURL = settings.Key("api_url").String() 170 if configApiURL == "" { 171 errorTask(c, "Validating arguments") 172 return configApiKey, configApiURL, errors.New("couldn't find an api_url in your config") 173 } 174 } 175 } 176 177 return configApiKey, configApiURL, nil 178} 179 180func Doctor(c *cobra.Command, _ []string) error { 181 // Initialize a new context with task state 182 c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{})) 183 184 // check our os 185 printTask(c, "Checking operating system") 186 187 os_name := runtime.GOOS 188 189 user_dir, err := os.UserHomeDir() 190 if err != nil { 191 errorTask(c, "Checking operating system") 192 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") 193 } 194 hackatime_path := filepath.Join(user_dir, ".wakatime.cfg") 195 196 if os_name != "linux" && os_name != "darwin" && os_name != "windows" { 197 errorTask(c, "Checking operating system") 198 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?") 199 } 200 completeTask(c, "Checking operating system") 201 202 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)) 203 204 printTask(c, "Checking wakatime config file") 205 206 rawCfg, err := os.ReadFile(hackatime_path) 207 if errors.Is(err, os.ErrNotExist) { 208 errorTask(c, "Checking wakatime config file") 209 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") 210 } 211 212 cfg, err := ini.Load(rawCfg) 213 if err != nil { 214 errorTask(c, "Checking wakatime config file") 215 return errors.New(err.Error()) 216 } 217 218 settings, err := cfg.GetSection("settings") 219 if err != nil { 220 errorTask(c, "Checking wakatime config file") 221 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()) 222 } 223 completeTask(c, "Checking wakatime config file") 224 225 printTask(c, "Verifying API credentials") 226 227 api_key := settings.Key("api_key").String() 228 api_url := settings.Key("api_url").String() 229 if api_key == "" { 230 errorTask(c, "Verifying API credentials") 231 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?") 232 } 233 if api_url == "" { 234 errorTask(c, "Verifying API credentials") 235 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?") 236 } 237 completeTask(c, "Verifying API credentials") 238 239 printTask(c, "Validating API URL") 240 241 correctApiUrl := "https://hackatime.hackclub.com/api/hackatime/v1" 242 if api_url != correctApiUrl { 243 if api_url == "https://api.wakatime.com/api/v1" { 244 client := wakatime.NewClient(api_key) 245 _, err := client.GetStatusBar() 246 247 if !errors.Is(err, wakatime.ErrUnauthorized) { 248 errorTask(c, "Validating API URL") 249 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") 250 } else { 251 errorTask(c, "Validating API URL") 252 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") 253 } 254 } 255 warnTask(c, "Validating API URL") 256 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)) 257 } else { 258 completeTask(c, "Validating API URL") 259 } 260 261 client := wakatime.NewClientWithOptions(api_key, api_url) 262 printTask(c, "Checking your coding stats for today") 263 264 duration, err := client.GetStatusBar() 265 if err != nil { 266 errorTask(c, "Checking your coding stats for today") 267 if errors.Is(err, wakatime.ErrUnauthorized) { 268 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") + "?") 269 } 270 271 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())) 272 } 273 completeTask(c, "Checking your coding stats for today") 274 275 c.Printf("Sweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for %s\n\n", styles.Fancy.Render(utils.PrettyPrintTime(duration.Data.GrandTotal.TotalSeconds))) 276 277 printTask(c, "Sending test heartbeat") 278 279 err = client.SendHeartbeat(testHeartbeat) 280 if err != nil { 281 errorTask(c, "Sending test heartbeat") 282 return errors.New("oh dear; looks like something went wrong when sending that heartbeat. " + styles.Bad.Render("Full error: \""+strings.TrimSpace(err.Error())+"\"")) 283 } 284 completeTask(c, "Sending test heartbeat") 285 286 c.Println("🥳 it worked! you are good to go! Happy coding 👋") 287 288 return nil 289} 290 291func TestHeartbeat(c *cobra.Command, args []string) error { 292 // Initialize a new context with task state 293 c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{})) 294 295 printTask(c, "Validating arguments") 296 297 api_key, api_url, err := getClientStuff(c) 298 299 completeTask(c, "Arguments look fine!") 300 301 printTask(c, "Loading api client") 302 303 client := wakatime.NewClientWithOptions(api_key, api_url) 304 _, err = client.GetStatusBar() 305 if err != nil { 306 errorTask(c, "Loading api client") 307 return err 308 } 309 310 completeTask(c, "Loading api client") 311 312 c.Println("Sending a test heartbeat to", styles.Muted.Render(api_url)) 313 314 printTask(c, "Sending test heartbeat") 315 316 err = client.SendHeartbeat(testHeartbeat) 317 318 if err != nil { 319 errorTask(c, "Sending test heartbeat") 320 return err 321 } 322 323 completeTask(c, "Sending test heartbeat") 324 325 c.Println("❇️ test heartbeat sent!") 326 327 return nil 328} 329 330func Status(c *cobra.Command, args []string) error { 331 // Initialize a new context with task state 332 c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{})) 333 334 printTask(c, "Validating arguments") 335 336 api_key, api_url, err := getClientStuff(c) 337 338 completeTask(c, "Arguments look fine!") 339 340 printTask(c, "Loading api client") 341 342 client := wakatime.NewClientWithOptions(api_key, api_url) 343 status, err := client.GetStatusBar() 344 if err != nil { 345 errorTask(c, "Loading api client") 346 return err 347 } 348 349 completeTask(c, "Loading api client") 350 351 c.Printf("\nLooks like you have coded today for %s today!\n", styles.Fancy.Render(utils.PrettyPrintTime(status.Data.GrandTotal.TotalSeconds))) 352 353 summary, err := client.GetLast7Days() 354 if err != nil { 355 return err 356 } 357 358 c.Printf("You have averaged %s over the last 7 days\n\n", styles.Fancy.Render(utils.PrettyPrintTime(int(summary.Data.DailyAverage)))) 359 360 // Display top 5 projects with progress bars 361 if len(summary.Data.Projects) > 0 { 362 c.Println(styles.Fancy.Render("Top Projects:")) 363 364 // Determine how many projects to show (up to 5) 365 count := min(5, len(summary.Data.Projects)) 366 367 // Find the longest project name for formatting 368 longestName := 0 369 longestTime := 0 370 371 for i := range count { 372 project := summary.Data.Projects[i] 373 if len(project.Name) > longestName { 374 longestName = len(project.Name) 375 } 376 377 timeStr := utils.PrettyPrintTime(int(project.TotalSeconds)) 378 if len(timeStr) > longestTime { 379 longestTime = len(timeStr) 380 } 381 } 382 383 // Display each project with a bar 384 for i := range count { 385 project := summary.Data.Projects[i] 386 387 // Format the project name and time with padding 388 paddedName := fmt.Sprintf("%-*s", longestName+2, project.Name) 389 timeStr := utils.PrettyPrintTime(int(project.TotalSeconds)) 390 paddedTime := fmt.Sprintf("%-*s", longestTime+2, timeStr) 391 392 // Create the progress bar 393 barWidth := 25 394 bar := "" 395 percentage := project.Percent 396 for j := range barWidth { 397 if float64(j) < percentage/(100/float64(barWidth)) { 398 bar += "█" 399 } else { 400 bar += "░" 401 } 402 } 403 404 // Use different styles for different components 405 styledName := styles.Fancy.Render(paddedName) 406 styledTime := styles.Muted.Render(paddedTime) 407 styledBar := styles.Success.Render(bar) 408 styledPercent := styles.Warn.Render(fmt.Sprintf("%.2f%%", percentage)) 409 410 // Print the formatted line 411 c.Printf(" %s %s %s %s\n", styledName, styledTime, styledBar, styledPercent) 412 } 413 414 c.Println() 415 } 416 417 // Display top 5 languages with progress bars 418 if len(summary.Data.Languages) > 0 { 419 c.Println(styles.Fancy.Render("Top Languages:")) 420 421 // Determine how many languages to show (up to 5) 422 count := min(5, len(summary.Data.Languages)) 423 424 // Find the longest language name for formatting 425 longestName := 0 426 longestTime := 0 427 428 for i := range count { 429 language := summary.Data.Languages[i] 430 if len(language.Name) > longestName { 431 longestName = len(language.Name) 432 } 433 434 timeStr := utils.PrettyPrintTime(int(language.TotalSeconds)) 435 if len(timeStr) > longestTime { 436 longestTime = len(timeStr) 437 } 438 } 439 440 // Display each language with a bar 441 for i := range count { 442 language := summary.Data.Languages[i] 443 444 // Format the language name and time with padding 445 paddedName := fmt.Sprintf("%-*s", longestName+2, language.Name) 446 timeStr := utils.PrettyPrintTime(int(language.TotalSeconds)) 447 paddedTime := fmt.Sprintf("%-*s", longestTime+2, timeStr) 448 449 // Create the progress bar 450 barWidth := 25 451 bar := "" 452 percentage := language.Percent 453 for j := range barWidth { 454 if float64(j) < percentage/(100/float64(barWidth)) { 455 bar += "█" 456 } else { 457 bar += "░" 458 } 459 } 460 461 // Use different styles for different components 462 styledName := styles.Fancy.Render(paddedName) 463 styledTime := styles.Muted.Render(paddedTime) 464 styledBar := styles.Success.Render(bar) 465 styledPercent := styles.Warn.Render(fmt.Sprintf("%.2f%%", percentage)) 466 467 // Print the formatted line 468 c.Printf(" %s %s %s %s\n", styledName, styledTime, styledBar, styledPercent) 469 } 470 471 c.Println() 472 } 473 474 return nil 475}