Skip to content

Commit 61eef99

Browse files
feat(calendar): add --add-attendee flag to preserve existing attendees
The --attendees flag replaces all attendees and resets their RSVP status. This new --add-attendee flag fetches the current event first and merges new attendees while preserving existing attendee metadata (responseStatus, displayName, etc). Features: - Add AddAttendee field to CalendarUpdateCmd - Implement mergeAttendees() for deduplication with case-insensitive email matching - Validation to prevent using --attendees and --add-attendee together - New attendees get responseStatus="needsAction" 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 19d833f commit 61eef99

2 files changed

Lines changed: 153 additions & 4 deletions

File tree

internal/cmd/calendar.go

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@ type CalendarUpdateCmd struct {
269269
To string `name:"to" help:"New end time (RFC3339; set empty to clear)"`
270270
Description string `name:"description" help:"New description (set empty to clear)"`
271271
Location string `name:"location" help:"New location (set empty to clear)"`
272-
Attendees string `name:"attendees" help:"Comma-separated attendee emails (set empty to clear)"`
272+
Attendees string `name:"attendees" help:"Comma-separated attendee emails (replaces all; set empty to clear)"`
273+
AddAttendee string `name:"add-attendee" help:"Comma-separated attendee emails to add (preserves existing attendees)"`
273274
AllDay bool `name:"all-day" help:"All-day event (use date-only in --from/--to)"`
274275
}
275276

@@ -295,6 +296,11 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
295296
}
296297
}
297298

299+
// Cannot use both --attendees and --add-attendee at the same time.
300+
if flagProvided(kctx, "attendees") && flagProvided(kctx, "add-attendee") {
301+
return usage("cannot use both --attendees and --add-attendee; use --attendees to replace all, or --add-attendee to add")
302+
}
303+
298304
patch := &calendar.Event{}
299305
changed := false
300306
if flagProvided(kctx, "summary") {
@@ -321,15 +327,26 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
321327
patch.Attendees = buildAttendees(c.Attendees)
322328
changed = true
323329
}
324-
if !changed {
325-
return usage("no updates provided")
326-
}
327330

328331
svc, err := newCalendarService(ctx, account)
329332
if err != nil {
330333
return err
331334
}
332335

336+
// For --add-attendee, fetch current event to preserve existing attendees with metadata.
337+
if flagProvided(kctx, "add-attendee") {
338+
existing, err := svc.Events.Get(calendarID, eventID).Do()
339+
if err != nil {
340+
return fmt.Errorf("failed to fetch current event: %w", err)
341+
}
342+
patch.Attendees = mergeAttendees(existing.Attendees, c.AddAttendee)
343+
changed = true
344+
}
345+
346+
if !changed {
347+
return usage("no updates provided")
348+
}
349+
333350
updated, err := svc.Events.Patch(calendarID, eventID, patch).Do()
334351
if err != nil {
335352
return err
@@ -592,6 +609,39 @@ func buildAttendees(csv string) []*calendar.EventAttendee {
592609
return out
593610
}
594611

612+
// mergeAttendees preserves existing attendees (with all their metadata like responseStatus)
613+
// and adds new attendees from the CSV string. Duplicates (by email) are skipped.
614+
func mergeAttendees(existing []*calendar.EventAttendee, addCSV string) []*calendar.EventAttendee {
615+
newEmails := splitCSV(addCSV)
616+
if len(newEmails) == 0 {
617+
return existing
618+
}
619+
620+
// Build a set of existing emails for deduplication
621+
existingEmails := make(map[string]bool, len(existing))
622+
for _, a := range existing {
623+
if a != nil && a.Email != "" {
624+
existingEmails[strings.ToLower(a.Email)] = true
625+
}
626+
}
627+
628+
// Start with existing attendees (preserving all metadata)
629+
out := make([]*calendar.EventAttendee, 0, len(existing)+len(newEmails))
630+
out = append(out, existing...)
631+
632+
// Add new attendees that don't already exist
633+
for _, email := range newEmails {
634+
if !existingEmails[strings.ToLower(email)] {
635+
out = append(out, &calendar.EventAttendee{
636+
Email: email,
637+
ResponseStatus: "needsAction",
638+
})
639+
existingEmails[strings.ToLower(email)] = true
640+
}
641+
}
642+
return out
643+
}
644+
595645
func splitCSV(s string) []string {
596646
s = strings.TrimSpace(s)
597647
if s == "" {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"google.golang.org/api/calendar/v3"
7+
)
8+
9+
func TestMergeAttendees(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
existing []*calendar.EventAttendee
13+
addCSV string
14+
wantLen int
15+
}{
16+
{
17+
name: "add to empty list",
18+
existing: nil,
19+
addCSV: "a@test.com,b@test.com",
20+
wantLen: 2,
21+
},
22+
{
23+
name: "add to existing list",
24+
existing: []*calendar.EventAttendee{
25+
{Email: "existing@test.com", ResponseStatus: "accepted"},
26+
},
27+
addCSV: "new@test.com",
28+
wantLen: 2,
29+
},
30+
{
31+
name: "skip duplicates case-insensitive",
32+
existing: []*calendar.EventAttendee{
33+
{Email: "Existing@Test.com", ResponseStatus: "accepted"},
34+
},
35+
addCSV: "existing@test.com,new@test.com",
36+
wantLen: 2,
37+
},
38+
{
39+
name: "preserve existing metadata",
40+
existing: []*calendar.EventAttendee{
41+
{Email: "alice@test.com", ResponseStatus: "accepted", DisplayName: "Alice"},
42+
},
43+
addCSV: "bob@test.com",
44+
wantLen: 2,
45+
},
46+
{
47+
name: "empty add string",
48+
existing: []*calendar.EventAttendee{
49+
{Email: "keep@test.com", ResponseStatus: "accepted"},
50+
},
51+
addCSV: "",
52+
wantLen: 1,
53+
},
54+
}
55+
56+
for _, tt := range tests {
57+
t.Run(tt.name, func(t *testing.T) {
58+
got := mergeAttendees(tt.existing, tt.addCSV)
59+
if len(got) != tt.wantLen {
60+
t.Errorf("mergeAttendees() returned %d attendees, want %d", len(got), tt.wantLen)
61+
}
62+
63+
if tt.name == "preserve existing metadata" && len(got) > 0 {
64+
found := false
65+
for _, a := range got {
66+
if a.Email == "alice@test.com" {
67+
found = true
68+
if a.ResponseStatus != "accepted" {
69+
t.Errorf("existing attendee lost responseStatus")
70+
}
71+
if a.DisplayName != "Alice" {
72+
t.Errorf("existing attendee lost displayName")
73+
}
74+
}
75+
}
76+
if !found {
77+
t.Errorf("existing attendee alice@test.com not found in result")
78+
}
79+
}
80+
})
81+
}
82+
}
83+
84+
func TestMergeAttendeesNewHaveNeedsAction(t *testing.T) {
85+
existing := []*calendar.EventAttendee{
86+
{Email: "existing@test.com", ResponseStatus: "accepted"},
87+
}
88+
got := mergeAttendees(existing, "new@test.com")
89+
90+
for _, a := range got {
91+
if a.Email == "new@test.com" {
92+
if a.ResponseStatus != "needsAction" {
93+
t.Errorf("new attendee should have responseStatus=needsAction, got %q", a.ResponseStatus)
94+
}
95+
return
96+
}
97+
}
98+
t.Error("new attendee not found in result")
99+
}

0 commit comments

Comments
 (0)