From b67d70df8dbb9d576d7b200992d51a98ecafcfe1 Mon Sep 17 00:00:00 2001 From: jms-guy Date: Wed, 29 Oct 2025 13:50:25 -0400 Subject: [PATCH 1/2] wakapi integration functions and config changes --- cmd/cli/commands.go | 65 +++++++++- cmd/cli/root.go | 6 + cmd/cli/timekeep.go | 73 +++++++++-- .../internal/events/event_controller.go | 4 +- .../internal/events/events_wakatime.go | 113 +++++++++++++++--- cmd/service/service_linux.go | 2 +- cmd/service/service_windows.go | 2 +- docs/commands.md | 13 +- internal/config/config.go | 11 ++ 9 files changed, 259 insertions(+), 30 deletions(-) diff --git a/cmd/cli/commands.go b/cmd/cli/commands.go index 01d351b..462cbfa 100644 --- a/cmd/cli/commands.go +++ b/cmd/cli/commands.go @@ -339,7 +339,7 @@ func (s *CLIService) GetVersion() error { return nil } -// Changes config to enable WakaTime with API key +// Changes config to enable WakaTime func (s *CLIService) EnableWakaTime(apiKey, path string) error { if s.Config.WakaTime.Enabled { return nil @@ -385,13 +385,63 @@ func (s *CLIService) DisableWakaTime() error { return nil } +// Changes config to enable Wakapi +func (s *CLIService) EnableWakapi(apiKey, server string) error { + if s.Config.Wakapi.Enabled { + return nil + } + + if apiKey != "" { + s.Config.Wakapi.APIKey = apiKey + } + + if s.Config.Wakapi.APIKey == "" { + return fmt.Errorf("WakaTime API key required. Use flag: --api_key ") + } + + if server != "" { + s.Config.Wakapi.Server = server + } + + if s.Config.Wakapi.Server == "" { + return fmt.Errorf("wakapi server address required. Use flag: --server
") + } + + s.Config.Wakapi.Enabled = true + + if err := s.saveAndNotify(); err != nil { + return err + } + + return nil +} + +// Disables Wakapi in config +func (s *CLIService) DisableWakapi() error { + if !s.Config.Wakapi.Enabled { + return nil + } + + s.Config.Wakapi.Enabled = false + + if err := s.saveAndNotify(); err != nil { + return err + } + + return nil +} + // Set various config values -func (s *CLIService) SetConfig(cliPath, project, interval string, grace int) error { +func (s *CLIService) SetConfig(cliPath, server, project, interval string, grace int) error { if cliPath != "" { s.Config.WakaTime.CLIPath = cliPath } + if server != "" { + s.Config.Wakapi.Server = server + } if project != "" { s.Config.WakaTime.GlobalProject = project + s.Config.Wakapi.GlobalProject = project } if interval != "" { s.Config.PollInterval = interval @@ -417,3 +467,14 @@ func (s *CLIService) StatusWakatime() error { return nil } + +// Returns Wakapi enabled/disabled status for user +func (s *CLIService) StatusWakapi() error { + if s.Config.Wakapi.Enabled { + fmt.Println("enabled") + } else { + fmt.Println("disabled") + } + + return nil +} diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 3e2b850..5e6bf09 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -24,7 +24,13 @@ func (s *CLIService) RootCmd() *cobra.Command { wCmd.AddCommand(s.wakatimeEnable()) wCmd.AddCommand(s.wakatimeDisable()) + wpCmd := s.wakapiIntegration() + wpCmd.AddCommand(s.wakapiStatus()) + wpCmd.AddCommand(s.wakapiEnable()) + wpCmd.AddCommand(s.wakapiDisable()) + rootCmd.AddCommand(wCmd) + rootCmd.AddCommand(wpCmd) rootCmd.AddCommand(s.addProgramsCmd()) rootCmd.AddCommand(s.updateCmd()) rootCmd.AddCommand(s.removeProgramsCmd()) diff --git a/cmd/cli/timekeep.go b/cmd/cli/timekeep.go index ac87bc5..b7826d7 100644 --- a/cmd/cli/timekeep.go +++ b/cmd/cli/timekeep.go @@ -96,7 +96,7 @@ func (s *CLIService) getListcmd() *cobra.Command { Use: "ls", Aliases: []string{"LS", "list", "List", "LIST"}, Short: "Lists programs being tracked by service", - Args: cobra.ExactArgs(0), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -156,7 +156,7 @@ func (s *CLIService) refreshCmd() *cobra.Command { Use: "refresh", Aliases: []string{"Refresh", "REFRESH"}, Short: "Sends a manual refresh command to the service", - Args: cobra.ExactArgs(0), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { err := s.ServiceCmd.WriteToService() if err != nil { @@ -194,7 +194,7 @@ func (s *CLIService) statusServiceCmd() *cobra.Command { Use: "status", Aliases: []string{"Status", "STATUS"}, Short: "Gets current OS state of Timekeep service", - Args: cobra.ExactArgs(0), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return s.StatusService() }, @@ -206,7 +206,7 @@ func (s *CLIService) getActiveSessionsCmd() *cobra.Command { Use: "active", Aliases: []string{"Active", "ACTIVE"}, Short: "Get list of current active sessions being tracked", - Args: cobra.ExactArgs(0), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -220,7 +220,7 @@ func (s *CLIService) getVersionCmd() *cobra.Command { Use: "version", Aliases: []string{"Version", "VERSION"}, Short: "Get current version of Timekeep", - Args: cobra.ExactArgs(0), + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return s.GetVersion() }, @@ -239,7 +239,8 @@ func (s *CLIService) wakatimeStatus() *cobra.Command { return &cobra.Command{ Use: "status", Aliases: []string{"STATUS"}, - Short: "Show WakaTime current enabled/disabled status", + Short: "Show current enabled/disabled status", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return s.StatusWakatime() }, @@ -251,6 +252,7 @@ func (s *CLIService) wakatimeEnable() *cobra.Command { Use: "enable", Aliases: []string{"Enable", "ENABLE"}, Short: "Enable WakaTime integration", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { apiKey, _ := cmd.Flags().GetString("api_key") path, _ := cmd.Flags().GetString("cli_path") @@ -270,12 +272,65 @@ func (s *CLIService) wakatimeDisable() *cobra.Command { Use: "disable", Aliases: []string{"Disable", "DISABLE"}, Short: "Disable WakaTime integration", + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return s.DisableWakaTime() }, } } +func (s *CLIService) wakapiIntegration() *cobra.Command { + return &cobra.Command{ + Use: "wakapi", + Aliases: []string{"Wakapi", "WAKAPI"}, + Short: "Enable/disable integration with Wakapi", + } +} + +func (s *CLIService) wakapiStatus() *cobra.Command { + return &cobra.Command{ + Use: "status", + Aliases: []string{"Status", "STATUS"}, + Short: "Show current enabled/disabled status", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return s.StatusWakapi() + }, + } +} + +func (s *CLIService) wakapiEnable() *cobra.Command { + cmd := &cobra.Command{ + Use: "enable", + Aliases: []string{"Enable", "ENABLE"}, + Short: "Enable Wakapi integration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + apiKey, _ := cmd.Flags().GetString("api_key") + server, _ := cmd.Flags().GetString("server") + + return s.EnableWakapi(apiKey, server) + }, + } + + cmd.Flags().String("api_key", "", "User's Wakapi API key") + cmd.Flags().String("server", "", "User's wakapi server address") + + return cmd +} + +func (s *CLIService) wakapiDisable() *cobra.Command { + return &cobra.Command{ + Use: "disable", + Aliases: []string{"Disable", "DISABLE"}, + Short: "Disable Wakapi integration", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return s.DisableWakapi() + }, + } +} + func (s *CLIService) setConfigCmd() *cobra.Command { cmd := &cobra.Command{ Use: "config", @@ -283,16 +338,18 @@ func (s *CLIService) setConfigCmd() *cobra.Command { Short: "Set various config values", RunE: func(cmd *cobra.Command, args []string) error { cliPath, _ := cmd.Flags().GetString("cli_path") + server, _ := cmd.Flags().GetString("server") project, _ := cmd.Flags().GetString("global_project") interval, _ := cmd.Flags().GetString("poll_interval") grace, _ := cmd.Flags().GetInt("poll_grace") - return s.SetConfig(cliPath, project, interval, grace) + return s.SetConfig(cliPath, server, project, interval, grace) }, } cmd.Flags().String("cli_path", "", "Set absolute path to wakatime-cli binary") - cmd.Flags().String("global_project", "", "Set global project variable for WakaTime data sorting") + cmd.Flags().String("server", "", "Set server address for user's wakapi instance") + cmd.Flags().String("global_project", "", "Set global project variable for WakaTime/Wakapi data sorting") cmd.Flags().String("poll_interval", "", "Set the polling interval for process monitoring for Linux version") cmd.Flags().Int("poll_grace", 3, "Set grace period for PIDs missed via polling (process will only register as finished after 'poll_interval * poll_grace' ex. '1s * 3 = 3s')") diff --git a/cmd/service/internal/events/event_controller.go b/cmd/service/internal/events/event_controller.go index b30cb6b..98bacb3 100644 --- a/cmd/service/internal/events/event_controller.go +++ b/cmd/service/internal/events/event_controller.go @@ -6,6 +6,7 @@ import ( "encoding/json" "log" "net" + "net/http" "os/exec" "strings" "sync" @@ -32,6 +33,7 @@ type EventController struct { MonCancel context.CancelFunc // Monitoring function cancel context WakaCancel context.CancelFunc // WakaTime function cancel context Config *config.Config // Struct built from config file + Client *http.Client // Http Client for Wakapi heartbeat requests version string // Timekeep version } @@ -106,7 +108,7 @@ func (e *EventController) RefreshProcessMonitor(serviceCtx context.Context, logg e.StartMonitor(serviceCtx, logger, sm, pr, a, h, toTrack) } - if e.Config.WakaTime.Enabled { + if e.Config.WakaTime.Enabled || e.Config.Wakapi.Enabled { e.StartHeartbeats(serviceCtx, logger, sm) } diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go index 8731cf7..56e1477 100644 --- a/cmd/service/internal/events/events_wakatime.go +++ b/cmd/service/internal/events/events_wakatime.go @@ -1,17 +1,23 @@ package events import ( + "bytes" "context" + "encoding/base64" + "encoding/json" "fmt" + "io" "log" + "net/http" "os" "os/exec" + "strings" "time" "github.com/jms-guy/timekeep/cmd/service/internal/sessions" ) -// Start WakaTime heartbeat ticker +// Start WakaTime/Wakapi heartbeat ticker func (e *EventController) StartHeartbeats(parent context.Context, logger *log.Logger, sm *sessions.SessionManager) { newCtx, newCancel := context.WithCancel(parent) @@ -24,7 +30,19 @@ func (e *EventController) StartHeartbeats(parent context.Context, logger *log.Lo oldCancel() } - logger.Println("INFO: Starting WakaTime heartbeats") + if e.Config.Wakapi.Enabled && e.Client == nil { + logger.Println("INFO: Initializing Wakapi Http client") + e.Client = &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + DisableKeepAlives: false, + MaxIdleConns: 10, + IdleConnTimeout: 90 * time.Second, + }, + } + } + + logger.Println("INFO: Starting heartbeats") go func(ctx context.Context) { ticker := time.NewTicker(time.Minute) @@ -34,15 +52,14 @@ func (e *EventController) StartHeartbeats(parent context.Context, logger *log.Lo for { select { case <-ctx.Done(): - logger.Println("INFO: Stopping WakaTime heartbeats") + logger.Println("INFO: Stopping heartbeats") return case <-ticker.C: if errorCount >= 5 { - logger.Println("ERROR: WakaTime heartbeats failed 5 times consecutively, stopping") + logger.Println("ERROR: Heartbeats failed 5 times consecutively, stopping") return } if err := e.sendHeartbeats(ctx, logger, sm); err != nil { - logger.Printf("ERROR: Failed to send WakaTime heartbeat: %s", err) errorCount++ continue } @@ -52,10 +69,11 @@ func (e *EventController) StartHeartbeats(parent context.Context, logger *log.Lo }(newCtx) } -// Send specified heartbeats to WakaTime +// Send specified heartbeats to WakaTime/Wakapi func (e *EventController) sendHeartbeats(ctx context.Context, logger *log.Logger, sm *sessions.SessionManager) error { type item struct{ program, category, project string } items := []item{} + var err error sm.Mu.Lock() for p, t := range sm.Programs { @@ -66,27 +84,38 @@ func (e *EventController) sendHeartbeats(ctx context.Context, logger *log.Logger sm.Mu.Unlock() for _, it := range items { - if err := e.sendWakaHeartbeat(ctx, logger, it.program, it.category, it.project); err != nil { - return err + if e.Config.WakaTime.Enabled { + if err = e.sendWakaTimeHeartbeat(ctx, logger, it.program, it.category, it.project); err != nil { + logger.Printf("ERROR: Failed to send WakaTime heartbeat: %s", err) + } else { + logger.Printf("INFO: WakaTime heartbeat sent for %s, category %s", it.program, it.category) + } + } + + if e.Config.Wakapi.Enabled { + if err := e.sendWakapiHeartbeat(ctx, it.program, it.category, it.project); err != nil { + logger.Printf("ERROR: Failed to send Wakapi heartbeat: %s", err) + } else { + logger.Printf("INFO: Wakapi heartbeat send for %s, category %s", it.program, it.category) + } } - logger.Printf("INFO: WakaTime heartbeat sent for %s, category %s", it.program, it.category) } - return nil + + return err } // Call the wakatime-cli heartbeat command -func (e *EventController) sendWakaHeartbeat(ctx context.Context, logger *log.Logger, program, category, project string) error { +func (e *EventController) sendWakaTimeHeartbeat(ctx context.Context, logger *log.Logger, program, category, project string) error { cliPath := e.Config.WakaTime.CLIPath if cliPath == "" { - logger.Println("ERROR: wakatime-cli path not set") + return fmt.Errorf("wakatime-cli path not set") } projectToUse := e.Config.WakaTime.GlobalProject if project != "" { projectToUse = project } - logger.Printf("INFO: Sending WakaTime heartbeat for %s, category %s, project %s", program, category, projectToUse) args := []string{ "--key", e.Config.WakaTime.APIKey, @@ -99,8 +128,6 @@ func (e *EventController) sendWakaHeartbeat(ctx context.Context, logger *log.Log "--write", } - logger.Printf("DEBUG: cli=%s args=%v", cliPath, args) - execCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() @@ -117,6 +144,62 @@ func (e *EventController) sendWakaHeartbeat(ctx context.Context, logger *log.Log return nil } +// Send heartbeat to user's wakapi instance +func (e *EventController) sendWakapiHeartbeat(ctx context.Context, program, category, project string) error { + if e.Config.Wakapi.Server == "" || e.Config.Wakapi.APIKey == "" { + return fmt.Errorf("missing config variable") + } + + projectToUse := e.Config.Wakapi.GlobalProject + if project != "" { + projectToUse = project + } + + type Heartbeat struct { + Entity string `json:"entity"` + Type string `json:"type"` + Category string `json:"category"` + Project string `json:"project"` + Time int64 `json:"time"` + IsWrite bool `json:"is_write"` + } + + heartbeat := Heartbeat{ + Entity: program, + Type: "app", + Category: category, + Project: projectToUse, + Time: time.Now().Unix(), + IsWrite: false, + } + + heartbeatData, err := json.Marshal(heartbeat) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(e.Config.Wakapi.Server, "/")+"/heartbeat", bytes.NewBuffer(heartbeatData)) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(e.Config.Wakapi.APIKey))) + + resp, err := e.Client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("wakapi: status %d: %s", resp.StatusCode, bytes.TrimSpace(b)) + } + + return nil +} + // Stops WakaTime heartbeat ticker after disabling integration func (e *EventController) StopHeartbeats() { e.mu.Lock() diff --git a/cmd/service/service_linux.go b/cmd/service/service_linux.go index 42a0492..ee654ce 100644 --- a/cmd/service/service_linux.go +++ b/cmd/service/service_linux.go @@ -78,7 +78,7 @@ func (s *timekeepService) Manage() (string, error) { s.eventCtrl.StartMonitor(serviceCtx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) } - if s.eventCtrl.Config.WakaTime.Enabled { + if s.eventCtrl.Config.WakaTime.Enabled || s.eventCtrl.Config.Wakapi.Enabled { s.eventCtrl.StartHeartbeats(serviceCtx, s.logger.Logger, s.sessions) } diff --git a/cmd/service/service_windows.go b/cmd/service/service_windows.go index b27c011..a986e52 100644 --- a/cmd/service/service_windows.go +++ b/cmd/service/service_windows.go @@ -74,7 +74,7 @@ func (s *timekeepService) Execute(args []string, r <-chan svc.ChangeRequest, sta s.eventCtrl.StartMonitor(serviceCtx, s.logger.Logger, s.sessions, s.prRepo, s.asRepo, s.hsRepo, toTrack) } - if s.eventCtrl.Config.WakaTime.Enabled { + if s.eventCtrl.Config.WakaTime.Enabled || s.eventCtrl.Config.Wakapi.Enabled { s.eventCtrl.StartHeartbeats(serviceCtx, s.logger.Logger, s.sessions) } diff --git a/docs/commands.md b/docs/commands.md index fde1a63..6e2fbc1 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -16,7 +16,8 @@ - `timekeep config --poll_interval "750ms" --poll_grace 2` - Flags: - `cli_path` - wakatime-cli path for WakaTime integration (ABSOLUTE path) - - `global_project` - Default project used for WakaTime program sorting + - `server` - user's wakapi instance server address + - `global_project` - Default project used for WakaTime/Wakapi program sorting. Sets value for both project variables, if you want different values, you must manually change the config file - `poll_interval` - Polling interval for Linux process monitoring (default 1s) - `poll_grace` - Grace period for PID removal from sessions on Linux version (default 3) @@ -69,4 +70,12 @@ - `--api_key "KEY"` - Set WakaTime API key - `--cli_path "PATH"` - Set wakatime-cli path(absolute) - Disable integration with `timekeep wakatime disable` - - Check WakaTime enabled/disabled status with `timekeep wakatime status` \ No newline at end of file + - Check WakaTime enabled/disabled status with `timekeep wakatime status` + +- `wakapi [status|enable|disable]` + - Enable Wakapi integration with `timekeep wakapi enable` + - Flags: + - `--api_key "KEY"` - Set Wakapi API key + - `--server "ADDRESS"` - Set server address for wakapi instance + - Disable integration with `timekeep wakapi disable` + - Check Wakapi enabled/disabled status with `timekeep wakapi status` \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index a8838e6..6c97ed9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( type Config struct { WakaTime WakaTimeConfig `json:"wakatime"` // WakaTime integration variables + Wakapi WakapiConfig `json:"wakapi"` // Wakapi integration variables PollInterval string `json:"poll_interval,omitempty"` // Linux - monitor polling interval, default 1s PollGrace int `json:"poll_grace,omitempty"` // Linux - number representing the grace period granted to PIDs accidently missed by polling, default 3 } @@ -21,9 +22,19 @@ type WakaTimeConfig struct { GlobalProject string `json:"global_project,omitempty"` // Default project to associate all tracked programs with } +type WakapiConfig struct { + Enabled bool `json:"enabled"` // Wakapi integration enabling value + Server string `json:"server,omitempty"` // Wakapi server address + APIKey string `json:"api_key,omitempty"` // Wakapi API key + GlobalProject string `json:"global_project,omitempty"` // Default project to associate all tracked programs with +} + const defaultConfig = `{ "wakatime": { "enabled": false + }, + "wakapi": { + "enabled": false } }` From 2a7c4a5b70599a58cfbcd6d0515577da4c00ac53 Mon Sep 17 00:00:00 2001 From: jms-guy Date: Thu, 30 Oct 2025 13:52:51 -0400 Subject: [PATCH 2/2] readme update, round out wakapi integration --- README.md | 128 ++++++++++-------- .../internal/events/events_wakatime.go | 17 +-- scripts/install.sh | 3 + 3 files changed, 84 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 6f9dfd9..6aa045c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A process activity tracker, it runs as a background service recording start/stop - [How It Works](#how-it-works) - [Usage](#usage) - [Installation](#installation) -- [WakaTime](#wakatime) +- [WakaTime/Wakapi](#wakatimewakapi) - [File Locations](#file-locations) - [Current Limitations](#current-limitations) - [Contributing & Issues](#contributing--issues) @@ -91,11 +91,35 @@ sc.exe start timekeep Get-Service -Name "timekeep" ``` + Test using CLI: ```powershell .\timekeep.exe status # Check if the service is responsive ``` +##### To include shell completion: + +```powershell +New-Item -Force -ItemType Directory (Split-Path $PROFILE) | Out-Null +$comp = Join-Path (Split-Path $PROFILE) 'timekeep.ps1' +timekeep completion powershell > $comp +if (-not (Select-String -Path $PROFILE -SimpleMatch $comp -Quiet)) { Add-Content $PROFILE "`n. $comp" } +. $PROFILE +``` + +If you encounter issues running scripts in PowerShell, bypass the ExecutionPolicy with: + +```powershell +powershell -ExecutionPolicy Bypass +``` + +for a single terminal session, or loosen restrictions with: + +```powershell +Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned +``` + + #### Linux ```bash # Clone and build @@ -158,54 +182,13 @@ Test using CLI: timekeep status # Check if the service is responsive ``` -### Shell Completion -To include Tab-completion for Shell commands, run the completion commands specific for your Shell. - -**Bash** +##### To include shell completion: ```bash timekeep completion bash | sudo tee /etc/bash_completion.d/timekeep >/dev/null source /etc/bash_completion ``` -**PowerShell** - -```powershell -New-Item -Force -ItemType Directory (Split-Path $PROFILE) | Out-Null -$comp = Join-Path (Split-Path $PROFILE) 'timekeep.ps1' -timekeep completion powershell > $comp -if (-not (Select-String -Path $PROFILE -SimpleMatch $comp -Quiet)) { Add-Content $PROFILE "`n. $comp" } -. $PROFILE -``` - -If you encounter issues running scripts in PowerShell, bypass the ExecutionPolicy with: - -```powershell -powershell -ExecutionPolicy Bypass -``` - -for a single terminal session, or with: - -```powershell -Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned -``` - -to loosen restrictions. - - -**Untested** - **Zsh** -```zsh -timekeep completion zsh | sudo tee /usr/local/share/zsh/site-functions/_timekeep >/dev/null -autoload -U compinit && compinit -``` - -**Untested** - **Fish** -```fish -mkdir -p ~/.config/fish/completions -timekeep completion fish > ~/.config/fish/completions/timekeep.fish -# open a new fish session -``` - ## Uninstalling ### Windows @@ -222,7 +205,10 @@ sudo rm /usr/local/bin/timekeepd /usr/local/bin/timekeep sudo systemctl daemon-reload ``` -## WakaTime +## WakaTime/Wakapi + +### WakaTime + Timekeep now integrates with [WakaTime](https://wakatime.com), allowing users to track external program usage alongside their IDE and web-browsing stats. **Timekeep does not track activity within these programs, only when these programs are running.** To enable WakaTime integration, users must: @@ -231,13 +217,13 @@ To enable WakaTime integration, users must: Enable integration through timekeep. Retrieve your API key from your [WakaTime profile settings](https://wakatime.com/settings/account). Set your WakaTime API key and wakatime-cli path either directly in the Timekeep [config](https://github.com/jms-guy/timekeep?tab=readme-ov-file#file-locations) file, or provide them through flags: -`timekeep wakatime enable --api_key "YOUR-KEY" --cli_path "wakatime-cli-PATH"` +`timekeep wakatime enable --api_key "YOUR_KEY" --cli_path "wakatime-cli_PATH"` ```json { "wakatime": { "enabled": true, - "api_key": "APIKEY", + "api_key": "API_KEY", "cli_path": "PATH", "global_project": "PROJECT" } @@ -246,11 +232,11 @@ Enable integration through timekeep. Retrieve your API key from your [WakaTime p **The wakatime-cli path must be an absolute path.**: *C:\Path\To\\.wakatime\wakatime-cli.exe* -### Complete WakaTime setup example +#### Complete WakaTime setup example -`timekeep wakatime enable --api_key YOUR-KEY --cli_path wakatime-cli-PATH` +`timekeep wakatime enable --api_key "YOUR_KEY" --cli_path "wakatime-cli_PATH"` -`timekeep add photoshop.exe --category designing --project "UI Design"` +`timekeep add photoshop.exe --category "designing" --project "UI Design"` Check WakaTime current enabled/disabled status: @@ -260,10 +246,10 @@ Disable integration with: `timekeep wakatime disable` -### Categories +#### Categories After enabling, wakatime-cli heartbeats will be sent containing tracking data for given programs. Note, that only programs added to Timekeep with a given category will have data sent to WakaTime. -`timekeep add notepad.exe --category notes` +`timekeep add notepad.exe --category "notes"` If no category is set for a program, it will still be tracked locally, but no data for it will be sent out. @@ -277,20 +263,44 @@ List of categories accepted(defined [here](https://github.com/wakatime/wakatime- " \"translating\", or \"designing\". ``` -### Projects +#### Projects Timekeep has no automatic project detection for WakaTime. Users may set a global project for all programs to use in the config, or via the command: `timekeep config --global_project "YOUR-PROJECT"` Users can also set project variables on a per-program basis: -`timekeep add notepad.exe --category notes --project Timekeep` +`timekeep add notepad.exe --category "notes" --project "Timekeep"` Program-set project variables will take precedence over a set Global Project. If no project variable is set via the global_project config or when adding programs, WakaTime will fall back to default "Unknown Project". Users can update a program's category or project with the **update** command: -`timekeep update notepad.exe --category planning --project Timekeep2` +`timekeep update notepad.exe --category "planning" --project "Timekeep2"` + +### Wakapi + +Similar to WakaTime, users can also allow their program activity to be tracked via [Wakapi](https://github.com/muety/wakapi). The commands and structures are very similar, to enable integration you need your Wakapi API key as well as the address to your running Wakapi server, provided through either command flags or editing the config file. + +`timekeep wakapi enable --api_key "YOUR_KEY" --server "127.0.0.1:3000/api"` + +`timekeep wakapi disable` + +`timekeep wakapi status` + +```json +{ + "wakapi": { + "enabled": true, + "api_key": "API_KEY", + "server": "ADDRESS", + "global_project": "PROJECT" + } +} +``` + +The global project variable for Wakapi can be altered manually in the config file, otherwise setting it via the `config` command will by default set it to the same value as the WakaTime global project variable. + ## File Locations - **Logs** @@ -300,16 +310,22 @@ Users can update a program's category or project with the **update** command: - **Config** - **Windows**: *C:\ProgramData\Timekeep\config* - **Linux**: *~/.config/timekeep* - - **Config struct**: + - **Config structure**: ```json { "wakatime": { "enabled": true, - "api_key": "APIKEY", + "api_key": "API_KEY", "cli_path": "PATH", "global_project": "PROJECT" }, + "wakapi": { + "enabled": true, + "api_key": "API_KEY", + "server": "ADDRESS", + "global_project": "PROJECT" + }, "poll_interval": "1s", "poll_grace": 3, } diff --git a/cmd/service/internal/events/events_wakatime.go b/cmd/service/internal/events/events_wakatime.go index 56e1477..29cd4ec 100644 --- a/cmd/service/internal/events/events_wakatime.go +++ b/cmd/service/internal/events/events_wakatime.go @@ -144,7 +144,7 @@ func (e *EventController) sendWakaTimeHeartbeat(ctx context.Context, logger *log return nil } -// Send heartbeat to user's wakapi instance +// Send heartbeat to user's wakapi server func (e *EventController) sendWakapiHeartbeat(ctx context.Context, program, category, project string) error { if e.Config.Wakapi.Server == "" || e.Config.Wakapi.APIKey == "" { return fmt.Errorf("missing config variable") @@ -156,12 +156,13 @@ func (e *EventController) sendWakapiHeartbeat(ctx context.Context, program, cate } type Heartbeat struct { - Entity string `json:"entity"` - Type string `json:"type"` - Category string `json:"category"` - Project string `json:"project"` - Time int64 `json:"time"` - IsWrite bool `json:"is_write"` + Entity string `json:"entity"` + Type string `json:"type"` + Category string `json:"category"` + Project string `json:"project"` + Time int64 `json:"time"` + IsWrite bool `json:"is_write"` + OperatingSystem string `json:"operating_system"` } heartbeat := Heartbeat{ @@ -200,7 +201,7 @@ func (e *EventController) sendWakapiHeartbeat(ctx context.Context, program, cate return nil } -// Stops WakaTime heartbeat ticker after disabling integration +// Cancels heartbeat context func (e *EventController) StopHeartbeats() { e.mu.Lock() cancel := e.WakaCancel diff --git a/scripts/install.sh b/scripts/install.sh index 18d5f7d..587c24c 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -57,4 +57,7 @@ sudo systemctl daemon-reload sudo systemctl enable timekeep.service sudo systemctl start timekeep.service +timekeep completion bash | sudo tee /etc/bash_completion.d/timekeep >/dev/null +source /etc/bash_completion + echo "Installation complete. Run 'timekeep status' to test." \ No newline at end of file