diff --git a/internal/cmd/calendar_create_update_test.go b/internal/cmd/calendar_create_update_test.go index ad5b5309..b9a3ac4c 100644 --- a/internal/cmd/calendar_create_update_test.go +++ b/internal/cmd/calendar_create_update_test.go @@ -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) } } diff --git a/internal/cmd/calendar_edit.go b/internal/cmd/calendar_edit.go index 37c6dfc8..0cf69d73 100644 --- a/internal/cmd/calendar_edit.go +++ b/internal/cmd/calendar_edit.go @@ -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"` @@ -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"` @@ -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 { @@ -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 != "" { @@ -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 diff --git a/internal/cmd/calendar_focus_time.go b/internal/cmd/calendar_focus_time.go index 4c704950..9ca20a42 100644 --- a/internal/cmd/calendar_focus_time.go +++ b/internal/cmd/calendar_focus_time.go @@ -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 {