522 lines
11 KiB
Go
522 lines
11 KiB
Go
// 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
|
|
}
|