Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,8 @@ INSTANCES:20251226T000000,20251230T000000
RRULE:FREQ=SECONDLY;BYHOUR=1,2;BYMINUTE=3,4,59;BYSECOND=5,6;UNTIL=20250216T020500
DTSTART:20250216T015905
INSTANCES:20250216T015905,20250216T015906,20250216T020305,20250216T020306,20250216T020405,20250216T020406

# BYWEEKNO 1 - UNTIL and the last recurrence are in 2024 while the week is 1 of 2025.
RRULE:FREQ=YEARLY;BYWEEKNO=1;BYDAY=MO;UNTIL=20241230
DTSTART:20240101
INSTANCES:20240101,20241230
112 changes: 62 additions & 50 deletions Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ private RecurrencePattern ProcessRecurrencePattern(CalDateTime referenceDate)
/// For example, if the search start date (start) is Wed, Mar 23, 12:19PM, but the recurrence is Mon - Fri, 9:00AM - 5:00PM,
/// the start dates returned should all be at 9:00AM, and not 12:19PM.
/// </summary>
private IEnumerable<CalDateTime> GetDates(CalDateTime seed, CalDateTime? periodStart, CalDateTime? periodEnd, int maxCount, RecurrencePattern pattern,
private IEnumerable<CalDateTime> GetDates(CalDateTime seed, CalDateTime? periodStart, CalDateTime? periodEnd, RecurrencePattern pattern,
EvaluationOptions options)
{
// In the first step, we work with DateTime values, so we need to convert the CalDateTime to DateTime
Expand Down Expand Up @@ -136,82 +136,94 @@ private IEnumerable<CalDateTime> GetDates(CalDateTime seed, CalDateTime? periodS
// Do the enumeration in a separate method, as it is a generator method that is
// only executed after enumeration started. In order to do most validation upfront,
// do as many steps outside the generator as possible.
return EnumerateDates(originalDate, seedCopy, periodStartDt, periodEndDt, maxCount, pattern, options);
return EnumerateDates(originalDate, seedCopy, periodStartDt, periodEndDt, pattern, options);
}

private IEnumerable<CalDateTime> EnumerateDates(CalDateTime originalDate, CalDateTime intervalRefTime, CalDateTime? periodStart, CalDateTime? periodEnd, int maxCount, RecurrencePattern pattern, EvaluationOptions options)
private IEnumerable<CalDateTime> EnumerateDates(CalDateTime originalDate, CalDateTime intervalRefTime, CalDateTime? periodStart, CalDateTime? periodEnd, RecurrencePattern pattern, EvaluationOptions options)
{
var expandBehavior = RecurrenceUtil.GetExpandBehaviorList(pattern);

// This value is only used for performance reasons to stop incrementing after
// until is passed, even if no recurrences are being found.
// As a safe heuristic we add 1d to the UNTIL value to cover any time shift and DST changes.
// It's just important that we don't miss any recurrences, not that we stop exactly at UNTIL.
// Precise UNTIL handling is done outside this method after TZ conversion.
var coarseUntil = pattern.Until?.AddDays(1);
var searchEndDate = GetSearchEndDate(periodEnd, pattern);

var noCandidateIncrementCount = 0;

var dateCount = 0;
while (maxCount < 0 || dateCount < maxCount)
while (true)
{
if (intervalRefTime > coarseUntil)
{
break;
}

if (dateCount >= pattern.Count)
{
break;
}

//No need to continue if the seed is after the periodEnd
if (intervalRefTime > periodEnd)
{
//No need to continue if the interval's lower limit is after the periodEnd
if (searchEndDate < GetIntervalLowerLimit(intervalRefTime, pattern))
break;
}

var candidates = GetCandidates(intervalRefTime, pattern, expandBehavior);
if (candidates.Count > 0)

foreach (var t in candidates.Where(t => t >= originalDate))
{
noCandidateIncrementCount = 0;

foreach (var t in candidates.Where(t => t >= originalDate))
var candidate = t;

// candidates MAY occur before periodStart
// For example, FREQ=YEARLY;BYWEEKNO=1 could return dates
// from the previous year.
//
// exclude candidates that start at the same moment as periodEnd if the period is a range but keep them if targeting a specific moment
if (dateCount >= pattern.Count)
{
var candidate = t;

// candidates MAY occur before periodStart
// For example, FREQ=YEARLY;BYWEEKNO=1 could return dates
// from the previous year.
//
// exclude candidates that start at the same moment as periodEnd if the period is a range but keep them if targeting a specific moment
if (dateCount >= pattern.Count)
{
break;
}
break;
}

if ((candidate >= periodEnd && periodStart != periodEnd) || candidate > periodEnd && periodStart == periodEnd)
{
continue;
}
if ((candidate >= periodEnd && periodStart != periodEnd) || candidate > periodEnd && periodStart == periodEnd)
{
continue;
}

// UNTIL is applied outside of this method, after TZ conversion has been applied.
// UNTIL is applied outside of this method, after TZ conversion has been applied.

yield return candidate;
dateCount++;
}
}
else
{
noCandidateIncrementCount++;
if (noCandidateIncrementCount > options?.MaxUnmatchedIncrementsLimit)
throw new EvaluationLimitExceededException();
yield return candidate;
dateCount++;
}

noCandidateIncrementCount++;
if (noCandidateIncrementCount > options?.MaxUnmatchedIncrementsLimit)
throw new EvaluationLimitExceededException();

IncrementDate(ref intervalRefTime, pattern, pattern.Interval);
}
}

private static CalDateTime? GetSearchEndDate(CalDateTime? periodEnd, RecurrencePattern pattern)
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍

{
// This value is only used for performance reasons to stop incrementing after
// until is passed, even if no recurrences are being found.
// As a safe heuristic we add 1d to the UNTIL value to cover any time shift and DST changes.
// It's just important that we don't miss any recurrences, not that we stop exactly at UNTIL.
// Precise UNTIL handling is done outside this method after TZ conversion.
var coarseUntil = pattern.Until?.AddDays(1);

if ((coarseUntil == null) || (periodEnd == null))
return (coarseUntil ?? periodEnd);

return (coarseUntil < periodEnd ? coarseUntil : periodEnd);
}

/// <summary>
/// Find the lowest possible date/time for a recurrence in the given interval.
/// </summary>
/// <remarks>
/// Usually intervalRefTime is either at DTSTART or later at the start of the interval.
/// However, for YEARLY recurrences with BYWEEKNO=1 there could be recurrences before
/// Jan 1st, so we need to adjust the intervalRefTime to the start of the week.
/// </remarks>
private static CalDateTime GetIntervalLowerLimit(CalDateTime intervalRefTime, RecurrencePattern pattern)
{
var intervalLowerLimit = intervalRefTime;
if ((pattern.Frequency == FrequencyType.Yearly) && (pattern.ByWeekNo.Count != 0))
intervalLowerLimit = GetFirstDayOfWeekDate(intervalRefTime, pattern.FirstDayOfWeek);
return intervalLowerLimit;
}

private struct ExpandContext
{
/// <summary>
Expand Down Expand Up @@ -890,7 +902,7 @@ public override IEnumerable<Period> Evaluate(CalDateTime referenceDate, CalDateT
// Create a recurrence pattern suitable for use during evaluation.
var pattern = ProcessRecurrencePattern(referenceDate);

var periodQuery = GetDates(referenceDate, periodStart, periodEnd, -1, pattern, options)
var periodQuery = GetDates(referenceDate, periodStart, periodEnd, pattern, options)
.Select(dt => CreatePeriod(dt, referenceDate));

if (pattern.Until is not null)
Expand Down
Loading