Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -960,13 +960,15 @@ gog tasks get <tasklistId> <taskId>
gog tasks add <tasklistId> --title "Task title"
gog tasks add <tasklistId> --title "Weekly sync" --due 2025-02-01 --repeat weekly --repeat-count 4
gog tasks add <tasklistId> --title "Daily standup" --due 2025-02-01 --repeat daily --repeat-until 2025-02-05
gog tasks add <tasklistId> --title "Bi-weekly review" --due 2025-02-01 --recur-rrule "FREQ=WEEKLY;INTERVAL=2" --repeat-count 3
gog tasks update <tasklistId> <taskId> --title "New title"
gog tasks done <tasklistId> <taskId>
gog tasks undo <tasklistId> <taskId>
gog tasks delete <tasklistId> <taskId>
gog tasks clear <tasklistId>

# 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.
```

Expand Down
104 changes: 76 additions & 28 deletions internal/cmd/tasks_items.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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")
}
Expand All @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions internal/cmd/tasks_items_validation_more_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down
79 changes: 77 additions & 2 deletions internal/cmd/tasks_repeat.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
Loading