diff --git a/Ical.Net.Tests/CalendarEventTest.cs b/Ical.Net.Tests/CalendarEventTest.cs index 20787e8f6..e616cebef 100644 --- a/Ical.Net.Tests/CalendarEventTest.cs +++ b/Ical.Net.Tests/CalendarEventTest.cs @@ -17,7 +17,7 @@ namespace Ical.Net.Tests; [TestFixture] public class CalendarEventTest { - private static readonly DateTime _now = DateTime.UtcNow; + private static readonly DateTime _now = DateTime.SpecifyKind(DateTime.Now, DateTimeKind.Unspecified); private static readonly DateTime _later = _now.AddHours(1); private static readonly string _uid = Guid.NewGuid().ToString(); @@ -249,29 +249,29 @@ public void RrulesAreSignificantTests() Assert.That(testRrule.GetHashCode(), Is.Not.EqualTo(simpleEvent.GetHashCode())); var testRdate = GetSimpleEvent(); - testRdate.RecurrenceDates = new List { new PeriodList { new Period(new CalDateTime(_now)) } }; + testRdate.RecurrenceDatesPeriodLists = new List { new PeriodList { new Period(new CalDateTime(_now)) } }; Assert.That(testRdate, Is.Not.EqualTo(simpleEvent)); Assert.That(testRdate.GetHashCode(), Is.Not.EqualTo(simpleEvent.GetHashCode())); } private static List GetSimpleRecurrenceList() => new List { new RecurrencePattern(FrequencyType.Daily, 1) { Count = 5 } }; - private static List GetExceptionDates() - => new List { new PeriodList { new Period(new CalDateTime(_now.AddDays(1).Date)) } }; + private static List GetExceptionDates() + => new List { new CalDateTime(_now.AddDays(1).Date) }; [Test] public void EventWithRecurrenceAndExceptionComparison() { var vEvent = GetSimpleEvent(); vEvent.RecurrenceRules = GetSimpleRecurrenceList(); - vEvent.ExceptionDates = GetExceptionDates(); + vEvent.ExceptionDates.AddRange(GetExceptionDates()); var calendar = new Calendar(); calendar.Events.Add(vEvent); var vEvent2 = GetSimpleEvent(); vEvent2.RecurrenceRules = GetSimpleRecurrenceList(); - vEvent2.ExceptionDates = GetExceptionDates(); + vEvent2.ExceptionDates.AddRange(GetExceptionDates()); var cal2 = new Calendar(); cal2.Events.Add(vEvent2); @@ -283,8 +283,7 @@ public void EventWithRecurrenceAndExceptionComparison() { Assert.That(eventB.RecurrenceRules.First(), Is.EqualTo(eventA.RecurrenceRules.First())); Assert.That(eventB.RecurrenceRules.First().GetHashCode(), Is.EqualTo(eventA.RecurrenceRules.First().GetHashCode())); - Assert.That(eventB.ExceptionDates.First(), Is.EqualTo(eventA.ExceptionDates.First())); - Assert.That(eventB.ExceptionDates.First().GetHashCode(), Is.EqualTo(eventA.ExceptionDates.First().GetHashCode())); + Assert.That(eventB.ExceptionDates.GetAllDates().First(), Is.EqualTo(eventA.ExceptionDates.GetAllDates().First())); Assert.That(eventB.GetHashCode(), Is.EqualTo(eventA.GetHashCode())); Assert.That(eventB, Is.EqualTo(eventA)); Assert.That(cal2, Is.EqualTo(calendar)); @@ -309,7 +308,7 @@ public void AddingExdateToEventShouldNotBeEqualToOriginal() var deserializedNoExDate = Calendar.Load(serialized); Assert.That(deserializedNoExDate, Is.EqualTo(cal1)); - vEvent.ExceptionDates = GetExceptionDates(); + vEvent.ExceptionDates.AddRange(GetExceptionDates()); serialized = serializer.SerializeToString(cal1); var deserializedWithExDate = Calendar.Load(serialized); @@ -516,7 +515,7 @@ public void GetEffectiveDurationTests() { Assert.That(evt.DtStart.Value, Is.EqualTo(dt.Date)); Assert.That(evt.Duration, Is.Null); - Assert.That(evt.GetEffectiveDuration(), Is.EqualTo(Duration.FromDays(1))); + Assert.That(evt.GetEffectiveDuration(), Is.EqualTo(DataTypes.Duration.FromDays(1))); }); evt = new CalendarEvent diff --git a/Ical.Net.Tests/CalendarPropertiesTest.cs b/Ical.Net.Tests/CalendarPropertiesTest.cs index f416bb939..1549cca96 100644 --- a/Ical.Net.Tests/CalendarPropertiesTest.cs +++ b/Ical.Net.Tests/CalendarPropertiesTest.cs @@ -61,4 +61,4 @@ public void PropertySetValueMustAllowNull() var property = new CalendarProperty(); Assert.DoesNotThrow(() => property.SetValue(null)); } -} \ No newline at end of file +} diff --git a/Ical.Net.Tests/CollectionHelpersTests.cs b/Ical.Net.Tests/CollectionHelpersTests.cs index 7f13ab1e8..50eb4676b 100644 --- a/Ical.Net.Tests/CollectionHelpersTests.cs +++ b/Ical.Net.Tests/CollectionHelpersTests.cs @@ -14,7 +14,7 @@ namespace Ical.Net.Tests; internal class CollectionHelpersTests { - private static readonly DateTime _now = DateTime.UtcNow; + private static readonly DateTime _now = DateTime.SpecifyKind(DateTime.UtcNow, DateTimeKind.Unspecified); private static List GetExceptionDates() => new List { new PeriodList { new Period(new CalDateTime(_now.AddDays(1).Date)) } }; @@ -29,7 +29,7 @@ public void ExDateTests() }); var changedPeriod = GetExceptionDates(); - changedPeriod.First().First().StartTime = new CalDateTime(_now.AddHours(-1)); + changedPeriod[0][0] = new Period(new CalDateTime(_now.AddHours(-1)), changedPeriod[0][0].EndTime); Assert.That(changedPeriod, Is.Not.EqualTo(GetExceptionDates())); } diff --git a/Ical.Net.Tests/CopyComponentTests.cs b/Ical.Net.Tests/CopyComponentTests.cs index 24d77f30d..bc876ddf7 100644 --- a/Ical.Net.Tests/CopyComponentTests.cs +++ b/Ical.Net.Tests/CopyComponentTests.cs @@ -110,7 +110,7 @@ public void CopyFreeBusyTest() { Start = new CalDateTime(_now), End = new CalDateTime(_later), - Entries = { new FreeBusyEntry { Language = "English", StartTime = new CalDateTime(2024, 10, 1), Duration = Duration.FromDays(1), Status = FreeBusyStatus.Busy } } + Entries = { new FreeBusyEntry(new Period(new CalDateTime(2024, 10, 1), Duration.FromDays(1)), FreeBusyStatus.Busy) { Language = "English" }} }; var copy = orig.Copy(); diff --git a/Ical.Net.Tests/DeserializationTests.cs b/Ical.Net.Tests/DeserializationTests.cs index 5a596c720..ea995ca68 100644 --- a/Ical.Net.Tests/DeserializationTests.cs +++ b/Ical.Net.Tests/DeserializationTests.cs @@ -267,36 +267,37 @@ public void Encoding3() [Test] public void Event8() { - var sr = @"BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//Apple Computer\, Inc//iCal 1.0//EN -CALSCALE:GREGORIAN -BEGIN:VEVENT -CREATED:20070404T211714Z -DTEND:20070407T010000Z -DTSTAMP:20070404T211714Z -DTSTART:20070406T230000Z -DURATION:PT2H -RRULE:FREQ=WEEKLY;UNTIL=20070801T070000Z;BYDAY=FR -SUMMARY:Friday Meetings -DTSTAMP:20040103T033800Z -SEQUENCE:1 -UID:fd940618-45e2-4d19-b118-37fd7a8e3906 -END:VEVENT -BEGIN:VEVENT -CREATED:20070404T204310Z -DTEND:20070416T030000Z -DTSTAMP:20070404T204310Z -DTSTART:20070414T200000Z -DURATION:P1DT7H -RRULE:FREQ=DAILY;COUNT=12;BYDAY=SA,SU -SUMMARY:Weekend Yea! -DTSTAMP:20040103T033800Z -SEQUENCE:1 -UID:ebfbd3e3-cc1e-4a64-98eb-ced2598b3908 -END:VEVENT -END:VCALENDAR -"; + var sr = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Apple Computer\, Inc//iCal 1.0//EN + CALSCALE:GREGORIAN + BEGIN:VEVENT + CREATED:20070404T211714Z + DTEND:20070407T010000Z + DTSTAMP:20070404T211714Z + DTSTART:20070406T230000Z + DURATION:PT2H + RRULE:FREQ=WEEKLY;UNTIL=20070801T070000Z;BYDAY=FR + SUMMARY:Friday Meetings + DTSTAMP:20040103T033800Z + SEQUENCE:1 + UID:fd940618-45e2-4d19-b118-37fd7a8e3906 + END:VEVENT + BEGIN:VEVENT + CREATED:20070404T204310Z + DTEND:20070416T030000Z + DTSTAMP:20070404T204310Z + DTSTART:20070414T200000Z + DURATION:P1DT7H + RRULE:FREQ=DAILY;COUNT=12;BYDAY=SA,SU + SUMMARY:Weekend Yea! + DTSTAMP:20040103T033800Z + SEQUENCE:1 + UID:ebfbd3e3-cc1e-4a64-98eb-ced2598b3908 + END:VEVENT + END:VCALENDAR + """; var iCal = Calendar.Load(sr); Assert.That(iCal.Events.Count == 2, Is.True, "There should be 2 events in the parsed calendar"); Assert.That(iCal.Events["fd940618-45e2-4d19-b118-37fd7a8e3906"], Is.Not.Null, "Event fd940618-45e2-4d19-b118-37fd7a8e3906 should exist in the calendar"); @@ -351,25 +352,46 @@ public void Google1() public void RecurrenceDates1() { var iCal = Calendar.Load(IcsFiles.RecurrenceDates1); - Assert.That(iCal.Events, Has.Count.EqualTo(1)); - Assert.That(iCal.Events.First().RecurrenceDates, Has.Count.EqualTo(3)); + + var expectedStartTimes = new List + { + // date are unique + new CalDateTime(1997, 7, 14, 12, 30, 0, CalDateTime.UtcTzId), + new CalDateTime(1996, 4, 3, 2, 0, 0, CalDateTime.UtcTzId), + new CalDateTime(1996, 4, 4, 1, 0, 0, CalDateTime.UtcTzId), + new CalDateTime(1997, 1, 1), + new CalDateTime(1997, 1, 20), + new CalDateTime(1997, 2, 17), + new CalDateTime(1997, 4, 21), + new CalDateTime(1997, 5, 26), + new CalDateTime(1997, 7, 4), + new CalDateTime(1997, 9, 1), + new CalDateTime(1997, 10, 14), + new CalDateTime(1997, 11, 28), + new CalDateTime(1997, 11, 29), + new CalDateTime(1997, 12, 25) + }; + + var expectedEndTime = new CalDateTime(new DateTime(1996, 4, 3, 4, 0, 0, DateTimeKind.Utc)); + + var actualStartTimes = iCal.Events[0].RecurrenceDates.GetAllPeriods() + .Select(p => p.StartTime) + .Union(iCal.Events[0].RecurrenceDates.GetAllDates()) + .ToList(); Assert.Multiple(() => { - Assert.That(iCal.Events.First().RecurrenceDates[0][0].StartTime, Is.EqualTo((CalDateTime)new DateTime(1997, 7, 14, 12, 30, 0, DateTimeKind.Utc))); - Assert.That(iCal.Events.First().RecurrenceDates[1][0].StartTime, Is.EqualTo((CalDateTime)new DateTime(1996, 4, 3, 2, 0, 0, DateTimeKind.Utc))); - Assert.That(iCal.Events.First().RecurrenceDates[1][0].EndTime, Is.EqualTo((CalDateTime)new DateTime(1996, 4, 3, 4, 0, 0, DateTimeKind.Utc))); - Assert.That(iCal.Events.First().RecurrenceDates[2][0].StartTime, Is.EqualTo(new CalDateTime(1997, 1, 1))); - Assert.That(iCal.Events.First().RecurrenceDates[2][1].StartTime, Is.EqualTo(new CalDateTime(1997, 1, 20))); - Assert.That(iCal.Events.First().RecurrenceDates[2][2].StartTime, Is.EqualTo(new CalDateTime(1997, 2, 17))); - Assert.That(iCal.Events.First().RecurrenceDates[2][3].StartTime, Is.EqualTo(new CalDateTime(1997, 4, 21))); - Assert.That(iCal.Events.First().RecurrenceDates[2][4].StartTime, Is.EqualTo(new CalDateTime(1997, 5, 26))); - Assert.That(iCal.Events.First().RecurrenceDates[2][5].StartTime, Is.EqualTo(new CalDateTime(1997, 7, 4))); - Assert.That(iCal.Events.First().RecurrenceDates[2][6].StartTime, Is.EqualTo(new CalDateTime(1997, 9, 1))); - Assert.That(iCal.Events.First().RecurrenceDates[2][7].StartTime, Is.EqualTo(new CalDateTime(1997, 10, 14))); - Assert.That(iCal.Events.First().RecurrenceDates[2][8].StartTime, Is.EqualTo(new CalDateTime(1997, 11, 28))); - Assert.That(iCal.Events.First().RecurrenceDates[2][9].StartTime, Is.EqualTo(new CalDateTime(1997, 11, 29))); - Assert.That(iCal.Events.First().RecurrenceDates[2][10].StartTime, Is.EqualTo(new CalDateTime(1997, 12, 25))); + Assert.That(iCal.Events, Has.Count.EqualTo(1)); + Assert.That(iCal.Events[0].RecurrenceDatesPeriodLists, Has.Count.EqualTo(3)); + Assert.That(actualStartTimes, Has.Count.EqualTo(expectedStartTimes.Count)); + + foreach (var date in expectedStartTimes) + { + Assert.That(actualStartTimes.Single(dt => dt.Equals(date)), + Is.EqualTo(date), "Should contain " + date); + } + + Assert.That(iCal.Events[0].RecurrenceDates.Contains(new Period(expectedStartTimes[1], expectedEndTime))); }); } @@ -549,13 +571,14 @@ public void Property1() [TestCase(false)] public void KeepApartDtEndAndDuration_Tests(bool useDtEnd) { - var calStr = $@"BEGIN:VCALENDAR -BEGIN:VEVENT -DTSTART:20070406T230000Z -{(useDtEnd ? "DTEND:20070407T010000Z" : "DURATION:PT1H")} -END:VEVENT -END:VCALENDAR -"; + var calStr = $""" + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTART:20070406T230000Z + {(useDtEnd ? "DTEND:20070407T010000Z" : "DURATION:PT1H")} + END:VEVENT + END:VCALENDAR + """; var calendar = Calendar.Load(calStr); diff --git a/Ical.Net.Tests/EqualityAndHashingTests.cs b/Ical.Net.Tests/EqualityAndHashingTests.cs index f40113ddc..1a7a54adf 100644 --- a/Ical.Net.Tests/EqualityAndHashingTests.cs +++ b/Ical.Net.Tests/EqualityAndHashingTests.cs @@ -18,7 +18,7 @@ namespace Ical.Net.Tests; public class EqualityAndHashingTests { - private const string _someTz = "America/Los_Angeles"; + private const string TzId = "America/Los_Angeles"; private static readonly DateTime _nowTime = DateTime.Parse("2016-07-16T16:47:02.9310521-04:00"); private static readonly DateTime _later = _nowTime.AddHours(1); @@ -39,8 +39,8 @@ public static IEnumerable CalDateTime_TestCases() var nowCalDt = new CalDateTime(_nowTime); yield return new TestCaseData(nowCalDt, new CalDateTime(_nowTime)).SetName("Now, no time zone"); - var nowCalDtWithTz = new CalDateTime(_nowTime, _someTz); - yield return new TestCaseData(nowCalDtWithTz, new CalDateTime(_nowTime, _someTz)).SetName("Now, with time zone"); + var nowCalDtWithTz = new CalDateTime(_nowTime, TzId); + yield return new TestCaseData(nowCalDtWithTz, new CalDateTime(_nowTime, TzId)).SetName("Now, with time zone"); } [Test] @@ -281,7 +281,7 @@ public void Resources_Tests() }); } - internal static (byte[] original, byte[] copy) GetAttachments() + private static (byte[] original, byte[] copy) GetAttachments() { var payload = Encoding.UTF8.GetBytes("This is an attachment!"); var payloadCopy = new byte[payload.Length]; @@ -347,112 +347,51 @@ public void PeriodListTests() new DateTime(2017, 03, 02, 06, 00, 00), new DateTime(2017, 03, 03, 06, 00, 00), new DateTime(2017, 03, 06, 06, 00, 00), - new DateTime(2017, 03, 07, 06, 00, 00), - new DateTime(2017, 03, 08, 06, 00, 00), - new DateTime(2017, 03, 09, 06, 00, 00), - new DateTime(2017, 03, 10, 06, 00, 00), - new DateTime(2017, 03, 13, 06, 00, 00), - new DateTime(2017, 03, 14, 06, 00, 00), - new DateTime(2017, 03, 17, 06, 00, 00), - new DateTime(2017, 03, 20, 06, 00, 00), - new DateTime(2017, 03, 21, 06, 00, 00), - new DateTime(2017, 03, 22, 06, 00, 00), - new DateTime(2017, 03, 23, 06, 00, 00), - new DateTime(2017, 03, 24, 06, 00, 00), - new DateTime(2017, 03, 27, 06, 00, 00), - new DateTime(2017, 03, 28, 06, 00, 00), - new DateTime(2017, 03, 29, 06, 00, 00), - new DateTime(2017, 03, 30, 06, 00, 00), - new DateTime(2017, 03, 31, 06, 00, 00), - new DateTime(2017, 04, 03, 06, 00, 00), - new DateTime(2017, 04, 05, 06, 00, 00), - new DateTime(2017, 04, 06, 06, 00, 00), - new DateTime(2017, 04, 07, 06, 00, 00), - new DateTime(2017, 04, 10, 06, 00, 00), - new DateTime(2017, 04, 11, 06, 00, 00), - new DateTime(2017, 04, 12, 06, 00, 00), - new DateTime(2017, 04, 13, 06, 00, 00), - new DateTime(2017, 04, 17, 06, 00, 00), - new DateTime(2017, 04, 18, 06, 00, 00), - new DateTime(2017, 04, 19, 06, 00, 00), - new DateTime(2017, 04, 20, 06, 00, 00), - new DateTime(2017, 04, 21, 06, 00, 00), - new DateTime(2017, 04, 24, 06, 00, 00), - new DateTime(2017, 04, 25, 06, 00, 00), - new DateTime(2017, 04, 27, 06, 00, 00), new DateTime(2017, 04, 28, 06, 00, 00), - new DateTime(2017, 05, 01, 06, 00, 00), + new DateTime(2017, 05, 01, 06, 00, 00) } .Select(dt => new Period(new CalDateTime(dt))).ToList(); + var a = new PeriodList(); foreach (var period in startTimesA) { a.Add(period); } - //Difference from A: first element became the second, and last element became the second-to-last element + // Difference from A: first element became the second, + // and last element became the second-to-last element var startTimesB = new List { new DateTime(2017, 03, 03, 06, 00, 00), new DateTime(2017, 03, 02, 06, 00, 00), new DateTime(2017, 03, 06, 06, 00, 00), - new DateTime(2017, 03, 07, 06, 00, 00), - new DateTime(2017, 03, 08, 06, 00, 00), - new DateTime(2017, 03, 09, 06, 00, 00), - new DateTime(2017, 03, 10, 06, 00, 00), - new DateTime(2017, 03, 13, 06, 00, 00), - new DateTime(2017, 03, 14, 06, 00, 00), - new DateTime(2017, 03, 17, 06, 00, 00), - new DateTime(2017, 03, 20, 06, 00, 00), - new DateTime(2017, 03, 21, 06, 00, 00), - new DateTime(2017, 03, 22, 06, 00, 00), - new DateTime(2017, 03, 23, 06, 00, 00), - new DateTime(2017, 03, 24, 06, 00, 00), - new DateTime(2017, 03, 27, 06, 00, 00), - new DateTime(2017, 03, 28, 06, 00, 00), - new DateTime(2017, 03, 29, 06, 00, 00), - new DateTime(2017, 03, 30, 06, 00, 00), - new DateTime(2017, 03, 31, 06, 00, 00), - new DateTime(2017, 04, 03, 06, 00, 00), - new DateTime(2017, 04, 05, 06, 00, 00), - new DateTime(2017, 04, 06, 06, 00, 00), - new DateTime(2017, 04, 07, 06, 00, 00), - new DateTime(2017, 04, 10, 06, 00, 00), - new DateTime(2017, 04, 11, 06, 00, 00), - new DateTime(2017, 04, 12, 06, 00, 00), - new DateTime(2017, 04, 13, 06, 00, 00), - new DateTime(2017, 04, 17, 06, 00, 00), - new DateTime(2017, 04, 18, 06, 00, 00), - new DateTime(2017, 04, 19, 06, 00, 00), - new DateTime(2017, 04, 20, 06, 00, 00), - new DateTime(2017, 04, 21, 06, 00, 00), - new DateTime(2017, 04, 24, 06, 00, 00), - new DateTime(2017, 04, 25, 06, 00, 00), - new DateTime(2017, 04, 27, 06, 00, 00), new DateTime(2017, 05, 01, 06, 00, 00), - new DateTime(2017, 04, 28, 06, 00, 00), + new DateTime(2017, 04, 28, 06, 00, 00) } .Select(dt => new Period(new CalDateTime(dt))).ToList(); + var b = new PeriodList(); + foreach (var period in startTimesB) { b.Add(period); } var collectionEqual = CollectionHelpers.Equals(a, b); - Assert.Multiple(() => - { - Assert.That(collectionEqual, Is.EqualTo(true)); - Assert.That(b.GetHashCode(), Is.EqualTo(a.GetHashCode())); - }); var listOfListA = new List { a }; var listOfListB = new List { b }; - Assert.That(CollectionHelpers.Equals(listOfListA, listOfListB), Is.True); var aThenB = new List { a, b }; var bThenA = new List { b, a }; - Assert.That(CollectionHelpers.Equals(aThenB, bThenA), Is.True); + + Assert.Multiple(() => + { + Assert.That(collectionEqual, Is.EqualTo(true)); + Assert.That(b.GetHashCode(), Is.EqualTo(a.GetHashCode())); + Assert.That(CollectionHelpers.Equals(listOfListA, listOfListB), Is.True); + Assert.That(CollectionHelpers.Equals(aThenB, bThenA), Is.True); + }); } [Test] @@ -489,8 +428,6 @@ private void TestComparison(Func calOp, Func. TestComparison((dt1, dt2) => dt1 == dt2, (i1, i2) => i1 == i2); TestComparison((dt1, dt2) => dt1 != dt2, (i1, i2) => i1 != i2); TestComparison((dt1, dt2) => dt1 > dt2, (i1, i2) => i1 > i2); diff --git a/Ical.Net.Tests/GetOccurrenceTests.cs b/Ical.Net.Tests/GetOccurrenceTests.cs index 1ccb105d8..f325b4c05 100644 --- a/Ical.Net.Tests/GetOccurrenceTests.cs +++ b/Ical.Net.Tests/GetOccurrenceTests.cs @@ -178,9 +178,9 @@ public void GetOccurrencesWithRecurrenceIdShouldEnumerate() BEGIN:VEVENT BACKGROUND:BUSY DESCRIPTION:Backup Daten + DTSTART;TZID=W. Europe Standard Time:20150305T000100 DTEND;TZID=W. Europe Standard Time:20150305T043000 DTSTAMP:20161122T120652Z - DTSTART;TZID=W. Europe Standard Time:20150305T000100 RESOURCES:server RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=0,12 SUMMARY:Server @@ -192,9 +192,9 @@ public void GetOccurrencesWithRecurrenceIdShouldEnumerate() BEGIN:VEVENT BACKGROUND:BUSY DESCRIPTION:Backup Daten - DTEND;TZID=W. Europe Standard Time:20161128T043000 + DTSTART;TZID=W. Europe Standard Time:20161128T043000 + DTEND;TZID=W. Europe Standard Time:20161128T150100 DTSTAMP:20161122T120652Z - DTSTART;TZID=W. Europe Standard Time:20161128T150100 RECURRENCE-ID:20161128T000100 RESOURCES:server SEQUENCE:0 @@ -216,13 +216,13 @@ public void GetOccurrencesWithRecurrenceIdShouldEnumerate() new CalDateTime("20161114T120100", "W. Europe Standard Time"), new CalDateTime("20161121T000100", "W. Europe Standard Time"), new CalDateTime("20161121T120100", "W. Europe Standard Time"), + new CalDateTime("20161128T043000", "W. Europe Standard Time"), // The replaced entry new CalDateTime("20161128T120100", "W. Europe Standard Time"), - new CalDateTime("20161128T150100", "W. Europe Standard Time"), // The replaced entry new CalDateTime("20161205T000100", "W. Europe Standard Time"), new CalDateTime("20161205T120100", "W. Europe Standard Time") ]; - // Specify end time that is between the original occurrence ta 20161128T0001 and the overridden one at 20161128T0030. + // Specify end time that is between the original occurrence at 20161128T0001 and the overridden one at 20161128T0030. // The overridden one shouldn't be returned, because it was replaced and the other one is in the future. var occurrences2 = collection.GetOccurrences(new CalDateTime(startCheck), new CalDateTime("20161128T002000", "W. Europe Standard Time")) .ToList(); diff --git a/Ical.Net.Tests/PeriodListTest.cs b/Ical.Net.Tests/PeriodListTest.cs new file mode 100644 index 000000000..7756fc6a1 --- /dev/null +++ b/Ical.Net.Tests/PeriodListTest.cs @@ -0,0 +1,194 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System; +using System.IO; +using Ical.Net.DataTypes; +using Ical.Net.Utility; +using NUnit.Framework; + +namespace Ical.Net.Tests; + +[TestFixture] +public class PeriodListTests +{ + [Test] + public void RemovePeriod_ShouldDecreaseCount() + { + // Arrange + var periodList = new PeriodList(); + var period = new Period(new CalDateTime(2023, 1, 1, 0, 0, 0), Duration.FromHours(1)); + periodList.Add(period); + + // Act + periodList.Remove(period); + + // Assert + Assert.That(periodList, Has.Count.EqualTo(0)); + } + + [Test] + public void GetSet_Period_ShouldReturnCorrectPeriod() + { + // Arrange + var periodList = new PeriodList(); + var period1 = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0), Duration.FromHours(1)); + var period2 = new Period(new CalDateTime(2025, 2, 1, 0, 0, 0), Duration.FromHours(1)); + + periodList.Add(period1); + periodList.Add(period2); + + // Act + var retrievedPeriod = periodList[0]; + periodList[1] = period2; + + // Assert + Assert.Multiple(() => + { + Assert.That(period1, Is.EqualTo(retrievedPeriod)); + Assert.That(periodList.Contains(period1), Is.True); + Assert.That(periodList[periodList.IndexOf(period2)], Is.EqualTo(period2)); + }); + } + + [Test] + public void ExistingPeriod_ShouldNotBeAddedAgain() + { + // Arrange + var periodList = new PeriodList(); + var period = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0), Duration.FromHours(1)); + + periodList.Add(period); + periodList.Add(period); + periodList.Insert(0, period); + + // Assert + Assert.That(periodList, Has.Count.EqualTo(1)); + } + + [Test] + public void AddPeriodWithInconsistentTimezoneOrPeriodKind_ShouldThrow() + { + // Arrange + var periodList = new PeriodList(); + var dateTimeUtcPeriod = new Period(new CalDateTime(2023, 1, 1, 12, 0, 0, CalDateTime.UtcTzId)); + var durationPeriod = new Period(new CalDateTime(2023, 1, 2, 12, 0, 0, CalDateTime.UtcTzId), new Duration(1, 0, 0, 0)); + var dateTimeLocalPeriod = new Period(new CalDateTime(2023, 1, 2, 12, 0, 0, "America/New_York")); + var dateOnlyPeriod = new Period(new CalDateTime(2023, 1, 3)); + + // The first period determines timezone and period kind + periodList.Add(dateTimeUtcPeriod); + + // Act & Assert + Assert.Multiple(() => + { + Assert.That(periodList.Count, Is.EqualTo(1)); + + // Test adding a period with inconsistent PeriodKind + Assert.That(() => periodList.Add(durationPeriod), Throws.ArgumentException); + + // Test adding a period with inconsistent TzId + Assert.That(() => periodList.Add(dateTimeLocalPeriod), Throws.ArgumentException); + + // Test adding period with inconsistent PeriodKind + Assert.That(() => periodList.Insert(0, dateOnlyPeriod), Throws.ArgumentException); + ; + Assert.That(periodList.Count, Is.EqualTo(1)); + }); + + } + [Test] + public void Clear_ShouldRemoveAllPeriods() + { + // Arrange + var periodList = new PeriodList(); + var pl = new PeriodList + { + new CalDateTime(2025, 1, 2), + new CalDateTime(2025, 1, 3) + }; + + periodList.AddRange(pl); + var count = periodList.Count; + + // Act + periodList.Clear(); + + // Assert + Assert.Multiple(() => + { + Assert.That(count, Is.EqualTo(2)); + Assert.That(periodList, Has.Count.EqualTo(0)); + }); + } + + [Test] + public void Create_FromStringReader_ShouldSucceed() + { + // Arrange + const string periodString = "20250101T000000Z/20250101T010000Z,20250102T000000Z/20250102T010000Z"; + using var reader = new StringReader(periodString); + + // Act + var periodList = PeriodList.FromStringReader(reader); + + // Assert + Assert.Multiple(() => + { + Assert.That(periodList, Has.Count.EqualTo(2)); + Assert.That(periodList[0].StartTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 0, 0, 0, "UTC"))); + Assert.That(periodList[0].EndTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "UTC"))); + Assert.That(periodList[1].StartTime, Is.EqualTo(new CalDateTime(2025, 1, 2, 0, 0, 0, "UTC"))); + Assert.That(periodList[1].EndTime, Is.EqualTo(new CalDateTime(2025, 1, 2, 1, 0, 0, "UTC"))); + Assert.That(periodList.IsReadOnly, Is.EqualTo(false)); + }); + } + + [Test] + public void InsertAt_ShouldInsertPeriodAtCorrectPosition() + { + // Arrange + var periodList = new PeriodList(); + var period1 = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0), Duration.FromHours(1)); + var period2 = new Period(new CalDateTime(2025, 1, 2, 0, 0, 0), Duration.FromHours(1)); + var period3 = new Period(new CalDateTime(2025, 1, 3, 0, 0, 0), Duration.FromHours(1)); + periodList.Add(period1); + periodList.Add(period3); + + // Act + periodList.Insert(1, period2); + + // Assert + Assert.Multiple(() => + { + Assert.That(periodList, Has.Count.EqualTo(3)); + Assert.That(periodList[1], Is.EqualTo(period2)); + }); + } + + [Test] + public void RemoveAt_ShouldRemovePeriodAtCorrectPosition() + { + // Arrange + var periodList = new PeriodList(); + var period1 = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0), Duration.FromHours(1)); + var period2 = new Period(new CalDateTime(2025, 1, 2, 0, 0, 0), Duration.FromHours(1)); + var period3 = new Period(new CalDateTime(2025, 1, 3, 0, 0, 0), Duration.FromHours(1)); + periodList.Add(period1); + periodList.Add(period2); + periodList.Add(period3); + + // Act + periodList.RemoveAt(1); + + // Assert + Assert.Multiple(() => + { + Assert.That(periodList, Has.Count.EqualTo(2)); + Assert.That(periodList[1], Is.EqualTo(period3)); + }); + } +} diff --git a/Ical.Net.Tests/PeriodListWrapperTests.cs b/Ical.Net.Tests/PeriodListWrapperTests.cs new file mode 100644 index 000000000..5c1771aae --- /dev/null +++ b/Ical.Net.Tests/PeriodListWrapperTests.cs @@ -0,0 +1,346 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System.Linq; +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using Ical.Net.Serialization; +using NUnit.Framework; + +namespace Ical.Net.Tests; + +[TestFixture] +public class PeriodListWrapperTests +{ + #region ** ExceptionDates ** + + [Test] + public void AddExDateTime_ShouldCreate_DedicatePeriodList() + { + var cal = new Calendar(); + var evt = new CalendarEvent(); + cal.Events.Add(evt); + var exDates = evt.ExceptionDates; + + exDates // Add date-only + .Add(new CalDateTime(2025, 1, 1)) + .Add(new CalDateTime(2025, 1, 2)) + // duplicate + .Add(new CalDateTime(2025, 1, 2)) + // Should go to a new PeriodList + .Add(new CalDateTime(2025, 1, 2, 14, 0, 0, CalDateTime.UtcTzId)); + + exDates.AddRange([ // Add date-time + new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin"), + new CalDateTime(2025, 1, 1, 10, 11, 13, "Europe/Berlin"), + // duplicate + new CalDateTime(2025, 1, 1, 10, 11, 13, "Europe/Berlin") + ]); + + var serialized = new CalendarSerializer(cal).SerializeToString(); + + Assert.Multiple(() => + { + // 2 dedicate PeriodList objects + Assert.That(evt.ExceptionDatesPeriodLists, Has.Count.EqualTo(3)); + + // First PeriodList is date-only + Assert.That(evt.ExceptionDatesPeriodLists[0], Has.Count.EqualTo(2)); + Assert.That(evt.ExceptionDatesPeriodLists[0].TzId, Is.Null); + Assert.That(evt.ExceptionDatesPeriodLists[0].PeriodKind, Is.EqualTo(PeriodKind.DateOnly)); + + // Second PeriodList is date-time UTC + Assert.That(evt.ExceptionDatesPeriodLists[1], Has.Count.EqualTo(1)); + Assert.That(evt.ExceptionDatesPeriodLists[1].TzId, Is.EqualTo(CalDateTime.UtcTzId)); + Assert.That(evt.ExceptionDatesPeriodLists[1].PeriodKind, Is.EqualTo(PeriodKind.DateTime)); + + // Second PeriodList is date-time + Assert.That(evt.ExceptionDatesPeriodLists[2], Has.Count.EqualTo(2)); + Assert.That(evt.ExceptionDatesPeriodLists[2].TzId, Is.EqualTo("Europe/Berlin")); + Assert.That(evt.ExceptionDatesPeriodLists[2].PeriodKind, Is.EqualTo(PeriodKind.DateTime)); + + Assert.That(serialized, + Does.Contain( + "EXDATE;VALUE=DATE:20250101,20250102\r\n" + + "EXDATE:20250102T140000Z\r\n" + + "EXDATE;TZID=Europe/Berlin:20250101T101112,20250101T101113")); + + // A flattened list of all dates + Assert.That(exDates.GetAllDates().Count(), Is.EqualTo(5)); + }); + } + + [Test] + public void RemoveExDateTime_ShouldRemove_FromPeriodList() + { + var evt = new CalendarEvent(); + var exDates = evt.ExceptionDates; + + var dateOnly = new CalDateTime(2025, 1, 1); + var dateTime = new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin"); + + exDates.Add(dateOnly); + exDates.Add(dateOnly.AddDays(1)); + exDates.Add(dateTime); + + var dateTimeSuccess = exDates.Remove(dateTime); + var dateOnlySuccess = exDates.Remove(dateOnly); + var dateOnlyFail = !exDates.Remove(dateOnly); // already removed + + Assert.Multiple(() => + { + Assert.That(dateOnlySuccess, Is.True); + Assert.That(dateTimeSuccess, Is.True); + Assert.That(dateOnlyFail, Is.True); + Assert.That(evt.ExceptionDatesPeriodLists[0], Has.Count.EqualTo(1)); + Assert.That(evt.ExceptionDatesPeriodLists[1], Is.Empty); + // Empty lists should work as well + evt.ExceptionDatesPeriodLists.Clear(); + Assert.That(() => exDates.Remove(dateTime), Is.False); + }); + } + + #endregion + + #region ** RecurrenceDates ** + + [Test] + public void AddRDateTime_ShouldCreate_DedicatePeriodList() + { + var cal = new Calendar(); + var evt = new CalendarEvent(); + cal.Events.Add(evt); + var recDates = evt.RecurrenceDates; + + recDates // Add date-only + .Add(new CalDateTime(2025, 1, 1)) + .Add(new CalDateTime(2025, 1, 2)) + .Add(new CalDateTime(2025, 1, 2)); // duplicate + + recDates.AddRange([ // Add date-time + new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin"), + new CalDateTime(2025, 1, 1, 10, 11, 13, "Europe/Berlin"), + new CalDateTime(2025, 1, 1, 10, 11, 13, "Europe/Berlin") // duplicate + ]); + + var serialized = new CalendarSerializer(cal).SerializeToString(); + + Assert.Multiple(() => + { + // 2 dedicate PeriodList objects + Assert.That(evt.RecurrenceDatesPeriodLists, Has.Count.EqualTo(2)); + + // First PeriodList is date-only + Assert.That(evt.RecurrenceDatesPeriodLists[0], Has.Count.EqualTo(2)); + Assert.That(evt.RecurrenceDatesPeriodLists[0].TzId, Is.Null); + Assert.That(evt.RecurrenceDatesPeriodLists[0].PeriodKind, Is.EqualTo(PeriodKind.DateOnly)); + + // Third PeriodList is date-time + Assert.That(evt.RecurrenceDatesPeriodLists[1], Has.Count.EqualTo(2)); + Assert.That(evt.RecurrenceDatesPeriodLists[1].TzId, Is.EqualTo("Europe/Berlin")); + Assert.That(evt.RecurrenceDatesPeriodLists[1].PeriodKind, Is.EqualTo(PeriodKind.DateTime)); + + Assert.That(serialized, + Does.Contain( + "RDATE;VALUE=DATE:20250101,20250102\r\n" + + "RDATE;TZID=Europe/Berlin:20250101T101112,20250101T101113")); + + // A flattened list of all dates + Assert.That(recDates.GetAllDates().Count(), Is.EqualTo(4)); + }); + } + + [Test] + public void AddRPeriod_ShouldCreate_DedicatePeriodList() + { + var cal = new Calendar(); + var evt = new CalendarEvent(); + cal.Events.Add(evt); + var recPeriod = evt.RecurrenceDates; + + recPeriod + // Add date-only period + .Add(Period.Create(new CalDateTime(2025, 1, 2), new CalDateTime(2025, 1, 5))) + // Add zoned date-time period + .Add(Period.Create(new CalDateTime(2025, 2, 2, 0, 0, 0, CalDateTime.UtcTzId), + new CalDateTime(2025, 2, 2, 6, 0, 0, CalDateTime.UtcTzId))) + // duplicate + .Add(Period.Create(new CalDateTime(2025, 2, 2, 0, 0, 0, CalDateTime.UtcTzId), + new CalDateTime(2025, 2, 2, 6, 0, 0, CalDateTime.UtcTzId))); + + recPeriod.AddRange([ + // Add date-only period with end time + Period.Create(new CalDateTime(2025, 5, 1), new CalDateTime(2025, 5, 10)), + // Add zoned date-time period with end time + Period.Create(new CalDateTime(2025, 6, 1, 12, 0, 0, CalDateTime.UtcTzId), new CalDateTime(2025, 6, 1, 14, 0, 0, CalDateTime.UtcTzId)), + // duplicate + Period.Create(new CalDateTime(2025, 6, 1, 12, 0, 0, CalDateTime.UtcTzId), new CalDateTime(2025, 6, 1, 14, 0, 0, CalDateTime.UtcTzId)), + // Add date-only with duration + Period.Create(new CalDateTime(2025, 5, 1), duration: Duration.FromDays(9)), + // Add zoned date-time period with duration + Period.Create(new CalDateTime(2025, 6, 1, 12, 0, 0, "Europe/Vienna"), duration: Duration.FromHours(8)) + ]); + + var serializer = new CalendarSerializer(cal); + var serialized = serializer.SerializeToString(); + // Assign the deserialized event + cal = Calendar.Load(serialized); + evt = cal.Events[0]; + + // Assert the serialized string and the deserialized event + Assert.Multiple(() => + { + // 2 dedicate PeriodList objects + Assert.That(evt.RecurrenceDatesPeriodLists, Has.Count.EqualTo(3)); + + // First PeriodList has date-only periods + Assert.That(evt.RecurrenceDatesPeriodLists[0], Has.Count.EqualTo(3)); + Assert.That(evt.RecurrenceDatesPeriodLists[0].TzId, Is.Null); + Assert.That(evt.RecurrenceDatesPeriodLists[0].PeriodKind, Is.EqualTo(PeriodKind.Period)); + + // Second PeriodList has UTC date-time periods + Assert.That(evt.RecurrenceDatesPeriodLists[1], Has.Count.EqualTo(2)); + Assert.That(evt.RecurrenceDatesPeriodLists[1].TzId, Is.EqualTo("UTC")); + Assert.That(evt.RecurrenceDatesPeriodLists[1].PeriodKind, Is.EqualTo(PeriodKind.Period)); + + // Third PeriodList has zoned date-time with duration + Assert.That(evt.RecurrenceDatesPeriodLists[2], Has.Count.EqualTo(1)); + Assert.That(evt.RecurrenceDatesPeriodLists[2].TzId, Is.EqualTo("Europe/Vienna")); + Assert.That(evt.RecurrenceDatesPeriodLists[2].PeriodKind, Is.EqualTo(PeriodKind.Period)); + + Assert.That(serialized, + Does.Contain( + "RDATE;VALUE=PERIOD:20250102/20250105,20250501/20250510,20250501/P9D\r\n" + + "RDATE;VALUE=PERIOD:20250202T000000Z/20250202T060000Z,20250601T120000Z/2025\r\n" + + " 0601T140000Z\r\n" + + "RDATE;TZID=Europe/Vienna;VALUE=PERIOD:20250601T120000/PT8H")); + + // A flattened list of all dates + Assert.That(recPeriod.GetAllDates().Count(), Is.EqualTo(0)); + // A flattened list of all periods + Assert.That(recPeriod.GetAllPeriods().Count(), Is.EqualTo(6)); + }); + } + + [Test] + public void RemoveRDateTime_ShouldRemove_FromPeriodList() + { + var evt = new CalendarEvent(); + var recDates = evt.RecurrenceDates; + + var period1 = new Period(new CalDateTime(2025, 1, 1), Duration.FromDays(5)); + var period2 = new Period(new CalDateTime(2025, 1, 1, 10, 0, 0, "Europe/Berlin"), Duration.FromHours(6)); + + recDates.Add(period1).Add(period2); + recDates.Add(new Period(period1.StartTime.AddDays(1), Duration.FromDays(5))); + + var period1Success = recDates.Remove(period1); + var period2Success = recDates.Remove(period2); + var period2Fail = !recDates.Remove(period2); // already removed + + Assert.Multiple(() => + { + Assert.That(period2Success, Is.True); + Assert.That(period1Success, Is.True); + Assert.That(period2Fail, Is.True); + Assert.That(evt.RecurrenceDatesPeriodLists[0], Has.Count.EqualTo(1)); + Assert.That(evt.RecurrenceDatesPeriodLists[1], Is.Empty); + }); + } + + [Test] + public void Contains_ShouldReturnTrue_IfPeriodExists() + { + var evt = new CalendarEvent(); + var recDates = evt.RecurrenceDates; + + var period1 = new Period(new CalDateTime(2025, 1, 1), Duration.FromDays(5)); + var period2 = new Period(new CalDateTime(2025, 1, 1, 10, 0, 0, "Europe/Berlin"), Duration.FromHours(6)); + + recDates.Add(period1).Add(period2); + + Assert.Multiple(() => + { + Assert.That(recDates.Contains(period1), Is.True); + Assert.That(recDates.Contains(period2), Is.True); + }); + } + + [Test] + public void Contains_ShouldReturnFalse_IfPeriodDoesNotExist() + { + var evt = new CalendarEvent(); + var recDates = evt.RecurrenceDates; + + var period1 = new Period(new CalDateTime(2025, 1, 1), Duration.FromDays(5)); + var period2 = new Period(new CalDateTime(2025, 1, 1, 10, 0, 0, "Europe/Berlin"), Duration.FromHours(6)); + + recDates.AddRange([period1, period2]); + + Assert.Multiple(() => + { + Assert.That(recDates.Contains(new Period(period1.StartTime.AddDays(1), Duration.FromDays(5))), Is.False); + Assert.That(recDates.Contains(new Period(period2.StartTime.AddDays(1), Duration.FromHours(6))), Is.False); + }); + } + + #endregion + + #region ** PeriodListWrapperBase ** + + [Test] + public void Clear_ShouldRemoveAllPeriods() + { + var evt = new CalendarEvent(); + var exDates = evt.ExceptionDates; + + exDates + .Add(new CalDateTime(2025, 1, 1)) + .Add(new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin")); + + exDates.Clear(); + + Assert.That(evt.ExceptionDatesPeriodLists, Is.Empty); + } + + [Test] + public void Contains_ShouldReturnTrue_IfDateExists() + { + var evt = new CalendarEvent(); + var exDates = evt.ExceptionDates; + + var dateOnly = new CalDateTime(2025, 1, 1); + var dateTime = new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin"); + + exDates.Add(dateOnly).Add(dateTime); + + Assert.Multiple(() => + { + Assert.That(exDates.Contains(dateOnly), Is.True); + Assert.That(exDates.Contains(dateTime), Is.True); + }); + } + + [Test] + public void Contains_ShouldReturnFalse_IfDateDoesNotExist() + { + var evt = new CalendarEvent(); + var exDates = evt.ExceptionDates; + + var dateOnly = new CalDateTime(2025, 1, 1); + var dateTime = new CalDateTime(2025, 1, 1, 10, 11, 12, "Europe/Berlin"); + + exDates.AddRange([dateOnly, dateTime]); + + Assert.Multiple(() => + { + Assert.That(exDates.Contains(dateOnly.AddDays(1)), Is.False); + Assert.That(exDates.Contains(dateTime.AddDays(1)), Is.False); + }); + } + + #endregion +} diff --git a/Ical.Net.Tests/PeriodTests.cs b/Ical.Net.Tests/PeriodTests.cs index 3ae3021d6..db3c5a766 100644 --- a/Ical.Net.Tests/PeriodTests.cs +++ b/Ical.Net.Tests/PeriodTests.cs @@ -27,57 +27,63 @@ public void CreatePeriodWithArguments() Assert.That(period.Duration, Is.Null); Assert.That(periodWithEndTime.StartTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"))); - Assert.That(periodWithEndTime.GetEffectiveEndTime(), Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "America/New_York"))); - Assert.That(periodWithEndTime.GetEffectiveDuration(), Is.EqualTo(Duration.FromHours(1))); + Assert.That(periodWithEndTime.EffectiveEndTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "America/New_York"))); + Assert.That(periodWithEndTime.EffectiveDuration, Is.EqualTo(Duration.FromHours(1))); Assert.That(periodWithDuration.StartTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"))); - Assert.That(periodWithDuration.GetEffectiveEndTime(), Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "America/New_York"))); - Assert.That(periodWithDuration.GetEffectiveDuration(), Is.EqualTo(Duration.FromHours(1))); + Assert.That(periodWithDuration.EffectiveEndTime, Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0, "America/New_York"))); + Assert.That(periodWithDuration.EffectiveDuration, Is.EqualTo(Duration.FromHours(1))); + + Assert.That(Period.Create(period.StartTime).Duration, Is.Null); + Assert.That(Period.Create(period.StartTime).EffectiveDuration, Is.Null); }); } [Test] public void CreatePeriodWithInvalidArgumentsShouldThrow() { - Assert.Throws(() => _ = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"), - new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"))); - Assert.Throws(() => - _ = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"), Duration.FromHours(-1))); - } - [Test] - public void SetAndGetStartTime() - { - var period = new Period(new CalDateTime(DateTime.UtcNow)); - var startTime = new CalDateTime(2025, 1, 1, 0, 0, 0); - period.StartTime = startTime; + Assert.Multiple(() => + { + // EndTime is before StartTime + Assert.Throws(() => _ = new Period( + new CalDateTime(2025, 1, 2, 0, 0, 0, "America/New_York"), + new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"))); - Assert.That(period.StartTime, Is.EqualTo(startTime)); - } + // Duration is negative + Assert.Throws(() => + _ = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0, "America/New_York"), Duration.FromHours(-1))); - [Test] - public void SetEndTime_GetDuration() - { - var period = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0)); - var endTime = new CalDateTime(2025, 1, 31, 0, 0, 0); - period.EndTime = endTime; + // Timezones are different + Assert.Throws(() => _ = new Period( + new CalDateTime(2025, 1, 2, 0, 0, 0, "America/New_York"), + new CalDateTime(2025, 1, 1, 0, 0, 0, "Europe/Vienna"))); - Assert.That(period.GetEffectiveEndTime(), Is.EqualTo(endTime)); - Assert.That(period.EndTime, Is.EqualTo(endTime)); - Assert.That(period.Duration, Is.Null); - Assert.That(period.GetEffectiveDuration(), Is.EqualTo(Duration.FromDays(30))); + // StartTime is date-only while EndTime has time + Assert.Throws(() => _ = new Period(new CalDateTime(2025, 1, 2, 0, 0, 0), + new CalDateTime(2025, 1, 1))); + }); } [Test] - public void SetDuration_GetEndTime() + public void Timezones_StartTime_EndTime_MustBeEqual() { - var period = new Period(new CalDateTime(2025, 1, 1, 0, 0, 0)); - var duration = Duration.FromHours(1); - period.Duration = duration; + var periods = new[] + { + (new CalDateTime(2025, 1, 1, 0, 0, 0, "Europe/Vienna"), + new CalDateTime(2025, 1, 1, 0, 0, 0, CalDateTime.UtcTzId)), + (new CalDateTime(2025, 1, 1, 0, 0, 0, null), + new CalDateTime(2025, 1, 1, 0, 0, 0, CalDateTime.UtcTzId)), + (new CalDateTime(2025, 1, 1, 0, 0, 0, CalDateTime.UtcTzId), + new CalDateTime(2025, 1, 1, 0, 0, 0, null)) + }; - Assert.That(period.GetEffectiveDuration(), Is.EqualTo(duration)); - Assert.That(period.Duration, Is.EqualTo(duration)); - Assert.That(period.EndTime, Is.Null); - Assert.That(period.GetEffectiveEndTime(), Is.EqualTo(new CalDateTime(2025, 1, 1, 1, 0, 0))); + Assert.Multiple(() => + { + foreach (var p in periods) + { + Assert.Throws(() => _ = new Period(p.Item1, p.Item2)); + } + }); } [Test] diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index a84d75cf1..b98cbb145 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -65,8 +65,7 @@ int eventIndex // Associate each incoming date/time with the calendar. expectedPeriods[i].AssociatedObject = cal; - var period = expectedPeriods[i].Copy(); - period.EndTime = period.GetEffectiveEndTime(); + var period = new Period(expectedPeriods[i].StartTime, expectedPeriods[i].EffectiveEndTime) {AssociatedObject = cal}; Assert.That(occurrences[i].Period, Is.EqualTo(period), "Event should occur on " + period); if (timeZones != null) @@ -2563,11 +2562,12 @@ public void DurationOfRecurrencesOverDst(string dtStart, string dtEnd, string d1 .Where(x => x != null) .Select(x => (Period)periodSerializer.Deserialize(new StringReader(x))) .ToArray(); - - foreach (var p in expectedPeriods) + + for (var index = 0; index < expectedPeriods.Length; index++) { - p.StartTime = p.StartTime.ToTimeZone(start.TzId); - p.EndTime = p.StartTime.Add(p.Duration.Value); + var p = expectedPeriods[index]; + var newStart = p.StartTime.ToTimeZone(start.TzId); + expectedPeriods[index] = Period.Create(newStart, end: newStart.Add(p.Duration!.Value)); } // date only cannot have a time zone @@ -3292,16 +3292,13 @@ public void AddExDateToEventAfterGetOccurrencesShouldRecomputeResult() Assert.That(occurrences, Has.Count.EqualTo(5)); var exDate = _now.AddDays(1); - var period = new Period(new CalDateTime(exDate, false)); - var periodList = new PeriodList { period }; - e.ExceptionDates.Add(periodList); + e.ExceptionDates.Add(new CalDateTime(exDate, false)); occurrences = e.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(4)); //Specifying just a date should "black out" that date var excludeTwoDaysFromNow = _now.AddDays(2).Date; - period = new Period(new CalDateTime(excludeTwoDaysFromNow, false)); - periodList.Add(period); + e.ExceptionDates.Add(new CalDateTime(excludeTwoDaysFromNow, false)); occurrences = e.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(3)); } @@ -3326,7 +3323,7 @@ private static CalendarEvent GetEventWithRecurrenceRules() } [Test] - public void ExDateFold_Tests() + public void ExDatesShouldGetMergedInOutput() { var start = _now.AddYears(-1); var end = start.AddHours(1); @@ -3339,12 +3336,12 @@ public void ExDateFold_Tests() }; var firstExclusion = new CalDateTime(start.AddDays(4)); - e.ExceptionDates = new List { new PeriodList { new Period(firstExclusion) } }; + e.ExceptionDates.Add(firstExclusion); var serialized = SerializationHelpers.SerializeToString(e); Assert.That(Regex.Matches(serialized, "EXDATE:"), Has.Count.EqualTo(1)); var secondExclusion = new CalDateTime(start.AddDays(5)); - e.ExceptionDates.First().Add(new Period(secondExclusion)); + e.ExceptionDates.Add(secondExclusion); serialized = SerializationHelpers.SerializeToString(e); Assert.That(Regex.Matches(serialized, "EXDATE:"), Has.Count.EqualTo(1)); } @@ -3364,15 +3361,13 @@ public void ExDateTimeZone_Tests() RecurrenceRules = new List { rrule }, }; - var exceptionDateList = new PeriodList { TzId = tzid }; - exceptionDateList.Add(new Period(new CalDateTime(_now.AddDays(1)))); - e.ExceptionDates.Add(exceptionDateList); + e.ExceptionDates.Add(new CalDateTime(_now.AddDays(1), tzid)); var serialized = SerializationHelpers.SerializeToString(e); const string expected = "TZID=Europe/Stockholm"; Assert.That(Regex.Matches(serialized, expected), Has.Count.EqualTo(3)); - e.ExceptionDates.First().Add(new Period(new CalDateTime(_now.AddDays(2)))); + e.ExceptionDates.Add(new CalDateTime(_now.AddDays(2), tzid)); serialized = SerializationHelpers.SerializeToString(e); Assert.That(Regex.Matches(serialized, expected), Has.Count.EqualTo(3)); } @@ -3490,16 +3485,14 @@ public void RecurrenceRuleTests() VERSION:2.0 BEGIN:VEVENT DTEND;TZID=UTC:20170228T140000 - DTSTAMP;TZID=UTC:20170428T171444 + DTSTAMP:20170428T171444Z DTSTART;TZID=UTC:20170228T060000 - EXDATE;TZID=UTC:20170302T060000,20170303T060000,20170306T060000,20170307T0 - 60000,20170308T060000,20170309T060000,20170310T060000,20170313T060000,201 - 70314T060000,20170317T060000,20170320T060000,20170321T060000,20170322T060 - 000,20170323T060000,20170324T060000,20170327T060000,20170328T060000,20170 - 329T060000,20170330T060000,20170331T060000,20170403T060000,20170405T06000 - 0,20170406T060000,20170407T060000,20170410T060000,20170411T060000,2017041 - 2T060000 - EXDATE;TZID=UTC:20170417T060000,20170413T060000 + EXDATE;TZID=UTC:20170302T060000,20170303T060000,20170306T060000,20170307T060000, + 20170308T060000,20170309T060000,20170310T060000,20170313T060000,20170314T060000, + 20170317T060000,20170320T060000,20170321T060000,20170322T060000,20170323T060000, + 20170324T060000,20170327T060000,20170328T060000,20170329T060000,20170330T060000, + 20170331T060000,20170403T060000,20170405T060000,20170406T060000,20170407T060000, + 20170410T060000,20170411T060000,20170412T060000,20170413T060000,20170417T060000 IMPORTANCE:None RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR UID:001b7e43-98df-4fcc-b9ec-345a28a4fc14 @@ -3594,8 +3587,8 @@ public void ManyExclusionDatesEqualityTesting() var calendarB = collectionB.First(); var eventA = calendarA.Events.First(); var eventB = calendarB.Events.First(); - var exDatesA = eventA.ExceptionDates; - var exDatesB = eventB.ExceptionDates; + var exDatesA = eventA.ExceptionDates.GetAllDates(); + var exDatesB = eventB.ExceptionDates.GetAllDates(); Assert.Multiple(() => { @@ -3661,24 +3654,26 @@ public static IEnumerable UntilTimeZoneSerializationTestCases() [Test] public void InclusiveRruleUntil() { - const string icalText = @"BEGIN:VCALENDAR -BEGIN:VEVENT -DTSTART;VALUE=DATE:20180101 -DTEND;VALUE=DATE:20180102 -RRULE:FREQ=WEEKLY;UNTIL=20180105;BYDAY=MO,TU,WE,TH,FR -DTSTAMP:20170926T001103Z -UID:5kvks79u4nurqopt7qv4fi1jo8@google.com -CREATED:20170922T131958Z -DESCRIPTION: -LAST-MODIFIED:20170922T131958Z -LOCATION: -SEQUENCE:0 -STATUS:CONFIRMED -SUMMARY:Holiday Break - No School -TRANSP:TRANSPARENT -END:VEVENT -END:VCALENDAR -"; + const string icalText = + """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTART;VALUE=DATE:20180101 + DTEND;VALUE=DATE:20180102 + RRULE:FREQ=WEEKLY;UNTIL=20180105;BYDAY=MO,TU,WE,TH,FR + DTSTAMP:20170926T001103Z + UID:5kvks79u4nurqopt7qv4fi1jo8@google.com + CREATED:20170922T131958Z + DESCRIPTION: + LAST-MODIFIED:20170922T131958Z + LOCATION: + SEQUENCE:0 + STATUS:CONFIRMED + SUMMARY:Holiday Break - No School + TRANSP:TRANSPARENT + END:VEVENT + END:VCALENDAR + """; const string timeZoneId = @"Eastern Standard Time"; var calendar = Calendar.Load(icalText); var firstEvent = calendar.Events.First(); diff --git a/Ical.Net.Tests/RecurrenceWithExDateTests.cs b/Ical.Net.Tests/RecurrenceWithExDateTests.cs new file mode 100644 index 000000000..92dca0d37 --- /dev/null +++ b/Ical.Net.Tests/RecurrenceWithExDateTests.cs @@ -0,0 +1,238 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System; +using System.Linq; +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using Ical.Net.Serialization; +using NUnit.Framework; + +namespace Ical.Net.Tests; + +/// +/// The class contains the tests for submitted issues from the GitHub repository, +/// slightly modified to fit the testing environment and the current version of the library. +/// +[TestFixture] +public class RecurrenceWithExDateTests +{ + [TestCase(true)] + [TestCase(false)] + public void ShouldNotOccurOnLocalExceptionDate(bool useExDateWithTime) + { + // Arrange + var id = Guid.NewGuid(); + const string timeZoneId = "Europe/London"; // IANA Time Zone ID + var start = new CalDateTime(2024, 10, 19, 18, 0, 0, timeZoneId); + var end = new CalDateTime(2024, 10, 19, 19, 0, 0, timeZoneId); + + var exceptionDate = useExDateWithTime + ? new CalDateTime(2024, 10, 19, 21, 0, 0, timeZoneId) + : new CalDateTime(2024, 10, 19); + + var recurrencePattern = new RecurrencePattern(FrequencyType.Hourly) + { + Count = 2, + Interval = 3 + }; + + var recurringEvent = new CalendarEvent + { + Summary = "My Recurring Event", + Uid = id.ToString(), + Start = start, + End = end + }; + recurringEvent.RecurrenceRules.Add(recurrencePattern); + recurringEvent.ExceptionDates.Add(exceptionDate); + + var calendar = new Calendar(); + calendar.Events.Add(recurringEvent); + + // Act + var serializer = new CalendarSerializer(); + var ics = serializer.SerializeToString(calendar); + + var deserializedCalendar = Calendar.Load(ics); + var occurrences = deserializedCalendar.GetOccurrences().ToList(); + + Assert.Multiple(() => + { + if (useExDateWithTime) + { + Assert.That(occurrences.Single().Period, Is.EqualTo(new Period(start, end))); + Assert.That(ics, Does.Contain("EXDATE;TZID=Europe/London:20241019T210000")); + } + else + { + Assert.That(occurrences, Has.Count.EqualTo(0)); + Assert.That(ics, Does.Contain("EXDATE;VALUE=DATE:20241019")); + } + }); + } + + [Test] + public void ShouldNotOccurOnUtcExceptionDate() + { + // Using Windows Time Zone ID + var ics = """ + BEGIN:VCALENDAR + PRODID:-//github.com/ical-org/ical.net//NONSGML ical.net 4.0//EN + VERSION:2.0 + BEGIN:VEVENT + DTEND;TZID=GMT Standard Time:20241019T190000 + DTSTAMP:20241018T083839Z + DTSTART;TZID=GMT Standard Time:20241019T180000 + EXDATE:20241019T190000Z + RRULE:FREQ=HOURLY;INTERVAL=1;COUNT=4 + SEQUENCE:0 + SUMMARY:My Recurring Event + UID:c9f3a28d-97d6-43f7-872e-6cd79f67093d + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics); + var occurrences = cal.GetOccurrences().ToList(); + + var serializer = new CalendarSerializer(); + ics = serializer.SerializeToString(cal); + // serialize and deserialize to ensure the exclusion dates de/serialized + cal = Calendar.Load(new CalendarSerializer(cal).SerializeToString()); + + // Start date: 2024-10-19 at 18:00 (GMT Standard Time) + // Recurrence: Every hour, 4 occurrences + // Occurrences: + // 2024-10-19 18:00 (UTC Offset: +0100) + // 2024-10-19 19:00 (UTC Offset: +0100) + // 2024-10-19 21:00 (UTC Offset: +0100) + // Excluded dates impact: + // 2024-10-19 at 19:00 UTC (= 2024-10-19 20:00 in "GMT Standard Time") + Assert.Multiple(() => + { + Assert.That(occurrences.Count, Is.EqualTo(3)); + Assert.That( + occurrences.All( + o => !cal + .Events[0] + .ExceptionDates.GetAllDates() + .Any(ex => ex.Equals(o.Period.StartTime))), Is.True); + Assert.That(ics, Does.Contain("EXDATE:20241019T190000Z")); + }); + } + + [Test] + public void MultipleExclusionDatesSameTimeZoneShouldBeExcluded() + { + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//github.com/ical-org/ical.net//NONSGML ical.net 4.0//EN + BEGIN:VEVENT + UID:uid2@example.com + DTSTAMP:20231021T162159Z + DTSTART;TZID=Europe/Berlin:20231025T090000 + DTEND;TZID=Europe/Berlin:20231025T100000 + RRULE:FREQ=WEEKLY;COUNT=10 + EXDATE;TZID=Europe/Berlin:20231029T090000,20231105T090000,20231112T090000 + SUMMARY:Weekly Meeting + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics); + var occurrences = cal.GetOccurrences().ToList(); + + var serializer = new CalendarSerializer(); + ics = serializer.SerializeToString(cal); + // serialize and deserialize to ensure the exclusion dates de/serialized + cal = Calendar.Load(new CalendarSerializer(cal).SerializeToString()); + + // Occurrences: + // 2023-10-25 09:00 (UTC Offset: +0200) + // 2023-11-01 09:00 (UTC Offset: +0100) + // 2023-11-08 09:00 (UTC Offset: +0100) + // 2023-11-15 09:00 (UTC Offset: +0100) + // 2023-11-22 09:00 (UTC Offset: +0100) + // 2023-11-29 09:00 (UTC Offset: +0100) + // 2023-12-06 09:00 (UTC Offset: +0100) + // 2023-12-13 09:00 (UTC Offset: +0100) + // 2023-12-20 09:00 (UTC Offset: +0100) + // 2023-12-27 09:00 (UTC Offset: +0100) + // Exclusion Dates impact: + // 2023-10-29 09:00 (UTC Offset: +0200) + // 2023-11-05 09:00 (UTC Offset: +0100) + // 2023-11-12 09:00 (UTC Offset: +0100) + // The recurrences are adjusted for the switch from Daylight Saving Time to + // Standard Time, which occurs on October 29, 2023, in the Europe/Berlin time zone. + + Assert.Multiple(() => + { + Assert.That(occurrences.Count, Is.EqualTo(10)); + Assert.That(cal.Events[0].ExceptionDates.GetAllDates().Count(), Is.EqualTo(3)); + Assert.That( + occurrences.All( + o => !cal + .Events[0] + .ExceptionDates.GetAllDates() + .Any(ex => ex.Equals(o.Period.StartTime))), Is.True); + Assert.That(ics, Does.Contain("EXDATE;TZID=Europe/Berlin:20231029T090000,20231105T090000,20231112T090000")); + }); + } + + [Test] + public void MultipleExclusionDatesDifferentZoneShouldBeExcluded() + { + var ics = """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//github.com/ical-org/ical.net//NONSGML ical.net 4.0//EN + BEGIN:VEVENT + UID:uid5@example.com + DTSTAMP:20231021T162159Z + DTSTART;TZID=America/New_York:20231025T090000 + DTEND;TZID=America/New_York:20231025T100000 + RRULE:FREQ=WEEKLY;COUNT=10 + EXDATE;TZID=America/New_York:20231029T090000 + EXDATE;TZID=Europe/London:20231101T130000 + SUMMARY:Weekly Meeting + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(ics); + // serialize and deserialize to ensure the exclusion dates de/serialized + cal = Calendar.Load(new CalendarSerializer(cal).SerializeToString()); + var occurrences = cal.GetOccurrences().ToList(); + + // Occurrences: + // October 25, 2023, 09:00 AM (EDT, UTC-4) + // November 8, 2023, 09:00 AM (EST, UTC-5) + // November 15, 2023, 09:00 AM (EST, UTC-5) + // November 22, 2023, 09:00 AM (EST, UTC-5) + // November 29, 2023, 09:00 AM (EST, UTC-5) + // December 6, 2023, 09:00 AM (EST, UTC-5) + // December 13, 2023, 09:00 AM (EST, UTC-5) + // December 20, 2023, 09:00 AM (EST, UTC-5) + // December 27, 2023, 09:00 AM (EST, UTC-5) + // Exclusion Dates Impact + // October 29, 2023, 09:00 AM (America/New_York): Excluded + // November 1, 2023, 01:00 PM (Europe/London): Excluded - November 1, 2023, 09:00 AM (EDT, UTC-4) + + Assert.Multiple(() => + { + Assert.That(occurrences.Count, Is.EqualTo(9)); + Assert.That(cal.Events[0].ExceptionDates.GetAllDates().Count(), Is.EqualTo(2)); + Assert.That( + occurrences.All( + o => !cal + .Events[0] + .ExceptionDates.GetAllDates() + .Any(ex => ex.Equals(o.Period.StartTime))), Is.True); + }); + } +} diff --git a/Ical.Net.Tests/RecurrenceWithRDateTests.cs b/Ical.Net.Tests/RecurrenceWithRDateTests.cs new file mode 100644 index 000000000..56d645e5c --- /dev/null +++ b/Ical.Net.Tests/RecurrenceWithRDateTests.cs @@ -0,0 +1,399 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Ical.Net.CalendarComponents; +using Ical.Net.Collections; +using Ical.Net.DataTypes; +using Ical.Net.Serialization; +using NUnit.Framework; + +namespace Ical.Net.Tests; + +[TestFixture] +public class RecurrenceWithRDateTests +{ + [Test] + public void RDate_SingleDateTime_IsProcessedCorrectly() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 1), + }; + calendarEvent.RecurrenceDates.Add(new CalDateTime(2023, 10, 2, 10, 0, 0)); + + 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, 10, 0, 0))); + 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); + + List recurrenceDates = + [ + new Period(new CalDateTime(2023, 10, 2)) + ]; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + }; + calendarEvent.RecurrenceDates.AddRange(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;VALUE=DATE:20231002")); + Assert.That(ics, Does.Not.Contain("DURATION:")); + }); + } + + [Test] + public void RDate_MultipleDates_WithTimeZones_AreProcessedCorrectly() + { + const string tzId = "America/New_York"; + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, tzId); + var recurrenceDates = new List + { + new CalDateTime(2023, 10, 2, 10, 0, 0, tzId), + new CalDateTime(2023, 10, 3, 10, 0, 0, tzId) + }; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 2), + }; + calendarEvent.RecurrenceDates.AddRange(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(3)); + 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("RDATE;TZID=America/New_York:20231002T100000,20231003T100000")); + Assert.That(ics, Does.Contain("DURATION:PT2H")); + }); + } + + [Test] + public void RDate_PeriodsWithTimezone_AreProcessedCorrectly() + { + const string tzId = "America/New_York"; + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, tzId); + var recDateCollection = new List + { + new Period(new CalDateTime(2023, 10, 2, 10, 0, 0, tzId), new Duration(hours: 4)), + new Period(new CalDateTime(2023, 10, 3, 10, 0, 0, tzId), new Duration(hours: 5)) + }; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 2), + }; + calendarEvent.RecurrenceDates.AddRange(recDateCollection); + + cal.Events.Add(calendarEvent); + + // Serialization + + var serializer = new CalendarSerializer(); + var ics = serializer.SerializeToString(cal); + + var occurrences = calendarEvent.GetOccurrences().ToList(); + + Assert.Multiple(() => + { + Assert.That(occurrences, Has.Count.EqualTo(3)); + 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(occurrences[1].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 4))); + Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 5))); + // 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 + + cal = Calendar.Load(ics); + occurrences = cal.Events.First().GetOccurrences().ToList(); + + Assert.Multiple(() => + { + Assert.That(occurrences, Has.Count.EqualTo(3)); + 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(occurrences[1].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 4))); + Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 5))); + }); + } + + [Test] + public void RDate_MixedDatesAndPeriods_AreProcessedCorrectly() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 1), + }; + calendarEvent.RecurrenceDates.Add(new CalDateTime(2023, 10, 2, 10, 0, 0)) + .Add(new Period(new CalDateTime(2023, 10, 3, 10, 0, 0), new Duration(hours: 3))); + + 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(3)); + 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, 3, 10, 0, 0))); + Assert.That(occurrences[2].Period.EffectiveDuration, Is.EqualTo(new Duration(hours: 3))); + Assert.That(ics, Does.Contain("RDATE:20231002T100000")); + Assert.That(ics, Does.Contain("RDATE;VALUE=PERIOD:20231003T100000/PT3H")); + }); + } + + [Test] + public void RDate_DifferentTimeZones_AreProcessedCorrectly() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, "America/New_York"); + List recDates = + [ + new CalDateTime(2023, 10, 2, 10, 0, 0, "America/Los_Angeles"), + new CalDateTime(2023, 10, 3, 10, 0, 0, "Europe/London") + ]; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 1), + }; + calendarEvent.RecurrenceDates.AddRange(recDates); + + 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(3)); + Assert.That(occurrences[0].Period.StartTime, + Is.EqualTo(new CalDateTime(2023, 10, 1, 10, 0, 0, "America/New_York"))); + Assert.That(occurrences[1].Period.StartTime, + Is.EqualTo(new CalDateTime(2023, 10, 2, 10, 0, 0, "America/Los_Angeles"))); + Assert.That(occurrences[2].Period.StartTime, + Is.EqualTo(new CalDateTime(2023, 10, 3, 10, 0, 0, "Europe/London"))); + Assert.That(ics, Does.Contain("RDATE;TZID=America/Los_Angeles:20231002T100000")); + Assert.That(ics, Does.Contain("RDATE;TZID=Europe/London:20231003T100000")); + }); + } + + [Test] + public void RDate_DateOnlyWithDurationAndDateTime_AreProcessedCorrectly() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(days: 2), + }; + calendarEvent.RecurrenceDates.Add(new Period(new CalDateTime(2023, 10, 2), new Duration(days: 1))) + .Add(new CalDateTime(2023, 10, 3, 10, 0, 0)); + + 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(3)); + Assert.That(occurrences[0].Period.StartTime, Is.EqualTo(eventStart)); + 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;VALUE=PERIOD:20231002/P1D")); + Assert.That(ics, Does.Contain("RDATE:20231003T100000")); + Assert.That(ics, Does.Contain("DURATION:P2D")); + }); + } + + [Test] + public void RDate_OverlappingPeriods_AreProcessedCorrectly() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); + List recDates = + [ + new Period(new CalDateTime(2023, 10, 2, 10, 0, 0), new Duration(hours: 2)), + new Period(new CalDateTime(2023, 10, 2, 11, 0, 0), new Duration(hours: 2)) + ]; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 1), + }; + calendarEvent.RecurrenceDates.AddRange(recDates); + + 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(3)); + 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;VALUE=PERIOD:20231002T100000/PT2H,20231002T110000/PT2H")); + }); + } + + [Test] + public void RDate_LargeNumberOfDates_ShouldBeLineFolded() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); + var recurrenceDates = new PeriodList(); + for (var i = 1; i <= 100; i++) // Adjusted to create 100 dates + { + recurrenceDates.Add(new Period(eventStart.AddDays(i))); + } + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 1), + }; + calendarEvent.RecurrenceDates.AddRange(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(101)); // Including the original event + for (var i = 0; i < 101; i++) + { + Assert.That(occurrences[i].Period.StartTime, Is.EqualTo(eventStart.AddDays(i))); + } + // First folded line is 75 characters long + Assert.That(ics, Does.Contain("RDATE:20231002T100000,20231003T100000,20231004T100000,20231005T100000,2023")); + // Last folded line + Assert.That(ics, Does.Contain(" T100000,20240106T100000,20240107T100000,20240108T100000,20240109T100000")); + }); + } + + [Test] + public void RDate_DuplicateDates_ShouldBeSerializedJustOnce() + { + var cal = new Calendar(); + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0); + + var periodDuplicate = new Period(new CalDateTime(2023, 10, 2, 10, 0, 0), new Duration(hours: 2)); + List recDates = [periodDuplicate, periodDuplicate]; + + var calendarEvent = new CalendarEvent + { + Start = eventStart, + Duration = new Duration(hours: 1), + }; + calendarEvent.RecurrenceDates.AddRange(recDates); + + 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(eventStart)); + Assert.That(occurrences[1].Period.StartTime, Is.EqualTo(periodDuplicate.StartTime)); + + Assert.That(ics, Does.Contain("RDATE;VALUE=PERIOD:20231002T100000/PT2H")); + }); + } + + [Test] + public void RDate_DateOnly_WithExactDuration_ShouldThrow() + { + var eventStart = new CalDateTime(2023, 10, 1, 10, 0, 0, "America/New_York"); + var recurrenceDates = new List + { + 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 + }; + calendarEvent.RecurrenceDates.AddRange(recurrenceDates); + + Assert.That(() => { _ = calendarEvent.GetOccurrences().ToList(); }, Throws.InvalidOperationException); + } +} diff --git a/Ical.Net.Tests/SimpleDeserializationTests.cs b/Ical.Net.Tests/SimpleDeserializationTests.cs index b1c1f117d..2c070367f 100644 --- a/Ical.Net.Tests/SimpleDeserializationTests.cs +++ b/Ical.Net.Tests/SimpleDeserializationTests.cs @@ -346,36 +346,6 @@ public void Google1() Assert.That(occurrences, Has.Count.EqualTo(dateTimes.Length), "There should be exactly " + dateTimes.Length + " occurrences; there were " + occurrences.Count); } - /// - /// Tests that valid RDATE properties are parsed correctly. - /// - [Test, Category("Deserialization")] - public void RecurrenceDates1() - { - var iCal = SimpleDeserializer.Default.Deserialize(new StringReader(IcsFiles.RecurrenceDates1)).Cast().Single(); - Assert.That(iCal.Events, Has.Count.EqualTo(1)); - Assert.That(iCal.Events.First().RecurrenceDates, Has.Count.EqualTo(3)); - - Assert.Multiple(() => - { - Assert.That(iCal.Events.First().RecurrenceDates[0][0].StartTime, Is.EqualTo((CalDateTime)new DateTime(1997, 7, 14, 12, 30, 0, DateTimeKind.Utc))); - Assert.That(iCal.Events.First().RecurrenceDates[1][0].StartTime, Is.EqualTo((CalDateTime)new DateTime(1996, 4, 3, 2, 0, 0, DateTimeKind.Utc))); - Assert.That(iCal.Events.First().RecurrenceDates[1][0].EndTime, Is.EqualTo((CalDateTime)new DateTime(1996, 4, 3, 4, 0, 0, DateTimeKind.Utc))); - Assert.That(iCal.Events.First().RecurrenceDates[2][0].StartTime, Is.EqualTo(new CalDateTime(1997, 1, 1))); - Assert.That(iCal.Events.First().RecurrenceDates[2][1].StartTime, Is.EqualTo(new CalDateTime(1997, 1, 20))); - Assert.That(iCal.Events.First().RecurrenceDates[2][2].StartTime, Is.EqualTo(new CalDateTime(1997, 2, 17))); - Assert.That(iCal.Events.First().RecurrenceDates[2][3].StartTime, Is.EqualTo(new CalDateTime(1997, 4, 21))); - Assert.That(iCal.Events.First().RecurrenceDates[2][4].StartTime, Is.EqualTo(new CalDateTime(1997, 5, 26))); - Assert.That(iCal.Events.First().RecurrenceDates[2][5].StartTime, Is.EqualTo(new CalDateTime(1997, 7, 4))); - Assert.That(iCal.Events.First().RecurrenceDates[2][6].StartTime, Is.EqualTo(new CalDateTime(1997, 9, 1))); - Assert.That(iCal.Events.First().RecurrenceDates[2][7].StartTime, Is.EqualTo(new CalDateTime(1997, 10, 14))); - Assert.That(iCal.Events.First().RecurrenceDates[2][8].StartTime, Is.EqualTo(new CalDateTime(1997, 11, 28))); - Assert.That(iCal.Events.First().RecurrenceDates[2][9].StartTime, Is.EqualTo(new CalDateTime(1997, 11, 29))); - Assert.That(iCal.Events.First().RecurrenceDates[2][10].StartTime, Is.EqualTo(new CalDateTime(1997, 12, 25))); - }); - } - - /// /// Tests that valid REQUEST-STATUS properties are parsed correctly. /// [Test, Category("Deserialization")] diff --git a/Ical.Net.Tests/VTimeZoneTest.cs b/Ical.Net.Tests/VTimeZoneTest.cs index ca14f1ecb..a12b7d2b7 100644 --- a/Ical.Net.Tests/VTimeZoneTest.cs +++ b/Ical.Net.Tests/VTimeZoneTest.cs @@ -5,9 +5,11 @@ using System; using System.Collections.Generic; +using System.Text.RegularExpressions; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using Ical.Net.Serialization; +using Ical.Net.Utility; using NUnit.Framework; namespace Ical.Net.Tests; @@ -103,7 +105,8 @@ public void VTimeZoneEuropeMoscowShouldSerializeProperly() { var iCal = CreateTestCalendar("Europe/Moscow"); var serializer = new CalendarSerializer(); - var serialized = serializer.SerializeToString(iCal); + // Unwrap the lines to make it easier to search for specific values + var serialized = TextUtil.UnwrapLines(serializer.SerializeToString(iCal)); Assert.Multiple(() => { @@ -122,7 +125,8 @@ public void VTimeZoneEuropeMoscowShouldSerializeProperly() Assert.That(serialized.Contains("TZOFFSETTO:+023017"), Is.True, "TZOFFSETTO:+023017 was not serialized"); Assert.That(serialized.Contains("DTSTART:19180916T010000"), Is.True, "DTSTART:19180916T010000 was not serialized"); Assert.That(serialized.Contains("DTSTART:19171228T000000"), Is.True, "DTSTART:19171228T000000 was not serialized"); - Assert.That(serialized.Contains("RDATE:19991031T030000"), Is.True, "RDATE:19991031T030000 was not serialized"); + // RDATE may contain multiple dates, separated by a comma + Assert.That(Regex.IsMatch(serialized, $@"RDATE:.*\b19991031T030000\b", RegexOptions.Compiled, RegexDefaults.Timeout), Is.True, "RDATE:19731028T020000 was not serialized"); }); } @@ -201,7 +205,8 @@ public void VTimeZoneAmericaAnchorageShouldSerializeProperly() { var iCal = CreateTestCalendar("America/Anchorage"); var serializer = new CalendarSerializer(); - var serialized = serializer.SerializeToString(iCal); + // Unwrap the lines to make it easier to search for specific values + var serialized = TextUtil.UnwrapLines(serializer.SerializeToString(iCal)); Assert.Multiple(() => { @@ -214,10 +219,11 @@ public void VTimeZoneAmericaAnchorageShouldSerializeProperly() Assert.That(serialized.Contains("TZNAME:YST"), Is.True, "YST was not serialized"); Assert.That(serialized.Contains("TZNAME:AHDT"), Is.True, "AHDT was not serialized"); Assert.That(serialized.Contains("TZNAME:LMT"), Is.True, "LMT was not serialized"); - Assert.That(serialized.Contains("RDATE:19731028T020000"), Is.True, "RDATE:19731028T020000 was not serialized"); - Assert.That(serialized.Contains("RDATE:19801026T020000"), Is.True, "RDATE:19801026T020000 was not serialized"); - Assert.That(serialized.Contains("DTSTART:19420209T020000"), Is.True, "DTSTART:19420209T020000 was not serialized"); + // RDATE may contain multiple dates, separated by a comma + Assert.That(Regex.IsMatch(serialized, $@"RDATE:.*\b19731028T020000\b", RegexOptions.Compiled, RegexDefaults.Timeout), Is.True, "RDATE:19731028T020000 was not serialized"); + Assert.That(Regex.IsMatch(serialized, $@"RDATE:.*\b19801026T020000\b", RegexOptions.Compiled, RegexDefaults.Timeout), Is.True, "RDATE:19731028T020000 was not serialized"); Assert.That(serialized.Contains("RDATE:19670401/P1D"), Is.False, "RDate was not properly serialized for vtimezone, should be RDATE:19670401T000000"); + Assert.That(serialized.Contains("DTSTART:19420209T020000"), Is.True, "DTSTART:19420209T020000 was not serialized"); }); } @@ -294,4 +300,4 @@ private static Calendar CreateTestCalendar(string tzId, DateTime? earliestTime = iCal.Events.Add(calEvent2); return iCal; } -} \ No newline at end of file +} diff --git a/Ical.Net/Calendar.cs b/Ical.Net/Calendar.cs index d4b00e46c..b833167e0 100644 --- a/Ical.Net/Calendar.cs +++ b/Ical.Net/Calendar.cs @@ -275,20 +275,14 @@ public virtual IEnumerable GetOccurrences(IDateTime startTime = n /// An object of the type specified public T Create() where T : ICalendarComponent { - var obj = Activator.CreateInstance(typeof(T)) as ICalendarObject; - if (obj is T) + if (Activator.CreateInstance(typeof(T), true) is ICalendarObject cal) { - this.AddChild(obj); - return (T) obj; + this.AddChild(cal); + return (T) cal; } return default(T); } - public void Dispose() - { - Children.Clear(); - } - public virtual void MergeWith(IMergeable obj) { var c = obj as Calendar; diff --git a/Ical.Net/CalendarComponents/Alarm.cs b/Ical.Net/CalendarComponents/Alarm.cs index 531e17477..8bc2277be 100644 --- a/Ical.Net/CalendarComponents/Alarm.cs +++ b/Ical.Net/CalendarComponents/Alarm.cs @@ -3,10 +3,10 @@ // Licensed under the MIT license. // +#nullable enable using System; using System.Collections.Generic; using Ical.Net.DataTypes; -using Ical.Net.Utility; namespace Ical.Net.CalendarComponents; @@ -58,7 +58,7 @@ public virtual string Summary set => Properties.Set("SUMMARY", value); } - public virtual Trigger Trigger + public virtual Trigger? Trigger { get => Properties.Get(TriggerRelation.Key); set => Properties.Set(TriggerRelation.Key, value); @@ -73,7 +73,7 @@ public Alarm() /// Gets a list of alarm occurrences for the given recurring component, /// that occur between and . /// - public virtual IList GetOccurrences(IRecurringComponent rc, IDateTime fromDate, IDateTime toDate) + public virtual IList GetOccurrences(IRecurringComponent rc, IDateTime? fromDate, IDateTime? toDate) { if (Trigger == null) { @@ -93,7 +93,7 @@ public virtual IList GetOccurrences(IRecurringComponent rc, IDa fromDate = rc.Start.Copy(); } - Duration? d = null; + Duration? duration = null; foreach (var o in rc.GetOccurrences(fromDate, toDate)) { var dt = o.Period.StartTime; @@ -102,15 +102,15 @@ public virtual IList GetOccurrences(IRecurringComponent rc, IDa if (o.Period.EndTime != null) { dt = o.Period.EndTime; - if (d == null) + if (duration == null) { - d = o.Period.Duration!.Value; // the getter always returns a value + duration = o.Period.EffectiveDuration; } } // Use the "last-found" duration as a reference point - else if (d != null) + else if (duration != null) { - dt = o.Period.StartTime.Add(d.Value); + dt = o.Period.StartTime.Add(duration.Value); } else { @@ -119,7 +119,7 @@ public virtual IList GetOccurrences(IRecurringComponent rc, IDa } } - occurrences.Add(new AlarmOccurrence(this, dt.Add(Trigger.Duration.Value), rc)); + occurrences.Add(new AlarmOccurrence(this, dt.Add(Trigger.Duration!.Value), rc)); } } else @@ -142,6 +142,7 @@ public virtual IList GetOccurrences(IRecurringComponent rc, IDa /// is null, all triggered alarms will be returned. /// /// The earliest date/time to poll trigered alarms for. + /// /// A list of objects, each containing a triggered alarm. public virtual IList Poll(IDateTime start, IDateTime end) { diff --git a/Ical.Net/CalendarComponents/CalendarEvent.cs b/Ical.Net/CalendarComponents/CalendarEvent.cs index e22d67460..53c45a7a0 100644 --- a/Ical.Net/CalendarComponents/CalendarEvent.cs +++ b/Ical.Net/CalendarComponents/CalendarEvent.cs @@ -287,29 +287,18 @@ protected bool Equals(CalendarEvent? other) return false; } - // RDATEs and EXDATEs are all List, because the spec allows for multiple declarations of collections. - // Consequently we have to contrive a normalized representation before we can determine whether two events are equal - - var exDates = PeriodList.GetGroupedPeriods(ExceptionDates); - var otherExDates = PeriodList.GetGroupedPeriods(other.ExceptionDates); - if (exDates.Keys.Count != otherExDates.Keys.Count || !exDates.Keys.OrderBy(k => k).SequenceEqual(otherExDates.Keys.OrderBy(k => k))) - { - return false; - } - - if (exDates.Any(exDate => !exDate.Value.OrderBy(d => d).SequenceEqual(otherExDates[exDate.Key].OrderBy(d => d)))) - { - return false; - } - - var rDates = PeriodList.GetGroupedPeriods(RecurrenceDates); - var otherRDates = PeriodList.GetGroupedPeriods(other.RecurrenceDates); - if (rDates.Keys.Count != otherRDates.Keys.Count || !rDates.Keys.OrderBy(k => k).SequenceEqual(otherRDates.Keys.OrderBy(k => k))) + // exDates and otherExDates are filled with a sorted list of distinct periods + var exDates = ExceptionDates.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime).ToList(); + var otherExDates = other.ExceptionDates.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime).ToList(); + if (exDates.Count != otherExDates.Count || !exDates.SequenceEqual(otherExDates)) { return false; } - if (rDates.Any(exDate => !exDate.Value.OrderBy(d => d).SequenceEqual(otherRDates[exDate.Key].OrderBy(d => d)))) + // rDates and otherRDates are filled with a sorted list of distinct periods + var rDates = RecurrenceDates.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime).ToList(); + var otherRDates = other.RecurrenceDates.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime).ToList(); + if (rDates.Count != otherRDates.Count && !rDates.SequenceEqual(otherRDates)) { return false; } @@ -341,9 +330,9 @@ public override int GetHashCode() hashCode = (hashCode * 397) ^ Transparency?.GetHashCode() ?? 0; hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(Attachments); hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(Resources); - hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCodeForNestedCollection(ExceptionDates); + hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(ExceptionDates.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime)); hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(ExceptionRules); - hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCodeForNestedCollection(RecurrenceDates); + hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(RecurrenceDates.GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime)); hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(RecurrenceRules); return hashCode; } diff --git a/Ical.Net/CalendarComponents/IRecurrable.cs b/Ical.Net/CalendarComponents/IRecurrable.cs index 31d242152..7b58df39b 100644 --- a/Ical.Net/CalendarComponents/IRecurrable.cs +++ b/Ical.Net/CalendarComponents/IRecurrable.cs @@ -15,9 +15,9 @@ public interface IRecurrable : IGetOccurrences, IServiceProvider /// IDateTime Start { get; set; } - IList ExceptionDates { get; set; } + ExceptionDates ExceptionDates { get; } IList ExceptionRules { get; set; } - IList RecurrenceDates { get; set; } + RecurrenceDates RecurrenceDates { get; } IList RecurrenceRules { get; set; } IDateTime RecurrenceId { get; set; } -} \ No newline at end of file +} diff --git a/Ical.Net/CalendarComponents/RecurringComponent.cs b/Ical.Net/CalendarComponents/RecurringComponent.cs index 2e6dec205..50323e98e 100644 --- a/Ical.Net/CalendarComponents/RecurringComponent.cs +++ b/Ical.Net/CalendarComponents/RecurringComponent.cs @@ -75,12 +75,14 @@ public virtual IDateTime DtStart set => Properties.Set("DTSTART", value); } - public virtual IList ExceptionDates + internal IList ExceptionDatesPeriodLists { get => Properties.GetMany("EXDATE"); set => Properties.Set("EXDATE", value); } + public virtual ExceptionDates ExceptionDates { get; internal set; } + public virtual IList ExceptionRules { get => Properties.GetMany("EXRULE"); @@ -99,12 +101,14 @@ public virtual int Priority set => Properties.Set("PRIORITY", value); } - public virtual IList RecurrenceDates + internal virtual IList RecurrenceDatesPeriodLists { get => Properties.GetMany("RDATE"); set => Properties.Set("RDATE", value); } + public virtual RecurrenceDates RecurrenceDates { get; internal set; } + public virtual IList RecurrenceRules { get => Properties.GetMany("RRULE"); @@ -161,7 +165,12 @@ public RecurringComponent(string name) : base(name) EnsureProperties(); } - private void Initialize() => SetService(new RecurringEvaluator(this)); + private void Initialize() + { + SetService(new RecurringEvaluator(this)); + ExceptionDates = new ExceptionDates(ExceptionDatesPeriodLists); + RecurrenceDates = new RecurrenceDates(RecurrenceDatesPeriodLists); + } private void EnsureProperties() { @@ -201,9 +210,9 @@ protected bool Equals(RecurringComponent other) && Attachments.SequenceEqual(other.Attachments) && CollectionHelpers.Equals(Categories, other.Categories) && CollectionHelpers.Equals(Contacts, other.Contacts) - && CollectionHelpers.Equals(ExceptionDates, other.ExceptionDates) + && CollectionHelpers.Equals(ExceptionDatesPeriodLists, other.ExceptionDatesPeriodLists) && CollectionHelpers.Equals(ExceptionRules, other.ExceptionRules) - && CollectionHelpers.Equals(RecurrenceDates, other.RecurrenceDates, orderSignificant: true) + && CollectionHelpers.Equals(RecurrenceDatesPeriodLists, other.RecurrenceDatesPeriodLists, orderSignificant: true) && CollectionHelpers.Equals(RecurrenceRules, other.RecurrenceRules, orderSignificant: true); return result; @@ -229,9 +238,9 @@ public override int GetHashCode() hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(Attachments); hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(Categories); hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(Contacts); - hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(ExceptionDates); + hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(ExceptionDatesPeriodLists); hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(ExceptionRules); - hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(RecurrenceDates); + hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(RecurrenceDatesPeriodLists); hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(RecurrenceRules); return hashCode; } diff --git a/Ical.Net/CalendarComponents/Todo.cs b/Ical.Net/CalendarComponents/Todo.cs index 86e38815d..0078f2e59 100644 --- a/Ical.Net/CalendarComponents/Todo.cs +++ b/Ical.Net/CalendarComponents/Todo.cs @@ -148,7 +148,7 @@ public virtual bool IsCompleted(IDateTime currDt) // Evaluate to the previous occurrence. var periods = _mEvaluator.EvaluateToPreviousOccurrence(Completed, currDt); - return periods.Cast().All(p => !p.StartTime.GreaterThan(Completed) || !currDt.GreaterThanOrEqual(p.StartTime)); + return periods.All(p => !p.StartTime.GreaterThan(Completed) || !currDt.GreaterThanOrEqual(p.StartTime)); } return false; } diff --git a/Ical.Net/CalendarComponents/VTimeZone.cs b/Ical.Net/CalendarComponents/VTimeZone.cs index 5e37e6fbf..b7b82a071 100644 --- a/Ical.Net/CalendarComponents/VTimeZone.cs +++ b/Ical.Net/CalendarComponents/VTimeZone.cs @@ -22,29 +22,29 @@ public class VTimeZone : CalendarComponent public static VTimeZone FromLocalTimeZone() => FromDateTimeZone(DateUtil.LocalDateTimeZone.Id); - public static VTimeZone FromLocalTimeZone(DateTime earlistDateTimeToSupport, bool includeHistoricalData) - => FromDateTimeZone(DateUtil.LocalDateTimeZone.Id, earlistDateTimeToSupport, includeHistoricalData); + public static VTimeZone FromLocalTimeZone(DateTime earliestDateTimeToSupport, bool includeHistoricalData) + => FromDateTimeZone(DateUtil.LocalDateTimeZone.Id, earliestDateTimeToSupport, includeHistoricalData); public static VTimeZone FromSystemTimeZone(TimeZoneInfo tzinfo) => FromSystemTimeZone(tzinfo, new DateTime(DateTime.Now.Year, 1, 1), false); - public static VTimeZone FromSystemTimeZone(TimeZoneInfo tzinfo, DateTime earlistDateTimeToSupport, bool includeHistoricalData) - => FromDateTimeZone(tzinfo.Id, earlistDateTimeToSupport, includeHistoricalData); + public static VTimeZone FromSystemTimeZone(TimeZoneInfo tzinfo, DateTime earliestDateTimeToSupport, bool includeHistoricalData) + => FromDateTimeZone(tzinfo.Id, earliestDateTimeToSupport, includeHistoricalData); public static VTimeZone FromDateTimeZone(string tzId) => FromDateTimeZone(tzId, new DateTime(DateTime.Now.Year, 1, 1), includeHistoricalData: false); - public static VTimeZone FromDateTimeZone(string tzId, DateTime earlistDateTimeToSupport, bool includeHistoricalData) + public static VTimeZone FromDateTimeZone(string tzId, DateTime earliestDateTimeToSupport, bool includeHistoricalData) { var vTimeZone = new VTimeZone(tzId); var earliestYear = 1900; - var earliestMonth = earlistDateTimeToSupport.Month; - var earliestDay = earlistDateTimeToSupport.Day; + var earliestMonth = earliestDateTimeToSupport.Month; + var earliestDay = earliestDateTimeToSupport.Day; // Support date/times for January 1st of the previous year by default. - if (earlistDateTimeToSupport.Year > 1900) + if (earliestDateTimeToSupport.Year > 1900) { - earliestYear = earlistDateTimeToSupport.Year - 1; + earliestYear = earliestDateTimeToSupport.Year - 1; // Since we went back a year, we can't still be in a leap-year if (earliestMonth == 2 && earliestDay == 29) earliestDay = 28; @@ -56,7 +56,7 @@ public static VTimeZone FromDateTimeZone(string tzId, DateTime earlistDateTimeTo earliestDay = 28; } var earliest = Instant.FromUtc(earliestYear, earliestMonth, earliestDay, - earlistDateTimeToSupport.Hour, earlistDateTimeToSupport.Minute); + earliestDateTimeToSupport.Hour, earliestDateTimeToSupport.Minute); // Only include historical data if asked to do so. Otherwise, // use only the most recent adjustment rules available. @@ -241,7 +241,6 @@ private static void PopulateTimeZoneInfoRecurrenceDates(VTimeZoneInfo tzi, List< { foreach (var interval in intervals) { - var periodList = new PeriodList(); var time = interval.IsoLocalStart.ToDateTimeUnspecified(); var date = new CalDateTime(time, true).Add(delta.ToDurationExact()) as CalDateTime; if (date == null) @@ -249,8 +248,7 @@ private static void PopulateTimeZoneInfoRecurrenceDates(VTimeZoneInfo tzi, List< continue; } - periodList.Add(date); - tzi.RecurrenceDates.Add(periodList); + tzi.RecurrenceDates.Add(date); } } diff --git a/Ical.Net/Collections/GroupedValueList.cs b/Ical.Net/Collections/GroupedValueList.cs index e50caa63f..c85681678 100644 --- a/Ical.Net/Collections/GroupedValueList.cs +++ b/Ical.Net/Collections/GroupedValueList.cs @@ -30,10 +30,12 @@ public virtual void Set(TGroup group, IEnumerable values) } // No matching item was found, add a new item to the list - var obj = Activator.CreateInstance(typeof(TItem)) as TInterface; - obj.Group = group; - obj.SetValue(values); - Add(obj); + if (Activator.CreateInstance(typeof(TItem), true) is TInterface obj) + { + obj.Group = group; + obj.SetValue(values); + Add(obj); + } } public virtual TType Get(TGroup group) @@ -50,4 +52,4 @@ public virtual TType Get(TGroup group) } public virtual IList GetMany(TGroup group) => new GroupedValueListProxy(this, group); -} \ No newline at end of file +} diff --git a/Ical.Net/DataTypes/AlarmOccurrence.cs b/Ical.Net/DataTypes/AlarmOccurrence.cs index bd60a65c4..8ea472d5d 100644 --- a/Ical.Net/DataTypes/AlarmOccurrence.cs +++ b/Ical.Net/DataTypes/AlarmOccurrence.cs @@ -69,4 +69,4 @@ public override int GetHashCode() return hashCode; } } -} \ No newline at end of file +} diff --git a/Ical.Net/DataTypes/CalendarDataType.cs b/Ical.Net/DataTypes/CalendarDataType.cs index 0d75e2fc6..71bd33404 100644 --- a/Ical.Net/DataTypes/CalendarDataType.cs +++ b/Ical.Net/DataTypes/CalendarDataType.cs @@ -154,7 +154,7 @@ public virtual void CopyFrom(ICopyable obj) public virtual T Copy() { var type = GetType(); - var obj = Activator.CreateInstance(type) as ICopyable; + var obj = Activator.CreateInstance(type, true) as ICopyable; if (obj is not T o) return default(T); diff --git a/Ical.Net/DataTypes/ExceptionDates.cs b/Ical.Net/DataTypes/ExceptionDates.cs new file mode 100644 index 000000000..28fbfdea8 --- /dev/null +++ b/Ical.Net/DataTypes/ExceptionDates.cs @@ -0,0 +1,49 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System.Collections.Generic; + +namespace Ical.Net.DataTypes; + +/// +/// This class is used to manage ICalendar EXDATE properties, which can be date-time and date-only. +/// +/// The class is a wrapper around a list of PeriodList objects. +/// Specifically, it is used to group periods by their TzId, PeriodKind and date-time/date-only +/// in way that serialization conforms to the RFC 5545 standard. +/// +/// +public class ExceptionDates : PeriodListWrapperBase +{ + internal ExceptionDates(IList listOfPeriodList) : base(listOfPeriodList) + { } + + /// + /// Adds a date to the list, if it doesn't already exist. + /// + public ExceptionDates Add(IDateTime dt) + { + var periodList = GetOrCreatePeriodList(dt); + + var dtPeriod = new Period(dt); + periodList.Add(dtPeriod); + + return this; + } + + /// + /// Adds a range of dates to the list, if they don't already exist. + /// + public ExceptionDates AddRange(IEnumerable dates) + { + foreach (var dt in dates) + { + Add(dt); + } + + return this; + } +} diff --git a/Ical.Net/DataTypes/Period.cs b/Ical.Net/DataTypes/Period.cs index 315273020..a3e4580f9 100644 --- a/Ical.Net/DataTypes/Period.cs +++ b/Ical.Net/DataTypes/Period.cs @@ -11,17 +11,58 @@ namespace Ical.Net.DataTypes; /// /// Represents an iCalendar period of time. +/// +/// 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 date/time or date-only, with the duration unspecified. ///
public class Period : EncodableDataType, IComparable { - public Period() { } + private IDateTime _startTime = null!; + private IDateTime? _endTime; + private Duration? _duration; + + /// + /// Creates a new instance starting at the given time. + /// It ensures the that a consistent logic is applied to the and parameters. + /// TLDR; If is provided, it will be used as the end time of the period. + /// + /// + /// + /// + /// + /// + /// A new object if the parameter is not . + /// + /// If the parameter is , the method returns immediately. + /// + /// If the parameter is not , a new object is created using the and times. + /// + /// If the parameter is , the parameter is used to create a new object using the time and the specified . + /// + /// If both and are , a new object is created using only the time. + /// + internal static Period Create(IDateTime start, IDateTime? end = null, Duration? duration = null, ICalendarObject? associatedObject = null) + { + if (end is not null) + return new Period(start, end) { AssociatedObject = associatedObject }; + + if (duration is not null) + return new Period(start, duration.Value) { AssociatedObject = associatedObject }; + + return new Period(start) { AssociatedObject = associatedObject }; + } + + // Needed for the serialization factory + internal Period() { } /// /// Creates a new instance starting at the given time - /// and ending at the given time. The latter may be null. + /// and ending at the given time. /// - /// A that has a date-only , no - /// and no duration set, is considered to last for one day. + /// If time is not provided, the period will be considered as starting at the given time, + /// while the duration is unspecified. /// /// /// @@ -29,18 +70,27 @@ public Period() { } /// public Period(IDateTime start, IDateTime? end = null) { - if (end != null && end.LessThanOrEqual(start)) - { - throw new ArgumentException("End time must be greater than end time.", nameof(end)); - } + // Ensure consistent arguments + if (end != null && start.TzId != end.TzId) + throw new ArgumentException($"Start time ({start}) and end time ({end}) must have the same timezone."); + + if (end != null && start.HasTime != end.HasTime) + throw new ArgumentException( + $"Start time ({start}) and end time ({end}) must both have a time or both be date-only."); + + if (end != null && end.LessThan(start)) + throw new ArgumentException($"End time ({end}) must be greater than start time ({start}).", nameof(end)); - StartTime = start ?? throw new ArgumentNullException(nameof(start)); - EndTime = end; + _startTime = start; + _endTime = end; } /// /// Creates a new instance starting at the given time /// and lasting for the given duration. + /// + /// If is not provided, the period will be considered as starting at the given time, + /// while the duration is unspecified. /// /// /// @@ -48,10 +98,10 @@ public Period(IDateTime start, IDateTime? end = null) public Period(IDateTime start, Duration duration) { if (duration.Sign < 0) - throw new ArgumentException("Duration must be greater than or equal to zero.", nameof(duration)); + throw new ArgumentException($"Duration ({duration}) must be greater than or equal to zero.", nameof(duration)); - StartTime = start; - Duration = duration; + _startTime = start; + _duration = duration; } /// @@ -61,12 +111,12 @@ public override void CopyFrom(ICopyable obj) if (obj is not Period p) return; - StartTime = p.StartTime.Copy(); - EndTime = p.EndTime?.Copy(); - Duration = p.Duration; + _startTime = p._startTime.Copy(); + _endTime = p._endTime?.Copy(); + _duration = p._duration; } - protected bool Equals(Period other) => Equals(StartTime, other.StartTime) && Equals(EndTime, other.EndTime) && Duration.Equals(other.Duration); + protected bool Equals(Period other) => Equals(StartTime, other.StartTime) && Equals(EndTime, other.EndTime) && Equals(Duration, other.Duration); /// public override bool Equals(object? obj) @@ -89,60 +139,82 @@ public override int GetHashCode() } /// - public override string ToString() + public override string? ToString() { var periodSerializer = new PeriodSerializer(); return periodSerializer.SerializeToString(this); } /// - /// Gets or sets the start time of the period. + /// Gets the start time of the period. /// - public virtual IDateTime StartTime { get; set; } = null!; + public virtual IDateTime StartTime => _startTime; /// - /// Gets either the end time of the period that was set, - /// or calculates the exact end time based on the nominal duration. - /// - /// Sets the end time of the period. - /// Either the or the can be set at a time. - /// The last one set will be stored, and the other will be calculated. + /// Gets the original end time that was set, /// - public virtual IDateTime? EndTime { get; set; } + public virtual IDateTime? EndTime => _endTime; /// - /// Gets either the nominal duration of the period that was set, - /// or calculates the exact duration based on the end time. - /// - /// Sets the duration of the period. - /// Either the or the can be set at a time. - /// The last one set will be stored, and the other will be calculated. - /// - /// A that has a date-only , no - /// and no duration set, is considered to last for one day. + /// Gets the end time of the period that was set, or - if this is - + /// calculates the end time based by adding to the . + /// If and are both , the method returns . /// - public virtual Duration? Duration { get; set; } - - internal Duration GetEffectiveDuration() + public virtual IDateTime? EffectiveEndTime { - if (Duration is { } d) - return d; - - if (EndTime is { } endTime) - return endTime.Subtract(StartTime); + get + { + var effectiveDuration = EffectiveDuration; + return _endTime switch + { + null when _duration is null => null, + { } endTime => endTime, + _ => effectiveDuration is not null + ? _startTime.Add(effectiveDuration.Value) + : null + }; + } + } - if (!StartTime.HasTime) - return DataTypes.Duration.FromDays(1); + /// + /// Gets the original duration of the period as it was set.
+ /// See also . + ///
+ public virtual Duration? Duration => _duration; - return DataTypes.Duration.Zero; + /// + /// Gets the duration of the period that was set, or - if this is - + /// calculates the exact duration by subtracting from . + /// If and are both , the method returns . + /// + public virtual Duration? EffectiveDuration + { + get + { + return _duration switch + { + null when _endTime is null => null, + { } d => d, + _ => _endTime is { } endTime + ? endTime.Subtract(_startTime) + : null + }; + } } - internal IDateTime GetEffectiveEndTime() + internal string? TzId => _startTime.TzId; // same timezone for start and end + + internal PeriodKind PeriodKind { - if (EndTime is { } endTime) - return endTime; + get + { + if (EffectiveDuration != null) + { + return PeriodKind.Period; + } - return StartTime.Add(GetEffectiveDuration()); + return StartTime.HasTime ? PeriodKind.DateTime : PeriodKind.DateOnly; + } } /// @@ -155,14 +227,14 @@ internal IDateTime GetEffectiveEndTime() public virtual bool Contains(IDateTime? dt) { // Start time is inclusive - if (dt == null || !StartTime.LessThanOrEqual(dt)) + if (dt == null || !_startTime.LessThanOrEqual(dt)) { return false; } - var endTime = GetEffectiveEndTime(); + var endTime = EffectiveEndTime; // End time is exclusive - return endTime == null || endTime.GreaterThan(dt); + return endTime?.GreaterThan(dt) != false; } /// @@ -178,9 +250,10 @@ public virtual bool Contains(IDateTime? dt) public virtual bool CollidesWith(Period period) => Contains(period.StartTime) || period.Contains(StartTime) - || Contains(period.GetEffectiveEndTime()) - || period.Contains(GetEffectiveEndTime()); + || Contains(period.EffectiveEndTime) + || period.Contains(EffectiveEndTime); + /// public int CompareTo(Period? other) { if (other == null) @@ -188,11 +261,11 @@ public int CompareTo(Period? other) return 1; } - if (StartTime.Equals(other.StartTime)) + if (StartTime.AsUtc.Equals(other.StartTime.AsUtc)) { return 0; } - if (StartTime.LessThan(other.StartTime)) + if (StartTime.LessThanOrEqual(other.StartTime)) { return -1; } @@ -201,4 +274,3 @@ public int CompareTo(Period? other) return 1; } } - diff --git a/Ical.Net/DataTypes/PeriodKind.cs b/Ical.Net/DataTypes/PeriodKind.cs new file mode 100644 index 000000000..af7c66fd5 --- /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 44fd59794..efb85f5c0 100644 --- a/Ical.Net/DataTypes/PeriodList.cs +++ b/Ical.Net/DataTypes/PeriodList.cs @@ -3,11 +3,11 @@ // Licensed under the MIT license. // +#nullable enable using System; using System.Collections; using System.Collections.Generic; using System.IO; -using System.Linq; using Ical.Net.Evaluation; using Ical.Net.Serialization.DataTypes; using Ical.Net.Utility; @@ -15,26 +15,54 @@ namespace Ical.Net.DataTypes; /// -/// An iCalendar list of recurring dates (or date exclusions) +/// An iCalendar list used to represent a list of objects +/// for EXDATE and RDATE properties. /// -public class PeriodList : EncodableDataType, IList +internal class PeriodList : EncodableDataType, IList { - public string TzId { get; set; } + internal PeriodKind PeriodKind => Count == 0 ? PeriodKind.Undefined : Periods[0].PeriodKind; + + internal string? TzId => Count == 0 ? null : Periods[0].TzId; + + /// + /// Gets the number of s of the list. + /// public int Count => Periods.Count; - protected IList Periods { get; set; } = new List(); + /// + /// Gets the list of s of the list. + /// + protected IList Periods { get; } = new List(); + // Also needed for the serialization factory public PeriodList() { SetService(new PeriodListEvaluator(this)); } - public PeriodList(string value) : this() + /// + /// Creates a new instance of the class from the . + /// + /// + /// + private PeriodList(StringReader value) { var serializer = new PeriodListSerializer(); - CopyFrom(serializer.Deserialize(new StringReader(value)) as ICopyable); + if (serializer.Deserialize(value) is ICopyable deserialized) + { + CopyFrom(deserialized); + } + + SetService(new PeriodListEvaluator(this)); } + /// + /// Creates a new instance of the class from the object. + /// + /// + /// + public static PeriodList FromStringReader(StringReader value) => new PeriodList(value); + /// public override void CopyFrom(ICopyable obj) { @@ -48,82 +76,113 @@ public override void CopyFrom(ICopyable obj) { Add(p.Copy()); } - - // String assignments create new instances - TzId = list.TzId; } - public override string ToString() => new PeriodListSerializer().SerializeToString(this); - - public void Add(IDateTime dt) => Periods.Add(new Period(dt)); - - public static Dictionary> GetGroupedPeriods(IList periodLists) - { - // In order to know if two events are equal, a semantic understanding of exdates, rdates, rrules, and exrules is required. This could be done by - // computing the complete recurrence set (expensive) while being time-zone sensitive, or by comparing each List in each IPeriodList. - - // For example, events containing these rules generate the same recurrence set, including having the same time zone for each occurrence, so - // they're the same: - // Event A: - // RDATE:20170302T060000Z,20170303T060000Z - // Event B: - // RDATE:20170302T060000Z - // RDATE:20170303T060000Z - - var grouped = new Dictionary>(StringComparer.OrdinalIgnoreCase); - foreach (var periodList in periodLists) - { - var defaultBucket = string.IsNullOrWhiteSpace(periodList.TzId) ? "" : periodList.TzId; - - foreach (var period in periodList) - { - var actualBucket = string.IsNullOrWhiteSpace(period.StartTime.TzId) ? defaultBucket : period.StartTime.TzId; - - if (!grouped.ContainsKey(actualBucket)) - { - grouped.Add(actualBucket, new HashSet()); - } - grouped[actualBucket].Add(period); - } - } - return grouped.ToDictionary(k => k.Key, v => v.Value.OrderBy(d => d.StartTime).ToList()); - } + /// + /// Gets the string representation of the list. + /// + /// + public override string? ToString() => new PeriodListSerializer().SerializeToString(this); - protected bool Equals(PeriodList other) => string.Equals(TzId, other.TzId, StringComparison.OrdinalIgnoreCase) - && CollectionHelpers.Equals(Periods, other.Periods); + protected bool Equals(PeriodList other) => CollectionHelpers.Equals(Periods, other.Periods); - public override bool Equals(object obj) + /// + public override bool Equals(object? obj) { if (ReferenceEquals(null, obj)) return false; if (ReferenceEquals(this, obj)) return true; return obj.GetType() == GetType() && Equals((PeriodList) obj); } - public override int GetHashCode() - { - unchecked - { - var hashCode = TzId?.GetHashCode() ?? 0; - hashCode = (hashCode * 397) ^ CollectionHelpers.GetHashCode(Periods); - return hashCode; - } - } + /// + public override int GetHashCode() => CollectionHelpers.GetHashCode(Periods); + /// public Period this[int index] { get => Periods[index]; - set => Periods[index] = value; + set + { + EnsureConsistentTimezoneAndPeriodKind(value); + Periods[index] = value; + } } + /// public bool Remove(Period item) => Periods.Remove(item); + + /// public bool IsReadOnly => Periods.IsReadOnly; + + /// public int IndexOf(Period item) => Periods.IndexOf(item); - public void Insert(int index, Period item) => Periods.Insert(index, item); + + /// + /// Inserts a to the list if it does not already exist.
+ /// The timezone period kind of the first value added determines the timezone for the whole list. + ///
+ /// + public void Insert(int index, Period item) + { + EnsureConsistentTimezoneAndPeriodKind(item); + if (Periods.Contains(item)) return; + Periods.Insert(index, item); + } + + /// public void RemoveAt(int index) => Periods.RemoveAt(index); - public void Add(Period item) => Periods.Add(item); + + /// + /// Adds a to the list if it does not already exist.
+ /// The timezone period kind of the first value added determines the timezone for the whole list. + ///
+ /// The for an 'RDATE'. + /// + public void Add(Period item) + { + EnsureConsistentTimezoneAndPeriodKind(item); + if (Periods.Contains(item)) return; + Periods.Add(item); + } + + /// + /// Adds a DATE or DATE-TIME value for an 'EXDATE' or 'RDATE' to the list if it does not already exist.
+ /// The timezone period kind of the first value added determines the timezone for the whole list. + ///
+ /// + /// + public void Add(IDateTime dt) + { + var p = new Period(dt); + Add(p); + } + + /// public void Clear() => Periods.Clear(); + + /// public bool Contains(Period item) => Periods.Contains(item); + + /// public void CopyTo(Period[] array, int arrayIndex) => Periods.CopyTo(array, arrayIndex); + + /// public IEnumerator GetEnumerator() => Periods.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => Periods.GetEnumerator(); -} \ No newline at end of file + + private void EnsureConsistentTimezoneAndPeriodKind(Period p) + { + if (Count != 0 && p.PeriodKind != Periods[0].PeriodKind) + { + throw new ArgumentException( + $"All Periods of a PeriodList must be of the same period kind. Current Kind: {Periods[0].PeriodKind}, Provided Kind: {p.PeriodKind}"); + } + + if (Count != 0 && p.TzId != Periods[0].TzId) + { + throw new ArgumentException( + $"All Periods of a PeriodList must have the same timezone. Current TzId: {Periods[0].TzId}, Provided TzId: {p.TzId}"); + } + } +} diff --git a/Ical.Net/DataTypes/PeriodListWrapperBase.cs b/Ical.Net/DataTypes/PeriodListWrapperBase.cs new file mode 100644 index 000000000..56072d47b --- /dev/null +++ b/Ical.Net/DataTypes/PeriodListWrapperBase.cs @@ -0,0 +1,109 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Ical.Net.Utility; + +namespace Ical.Net.DataTypes; + +/// This base class is used to manage ICalendar EXDATE and RATE properties. +/// +/// The class is a wrapper around a list of PeriodList objects. +/// Specifically, it is used to group periods by their TzId and PeriodKind +/// is way that serialization conforms to the RFC 5545 standard. +/// +public abstract class PeriodListWrapperBase +{ + private protected IList ListOfPeriodList; + + private protected PeriodListWrapperBase(IList periodList) => ListOfPeriodList = periodList; + + /// + /// Gets a flattened list of all distinct dates in the list + /// + public IEnumerable GetAllDates() + => ListOfPeriodList.SelectMany(pl => + pl.Where(p => p.PeriodKind is PeriodKind.DateOnly or PeriodKind.DateTime) + .Select(p => p.StartTime)) + .Distinct(); + + /// + /// Clears all elements from the list. + /// + public void Clear() => ListOfPeriodList.Clear(); + + /// + /// Determines whether the list contains the . + /// + public bool Contains(IDateTime dt) + { + var periodList = GetPeriodList(dt); + + return periodList? + .FirstOrDefault(period => Equals(period.StartTime, dt)) != null; + } + + /// + public bool Remove(IDateTime dt) + { + var periodList = GetPeriodList(dt); + + if (periodList == null) return false; + + var dtPeriod = new Period(dt); + + return periodList.Remove(dtPeriod); + } + + private protected PeriodList GetOrCreatePeriodList(IDateTime dt) + { + var periodList = GetPeriodList(dt); + + if (periodList != null) return periodList; + + periodList = new PeriodList(); + ListOfPeriodList.Add(periodList); + return periodList; + } + + private protected PeriodList GetOrCreatePeriodList(Period period) + { + var periodList = GetPeriodList(period); + + if (periodList != null) return periodList; + + periodList = new PeriodList(); + ListOfPeriodList.Add(periodList); + return periodList; + } + + private protected PeriodList? GetPeriodList(IDateTime dt) + { + // The number of PeriodLists is expected to be small, so a linear search is acceptable. + return ListOfPeriodList + .FirstOrDefault(p => + p.TzId == dt.TzId + && p.PeriodKind == (dt.HasTime ? PeriodKind.DateTime : PeriodKind.DateOnly)); + } + + private protected PeriodList? GetPeriodList(Period period) + { + // The number of PeriodLists is expected to be small, so a linear search is acceptable. + return ListOfPeriodList + .FirstOrDefault(p => + p.TzId == period.TzId + && p.PeriodKind == period.PeriodKind + && p[0].StartTime.HasTime == period.StartTime.HasTime); + } + + /// + /// Gets a flattened list of all distinct periods with + /// , and . + /// + internal IEnumerable GetAllPeriodsByKind(params PeriodKind[] periodKinds) + => ListOfPeriodList.SelectMany(pl => pl.Where(p => periodKinds.Contains(p.PeriodKind))).Distinct(); +} diff --git a/Ical.Net/DataTypes/RecurrenceDates.cs b/Ical.Net/DataTypes/RecurrenceDates.cs new file mode 100644 index 000000000..a94135342 --- /dev/null +++ b/Ical.Net/DataTypes/RecurrenceDates.cs @@ -0,0 +1,102 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +#nullable enable +using System.Collections.Generic; +using System.Linq; +using Ical.Net.Utility; + +namespace Ical.Net.DataTypes; + +/// +/// This class is used to manage ICalendar RDATE properties, which can be date-time, date-only and period. +/// +/// The class is a wrapper around a list of PeriodList objects. +/// Specifically, it is used to group periods by their TzId, PeriodKind and date-time/date-only +/// in way that serialization conforms to the RFC 5545 standard. +/// +/// +public class RecurrenceDates : PeriodListWrapperBase +{ + internal RecurrenceDates(IList listOfPeriodList) : base(listOfPeriodList) + { } + + /// + /// Adds a date to the list, if it doesn't already exist. + /// + public RecurrenceDates Add(IDateTime dt) + { + var periodList = GetOrCreatePeriodList(dt); + var dtPeriod = new Period(dt); + periodList.Add(dtPeriod); + + return this; + } + + /// + /// Adds a period to the list, if it doesn't already exist. + /// + public RecurrenceDates Add(Period period) + { + var periodList = GetOrCreatePeriodList(period); + periodList.Add(period); + + return this; + } + + /// + /// Adds a range of dates to the list, if they don't already exist. + /// + public RecurrenceDates AddRange(IEnumerable dates) + { + foreach (var dt in dates) + { + Add(dt); + } + + return this; + } + + /// + /// Adds a range of periods to the list, if they don't already exist. + /// + public RecurrenceDates AddRange(IEnumerable periods) + { + foreach (var period in periods) + { + Add(period); + } + + return this; + } + + /// + /// Determines whether the list contains the . + /// + public bool Contains(Period period) + { + var periodList = GetPeriodList(period); + + return periodList? + .FirstOrDefault(p => Equals(p, period)) != null; + } + + /// + public bool Remove(Period period) + { + var periodList = GetPeriodList(period); + + if (periodList == null) return false; + + return periodList.Remove(period); + } + + /// + /// Gets a flattened and ordered list of all distinct periods in the list. + /// + public IEnumerable GetAllPeriods() + => ListOfPeriodList. + SelectMany(pl => pl.Where(p => p.PeriodKind is PeriodKind.Period)).OrderedDistinct(); +} diff --git a/Ical.Net/Evaluation/EventEvaluator.cs b/Ical.Net/Evaluation/EventEvaluator.cs index 89de4ca4c..cd640c407 100644 --- a/Ical.Net/Evaluation/EventEvaluator.cs +++ b/Ical.Net/Evaluation/EventEvaluator.cs @@ -90,10 +90,16 @@ and it may differ from the time span added to the period start time. endTime = endDt; } - // Return the Period object with the calculated end time and duration. - period.Duration = endTime.Subtract(period.StartTime); // exact duration - period.EndTime = endTime; // Only EndTime is relevant for further processing. + // Return the Period object with the calculated end time. + // Only EndTime is relevant for further processing, + // so we have to set it. + // If the period duration is not null here, it is an RDATE period + // and has priority over the calculated end time. - return period; + return new Period( + start: period.StartTime, + end: period.Duration == null + ? endTime + : period.EffectiveEndTime); } } diff --git a/Ical.Net/Evaluation/PeriodListEvaluator.cs b/Ical.Net/Evaluation/PeriodListEvaluator.cs index bae660485..d90585fe3 100644 --- a/Ical.Net/Evaluation/PeriodListEvaluator.cs +++ b/Ical.Net/Evaluation/PeriodListEvaluator.cs @@ -9,7 +9,7 @@ namespace Ical.Net.Evaluation; -public class PeriodListEvaluator : Evaluator +internal class PeriodListEvaluator : Evaluator { private readonly PeriodList _mPeriodList; @@ -24,7 +24,7 @@ public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? if (includeReferenceDateInResults) { - Period p = new Period(referenceDate); + var p = new Period(referenceDate); periods.Add(p); } diff --git a/Ical.Net/Evaluation/RecurringEvaluator.cs b/Ical.Net/Evaluation/RecurringEvaluator.cs index 0509d10e6..54755aee8 100644 --- a/Ical.Net/Evaluation/RecurringEvaluator.cs +++ b/Ical.Net/Evaluation/RecurringEvaluator.cs @@ -73,10 +73,10 @@ protected IEnumerable EvaluateRRule(IDateTime referenceDate, DateTime? p /// Evaluates the RDate component. protected IEnumerable EvaluateRDate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd) { - if (Recurrable.RecurrenceDates == null || !Recurrable.RecurrenceDates.Any()) - return []; + var recurrences = + new SortedSet(Recurrable.RecurrenceDates + .GetAllPeriodsByKind(PeriodKind.Period, PeriodKind.DateOnly, PeriodKind.DateTime)); - var recurrences = new SortedSet(Recurrable.RecurrenceDates.SelectMany(rdate => rdate)); return recurrences; } @@ -116,10 +116,8 @@ protected IEnumerable EvaluateExRule(IDateTime referenceDate, DateTime? /// The end date of the range to evaluate. protected IEnumerable EvaluateExDate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd) { - if (Recurrable.ExceptionDates == null || !Recurrable.ExceptionDates.Any()) - return []; - - var exDates = new SortedSet(Recurrable.ExceptionDates.SelectMany(exDate => exDate)); + var exDates = new SortedSet(Recurrable + .ExceptionDates.GetAllPeriodsByKind(PeriodKind.DateOnly, PeriodKind.DateTime)); return exDates; } @@ -142,7 +140,7 @@ public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? .OrderedMerge(rdateOccurrences) .OrderedDistinct() .OrderedExclude(exRuleExclusions) - .OrderedExclude(exDateExclusions, Comparer.Create(CompareDateOverlap)); + .OrderedExclude(exDateExclusions, Comparer.Create(CompareExDateOverlap)); return periods; } @@ -151,8 +149,10 @@ public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? /// Compares whether the given period's date overlaps with the given EXDATE. The dates are /// considered to overlap if they start at the same time, or the EXDATE is an all-day date /// and the period's start date is the same as the EXDATE's date. + /// + /// Note: for is always . ///
- private static int CompareDateOverlap(Period period, Period exDate) + private static int CompareExDateOverlap(Period period, Period exDate) { var cmp = period.CompareTo(exDate); if ((cmp != 0) && !exDate.StartTime.HasTime && (period.StartTime.Value.Date == exDate.StartTime.Value)) diff --git a/Ical.Net/Evaluation/TodoEvaluator.cs b/Ical.Net/Evaluation/TodoEvaluator.cs index 17e268f3d..c7e38d7d6 100644 --- a/Ical.Net/Evaluation/TodoEvaluator.cs +++ b/Ical.Net/Evaluation/TodoEvaluator.cs @@ -29,13 +29,10 @@ internal IEnumerable EvaluateToPreviousOccurrence(IDateTime completedDat DetermineStartingRecurrence(rrule, ref beginningDate); } } - if (Todo.RecurrenceDates != null) - { - foreach (var rdate in Todo.RecurrenceDates) - { - DetermineStartingRecurrence(rdate, ref beginningDate); - } - } + + DetermineStartingRecurrence(Todo.RecurrenceDates.GetAllPeriods(), ref beginningDate); + DetermineStartingRecurrence(Todo.RecurrenceDates.GetAllDates(), ref beginningDate); + if (Todo.ExceptionRules != null) { foreach (var exrule in Todo.ExceptionRules) @@ -43,18 +40,13 @@ internal IEnumerable EvaluateToPreviousOccurrence(IDateTime completedDat DetermineStartingRecurrence(exrule, ref beginningDate); } } - if (Todo.ExceptionDates != null) - { - foreach (var exdate in Todo.ExceptionDates) - { - DetermineStartingRecurrence(exdate, ref beginningDate); - } - } + + DetermineStartingRecurrence(Todo.ExceptionDates.GetAllDates(), ref beginningDate); return Evaluate(Todo.Start, DateUtil.GetSimpleDateTimeData(beginningDate), DateUtil.GetSimpleDateTimeData(currDt).AddTicks(1), true); } - private void DetermineStartingRecurrence(PeriodList rdate, ref IDateTime referenceDateTime) + private static void DetermineStartingRecurrence(IEnumerable rdate, ref IDateTime referenceDateTime) { var dt2 = referenceDateTime; foreach (var p in rdate.Where(p => p.StartTime.LessThan(dt2))) @@ -63,6 +55,15 @@ private void DetermineStartingRecurrence(PeriodList rdate, ref IDateTime referen } } + private static void DetermineStartingRecurrence(IEnumerable rdate, ref IDateTime referenceDateTime) + { + var dt2 = referenceDateTime; + foreach (var dt in rdate.Where(dt => dt.LessThan(dt2))) + { + referenceDateTime = dt; + } + } + private void DetermineStartingRecurrence(RecurrencePattern recur, ref IDateTime referenceDateTime) { if (recur.Count.HasValue) @@ -83,27 +84,7 @@ public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? if (Todo.Start == null) return []; - Period PeriodWithDuration(Period p) - { - if (p.EndTime != null) - return p; - - var period = p.Copy(); - - var d = Todo.Duration; - if (d != null) - { - period.EndTime = period.StartTime.Add(d.Value); - } - else - { - period.Duration = default; - } - - return period; - } - return base.Evaluate(referenceDate, periodStart, periodEnd, includeReferenceDateInResults) - .Select(PeriodWithDuration); + .Select(p => p); } } diff --git a/Ical.Net/Serialization/DataTypes/DataTypeSerializer.cs b/Ical.Net/Serialization/DataTypes/DataTypeSerializer.cs index d82e51830..6bc28e431 100644 --- a/Ical.Net/Serialization/DataTypes/DataTypeSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DataTypeSerializer.cs @@ -17,7 +17,7 @@ protected DataTypeSerializer(SerializationContext ctx) : base(ctx) { } protected virtual ICalendarDataType CreateAndAssociate() { // Create an instance of the object - if (!(Activator.CreateInstance(TargetType) is ICalendarDataType dt)) + if (Activator.CreateInstance(TargetType, true) is not ICalendarDataType dt) { return null; } @@ -29,4 +29,4 @@ protected virtual ICalendarDataType CreateAndAssociate() return dt; } -} \ No newline at end of file +} diff --git a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs index 7d45ec06d..838e1a586 100644 --- a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs @@ -43,12 +43,9 @@ public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } // the time value. The "TZID" property parameter MUST NOT be applied to DATE-TIME // properties whose time values are specified in UTC. - var kind = dt.IsUtc - ? DateTimeKind.Utc - : DateTimeKind.Unspecified; - if (dt.IsUtc) { + // 'Z' is used as the UTC designator dt.Parameters.Remove("TZID"); } else if (!string.IsNullOrWhiteSpace(dt.TzId)) @@ -56,16 +53,6 @@ public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } dt.Parameters.Set("TZID", dt.TzId); } - var dateWithNewKind = DateTime.SpecifyKind(dt.Value, kind); - // We can't use 'Copy' because we need to change the value - dt = dt.HasTime - ? new CalDateTime(dateWithNewKind, dt.TzId, true) { AssociatedObject = dt.AssociatedObject } - : new CalDateTime(dateWithNewKind, dt.TzId, false) { AssociatedObject = dt.AssociatedObject }; - - // FIXME: what if DATE is the default value type for this? - // Also, what if the DATE-TIME value type is specified on something - // where DATE-TIME is the default value type? It should be removed - // during serialization, as it's redundant... if (!dt.HasTime) { dt.SetValueType("DATE"); @@ -73,13 +60,12 @@ public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } var value = new StringBuilder(512); value.Append($"{dt.Year:0000}{dt.Month:00}{dt.Day:00}"); - if (dt.HasTime) + if (!dt.HasTime) return Encode(dt, value.ToString()); + + value.Append($"T{dt.Hour:00}{dt.Minute:00}{dt.Second:00}"); + if (dt.IsUtc) { - value.Append($"T{dt.Hour:00}{dt.Minute:00}{dt.Second:00}"); - if (dt.IsUtc) - { - value.Append("Z"); - } + value.Append("Z"); } // Encode the value as necessary diff --git a/Ical.Net/Serialization/DataTypes/DurationSerializer.cs b/Ical.Net/Serialization/DataTypes/DurationSerializer.cs index 7f86f807b..d17800b08 100644 --- a/Ical.Net/Serialization/DataTypes/DurationSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DurationSerializer.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. // +#nullable enable using System; using System.IO; using System.Text; @@ -19,7 +20,7 @@ public DurationSerializer(SerializationContext ctx) : base(ctx) { } public override Type TargetType => typeof(Duration); - public override string SerializeToString(object obj) + public override string? SerializeToString(object obj) => (obj is not Duration duration) ? null : SerializeToString(duration); private static string SerializeToString(Duration ts) @@ -53,20 +54,24 @@ private static string SerializeToString(Duration ts) return sb.ToString(); } - internal static readonly Regex TimespanMatch = + internal static readonly Regex DurationMatch = new Regex(@"^(?\+|-)?P(((?\d+)W)|(?
((?\d+)D)?(?