// TODO: error handling // TODO: split combinedOutput and handle stderr differently package timewarrior import ( "bytes" "encoding/json" "fmt" "log/slog" "os/exec" "regexp" "slices" "strings" "sync" ) const ( twBinary = "timew" ) type TimeWarrior interface { GetConfig() *TWConfig GetTags() []string GetIntervals(filter ...string) Intervals StartTracking(tags []string) error StopTracking() error ContinueTracking() error ContinueInterval(id int) error CancelTracking() error DeleteInterval(id int) error ModifyInterval(interval *Interval) error GetSummary(filter ...string) string GetActive() *Interval Undo() } type TimeSquire struct { configLocation string defaultArgs []string config *TWConfig mutex sync.Mutex } func NewTimeSquire(configLocation string) *TimeSquire { if _, err := exec.LookPath(twBinary); err != nil { slog.Error("Timewarrior not found") return nil } ts := &TimeSquire{ configLocation: configLocation, defaultArgs: []string{}, mutex: sync.Mutex{}, } ts.config = ts.extractConfig() return ts } func (ts *TimeSquire) GetConfig() *TWConfig { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.config } func (ts *TimeSquire) GetTags() []string { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"tags"}...)...) output, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed getting tags:", err) return nil } tags := make([]string, 0) lines := strings.Split(string(output), "\n") // Skip header lines and parse tag names for i, line := range lines { if i < 3 || line == "" { continue } // Tags are space-separated, first column is the tag name fields := strings.Fields(line) if len(fields) > 0 { tags = append(tags, fields[0]) } } slices.Sort(tags) return tags } func (ts *TimeSquire) GetIntervals(filter ...string) Intervals { ts.mutex.Lock() defer ts.mutex.Unlock() args := append(ts.defaultArgs, "export") if filter != nil { args = append(args, filter...) } cmd := exec.Command(twBinary, args...) output, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed getting intervals:", err) return nil } intervals := make(Intervals, 0) err = json.Unmarshal(output, &intervals) if err != nil { slog.Error("Failed unmarshalling intervals:", err) return nil } // Reverse the intervals to show newest first slices.Reverse(intervals) // Assign IDs based on new order (newest is @1) for i := range intervals { intervals[i].ID = i + 1 } return intervals } func (ts *TimeSquire) StartTracking(tags []string) error { ts.mutex.Lock() defer ts.mutex.Unlock() if len(tags) == 0 { return fmt.Errorf("at least one tag is required") } args := append(ts.defaultArgs, "start") args = append(args, tags...) cmd := exec.Command(twBinary, args...) if err := cmd.Run(); err != nil { slog.Error("Failed starting tracking:", err) return err } return nil } func (ts *TimeSquire) StopTracking() error { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, "stop")...) if err := cmd.Run(); err != nil { slog.Error("Failed stopping tracking:", err) return err } return nil } func (ts *TimeSquire) ContinueTracking() error { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, "continue")...) if err := cmd.Run(); err != nil { slog.Error("Failed continuing tracking:", err) return err } return nil } func (ts *TimeSquire) ContinueInterval(id int) error { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"continue", fmt.Sprintf("@%d", id)}...)...) if err := cmd.Run(); err != nil { slog.Error("Failed continuing interval:", err) return err } return nil } func (ts *TimeSquire) CancelTracking() error { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, "cancel")...) if err := cmd.Run(); err != nil { slog.Error("Failed canceling tracking:", err) return err } return nil } func (ts *TimeSquire) DeleteInterval(id int) error { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"delete", fmt.Sprintf("@%d", id)}...)...) if err := cmd.Run(); err != nil { slog.Error("Failed deleting interval:", err) return err } return nil } func (ts *TimeSquire) ModifyInterval(interval *Interval) error { ts.mutex.Lock() defer ts.mutex.Unlock() // Export the modified interval intervals, err := json.Marshal(Intervals{interval}) if err != nil { slog.Error("Failed marshalling interval:", err) return err } // Import the modified interval cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import"}...)...) cmd.Stdin = bytes.NewBuffer(intervals) out, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed modifying interval:", err, string(out)) return err } return nil } func (ts *TimeSquire) GetSummary(filter ...string) string { ts.mutex.Lock() defer ts.mutex.Unlock() args := append(ts.defaultArgs, "summary") if filter != nil { args = append(args, filter...) } cmd := exec.Command(twBinary, args...) output, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed getting summary:", err) return "" } return string(output) } func (ts *TimeSquire) GetActive() *Interval { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"get", "dom.active"}...)...) output, err := cmd.CombinedOutput() if err != nil || string(output) == "0\n" { return nil } // Get the active interval intervals := ts.GetIntervals() for _, interval := range intervals { if interval.End == "" { return interval } } return nil } func (ts *TimeSquire) Undo() { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"undo"}...)...) err := cmd.Run() if err != nil { slog.Error("Failed undoing:", err) } } func (ts *TimeSquire) extractConfig() *TWConfig { cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"show"}...)...) output, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed getting config:", err) return nil } return NewConfig(strings.Split(string(output), "\n")) } func extractTags(config string) []string { re := regexp.MustCompile(`tag\.([^.]+)\.[^.]+`) matches := re.FindAllStringSubmatch(config, -1) uniques := make(map[string]struct{}) for _, match := range matches { uniques[match[1]] = struct{}{} } var tags []string for tag := range uniques { tags = append(tags, tag) } slices.Sort(tags) return tags }