Skip to content

Commit 251a605

Browse files
author
Peter Steinberger
committed
feat(calendar): improve selection and recurring scope handling (openclaw#319) (thanks @salmonumbrella)
1 parent 2471025 commit 251a605

12 files changed

Lines changed: 598 additions & 48 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
- Gmail: add `gmail messages modify` for single-message label changes, complementing thread- and batch-level modify flows. (#281) — thanks @zerone0x.
2121
- Calendar: add `calendar subscribe` (aliases `sub`, `add-calendar`) to add a shared calendar to the current account’s calendar list. (#327) — thanks @cdthompson.
2222
- Calendar: add `calendar alias list|set|unset`, and let calendar commands resolve configured aliases before API/name lookup. (#393) — thanks @salmonumbrella.
23+
- Calendar: let `calendar freebusy` / `calendar conflicts` accept `--cal`, names, indices, and `--all` like `calendar events`. (#319) — thanks @salmonumbrella.
2324
- Slides: add `create-from-template` with `--replace` / `--replacements`, dry-run support, and template placeholder replacement stats. (#273) — thanks @penguinco.
2425
- Forms: add form update/question-management commands plus response watch create/list/delete/renew, with delete-question validation and confirmation guardrails. (#274) — thanks @alexknowshtml.
2526
- Sheets: add `sheets update-note` / `set-note` to write or clear cell notes across a range. (#430) — thanks @andybergon.
@@ -56,6 +57,7 @@
5657
- Auth: preserve scope-shaping flags in the remote step-2 replay guidance for `auth add --remote`. (#427) — thanks @doodaaatimmy-creator.
5758
- Auth: add `--gmail-scope full|readonly`, and disable `include_granted_scopes` for readonly/limited auth requests to avoid Drive/Gmail scope accumulation. (#113) — thanks @salmonumbrella.
5859
- Calendar: force-send `minutes=0` for `--reminder popup:0m` so zero-minute popup reminders survive Google Calendar API JSON omission rules. (#316) — thanks @salmonumbrella.
60+
- Calendar: let recurring `calendar update --scope=future` and `calendar delete --scope=future` start from an instance event ID by resolving the parent series first. (#319) — thanks @salmonumbrella.
5961
- Calendar: hide cancelled/deleted events from `calendar events` list output by explicitly setting `showDeleted=false`. (#362) — thanks @sharukh010.
6062
- Sheets: harden `sheets format` against `boarders` typo (JSON and field mask), with clearer error messages. (#284) — thanks @nilzzzzzz.
6163
- Sheets: force-send empty note values so `sheets update-note --note ''` reliably clears notes via the API. (#341) — thanks @Shehryar.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -851,9 +851,11 @@ gog calendar propose-time <calendarId> <eventId> --decline --comment "Can we do
851851
gog calendar freebusy --calendars "primary,work@example.com" \
852852
--from 2025-01-15T00:00:00Z \
853853
--to 2025-01-16T00:00:00Z
854+
gog calendar freebusy --cal Work --from 2025-01-15T00:00:00Z --to 2025-01-16T00:00:00Z
854855

855856
gog calendar conflicts --calendars "primary,work@example.com" \
856857
--today # Today's conflicts
858+
gog calendar conflicts --all --today # Check conflicts across all calendars
857859
```
858860

859861
### Time

docs/spec.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,8 @@ Flag aliases:
201201
- `gog calendar create <calendarId> --summary S --from DT --to DT [--description D] [--location L] [--attendees a@b.com,c@d.com] [--all-day] [--event-type TYPE]`
202202
- `gog calendar update <calendarId> <eventId> [--summary S] [--from DT] [--to DT] [--description D] [--location L] [--attendees ...] [--add-attendee ...] [--all-day] [--event-type TYPE]`
203203
- `gog calendar delete <calendarId> <eventId>`
204-
- `gog calendar freebusy <calendarIds> --from RFC3339 --to RFC3339`
204+
- `gog calendar freebusy [calendarIds] [--cal ID_OR_NAME] [--calendars CSV] [--all] --from RFC3339 --to RFC3339`
205+
- `gog calendar conflicts [--cal ID_OR_NAME] [--calendars CSV] [--all] [--from RFC3339|date|relative] [--to RFC3339|date|relative] [--today|--week|--days N]`
205206
- `gog calendar respond <calendarId> <eventId> --status accepted|declined|tentative [--send-updates all|none|externalOnly]`
206207
- `gog time now [--timezone TZ]`
207208
- `gog classroom courses [--state ...] [--max N] [--page TOKEN]`

internal/cmd/calendar_conflicts.go

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ type conflict struct {
2222
}
2323

2424
type CalendarConflictsCmd struct {
25-
From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"`
26-
To string `name:"to" help:"End time (RFC3339, date, or relative)"`
27-
Today bool `name:"today" help:"Today only (timezone-aware)"`
28-
Week bool `name:"week" help:"This week (uses --week-start, default Mon)"`
29-
Days int `name:"days" help:"Next N days (timezone-aware)" default:"0"`
30-
WeekStart string `name:"week-start" help:"Week start day for --week (sun, mon, ...)" default:""`
31-
Calendars string `name:"calendars" help:"Comma-separated calendar IDs" default:"primary"`
25+
From string `name:"from" help:"Start time (RFC3339, date, or relative: today, tomorrow, monday)"`
26+
To string `name:"to" help:"End time (RFC3339, date, or relative)"`
27+
Today bool `name:"today" help:"Today only (timezone-aware)"`
28+
Week bool `name:"week" help:"This week (uses --week-start, default Mon)"`
29+
Days int `name:"days" help:"Next N days (timezone-aware)" default:"0"`
30+
WeekStart string `name:"week-start" help:"Week start day for --week (sun, mon, ...)" default:""`
31+
Cal []string `name:"cal" help:"Calendar ID, name, or index (can be repeated)"`
32+
Calendars string `name:"calendars" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"`
33+
All bool `name:"all" help:"Query all calendars"`
3234
}
3335

3436
func (c *CalendarConflictsCmd) Run(ctx context.Context, flags *RootFlags) error {
@@ -38,21 +40,18 @@ func (c *CalendarConflictsCmd) Run(ctx context.Context, flags *RootFlags) error
3840
return err
3941
}
4042

41-
calendarIDs := splitCSV(c.Calendars)
42-
if len(calendarIDs) == 0 {
43-
return errors.New("no calendar IDs provided")
44-
}
45-
46-
// Resolve aliases for all calendar IDs
47-
resolvedIDs, err := prepareCalendarIDs(calendarIDs)
43+
svc, err := newCalendarService(ctx, account)
4844
if err != nil {
4945
return err
5046
}
5147

52-
svc, err := newCalendarService(ctx, account)
48+
calendarIDs, err := resolveSelectedCalendarIDs(ctx, svc, c.Cal, c.Calendars, c.All, true)
5349
if err != nil {
5450
return err
5551
}
52+
if len(calendarIDs) == 0 {
53+
return errors.New("no calendar IDs provided")
54+
}
5655

5756
// Use timezone-aware time resolution
5857
timeRange, err := ResolveTimeRange(ctx, svc, TimeRangeFlags{
@@ -69,8 +68,8 @@ func (c *CalendarConflictsCmd) Run(ctx context.Context, flags *RootFlags) error
6968

7069
from, to := timeRange.FormatRFC3339()
7170

72-
items := make([]*calendar.FreeBusyRequestItem, 0, len(resolvedIDs))
73-
for _, id := range resolvedIDs {
71+
items := make([]*calendar.FreeBusyRequestItem, 0, len(calendarIDs))
72+
for _, id := range calendarIDs {
7473
items = append(items, &calendar.FreeBusyRequestItem{Id: id})
7574
}
7675

internal/cmd/calendar_delete_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ func TestCalendarDeleteCmd_ScopeSingle(t *testing.T) {
1818
svc, closeSvc := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1919
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
2020
switch {
21+
case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev":
22+
w.Header().Set("Content-Type", "application/json")
23+
_ = json.NewEncoder(w).Encode(map[string]any{
24+
"id": "ev",
25+
"recurrence": []string{"RRULE:FREQ=DAILY"},
26+
})
27+
return
2128
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal@example.com/events/ev/instances"):
2229
w.Header().Set("Content-Type", "application/json")
2330
_ = json.NewEncoder(w).Encode(map[string]any{
@@ -227,3 +234,82 @@ func TestCalendarDeleteCmd_DryRunSkipsService(t *testing.T) {
227234
t.Fatalf("unexpected dry-run output: %#v", payload)
228235
}
229236
}
237+
238+
func TestCalendarDeleteCmd_ScopeFuture_InstanceEventID(t *testing.T) {
239+
origNew := newCalendarService
240+
t.Cleanup(func() { newCalendarService = origNew })
241+
242+
var patchedRecurrence []string
243+
svc, closeSvc := newCalendarServiceForTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
244+
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
245+
switch {
246+
case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev_instance":
247+
w.Header().Set("Content-Type", "application/json")
248+
_ = json.NewEncoder(w).Encode(map[string]any{
249+
"id": "ev_instance",
250+
"recurringEventId": "ev_master",
251+
})
252+
return
253+
case r.Method == http.MethodGet && path == "/calendars/cal@example.com/events/ev_master":
254+
w.Header().Set("Content-Type", "application/json")
255+
_ = json.NewEncoder(w).Encode(map[string]any{
256+
"id": "ev_master",
257+
"recurrence": []string{"RRULE:FREQ=DAILY"},
258+
})
259+
return
260+
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal@example.com/events/ev_master/instances"):
261+
w.Header().Set("Content-Type", "application/json")
262+
_ = json.NewEncoder(w).Encode(map[string]any{
263+
"items": []map[string]any{
264+
{
265+
"id": "ev_2",
266+
"originalStartTime": map[string]any{
267+
"dateTime": "2025-01-02T10:00:00Z",
268+
},
269+
},
270+
},
271+
})
272+
return
273+
case r.Method == http.MethodDelete && path == "/calendars/cal@example.com/events/ev_2":
274+
w.WriteHeader(http.StatusNoContent)
275+
return
276+
case r.Method == http.MethodPatch && path == "/calendars/cal@example.com/events/ev_master":
277+
var body calendar.Event
278+
_ = json.NewDecoder(r.Body).Decode(&body)
279+
patchedRecurrence = append([]string{}, body.Recurrence...)
280+
w.Header().Set("Content-Type", "application/json")
281+
_ = json.NewEncoder(w).Encode(body)
282+
return
283+
default:
284+
http.NotFound(w, r)
285+
return
286+
}
287+
}))
288+
defer closeSvc()
289+
newCalendarService = func(context.Context, string) (*calendar.Service, error) { return svc, nil }
290+
291+
ctx := newCalendarJSONContext(t)
292+
293+
cmd := CalendarDeleteCmd{
294+
CalendarID: "cal@example.com",
295+
EventID: "ev_instance",
296+
Scope: scopeFuture,
297+
OriginalStartTime: "2025-01-02T10:00:00Z",
298+
}
299+
flags := &RootFlags{Account: "a@b.com", Force: true}
300+
out := captureStdout(t, func() {
301+
if err := cmd.Run(ctx, flags); err != nil {
302+
t.Fatalf("CalendarDeleteCmd: %v", err)
303+
}
304+
})
305+
var payload struct {
306+
Deleted bool `json:"deleted"`
307+
EventID string `json:"eventId"`
308+
}
309+
if err := json.Unmarshal([]byte(out), &payload); err != nil {
310+
t.Fatalf("decode output: %v", err)
311+
}
312+
if !payload.Deleted || payload.EventID != "ev_2" || len(patchedRecurrence) == 0 {
313+
t.Fatalf("unexpected output: %#v", payload)
314+
}
315+
}

internal/cmd/calendar_edit.go

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -705,16 +705,15 @@ func (c *CalendarUpdateCmd) buildUpdateOutOfOfficeProperties(declineProvided boo
705705
func applyUpdateScope(ctx context.Context, svc *calendar.Service, calendarID, eventID, scope, originalStartTime string, patch *calendar.Event) (string, []string, error) {
706706
targetEventID := eventID
707707
var parentRecurrence []string
708+
recurringEventID := eventID
708709

709710
if scope == scopeFuture {
710-
parent, err := svc.Events.Get(calendarID, eventID).Context(ctx).Do()
711+
parentID, recurrence, err := resolveRecurringParentEvent(ctx, svc, calendarID, eventID)
711712
if err != nil {
712713
return "", nil, err
713714
}
714-
if len(parent.Recurrence) == 0 {
715-
return "", nil, fmt.Errorf("event %s is not a recurring event", eventID)
716-
}
717-
parentRecurrence = parent.Recurrence
715+
recurringEventID = parentID
716+
parentRecurrence = recurrence
718717
recurrenceOverride := len(patch.Recurrence) > 0
719718
if !recurrenceOverride {
720719
for _, field := range patch.ForceSendFields {
@@ -730,7 +729,14 @@ func applyUpdateScope(ctx context.Context, svc *calendar.Service, calendarID, ev
730729
}
731730

732731
if scope == scopeSingle || scope == scopeFuture {
733-
instanceID, err := resolveRecurringInstanceID(ctx, svc, calendarID, eventID, originalStartTime)
732+
if scope == scopeSingle {
733+
var err error
734+
recurringEventID, err = resolveRecurringSeriesID(ctx, svc, calendarID, eventID)
735+
if err != nil {
736+
return "", nil, err
737+
}
738+
}
739+
instanceID, err := resolveRecurringInstanceID(ctx, svc, calendarID, recurringEventID, originalStartTime)
734740
if err != nil {
735741
return "", nil, err
736742
}
@@ -823,18 +829,27 @@ func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
823829

824830
targetEventID := eventID
825831
var parentRecurrence []string
832+
parentEventID := eventID
826833
if scope == scopeFuture {
827-
parent, getErr := mutation.svc.Events.Get(mutation.calendarID, eventID).Context(ctx).Do()
828-
if getErr != nil {
829-
return getErr
830-
}
831-
if len(parent.Recurrence) == 0 {
832-
return fmt.Errorf("event %s is not a recurring event", eventID)
834+
parentID, recurrence, resolveErr := resolveRecurringParentEvent(ctx, mutation.svc, mutation.calendarID, eventID)
835+
if resolveErr != nil {
836+
return resolveErr
833837
}
834-
parentRecurrence = parent.Recurrence
838+
parentEventID = parentID
839+
parentRecurrence = recurrence
835840
}
836841
if scope == scopeSingle || scope == scopeFuture {
837-
instanceID, resolveErr := resolveRecurringInstanceID(ctx, mutation.svc, mutation.calendarID, eventID, c.OriginalStartTime)
842+
recurringEventID := eventID
843+
if scope == scopeSingle {
844+
recurringEventID, err = resolveRecurringSeriesID(ctx, mutation.svc, mutation.calendarID, eventID)
845+
if err != nil {
846+
return err
847+
}
848+
}
849+
if scope == scopeFuture {
850+
recurringEventID = parentEventID
851+
}
852+
instanceID, resolveErr := resolveRecurringInstanceID(ctx, mutation.svc, mutation.calendarID, recurringEventID, c.OriginalStartTime)
838853
if resolveErr != nil {
839854
return resolveErr
840855
}
@@ -849,7 +864,7 @@ func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
849864
if truncateErr != nil {
850865
return truncateErr
851866
}
852-
_, patchErr := mutation.patchEvent(ctx, eventID, &calendar.Event{Recurrence: truncated}, sendUpdates)
867+
_, patchErr := mutation.patchEvent(ctx, parentEventID, &calendar.Event{Recurrence: truncated}, sendUpdates)
853868
if patchErr != nil {
854869
return patchErr
855870
}

internal/cmd/calendar_edit_scope_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,3 +113,64 @@ func TestApplyUpdateScopeFuture_NonRecurring(t *testing.T) {
113113
t.Fatalf("expected error for non-recurring event")
114114
}
115115
}
116+
117+
func TestApplyUpdateScopeFuture_RecurringInstanceID(t *testing.T) {
118+
originalStart := "2025-01-02T10:00:00Z"
119+
120+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
121+
path := strings.TrimPrefix(r.URL.Path, "/calendar/v3")
122+
switch {
123+
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev_instance":
124+
w.Header().Set("Content-Type", "application/json")
125+
_ = json.NewEncoder(w).Encode(map[string]any{
126+
"id": "ev_instance",
127+
"recurringEventId": "ev_master",
128+
})
129+
case r.Method == http.MethodGet && path == "/calendars/cal/events/ev_master":
130+
w.Header().Set("Content-Type", "application/json")
131+
_ = json.NewEncoder(w).Encode(map[string]any{
132+
"id": "ev_master",
133+
"recurrence": []string{"RRULE:FREQ=DAILY"},
134+
})
135+
case r.Method == http.MethodGet && strings.HasPrefix(path, "/calendars/cal/events/ev_master/instances"):
136+
w.Header().Set("Content-Type", "application/json")
137+
_ = json.NewEncoder(w).Encode(map[string]any{
138+
"items": []map[string]any{
139+
{
140+
"id": "ev_1",
141+
"originalStartTime": map[string]any{
142+
"dateTime": originalStart,
143+
},
144+
},
145+
},
146+
})
147+
default:
148+
http.NotFound(w, r)
149+
}
150+
}))
151+
defer srv.Close()
152+
153+
svc, err := calendar.NewService(context.Background(),
154+
option.WithoutAuthentication(),
155+
option.WithHTTPClient(srv.Client()),
156+
option.WithEndpoint(srv.URL+"/"),
157+
)
158+
if err != nil {
159+
t.Fatalf("NewService: %v", err)
160+
}
161+
162+
patch := &calendar.Event{Summary: "updated"}
163+
targetID, parentRecurrence, err := applyUpdateScope(context.Background(), svc, "cal", "ev_instance", scopeFuture, originalStart, patch)
164+
if err != nil {
165+
t.Fatalf("applyUpdateScope: %v", err)
166+
}
167+
if targetID != "ev_1" {
168+
t.Fatalf("unexpected target id: %q", targetID)
169+
}
170+
if len(parentRecurrence) != 1 || parentRecurrence[0] != "RRULE:FREQ=DAILY" {
171+
t.Fatalf("unexpected parent recurrence: %#v", parentRecurrence)
172+
}
173+
if len(patch.Recurrence) != 1 || patch.Recurrence[0] != "RRULE:FREQ=DAILY" {
174+
t.Fatalf("patch did not inherit recurrence: %#v", patch.Recurrence)
175+
}
176+
}

internal/cmd/calendar_freebusy.go

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ import (
1313
)
1414

1515
type CalendarFreeBusyCmd struct {
16-
CalendarIDs string `arg:"" name:"calendarIds" help:"Comma-separated calendar IDs"`
17-
From string `name:"from" help:"Start time (RFC3339, required)"`
18-
To string `name:"to" help:"End time (RFC3339, required)"`
16+
CalendarIDs string `arg:"" optional:"" name:"calendarIds" help:"Comma-separated calendar IDs, names, or indices from 'calendar calendars'"`
17+
Cal []string `name:"cal" help:"Calendar ID, name, or index (can be repeated)"`
18+
All bool `name:"all" help:"Query all calendars"`
19+
From string `name:"from" help:"Start time (RFC3339, required)"`
20+
To string `name:"to" help:"End time (RFC3339, required)"`
1921
}
2022

2123
func (c *CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error {
@@ -25,32 +27,26 @@ func (c *CalendarFreeBusyCmd) Run(ctx context.Context, flags *RootFlags) error {
2527
return err
2628
}
2729

28-
calendarIDs := splitCSV(c.CalendarIDs)
29-
if len(calendarIDs) == 0 {
30-
return usage("no calendar IDs provided")
30+
if strings.TrimSpace(c.From) == "" || strings.TrimSpace(c.To) == "" {
31+
return usage("required: --from and --to")
3132
}
3233

33-
// Resolve aliases for all calendar IDs
34-
resolvedIDs, err := prepareCalendarIDs(calendarIDs)
34+
svc, err := newCalendarService(ctx, account)
3535
if err != nil {
3636
return err
3737
}
3838

39-
if strings.TrimSpace(c.From) == "" || strings.TrimSpace(c.To) == "" {
40-
return usage("required: --from and --to")
41-
}
42-
43-
svc, err := newCalendarService(ctx, account)
39+
calendarIDs, err := resolveSelectedCalendarIDs(ctx, svc, c.Cal, c.CalendarIDs, c.All, true)
4440
if err != nil {
4541
return err
4642
}
4743

4844
req := &calendar.FreeBusyRequest{
4945
TimeMin: c.From,
5046
TimeMax: c.To,
51-
Items: make([]*calendar.FreeBusyRequestItem, 0, len(resolvedIDs)),
47+
Items: make([]*calendar.FreeBusyRequestItem, 0, len(calendarIDs)),
5248
}
53-
for _, id := range resolvedIDs {
49+
for _, id := range calendarIDs {
5450
req.Items = append(req.Items, &calendar.FreeBusyRequestItem{Id: id})
5551
}
5652

0 commit comments

Comments
 (0)