diff --git a/e2e/events_repeating_test.go b/e2e/events_repeating_test.go index a518cda..c4cbae6 100644 --- a/e2e/events_repeating_test.go +++ b/e2e/events_repeating_test.go @@ -2,22 +2,20 @@ package e2e import ( + "fmt" "testing" "time" "github.com/git-calendar/core/pkg/core" "github.com/google/uuid" + rrule "github.com/teambition/rrule-go" ) func TestRepeatingEvent_GetEvents_UntilWeekly_GeneratesOccurrencesInRange(t *testing.T) { c := newTestCore(t) startTime := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour*4, core.Repetition{ - Frequency: core.Week, - Interval: 1, - Until: startTime.AddDate(1, 0, 0), - }) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour*4, recurrenceUntilEndOfDay(t, startTime, "WEEKLY", startTime.AddDate(1, 0, 0))) stored := requireEvent(t, c, parent.Id) if !stored.From.Equal(parent.From) { @@ -38,14 +36,19 @@ func TestRepeatingEvent_GetEvents_CountWeekly_GeneratesExactCount(t *testing.T) const count = 6 startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour*4, core.Repetition{ - Frequency: core.Week, - Interval: 1, - Count: count, - }) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour*4, recurrenceWithCount(t, startTime, "WEEKLY", count)) events := c.GetEvents(startTime.Add(-time.Hour), startTime.AddDate(0, 0, count*7+1), nil) - assertEventStarts(t, events, + for _, event := range events { + if event.Repeat == nil { + t.Fatalf("generated child %s should expose its parent's recurrence", event.Id) + } + if event.Repeat == parent.Repeat { + t.Fatalf("generated child %s should not expose the stored recurrence pointer", event.Id) + } + } + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 7), startTime.AddDate(0, 0, 14), @@ -60,14 +63,11 @@ func TestRepeatingEvent_Remove_Current_AddsParentExceptionAndHidesChild(t *testi const count = 6 startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - }) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", count)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 1), startTime.AddDate(0, 0, 2), @@ -82,7 +82,7 @@ func TestRepeatingEvent_Remove_Current_AddsParentExceptionAndHidesChild(t *testi } storedParent := requireEvent(t, c, parent.Id) - assertParentHasException(t, storedParent, removed.Id) + assertParentHasException(t, storedParent, removed.From) events = c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) assertNoEventAt(t, events, removed.From) @@ -95,11 +95,7 @@ func TestRepeatingEvent_Remove_Current_RejectsParentEvent(t *testing.T) { c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: 3, - }) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", 3)) err := c.RemoveRepeatingEvent(parent, core.Current) if err == nil { @@ -112,11 +108,7 @@ func TestRepeatingEvent_Remove_Current_RemovesOnlyTargetChild(t *testing.T) { const count = 5 startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parent := createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - }) + parent := createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", count)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) target := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) @@ -127,10 +119,11 @@ func TestRepeatingEvent_Remove_Current_RemovesOnlyTargetChild(t *testing.T) { } storedParent := requireEvent(t, c, parent.Id) - assertParentHasException(t, storedParent, target.Id) + assertParentHasException(t, storedParent, target.From) events = c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 1), startTime.AddDate(0, 0, 3), @@ -144,11 +137,7 @@ func TestRepeatingEvent_Remove_Following_RemovesTargetAndFollowingChildren(t *te const count = 5 startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - _ = createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - }) + _ = createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", count)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) target := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) @@ -159,7 +148,8 @@ func TestRepeatingEvent_Remove_Following_RemovesTargetAndFollowingChildren(t *te } events = c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 1), ) @@ -174,11 +164,7 @@ func TestRepeatingEvent_Remove_All_RemovesWholeSeries(t *testing.T) { const count = 5 startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - _ = createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - }) + _ = createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", count)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) target := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) @@ -199,16 +185,12 @@ func TestRepeatingEvent_Update_Current_DetachesOnlyTargetChild(t *testing.T) { const count = 5 startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parent := createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - }) + parent := createRepeatingEvent(t, c, "Daily event", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", count)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) target := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) - updated := cloneEvent(target) + updated := cloneEvent(t, target) updated.Title = "Daily event - update" updated.From = target.From.Add(time.Hour) updated.To = target.To.Add(time.Hour) @@ -219,10 +201,11 @@ func TestRepeatingEvent_Update_Current_DetachesOnlyTargetChild(t *testing.T) { } storedParent := requireEvent(t, c, parent.Id) - assertParentHasException(t, storedParent, target.Id) + assertParentHasException(t, storedParent, target.From) events = c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 1), startTime.AddDate(0, 0, 2).Add(time.Hour), @@ -247,11 +230,7 @@ func TestRepeatingEvent_Update_StrategiesRejectParentEvents(t *testing.T) { c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: 3, - }) + parent := createRepeatingEvent(t, c, "Repeating Event", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", 3)) tests := []struct { name string @@ -264,7 +243,7 @@ func TestRepeatingEvent_Update_StrategiesRejectParentEvents(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - updated := cloneEvent(parent) + updated := cloneEvent(t, parent) updated.Title = "Should fail" _, err := c.UpdateRepeatingEvent(parent, updated, tt.strategy) @@ -286,11 +265,7 @@ func TestRepeatingEvent_Update_Following_SplitsSeriesFromTargetChild(t *testing. Title: "Daily Meeting", From: startTime, To: startTime.Add(time.Hour), - Repeat: &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 1, 0), - }, + Repeat: recurrenceUntilEndOfDay(t, startTime, "DAILY", startTime.AddDate(0, 1, 0)), } createEvent(t, c, parent) @@ -298,13 +273,9 @@ func TestRepeatingEvent_Update_Following_SplitsSeriesFromTargetChild(t *testing. target := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) previous := requireEventAt(t, events, startTime.AddDate(0, 0, 1)) - updated := cloneEvent(target) + updated := cloneEvent(t, target) updated.Title = "Daily Meeting - New Phase" - updated.Repeat = &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 1, 0), - } + updated.Repeat = recurrenceUntilEndOfDay(t, updated.From, "DAILY", startTime.AddDate(0, 1, 0)) newParent, err := c.UpdateRepeatingEvent(target, updated, core.Following) if err != nil { @@ -325,11 +296,10 @@ func TestRepeatingEvent_Update_Following_SplitsSeriesFromTargetChild(t *testing. if oldParent.Repeat == nil { t.Fatalf("old parent should still repeat before the split") } - if !oldParent.Repeat.Until.Equal(dateOnly(previous.From)) { - t.Fatalf("old parent Until mismatch: expected %s, got %s", dateOnly(previous.From), oldParent.Repeat.Until) - } - if oldParent.Repeat.Count != 0 { - t.Fatalf("old parent Count should be reset to 0, got %d", oldParent.Repeat.Count) + wantRRule := recurrenceUntil(t, oldParent.From, "DAILY", previous.From).GetRRule().OrigOptions.RRuleString() + gotRRule := oldParent.Repeat.GetRRule().OrigOptions.RRuleString() + if gotRRule != wantRRule { + t.Fatalf("old parent RRULE mismatch: expected %q, got %q", wantRRule, gotRRule) } } @@ -344,24 +314,16 @@ func TestRepeatingEvent_Update_Following_SecondChild_DoesNotLeaveInvalidOldParen Title: "Daily Meeting", From: startTime, To: startTime.Add(time.Hour), - Repeat: &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 1, 0), - }, + Repeat: recurrenceUntilEndOfDay(t, startTime, "DAILY", startTime.AddDate(0, 1, 0)), } createEvent(t, c, parent) events := c.GetEvents(startTime, startTime.AddDate(0, 0, 21), nil) target := requireEventAt(t, events, startTime.AddDate(0, 0, 1)) - updated := cloneEvent(target) + updated := cloneEvent(t, target) updated.Title = "Daily Meeting - New Phase" - updated.Repeat = &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 1, 0), - } + updated.Repeat = recurrenceUntilEndOfDay(t, updated.From, "DAILY", startTime.AddDate(0, 1, 0)) newParent, err := c.UpdateRepeatingEvent(target, updated, core.Following) if err != nil { @@ -372,8 +334,8 @@ func TestRepeatingEvent_Update_Following_SecondChild_DoesNotLeaveInvalidOldParen } oldParent := requireEvent(t, c, parentId) - if oldParent.Repeat != nil && !oldParent.Repeat.Until.After(oldParent.From) { - t.Fatalf("old parent has invalid repetition boundary: From=%s Until=%s", oldParent.From, oldParent.Repeat.Until) + if oldParent.Repeat != nil { + t.Fatalf("old parent should not repeat when only its first occurrence remains; got RRULE %q", oldParent.Repeat.GetRRule().OrigOptions.RRuleString()) } } @@ -381,14 +343,10 @@ func TestRepeatingEvent_Update_Following_CarriesAndShiftsFutureExceptions(t *tes c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 0, 10), - }) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, recurrenceUntilEndOfDay(t, startTime, "DAILY", startTime.AddDate(0, 0, 10))) events := c.GetEvents(startTime, startTime.AddDate(0, 0, 6), nil) - second := cloneEvent(requireEventAt(t, events, startTime.AddDate(0, 0, 1))) + second := cloneEvent(t, requireEventAt(t, events, startTime.AddDate(0, 0, 1))) fourth := requireEventAt(t, events, startTime.AddDate(0, 0, 3)) if err := c.RemoveRepeatingEvent(fourth, core.Current); err != nil { @@ -396,14 +354,10 @@ func TestRepeatingEvent_Update_Following_CarriesAndShiftsFutureExceptions(t *tes } shift := time.Hour - updatedSecond := cloneEvent(second) + updatedSecond := cloneEvent(t, second) updatedSecond.From = second.From.Add(shift) updatedSecond.To = second.To.Add(shift) - updatedSecond.Repeat = &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 0, 10), - } + updatedSecond.Repeat = recurrenceUntilEndOfDay(t, updatedSecond.From, "DAILY", startTime.AddDate(0, 0, 10)) _, err := c.UpdateRepeatingEvent(second, updatedSecond, core.Following) if err != nil { @@ -411,7 +365,8 @@ func TestRepeatingEvent_Update_Following_CarriesAndShiftsFutureExceptions(t *tes } events = c.GetEvents(startTime, startTime.AddDate(0, 0, 5), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 1).Add(shift), startTime.AddDate(0, 0, 2).Add(shift), @@ -424,17 +379,13 @@ func TestRepeatingEvent_Update_Following_FirstChild_ShiftBackDoesNotKeepOriginal c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: 3, - }) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", 3)) events := c.GetEvents(startTime.Add(-time.Hour), startTime.AddDate(0, 0, 3), nil) first := requireEventAt(t, events, startTime) shift := -2 * time.Hour - updatedFirst := cloneEvent(first) + updatedFirst := cloneEvent(t, first) updatedFirst.From = first.From.Add(shift) updatedFirst.To = first.To.Add(shift) @@ -444,7 +395,8 @@ func TestRepeatingEvent_Update_Following_FirstChild_ShiftBackDoesNotKeepOriginal } events = c.GetEvents(startTime.Add(shift), startTime.AddDate(0, 0, 3), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime.Add(shift), startTime.AddDate(0, 0, 1).Add(shift), startTime.AddDate(0, 0, 2).Add(shift), @@ -456,27 +408,18 @@ func TestRepeatingEvent_Update_Following_TitleOnlyKeepsFutureDeletedChildHidden( c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 0, 10), - }) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, recurrenceUntilEndOfDay(t, startTime, "DAILY", startTime.AddDate(0, 0, 10))) events := c.GetEvents(startTime, startTime.AddDate(0, 0, 6), nil) - second := cloneEvent(requireEventAt(t, events, startTime.AddDate(0, 0, 1))) + second := cloneEvent(t, requireEventAt(t, events, startTime.AddDate(0, 0, 1))) fourth := requireEventAt(t, events, startTime.AddDate(0, 0, 3)) if err := c.RemoveRepeatingEvent(fourth, core.Current); err != nil { t.Fatalf("failed to remove fourth child: %v", err) } - updatedSecond := cloneEvent(second) + updatedSecond := cloneEvent(t, second) updatedSecond.Title = "Daily Standup - New Phase" - updatedSecond.Repeat = &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 0, 10), - } _, err := c.UpdateRepeatingEvent(second, updatedSecond, core.Following) if err != nil { @@ -484,7 +427,8 @@ func TestRepeatingEvent_Update_Following_TitleOnlyKeepsFutureDeletedChildHidden( } events = c.GetEvents(startTime, startTime.AddDate(0, 0, 5), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 1), startTime.AddDate(0, 0, 2), @@ -497,14 +441,10 @@ func TestRepeatingEvent_Update_Following_SplitsExceptionsAtOriginalTargetTime(t c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 0, 10), - }) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, recurrenceUntilEndOfDay(t, startTime, "DAILY", startTime.AddDate(0, 0, 10))) events := c.GetEvents(startTime, startTime.AddDate(0, 0, 6), nil) - second := cloneEvent(requireEventAt(t, events, startTime.AddDate(0, 0, 1))) + second := cloneEvent(t, requireEventAt(t, events, startTime.AddDate(0, 0, 1))) third := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) if err := c.RemoveRepeatingEvent(third, core.Current); err != nil { @@ -512,14 +452,10 @@ func TestRepeatingEvent_Update_Following_SplitsExceptionsAtOriginalTargetTime(t } shift := 72 * time.Hour - updatedSecond := cloneEvent(second) + updatedSecond := cloneEvent(t, second) updatedSecond.From = second.From.Add(shift) updatedSecond.To = second.To.Add(shift) - updatedSecond.Repeat = &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Until: startTime.AddDate(0, 0, 10), - } + updatedSecond.Repeat = recurrenceUntilEndOfDay(t, updatedSecond.From, "DAILY", startTime.AddDate(0, 0, 10)) _, err := c.UpdateRepeatingEvent(second, updatedSecond, core.Following) if err != nil { @@ -538,14 +474,10 @@ func TestRepeatingEvent_Update_Following_CountSeriesKeepsRemainingSlotsAfterFutu const count = 5 startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - }) + createRepeatingEvent(t, c, "Daily Standup", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", count)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, count+1), nil) - second := cloneEvent(requireEventAt(t, events, startTime.AddDate(0, 0, 1))) + second := cloneEvent(t, requireEventAt(t, events, startTime.AddDate(0, 0, 1))) fourth := requireEventAt(t, events, startTime.AddDate(0, 0, 3)) if err := c.RemoveRepeatingEvent(fourth, core.Current); err != nil { @@ -553,14 +485,10 @@ func TestRepeatingEvent_Update_Following_CountSeriesKeepsRemainingSlotsAfterFutu } shift := time.Hour - updatedSecond := cloneEvent(second) + updatedSecond := cloneEvent(t, second) updatedSecond.From = second.From.Add(shift) updatedSecond.To = second.To.Add(shift) - updatedSecond.Repeat = &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - } + updatedSecond.Repeat = recurrenceWithCount(t, updatedSecond.From, "DAILY", count) _, err := c.UpdateRepeatingEvent(second, updatedSecond, core.Following) if err != nil { @@ -568,7 +496,8 @@ func TestRepeatingEvent_Update_Following_CountSeriesKeepsRemainingSlotsAfterFutu } events = c.GetEvents(startTime, startTime.AddDate(0, 0, count+1), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 1).Add(shift), startTime.AddDate(0, 0, 2).Add(shift), @@ -581,17 +510,13 @@ func TestRepeatingEvent_Update_All_ShiftsWholeSeriesFromChild(t *testing.T) { c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - parent := createRepeatingEvent(t, c, "Monthly Review", startTime, time.Hour, core.Repetition{ - Frequency: core.Month, - Interval: 1, - Count: 5, - }) + parent := createRepeatingEvent(t, c, "Monthly Review", startTime, time.Hour, recurrenceWithCount(t, startTime, "MONTHLY", 5)) events := c.GetEvents(startTime, startTime.AddDate(0, 6, 0), nil) first := requireEventAt(t, events, startTime) shift := 2 * time.Hour - updated := cloneEvent(first) + updated := cloneEvent(t, first) updated.From = first.From.Add(shift) updated.To = first.To.Add(shift) updated.Title = "Monthly Review - Shifted" @@ -610,7 +535,8 @@ func TestRepeatingEvent_Update_All_ShiftsWholeSeriesFromChild(t *testing.T) { } events = c.GetEvents(startTime.Add(shift), startTime.AddDate(0, 6, 0).Add(shift), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime.Add(shift), startTime.AddDate(0, 1, 0).Add(shift), startTime.AddDate(0, 2, 0).Add(shift), @@ -623,11 +549,7 @@ func TestRepeatingEvent_Update_All_ShiftsExceptionsAndKeepsDeletedChildHidden(t c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - createRepeatingEvent(t, c, "Repeating", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: 3, - }) + createRepeatingEvent(t, c, "Repeating", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", 3)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, 3), nil) middle := requireEventAt(t, events, startTime.AddDate(0, 0, 1)) @@ -640,7 +562,7 @@ func TestRepeatingEvent_Update_All_ShiftsExceptionsAndKeepsDeletedChildHidden(t first := requireEventAt(t, events, startTime) shift := -time.Hour - updated := cloneEvent(first) + updated := cloneEvent(t, first) updated.From = first.From.Add(shift) updated.To = first.To.Add(shift) updated.Title = "Repeating - Shifted" @@ -651,7 +573,8 @@ func TestRepeatingEvent_Update_All_ShiftsExceptionsAndKeepsDeletedChildHidden(t } events = c.GetEvents(startTime.Add(shift), startTime.AddDate(0, 0, 3), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime.Add(shift), startTime.AddDate(0, 0, 2).Add(shift), ) @@ -662,11 +585,7 @@ func TestRepeatingEvent_Update_All_RepeatRuleChangeKeepsStoredExceptions(t *test c := newTestCore(t) startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) - createRepeatingEvent(t, c, "Repeating", startTime, time.Hour, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: 3, - }) + createRepeatingEvent(t, c, "Repeating", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", 3)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, 3), nil) middle := requireEventAt(t, events, startTime.AddDate(0, 0, 1)) @@ -679,16 +598,12 @@ func TestRepeatingEvent_Update_All_RepeatRuleChangeKeepsStoredExceptions(t *test first := requireEventAt(t, events, startTime) shift := -time.Hour - updated := cloneEvent(first) + updated := cloneEvent(t, first) updated.From = first.From.Add(shift) updated.To = first.To.Add(shift) updated.Title = "Repeating - Shifted" - updated.Repeat = &core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: 4, - Exceptions: []uuid.UUID{}, - } + updated.Repeat = recurrenceWithCount(t, updated.From, "DAILY", 4) + updated.Repeat.SetExDates(nil) _, err := c.UpdateRepeatingEvent(first, updated, core.All) if err != nil { @@ -696,7 +611,8 @@ func TestRepeatingEvent_Update_All_RepeatRuleChangeKeepsStoredExceptions(t *test } events = c.GetEvents(startTime.Add(shift), startTime.AddDate(0, 0, 5), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime.Add(shift), startTime.AddDate(0, 0, 2).Add(shift), startTime.AddDate(0, 0, 3).Add(shift), @@ -711,11 +627,7 @@ func TestRepeatingEvent_RemoveFollowingThenUpdateAllShiftTime_DoesNotLoseEvents( startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) duration := time.Hour - createRepeatingEvent(t, c, "Daily event", startTime, duration, core.Repetition{ - Frequency: core.Day, - Interval: 1, - Count: count, - }) + createRepeatingEvent(t, c, "Daily event", startTime, duration, recurrenceWithCount(t, startTime, "DAILY", count)) events := c.GetEvents(startTime, startTime.AddDate(0, 0, count), nil) third := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) @@ -732,13 +644,9 @@ func TestRepeatingEvent_RemoveFollowingThenUpdateAllShiftTime_DoesNotLoseEvents( newFrom := second.From.Add(-time.Hour) newTo := second.To.Add(-time.Hour) - // Model this the way a real caller would build a child update: - // only the mutable fields, no Repeat field carried over — children - // don't own the repeat rule, the parent does. newEvent := second newEvent.From = newFrom newEvent.To = newTo - newEvent.Repeat = nil updated, err := c.UpdateRepeatingEvent(second, newEvent, core.All) if err != nil { @@ -759,6 +667,57 @@ func TestRepeatingEvent_RemoveFollowingThenUpdateAllShiftTime_DoesNotLoseEvents( assertEventStarts(t, events, expectedFirst, expectedSecond) } +func TestRepeatingEvent_Update_Following_ToNeverStopsAtSelectedChild(t *testing.T) { + c := newTestCore(t) + + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + createRepeatingEvent(t, c, "Daily", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", 5)) + + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 5), nil) + selected := requireEventAt(t, events, startTime.AddDate(0, 0, 2)) + updated := cloneEvent(t, selected) + updated.Title = "No longer recurring" + updated.Repeat = nil + + basic, err := c.UpdateRepeatingEvent(selected, updated, core.Following) + if err != nil { + t.Fatalf("failed to stop recurrence from selected child: %v", err) + } + if basic.ParentId != nil || basic.Repeat != nil { + t.Fatalf("selected child should become basic: %+v", basic) + } + + events = c.GetEvents(startTime, startTime.AddDate(0, 0, 5), nil) + assertEventStarts(t, events, startTime, startTime.AddDate(0, 0, 1), startTime.AddDate(0, 0, 2)) + if got := requireEventAt(t, events, startTime.AddDate(0, 0, 2)).Title; got != updated.Title { + t.Fatalf("title = %q; want %q", got, updated.Title) + } +} + +func TestRepeatingEvent_Update_All_ToNeverConvertsParentToBasic(t *testing.T) { + c := newTestCore(t) + + startTime := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + parent := createRepeatingEvent(t, c, "Daily", startTime, time.Hour, recurrenceWithCount(t, startTime, "DAILY", 3)) + first := requireEventAt(t, c.GetEvents(startTime, startTime.AddDate(0, 0, 3), nil), startTime) + updated := cloneEvent(t, first) + updated.Repeat = nil + + basic, err := c.UpdateRepeatingEvent(first, updated, core.All) + if err != nil { + t.Fatalf("failed to stop recurrence for all children: %v", err) + } + if basic.Repeat != nil { + t.Fatal("parent should no longer repeat") + } + if stored := requireEvent(t, c, parent.Id); stored.Repeat != nil { + t.Fatal("stored parent should be basic") + } + + events := c.GetEvents(startTime, startTime.AddDate(0, 0, 3), nil) + assertEventStarts(t, events, startTime) +} + func TestEvent_Update_StandardToRepeating_GeneratesChildren(t *testing.T) { c := newTestCore(t) @@ -774,11 +733,7 @@ func TestEvent_Update_StandardToRepeating_GeneratesChildren(t *testing.T) { updated := event updated.Title = "Weekly meeting" - updated.Repeat = &core.Repetition{ - Frequency: core.Week, - Interval: 1, - Count: 3, - } + updated.Repeat = recurrenceWithCount(t, updated.From, "WEEKLY", 3) _, err := c.UpdateEvent(updated) if err != nil { @@ -786,7 +741,8 @@ func TestEvent_Update_StandardToRepeating_GeneratesChildren(t *testing.T) { } events := c.GetEvents(startTime, startTime.AddDate(0, 1, 0), nil) - assertEventStarts(t, events, + assertEventStarts( + t, events, startTime, startTime.AddDate(0, 0, 7), startTime.AddDate(0, 0, 14), @@ -796,8 +752,10 @@ func TestEvent_Update_StandardToRepeating_GeneratesChildren(t *testing.T) { if stored.Repeat == nil { t.Fatalf("updated parent should repeat") } - if stored.Repeat.Count != 3 { - t.Fatalf("updated parent Count mismatch: expected 3, got %d", stored.Repeat.Count) + wantRRule := recurrenceWithCount(t, stored.From, "WEEKLY", 3).GetRRule().OrigOptions.RRuleString() + gotRRule := stored.Repeat.GetRRule().OrigOptions.RRuleString() + if gotRRule != wantRRule { + t.Fatalf("updated parent RRULE mismatch: expected %q, got %q", wantRRule, gotRRule) } } @@ -816,7 +774,7 @@ func newTestCore(t *testing.T) *core.Core { return c } -func createRepeatingEvent(t *testing.T, c *core.Core, title string, from time.Time, duration time.Duration, repeat core.Repetition) core.Event { +func createRepeatingEvent(t *testing.T, c *core.Core, title string, from time.Time, duration time.Duration, repeat *rrule.Set) core.Event { t.Helper() event := core.Event{ @@ -825,7 +783,7 @@ func createRepeatingEvent(t *testing.T, c *core.Core, title string, from time.Ti Title: title, From: from, To: from.Add(duration), - Repeat: &repeat, + Repeat: repeat, } createEvent(t, c, event) @@ -887,24 +845,28 @@ func assertNoEventAt(t *testing.T, events []core.Event, from time.Time) { } } -func assertParentHasException(t *testing.T, parent core.Event, exception uuid.UUID) { +func assertParentHasException(t *testing.T, parent core.Event, exception time.Time) { t.Helper() if parent.Repeat == nil { t.Fatalf("parent %s should be repeating", parent.Id) } - if !containsUUID(parent.Repeat.Exceptions, exception) { - t.Fatalf("parent %s does not contain exception %s; exceptions: %v", parent.Id, exception, parent.Repeat.Exceptions) + exceptions := parent.Repeat.GetExDate() + if !containsTime(exceptions, exception) { + t.Fatalf("parent %s does not contain exception %s; exceptions: %v", parent.Id, exception, exceptions) } } -func cloneEvent(event core.Event) core.Event { +func cloneEvent(t *testing.T, event core.Event) core.Event { + t.Helper() cloned := event if event.Repeat != nil { - repeat := *event.Repeat - repeat.Exceptions = append([]uuid.UUID(nil), event.Repeat.Exceptions...) - cloned.Repeat = &repeat + repeat, err := rrule.StrToRRuleSet(event.Repeat.String()) + if err != nil { + t.Fatalf("failed to clone recurrence: %v", err) + } + cloned.Repeat = repeat } return cloned @@ -920,9 +882,9 @@ func findEventByFrom(events []core.Event, from time.Time) (core.Event, bool) { return core.Event{}, false } -func containsUUID(ids []uuid.UUID, id uuid.UUID) bool { - for _, cur := range ids { - if cur == id { +func containsTime(times []time.Time, want time.Time) bool { + for _, current := range times { + if current.Equal(want) { return true } } @@ -939,10 +901,39 @@ func eventStarts(events []core.Event) []time.Time { return starts } -func dateOnly(t time.Time) time.Time { - return time.Date( - t.Year(), t.Month(), t.Day(), - 0, 0, 0, 0, - t.Location(), +func recurrenceWithCount(t *testing.T, start time.Time, frequency string, count int) *rrule.Set { + t.Helper() + return recurrence(t, start, fmt.Sprintf("FREQ=%s;INTERVAL=1;COUNT=%d", frequency, count)) +} + +func recurrenceUntil(t *testing.T, start time.Time, frequency string, until time.Time) *rrule.Set { + t.Helper() + return recurrence(t, start, fmt.Sprintf("FREQ=%s;INTERVAL=1;UNTIL=%s", frequency, until.UTC().Format("20060102T150405Z"))) +} + +func recurrenceUntilEndOfDay(t *testing.T, start time.Time, frequency string, day time.Time) *rrule.Set { + t.Helper() + until := time.Date( + day.Year(), day.Month(), day.Day(), + 23, 59, 59, 0, + day.Location(), ) + return recurrenceUntil(t, start, frequency, until) +} + +func recurrence(t *testing.T, start time.Time, value string) *rrule.Set { + t.Helper() + option, err := rrule.StrToROptionInLocation(value, start.Location()) + if err != nil { + t.Fatalf("failed to parse RRULE %q: %v", value, err) + } + option.Dtstart = start + + rule, err := rrule.NewRRule(*option) + if err != nil { + t.Fatalf("failed to create RRULE %q: %v", value, err) + } + set := &rrule.Set{} + set.RRule(rule) + return set } diff --git a/go.mod b/go.mod index 54dd573..22f0c86 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jedisct1/go-aes-siv v1.0.0 github.com/rdleal/intervalst v1.5.0 + github.com/teambition/rrule-go v1.8.2 golang.org/x/crypto v0.53.0 ) diff --git a/go.sum b/go.sum index e503ba8..7ae3420 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= diff --git a/pkg/api/api.go b/pkg/api/api.go index 65c8910..e0eaf75 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -15,6 +15,7 @@ import ( "github.com/git-calendar/core/pkg/core" "github.com/google/uuid" + "github.com/teambition/rrule-go" ) const ( @@ -27,6 +28,13 @@ type Api struct { inner *core.Core } +// eventJSON is the API representation of an event. Recurrence is transported +// as an RFC 5545 string because rrule.Set is not JSON-serializable. +type eventJSON struct { + core.Event + Repeat *string `json:"repeat"` +} + // A "constructor" for the JSON API. func NewApi() *Api { return &Api{ @@ -87,47 +95,34 @@ func (a *Api) UpdateEvent(eventJson string) (string, error) { } func (a *Api) UpdateRepeatingEvent(oldEventJson, newEventJson string, strategy int) (string, error) { - var oldEvent core.Event - var newEvent core.Event - - if err := json.Unmarshal([]byte(oldEventJson), &oldEvent); err != nil { - fmt.Printf("CalendarCore got:\nNew: %s\nOld: %s\n", oldEventJson, newEventJson) - return emptyJson, fmt.Errorf("failed to unmarshal event data: %w", err) - } - - if err := json.Unmarshal([]byte(newEventJson), &newEvent); err != nil { - fmt.Printf("CalendarCore got:\nNew: %s\nOld: %s\n", oldEventJson, newEventJson) - return emptyJson, fmt.Errorf("failed to unmarshal event data: %w", err) + oldEvent, err := unmarshalEvent(oldEventJson) + if err != nil { + return emptyJson, err } - - updatedEvent, err := a.inner.UpdateRepeatingEvent(oldEvent, newEvent, core.UpdateStrategy(strategy)) + newEvent, err := unmarshalEvent(newEventJson) if err != nil { - fmt.Printf("CalendarCore got:\nNew: %s\nOld: %s\n", oldEventJson, newEventJson) return emptyJson, err } - jsonBytes, err := json.Marshal(updatedEvent) + updated, err := a.inner.UpdateRepeatingEvent(oldEvent, newEvent, core.UpdateStrategy(strategy)) if err != nil { return emptyJson, err } - - return string(jsonBytes), err + return marshalEvent(updated) } func (a *Api) RemoveEvent(eventJson string) error { - var event core.Event - err := json.Unmarshal([]byte(eventJson), &event) + event, err := unmarshalEvent(eventJson) if err != nil { - return fmt.Errorf("failed to unmarshal event data: %w", err) + return err } return a.inner.RemoveEvent(event) } func (a *Api) RemoveRepeatingEvent(eventJson string, strategy int) error { - var event core.Event - err := json.Unmarshal([]byte(eventJson), &event) + event, err := unmarshalEvent(eventJson) if err != nil { - return fmt.Errorf("failed to unmarshal event data: %w", err) + return err } return a.inner.RemoveRepeatingEvent(event, core.UpdateStrategy(strategy)) } @@ -143,13 +138,7 @@ func (a *Api) GetEvent(id string) (string, error) { return emptyJson, err } - // marshal to json - jsonBytes, err := json.Marshal(event) - if err != nil { - return emptyJson, fmt.Errorf("failed to marshal event to json: %w", err) - } - - return string(jsonBytes), nil + return marshalEvent(event) } func (a *Api) GetEvents(from, to string, filterJson string) (string, error) { @@ -170,13 +159,7 @@ func (a *Api) GetEvents(from, to string, filterJson string) (string, error) { // pass the args to inner api events := a.inner.GetEvents(f, t, filter) - // marshal to json - jsonBytes, err := json.Marshal(events) - if err != nil { - return emptyJsonArr, fmt.Errorf("failed to marshal events to json: %w", err) - } - - return string(jsonBytes), nil + return marshalEvents(events) } func (a *Api) CreateTag(calendar, tagJson string) (string, error) { @@ -234,29 +217,63 @@ func (a *Api) RemoveTag(calendar, id string) error { // ------------------------------------------------ Helpers ------------------------------------------------- -// A helper which: -// 1. Parses and validates input event -// 2. Calls the coreFunc -// 3. Marshals event that came back to JSON -// 4. Returns json func returnJsonEventAndError(eventJson string, coreFunc func(core.Event) (*core.Event, error)) (string, error) { - var event core.Event - err := json.Unmarshal([]byte(eventJson), &event) + event, err := unmarshalEvent(eventJson) + if err != nil { + return emptyJson, err + } + updated, err := coreFunc(event) if err != nil { - fmt.Println("CalendarCore got: ", eventJson) - return emptyJson, fmt.Errorf("failed to unmarshal event data: %w", err) + return emptyJson, err + } + return marshalEvent(updated) +} + +// unmarshalEvent decodes API JSON and rebuilds the internal recurrence set. +func unmarshalEvent(raw string) (core.Event, error) { + var data eventJSON + if err := json.Unmarshal([]byte(raw), &data); err != nil { + return core.Event{}, fmt.Errorf("failed to unmarshal event data: %w", err) + } + + var repeat *rrule.Set + if data.Repeat != nil && *data.Repeat != "" { + var err error + repeat, err = rrule.StrToRRuleSet(*data.Repeat) + if err != nil { + return core.Event{}, fmt.Errorf("invalid recurrence: %w", err) + } } + data.Event.Repeat = repeat + return data.Event, nil +} - newEvent, err := coreFunc(event) +// marshalEvent converts the recurrence set to its string form and encodes the event. +func marshalEvent(event *core.Event) (string, error) { + data, err := json.Marshal(eventToJSON(*event)) if err != nil { - fmt.Println("CalendarCore got: ", eventJson) - return emptyJson, err + return emptyJson, fmt.Errorf("failed to marshal event data: %w", err) } + return string(data), nil +} - jsonBytes, err := json.Marshal(newEvent) +func marshalEvents(events []core.Event) (string, error) { + result := make([]eventJSON, len(events)) + for i := range events { + result[i] = eventToJSON(events[i]) + } + data, err := json.Marshal(result) if err != nil { - return emptyJson, err + return emptyJsonArr, fmt.Errorf("failed to marshal event data: %w", err) } + return string(data), nil +} - return string(jsonBytes), err +func eventToJSON(event core.Event) eventJSON { + var repeat *string + if event.Repeat != nil { + value := event.Repeat.String() + repeat = &value + } + return eventJSON{Event: event, Repeat: repeat} } diff --git a/pkg/api/models.go b/pkg/api/models.go index ae71bc7..3a9594c 100644 --- a/pkg/api/models.go +++ b/pkg/api/models.go @@ -13,15 +13,7 @@ type Event struct { Calendar string TagId string ParentId string - Repeat *Repetition -} - -type Repetition struct { - Frequency int - Interval int - Until string - Count int - Exceptions []string + Repeat string // DTSTART/RRULE/EXDATE in RFC 5545 format. } // A DTO for Kotlin/Swift to use as the calendar structure. diff --git a/pkg/core/constants.go b/pkg/core/constants.go index 4c90680..063d00b 100644 --- a/pkg/core/constants.go +++ b/pkg/core/constants.go @@ -1,37 +1,17 @@ package core const ( - // IndexFileName string = "index.json" - // RichIndexFileName string = "index-rich.json" - EventsDirName string = "events" - TagsDirName = "tags" + TagsDirName string = "tags" GitAuthorName string = "git-calendar" GitRemoteName string = "origin" GitBranchName string = "main" -) - -// ------- Repeating frequency ------- - -// Repeating frequency. -type Freq int -const ( - Invalid Freq = iota // ints default value 0 is invalid - Day // Repeat daily. - Week // Repeat weekly. - Month // Repeat monthly. - Year // Repeat yearly. - _max // boundary for validation + // IndexFileName string = "index.json" + // RichIndexFileName string = "index-rich.json" ) -func (t Freq) IsValid() bool { - return t > Invalid && t <= _max -} - -// ------- Repeating update strategy ------- - type UpdateStrategy int const ( diff --git a/pkg/core/core_events.go b/pkg/core/core_events.go index 9c6d4d9..90f7e0d 100644 --- a/pkg/core/core_events.go +++ b/pkg/core/core_events.go @@ -10,6 +10,7 @@ import ( gogit "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/object" "github.com/google/uuid" + rrule "github.com/teambition/rrule-go" ) // Creates a new event and save it into git. @@ -132,7 +133,7 @@ func (c *Core) UpdateRepeatingEvent(old, new Event, strat UpdateStrategy) (*Even switch strat { case Current: - return c.updateCurrentChild(&new) + return c.updateCurrentChild(&old, &new) case Following: return c.updateFollowingChildren(&old, &new) case All: @@ -231,42 +232,25 @@ func (c *Core) GetEvents(from, to time.Time, filter GetEventsFilter) []Event { continue } - eventDuration := curEvent.To.Sub(curEvent.From) - firstStart, index := firstOccurrenceAtOrAfter(from, curEvent) - - if firstStart.IsZero() { - continue // no occurrences >= from + repeat, err := rrule.StrToRRuleSet(curEvent.Repeat.String()) + if err != nil { + continue } - - for firstStart.Before(to) { // while child event fits in the wanted interval - // logic when repeating until - if curEvent.Repeat.Count == 0 && dateAfter(firstStart, curEvent.Repeat.Until) { - break // new event exceeded the repetition end (Until) - } - // logic for repeating only N times (count) - if curEvent.Repeat.Count != 0 && index >= curEvent.Repeat.Count { - break // new event exceeded the max count of child events - } - - index++ - child := Event{ - Id: generateCustomUUID(curEvent.Id, firstStart), + starts := recurrenceBetween(curEvent.Repeat, from, to) + eventDuration := curEvent.To.Sub(curEvent.From) + for _, start := range starts { + result = append(result, Event{ + Id: generateCustomUUID(curEvent.Id, start), Title: curEvent.Title, Location: curEvent.Location, Description: curEvent.Description, - From: firstStart, - To: firstStart.Add(eventDuration), + From: start, + To: start.Add(eventDuration), Calendar: curEvent.Calendar, TagId: curEvent.TagId, ParentId: &curEvent.Id, - Repeat: curEvent.Repeat, - } - // ignore exceptions - if !slices.Contains(curEvent.Repeat.Exceptions, child.Id) { - result = append(result, child) - } - - firstStart = addUnit(firstStart, curEvent.Repeat.Interval, curEvent.Repeat.Frequency) // next occurrence + Repeat: repeat, + }) } } } @@ -274,20 +258,17 @@ func (c *Core) GetEvents(from, to time.Time, filter GetEventsFilter) []Event { return result } -// ------------------------------------------------ Helpers ------------------------------------------------- - -// Updates single generated/child event by adding it to its Parent repeat exceptions and creating a brand new event instead. -func (c *Core) updateCurrentChild(updated *Event) (*Event, error) { +// updateCurrentChild updates one generated child by excluding it and creating a detached event. +func (c *Core) updateCurrentChild(original, updated *Event) (*Event, error) { parent, ok := c.events[*updated.ParentId] // we check nil pointer in UpdateRepeatingEvent if !ok || parent == nil || !parent.IsParent() { return nil, errors.New("no valid parent found") } if parent.Repeat == nil { - return nil, errors.New("parent is not a repeating event, WTF") + return nil, errors.New("parent is not a repeating event") } - // update parent event with the new exception - parent.Repeat.Exceptions = append(parent.Repeat.Exceptions, updated.Id) + parent.Repeat.ExDate(original.From) if err := c.saveAndCommitEvent(parent, fmt.Sprintf("Added exception to parent %q", parent.Id)); err != nil { return nil, fmt.Errorf("failed to save parent event: %w", err) } @@ -301,153 +282,78 @@ func (c *Core) updateCurrentChild(updated *Event) (*Event, error) { return c.CreateEvent(detachedEvent) // save as new } -// updateFollowingChildren splits the time series into two by stopping the original parent event from repeating further and creating brand new parent with updated properties. +// updateFollowingChildren splits a series at the selected child. func (c *Core) updateFollowingChildren(old, new *Event) (*Event, error) { - parent, ok := c.events[*old.ParentId] // we check nil pointer in UpdateRepeatingEvent + parent, ok := c.events[*old.ParentId] if !ok || parent == nil || !parent.IsParent() { return nil, errors.New("no valid parent found") } - if parent.Repeat == nil { - return nil, errors.New("parent is not a repeating event") - } - - // keep originals for rollback - originalRepeat := *parent.Repeat - originalExceptions := append([]uuid.UUID(nil), parent.Repeat.Exceptions...) - // find position in the series - _, splitIndex := firstOccurrenceAtOrAfter(old.From, parent) - if splitIndex == -1 { - return nil, fmt.Errorf("could not find occurrence at or after %s", old.From) + originalRepeat := parent.Repeat + before, after, index, err := splitRecurrence(originalRepeat, old.From, new.From, new.Repeat) + if err != nil { + return nil, fmt.Errorf("failed to split recurrence: %w", err) } - - fromDiff := new.From.Sub(old.From) - - // editing the first occurrence -> update entire series - if splitIndex == 0 { + if index == 0 { return c.updateAllChildren(old, new) } - // split exceptions before and after the edited occurrence - exBefore, exAfter := splitExceptions(originalExceptions, old.From) - - // remove parent before we mutate it if err := c.intervalTree.RemoveEvent(*parent); err != nil { return nil, fmt.Errorf("failed to remove parent from interval tree: %w", err) } - - // ------------ cap the original parent ------------ - previousStart := addUnit(old.From, -originalRepeat.Interval, originalRepeat.Frequency) - if !previousStart.After(parent.From) { - // only one occurrence left -> turn it into a basic (non-repeating) event - parent.Repeat = nil - } else { - // keep repeating but stop before the split point - capped := originalRepeat - capped.Until = dateOnly(previousStart) - capped.Count = 0 - capped.Exceptions = exBefore - parent.Repeat = &capped - } - - // save the capped parent + parent.Repeat = before if err := c.intervalTree.InsertEvent(*parent); err != nil { - parent.Repeat = &originalRepeat - parent.Repeat.Exceptions = originalExceptions + parent.Repeat = originalRepeat + if rollbackErr := c.intervalTree.InsertEvent(*parent); rollbackErr != nil { + return nil, fmt.Errorf("failed to reinsert capped parent: %w; rollback failed too: %v", err, rollbackErr) + } return nil, fmt.Errorf("failed to reinsert capped parent: %w", err) } - if err := c.saveAndCommitEvent(parent, fmt.Sprintf("Capped parent event %q", parent.Id)); err != nil { + _ = c.intervalTree.RemoveEvent(*parent) + parent.Repeat = originalRepeat + _ = c.intervalTree.InsertEvent(*parent) return nil, fmt.Errorf("failed to commit capped parent: %w", err) } - // ------------ create the new continuing series ------------ newEvent := *new newEvent.Id = uuid.New() - newEvent.ParentId = nil // this is now its own parent - - if newEvent.Repeat != nil { - repeat := *newEvent.Repeat - repeat.Exceptions = nil - - // adjust count for remaining occurrences - if originalRepeat.Count != 0 { - remaining := originalRepeat.Count - splitIndex - if remaining < 1 { - remaining = 1 - } - repeat.Count = remaining - } - - // shift exceptions to the new parent - for _, ex := range exAfter { - exTime := getTimeFromUUID(ex).Add(fromDiff) - repeat.Exceptions = append(repeat.Exceptions, generateCustomUUID(newEvent.Id, exTime)) - } - - newEvent.Repeat = &repeat - } + newEvent.ParentId = nil + newEvent.Repeat = after created, err := c.CreateEvent(newEvent) - if err != nil { - // rollback: restore original parent - _ = c.intervalTree.RemoveEvent(*parent) - parent.Repeat = &originalRepeat - parent.Repeat.Exceptions = originalExceptions - - if rbErr := c.intervalTree.InsertEvent(*parent); rbErr != nil { - return nil, fmt.Errorf("failed to create new series: %w; rollback failed too: %v", err, rbErr) - } - if rbErr := c.saveAndCommitEvent(parent, fmt.Sprintf("rolled back parent %q", parent.Id)); rbErr != nil { - return nil, fmt.Errorf("failed to create new series: %w; rollback failed too: %v", err, rbErr) - } - return nil, fmt.Errorf("failed to create new continuing series: %w", err) + if err == nil { + return created, nil } - return created, nil + _ = c.intervalTree.RemoveEvent(*parent) + parent.Repeat = originalRepeat + if rollbackErr := c.intervalTree.InsertEvent(*parent); rollbackErr != nil { + return nil, fmt.Errorf("failed to create new series: %w; rollback failed too: %v", err, rollbackErr) + } + if rollbackErr := c.saveAndCommitEvent(parent, fmt.Sprintf("Rolled back parent %q", parent.Id)); rollbackErr != nil { + return nil, fmt.Errorf("failed to create new series: %w; rollback failed too: %v", err, rollbackErr) + } + return nil, fmt.Errorf("failed to create new continuing series: %w", err) } -// Updates the entire repeating series by only modifying the parent. That means all generated child events get updated as well. -// Both old and new arguments are child events. +// updateAllChildren applies a child update to its parent series. func (c *Core) updateAllChildren(old, new *Event) (*Event, error) { - parent, ok := c.events[*old.ParentId] // we check nil pointer in UpdateRepeatingEvent + parent, ok := c.events[*old.ParentId] if !ok || parent == nil || !parent.IsParent() { return nil, errors.New("no valid parent found") } + original := *parent fromDiff := new.From.Sub(old.From) toDiff := new.To.Sub(old.To) - - fromChanged := fromDiff != 0 - toChanged := toDiff != 0 - repeatChanged := repeatRuleChanged(parent.Repeat, new.Repeat) - - needsReindex := fromChanged || toChanged || repeatChanged - - if needsReindex { - if err := c.intervalTree.RemoveEvent(*parent); err != nil { - return nil, fmt.Errorf("failed to remove parent from interval tree: %w", err) - } - } - - var exceptions []uuid.UUID - if parent.Repeat != nil { - exceptions = append(exceptions, parent.Repeat.Exceptions...) // copy - } - - // shift all exceptions by the time fromDiff - if fromChanged { - for i := range exceptions { - exceptions[i] = getShiftedUUID(exceptions[i], fromDiff) - } + updatedRepeat, err := shiftRecurrence(original.Repeat, parent.From, parent.From.Add(fromDiff), new.Repeat) + if err != nil { + return nil, fmt.Errorf("failed to update recurrence: %w", err) } - if new.Repeat != nil { - repeat := *new.Repeat - repeat.Exceptions = exceptions - parent.Repeat = &repeat - } else if parent.Repeat != nil { - parent.Repeat.Exceptions = exceptions + if err := c.intervalTree.RemoveEvent(*parent); err != nil { + return nil, fmt.Errorf("failed to remove parent from interval tree: %w", err) } parent.Title = new.Title @@ -457,92 +363,61 @@ func (c *Core) updateAllChildren(old, new *Event) (*Event, error) { parent.To = parent.To.Add(toDiff) parent.TagId = new.TagId parent.Calendar = new.Calendar + parent.Repeat = updatedRepeat - if needsReindex { - if err := c.intervalTree.InsertEvent(*parent); err != nil { - return nil, fmt.Errorf("failed to reinsert parent: %w", err) - } + if err := c.intervalTree.InsertEvent(*parent); err != nil { + *parent = original + _ = c.intervalTree.InsertEvent(*parent) + return nil, fmt.Errorf("failed to reinsert parent: %w", err) } - if err := c.saveAndCommitEvent(parent, - fmt.Sprintf("Updated time series (parent %q)", parent.Id), - ); err != nil { + if err := c.saveAndCommitEvent(parent, fmt.Sprintf("Updated time series (parent %q)", parent.Id)); err != nil { return nil, fmt.Errorf("failed to save parent: %w", err) } - return parent, nil } func (c *Core) removeCurrentChild(event *Event) error { - parent, ok := c.events[*event.ParentId] // we check nil pointer in RemoveRepeatingEvent + parent, ok := c.events[*event.ParentId] if !ok || parent == nil || !parent.IsParent() { return errors.New("no valid parent found") } - - // if exception doesn't exist yet - if !slices.Contains(parent.Repeat.Exceptions, event.Id) { - // add date to parent exceptions - newException := event.Id - parent.Repeat.Exceptions = append(parent.Repeat.Exceptions, newException) - - // update/overwrite the file in repo - err := c.saveAndCommitEvent(parent, fmt.Sprintf("Updated event %q", event.Id)) - if err != nil { - return fmt.Errorf("failed to save event to repo: %w", err) - } + parent.Repeat.ExDate(event.From) + if parent.Repeat.After(parent.From, true).IsZero() { + return c.RemoveEvent(*parent) } - - // TODO: finish this - // cleanup the Parent if all Children are in Exceptions - // either (Count != 0 and Count = len(Exceptions)) or TODO: hard to know from the Until - if (parent.Repeat.Count != 0 && len(parent.Repeat.Exceptions) == parent.Repeat.Count) || (!parent.Repeat.Until.IsZero() && false) { // ughhh - err := c.deleteAndCommitEvent(parent.Id, fmt.Sprintf("Deleted event %q", parent.Id)) - if err != nil { - return fmt.Errorf("failed to delete event from git: %w", err) - } - delete(c.events, parent.Id) + if err := c.saveAndCommitEvent(parent, fmt.Sprintf("Updated event %q", event.Id)); err != nil { + return fmt.Errorf("failed to save event to repo: %w", err) } - return nil } func (c *Core) removeFollowingChildren(event *Event) error { - parent, ok := c.events[*event.ParentId] // we check nil pointer in RemoveRepeatingEvent + parent, ok := c.events[*event.ParentId] if !ok || parent == nil || !parent.IsParent() { return errors.New("no valid parent found") } - - // if its the first occurance, we might as well delete all if parent.From.Equal(event.From) { return c.removeAllChildren(event) } - // remove the parent from the tree using its CURRENT (pre-mutation) state + capped, err := capRecurrenceBefore(parent.Repeat, event.From) + if err != nil { + return fmt.Errorf("failed to cap recurrence: %w", err) + } + original := parent.Repeat if err := c.intervalTree.RemoveEvent(*parent); err != nil { return fmt.Errorf("failed to remove parent from interval tree: %w", err) } - - // ------------ cap the original parent ------------ - previousStart := addUnit(event.From, -parent.Repeat.Interval, parent.Repeat.Frequency) - if !previousStart.After(parent.From) { - // only one occurrence left -> turn it into a basic (non-repeating) event - parent.Repeat = nil - } else { - // keep repeating but stop before the deleted one - parent.Repeat.Until = dateOnly(previousStart) // the From time of the last not deleteded occurance - parent.Repeat.Count = 0 - } - + parent.Repeat = capped if err := c.intervalTree.InsertEvent(*parent); err != nil { + parent.Repeat = original + _ = c.intervalTree.InsertEvent(*parent) return fmt.Errorf("failed to reinsert parent into interval tree: %w", err) } - - // update parent file in repo - err := c.saveAndCommitEvent(parent, fmt.Sprintf("Updated event %q", event.Id)) - if err != nil { + if err := c.saveAndCommitEvent(parent, fmt.Sprintf("Updated event %q", event.Id)); err != nil { return fmt.Errorf("failed to save event to repo: %w", err) } - return nil } @@ -555,7 +430,7 @@ func (c *Core) removeAllChildren(event *Event) error { return c.RemoveEvent(*parent) } -// Serializes event to JSON, saves to file, stages and commits with given message. +// saveAndCommitEvent serializes event to JSON, saves to file, stages and commits with given message. func (c *Core) saveAndCommitEvent(event *Event, commitMsg string) error { event.UpdatedAt = time.Now() // force new time @@ -615,7 +490,7 @@ func (c *Core) saveAndCommitEvent(event *Event, commitMsg string) error { return nil } -// Removes event from filesystem and commits the change. +// deleteAndCommitEvent removes event from filesystem and commits the change. func (c *Core) deleteAndCommitEvent(eventId uuid.UUID, commitMsg string) error { event, ok := c.events[eventId] if !ok { diff --git a/pkg/core/event.go b/pkg/core/event.go index ae5a98f..fa06fdf 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -6,6 +6,7 @@ import ( "time" "github.com/google/uuid" + rrule "github.com/teambition/rrule-go" ) // Event represents a single calendar entry. @@ -15,29 +16,17 @@ import ( // 2. Parent: The "source of truth" for a recurring series (ParentId is nil, Repeat defines the rule). // 3. Child: A generated occurrence from a Parent (ParentId points to its Parent, Repeat copies the Parent rule). type Event struct { - Id uuid.UUID `json:"id"` // Should not change (different id = different event). Only UUIDv4 or UUIDv8 (for children) is being used. - Title string `json:"title"` // Should not be empty. - Location string `json:"location"` // Physical or virtual location (e.g., URL). - Description string `json:"description"` - From time.Time `json:"from"` - To time.Time `json:"to"` - Calendar string `json:"calendar"` // The name of the calendar the event belongs to. - TagId *uuid.UUID `json:"tag_id"` // A user-defined tag/category. Can be nil. - ParentId *uuid.UUID `json:"parent_id"` // Specific for child events. It is nil (not uuid.Nil) if the event is basic or parent. - Repeat *Repetition `json:"repeat"` - UpdatedAt time.Time `json:"-"` // Used for git conflict resolution; latest wins. Client doesn't need to see this -> json:"-". -} - -// Repetition defines the recurrence rules for a Parent event. -// -// A Repetition object exists only on Parent events to generate Children. -// A series must be capped by either Until (date) or Count (occurrences). Not both. -type Repetition struct { - Frequency Freq `json:"frequency"` // The unit of time for recurrence (Day, Week, Month, etc.). - Interval int `json:"interval"` // The multiplier for Frequency (e.g., Interval:2 * Frequency:Week = every other week). - Until time.Time `json:"until"` // Hard stop date for the series. It should just be a date, with time zeroed out (2026-01-01T00:00:00Z). If not, time should be ignored. It is inclusive. - Count int `json:"count"` // Total number of occurrences to generate. - Exceptions []uuid.UUID `json:"exceptions"` // List of Child IDs that deviate from the base rule (edited or cancelled). + Id uuid.UUID `json:"id"` // Should not change (different id = different event). Only UUIDv4 or UUIDv8 (for children) is being used. + Title string `json:"title"` // Should not be empty. + Location string `json:"location"` // Physical or virtual location (e.g., URL). + Description string `json:"description"` + From time.Time `json:"from"` + To time.Time `json:"to"` + Calendar string `json:"calendar"` // The name of the calendar the event belongs to. + TagId *uuid.UUID `json:"tag_id"` // A user-defined tag/category. Can be nil. + ParentId *uuid.UUID `json:"parent_id"` // Specific for child events. It is nil (not uuid.Nil) if the event is basic or parent. + Repeat *rrule.Set `json:"-"` // Internal recurrence set; API and file layers serialize it as RFC 5545 text. + UpdatedAt time.Time `json:"-"` // Used for git conflict resolution; latest wins. Client doesn't need to see this -> json:"-". } func (e *Event) Validate() error { @@ -61,32 +50,11 @@ func (e *Event) Validate() error { if e.From.Compare(e.To) != -1 { return errors.New("From timestamp cannot be greater or equal than To (cannot end before it starts)") } - if err := e.Repeat.Validate(); err != nil { - return fmt.Errorf("repetition is invalid: %w", err) - } - return nil -} - -func (r *Repetition) Validate() error { - if r == nil { - return nil - } - if !r.Frequency.IsValid() { - return errors.New("frequency is invalid") - } - if r.Interval < 1 { - return errors.New("interval is invalid") - } - if r.Until.IsZero() && r.Count < 1 { - return errors.New("combination of Until & Count is invalid") - } - if !r.Until.IsZero() && r.Count > 0 { - return errors.New("Count must be 0 when Until date is set") - } - if !r.Until.IsZero() { - r.Until = dateOnly(r.Until) // normalize to zeroed time + if e.ParentId == nil { + if err := validateRecurrence(e.Repeat, e.From); err != nil { + return fmt.Errorf("recurrence is invalid: %w", err) + } } - return nil } @@ -102,22 +70,15 @@ func (e Event) IsParent() bool { return e.ParentId == nil && e.Repeat != nil } -// Returns either the To time.Time for Basic non-repeating event, or calculates the last occurrence of a repeating Parent event and returns its To. +// getTreeEndTime returns the end of the final generated child. func (e Event) getTreeEndTime() time.Time { if e.Repeat == nil { return e.To } - eventEnd := e.To - if e.Repeat != nil { - eventEnd = e.Repeat.Until // if repeating, use interval [From, Repetition.Until T 23:59:59] - if !eventEnd.IsZero() { - // bump to end of that calendar day so the tree key covers the full last occurrence, not just midnight - eventEnd = endOfDay(eventEnd) - } - if e.Repeat.Count >= 1 { // if repeating on count basis - eventEnd = addUnit(e.To, e.Repeat.Interval*e.Repeat.Count, e.Repeat.Frequency) - } + last := recurrenceLast(e.Repeat) + if last.IsZero() { + return e.To } - return eventEnd + return last.Add(e.To.Sub(e.From)) } diff --git a/pkg/core/event_file.go b/pkg/core/event_file.go index 7fc4248..1a12e77 100644 --- a/pkg/core/event_file.go +++ b/pkg/core/event_file.go @@ -12,22 +12,31 @@ import ( "github.com/git-calendar/core/pkg/encryption" "github.com/go-git/go-billy/v5" "github.com/google/uuid" + rrule "github.com/teambition/rrule-go" ) // eventInFile represents event inside file. It doesn't have an Id and Calendar fields, since they can be derrived from the file path/name itself. type eventInFile struct { - Title string `json:"title,omitzero"` - Location string `json:"location,omitzero"` - Description string `json:"description,omitzero"` - From time.Time `json:"from,omitzero"` - To time.Time `json:"to,omitzero"` - TagId *uuid.UUID `json:"tag_id,omitzero"` - ParentId *uuid.UUID `json:"parent_id,omitzero"` - Repeat *Repetition `json:"repeat,omitzero"` - UpdatedAt time.Time `json:"updated_at,omitzero"` + Title string `json:"title,omitzero"` + Location string `json:"location,omitzero"` + Description string `json:"description,omitzero"` + From time.Time `json:"from,omitzero"` + To time.Time `json:"to,omitzero"` + TagId *uuid.UUID `json:"tag_id,omitzero"` + ParentId *uuid.UUID `json:"parent_id,omitzero"` + Repeat string `json:"repeat,omitzero"` + UpdatedAt time.Time `json:"updated_at,omitzero"` } -func (ef eventInFile) toEvent(id uuid.UUID, calendar string) Event { +func (ef eventInFile) toEvent(id uuid.UUID, calendar string) (Event, error) { + var repeat *rrule.Set + if ef.Repeat != "" { + var err error + repeat, err = rrule.StrToRRuleSet(ef.Repeat) + if err != nil { + return Event{}, fmt.Errorf("invalid recurrence: %w", err) + } + } return Event{ Id: id, Title: ef.Title, @@ -38,12 +47,16 @@ func (ef eventInFile) toEvent(id uuid.UUID, calendar string) Event { Calendar: calendar, TagId: ef.TagId, ParentId: ef.ParentId, - Repeat: ef.Repeat, + Repeat: repeat, UpdatedAt: ef.UpdatedAt, - } + }, nil } func (e Event) fileData() eventInFile { + var repeat string + if e.Repeat != nil { + repeat = e.Repeat.String() + } return eventInFile{ Title: e.Title, Location: e.Location, @@ -52,7 +65,7 @@ func (e Event) fileData() eventInFile { To: e.To, TagId: e.TagId, ParentId: e.ParentId, - Repeat: e.Repeat, + Repeat: repeat, UpdatedAt: e.UpdatedAt, } } @@ -110,36 +123,29 @@ func (e *Event) LoadFromBytes(raw []byte, name string, calendar string, decrypti return err } - var data eventInFile - - if len(decryptionKey) == 0 { // no encryption, just use the plaintext - if err := json.Unmarshal(raw, &data); err != nil { + if len(decryptionKey) != 0 { + var encrypted map[string]any + if err := json.Unmarshal(raw, &encrypted); err != nil { + return err + } + decrypted, err := encryption.DecryptFields(encrypted, decryptionKey, id[:]) + if err != nil { + return err + } + raw, err = json.Marshal(decrypted) + if err != nil { return err } - - *e = data.toEvent(id, calendar) - return nil - } - - var encrypted map[string]any - if err := json.Unmarshal(raw, &encrypted); err != nil { - return err } - decrypted, err := encryption.DecryptFields(encrypted, decryptionKey, id[:]) - if err != nil { + var data eventInFile + if err := json.Unmarshal(raw, &data); err != nil { return err } - - // eww (map to struct conversion) - tmp, err := json.Marshal(decrypted) + loaded, err := data.toEvent(id, calendar) if err != nil { return err } - if err := json.Unmarshal(tmp, &data); err != nil { - return err - } - - *e = data.toEvent(id, calendar) + *e = loaded return nil } diff --git a/pkg/core/recurrence.go b/pkg/core/recurrence.go new file mode 100644 index 0000000..1c0d32e --- /dev/null +++ b/pkg/core/recurrence.go @@ -0,0 +1,146 @@ +package core + +import ( + "errors" + "fmt" + "slices" + "time" + + rrule "github.com/teambition/rrule-go" +) + +func validateRecurrence(set *rrule.Set, dtstart time.Time) error { + if set == nil { + return nil + } + set.DTStart(dtstart) + rule := set.GetRRule() + if rule == nil { + return errors.New("recurrence requires an RRULE") + } + option := rule.OrigOptions + if option.Count == 0 && option.Until.IsZero() { + return errors.New("RRULE requires COUNT or UNTIL") + } + if option.Count != 0 && !option.Until.IsZero() { + return errors.New("RRULE cannot contain both COUNT and UNTIL") + } + return nil +} + +func recurrenceBetween(set *rrule.Set, from, to time.Time) []time.Time { + times := set.Between(from, to, true) + if len(times) != 0 && times[len(times)-1].Equal(to) { + times = times[:len(times)-1] + } + return times +} + +func recurrenceIndex(set *rrule.Set, at time.Time) (int, bool) { + rule := set.GetRRule() + times := rule.Between(rule.GetDTStart(), at, true) + if len(times) == 0 || !times[len(times)-1].Equal(at.Truncate(time.Second)) { + return -1, false + } + return len(times) - 1, true +} + +func recurrenceLast(set *rrule.Set) time.Time { + times := set.GetRRule().All() + if len(times) == 0 { + return time.Time{} + } + return times[len(times)-1] +} + +func capRecurrenceBefore(set *rrule.Set, cutoff time.Time) (*rrule.Set, error) { + rule := set.GetRRule() + previous := rule.Before(cutoff, false) + if previous.IsZero() { + return nil, fmt.Errorf("no occurrence before %s", cutoff) + } + + option := rule.OrigOptions + option.Count = 0 + option.Until = previous + before, _ := splitTimes(set.GetExDate(), cutoff) + + if previous.Equal(rule.GetDTStart()) && !containsTime(before, previous) { + return nil, nil + } + return newRecurrence(option, before) +} + +func splitRecurrence(set *rrule.Set, splitAt, newStart time.Time, replacement *rrule.Set) (*rrule.Set, *rrule.Set, int, error) { + index, ok := recurrenceIndex(set, splitAt) + if !ok { + return nil, nil, -1, fmt.Errorf("%s is not an occurrence", splitAt) + } + if index == 0 { + return nil, nil, 0, nil + } + before, err := capRecurrenceBefore(set, splitAt) + if err != nil || replacement == nil { + return before, nil, index, err + } + + option := replacement.GetRRule().OrigOptions + if count := set.GetRRule().OrigOptions.Count; count != 0 { + option.Count = max(count-index, 1) + option.Until = time.Time{} + } + + _, future := splitTimes(set.GetExDate(), splitAt) + shiftTimes(future, newStart.Sub(splitAt)) + after, err := newRecurrence(option, future) + if err != nil { + return nil, nil, -1, err + } + return before, after, index, validateRecurrence(after, newStart) +} + +func shiftRecurrence(set *rrule.Set, oldStart, newStart time.Time, replacement *rrule.Set) (*rrule.Set, error) { + if replacement == nil { + return nil, nil + } + next, err := rrule.StrToRRuleSet(replacement.String()) + if err != nil { + return nil, err + } + exceptions := slices.Clone(set.GetExDate()) + shiftTimes(exceptions, newStart.Sub(oldStart)) + next.SetExDates(exceptions) + return next, validateRecurrence(next, newStart) +} + +func newRecurrence(option rrule.ROption, exceptions []time.Time) (*rrule.Set, error) { + rule, err := rrule.NewRRule(option) + if err != nil { + return nil, err + } + set := &rrule.Set{} + set.RRule(rule) + set.SetExDates(exceptions) + return set, nil +} + +func splitTimes(times []time.Time, cutoff time.Time) (before, after []time.Time) { + for _, at := range times { + if at.Before(cutoff) { + before = append(before, at) + } else { + after = append(after, at) + } + } + return before, after +} + +func shiftTimes(times []time.Time, shift time.Duration) { + for i := range times { + times[i] = times[i].Add(shift) + } +} + +func containsTime(times []time.Time, want time.Time) bool { + return slices.ContainsFunc(times, func(at time.Time) bool { return at.Equal(want) }) +} diff --git a/pkg/core/utils.go b/pkg/core/utils.go index f154295..59bb129 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -6,7 +6,6 @@ import ( "fmt" "net/url" "path" - "reflect" "slices" "strings" "time" @@ -16,60 +15,6 @@ import ( "github.com/google/uuid" ) -func addUnit(t time.Time, value int, unit Freq) time.Time { - switch unit { - case Day: - return t.AddDate(0, 0, value) - case Week: - return t.AddDate(0, 0, 7*value) - case Month: - return t.AddDate(0, value, 0) - case Year: - return t.AddDate(value, 0, 0) - default: - return t - } -} - -// firstOccurrenceAtOrAfter returns the first start time >= searchStart (or zero time if none reasonable). -// Also returns how many steps from the original (0 = original event time). -func firstOccurrenceAtOrAfter(searchStart time.Time, ev *Event) (time.Time, int) { - if ev.Repeat == nil { - if !searchStart.After(ev.From) { - return ev.From, 0 - } - return time.Time{}, -1 // none in range - } - - current := ev.From - steps := 0 - const maxSteps = 36500 // safety limit (~100 years for freq=Daily) - - for current.Before(searchStart) && steps < maxSteps { - current = addUnit(current, ev.Repeat.Interval, ev.Repeat.Frequency) - steps++ - } - - if current.IsZero() || steps >= maxSteps { - return time.Time{}, -1 - } - - return current, steps -} - -func containsTime(exceptions []uuid.UUID, t time.Time) bool { - for _, ex := range exceptions { - exTime := getTimeFromUUID(ex) - if exTime.IsZero() { - continue - } - if exTime.Equal(t) { - return true - } - } - return false -} - // prepareRepoUrl extracts the auth (http://USER:PASS@example.com/...) from repoUrl and returns a new url using proxyUrl if present. func prepareRepoUrl(repoUrl *url.URL, proxyUrl *url.URL) (*url.URL, *http.BasicAuth) { if repoUrl == nil { @@ -197,54 +142,6 @@ func getTimeFromUUID(id uuid.UUID) time.Time { return time.Unix(int64(unix32), 0) } -// getShiftedUUID returns a copy of a UUIDv8 with its custom 32-bit timestamp (stored in bytes 12–15, big-endian) shifted by the given duration. -// Returns uuid.Nil if the input id is not v8. -func getShiftedUUID(id uuid.UUID, duration time.Duration) uuid.UUID { - if id.Version() != 8 { - return uuid.Nil - } - - // extract original timestamp (big-endian bytes 12-15) - origTime := binary.BigEndian.Uint32(id[12:16]) - - // calculate shift in whole seconds - secondsShift := int64(duration.Seconds()) - shiftedTime := uint32(int64(origTime) + secondsShift) - - // create new UUID and write back - newId := id // UUID is [16]byte, so this is a value copy - binary.BigEndian.PutUint32(newId[12:16], shiftedTime) - - return newId -} - -// splitExceptions returns two exceptions groups. One with exceptions before and one with exceptions after the specified cutoff. -func splitExceptions(exceptions []uuid.UUID, cutoff time.Time) (before, after []uuid.UUID) { - for _, ex := range exceptions { - if getTimeFromUUID(ex).Before(cutoff) { - before = append(before, ex) - } else { - after = append(after, ex) - } - } - return -} - -// repeatRuleChanged checks if Repetition differs. -func repeatRuleChanged(a, b *Repetition) bool { - if a == nil || b == nil { - return a != b - } - - aa := *a - bb := *b - - aa.Exceptions = nil - bb.Exceptions = nil - - return !reflect.DeepEqual(aa, bb) -} - type GetEventsFilter map[string][]uuid.UUID // map[Calendar.Name]Tag.Id func checkFilter(e *Event, f GetEventsFilter) bool { @@ -259,19 +156,3 @@ func checkFilter(e *Event, f GetEventsFilter) bool { return u == *e.TagId }) } - -func dateOnly(t time.Time) time.Time { - return time.Date( - t.Year(), t.Month(), t.Day(), - 0, 0, 0, 0, - t.Location(), - ) -} - -func dateAfter(a, b time.Time) bool { - return dateOnly(a).After(dateOnly(b)) -} - -func endOfDay(t time.Time) time.Time { - return dateOnly(t).Add(24*time.Hour - time.Nanosecond) -} diff --git a/pkg/core/utils_test.go b/pkg/core/utils_test.go index 3a973bb..7e4de14 100644 --- a/pkg/core/utils_test.go +++ b/pkg/core/utils_test.go @@ -11,75 +11,6 @@ import ( "github.com/google/uuid" ) -func TestAddUnit(t *testing.T) { - type args struct { - t time.Time - value int - unit Freq - } - tests := []struct { - name string - args args - want time.Time - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := addUnit(tt.args.t, tt.args.value, tt.args.unit); !cmp.Equal(tt.want, got) { - t.Errorf("addUnit() = %v, want %v\ndiff=%s", got, tt.want, cmp.Diff(tt.want, got)) - } - }) - } -} - -func TestFirstOccurrenceAtOrAfter(t *testing.T) { - type args struct { - searchStart time.Time - event *Event - } - tests := []struct { - name string - args args - want time.Time - want1 int - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, got1 := firstOccurrenceAtOrAfter(tt.args.searchStart, tt.args.event) - if !cmp.Equal(tt.want, got) { - t.Errorf("getFirstCandidate() got = %v, want %v\ndiff=%s", got, tt.want, cmp.Diff(tt.want, got)) - } - if got1 != tt.want1 { - t.Errorf("getFirstCandidate() got1 = %v, want %v", got1, tt.want1) - } - }) - } -} - -func TestContainsTime(t *testing.T) { - type args struct { - exceptions []uuid.UUID - t time.Time - } - tests := []struct { - name string - args args - want bool - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := containsTime(tt.args.exceptions, tt.args.t); got != tt.want { - t.Errorf("containsTime() = %v, want %v", got, tt.want) - } - }) - } -} - func TestPrepareRepoUrl(t *testing.T) { someProxyUrl := mustParseUrl("https://cors-proxy.abc") tests := []struct { @@ -255,13 +186,11 @@ func TestCustomUUIDs(t *testing.T) { name string parentId uuid.UUID t time.Time - shift time.Duration }{ { name: "basic", parentId: uuid.New(), // UUIDv4 t: time.Now().Round(time.Second), - shift: time.Hour, }, } for _, tt := range tests { @@ -271,12 +200,6 @@ func TestCustomUUIDs(t *testing.T) { if !cmp.Equal(tt.t, gotTime) { t.Errorf("getTimeFromUUID() = %v, want %v", gotTime, tt.t) } - - shiftedId := getShiftedUUID(gotId, tt.shift) - gotShiftedTime := getTimeFromUUID(shiftedId) - if !cmp.Equal(tt.t.Add(tt.shift), gotShiftedTime) { - t.Errorf("getTimeFromUUID() = %v, want %v", gotShiftedTime, tt.t.Add(tt.shift)) - } }) } }