// TODO: error handling // TODO: split combinedOutput and handle stderr differently // TODO: reorder functions package taskwarrior import ( "bytes" "encoding/json" "fmt" "log/slog" "os/exec" "regexp" "slices" "strings" "sync" ) const ( twBinary = "task" ) var ( nonStandardReports = map[string]struct{}{ "burndown.daily": {}, "burndown.monthly": {}, "burndown.weekly": {}, "calendar": {}, "colors": {}, "export": {}, "ghistory.annual": {}, "ghistory.monthly": {}, "history.annual": {}, "history.monthly": {}, "information": {}, "summary": {}, "timesheet": {}, } virtualTags = map[string]struct{}{ "ACTIVE": {}, "ANNOTATED": {}, "BLOCKED": {}, "BLOCKING": {}, "CHILD": {}, "COMPLETED": {}, "DELETED": {}, "DUE": {}, "DUETODAY": {}, "INSTANCE": {}, "LATEST": {}, "MONTH": {}, "ORPHAN": {}, "OVERDUE": {}, "PARENT": {}, "PENDING": {}, "PRIORITY": {}, "PROJECT": {}, "QUARTER": {}, "READY": {}, "SCHEDULED": {}, "TAGGED": {}, "TEMPLATE": {}, "TODAY": {}, "TOMORROW": {}, "UDA": {}, "UNBLOCKED": {}, "UNTIL": {}, "WAITING": {}, "WEEK": {}, "YEAR": {}, "YESTERDAY": {}, } ) type TaskWarrior interface { GetConfig() *TWConfig GetActiveContext() *Context GetContext(context string) *Context GetContexts() Contexts SetContext(context *Context) error GetProjects() []string GetPriorities() []string GetTags() []string GetReport(report string) *Report GetReports() Reports GetTasks(report *Report, filter ...string) Tasks AddTask(task *Task) error ImportTask(task *Task) SetTaskDone(task *Task) StartTask(task *Task) StopTask(task *Task) Undo() } type TaskSquire struct { configLocation string defaultArgs []string config *TWConfig reports Reports contexts Contexts mutex sync.Mutex } func NewTaskSquire(configLocation string) *TaskSquire { if _, err := exec.LookPath(twBinary); err != nil { slog.Error("Taskwarrior not found") return nil } defaultArgs := []string{fmt.Sprintf("rc=%s", configLocation), "rc.verbose=nothing", "rc.dependency.confirmation=no", "rc.recurrence.confirmation=no", "rc.confirmation=no", "rc.json.array=1"} ts := &TaskSquire{ configLocation: configLocation, defaultArgs: defaultArgs, mutex: sync.Mutex{}, } ts.config = ts.extractConfig() ts.reports = ts.extractReports() ts.contexts = ts.extractContexts() return ts } func (ts *TaskSquire) GetConfig() *TWConfig { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.config } func (ts *TaskSquire) GetTasks(report *Report, filter ...string) Tasks { ts.mutex.Lock() defer ts.mutex.Unlock() args := ts.defaultArgs if report.Context { for _, context := range ts.contexts { if context.Active && context.Name != "none" { args = append(args, context.ReadFilter) break } } } if filter != nil { args = append(args, filter...) } cmd := exec.Command(twBinary, append(args, []string{"export", report.Name}...)...) output, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed getting report:", err) return nil } tasks := make(Tasks, 0) err = json.Unmarshal(output, &tasks) if err != nil { slog.Error("Failed unmarshalling tasks:", err) return nil } for _, task := range tasks { if task.Depends != nil && len(task.Depends) > 0 { ids := make([]string, len(task.Depends)) for i, dependUuid := range task.Depends { ids[i] = ts.getIds([]string{fmt.Sprintf("uuid:%s", dependUuid)}) } task.DependsIds = strings.Join(ids, " ") } } return tasks } func (ts *TaskSquire) getIds(filter []string) string { cmd := exec.Command(twBinary, append(append(ts.defaultArgs, filter...), "_ids")...) out, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed getting field:", err) return "" } return strings.TrimSpace(string(out)) } func (ts *TaskSquire) GetContext(context string) *Context { ts.mutex.Lock() defer ts.mutex.Unlock() if context == "" { context = "none" } if context, ok := ts.contexts[context]; ok { return context } else { slog.Error(fmt.Sprintf("Context not found: %s", context.Name)) return nil } } func (ts *TaskSquire) GetActiveContext() *Context { ts.mutex.Lock() defer ts.mutex.Unlock() for _, context := range ts.contexts { if context.Active { return context } } return ts.contexts["none"] } func (ts *TaskSquire) GetContexts() Contexts { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.contexts } func (ts *TaskSquire) GetProjects() []string { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_projects"}...)...) output, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed getting projects:", err) return nil } projects := make([]string, 0) for _, project := range strings.Split(string(output), "\n") { if project != "" { projects = append(projects, project) } } slices.Sort(projects) return projects } func (ts *TaskSquire) GetPriorities() []string { ts.mutex.Lock() defer ts.mutex.Unlock() priorities := make([]string, 0) for _, priority := range strings.Split(ts.config.Get("uda.priority.values"), ",") { if priority != "" { priorities = append(priorities, priority) } } return priorities } func (ts *TaskSquire) 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) for _, tag := range strings.Split(string(output), "\n") { if _, ok := virtualTags[tag]; !ok && tag != "" { tags = append(tags, tag) } } slices.Sort(tags) return tags } func (ts *TaskSquire) GetReport(report string) *Report { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.reports[report] } func (ts *TaskSquire) GetReports() Reports { ts.mutex.Lock() defer ts.mutex.Unlock() return ts.reports } func (ts *TaskSquire) SetContext(context *Context) error { ts.mutex.Lock() defer ts.mutex.Unlock() if context.Name == "none" && ts.contexts["none"].Active { return nil } cmd := exec.Command(twBinary, []string{"context", context.Name}...) if err := cmd.Run(); err != nil { slog.Error("Failed setting context:", err) return err } // TODO: optimize this; there should be no need to re-extract everything ts.config = ts.extractConfig() ts.contexts = ts.extractContexts() return nil } func (ts *TaskSquire) AddTask(task *Task) error { ts.mutex.Lock() defer ts.mutex.Unlock() addArgs := []string{"add"} if task.Description == "" { slog.Error("Task description is required") return nil } else { addArgs = append(addArgs, task.Description) } if task.Priority != "" && task.Priority != "(none)" { addArgs = append(addArgs, fmt.Sprintf("priority:%s", task.Priority)) } if task.Project != "" && task.Project != "(none)" { addArgs = append(addArgs, fmt.Sprintf("project:%s", task.Project)) } if task.Tags != nil { for _, tag := range task.Tags { addArgs = append(addArgs, fmt.Sprintf("+%s", tag)) } } if task.Due != "" { addArgs = append(addArgs, fmt.Sprintf("due:%s", task.Due)) } cmd := exec.Command(twBinary, append(ts.defaultArgs, addArgs...)...) err := cmd.Run() if err != nil { slog.Error("Failed adding task:", err) } // TODO remove error? return nil } // TODO error handling func (ts *TaskSquire) ImportTask(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() tasks, err := json.Marshal(Tasks{task}) if err != nil { slog.Error("Failed marshalling task:", err) } cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"import", "-"}...)...) cmd.Stdin = bytes.NewBuffer(tasks) out, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed modifying task:", err, string(out)) } } func (ts *TaskSquire) SetTaskDone(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"done", task.Uuid}...)...) err := cmd.Run() if err != nil { slog.Error("Failed setting task done:", err) } } func (ts *TaskSquire) 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 task:", err) } } func (ts *TaskSquire) StartTask(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"start", task.Uuid}...)...) err := cmd.Run() if err != nil { slog.Error("Failed starting task:", err) } } func (ts *TaskSquire) StopTask(task *Task) { ts.mutex.Lock() defer ts.mutex.Unlock() cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"stop", task.Uuid}...)...) err := cmd.Run() if err != nil { slog.Error("Failed stopping task:", err) } } func (ts *TaskSquire) 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 (ts *TaskSquire) extractReports() Reports { cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_config"}...)...) output, err := cmd.CombinedOutput() if err != nil { return nil } availableReports := extractReports(string(output)) reports := make(Reports) for _, report := range availableReports { if _, ok := nonStandardReports[report]; ok { continue } reports[report] = &Report{ Name: report, Description: ts.config.Get(fmt.Sprintf("report.%s.description", report)), Labels: strings.Split(ts.config.Get(fmt.Sprintf("report.%s.labels", report)), ","), Filter: ts.config.Get(fmt.Sprintf("report.%s.filter", report)), Sort: ts.config.Get(fmt.Sprintf("report.%s.sort", report)), Columns: strings.Split(ts.config.Get(fmt.Sprintf("report.%s.columns", report)), ","), Context: ts.config.Get(fmt.Sprintf("report.%s.context", report)) == "1", } } return reports } func extractReports(config string) []string { re := regexp.MustCompile(`report\.([^.]+)\.[^.]+`) matches := re.FindAllStringSubmatch(config, -1) uniques := make(map[string]struct{}) for _, match := range matches { uniques[match[1]] = struct{}{} } var reports []string for part := range uniques { reports = append(reports, part) } slices.Sort(reports) return reports } func (ts *TaskSquire) extractContexts() Contexts { cmd := exec.Command(twBinary, append(ts.defaultArgs, []string{"_context"}...)...) output, err := cmd.CombinedOutput() if err != nil { slog.Error("Failed getting contexts:", err) return nil } activeContext := ts.config.Get("context") if activeContext == "" { activeContext = "none" } contexts := make(Contexts) contexts["none"] = &Context{ Name: "none", Active: activeContext == "none", ReadFilter: "", WriteFilter: "", } for _, context := range strings.Split(string(output), "\n") { if context == "" { continue } contexts[context] = &Context{ Name: context, Active: activeContext == context, ReadFilter: ts.config.Get(fmt.Sprintf("context.%s.read", context)), WriteFilter: ts.config.Get(fmt.Sprintf("context.%s.write", context)), } } return contexts }