From 8532029c5612e56341a3408b5653ae7310c32751 Mon Sep 17 00:00:00 2001 From: axunonb Date: Mon, 30 Dec 2024 22:04:36 +0100 Subject: [PATCH] Correct handling of iCalendar PERIOD PeriodKind - Add new enum Period: - Add internal `property string TzId` - Add internal method `PeriodKind GetPeriodKind()` - Ensure timeone consistence for setters of StartTime / EndTime PeriodList: - Add internal `property string TzId` - Add internal property ``PeriodKind PeriodListKind`m - Ensure added `Period`s have the same `TzId`and `PeriodKind` as the first one added PeriodListSerialilzer - Serializes TZID - Serializes value type PERIOD --- Ical.Net.Tests/RecurrenceWithRDateTests.cs | 162 +++++++++++++----- Ical.Net/DataTypes/Period.cs | 27 ++- Ical.Net/DataTypes/PeriodKind.cs | 28 +++ Ical.Net/DataTypes/PeriodList.cs | 42 +++-- .../DataTypes/PeriodListSerializer.cs | 13 +- 5 files changed, 211 insertions(+), 61 deletions(-) create mode 100644 Ical.Net/DataTypes/PeriodKind.cs diff --git a/Ical.Net.Tests/RecurrenceWithRDateTests.cs b/Ical.Net.Tests/RecurrenceWithRDateTests.cs index 1d43fe59..83a44e72 100644 --- a/Ical.Net.Tests/RecurrenceWithRDateTests.cs +++ b/Ical.Net.Tests/RecurrenceWithRDateTests.cs @@ -17,7 +17,7 @@ namespace Ical.Net.Tests; public class RecurrenceWithRDateTests { [Test] - public void RDate_SingleDate_IsProcessedCorrectly() + public void RDate_SingleDateTime_IsProcessedCorrectly() { var cal = new Calendar(); var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); @@ -44,8 +44,38 @@ public void RDate_SingleDate_IsProcessedCorrectly() Assert.That(occurrences, Has.Count.EqualTo(2)); Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0))); Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0))); - Assert.That(ics, Does.Contain("DURATION:PT1H")); Assert.That(ics, Does.Contain("RDATE:20231002T100000")); + Assert.That(ics, Does.Contain("DURATION:PT1H")); + }); + } + + [Test] + public void RDate_SingleDateOnly_IsProcessedCorrectly() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); + + var recurrenceDates = PeriodList.FromDateTime(new CalDateTime(2023, 10, 2)); + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + RecurrenceDates = new List { recurrenceDates } + }; + + cal.Events.Add(calendarEvent); + var serializer = new CalendarSerializer(); + var ics = serializer.SerializeToString(cal); + + var occurrences = calendarEvent.GetOccurrences().ToList(); + + Assert.Multiple(() => + { + Assert.That(occurrences, Has.Count.EqualTo(2)); + Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0))); + Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2))); + Assert.That(ics, Does.Contain("RDATE:20231002")); + Assert.That(ics, Does.Not.Contain("DURATION:")); }); } @@ -80,13 +110,13 @@ public void RDate_MultipleDates_WithTimeZones_AreProcessedCorrectly() Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0, tzId))); Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0, tzId))); Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0, tzId))); - Assert.That(ics, Does.Contain("DURATION:PT2H")); Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York:20231002T100000,20231003T100000")); + Assert.That(ics, Does.Contain("DURATION:PT2H")); }); } [Test] - public void RDate_Periods_AreProcessedCorrectly() + public void RDate_PeriodsWithTimezone_AreProcessedCorrectly() { const string tzId = "America/New_York"; var cal = new Calendar(); @@ -121,7 +151,8 @@ public void RDate_Periods_AreProcessedCorrectly() Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0, tzId))); Assert.That(occurrences[1].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 4))); Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 5))); - Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York:20231002T100000/PT4H,20231003T100000/PT5H")); + // Line folding is used for long lines + Assert.That(ics, Does.Contain("RDATE;TZID=America/New_York;VALUE=PERIOD:20231002T100000/PT4H,20231003T100\r\n 000/PT5H")); }); // Deserialization @@ -145,9 +176,12 @@ public void RDate_MixedDatesAndPeriods_AreProcessedCorrectly() { var cal = new Calendar(); var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); - var recurrenceDates = new PeriodList + var recurrenceDates1 = new PeriodList { new Period(new CalDateTime(2023, 10, 2, 10, 0, 0)), + }; + var recurrenceDates2 = new PeriodList + { new Period(new CalDateTime(2023, 10, 3, 10, 0, 0), new Duration(hours: 3)) }; @@ -155,7 +189,7 @@ public void RDate_MixedDatesAndPeriods_AreProcessedCorrectly() { Start = eventStart, Duration = new Duration(hours: 1), - RecurrenceDates = new List { recurrenceDates } + RecurrenceDates = new List { recurrenceDates1, recurrenceDates2 } }; cal.Events.Add(calendarEvent); @@ -171,7 +205,8 @@ public void RDate_MixedDatesAndPeriods_AreProcessedCorrectly() Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0))); Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0))); Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 3))); - Assert.That(ics, Does.Contain("RDATE:20231002T100000,20231003T100000/PT3H")); + Assert.That(ics, Does.Contain("RDATE:20231002T100000")); + Assert.That(ics, Does.Contain("RDATE;VALUE=PERIOD:20231003T100000/PT3H")); }); } @@ -217,34 +252,24 @@ public void RDate_DifferentTimeZones_AreProcessedCorrectly() } [Test] - public void AddingDifferentTimeZonesToPeriodList_ShouldThrow() - { - Assert.That(() => - { - _ = new PeriodList - { - new Period(new CalDateTime(2023, 10, 2, 10, 0, 0, "America/Los_Angeles")), - new Period(new CalDateTime(2023, 10, 3, 10, 0, 0, "Europe/London")) - }; - }, Throws.ArgumentException); - } - - [Test] - public void RDate_DateOnlyAndDateTime_AreProcessedCorrectly() + public void RDate_DateOnlyWithDurationAndDateTime_AreProcessedCorrectly() { var cal = new Calendar(); var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); - var recurrenceDates = new PeriodList + var recurrenceDates1 = new PeriodList { new Period(new CalDateTime(2023, 10, 2), new Duration(days: 1)), + }; + var recurrenceDates2 = new PeriodList + { new Period(new CalDateTime(2023, 10, 3, 10, 0, 0)) }; var calendarEvent = new CalendarEvent { Start = eventStart, - Duration = new Duration(days: 1), - RecurrenceDates = new List { recurrenceDates } + Duration = new Duration(days: 2), + RecurrenceDates = new List { recurrenceDates1, recurrenceDates2 } }; cal.Events.Add(calendarEvent); @@ -260,8 +285,9 @@ public void RDate_DateOnlyAndDateTime_AreProcessedCorrectly() Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2))); Assert.That(occurrences[1].Period.EffectiveDuration, Is.EqualTo(new Duration(days: 1))); Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0))); - Assert.That(ics, Does.Contain("RDATE:20231002/P1D,20231003T100000")); - Assert.That(ics, Does.Contain("DURATION:P1D")); + Assert.That(ics, Does.Contain("RDATE;VALUE=PERIOD:20231002/P1D")); + Assert.That(ics, Does.Contain("RDATE:20231003T100000")); + Assert.That(ics, Does.Contain("DURATION:P2D")); }); } @@ -295,29 +321,10 @@ public void RDate_OverlappingPeriods_AreProcessedCorrectly() Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0))); Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0))); Assert.That(occurrences[2].Period.StartTime, Is.EqualTo(new CalDateTime(2023, 10, 2, 11, 0, 0))); - Assert.That(ics, Does.Contain("RDATE:20231002T100000/PT2H,20231002T110000/PT2H")); + Assert.That(ics, Does.Contain("RDATE;VALUE=PERIOD:20231002T100000/PT2H,20231002T110000/PT2H")); }); } - [Test] - public void RDate_DateOnly_WithExactDuration_ShouldThrow() - { - var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, "America/New_York"); - var recurrenceDates = new PeriodList - { - new Period(new CalDateTime(2023, 10, 2)), - }; - - var calendarEvent = new CalendarEvent - { - Start = eventStart, - Duration = new Duration(hours: 1), // Exact duration cannot be added to date-only recurrence - RecurrenceDates = new List { recurrenceDates } - }; - - Assert.That(() => { _ = calendarEvent.GetOccurrences().ToList(); }, Throws.InvalidOperationException); - } - [Test] public void RDate_LargeNumberOfDates_ShouldBeLineFolded() { @@ -355,4 +362,65 @@ public void RDate_LargeNumberOfDates_ShouldBeLineFolded() Assert.That(ics, Does.Contain(" T100000,20240106T100000,20240107T100000,20240108T100000,20240109T100000")); }); } + + [Test] + public void AddingDifferentTimeZonesToPeriodList_ShouldThrow() + { + Assert.That(() => + { + _ = new PeriodList + { + new Period(new CalDateTime(2023, 10, 2, 10, 0, 0, "America/Los_Angeles")), + new Period(new CalDateTime(2023, 10, 3, 10, 0, 0, "Europe/London")) + }; + }, Throws.ArgumentException); + } + + [Test] + public void AddingDifferentPeriodTypes_ShouldThrow() + { + Assert.Multiple(() => + { + Assert.That(() => + { + _ = new PeriodList + { + // date-only + new Period(new CalDateTime(2023, 10, 2)), + // date-time + new Period(new CalDateTime(2023, 10, 3, 10, 0, 0)) + }; + }, Throws.ArgumentException); + + Assert.That(() => + { + _ = new PeriodList + { + // period + new Period(new CalDateTime(2023, 10, 3), Duration.FromDays(1)), + // date-only + new Period(new CalDateTime(2023, 10, 2)) + }; + }, Throws.ArgumentException); + }); + } + + [Test] + public void RDate_DateOnly_WithExactDuration_ShouldThrow() + { + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, "America/New_York"); + var recurrenceDates = new PeriodList + { + new Period(new CalDateTime(2023, 10, 2)), + }; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 1), // Exact duration cannot be added to date-only recurrence + RecurrenceDates = new List { recurrenceDates } + }; + + Assert.That(() => { _ = calendarEvent.GetOccurrences().ToList(); }, Throws.InvalidOperationException); + } } diff --git a/Ical.Net/DataTypes/Period.cs b/Ical.Net/DataTypes/Period.cs index b45a55bd..36a7fa12 100644 --- a/Ical.Net/DataTypes/Period.cs +++ b/Ical.Net/DataTypes/Period.cs @@ -15,7 +15,7 @@ namespace Ical.Net.DataTypes; /// A period can be defined
/// 1. by a start time and an end time,
/// 2. by a start time and a duration,
-/// 3. by a start time only, with the duration unspecified. +/// 3. by a start date/time or date-only, with the duration unspecified. /// public class Period : EncodableDataType, IComparable { @@ -44,7 +44,7 @@ public Period(IDateTime start, IDateTime? end = null) throw new ArgumentException($"End time ({end}) must be greater than start time ({start}).", nameof(end)); } - if (end?.TzId != null && start.TzId != end.TzId) throw new ArgumentException($"Start time ({start}) and end time ({end}) must have the same timezone.", nameof(end)); + EnsureConsistentTimezones(start, end); _startTime = start ?? throw new ArgumentNullException(nameof(start), "Start time cannot be null."); _endTime = end; } @@ -118,7 +118,11 @@ public override int GetHashCode() public virtual IDateTime StartTime //NOSONAR { get => _startTime; - set => _startTime = value; + set + { + EnsureConsistentTimezones(value, _endTime); + _startTime = value; + } } /// @@ -134,6 +138,7 @@ public virtual IDateTime? EndTime get => _endTime; set { + EnsureConsistentTimezones(_startTime, value); _endTime = value; if (_endTime != null) { @@ -175,6 +180,22 @@ public virtual Duration? Duration /// public virtual Duration? EffectiveDuration => _duration ?? (_endTime != null ? GetEffectiveDuration() : null); + private static void EnsureConsistentTimezones(IDateTime start, IDateTime? end) + { + if (end?.TzId != null && start.TzId != end.TzId) throw new ArgumentException($"Start time ({start}) and end time ({end}) must have the same timezone."); + } + + internal string? TzId => _startTime.TzId; // same timezone for start and end + + internal PeriodKind GetPeriodKind() + { + if (EffectiveDuration != null) + { + return PeriodKind.Period; + } + return StartTime.HasTime ? PeriodKind.DateTime : PeriodKind.DateOnly; + } + private Duration GetEffectiveDuration() { if (_duration is { } d) diff --git a/Ical.Net/DataTypes/PeriodKind.cs b/Ical.Net/DataTypes/PeriodKind.cs new file mode 100644 index 00000000..af7c66fd --- /dev/null +++ b/Ical.Net/DataTypes/PeriodKind.cs @@ -0,0 +1,28 @@ +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. + +#nullable enable +namespace Ical.Net.DataTypes; + +/// +/// The kind of s that can be added to a . +/// +internal enum PeriodKind +{ + /// + /// The period kind is undefined. + /// + Undefined, + /// + /// A date-time kind. + /// + DateTime, + /// + /// A date-only kind. + /// + DateOnly, + /// + /// A period that has a . + /// + Period +} diff --git a/Ical.Net/DataTypes/PeriodList.cs b/Ical.Net/DataTypes/PeriodList.cs index 6471cdd7..3b749006 100644 --- a/Ical.Net/DataTypes/PeriodList.cs +++ b/Ical.Net/DataTypes/PeriodList.cs @@ -25,7 +25,13 @@ public class PeriodList : EncodableDataType, IList /// Gets the timezone ID of the .
/// The timezone of the first item added determines the timezone for the list. /// - public string? TzId { get; private set; } + internal string? TzId { get; private set; } + + /// + /// Gets the kind that this is representing.
+ /// Only s with the same can be added to the list. + ///
+ internal PeriodKind PeriodListKind { get; private set; } /// /// Gets the number of s of the list. @@ -48,6 +54,7 @@ public PeriodList() /// Creates a new instance of the class from the . /// /// + /// private PeriodList(StringReader value) { var serializer = new PeriodListSerializer(); @@ -62,6 +69,7 @@ private PeriodList(StringReader value) /// Creates a new instance of the class from the object. /// /// + /// public static PeriodList FromStringReader(StringReader value) => new PeriodList(value); /// @@ -70,6 +78,7 @@ private PeriodList(StringReader value) /// /// /// A new instance of the . + /// public static PeriodList FromDateTime(IDateTime value) { var pl = new PeriodList().Add(value); @@ -188,9 +197,10 @@ public Period this[int index] /// RFC 5545 section 3.8.5.1. /// /// The for an 'RDATE'. + /// public void Add(Period item) { - EnsureConsistentTimezones(item.StartTime, item.EndTime); + EnsureConsistentTimezoneAndPeriodKind(item); Periods.Add(item); } @@ -202,10 +212,12 @@ public void Add(Period item) /// /// /// This instance of the . + /// public PeriodList Add(IDateTime dt) { - EnsureConsistentTimezones(dt, null); - Periods.Add(new Period(dt)); + var p = new Period(dt); + EnsureConsistentTimezoneAndPeriodKind(p); + Periods.Add(p); return this; } @@ -214,10 +226,11 @@ public PeriodList Add(IDateTime dt) /// The timezone of the first value added determines the timezone for the list. /// /// - /// + /// This instance of the . + /// public PeriodList AddPeriod(Period item) { - EnsureConsistentTimezones(item.StartTime, item.EndTime); + EnsureConsistentTimezoneAndPeriodKind(item); Add(item); return this; } @@ -235,13 +248,22 @@ public PeriodList AddPeriod(Period item) public IEnumerator GetEnumerator() => Periods.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => Periods.GetEnumerator(); - private void EnsureConsistentTimezones(IDateTime dt1, IDateTime? dt2) + private void EnsureConsistentTimezoneAndPeriodKind(Period p) { - if (Count != 0 && (dt1.TzId != TzId || (dt2 != null && dt2.TzId != TzId))) + if (Count != 0 && p.GetPeriodKind() != PeriodListKind) { - throw new ArgumentException($"All Periods of a PeriodList must have the same timezone. Current TzId: {TzId}, Provided TzId: {dt1.TzId} {dt2?.TzId}"); + throw new ArgumentException($"All Periods of a PeriodList must have the same value type. Current ValueType: {PeriodListKind}, Provided ValueType: {p.GetPeriodKind()}"); } - if (Count == 0) TzId = dt1.TzId; + if (Count != 0 && p.TzId != TzId) + { + throw new ArgumentException($"All Periods of a PeriodList must have the same timezone. Current TzId: {TzId}, Provided TzId: {p.TzId}"); + } + + if (Count == 0) + { + TzId = p.StartTime.TzId; + PeriodListKind = p.GetPeriodKind(); + } } } diff --git a/Ical.Net/Serialization/DataTypes/PeriodListSerializer.cs b/Ical.Net/Serialization/DataTypes/PeriodListSerializer.cs index 956d4c42..973bd453 100644 --- a/Ical.Net/Serialization/DataTypes/PeriodListSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/PeriodListSerializer.cs @@ -36,9 +36,20 @@ public PeriodListSerializer(SerializationContext ctx) : base(ctx) { } var parts = new List(periodList.Count); + // Set TzId before ValueType, so that it serializes first + if (!string.IsNullOrWhiteSpace(periodList.TzId)) + { + periodList.Parameters.Set("TZID", periodList.TzId); + } + + if (periodList.PeriodListKind == PeriodKind.Period) + { + periodList.SetValueType("PERIOD"); + } + foreach (var p in periodList) { - parts.Add(p.EndTime != null || p.Duration != null + parts.Add(p.EffectiveDuration != null ? periodSerializer.SerializeToString(p) : dtSerializer.SerializeToString(p.StartTime)); }