Skip to content

Commit 2fe9d95

Browse files
steipetesalmonumbrella
andcommitted
feat(tasks): add recur aliases and RRULE support for task repeats (#408)
- add --recur and --recur-rrule aliases for repeat materialization - support RRULE FREQ with optional INTERVAL when generating concrete task occurrences - document the materialized repeat behavior in README and changelog Co-authored-by: salmonumbrella <182032677+salmonumbrella@users.noreply.github.com>
1 parent e323f69 commit 2fe9d95

File tree

6 files changed

+353
-31
lines changed

6 files changed

+353
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
- Contacts: add `--relation type=person` to contact create/update, include relations in text `contacts get`, and cover relation payload updates. (#351) — thanks @karbassi.
2828
- Contacts: add `--address` to contact create/update and include addresses in text `contacts get`. (#148) — thanks @beezly.
2929
- Docs: add `--pageless` to `docs create`, `docs write`, and `docs update` to switch documents into pageless mode after writes. (#300) — thanks @shohei-majima.
30+
- Tasks: add `--recur` / `--recur-rrule` aliases for repeat materialization, including RRULE `INTERVAL` support for generated occurrences. (#408) — thanks @salmonumbrella.
3031
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
3132
- Drive: add `drive ls --all` (alias `--global`) to list across all accessible files; make `--all` and `--parent` mutually exclusive. (#107) — thanks @struong.
3233
- Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Fast, script-friendly CLI for Gmail, Calendar, Chat, Classroom, Drive, Docs, Sli
1414
- **Chat** - list/find/create spaces, list messages/threads (filter by thread/unread), send messages and DMs (Workspace-only)
1515
- **Drive** - list/search/upload/download files, manage permissions/comments, organize folders, list shared drives
1616
- **Contacts** - search/create/update contacts, access Workspace directory/other contacts
17-
- **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, repeat schedules
17+
- **Tasks** - manage tasklists and tasks: get/create/add/update/done/undo/delete/clear, repeat schedule materialization
1818
- **Sheets** - read/write/update spreadsheets, insert rows/cols, format cells, read notes, create new sheets (and export via Drive)
1919
- **Forms** - create/get forms and inspect responses
2020
- **Apps Script** - create/get projects, inspect content, and run functions
@@ -998,13 +998,15 @@ gog tasks get <tasklistId> <taskId>
998998
gog tasks add <tasklistId> --title "Task title"
999999
gog tasks add <tasklistId> --title "Weekly sync" --due 2025-02-01 --repeat weekly --repeat-count 4
10001000
gog tasks add <tasklistId> --title "Daily standup" --due 2025-02-01 --repeat daily --repeat-until 2025-02-05
1001+
gog tasks add <tasklistId> --title "Bi-weekly review" --due 2025-02-01 --recur-rrule "FREQ=WEEKLY;INTERVAL=2" --repeat-count 3
10011002
gog tasks update <tasklistId> <taskId> --title "New title"
10021003
gog tasks done <tasklistId> <taskId>
10031004
gog tasks undo <tasklistId> <taskId>
10041005
gog tasks delete <tasklistId> <taskId>
10051006
gog tasks clear <tasklistId>
10061007

10071008
# Note: Google Tasks treats due dates as date-only; time components may be ignored.
1009+
# Note: Public Google Tasks API does not expose true recurring-task metadata; `--repeat*`/`--recur*` materialize concrete tasks.
10081010
# See docs/dates.md for all supported date/time input formats across commands.
10091011
```
10101012

internal/cmd/tasks_items.go

Lines changed: 76 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -194,9 +194,71 @@ type TasksAddCmd struct {
194194
Due string `name:"due" help:"Due date (RFC3339 or YYYY-MM-DD; time may be ignored by Google Tasks)"`
195195
Parent string `name:"parent" help:"Parent task ID (create as subtask)"`
196196
Previous string `name:"previous" help:"Previous sibling task ID (controls ordering)"`
197-
Repeat string `name:"repeat" help:"Repeat task: daily, weekly, monthly, yearly"`
198-
RepeatCount int `name:"repeat-count" help:"Number of occurrences to create (requires --repeat)"`
199-
RepeatUntil string `name:"repeat-until" help:"Repeat until date/time (RFC3339 or YYYY-MM-DD; requires --repeat)"`
197+
Repeat string `name:"repeat" help:"Materialize repeated tasks: daily, weekly, monthly, yearly"`
198+
Recur string `name:"recur" help:"Alias for --repeat cadence: daily, weekly, monthly, yearly"`
199+
RecurRRule string `name:"recur-rrule" help:"Alias for --repeat cadence via RRULE (supports FREQ + optional INTERVAL)"`
200+
RepeatCount int `name:"repeat-count" help:"Number of occurrences to create (requires --repeat, --recur, or --recur-rrule)"`
201+
RepeatUntil string `name:"repeat-until" help:"Repeat until date/time (RFC3339 or YYYY-MM-DD; requires --repeat, --recur, or --recur-rrule)"`
202+
}
203+
204+
type tasksAddRepeatConfig struct {
205+
Unit repeatUnit
206+
Interval int
207+
Repeat string
208+
Recur string
209+
RecurRule string
210+
Until string
211+
}
212+
213+
func resolveTasksAddRepeatConfig(c *TasksAddCmd, due string) (tasksAddRepeatConfig, error) {
214+
config := tasksAddRepeatConfig{
215+
Interval: 1,
216+
Repeat: strings.TrimSpace(c.Repeat),
217+
Recur: strings.TrimSpace(c.Recur),
218+
RecurRule: strings.TrimSpace(c.RecurRRule),
219+
Until: strings.TrimSpace(c.RepeatUntil),
220+
}
221+
222+
if config.Repeat != "" && (config.Recur != "" || config.RecurRule != "") {
223+
return tasksAddRepeatConfig{}, usage("--repeat cannot be combined with --recur or --recur-rrule")
224+
}
225+
if config.Recur != "" && config.RecurRule != "" {
226+
return tasksAddRepeatConfig{}, usage("--recur and --recur-rrule are mutually exclusive")
227+
}
228+
229+
var err error
230+
switch {
231+
case config.RecurRule != "":
232+
config.Unit, config.Interval, err = parseRepeatRRule(config.RecurRule)
233+
case config.Recur != "":
234+
config.Unit, err = parseRepeatUnit(config.Recur)
235+
default:
236+
config.Unit, err = parseRepeatUnit(config.Repeat)
237+
}
238+
if err != nil {
239+
return tasksAddRepeatConfig{}, err
240+
}
241+
242+
if config.Unit == repeatNone && (config.Until != "" || c.RepeatCount != 0) {
243+
return tasksAddRepeatConfig{}, usage("--repeat, --recur, or --recur-rrule is required when using --repeat-count or --repeat-until")
244+
}
245+
246+
if config.Unit != repeatNone {
247+
if due == "" {
248+
return tasksAddRepeatConfig{}, usage("--due is required when using --repeat, --recur, or --recur-rrule")
249+
}
250+
if c.RepeatCount < 0 {
251+
return tasksAddRepeatConfig{}, usage("--repeat-count must be >= 0")
252+
}
253+
if config.Until == "" && c.RepeatCount == 0 {
254+
if config.Recur != "" || config.RecurRule != "" {
255+
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")
256+
}
257+
return tasksAddRepeatConfig{}, usage("--repeat requires --repeat-count or --repeat-until")
258+
}
259+
}
260+
261+
return config, nil
200262
}
201263

202264
func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error {
@@ -213,27 +275,10 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error {
213275
due := strings.TrimSpace(c.Due)
214276
parent := strings.TrimSpace(c.Parent)
215277
previous := strings.TrimSpace(c.Previous)
216-
repeatUntil := strings.TrimSpace(c.RepeatUntil)
217-
218-
repeatUnit, err := parseRepeatUnit(c.Repeat)
278+
repeatConfig, err := resolveTasksAddRepeatConfig(c, due)
219279
if err != nil {
220280
return err
221281
}
222-
if repeatUnit == repeatNone && (repeatUntil != "" || c.RepeatCount != 0) {
223-
return usage("--repeat is required when using --repeat-count or --repeat-until")
224-
}
225-
226-
if repeatUnit != repeatNone {
227-
if due == "" {
228-
return usage("--due is required when using --repeat")
229-
}
230-
if c.RepeatCount < 0 {
231-
return usage("--repeat-count must be >= 0")
232-
}
233-
if repeatUntil == "" && c.RepeatCount == 0 {
234-
return usage("--repeat requires --repeat-count or --repeat-until")
235-
}
236-
}
237282

238283
if dryRunErr := dryRunExit(ctx, flags, "tasks.add", map[string]any{
239284
"tasklist_id": tasklistID,
@@ -242,9 +287,12 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error {
242287
"due": due,
243288
"parent": parent,
244289
"previous": previous,
245-
"repeat": strings.TrimSpace(c.Repeat),
290+
"repeat": repeatConfig.Repeat,
291+
"recur": repeatConfig.Recur,
292+
"recur_rrule": repeatConfig.RecurRule,
293+
"repeat_step": repeatConfig.Interval,
246294
"repeat_count": c.RepeatCount,
247-
"repeat_until": repeatUntil,
295+
"repeat_until": repeatConfig.Until,
248296
}); dryRunErr != nil {
249297
return dryRunErr
250298
}
@@ -254,7 +302,7 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error {
254302
return err
255303
}
256304

257-
if repeatUnit == repeatNone {
305+
if repeatConfig.Unit == repeatNone {
258306
svc, svcErr := newTasksService(ctx, account)
259307
if svcErr != nil {
260308
return svcErr
@@ -314,8 +362,8 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error {
314362
}
315363

316364
var until *time.Time
317-
if repeatUntil != "" {
318-
untilValue, untilHasTime, parseErr := parseTaskDate(repeatUntil)
365+
if repeatConfig.Until != "" {
366+
untilValue, untilHasTime, parseErr := parseTaskDate(repeatConfig.Until)
319367
if parseErr != nil {
320368
return parseErr
321369
}
@@ -337,7 +385,7 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error {
337385
until = &untilValue
338386
}
339387

340-
schedule := expandRepeatSchedule(dueTime, repeatUnit, c.RepeatCount, until)
388+
schedule := expandRepeatSchedule(dueTime, repeatConfig.Unit, repeatConfig.Interval, c.RepeatCount, until)
341389
if len(schedule) == 0 {
342390
return usage("repeat produced no occurrences")
343391
}
@@ -361,7 +409,7 @@ func (c *TasksAddCmd) Run(ctx context.Context, flags *RootFlags) error {
361409
}
362410
task := &tasks.Task{
363411
Title: title,
364-
Notes: strings.TrimSpace(c.Notes),
412+
Notes: notes,
365413
Due: formatTaskDue(due, dueHasTime),
366414
}
367415
call := svc.Tasks.Insert(tasklistID, task)

internal/cmd/tasks_items_validation_more_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,18 @@ func TestTasksValidationErrors(t *testing.T) {
5050
if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", RepeatCount: 2}).Run(ctx, flags); err == nil {
5151
t.Fatalf("expected add repeat-count without repeat")
5252
}
53+
if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", Recur: "weekly", Due: "2025-01-01"}).Run(ctx, flags); err == nil {
54+
t.Fatalf("expected add recur missing count/until")
55+
}
56+
if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", Recur: "weekly", RecurRRule: "FREQ=WEEKLY", Due: "2025-01-01", RepeatCount: 2}).Run(ctx, flags); err == nil {
57+
t.Fatalf("expected add recur and recur-rrule conflict")
58+
}
59+
if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", Repeat: "weekly", Recur: "weekly", Due: "2025-01-01", RepeatCount: 2}).Run(ctx, flags); err == nil {
60+
t.Fatalf("expected add repeat and recur conflict")
61+
}
62+
if err := (&TasksAddCmd{TasklistID: "l1", Title: "Task", RecurRRule: "FREQ=WEEKLY;BYDAY=MO", Due: "2025-01-01", RepeatCount: 2}).Run(ctx, flags); err == nil {
63+
t.Fatalf("expected add recur-rrule unsupported token")
64+
}
5365

5466
{
5567
cmd := &TasksUpdateCmd{}

internal/cmd/tasks_repeat.go

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package cmd
22

33
import (
44
"fmt"
5+
"strconv"
56
"strings"
67
"time"
78

@@ -49,10 +50,84 @@ func parseTaskDate(value string) (time.Time, bool, error) {
4950
return parsed.Time, parsed.HasTime, nil
5051
}
5152

52-
func expandRepeatSchedule(start time.Time, unit repeatUnit, count int, until *time.Time) []time.Time {
53+
func parseRepeatRRule(raw string) (repeatUnit, int, error) {
54+
trimmed := strings.TrimSpace(raw)
55+
if trimmed == "" {
56+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (must include FREQ)", raw)
57+
}
58+
59+
if strings.HasPrefix(strings.ToUpper(trimmed), "RRULE:") {
60+
trimmed = strings.TrimSpace(trimmed[len("RRULE:"):])
61+
}
62+
if trimmed == "" {
63+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (must include FREQ)", raw)
64+
}
65+
66+
unit := repeatNone
67+
interval := 1
68+
seenFreq := false
69+
seenInterval := false
70+
for _, part := range strings.Split(trimmed, ";") {
71+
part = strings.TrimSpace(part)
72+
if part == "" {
73+
continue
74+
}
75+
76+
kv := strings.SplitN(part, "=", 2)
77+
if len(kv) != 2 {
78+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (malformed token %q)", raw, part)
79+
}
80+
81+
key := strings.ToUpper(strings.TrimSpace(kv[0]))
82+
value := strings.ToUpper(strings.TrimSpace(kv[1]))
83+
84+
switch key {
85+
case "FREQ":
86+
if seenFreq {
87+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (duplicate FREQ)", raw)
88+
}
89+
seenFreq = true
90+
switch value {
91+
case "DAILY":
92+
unit = repeatDaily
93+
case "WEEKLY":
94+
unit = repeatWeekly
95+
case "MONTHLY":
96+
unit = repeatMonthly
97+
case "YEARLY":
98+
unit = repeatYearly
99+
default:
100+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (unsupported FREQ %q)", raw, value)
101+
}
102+
case "INTERVAL":
103+
if seenInterval {
104+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (duplicate INTERVAL)", raw)
105+
}
106+
seenInterval = true
107+
parsed, err := strconv.Atoi(value)
108+
if err != nil || parsed <= 0 {
109+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (INTERVAL must be a positive integer)", raw)
110+
}
111+
interval = parsed
112+
default:
113+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (unsupported key %q; only FREQ and INTERVAL are supported)", raw, key)
114+
}
115+
}
116+
117+
if unit == repeatNone {
118+
return repeatNone, 0, fmt.Errorf("invalid --recur-rrule %q (missing FREQ)", raw)
119+
}
120+
121+
return unit, interval, nil
122+
}
123+
124+
func expandRepeatSchedule(start time.Time, unit repeatUnit, interval int, count int, until *time.Time) []time.Time {
53125
if unit == repeatNone {
54126
return []time.Time{start}
55127
}
128+
if interval <= 0 {
129+
interval = 1
130+
}
56131
if count < 0 {
57132
count = 0
58133
}
@@ -63,7 +138,7 @@ func expandRepeatSchedule(start time.Time, unit repeatUnit, count int, until *ti
63138
}
64139
out := []time.Time{}
65140
for i := 0; ; i++ {
66-
t := addRepeat(start, unit, i)
141+
t := addRepeat(start, unit, i*interval)
67142
if until != nil && t.After(*until) {
68143
break
69144
}

0 commit comments

Comments
 (0)