🌷 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/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}