summaryrefslogtreecommitdiff
path: root/internal/task
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-06-25 18:18:59 +0300
committerPaul Buetow <paul@buetow.org>2026-06-25 18:18:59 +0300
commitbca7d87f11a3ee88c75dc1ed706009d63022ef98 (patch)
tree70b5bed95f11bc30968917d8c088fa8b7697cfac /internal/task
parentb642f71049ba6030e45c9ddead6d1cc2b7e289e2 (diff)
fr0 add recurring series recurrence edit
Diffstat (limited to 'internal/task')
-rw-r--r--internal/task/crud.go66
-rw-r--r--internal/task/task_test.go95
-rw-r--r--internal/task/taskwarrior.go6
3 files changed, 167 insertions, 0 deletions
diff --git a/internal/task/crud.go b/internal/task/crud.go
index 51dd13c..d0ec612 100644
--- a/internal/task/crud.go
+++ b/internal/task/crud.go
@@ -255,6 +255,72 @@ func SetRecurrenceContext(ctx context.Context, id int, rec string) error {
return modifyTaskContext(ctx, id, "recur:"+rec)
}
+// SetRecurringSeriesRecurrenceContext sets the recurrence for every known task
+// in a recurring series identified by rootUUID.
+func SetRecurringSeriesRecurrenceContext(ctx context.Context, rootUUID, rec string) error {
+ tasks, err := RecurringSeries(ctx, rootUUID)
+ if err != nil {
+ return err
+ }
+ tasks = recurringSeriesUpdateOrder(tasks, rootUUID)
+ if len(tasks) == 0 {
+ return fmt.Errorf("recurring series %s not found", rootUUID)
+ }
+
+ completed := make([]Task, 0, len(tasks))
+ for _, tsk := range tasks {
+ if tsk.UUID == "" {
+ continue
+ }
+ if err := setRecurrenceUUIDContext(ctx, tsk.UUID, rec); err != nil {
+ if rollbackErr := restoreRecurringSeriesRecurrences(completed); rollbackErr != nil {
+ return fmt.Errorf("set recurrence for %s: %w; rollback failed: %w", tsk.UUID, err, rollbackErr)
+ }
+ return fmt.Errorf("set recurrence for %s: %w", tsk.UUID, err)
+ }
+ completed = append(completed, tsk)
+ }
+ if len(completed) == 0 {
+ return fmt.Errorf("recurring series %s has no task UUIDs", rootUUID)
+ }
+ return nil
+}
+
+func recurringSeriesUpdateOrder(tasks []Task, rootUUID string) []Task {
+ ordered := make([]Task, 0, len(tasks))
+ var root []Task
+ for _, tsk := range tasks {
+ if tsk.UUID == "" {
+ continue
+ }
+ if tsk.UUID == rootUUID {
+ root = append(root, tsk)
+ continue
+ }
+ ordered = append(ordered, tsk)
+ }
+ return append(ordered, root...)
+}
+
+func restoreRecurringSeriesRecurrences(tasks []Task) error {
+ ctx, cancel := rollbackContext()
+ defer cancel()
+
+ for i := len(tasks) - 1; i >= 0; i-- {
+ if err := setRecurrenceUUIDContext(ctx, tasks[i].UUID, tasks[i].Recur); err != nil {
+ return fmt.Errorf("restore recurrence for %s: %w", tasks[i].UUID, err)
+ }
+ }
+ return nil
+}
+
+func setRecurrenceUUIDContext(ctx context.Context, uuid, rec string) error {
+ if uuid == "" {
+ return fmt.Errorf("empty task UUID")
+ }
+ return runContext(ctx, "rc.recurrence.confirmation=no", uuid, "modify", "recur:"+rec)
+}
+
// SetDueDate sets the due date for the task with the given id.
func SetDueDate(id int, due string) error {
return SetDueDateContext(context.Background(), id, due)
diff --git a/internal/task/task_test.go b/internal/task/task_test.go
index 570cc86..acd0978 100644
--- a/internal/task/task_test.go
+++ b/internal/task/task_test.go
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "reflect"
"strings"
"testing"
"time"
@@ -824,6 +825,100 @@ func TestRecurringSeries(t *testing.T) {
}
}
+func TestSetRecurringSeriesRecurrenceContext(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ logFile := filepath.Join(tmp, "commands.txt")
+
+ script := fmt.Sprintf(`#!/bin/sh
+if [ "$1" = "(root or parent:root)" ] && [ "$2" = "status.any:" ] && [ "$3" = "export" ]; then
+ echo '{"id":0,"uuid":"root","description":"template","status":"recurring","recur":"daily"}'
+ echo '{"id":1,"uuid":"child-1","parent":"root","description":"child 1","status":"pending","recur":"daily"}'
+ echo '{"id":2,"uuid":"child-2","parent":"root","description":"child 2","status":"pending","recur":"daily"}'
+ exit 0
+fi
+echo "$@" >> %s
+`, shellQuote(logFile))
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ if err := os.Setenv("PATH", tmp+":"+origPath); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := os.Setenv("PATH", origPath); err != nil {
+ t.Errorf("restore PATH: %v", err)
+ }
+ })
+
+ if err := SetRecurringSeriesRecurrenceContext(context.Background(), "root", "weekly"); err != nil {
+ t.Fatalf("SetRecurringSeriesRecurrenceContext: %v", err)
+ }
+
+ got := readLinesFile(t, logFile)
+ want := []string{
+ "rc.recurrence.confirmation=no child-1 modify recur:weekly",
+ "rc.recurrence.confirmation=no child-2 modify recur:weekly",
+ "rc.recurrence.confirmation=no root modify recur:weekly",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("commands:\ngot %#v\nwant %#v", got, want)
+ }
+}
+
+func TestSetRecurringSeriesRecurrenceContextRollsBackCompletedUpdates(t *testing.T) {
+ tmp := t.TempDir()
+ taskPath := filepath.Join(tmp, "task")
+ logFile := filepath.Join(tmp, "commands.txt")
+
+ script := fmt.Sprintf(`#!/bin/sh
+if [ "$1" = "(root or parent:root)" ] && [ "$2" = "status.any:" ] && [ "$3" = "export" ]; then
+ echo '{"id":0,"uuid":"root","description":"template","status":"recurring","recur":"daily"}'
+ echo '{"id":1,"uuid":"child-1","parent":"root","description":"child 1","status":"pending","recur":"daily"}'
+ echo '{"id":2,"uuid":"child-2","parent":"root","description":"child 2","status":"pending","recur":"monthly"}'
+ exit 0
+fi
+echo "$@" >> %s
+if [ "$2" = "child-2" ] && [ "$4" = "recur:weekly" ]; then
+ echo "child-2 failed" >&2
+ exit 1
+fi
+`, shellQuote(logFile))
+ if err := os.WriteFile(taskPath, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ origPath := os.Getenv("PATH")
+ if err := os.Setenv("PATH", tmp+":"+origPath); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ if err := os.Setenv("PATH", origPath); err != nil {
+ t.Errorf("restore PATH: %v", err)
+ }
+ })
+
+ err := SetRecurringSeriesRecurrenceContext(context.Background(), "root", "weekly")
+ if err == nil {
+ t.Fatal("expected SetRecurringSeriesRecurrenceContext error")
+ }
+ if !strings.Contains(err.Error(), "set recurrence for child-2") {
+ t.Fatalf("error = %v, want child-2 context", err)
+ }
+
+ got := readLinesFile(t, logFile)
+ want := []string{
+ "rc.recurrence.confirmation=no child-1 modify recur:weekly",
+ "rc.recurrence.confirmation=no child-2 modify recur:weekly",
+ "rc.recurrence.confirmation=no child-1 modify recur:daily",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("commands:\ngot %#v\nwant %#v", got, want)
+ }
+}
+
func TestModifyHelpers(t *testing.T) {
if _, err := exec.LookPath("task"); err != nil {
t.Skip("task command not available")
diff --git a/internal/task/taskwarrior.go b/internal/task/taskwarrior.go
index 10b31b6..cfbf083 100644
--- a/internal/task/taskwarrior.go
+++ b/internal/task/taskwarrior.go
@@ -24,6 +24,7 @@ type Taskwarrior interface {
RemoveTagsContext(ctx context.Context, id int, tags []string) error
SetDueDateContext(ctx context.Context, id int, due string) error
SetRecurrenceContext(ctx context.Context, id int, rec string) error
+ SetRecurringSeriesRecurrenceContext(ctx context.Context, rootUUID, rec string) error
SetProjectContext(ctx context.Context, id int, project string) error
SetPriorityContext(ctx context.Context, id int, priority string) error
StartContext(ctx context.Context, id int) error
@@ -123,6 +124,11 @@ func (Client) SetRecurrenceContext(ctx context.Context, id int, rec string) erro
return SetRecurrenceContext(ctx, id, rec)
}
+// SetRecurringSeriesRecurrenceContext changes a recurring series recurrence value.
+func (Client) SetRecurringSeriesRecurrenceContext(ctx context.Context, rootUUID, rec string) error {
+ return SetRecurringSeriesRecurrenceContext(ctx, rootUUID, rec)
+}
+
// SetProjectContext changes a task project.
func (Client) SetProjectContext(ctx context.Context, id int, project string) error {
return SetProjectContext(ctx, id, project)