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
87 changes: 85 additions & 2 deletions internal/cmd/calendar_create_update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -204,8 +204,91 @@ func TestCalendarCreateCmd_RecurringOffsetTimezoneFallback(t *testing.T) {
if gotEvent.End == nil || gotEvent.End.TimeZone != "Etc/GMT-2" {
t.Fatalf("expected end timezone fallback Etc/GMT-2, got %#v", gotEvent.End)
}
if len(gotEvent.Recurrence) == 0 {
t.Fatalf("expected recurrence to be set")
if len(gotEvent.Recurrence) != 1 || gotEvent.Recurrence[0] != "FREQ=WEEKLY;BYDAY=TU,TH" {
t.Fatalf("unexpected recurrence payload: %#v", gotEvent.Recurrence)
}
}

func TestCalendarUpdateCmd_RecurrenceFillsMissingTimezone(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })

var (
gotPatch calendar.Event
currentLoaded bool
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
switch {
case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev":
currentLoaded = true
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
"start": map[string]any{
"dateTime": "2026-03-03T20:00:00+01:00",
},
"end": map[string]any{
"dateTime": "2026-03-03T20:30:00+01:00",
},
})
return
case r.Method == http.MethodPatch && path == "/calendars/cal@example.com/events/ev":
_ = json.NewDecoder(r.Body).Decode(&gotPatch)
if gotPatch.Start == nil || gotPatch.End == nil ||
gotPatch.Start.TimeZone == "" || gotPatch.End.TimeZone == "" {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": 400,
"message": "Missing time zone definition for start time.",
},
})
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "ev",
})
return
case r.Method == http.MethodGet && path == "/users/me/calendarList/cal@example.com":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "cal@example.com",
"timeZone": "UTC",
})
return
}
http.NotFound(w, r)
}))
defer srv.Close()

svc := newCalendarServiceFromServer(t, srv)
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }

ctx := newCalendarJSONContext(t)

cmd := &CalendarUpdateCmd{}
if err := runKong(t, cmd, []string{
"cal@example.com",
"ev",
"--rrule", "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
t.Fatalf("runKong: %v", err)
}

if !currentLoaded {
t.Fatalf("expected existing event fetch for recurring timezone enrichment")
}
if gotPatch.Start == nil || gotPatch.Start.TimeZone != "Etc/GMT-1" {
t.Fatalf("expected start timezone Etc/GMT-1, got %#v", gotPatch.Start)
}
if gotPatch.End == nil || gotPatch.End.TimeZone != "Etc/GMT-1" {
t.Fatalf("expected end timezone Etc/GMT-1, got %#v", gotPatch.End)
}
if len(gotPatch.Recurrence) != 1 || gotPatch.Recurrence[0] != "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" {
t.Fatalf("unexpected recurrence payload: %#v", gotPatch.Recurrence)
}
}

Expand Down
87 changes: 85 additions & 2 deletions internal/cmd/calendar_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type CalendarCreateCmd struct {
Location string `name:"location" help:"Location"`
Attendees string `name:"attendees" help:"Comma-separated attendee emails"`
AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"`
Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated."`
Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated." sep:"none"`
Reminders []string `name:"reminder" help:"Custom reminders as method:duration (e.g., popup:30m, email:1d). Can be repeated (max 5)."`
ColorId string `name:"event-color" help:"Event color ID (1-11). Use 'gog calendar colors' to see available colors."`
Visibility string `name:"visibility" help:"Event visibility: default, public, private, confidential"`
Expand Down Expand Up @@ -320,7 +320,7 @@ type CalendarUpdateCmd struct {
Attendees string `name:"attendees" help:"Comma-separated attendee emails (replaces all; set empty to clear)"`
AddAttendee string `name:"add-attendee" help:"Comma-separated attendee emails to add (preserves existing attendees)"`
AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"`
Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated. Set empty to clear."`
Recurrence []string `name:"rrule" help:"Recurrence rules (e.g., 'RRULE:FREQ=MONTHLY;BYMONTHDAY=11'). Can be repeated. Set empty to clear." sep:"none"`
Reminders []string `name:"reminder" help:"Custom reminders as method:duration (e.g., popup:30m, email:1d). Can be repeated (max 5). Set empty to clear."`
ColorId string `name:"event-color" help:"Event color ID (1-11, or empty to clear)"`
Visibility string `name:"visibility" help:"Event visibility: default, public, private, confidential"`
Expand Down Expand Up @@ -379,6 +379,7 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
if err != nil {
return err
}
recurrenceProvided := flagProvided(kctx, "rrule")

patch, changed, err := c.buildUpdatePatch(kctx)
if err != nil {
Expand Down Expand Up @@ -442,6 +443,11 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
if err != nil {
return err
}
if recurrenceProvided {
if enrichErr := ensureRecurringPatchDateTimes(ctx, svc, calendarID, targetEventID, patch); enrichErr != nil {
return enrichErr
}
}

call := svc.Events.Patch(calendarID, targetEventID, patch).Context(ctx)
if sendUpdates != "" {
Expand Down Expand Up @@ -609,6 +615,83 @@ func (c *CalendarUpdateCmd) applyRecurrence(kctx *kong.Context, patch *calendar.
return true
}

func ensureRecurringPatchDateTimes(ctx context.Context, svc *calendar.Service, calendarID, eventID string, patch *calendar.Event) error {
if len(patch.Recurrence) == 0 {
return nil
}

patch.Start = normalizeRecurringPatchDateTime(patch.Start, nil)
patch.End = normalizeRecurringPatchDateTime(patch.End, nil)
if !recurringPatchDateTimeNeedsFetch(patch.Start) && !recurringPatchDateTimeNeedsFetch(patch.End) {
return nil
}

current, err := svc.Events.Get(calendarID, eventID).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to fetch current event for recurrence timezone: %w", err)
}

patch.Start = normalizeRecurringPatchDateTime(patch.Start, current.Start)
patch.End = normalizeRecurringPatchDateTime(patch.End, current.End)
return nil
}

func recurringPatchDateTimeNeedsFetch(dt *calendar.EventDateTime) bool {
if dt == nil {
return true
}
if strings.TrimSpace(dt.Date) != "" {
return false
}
return strings.TrimSpace(dt.DateTime) == "" || strings.TrimSpace(dt.TimeZone) == ""
}

func normalizeRecurringPatchDateTime(primary, fallback *calendar.EventDateTime) *calendar.EventDateTime {
if primary == nil && fallback == nil {
return nil
}

var out *calendar.EventDateTime
if primary != nil {
out = cloneEventDateTime(primary)
} else {
out = cloneEventDateTime(fallback)
}
if out == nil {
return nil
}

if strings.TrimSpace(out.Date) != "" {
out.DateTime = ""
out.TimeZone = ""
return out
}
if strings.TrimSpace(out.DateTime) == "" && fallback != nil {
if strings.TrimSpace(fallback.Date) != "" {
return &calendar.EventDateTime{Date: fallback.Date}
}
out.DateTime = fallback.DateTime
}
if strings.TrimSpace(out.TimeZone) == "" && fallback != nil {
out.TimeZone = strings.TrimSpace(fallback.TimeZone)
}
if strings.TrimSpace(out.TimeZone) == "" && strings.TrimSpace(out.DateTime) != "" {
out.TimeZone = extractTimezone(out.DateTime)
}
return out
}

func cloneEventDateTime(in *calendar.EventDateTime) *calendar.EventDateTime {
if in == nil {
return nil
}
return &calendar.EventDateTime{
Date: in.Date,
DateTime: in.DateTime,
TimeZone: in.TimeZone,
}
}

func (c *CalendarUpdateCmd) applyReminders(kctx *kong.Context, patch *calendar.Event) (bool, error) {
if !flagProvided(kctx, "reminder") {
return false, nil
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/calendar_focus_time.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type CalendarFocusTimeCmd struct {
AutoDecline string `name:"auto-decline" help:"Auto-decline mode: none, all, new" default:"all"`
DeclineMessage string `name:"decline-message" help:"Message for declined invitations"`
ChatStatus string `name:"chat-status" help:"Chat status: available, doNotDisturb" default:"doNotDisturb"`
Recurrence []string `name:"rrule" help:"Recurrence rules. Can be repeated."`
Recurrence []string `name:"rrule" help:"Recurrence rules. Can be repeated." sep:"none"`
}

func (c *CalendarFocusTimeCmd) Run(ctx context.Context, flags *RootFlags) error {
Expand Down