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
38 changes: 38 additions & 0 deletions Ical.Net.Tests/RecurrenceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4170,4 +4170,42 @@ public void GetOccurrences_WithMixedKindExDatesAndTz_ShouldProperlyConsiderAll()

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

[Test]
public void GetOccurrences_WithPeriodStart_ShouldConsiderDurationCorrectly()
{
var cal = Calendar.Load("""
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20250701T000000
DURATION:P3D
RRULE:FREQ=WEEKLY
RDATE;VALUE=PERIOD:20250707T000000/P4D,20250709T000000/P1D
RDATE;VALUE=DATE:20250706T000000,20250710T000000
END:VEVENT
END:VCALENDAR
""")!;

var occurrences = cal.GetOccurrences(new CalDateTime("20250710T120000"))
.Select(o => o.Period.StartTime)
.Take(5)
.ToList();

var expectedDates = new string[]
{
// RDATE
"20250707T000000",
// RRULE
"20250708T000000",
// RDATE
"20250710T000000",
// RRULE
"20250715T000000",
// RRULE
"20250722T000000",
}.Select(x => new CalDateTime(x))
.ToList();

Assert.That(occurrences, Is.EqualTo(expectedDates));
}
}
11 changes: 4 additions & 7 deletions Ical.Net/CalendarComponents/RecurringComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
/// RRULEs, RDATE, EXRULEs, and EXDATEs, as well as the DTSTART
/// for the recurring item (all recurring items must have a DTSTART).
/// </remarks>
public class RecurringComponent : UniqueComponent, IRecurringComponent
public abstract class RecurringComponent : UniqueComponent, IRecurringComponent
{
public static IEnumerable<IRecurringComponent> SortByDate(IEnumerable<IRecurringComponent> list) => SortByDate<IRecurringComponent>(list);

Expand Down Expand Up @@ -151,25 +151,22 @@
/// </summary>
public virtual ICalendarObjectList<Alarm> Alarms => new CalendarObjectListProxy<Alarm>(Children);

private RecurringEvaluator? _evaluator;
public abstract IEvaluator? Evaluator { get; }

public virtual IEvaluator? Evaluator => _evaluator;

public RecurringComponent()
protected RecurringComponent()
{
Initialize();
EnsureProperties();
}

public RecurringComponent(string name) : base(name)
protected RecurringComponent(string name) : base(name)

Check warning on line 162 in Ical.Net/CalendarComponents/RecurringComponent.cs

View check run for this annotation

Codecov / codecov/patch

Ical.Net/CalendarComponents/RecurringComponent.cs#L162

Added line #L162 was not covered by tests
{
Initialize();
EnsureProperties();
}

private void Initialize()
{
_evaluator = new RecurringEvaluator(this);
ExceptionDates = new ExceptionDates(ExceptionDatesPeriodLists);
RecurrenceDates = new RecurrenceDates(RecurrenceDatesPeriodLists);
}
Expand Down
51 changes: 51 additions & 0 deletions Ical.Net/CalendarComponents/Todo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// Licensed under the MIT license.
//

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
Expand Down Expand Up @@ -63,6 +64,56 @@
}
}

/// <summary>
/// Gets the duration that gets added to the period start time to get the period end time.
/// <para/>
/// If the <see cref="Duration"/> property is not null, its value will be returned.<br/>
/// If <see cref="RecurringComponent.DtStart"/> and <see cref="Due"/> are set, it will return <see cref="Due"/> minus <see cref="RecurringComponent.DtStart"/>.<br/>
/// </summary>
/// <remarks>
/// Note: For recurring events, the <b>exact duration</b> of individual occurrences may vary due to DST transitions
/// of the given <see cref="RecurringComponent.DtStart"/> and <see cref="Due"/> timezones.
/// </remarks>
/// <returns>The duration that gets added to the period start time to get the period end time.</returns>
public Duration? EffectiveDuration
{
get
{
// 3.8.5.3. Recurrence Rule
// If the duration of the recurring component is specified with the
// "DURATION" property, then the same NOMINAL duration will apply to
// all the members of the generated recurrence set and the exact
// duration of each recurrence instance will depend on its specific
// start time.
if (Duration is not null)
return Duration.Value;

Check warning on line 89 in Ical.Net/CalendarComponents/Todo.cs

View check run for this annotation

Codecov / codecov/patch

Ical.Net/CalendarComponents/Todo.cs#L89

Added line #L89 was not covered by tests

if (DtStart is not { } dtStart)
{
// Mustn't happen
throw new InvalidOperationException("DtStart must be set.");

Check warning on line 94 in Ical.Net/CalendarComponents/Todo.cs

View check run for this annotation

Codecov / codecov/patch

Ical.Net/CalendarComponents/Todo.cs#L94

Added line #L94 was not covered by tests
}

if (Due is { } dtEnd)
{
/*
3.8.5.3. Recurrence Rule:
If the duration of the recurring component is specified with the
"DTEND" or "DUE" property, then the same EXACT duration will apply
to all the members of the generated recurrence set.

We use the difference from DtStart to DtEnd (neglecting timezone),
because the caller will set the period end time to the
same timezone as the event end time. This finally leads to an exact duration
calculation from the zoned start time to the zoned end time.
*/
return dtEnd.Subtract(dtStart);

Check warning on line 110 in Ical.Net/CalendarComponents/Todo.cs

View check run for this annotation

Codecov / codecov/patch

Ical.Net/CalendarComponents/Todo.cs#L110

Added line #L110 was not covered by tests
}

return null;
}
}

public virtual GeographicLocation? GeographicLocation
{
get => Properties.Get<GeographicLocation>("GEO");
Expand Down
2 changes: 2 additions & 0 deletions Ical.Net/Evaluation/EventEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public class EventEvaluator : RecurringEvaluator
{
protected CalendarEvent CalendarEvent => (CalendarEvent) Recurrable;

protected override Duration? DefaultDuration => CalendarEvent.EffectiveDuration;

/// <summary>
/// Initializes a new instance of the <see cref="EventEvaluator"/> class.
/// </summary>
Expand Down
88 changes: 48 additions & 40 deletions Ical.Net/Evaluation/RecurringEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@

namespace Ical.Net.Evaluation;

public class RecurringEvaluator : Evaluator
public abstract class RecurringEvaluator : Evaluator
{
protected IRecurrable Recurrable { get; set; }

public RecurringEvaluator(IRecurrable obj)
protected abstract Duration? DefaultDuration { get; }

protected RecurringEvaluator(IRecurrable obj)
{
Recurrable = obj;
}
Expand All @@ -32,10 +34,13 @@ protected IEnumerable<Period> EvaluateRRule(CalDateTime referenceDate, CalDateTi
if (!Recurrable.RecurrenceRules.Any())
return [];

var d = this.DefaultDuration;
var effPeriodStart = (d != null) ? periodStart?.AddLeniently(-d.Value) : periodStart;

var periodsQueries = Recurrable.RecurrenceRules.Select(rule =>
{
var ruleEvaluator = new RecurrencePatternEvaluator(rule);
return ruleEvaluator.Evaluate(referenceDate, periodStart, options);
return ruleEvaluator.Evaluate(referenceDate, effPeriodStart, options);
})
// Enumerate the outer sequence (not the inner sequences of periods themselves) now to ensure
// the initialization code is run, including validation and error handling.
Expand All @@ -46,33 +51,28 @@ protected IEnumerable<Period> EvaluateRRule(CalDateTime referenceDate, CalDateTi
}

/// <summary> Evaluates the RDate component. </summary>
protected IEnumerable<Period> EvaluateRDate(CalDateTime? periodStart)
{
var recurrences = Recurrable.RecurrenceDates
.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime)
.AsEnumerable();

if (periodStart != null)
recurrences = recurrences.Where(p => p.StartTime.GreaterThanOrEqual(periodStart));

return new SortedSet<Period>(recurrences);
}
protected IEnumerable<Period> EvaluateRDate()
=> new SortedSet<Period>(Recurrable.RecurrenceDates
.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime));

/// <summary>
/// Evaluates the ExRule component.
/// </summary>
/// <param name="referenceDate"></param>
/// <param name="periodStart">The beginning date of the range to evaluate.</param>
/// <param name="options"></param>
protected IEnumerable<Period> EvaluateExRule(CalDateTime referenceDate, CalDateTime? periodStart, EvaluationOptions? options)
private IEnumerable<Period> EvaluateExRule(CalDateTime referenceDate, EvaluationOptions? options)
{
if (!Recurrable.ExceptionRules.Any())
return [];

// We don't apply periodStart here, because calculating it would be quire complex, because
// RDATE's may have arbitrary durations.
CalDateTime? effPeriodStart = null;

var exRuleEvaluatorQueries = Recurrable.ExceptionRules.Select(exRule =>
{
var exRuleEvaluator = new RecurrencePatternEvaluator(exRule);
return exRuleEvaluator.Evaluate(referenceDate, periodStart, options);
return exRuleEvaluator.Evaluate(referenceDate, effPeriodStart, options);
})
// Enumerate the outer sequence (not the inner sequences of periods themselves) now to ensure
// the initialization code is run, including validation and error handling.
Expand All @@ -85,18 +85,9 @@ protected IEnumerable<Period> EvaluateExRule(CalDateTime referenceDate, CalDateT
/// <summary>
/// Evaluates the ExDate component.
/// </summary>
/// <param name="periodStart">The beginning date of the range to evaluate.</param>
/// <param name="periodKinds">The period kinds to be returned. Used as a filter.</param>
private IEnumerable<Period> EvaluateExDate(CalDateTime? periodStart, params PeriodKind[] periodKinds)
{
var exDates = Recurrable.ExceptionDates.GetAllPeriodsByKind(periodKinds)
.AsEnumerable();

if (periodStart != null)
exDates = exDates.Where(p => p.StartTime.GreaterThanOrEqual(periodStart));

return new SortedSet<Period>(exDates);
}
private IEnumerable<Period> EvaluateExDate(params PeriodKind[] periodKinds)
=> new SortedSet<Period>(Recurrable.ExceptionDates.GetAllPeriodsByKind(periodKinds));

public override IEnumerable<Period> Evaluate(CalDateTime referenceDate, CalDateTime? periodStart, EvaluationOptions? options)
{
Expand All @@ -109,23 +100,39 @@ public override IEnumerable<Period> Evaluate(CalDateTime referenceDate, CalDateT
? [new Period(referenceDate)]
: EvaluateRRule(referenceDate, periodStart, options);

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

var periods =
rruleOccurrences
.OrderedMerge(rdateOccurrences)
.OrderedDistinct();

var exRuleExclusions = EvaluateExRule(referenceDate, periodStart, options);
// Apply the default duration, if any.
var d = this.DefaultDuration;
if (d != null)
periods = periods.Select(p => (p.EffectiveDuration != null) ? p : new Period(p.StartTime, d.Value));

// EXDATEs could contain date-only entries while DTSTART is date-time. Probably this isn't supported
// Filter by periodStart
if (periodStart is not null)
{
// Include occurrences that start before periodStart, but end after periodStart.
periods = periods.Where(p => (p.StartTime >= periodStart) || (p.EffectiveEndTime > periodStart));
}

var exRuleExclusions = EvaluateExRule(referenceDate, options);

// EXDATEs could contain date-only entries while DTSTART is date-time. This case isn't clearly defined
// 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)
// Different systems handle this differently, e.g. Outlook excludes any occurrences where the date portion
// matches an date-only EXDATE, while Google Calendar ignores such EXDATEs completely, if DTSTART is date-time.
// In Ical.Net we follow the Outlook approach, which requires us to handle date-only EXDATEs separately.
var exDateExclusionsDateOnly = new HashSet<DateOnly>(EvaluateExDate(PeriodKind.DateOnly)
.Select(x => x.StartTime.Date));

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

var periods =
rruleOccurrences
.OrderedMerge(rdateOccurrences)
.OrderedDistinct()
// Exclude occurrences according to EXRULEs and EXDATEs.
periods = periods
.OrderedExclude(exRuleExclusions)
.OrderedExclude(exDateExclusionsDateTime)

Expand All @@ -136,8 +143,9 @@ public override IEnumerable<Period> Evaluate(CalDateTime referenceDate, CalDateT
// 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))
.Where(dt => !exDateExclusionsDateOnly.Contains(dt.StartTime.Date));

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

Expand Down
2 changes: 2 additions & 0 deletions Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,7 @@
set => Recurrable = value;
}

protected override Duration? DefaultDuration => null;

Check warning on line 21 in Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs

View check run for this annotation

Codecov / codecov/patch

Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs#L21

Added line #L21 was not covered by tests

public TimeZoneInfoEvaluator(IRecurrable tzi) : base(tzi) { }
}
2 changes: 2 additions & 0 deletions Ical.Net/Evaluation/TodoEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ public class TodoEvaluator : RecurringEvaluator
{
protected Todo Todo => Recurrable as Todo ?? throw new InvalidOperationException();

protected override Duration? DefaultDuration => Todo.EffectiveDuration;

public TodoEvaluator(Todo todo) : base(todo) { }

internal IEnumerable<Period> EvaluateToPreviousOccurrence(CalDateTime completedDate, CalDateTime currDt, EvaluationOptions? options)
Expand Down
8 changes: 8 additions & 0 deletions Ical.Net/Utility/DateUtil.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,4 +132,12 @@ internal static DataTypes.Duration ToDurationExact(this TimeSpan timeSpan)
/// </remarks>
internal static DataTypes.Duration ToDuration(this TimeSpan timeSpan)
=> DataTypes.Duration.FromTimeSpan(timeSpan);

internal static CalDateTime AddLeniently(this CalDateTime dt, DataTypes.Duration d)
{
if (d.HasTime && !dt.HasTime)
dt = new CalDateTime(dt.Date, TimeOnly.MinValue);

return dt.Add(d);
}
}
Loading