From 709fe2460b4d31058ed6580b0b81303f25e347d2 Mon Sep 17 00:00:00 2001 From: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:16:44 -0800 Subject: [PATCH] feat(tasks): add recur aliases for repeat materialization --- README.md | 4 +- internal/cmd/tasks_items.go | 104 +++++++--- .../cmd/tasks_items_validation_more_test.go | 12 ++ internal/cmd/tasks_repeat.go | 79 +++++++- internal/cmd/tasks_repeat_test.go | 184 ++++++++++++++++++ 5 files changed, 352 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 8d071122..9922daef 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli - **Chat** - list/find/create spaces, list messages/threads (filter by thread/unread), send messages and DMs (Workspace-only) - **Drive** - list/search/upload/download files, manage permissions/comments, organize folders, list shared drives - **Contacts** - search/create/update contacts, access Workspace directory/other contacts -- **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, repeat schedules +- **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, repeat schedule materialization - **Sheets** - read/write/update spreadsheets, insert rows/cols, format cells, read notes, create new sheets (and export via Drive) - **Forms** - create/get forms and inspect responses - **Apps Script** - create/get projects, inspect content, and run functions @@ -960,6 +960,7 @@ gog tasks get gog tasks add --title "Task title" gog tasks add --title "Weekly sync" --due 2025-02-01 --repeat weekly --repeat-count 4 gog tasks add --title "Daily standup" --due 2025-02-01 --repeat daily --repeat-until 2025-02-05 +gog tasks add --title "Bi-weekly review" --due 2025-02-01 --recur-rrule "FREQ=WEEKLY;INTERVAL=2" --repeat-count 3 gog tasks update --title "New title" gog tasks done gog tasks undo @@ -967,6 +968,7 @@ gog tasks delete gog tasks clear # Note: Google Tasks treats due dates as date-only; time components may be ignored. +# Note: Public Google Tasks API does not expose true recurring-task metadata; `--repeat*`/`--recur*` materialize concrete tasks. # See docs/dates.md for all supported date/time input formats across commands. ``` diff --git a/internal/cmd/tasks_items.go b/internal/cmd/tasks_items.go index 8a89d02e..bb08ee51 100644 --- a/internal/cmd/tasks_items.go +++ b/internal/cmd/tasks_items.go @@ -194,9 +194,71 @@ type TasksAddCmd struct { Due string `name:"due" help:"Due date (RFC3339 or YYYY-MM-DD; time may be ignored by Google Tasks)"` Parent string `name:"parent" help:"Parent task ID (create as subtask)"` Previous string `name:"previous" help:"Previous sibling task ID (controls ordering)"` - Repeat string `name:"repeat" help:"Repeat task: daily, weekly, monthly, yearly"` - RepeatCount int `name:"repeat-count" help:"Number of occurrences to create (requires --repeat)"` - RepeatUntil string `name:"repeat-until" help:"Repeat until date/time (RFC3339 or YYYY-MM-DD; requires --repeat)"` + Repeat string `name:"repeat" help:"Materialize repeated tasks: daily, weekly, monthly, yearly"` + Recur string `name:"recur" help:"Alias for --repeat cadence: daily, weekly, monthly, yearly"` + RecurRRule string `name:"recur-rrule" help:"Alias for --repeat cadence via RRULE (supports FREQ + optional INTERVAL)"` + RepeatCount int `name:"repeat-count" help:"Number of occurrences to create (requires --repeat, --recur, or --recur-rrule)"` + RepeatUntil string `name:"repeat-until" help:"Repeat until date/time (RFC3339 or YYYY-MM-DD; requires --repeat, --recur, or --recur-rrule)"` +} + +type tasksAddRepeatConfig struct { + Unit repeatUnit + Interval int + Repeat string + Recur string + RecurRule string + Until string +} + +func resolveTasksAddRepeatConfig(c *TasksAddCmd, due string) (tasksAddRepeatConfig, error) { + config := tasksAddRepeatConfig{ + Interval: 1, + Repeat: strings.TrimSpace(c.Repeat), + Recur: strings.TrimSpace(c.Recur), + RecurRule: strings.TrimSpace(c.RecurRRule), + Until: strings.TrimSpace(c.RepeatUntil), + } + + if config.Repeat != "" && (config.Recur != "" || config.RecurRule != "") { + return tasksAddRepeatConfig{}, usage("--repeat cannot be combined with --recur or --recur-rrule") + } + if config.Recur != "" && config.RecurRule != "" { + return tasksAddRepeatConfig{}, usage("--recur and --recur-rrule are mutually exclusive") + } + + var err error + switch { + case config.RecurRule != "": + config.Unit, config.Interval, err = parseRepeatRRule(config.RecurRule) + case config.Recur != "": + config.Unit, err = parseRepeatUnit(config.Recur) + default: + config.Unit, err = parseRepeatUnit(config.Repeat) + } + if err != nil { + return tasksAddRepeatConfig{}, err + } + + if config.Unit == repeatNone && (config.Until != "" || c.RepeatCount != 0) { + return tasksAddRepeatConfig{}, usage("--repeat, --recur, or --recur-rrule is required when using --repeat-count or --repeat-until") + } + + if config.Unit != repeatNone { + if due == "" { + return tasksAddRepeatConfig{}, usage("--due is required when using --repeat, --recur, or --recur-rrule") + } + if c.RepeatCount < 0 { + return tasksAddRepeatConfig{}, usage("--repeat-count must be >= 0") + } + if config.Until == "" && c.RepeatCount == 0 { + if config.Recur != "" || config.RecurRule != "" { + return tasksAddRepeatConfig{}, usage("Google Tasks API does not support server-side recurring metadata; use --repeat-count or --repeat-until with --recur/--recur-rrule to materialize occurrences") + } + return tasksAddRepeatConfig{}, usage("--repeat requires --repeat-count or --repeat-until") + } + } + + return config, nil } func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error { @@ -213,27 +275,10 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error { due := strings.TrimSpace(c.Due) parent := strings.TrimSpace(c.Parent) previous := strings.TrimSpace(c.Previous) - repeatUntil := strings.TrimSpace(c.RepeatUntil) - - repeatUnit, err := parseRepeatUnit(c.Repeat) + repeatConfig, err := resolveTasksAddRepeatConfig(c, due) if err != nil { return err } - if repeatUnit == repeatNone && (repeatUntil != "" || c.RepeatCount != 0) { - return usage("--repeat is required when using --repeat-count or --repeat-until") - } - - if repeatUnit != repeatNone { - if due == "" { - return usage("--due is required when using --repeat") - } - if c.RepeatCount < 0 { - return usage("--repeat-count must be >= 0") - } - if repeatUntil == "" && c.RepeatCount == 0 { - return usage("--repeat requires --repeat-count or --repeat-until") - } - } if dryRunErr := dryRunExit(ctx, flags, "tasks.add", map[string]any{ "tasklist_id": tasklistID, @@ -242,9 +287,12 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error { "due": due, "parent": parent, "previous": previous, - "repeat": strings.TrimSpace(c.Repeat), + "repeat": repeatConfig.Repeat, + "recur": repeatConfig.Recur, + "recur_rrule": repeatConfig.RecurRule, + "repeat_step": repeatConfig.Interval, "repeat_count": c.RepeatCount, - "repeat_until": repeatUntil, + "repeat_until": repeatConfig.Until, }); dryRunErr != nil { return dryRunErr } @@ -254,7 +302,7 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error { return err } - if repeatUnit == repeatNone { + if repeatConfig.Unit == repeatNone { svc, svcErr := newTasksService(ctx, account) if svcErr != nil { return svcErr @@ -314,8 +362,8 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error { } var until *time.Time - if repeatUntil != "" { - untilValue, untilHasTime, parseErr := parseTaskDate(repeatUntil) + if repeatConfig.Until != "" { + untilValue, untilHasTime, parseErr := parseTaskDate(repeatConfig.Until) if parseErr != nil { return parseErr } @@ -337,7 +385,7 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error { until = &untilValue } - schedule := expandRepeatSchedule(dueTime, repeatUnit, c.RepeatCount, until) + schedule := expandRepeatSchedule(dueTime, repeatConfig.Unit, repeatConfig.Interval, c.RepeatCount, until) if len(schedule) == 0 { return usage("repeat produced no occurrences") } @@ -361,7 +409,7 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error { } task := &tasks.Task{ Title: title, - Notes: strings.TrimSpace(c.Notes), + Notes: notes, Due: formatTaskDue(due, dueHasTime), } call := svc.Tasks.Insert(tasklistID, task) diff --git a/internal/cmd/tasks_items_validation_more_test.go b/internal/cmd/tasks_items_validation_more_test.go index ea982bca..1feccee6 100644 --- a/internal/cmd/tasks_items_validation_more_test.go +++ b/internal/cmd/tasks_items_validation_more_test.go @@ -50,6 +50,18 @@ func TestTasksValidationErrors(t *testing.T) { if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", RepeatCount: 2}).Run(ctx, flags); err == nil { t.Fatalf("expected add repeat-count without repeat") } + if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", Recur: "weekly", Due: "2025-01-01"}).Run(ctx, flags); err == nil { + t.Fatalf("expected add recur missing count/until") + } + if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", Recur: "weekly", RecurRRule: "FREQ=WEEKLY", Due: "2025-01-01", RepeatCount: 2}).Run(ctx, flags); err == nil { + t.Fatalf("expected add recur and recur-rrule conflict") + } + if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", Repeat: "weekly", Recur: "weekly", Due: "2025-01-01", RepeatCount: 2}).Run(ctx, flags); err == nil { + t.Fatalf("expected add repeat and recur conflict") + } + if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", RecurRRule: "FREQ=WEEKLY;BYDAY=MO", Due: "2025-01-01", RepeatCount: 2}).Run(ctx, flags); err == nil { + t.Fatalf("expected add recur-rrule unsupported token") + } { cmd := &TasksUpdateCmd{} diff --git a/internal/cmd/tasks_repeat.go b/internal/cmd/tasks_repeat.go index 909ca961..148cf28b 100644 --- a/internal/cmd/tasks_repeat.go +++ b/internal/cmd/tasks_repeat.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strconv" "strings" "time" @@ -49,10 +50,84 @@ func parseTaskDate(value string) (time.Time, bool, error) { return parsed.Time, parsed.HasTime, nil } -func expandRepeatSchedule(start time.Time, unit repeatUnit, count int, until *time.Time) []time.Time { +func parseRepeatRRule(raw string) (repeatUnit, int, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (must include FREQ)", raw) + } + + if strings.HasPrefix(strings.ToUpper(trimmed), "RRULE:") { + trimmed = strings.TrimSpace(trimmed[len("RRULE:"):]) + } + if trimmed == "" { + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (must include FREQ)", raw) + } + + unit := repeatNone + interval := 1 + seenFreq := false + seenInterval := false + for _, part := range strings.Split(trimmed, ";") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (malformed token %q)", raw, part) + } + + key := strings.ToUpper(strings.TrimSpace(kv[0])) + value := strings.ToUpper(strings.TrimSpace(kv[1])) + + switch key { + case "FREQ": + if seenFreq { + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (duplicate FREQ)", raw) + } + seenFreq = true + switch value { + case "DAILY": + unit = repeatDaily + case "WEEKLY": + unit = repeatWeekly + case "MONTHLY": + unit = repeatMonthly + case "YEARLY": + unit = repeatYearly + default: + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (unsupported FREQ %q)", raw, value) + } + case "INTERVAL": + if seenInterval { + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (duplicate INTERVAL)", raw) + } + seenInterval = true + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (INTERVAL must be a positive integer)", raw) + } + interval = parsed + default: + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (unsupported key %q; only FREQ and INTERVAL are supported)", raw, key) + } + } + + if unit == repeatNone { + return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (missing FREQ)", raw) + } + + return unit, interval, nil +} + +func expandRepeatSchedule(start time.Time, unit repeatUnit, interval int, count int, until *time.Time) []time.Time { if unit == repeatNone { return []time.Time{start} } + if interval <= 0 { + interval = 1 + } if count < 0 { count = 0 } @@ -63,7 +138,7 @@ func expandRepeatSchedule(start time.Time, unit repeatUnit, count int, until *ti } out := []time.Time{} for i := 0; ; i++ { - t := addRepeat(start, unit, i) + t := addRepeat(start, unit, i*interval) if until != nil && t.After(*until) { break } diff --git a/internal/cmd/tasks_repeat_test.go b/internal/cmd/tasks_repeat_test.go index fe6bff69..4b28ade4 100644 --- a/internal/cmd/tasks_repeat_test.go +++ b/internal/cmd/tasks_repeat_test.go @@ -186,6 +186,150 @@ func TestTasksAddCmd_RepeatUntilDateOnlyWithTimeDue(t *testing.T) { } } +func TestTasksAddCmd_RecurAliasCreatesMultiple(t *testing.T) { + origNew := newTasksService + t.Cleanup(func() { newTasksService = origNew }) + + var gotDue []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/tasks/v1/users/@me/lists" && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "l1", "title": "One"}, + }, + }) + return + } + if !(r.URL.Path == "/tasks/v1/lists/l1/tasks" && r.Method == http.MethodPost) { + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if due, ok := body["due"].(string); ok { + gotDue = append(gotDue, due) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "due": body["due"], + }) + })) + defer srv.Close() + + svc, err := tasks.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + _ = captureStdout(t, func() { + if err := runKong(t, &TasksAddCmd{}, []string{ + "l1", + "--title", "Task", + "--due", "2025-01-01", + "--recur", "weekly", + "--repeat-count", "3", + }, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + + if len(gotDue) != 3 { + t.Fatalf("expected 3 tasks, got due=%d", len(gotDue)) + } + if gotDue[0] != "2025-01-01T00:00:00Z" || gotDue[1] != "2025-01-08T00:00:00Z" || gotDue[2] != "2025-01-15T00:00:00Z" { + t.Fatalf("unexpected due schedule: %#v", gotDue) + } +} + +func TestTasksAddCmd_RecurRRuleIntervalCreatesMultiple(t *testing.T) { + origNew := newTasksService + t.Cleanup(func() { newTasksService = origNew }) + + var gotDue []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/tasks/v1/users/@me/lists" && r.Method == http.MethodGet { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "items": []map[string]any{ + {"id": "l1", "title": "One"}, + }, + }) + return + } + if !(r.URL.Path == "/tasks/v1/lists/l1/tasks" && r.Method == http.MethodPost) { + http.NotFound(w, r) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if due, ok := body["due"].(string); ok { + gotDue = append(gotDue, due) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "t1", + "due": body["due"], + }) + })) + defer srv.Close() + + svc, err := tasks.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newTasksService = func(context.Context, string) (*tasks.Service, error) { return svc, nil } + + u, err := ui.New(ui.Options{Stdout: os.Stdout, Stderr: os.Stderr, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + _ = captureStdout(t, func() { + if err := runKong(t, &TasksAddCmd{}, []string{ + "l1", + "--title", "Task", + "--due", "2025-01-01", + "--recur-rrule", "FREQ=DAILY;INTERVAL=2", + "--repeat-count", "3", + }, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + + if len(gotDue) != 3 { + t.Fatalf("expected 3 tasks, got due=%d", len(gotDue)) + } + if gotDue[0] != "2025-01-01T00:00:00Z" || gotDue[1] != "2025-01-03T00:00:00Z" || gotDue[2] != "2025-01-05T00:00:00Z" { + t.Fatalf("unexpected due schedule: %#v", gotDue) + } +} + func TestParseTaskDate_FlexibleFormats(t *testing.T) { t.Parallel() @@ -222,3 +366,43 @@ func TestParseTaskDate_FlexibleFormats(t *testing.T) { }) } } + +func TestParseRepeatRRule(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + raw string + wantUnit repeatUnit + wantInterval int + wantErr bool + }{ + {name: "freq only", raw: "FREQ=WEEKLY", wantUnit: repeatWeekly, wantInterval: 1}, + {name: "rrule prefix and interval", raw: "RRULE:FREQ=MONTHLY;INTERVAL=2", wantUnit: repeatMonthly, wantInterval: 2}, + {name: "missing freq", raw: "INTERVAL=2", wantErr: true}, + {name: "invalid interval", raw: "FREQ=DAILY;INTERVAL=0", wantErr: true}, + {name: "duplicate freq", raw: "FREQ=DAILY;FREQ=WEEKLY", wantErr: true}, + {name: "duplicate interval", raw: "FREQ=DAILY;INTERVAL=1;INTERVAL=2", wantErr: true}, + {name: "unsupported key", raw: "FREQ=WEEKLY;BYDAY=MO", wantErr: true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + gotUnit, gotInterval, err := parseRepeatRRule(tc.raw) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error") + } + return + } + if err != nil { + t.Fatalf("parseRepeatRRule: %v", err) + } + if gotUnit != tc.wantUnit || gotInterval != tc.wantInterval { + t.Fatalf("got unit=%v interval=%d want unit=%v interval=%d", gotUnit, gotInterval, tc.wantUnit, tc.wantInterval) + } + }) + } +}