Skip to content

Commit 69dcbc4

Browse files
committed
#825, Evaluation: Let GetOccurrences() return still active occurrences, even if they started in the past
1 parent 8700ef8 commit 69dcbc4

File tree

6 files changed

+111
-38
lines changed

6 files changed

+111
-38
lines changed

Ical.Net/CalendarComponents/Todo.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Licensed under the MIT license.
44
//
55

6+
using System;
67
using System.Collections.Generic;
78
using System.Diagnostics;
89
using System.Linq;
@@ -63,6 +64,56 @@ public virtual Duration? Duration
6364
}
6465
}
6566

67+
/// <summary>
68+
/// Gets the duration that gets added to the period start time to get the period end time.
69+
/// <para/>
70+
/// If the <see cref="Duration"/> property is not null, its value will be returned.<br/>
71+
/// If <see cref="RecurringComponent.DtStart"/> and <see cref="Due"/> are set, it will return <see cref="Due"/> minus <see cref="RecurringComponent.DtStart"/>.<br/>
72+
/// </summary>
73+
/// <remarks>
74+
/// Note: For recurring events, the <b>exact duration</b> of individual occurrences may vary due to DST transitions
75+
/// of the given <see cref="RecurringComponent.DtStart"/> and <see cref="Due"/> timezones.
76+
/// </remarks>
77+
/// <returns>The duration that gets added to the period start time to get the period end time.</returns>
78+
public Duration? EffectiveDuration
79+
{
80+
get
81+
{
82+
// 3.8.5.3. Recurrence Rule
83+
// If the duration of the recurring component is specified with the
84+
// "DURATION" property, then the same NOMINAL duration will apply to
85+
// all the members of the generated recurrence set and the exact
86+
// duration of each recurrence instance will depend on its specific
87+
// start time.
88+
if (Duration is not null)
89+
return Duration.Value;
90+
91+
if (DtStart is not { } dtStart)
92+
{
93+
// Mustn't happen
94+
throw new InvalidOperationException("DtStart must be set.");
95+
}
96+
97+
if (Due is { } dtEnd)
98+
{
99+
/*
100+
3.8.5.3. Recurrence Rule:
101+
If the duration of the recurring component is specified with the
102+
"DTEND" or "DUE" property, then the same EXACT duration will apply
103+
to all the members of the generated recurrence set.
104+
105+
We use the difference from DtStart to DtEnd (neglecting timezone),
106+
because the caller will set the period end time to the
107+
same timezone as the event end time. This finally leads to an exact duration
108+
calculation from the zoned start time to the zoned end time.
109+
*/
110+
return dtEnd.Subtract(dtStart);
111+
}
112+
113+
return null;
114+
}
115+
}
116+
66117
public virtual GeographicLocation? GeographicLocation
67118
{
68119
get => Properties.Get<GeographicLocation>("GEO");

Ical.Net/Evaluation/EventEvaluator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ public class EventEvaluator : RecurringEvaluator
1818
{
1919
protected CalendarEvent CalendarEvent => (CalendarEvent) Recurrable;
2020

21+
protected override Duration? DefaultDuration => CalendarEvent.EffectiveDuration;
22+
2123
/// <summary>
2224
/// Initializes a new instance of the <see cref="EventEvaluator"/> class.
2325
/// </summary>

Ical.Net/Evaluation/RecurringEvaluator.cs

Lines changed: 46 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ public abstract class RecurringEvaluator : Evaluator
1616
{
1717
protected IRecurrable Recurrable { get; set; }
1818

19+
protected abstract Duration? DefaultDuration { get; }
20+
1921
protected RecurringEvaluator(IRecurrable obj)
2022
{
2123
Recurrable = obj;
@@ -32,10 +34,13 @@ protected IEnumerable<Period> EvaluateRRule(CalDateTime referenceDate, CalDateTi
3234
if (!Recurrable.RecurrenceRules.Any())
3335
return [];
3436

37+
var d = this.DefaultDuration;
38+
var effPeriodStart = (d != null) ? periodStart?.AddLeniently(-d.Value) : periodStart;
39+
3540
var periodsQueries = Recurrable.RecurrenceRules.Select(rule =>
3641
{
3742
var ruleEvaluator = new RecurrencePatternEvaluator(rule);
38-
return ruleEvaluator.Evaluate(referenceDate, periodStart, options);
43+
return ruleEvaluator.Evaluate(referenceDate, effPeriodStart, options);
3944
})
4045
// Enumerate the outer sequence (not the inner sequences of periods themselves) now to ensure
4146
// the initialization code is run, including validation and error handling.
@@ -46,33 +51,28 @@ protected IEnumerable<Period> EvaluateRRule(CalDateTime referenceDate, CalDateTi
4651
}
4752

4853
/// <summary> Evaluates the RDate component. </summary>
49-
protected IEnumerable<Period> EvaluateRDate(CalDateTime? periodStart)
50-
{
51-
var recurrences = Recurrable.RecurrenceDates
52-
.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime)
53-
.AsEnumerable();
54-
55-
if (periodStart != null)
56-
recurrences = recurrences.Where(p => p.StartTime.GreaterThanOrEqual(periodStart));
57-
58-
return new SortedSet<Period>(recurrences);
59-
}
54+
protected IEnumerable<Period> EvaluateRDate()
55+
=> new SortedSet<Period>(Recurrable.RecurrenceDates
56+
.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime));
6057

6158
/// <summary>
6259
/// Evaluates the ExRule component.
6360
/// </summary>
6461
/// <param name="referenceDate"></param>
65-
/// <param name="periodStart">The beginning date of the range to evaluate.</param>
6662
/// <param name="options"></param>
67-
protected IEnumerable<Period> EvaluateExRule(CalDateTime referenceDate, CalDateTime? periodStart, EvaluationOptions? options)
63+
private IEnumerable<Period> EvaluateExRule(CalDateTime referenceDate, EvaluationOptions? options)
6864
{
6965
if (!Recurrable.ExceptionRules.Any())
7066
return [];
7167

68+
// We don't apply periodStart here, because calculating it would be quire complex, because
69+
// RDATE's may have arbitrary durations.
70+
CalDateTime? effPeriodStart = null;
71+
7272
var exRuleEvaluatorQueries = Recurrable.ExceptionRules.Select(exRule =>
7373
{
7474
var exRuleEvaluator = new RecurrencePatternEvaluator(exRule);
75-
return exRuleEvaluator.Evaluate(referenceDate, periodStart, options);
75+
return exRuleEvaluator.Evaluate(referenceDate, effPeriodStart, options);
7676
})
7777
// Enumerate the outer sequence (not the inner sequences of periods themselves) now to ensure
7878
// the initialization code is run, including validation and error handling.
@@ -85,18 +85,9 @@ protected IEnumerable<Period> EvaluateExRule(CalDateTime referenceDate, CalDateT
8585
/// <summary>
8686
/// Evaluates the ExDate component.
8787
/// </summary>
88-
/// <param name="periodStart">The beginning date of the range to evaluate.</param>
8988
/// <param name="periodKinds">The period kinds to be returned. Used as a filter.</param>
90-
private IEnumerable<Period> EvaluateExDate(CalDateTime? periodStart, params PeriodKind[] periodKinds)
91-
{
92-
var exDates = Recurrable.ExceptionDates.GetAllPeriodsByKind(periodKinds)
93-
.AsEnumerable();
94-
95-
if (periodStart != null)
96-
exDates = exDates.Where(p => p.StartTime.GreaterThanOrEqual(periodStart));
97-
98-
return new SortedSet<Period>(exDates);
99-
}
89+
private IEnumerable<Period> EvaluateExDate(params PeriodKind[] periodKinds)
90+
=> new SortedSet<Period>(Recurrable.ExceptionDates.GetAllPeriodsByKind(periodKinds));
10091

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

112-
var rdateOccurrences = EvaluateRDate(periodStart);
103+
var rdateOccurrences = EvaluateRDate();
104+
105+
var periods =
106+
rruleOccurrences
107+
.OrderedMerge(rdateOccurrences)
108+
.OrderedDistinct();
113109

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

116-
// EXDATEs could contain date-only entries while DTSTART is date-time. Probably this isn't supported
115+
// Filter by periodStart
116+
if (periodStart is not null)
117+
{
118+
// Include occurrences that start before periodStart, but end after periodStart.
119+
periods = periods.Where(p => (p.StartTime >= periodStart) || (p.EffectiveEndTime > periodStart));
120+
}
121+
122+
var exRuleExclusions = EvaluateExRule(referenceDate, options);
123+
124+
// EXDATEs could contain date-only entries while DTSTART is date-time. This case isn't clearly defined
117125
// by the RFC, but it seems to be used in the wild (see https://github.com/ical-org/ical.net/issues/829).
118-
// So we must make sure to return all-day EXDATEs that could overlap with recurrences, even if the day starts
119-
// before `periodStart`. We therefore start 2 days earlier (2 for safety regarding the TZ).
120-
var exDateExclusionsDateOnly = new HashSet<DateOnly>(EvaluateExDate(periodStart?.AddDays(-2), PeriodKind.DateOnly)
126+
// Different systems handle this differently, e.g. Outlook excludes any occurrences where the date portion
127+
// matches an date-only EXDATE, while Google Calendar ignores such EXDATEs completely, if DTSTART is date-time.
128+
// In Ical.Net we follow the Outlook approach, which requires us to handle date-only EXDATEs separately.
129+
var exDateExclusionsDateOnly = new HashSet<DateOnly>(EvaluateExDate(PeriodKind.DateOnly)
121130
.Select(x => x.StartTime.Date));
122131

123-
var exDateExclusionsDateTime = EvaluateExDate(periodStart, PeriodKind.DateTime);
132+
var exDateExclusionsDateTime = EvaluateExDate(PeriodKind.DateTime);
124133

125-
var periods =
126-
rruleOccurrences
127-
.OrderedMerge(rdateOccurrences)
128-
.OrderedDistinct()
134+
// Exclude occurrences according to EXRULEs and EXDATEs.
135+
periods = periods
129136
.OrderedExclude(exRuleExclusions)
130137
.OrderedExclude(exDateExclusionsDateTime)
131138

@@ -136,8 +143,9 @@ public override IEnumerable<Period> Evaluate(CalDateTime referenceDate, CalDateT
136143
// The order of dates in the EXDATEs doesn't necessarily match the order of dates returned by RDATEs
137144
// due to RDATEs could have different time zones. We therefore use a regular `.Where()` to look up
138145
// the EXDATEs in the HashSet rather than using `.OrderedExclude()`, which would require correct ordering.
139-
.Where(dt => !exDateExclusionsDateOnly.Contains(dt.StartTime.Date))
146+
.Where(dt => !exDateExclusionsDateOnly.Contains(dt.StartTime.Date));
140147

148+
periods = periods
141149
// Convert overflow exceptions to expected ones.
142150
.HandleEvaluationExceptions();
143151

Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,7 @@ protected VTimeZoneInfo TimeZoneInfo
1818
set => Recurrable = value;
1919
}
2020

21+
protected override Duration? DefaultDuration => null;
22+
2123
public TimeZoneInfoEvaluator(IRecurrable tzi) : base(tzi) { }
2224
}

Ical.Net/Evaluation/TodoEvaluator.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ public class TodoEvaluator : RecurringEvaluator
1515
{
1616
protected Todo Todo => Recurrable as Todo ?? throw new InvalidOperationException();
1717

18+
protected override Duration? DefaultDuration => Todo.EffectiveDuration;
19+
1820
public TodoEvaluator(Todo todo) : base(todo) { }
1921

2022
internal IEnumerable<Period> EvaluateToPreviousOccurrence(CalDateTime completedDate, CalDateTime currDt, EvaluationOptions? options)

Ical.Net/Utility/DateUtil.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,4 +132,12 @@ internal static DataTypes.Duration ToDurationExact(this TimeSpan timeSpan)
132132
/// </remarks>
133133
internal static DataTypes.Duration ToDuration(this TimeSpan timeSpan)
134134
=> DataTypes.Duration.FromTimeSpan(timeSpan);
135+
136+
internal static CalDateTime AddLeniently(this CalDateTime dt, DataTypes.Duration d)
137+
{
138+
if (d.HasTime && !dt.HasTime)
139+
dt = new CalDateTime(dt.Date, TimeOnly.MinValue);
140+
141+
return dt.Add(d);
142+
}
135143
}

0 commit comments

Comments
 (0)