Skip to content

Commit 7b7b3a6

Browse files
feat(calendar): support scope=future for recurring updates/deletes
1 parent 2495f1b commit 7b7b3a6

2 files changed

Lines changed: 162 additions & 4 deletions

File tree

internal/cmd/calendar.go

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,9 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
383383
return usage("--original-start required when --scope=single")
384384
}
385385
case scopeFuture:
386-
return fmt.Errorf("scope=future is not supported yet")
386+
if strings.TrimSpace(c.OriginalStartTime) == "" {
387+
return usage("--original-start required when --scope=future")
388+
}
387389
case scopeAll:
388390
default:
389391
return fmt.Errorf("invalid scope: %q (must be single, future, or all)", scope)
@@ -481,7 +483,19 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
481483
}
482484

483485
targetEventID := eventID
484-
if scope == scopeSingle {
486+
var parentRecurrence []string
487+
if scope == scopeFuture {
488+
parent, getErr := svc.Events.Get(calendarID, eventID).Context(ctx).Do()
489+
if getErr != nil {
490+
return getErr
491+
}
492+
if len(parent.Recurrence) == 0 {
493+
return fmt.Errorf("event %s is not a recurring event", eventID)
494+
}
495+
parentRecurrence = parent.Recurrence
496+
patch.Recurrence = parentRecurrence
497+
}
498+
if scope == scopeSingle || scope == scopeFuture {
485499
instanceID, resolveErr := resolveRecurringInstanceID(ctx, svc, calendarID, eventID, c.OriginalStartTime)
486500
if resolveErr != nil {
487501
return resolveErr
@@ -493,6 +507,16 @@ func (c *CalendarUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
493507
if err != nil {
494508
return err
495509
}
510+
if scope == scopeFuture {
511+
truncated, truncateErr := truncateRecurrence(parentRecurrence, c.OriginalStartTime)
512+
if truncateErr != nil {
513+
return truncateErr
514+
}
515+
_, patchErr := svc.Events.Patch(calendarID, eventID, &calendar.Event{Recurrence: truncated}).Context(ctx).Do()
516+
if patchErr != nil {
517+
return patchErr
518+
}
519+
}
496520
if outfmt.IsJSON(ctx) {
497521
return outfmt.WriteJSON(os.Stdout, map[string]any{"event": updated})
498522
}
@@ -532,7 +556,9 @@ func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
532556
return usage("--original-start required when --scope=single")
533557
}
534558
case scopeFuture:
535-
return fmt.Errorf("scope=future is not supported yet")
559+
if strings.TrimSpace(c.OriginalStartTime) == "" {
560+
return usage("--original-start required when --scope=future")
561+
}
536562
case scopeAll:
537563
default:
538564
return fmt.Errorf("invalid scope: %q (must be single, future, or all)", scope)
@@ -542,6 +568,9 @@ func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
542568
if scope == scopeSingle {
543569
confirmMessage = fmt.Sprintf("delete event %s (instance start %s) from calendar %s", eventID, c.OriginalStartTime, calendarID)
544570
}
571+
if scope == scopeFuture {
572+
confirmMessage = fmt.Sprintf("delete event %s (instance start %s) and all following from calendar %s", eventID, c.OriginalStartTime, calendarID)
573+
}
545574
if confirmErr := confirmDestructive(ctx, flags, confirmMessage); confirmErr != nil {
546575
return confirmErr
547576
}
@@ -552,7 +581,18 @@ func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
552581
}
553582

554583
targetEventID := eventID
555-
if scope == scopeSingle {
584+
var parentRecurrence []string
585+
if scope == scopeFuture {
586+
parent, getErr := svc.Events.Get(calendarID, eventID).Context(ctx).Do()
587+
if getErr != nil {
588+
return getErr
589+
}
590+
if len(parent.Recurrence) == 0 {
591+
return fmt.Errorf("event %s is not a recurring event", eventID)
592+
}
593+
parentRecurrence = parent.Recurrence
594+
}
595+
if scope == scopeSingle || scope == scopeFuture {
556596
instanceID, resolveErr := resolveRecurringInstanceID(ctx, svc, calendarID, eventID, c.OriginalStartTime)
557597
if resolveErr != nil {
558598
return resolveErr
@@ -563,6 +603,16 @@ func (c *CalendarDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
563603
if err := svc.Events.Delete(calendarID, targetEventID).Do(); err != nil {
564604
return err
565605
}
606+
if scope == scopeFuture {
607+
truncated, truncateErr := truncateRecurrence(parentRecurrence, c.OriginalStartTime)
608+
if truncateErr != nil {
609+
return truncateErr
610+
}
611+
_, patchErr := svc.Events.Patch(calendarID, eventID, &calendar.Event{Recurrence: truncated}).Context(ctx).Do()
612+
if patchErr != nil {
613+
return patchErr
614+
}
615+
}
566616
if outfmt.IsJSON(ctx) {
567617
return outfmt.WriteJSON(os.Stdout, map[string]any{
568618
"deleted": true,
@@ -1010,6 +1060,76 @@ func originalStartRange(originalStart string) (string, string, error) {
10101060
return parsed.Format(time.RFC3339), parsed.Add(24 * time.Hour).Format(time.RFC3339), nil
10111061
}
10121062

1063+
func truncateRecurrence(rules []string, originalStart string) ([]string, error) {
1064+
if len(rules) == 0 {
1065+
return nil, fmt.Errorf("recurrence rules missing")
1066+
}
1067+
untilValue, err := recurrenceUntil(originalStart)
1068+
if err != nil {
1069+
return nil, err
1070+
}
1071+
1072+
updated := make([]string, 0, len(rules))
1073+
foundRule := false
1074+
for _, rule := range rules {
1075+
trimmed := strings.TrimSpace(rule)
1076+
upper := strings.ToUpper(trimmed)
1077+
if !strings.HasPrefix(upper, "RRULE") {
1078+
updated = append(updated, trimmed)
1079+
continue
1080+
}
1081+
foundRule = true
1082+
body := strings.TrimPrefix(trimmed, "RRULE:")
1083+
if body == trimmed {
1084+
body = strings.TrimPrefix(trimmed, "RRULE")
1085+
body = strings.TrimPrefix(body, ":")
1086+
}
1087+
parts := strings.Split(body, ";")
1088+
filtered := make([]string, 0, len(parts)+1)
1089+
for _, part := range parts {
1090+
part = strings.TrimSpace(part)
1091+
if part == "" {
1092+
continue
1093+
}
1094+
upperPart := strings.ToUpper(part)
1095+
if strings.HasPrefix(upperPart, "UNTIL=") || strings.HasPrefix(upperPart, "COUNT=") {
1096+
continue
1097+
}
1098+
filtered = append(filtered, part)
1099+
}
1100+
filtered = append(filtered, "UNTIL="+untilValue)
1101+
updated = append(updated, "RRULE:"+strings.Join(filtered, ";"))
1102+
}
1103+
if !foundRule {
1104+
return nil, fmt.Errorf("recurrence has no RRULE")
1105+
}
1106+
return updated, nil
1107+
}
1108+
1109+
func recurrenceUntil(originalStart string) (string, error) {
1110+
originalStart = strings.TrimSpace(originalStart)
1111+
if originalStart == "" {
1112+
return "", fmt.Errorf("original start time required")
1113+
}
1114+
if strings.Contains(originalStart, "T") {
1115+
parsed, err := time.Parse(time.RFC3339, originalStart)
1116+
if err != nil {
1117+
parsed, err = time.Parse(time.RFC3339Nano, originalStart)
1118+
}
1119+
if err != nil {
1120+
return "", fmt.Errorf("invalid original start time %q", originalStart)
1121+
}
1122+
until := parsed.Add(-time.Second).UTC()
1123+
return until.Format("20060102T150405Z"), nil
1124+
}
1125+
parsed, err := time.Parse("2006-01-02", originalStart)
1126+
if err != nil {
1127+
return "", fmt.Errorf("invalid original start date %q", originalStart)
1128+
}
1129+
until := parsed.AddDate(0, 0, -1)
1130+
return until.Format("20060102"), nil
1131+
}
1132+
10131133
func buildAttendees(csv string) []*calendar.EventAttendee {
10141134
addrs := splitCSV(csv)
10151135
if len(addrs) == 0 {

internal/cmd/calendar_test.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,41 @@ func TestParseAttendee(t *testing.T) {
179179
})
180180
}
181181
}
182+
183+
func TestRecurrenceUntil(t *testing.T) {
184+
got, err := recurrenceUntil("2025-01-10")
185+
if err != nil {
186+
t.Fatalf("unexpected error: %v", err)
187+
}
188+
if got != "20250109" {
189+
t.Fatalf("unexpected until date: %s", got)
190+
}
191+
192+
got, err = recurrenceUntil("2025-01-10T12:00:00Z")
193+
if err != nil {
194+
t.Fatalf("unexpected error: %v", err)
195+
}
196+
if got != "20250110T115959Z" {
197+
t.Fatalf("unexpected until datetime: %s", got)
198+
}
199+
}
200+
201+
func TestTruncateRecurrence(t *testing.T) {
202+
rules := []string{
203+
"RRULE:FREQ=WEEKLY;COUNT=10",
204+
"EXDATE:20250101T100000Z",
205+
}
206+
truncated, err := truncateRecurrence(rules, "2025-01-10T12:00:00Z")
207+
if err != nil {
208+
t.Fatalf("unexpected error: %v", err)
209+
}
210+
if len(truncated) != 2 {
211+
t.Fatalf("unexpected rule count: %#v", truncated)
212+
}
213+
if truncated[0] != "RRULE:FREQ=WEEKLY;UNTIL=20250110T115959Z" {
214+
t.Fatalf("unexpected RRULE: %s", truncated[0])
215+
}
216+
if truncated[1] != "EXDATE:20250101T100000Z" {
217+
t.Fatalf("unexpected EXDATE: %s", truncated[1])
218+
}
219+
}

0 commit comments

Comments
 (0)