Skip to content
106 changes: 106 additions & 0 deletions Ical.Net.Tests/RecurrenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4064,4 +4064,110 @@ public void AmbiguousLocalTime_WithShortDurationOfRecurrence()
Assert.That(occ[1].Period.EffectiveDuration, Is.EqualTo(new Duration(0, 0, 0, 45, 0)));
});
}

[Test]
[TestCase("20250101T120000", "20250101T100000", "20250103T100000")]
[TestCase("20250102T120000", "20250103T100000", "20250104T100000")]
// see https://github.com/ical-org/ical.net/issues/829
public void GetOccurrences_ShouldNotIgnoreExDatesForToday(string periodStart, string expected1, string expected2)
{
var cal = new CalendarEvent
{
Start = new CalDateTime("20250101T100000"),
End = new CalDateTime("20250101T200000"),
RecurrenceRules = [new RecurrencePattern("FREQ=DAILY")],
};

cal.ExceptionDates.Add(new CalDateTime("20250102"));

var occurrences = cal.GetOccurrences(new CalDateTime(periodStart))
.Take(2)
.Select(o => o.Period.StartTime)
.ToList();

var expectedDates = new[]
{
new CalDateTime(expected1),
new CalDateTime(expected2)
};

Assert.That(occurrences, Is.EqualTo(expectedDates));
}

[Test]
public void GetOccurrences_WithMixedKindExDates_ShouldProperlyConsiderAll()
{
var cal = new CalendarEvent
{
Start = new CalDateTime("20250702T100000"),
End = new CalDateTime("20250702T200000"),
RecurrenceRules = [new RecurrencePattern("FREQ=DAILY")],
};


// Should be considered only at the exact time
cal.ExceptionDates.Add(new CalDateTime("20250703T000000"));

// Should be considered all-day
cal.ExceptionDates.Add(new CalDateTime("20250703"));

// Should be considered only at the exact time
cal.ExceptionDates.Add(new CalDateTime("20250703T150000"));

var occurrences = cal.GetOccurrences()
.Take(2)
.Select(o => o.Period.StartTime)
.ToList();

var expectedDates = new[]
{
new CalDateTime("20250702T100000"),
new CalDateTime("20250704T100000")
};

Assert.That(occurrences, Is.EqualTo(expectedDates));
}

[Test]
public void GetOccurrences_WithMixedKindExDatesAndTz_ShouldProperlyConsiderAll()
{
var cal = Calendar.Load("""
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20250701T000000Z
DURATION:PT1H
RDATE;TZID=Etc/GMT+12:20250702T234500
RDATE;TZID=Etc/GMT-13:20250704T001500
RDATE:20250703T113000Z
EXDATE;VALUE=DATE:20250703
END:VEVENT
END:VCALENDAR
""")!;

var evt = cal.Events.Single();

var occurrences = cal.GetOccurrences()
.Select(o => o.Period.StartTime)
.ToList();

var expectedDates = new[]
{
new CalDateTime("20250701T000000", "UTC"),

// All-day EXDATEs are matched against the date in the RDATE's time zone,
// so 20250704 isn't excluded even though on Jul 3 in DTSTART's TZ.
new CalDateTime("20250704T001500", "Etc/GMT-13"),

// 20250703T113000 is on 20250703, so it should be removed according
// to our implementation that considers DATE-only EXDATEs as all-day.
// See https://github.com/ical-org/ical.net/pull/830 for more information.
// excluded: new CalDateTime("20250703T113000", "UTC"),

// same as above. All-day EXDATEs are matched against the date in the RDATE's time zone,
// so 20250702 isn't excluded even though on Jul 3 in DTSTART's TZ.
new CalDateTime("20250702T234500", "Etc/GMT+12"),
};

Assert.That(occurrences, Is.EqualTo(expectedDates));
}
}
47 changes: 24 additions & 23 deletions Ical.Net/Evaluation/RecurringEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ protected IEnumerable<Period> EvaluateRRule(CalDateTime referenceDate, CalDateTi
}

/// <summary> Evaluates the RDate component. </summary>
protected IEnumerable<Period> EvaluateRDate(CalDateTime referenceDate, CalDateTime? periodStart)
protected IEnumerable<Period> EvaluateRDate(CalDateTime? periodStart)
{
var recurrences = Recurrable.RecurrenceDates
.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime)
Expand Down Expand Up @@ -85,11 +85,11 @@ protected IEnumerable<Period> EvaluateExRule(CalDateTime referenceDate, CalDateT
/// <summary>
/// Evaluates the ExDate component.
/// </summary>
/// <param name="referenceDate"></param>
/// <param name="periodStart">The beginning date of the range to evaluate.</param>
protected IEnumerable<Period> EvaluateExDate(CalDateTime referenceDate, CalDateTime? periodStart)
/// <param name="periodKinds">The period kinds to be returned. Used as a filter.</param>
private IEnumerable<Period> EvaluateExDate(CalDateTime? periodStart, params PeriodKind[] periodKinds)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the signature and access modifier would mean a breaking change?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Puh, right, theoretically yes. But for this to be an issue, somebody would have to inherit Recurring Evaluator and then call this method. I really wouldn't expect this to be the case. Would wait for potential complaints.

{
var exDates = Recurrable.ExceptionDates.GetAllPeriodsByKind(PeriodKind.DateOnly, PeriodKind.DateTime)
var exDates = Recurrable.ExceptionDates.GetAllPeriodsByKind(periodKinds)
.AsEnumerable();

if (periodStart != null)
Expand All @@ -109,37 +109,38 @@ public override IEnumerable<Period> Evaluate(CalDateTime referenceDate, CalDateT
? [new Period(referenceDate)]
: EvaluateRRule(referenceDate, periodStart, options);

var rdateOccurrences = EvaluateRDate(referenceDate, periodStart);
var rdateOccurrences = EvaluateRDate(periodStart);

var exRuleExclusions = EvaluateExRule(referenceDate, periodStart, options);
var exDateExclusions = EvaluateExDate(referenceDate, periodStart);

// EXDATEs could contain date-only entries while DTSTART is date-time. Probably this isn't supported
// by the RFC, but it seems to be used in the wild (see https://github.com/ical-org/ical.net/issues/829).
// So we must make sure to return all-day EXDATEs that could overlap with recurrences, even if the day starts
// before `periodStart`. We therefore start 2 days earlier (2 for safety regarding the TZ).
var exDateExclusionsDateOnly = new HashSet<DateOnly>(EvaluateExDate(periodStart?.AddDays(-2), PeriodKind.DateOnly)
.Select(x => x.StartTime.Date));

var exDateExclusionsDateTime = EvaluateExDate(periodStart, PeriodKind.DateTime);

var periods =
rruleOccurrences
.OrderedMerge(rdateOccurrences)
.OrderedDistinct()
.OrderedExclude(exRuleExclusions)
.OrderedExclude(exDateExclusions, Comparer<Period>.Create(CompareExDateOverlap))
.OrderedExclude(exDateExclusionsDateTime)

// We accept date-only EXDATEs to be used with date-time DTSTARTs. In such cases we exclude those occurrences
// that, in their respective time zone, have a date component that matches an EXDATE.
// See https://github.com/ical-org/ical.net/pull/830 for more information.
//
// The order of dates in the EXDATEs doesn't necessarily match the order of dates returned by RDATEs
// due to RDATEs could have different time zones. We therefore use a regular `.Where()` to look up
// the EXDATEs in the HashSet rather than using `.OrderedExclude()`, which would require correct ordering.
.Where(dt => !exDateExclusionsDateOnly.Contains(dt.StartTime.Date))

// Convert overflow exceptions to expected ones.
.HandleEvaluationExceptions();

return periods;
}

/// <summary>
/// Compares whether the given period's date overlaps with the given EXDATE. The dates are
/// considered to overlap if they start at the same time, or the EXDATE is an all-day date
/// and the period's start date is the same as the EXDATE's date.
/// <para/>
/// Note: <see cref="Period.EffectiveDuration"/> for <paramref name="exDate"/> is always <see langword="null"/>.
/// </summary>
private static int CompareExDateOverlap(Period period, Period exDate)
{
var cmp = period.CompareTo(exDate);
if ((cmp != 0) && !exDate.StartTime.HasTime && (period.StartTime.Value.Date == exDate.StartTime.Value))
cmp = 0;

return cmp;
}
}
Loading