322 lines
6.6 KiB
Go
322 lines
6.6 KiB
Go
// 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
|
|
}
|