Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix incorrect handling of UNTIL if falling into DST change and some related improvements #738

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
4 changes: 2 additions & 2 deletions Ical.Net.Benchmarks/OccurencePerfTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ private static Calendar GetFourCalendarEventsWithUntilRule()
{
var rrule = new RecurrencePattern(FrequencyType.Daily, 1)
{
Until = startTime.AddDays(10),
Until = new CalDateTime(startTime.AddDays(10)),
};

var e = new CalendarEvent
Expand Down Expand Up @@ -141,4 +141,4 @@ private static Calendar GetFourCalendarEventsWithCountRule()
c.Events.AddRange(events);
return c;
}
}
}
9 changes: 4 additions & 5 deletions Ical.Net.Tests/DocumentationExamples.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ public void Daily_Test()
// We want it to recur through the end of July.
var vEvent = new CalendarEvent
{
DtStart = new CalDateTime(DateTime.Parse("2016-07-01T07:00")),
DtEnd = new CalDateTime(DateTime.Parse("2016-07-01T08:00")),
DtStart = new CalDateTime("20160701T070000"),
DtEnd = new CalDateTime("20160701T080000"),
};

//Recur daily through the end of the day, July 31, 2016
var recurrenceRule = new RecurrencePattern(FrequencyType.Daily, 1)
{
Until = DateTime.Parse("2016-07-31T23:59:59")
Until = new CalDateTime("20160731T235959")
};

vEvent.RecurrenceRules = new List<RecurrencePattern> { recurrenceRule };
Expand Down Expand Up @@ -57,7 +57,7 @@ public void EveryOtherTuesdayUntilTheEndOfTheYear_Test()
// Recurring every other Tuesday until Dec 31
var rrule = new RecurrencePattern(FrequencyType.Weekly, 2)
{
Until = DateTime.Parse("2016-12-31T11:59:59")
Until = new CalDateTime("20161231T115959")
};
vEvent.RecurrenceRules = new List<RecurrencePattern> { rrule };

Expand Down Expand Up @@ -88,7 +88,6 @@ public void FourthThursdayOfNovember_Tests()
Interval = 1,
ByMonth = new List<int> { 11 },
ByDay = new List<WeekDay> { new WeekDay { DayOfWeek = DayOfWeek.Thursday, Offset = 4 } },
Until = DateTime.MaxValue
};
vEvent.RecurrenceRules = new List<RecurrencePattern> { rrule };

Expand Down
50 changes: 48 additions & 2 deletions Ical.Net.Tests/RecurrenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,52 @@
EventOccurrenceTest(cal, fromDate, toDate, expectedPeriods, timeZones, 0);
}

private static TestCaseData[] EventOccurrenceTestCases = new TestCaseData[]
{
new("""
DTSTART;TZID=Europe/Amsterdam:20201024T023000
DURATION:PT5M
RRULE:FREQ=DAILY;UNTIL=20201025T010000Z
""",
new []
{
"20201024T023000/PT5M",
"20201025T023000/PT5M"
}
),
};

[Test, Category("Recurrence")]
[TestCaseSource(nameof(EventOccurrenceTestCases))]
public void EventOccurrenceTest(
string eventIcal,
string[] expectedPeriods)
{
var eventSerializer = new EventSerializer();
var calendarIcalStr = $"""
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
{eventIcal}
END:VEVENT
END:VCALENDAR
""";

var cal = Calendar.Load(calendarIcalStr);
var tzid = cal.Events.Single().Start.TzId;

var periodSerializer = new PeriodSerializer();
var periods = expectedPeriods
.Select(p => (Period) periodSerializer.Deserialize(new StringReader(p)))
.Select(p =>
p.Duration is null
? new Period(p.StartTime.ToTimeZone(tzid), p.EndTime)
: new Period(p.StartTime.ToTimeZone(tzid), p.Duration.Value))
.ToArray();

EventOccurrenceTest(cal, null, null, periods, null, 0);
}

/// <summary>
/// See Page 45 of RFC 2445 - RRULE:FREQ=YEARLY;INTERVAL=2;BYMONTH=1;BYDAY=SU;BYHOUR=8,9;BYMINUTE=30
/// </summary>
Expand Down Expand Up @@ -3134,7 +3180,7 @@

var rrule = new RecurrencePattern(FrequencyType.Weekly, interval: 1)
{
Until = DateTime.Parse("2016-08-31T07:00:00"),
Until = new CalDateTime("20160831T070000"),
ByDay = new List<WeekDay> { new WeekDay(DayOfWeek.Wednesday) },
};

Expand Down Expand Up @@ -3307,7 +3353,7 @@
{
var start = _now.AddYears(-1);
var end = start.AddHours(1);
var rrule = new RecurrencePattern(FrequencyType.Daily) { Until = start.AddYears(2) };
var rrule = new RecurrencePattern(FrequencyType.Daily) { Until = new CalDateTime(start.AddYears(2)) };
var e = new CalendarEvent
{
DtStart = new CalDateTime(start),
Expand Down Expand Up @@ -3586,7 +3632,7 @@
[TestCase(null, false)]
[TestCase(CalDateTime.UtcTzId, false)]
[TestCase("America/New_York", true)]
public void DisallowedUntilShouldThrow(string? tzId, bool shouldThrow)

Check warning on line 3635 in Ical.Net.Tests/RecurrenceTests.cs

View workflow job for this annotation

GitHub Actions / tests

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 3635 in Ical.Net.Tests/RecurrenceTests.cs

View workflow job for this annotation

GitHub Actions / tests

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 3635 in Ical.Net.Tests/RecurrenceTests.cs

View workflow job for this annotation

GitHub Actions / tests

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 3635 in Ical.Net.Tests/RecurrenceTests.cs

View workflow job for this annotation

GitHub Actions / coverage

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 3635 in Ical.Net.Tests/RecurrenceTests.cs

View workflow job for this annotation

GitHub Actions / coverage

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 3635 in Ical.Net.Tests/RecurrenceTests.cs

View workflow job for this annotation

GitHub Actions / coverage

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
{
var dt = new CalDateTime(2025, 11, 08, 10, 30, 00, tzId);
var recPattern = new RecurrencePattern(FrequencyType.Daily, 1);
Expand Down
2 changes: 1 addition & 1 deletion Ical.Net.Tests/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@ public void TestRRuleUntilSerialization()
{
var rrule = new RecurrencePattern(FrequencyType.Daily)
{
Until = _nowTime.AddDays(7),
Until = new CalDateTime(_nowTime.AddDays(7)),
};
const string someTz = "Europe/Volgograd";
var e = new CalendarEvent
Expand Down
17 changes: 4 additions & 13 deletions Ical.Net/DataTypes/CalDateTime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,6 @@ public override int GetHashCode()
{
var hashCode = Value.GetHashCode();
hashCode = (hashCode * 397) ^ HasTime.GetHashCode();
hashCode = (hashCode * 397) ^ AsUtc.GetHashCode();
hashCode = (hashCode * 397) ^ (TzId != null ? TzId.GetHashCode() : 0);
return hashCode;
}
Expand All @@ -251,28 +250,28 @@ public override int GetHashCode()
{
return left != null
&& right != null
&& ((left.IsFloating || right.IsFloating) ? left.Value < right.Value : left.AsUtc < right.AsUtc);
&& ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value < right.Value : left.AsUtc < right.AsUtc);
}

public static bool operator >(CalDateTime? left, CalDateTime? right)
{
return left != null
&& right != null
&& ((left.IsFloating || right.IsFloating) ? left.Value > right.Value : left.AsUtc > right.AsUtc);
&& ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value > right.Value : left.AsUtc > right.AsUtc);
}

public static bool operator <=(CalDateTime? left, CalDateTime? right)
{
return left != null
&& right != null
&& ((left.IsFloating || right.IsFloating) ? left.Value <= right.Value : left.AsUtc <= right.AsUtc);
&& ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value <= right.Value : left.AsUtc <= right.AsUtc);
}

public static bool operator >=(CalDateTime? left, CalDateTime? right)
{
return left != null
&& right != null
&& ((left.IsFloating || right.IsFloating) ? left.Value >= right.Value : left.AsUtc >= right.AsUtc);
&& ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value >= right.Value : left.AsUtc >= right.AsUtc);
}

public static bool operator ==(CalDateTime? left, CalDateTime? right)
Expand Down Expand Up @@ -302,14 +301,6 @@ public override int GetHashCode()
return !(left == right);
}

/// <summary>
/// Creates a new instance of <see cref="CalDateTime"/> with <see langword="true"/> for <see cref="HasTime"/>
/// </summary>
public static implicit operator CalDateTime(DateTime left)
{
return new CalDateTime(left);
}

/// <summary>
/// Converts the date/time to UTC (Coordinated Universal Time)
/// If <see cref="IsFloating"/>==<see langword="true"/>
Expand Down
80 changes: 20 additions & 60 deletions Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using System.Collections.Generic;
using System.Linq;
using Ical.Net.DataTypes;
using Ical.Net.Utility;

namespace Ical.Net.Evaluation;

Expand All @@ -28,12 +27,6 @@ private RecurrencePattern ProcessRecurrencePattern(CalDateTime referenceDate)
var r = new RecurrencePattern();
r.CopyFrom(Pattern);

// Convert the UNTIL value to one that matches the same time information as the reference date
if (r.Until is not null)
{
r.Until = MatchTimeZone(referenceDate, r.Until);
}

if (referenceDate.HasTime)
{
if (r.Frequency > FrequencyType.Secondly && r.BySecond.Count == 0 && referenceDate.HasTime
Expand Down Expand Up @@ -148,21 +141,22 @@ private IEnumerable<DateTime> GetDates(CalDateTime seed, CalDateTime? periodStar
return EnumerateDates(originalDate, seedCopy, periodStartDt, periodEndDt, maxCount, pattern);
}

private IEnumerable<DateTime> EnumerateDates(DateTime originalDate, DateTime seedCopy, DateTime? periodStart, DateTime? periodEnd, int maxCount, RecurrencePattern pattern)
private IEnumerable<DateTime> EnumerateDates(DateTime originalDate, DateTime intervalRefTime, DateTime? periodStart, DateTime? periodEnd, int maxCount, RecurrencePattern pattern)
{
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?.Value.AddDays(1);

var noCandidateIncrementCount = 0;
DateTime? candidate = null;
var dateCount = 0;
while (maxCount < 0 || dateCount < maxCount)
{
if (pattern.Until is not null && candidate > pattern.Until)
{
break;
}

if (candidate > periodEnd)
if (intervalRefTime > coarseUntil)
{
break;
}
Expand All @@ -173,19 +167,19 @@ private IEnumerable<DateTime> EnumerateDates(DateTime originalDate, DateTime see
}

//No need to continue if the seed is after the periodEnd
if (seedCopy > periodEnd)
if (intervalRefTime > periodEnd)
{
break;
}

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

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

// candidates MAY occur before periodStart
// For example, FREQ=YEARLY;BYWEEKNO=1 could return dates
Expand All @@ -202,11 +196,10 @@ private IEnumerable<DateTime> EnumerateDates(DateTime originalDate, DateTime see
continue;
}

if (pattern.Until is null || candidate <= pattern.Until)
{
yield return candidate.Value;
dateCount++;
}
// UNTIL is applied outside of this method, after TZ conversion has been applied.

yield return candidate;
dateCount++;
}
}
else
Expand All @@ -218,7 +211,7 @@ private IEnumerable<DateTime> EnumerateDates(DateTime originalDate, DateTime see
}
}

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

Expand Down Expand Up @@ -903,42 +896,9 @@ public override IEnumerable<Period> Evaluate(CalDateTime referenceDate, CalDateT
var periodQuery = GetDates(referenceDate, periodStart, periodEnd, -1, pattern, includeReferenceDateInResults)
.Select(dt => CreatePeriod(dt, referenceDate));

return periodQuery;
}

private static CalDateTime MatchTimeZone(CalDateTime reference, CalDateTime until)
{
/*
The value of the "UNTIL" rule part MUST have the same value type as the
"DTSTART" property. Furthermore, if the "DTSTART" property is
specified as a date with local time, then the UNTIL rule part MUST
also be specified as a date with local time.

If the "DTSTART" property is specified as a date with UTC time or a date with local
time and time zone reference, then the UNTIL rule part MUST be
specified as a date with UTC time.
*/
string? untilTzId;
if (reference.IsFloating)
{
// If 'reference' is floating, then 'until' must be floating
untilTzId = null;
}
else
{
// If 'reference' has a timezone, 'until' MUST be UTC,
// but in case of UTC rule violation we fall back to the 'reference' timezone
untilTzId = until.TzId == CalDateTime.UtcTzId
? CalDateTime.UtcTzId
: reference.TzId;
}
if (pattern.Until is not null)
periodQuery = periodQuery.TakeWhile(p => p.StartTime <= pattern.Until);

var untilCalDt = new CalDateTime(until.Value, untilTzId, reference.HasTime);

// If 'reference' is floating, then 'until' is floating, too
return reference.TzId is null
? untilCalDt
// convert to the reference timezone and convert the value to Floating
: untilCalDt.ToTimeZone(reference.TzId).ToTimeZone(null);
return periodQuery;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -426,10 +426,7 @@
}
else if ((match = RecurUntil.Match(item)).Success)
{
var dt = DateTime.Parse(match.Groups["DateTime"].Value);
DateTime.SpecifyKind(dt, DateTimeKind.Utc);

r.Until = dt;
r.Until = new CalDateTime(match.Groups["DateTime"].Value);

Check warning on line 429 in Ical.Net/Serialization/DataTypes/RecurrencePatternSerializer.cs

View check run for this annotation

Codecov / codecov/patch

Ical.Net/Serialization/DataTypes/RecurrencePatternSerializer.cs#L429

Added line #L429 was not covered by tests
}
else if ((match = SpecificRecurrenceCount.Match(item)).Success)
{
Expand Down
Loading