package task import ( "bufio" "bytes" "encoding/json" "fmt" "io" "os" "os/exec" "sort" "strconv" "strings" "time" "github.com/google/shlex" ) // DateFormat is the date format used by Taskwarrior in all date fields // (e.g. Entry, Due, Start). All date parsing and formatting in this // package uses this constant. const DateFormat = "20060102T150405Z" // Task represents a taskwarrior task as returned by `task export`. type Annotation struct { Entry string `json:"entry"` Description string `json:"description"` } type Task struct { ID int `json:"id"` UUID string `json:"uuid"` Description string `json:"description"` Project string `json:"project"` Tags []string `json:"tags"` Status string `json:"status"` Start string `json:"start"` Entry string `json:"entry"` Due string `json:"due"` Priority string `json:"priority"` Recur string `json:"recur"` Urgency float64 `json:"urgency"` Annotations []Annotation `json:"annotations"` } // debugConfig groups the optional debug-logging state for the task package. // Collecting related vars into a struct makes the mutable state explicit and // allows the logger to be swapped or reset cleanly without touching unrelated // package globals. type debugConfig struct { writer io.Writer file *os.File // tracked separately so it can be closed on reconfiguration } // dbg holds the active debug-logging configuration for this package. // It is written only via SetDebugLog and read only in run(). var dbg debugConfig // SetDebugLog enables logging of executed commands to the given file. // Passing an empty path disables logging and closes any previously opened file. func SetDebugLog(path string) error { // Close existing debug file if open before re-configuring. if dbg.file != nil { _ = dbg.file.Close() dbg.file = nil dbg.writer = nil } if path == "" { return nil } f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return err } dbg.file = f dbg.writer = f return nil } // Add creates a new task with the given description and tags. func Add(description string, tags []string) error { var args []string for _, t := range tags { if len(t) > 0 && t[0] != '+' { t = "+" + t } args = append(args, t) } args = append(args, description) return AddArgs(args) } // AddArgs runs "task add" with the provided arguments. Each element in args // is passed as a separate command-line argument, allowing the caller to // specify additional modifiers like due dates or tags. func AddArgs(args []string) error { return run(append([]string{"add"}, args...)...) } // AddLine splits the given line into shell words and runs "task add" with the // resulting arguments. This allows users to pass raw Taskwarrior parameters // such as "due:today" directly. func AddLine(line string) error { fields, err := shlex.Split(line) if err != nil { return err } return AddArgs(fields) } // Export retrieves all tasks using `task export rc.json.array=off` and parses // the JSON output into a slice of Task structs. // Export retrieves tasks using `task export rc.json.array=off` and parses // the JSON output into a slice of Task structs. Optional filter arguments are // passed directly to the `task` command before `export`. func Export(filters ...string) ([]Task, error) { args := append(filters, "export", "rc.json.array=off") cmd := exec.Command("task", args...) var stderr bytes.Buffer cmd.Stderr = &stderr out, err := cmd.Output() if err != nil { // Include stderr output in the error message if stderr.Len() > 0 { return nil, fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String())) } return nil, err } var tasks []Task scanner := bufio.NewScanner(bytes.NewReader(out)) for scanner.Scan() { line := scanner.Bytes() line = bytes.TrimSpace(line) if len(line) == 0 { continue } var t Task if err := json.Unmarshal(line, &t); err != nil { return nil, err } tasks = append(tasks, t) } if err := scanner.Err(); err != nil { return nil, err } return tasks, nil } func run(args ...string) error { if dbg.writer != nil { fmt.Fprintln(dbg.writer, "task "+strings.Join(args, " ")) } cmd := exec.Command("task", args...) // Capture stderr to provide better error messages var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { // Include stderr output in the error message if stderr.Len() > 0 { return fmt.Errorf("%v: %s", err, strings.TrimSpace(stderr.String())) } return err } return nil } // modifyTask runs a modify command with validation func modifyTask(id int, args ...string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } return run(append([]string{strconv.Itoa(id), "modify"}, args...)...) } // simpleTaskCommand runs a simple command on a task with validation func simpleTaskCommand(id int, command string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } return run(strconv.Itoa(id), command) } // SetStatus changes the status of the task with the given id. func SetStatus(id int, status string) error { return modifyTask(id, "status:"+status) } // SetStatusUUID changes the status of the task with the given UUID. func SetStatusUUID(uuid, status string) error { return run(uuid, "modify", "status:"+status) } // Start begins the task with the given id. func Start(id int) error { return simpleTaskCommand(id, "start") } // Stop stops the task with the given id. func Stop(id int) error { return simpleTaskCommand(id, "stop") } // Done marks the task with the given id as completed. func Done(id int) error { return simpleTaskCommand(id, "done") } // Delete removes the task with the given id. func Delete(id int) error { return simpleTaskCommand(id, "delete") } // SetPriority changes the priority of the task with the given id. func SetPriority(id int, priority string) error { return modifyTask(id, "priority:"+priority) } // AddTags adds tags to the task with the given id. func AddTags(id int, tags []string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } args := []string{strconv.Itoa(id), "modify"} for _, t := range tags { if len(t) > 0 && t[0] != '+' { t = "+" + t } args = append(args, t) } return run(args...) } // RemoveTags removes tags from the task with the given id. func RemoveTags(id int, tags []string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } args := []string{strconv.Itoa(id), "modify"} for _, t := range tags { if len(t) > 0 && t[0] != '-' { t = "-" + t } args = append(args, t) } return run(args...) } // SetTags sets the tags of the task with the given id to exactly the provided set. // Tags not present will be removed and new tags added as needed. func SetTags(id int, tags []string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } tasks, err := Export(strconv.Itoa(id)) if err != nil { return err } if len(tasks) == 0 { return fmt.Errorf("task %d not found", id) } current := make(map[string]struct{}) for _, t := range tasks[0].Tags { current[t] = struct{}{} } desired := make(map[string]struct{}) for _, t := range tags { desired[t] = struct{}{} } var adds, removes []string for t := range desired { if _, ok := current[t]; !ok { adds = append(adds, t) } } for t := range current { if _, ok := desired[t]; !ok { removes = append(removes, t) } } if len(adds) > 0 { if err := AddTags(id, adds); err != nil { return err } } if len(removes) > 0 { if err := RemoveTags(id, removes); err != nil { return err } } return nil } // SetRecurrence sets the recurrence for the task with the given id. func SetRecurrence(id int, rec string) error { return modifyTask(id, "recur:"+rec) } // SetDueDate sets the due date for the task with the given id. func SetDueDate(id int, due string) error { return modifyTask(id, "due:"+due) } // SetDescription changes the description of the task with the given id. func SetDescription(id int, desc string) error { return modifyTask(id, "description:"+desc) } // SetProject changes the project of the task with the given id. func SetProject(id int, project string) error { return modifyTask(id, "project:"+project) } // Annotate adds an annotation to the task with the given id. func Annotate(id int, text string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } return run(strconv.Itoa(id), "annotate", text) } // Denotate removes an annotation from the task with the given id. // Denotate removes an annotation from the task with the given id. The // annotation text is matched exactly when provided. If text is empty, the // oldest annotation is removed. func Denotate(id int, text string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } args := []string{strconv.Itoa(id), "denotate"} if text != "" { args = append(args, text) } return run(args...) } // ReplaceAnnotations removes all existing annotations from the task with the // given id and sets a single annotation with the provided text. If text is // empty, all annotations are simply removed. func ReplaceAnnotations(id int, text string) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } tasks, err := Export(strconv.Itoa(id)) if err != nil { return err } if len(tasks) == 0 { return fmt.Errorf("task %d not found", id) } anns := tasks[0].Annotations for i := len(anns) - 1; i >= 0; i-- { if err := Denotate(id, anns[i].Description); err != nil { return err } } if text == "" { return nil } return Annotate(id, text) } // Edit opens the task in an editor for manual modification. // EditCmd returns an exec.Cmd that edits the task with the given id. // The caller is responsible for running the command, typically via // tea.ExecProcess so that the terminal state is properly managed. func EditCmd(id int) *exec.Cmd { if id <= 0 { // Return a command that will fail with an appropriate error cmd := exec.Command("sh", "-c", fmt.Sprintf("echo 'invalid task ID: %d' >&2; exit 1", id)) return cmd } cmd := exec.Command("task", strconv.Itoa(id), "edit") cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd } // Edit opens the task in an editor for manual modification. // This is a convenience wrapper around EditCmd. func Edit(id int) error { if id <= 0 { return fmt.Errorf("invalid task ID: %d", id) } return EditCmd(id).Run() } // SortTasks orders tasks by start status, priority, due date, tag names and id. // Started tasks are always placed before non-started ones. Tasks without a due // date are placed after tasks with a due date. Overdue tasks are placed at the // very top regardless of other properties. // // The sort order is: // 1. Overdue tasks (oldest due date first) // 2. Started tasks (not completed) // 3. High priority tasks // 4. Tasks with earlier due dates // 5. Tasks sorted alphabetically by tags // 6. Tasks sorted by ID (oldest first) func SortTasks(tasks []Task) { // Helper to join tags in a consistent order for comparison joinTags := func(tags []string) string { if len(tags) == 0 { return "" } cpy := append([]string(nil), tags...) sort.Strings(cpy) return strings.Join(cpy, " ") } // Convert priority to numeric value for comparison (higher = more important) priVal := func(p string) int { switch p { case "H": return 3 case "M": return 2 case "L": return 1 default: return 0 } } // Parse due date string into time.Time parseDue := func(s string) (time.Time, bool) { if s == "" { return time.Time{}, false } t, err := time.Parse(DateFormat, s) if err != nil { return time.Time{}, false } return t, true } // Check if a task is overdue overdue := func(t Task) bool { du, ok := parseDue(t.Due) return ok && time.Now().After(du) } sort.Slice(tasks, func(i, j int) bool { ti, tj := tasks[i], tasks[j] if oi, oj := overdue(ti), overdue(tj); oi != oj { return oi } startedI := ti.Start != "" && ti.Status != "completed" startedJ := tj.Start != "" && tj.Status != "completed" if startedI != startedJ { return startedI } pi, pj := priVal(ti.Priority), priVal(tj.Priority) if pi != pj { return pi > pj } di, iok := parseDue(ti.Due) dj, jok := parseDue(tj.Due) if iok && !jok { return true } if !iok && jok { return false } if iok && jok && !di.Equal(dj) { return di.Before(dj) } tgI, tgJ := joinTags(ti.Tags), joinTags(tj.Tags) if tgI != tgJ { return tgI < tgJ } return ti.ID < tj.ID }) }