馃尫 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}