馃尫 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
116func Doctor() *cobra.Command {
117 cmd := &cobra.Command{
118 Use: "doc",
119 Short: "diagnose potential hackatime issues",
120 RunE: func(c *cobra.Command, _ []string) error {
121 // Initialize a new context with task state
122 c.SetContext(context.WithValue(context.Background(), "taskState", &taskState{}))
123
124 // check our os
125 printTask(c, "Checking operating system")
126
127 os_name := runtime.GOOS
128
129 user_dir, err := os.UserHomeDir()
130 if err != nil {
131 errorTask(c, "Checking operating system")
132 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")
133 }
134 hackatime_path := filepath.Join(user_dir, ".wakatime.cfg")
135
136 if os_name != "linux" && os_name != "darwin" && os_name != "windows" {
137 errorTask(c, "Checking operating system")
138 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?")
139 }
140 completeTask(c, "Checking operating system")
141
142 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))
143
144 printTask(c, "Checking wakatime config file")
145
146 rawCfg, err := os.ReadFile(hackatime_path)
147 if errors.Is(err, os.ErrNotExist) {
148 errorTask(c, "Checking wakatime config file")
149 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")
150 }
151
152 cfg, err := ini.Load(rawCfg)
153 if err != nil {
154 errorTask(c, "Checking wakatime config file")
155 return errors.New(err.Error())
156 }
157
158 settings, err := cfg.GetSection("settings")
159 if err != nil {
160 errorTask(c, "Checking wakatime config file")
161 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())
162 }
163 completeTask(c, "Checking wakatime config file")
164
165 printTask(c, "Verifying API credentials")
166
167 api_key := settings.Key("api_key").String()
168 api_url := settings.Key("api_url").String()
169 if api_key == "" {
170 errorTask(c, "Verifying API credentials")
171 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?")
172 }
173 if api_url == "" {
174 errorTask(c, "Verifying API credentials")
175 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?")
176 }
177 completeTask(c, "Verifying API credentials")
178
179 printTask(c, "Validating API URL")
180
181 correctApiUrl := "https://hackatime.hackclub.com/api/hackatime/v1"
182 if api_url != correctApiUrl {
183 if api_url == "https://api.wakatime.com/api/v1" {
184 client := wakatime.NewClient(api_key)
185 _, err := client.GetStatusBar()
186
187 if !errors.Is(err, wakatime.ErrUnauthorized) {
188 errorTask(c, "Validating API URL")
189 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")
190 } else {
191 errorTask(c, "Validating API URL")
192 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")
193 }
194 }
195 warnTask(c, "Validating API URL")
196 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))
197 } else {
198 completeTask(c, "Validating API URL")
199 }
200
201 client := wakatime.NewClientWithOptions(api_key, api_url)
202 printTask(c, "Checking your coding stats for today")
203
204 duration, err := client.GetStatusBar()
205 if err != nil {
206 errorTask(c, "Checking your coding stats for today")
207 if errors.Is(err, wakatime.ErrUnauthorized) {
208 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") + "?")
209 }
210
211 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()))
212 }
213 completeTask(c, "Checking your coding stats for today")
214
215 // Add small delay to make the spinner animation visible
216
217 // Convert seconds to a formatted time string (hours, minutes, seconds)
218 totalSeconds := duration.Data.GrandTotal.TotalSeconds
219 hours := totalSeconds / 3600
220 minutes := (totalSeconds % 3600) / 60
221 seconds := totalSeconds % 60
222
223 formattedTime := ""
224 if hours > 0 {
225 formattedTime += fmt.Sprintf("%d hours, ", hours)
226 }
227 if minutes > 0 || hours > 0 {
228 formattedTime += fmt.Sprintf("%d minutes, ", minutes)
229 }
230 formattedTime += fmt.Sprintf("%d seconds", seconds)
231
232 c.Printf("Sweet!!! Looks like your hackatime is configured properly! Looks like you have coded today for %s\n\n", styles.Fancy.Render(formattedTime))
233
234 printTask(c, "Sending test heartbeat")
235
236 err = client.SendHeartbeat(wakatime.Heartbeat{
237 Branch: "main",
238 Category: "coding",
239 CursorPos: 1,
240 Entity: filepath.Join(user_dir, "akami.txt"),
241 Type: "file",
242 IsWrite: true,
243 Language: "Go",
244 LineNo: 1,
245 LineCount: 4,
246 Project: "example",
247 ProjectRootCount: 3,
248 Time: float64(time.Now().Unix()),
249 })
250 if err != nil {
251 errorTask(c, "Sending test heartbeat")
252 return errors.New("oh dear; looks like something went wrong when sending that heartbeat. " + styles.Bad.Render("Full error: \""+strings.TrimSpace(err.Error())+"\""))
253 }
254 completeTask(c, "Sending test heartbeat")
255
256 c.Println("馃コ it worked! you are good to go! Happy coding 馃憢")
257
258 return nil
259 },
260 }
261 return cmd
262}