diff --git a/Ical.Net.Benchmarks/OccurencePerfTests.cs b/Ical.Net.Benchmarks/OccurencePerfTests.cs index d14d9634f..f7caa0f2c 100644 --- a/Ical.Net.Benchmarks/OccurencePerfTests.cs +++ b/Ical.Net.Benchmarks/OccurencePerfTests.cs @@ -40,13 +40,13 @@ public void GetOccurrences() public void MultipleEventsWithUntilOccurrencesSearchingByWholeCalendar() { var searchStart = _calendarFourEvents.Events.First().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(-1) .InZoneLeniently(tz); var searchEnd = _calendarFourEvents.Events.Last().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(1) .InZoneLeniently(tz) @@ -59,13 +59,13 @@ public void MultipleEventsWithUntilOccurrencesSearchingByWholeCalendar() public void MultipleEventsWithUntilOccurrences() { var searchStart = _calendarFourEvents.Events.First().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(-1) .InZoneLeniently(tz); var searchEnd = _calendarFourEvents.Events.Last().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(1) .InZoneLeniently(tz) @@ -80,13 +80,13 @@ public void MultipleEventsWithUntilOccurrences() public void MultipleEventsWithUntilOccurrencesEventsAsParallel() { var searchStart = _calendarFourEvents.Events.First().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(-1) .InZoneLeniently(tz); var searchEnd = _calendarFourEvents.Events.Last().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(1) .PlusDays(10) @@ -195,13 +195,13 @@ public void MultipleEventsWithCountOccurrencesSearchingByWholeCalendar() { var calendar = GetFourCalendarEventsWithCountRule(); var searchStart = _calendarFourEvents.Events.First().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(-1) .InZoneLeniently(tz); var searchEnd = _calendarFourEvents.Events.Last().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(1) .InZoneLeniently(tz) @@ -215,13 +215,13 @@ public void MultipleEventsWithCountOccurrences() { var calendar = GetFourCalendarEventsWithCountRule(); var searchStart = _calendarFourEvents.Events.First().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(-1) .InZoneLeniently(tz); var searchEnd = _calendarFourEvents.Events.Last().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(1) .InZoneLeniently(tz) @@ -237,13 +237,13 @@ public void MultipleEventsWithCountOccurrencesEventsAsParallel() { var calendar = GetFourCalendarEventsWithCountRule(); var searchStart = _calendarFourEvents.Events.First().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(-1) .InZoneLeniently(tz); var searchEnd = _calendarFourEvents.Events.Last().DtStart! - .ToZonedOrDefault(tz) + .ToZonedOrDefault(tz, _calendarFourEvents.TimeZoneProvider) .LocalDateTime .PlusYears(1) .PlusDays(10) diff --git a/Ical.Net.Tests/DeserializationTests.cs b/Ical.Net.Tests/DeserializationTests.cs index 269b197ff..d512480a8 100644 --- a/Ical.Net.Tests/DeserializationTests.cs +++ b/Ical.Net.Tests/DeserializationTests.cs @@ -124,8 +124,10 @@ public void Bug2938007() Assert.That(evt.End?.HasTime, Is.True); } - var results = evt.GetOccurrences(new LocalDateTime(2010, 1, 17, 0, 0, 0).InZoneLeniently("Asia/Tokyo")) - .TakeWhileBefore(new LocalDateTime(2010, 2, 1, 0, 0, 0).InZoneLeniently("Asia/Tokyo").ToInstant()); + var tz = iCal.TimeZoneProvider["Asia/Tokyo"]; + + var results = evt.GetOccurrences(new LocalDateTime(2010, 1, 17, 0, 0, 0).InZoneLeniently(tz)) + .TakeWhileBefore(new LocalDateTime(2010, 2, 1, 0, 0, 0).InZoneLeniently(tz).ToInstant()); foreach (var o in results) { diff --git a/Ical.Net.Tests/FreeBusyTest.cs b/Ical.Net.Tests/FreeBusyTest.cs index eb04a225e..5ea893fe9 100644 --- a/Ical.Net.Tests/FreeBusyTest.cs +++ b/Ical.Net.Tests/FreeBusyTest.cs @@ -7,7 +7,6 @@ using System.Collections.Generic; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; -using Ical.Net.Utility; using NodaTime; using NUnit.Framework; using Duration = Ical.Net.DataTypes.Duration; @@ -31,8 +30,10 @@ public void GetFreeBusyStatusByDateTime() evt.Start = new CalDateTime(2025, 10, 1, 8, 0, 0); evt.End = new CalDateTime(2025, 10, 1, 9, 0, 0); + var tz = cal.TimeZoneProvider["America/New_York"]; + var freeBusy = cal.GetFreeBusy( - DateUtil.GetZone("America/New_York"), + tz, new CalDateTime(2025, 10, 1, 0, 0, 0), new CalDateTime(2025, 10, 7, 11, 59, 59))!; @@ -60,7 +61,7 @@ public void GetFreeBusyStatusByPeriod() evt.End = new CalDateTime(2025, 6, 1, 10, 0, 0); var freeBusy = cal.GetFreeBusy( - DateUtil.GetZone("UTC"), + cal.TimeZoneProvider["UTC"], new CalDateTime(2025, 6, 1, 0, 0, 0, "UTC"), new CalDateTime(2025, 6, 7, 0, 0, 0, "UTC"))!; @@ -106,7 +107,6 @@ public void Contains_Tests() var periodWithDuration = new FreeBusyEntry(new(start.InUtc().ToCalDateTime(), Duration.FromHours(1)), FreeBusyStatus.Free); using (Assert.EnterMultipleScope()) { - Assert.That(periodWithDuration.Contains(null), Is.False, "Contains should return false for null dt."); Assert.That(periodWithDuration.Contains(dtBefore), Is.False, "Contains should return false if dt is before start."); Assert.That(periodWithDuration.Contains(dtAtStart), Is.True, "Contains should return true for dt equal to start."); Assert.That(periodWithDuration.Contains(dtMid), Is.True, "Contains should return true for dt in the middle."); @@ -263,7 +263,7 @@ public void CreateFiltersOccurrencesByRequestAttendees() { Assert.That(freeBusy.Entries, Has.Count.EqualTo(1)); Assert.That(freeBusy.Entries[0].Status, Is.EqualTo(FreeBusyStatus.Busy)); - Assert.That(freeBusy.Entries[0].StartTime.ToDateTimeUtc(), Is.EqualTo(busyEvent.Start.ToDateTimeUtc())); + Assert.That(freeBusy.Entries[0].StartTime, Is.EqualTo(new CalDateTime(2025, 9, 1, 9, 0, 0, "UTC"))); } } diff --git a/Ical.Net.Tests/MatchTimeZoneTests.cs b/Ical.Net.Tests/MatchTimeZoneTests.cs index 06675726e..10f8971fe 100644 --- a/Ical.Net.Tests/MatchTimeZoneTests.cs +++ b/Ical.Net.Tests/MatchTimeZoneTests.cs @@ -6,6 +6,8 @@ using System; using System.Linq; using Ical.Net.DataTypes; +using NodaTime; +using NodaTime.TimeZones; using NUnit.Framework; namespace Ical.Net.Tests; @@ -241,4 +243,107 @@ public void MatchTimeZone_DateOnly() Assert.That(occurrences.Count, Is.EqualTo(5)); } } + + [Test, Category("Recurrence")] + public void MatchTimeZone_CaseInsensitive_EventEval() + { + const string ical = + """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp//NONSGML Event//EN + BEGIN:VEVENT + UID:example1 + SUMMARY:Event with local time and time zone + DTSTART;TZID=America/New_york:20231101T090000 + RRULE:FREQ=DAILY;UNTIL=20231105T130000Z + DTEND;TZID=America/New_york:20231101T100000 + END:VEVENT + END:VCALENDAR + """; + + var calendar = Calendar.Load(ical)!; + var evt = calendar.Events.First(); + + // Throws because the default time zone provider + // expects "New_York" instead of "New_york". + Assert.Throws(() => + { + var _ = evt.GetOccurrences(new CalDateTime(2023, 11, 01)) + .TakeWhileBefore(new CalDateTime(2023, 11, 06)); + }); + + // Set the calendar time zone provider to ignore case + calendar.TimeZoneProvider = CalendarTimeZoneProviders.TzdbWithAliasesIgnoreCase; + + var occurrences = evt.GetOccurrences(new CalDateTime(2023, 11, 01)) + .TakeWhileBefore(new CalDateTime(2023, 11, 06)) + .ToList(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(occurrences, Has.Count.EqualTo(4)); + Assert.That(occurrences[0].Start.Zone.Id, Is.EqualTo("America/New_York")); + } + } + + [Test, Category("Recurrence")] + public void MatchTimeZone_CaseInsensitive_CalendarEval() + { + const string ical = + """ + BEGIN:VCALENDAR + VERSION:2.0 + PRODID:-//Example Corp//NONSGML Event//EN + BEGIN:VEVENT + UID:example1 + SUMMARY:Event with local time and time zone + DTSTART;TZID=America/New_york:20231101T090000 + RRULE:FREQ=DAILY;UNTIL=20231105T130000Z + DTEND;TZID=America/New_york:20231101T100000 + END:VEVENT + BEGIN:VEVENT + UID:example2 + SUMMARY:Event with local time and time zone + DTSTART;TZID=us-eastern:20231101T100000 + RRULE:FREQ=DAILY;UNTIL=20231105T130000Z + DTEND;TZID=us-eastern:20231101T110000 + END:VEVENT + BEGIN:VEVENT + UID:example3 + SUMMARY:Event with local time and time zone + DTSTART;TZID=eastern standard time:20231101T110000 + RRULE:FREQ=DAILY;UNTIL=20231105T130000Z + DTEND;TZID=eastern standard time:20231101T120000 + END:VEVENT + END:VCALENDAR + """; + + var calendar = Calendar.Load(ical)!; + + var tz = calendar.TimeZoneProvider.GetZoneOrNull("America/Los_Angeles")!; + + var start = new LocalDate(2023, 11, 1).AtStartOfDayInZone(tz); + var end = new LocalDate(2023, 11, 6).AtStartOfDayInZone(tz).ToInstant(); + + // Throws because the default time zone provider + // expects "New_York" instead of "New_york". + Assert.Throws(() => + { + var _ = calendar.GetOccurrences(start).TakeWhileBefore(end); + }); + + // Set the calendar time zone provider to ignore case + calendar.TimeZoneProvider = CalendarTimeZoneProviders.TzdbWithAliasesIgnoreCase; + + var occurrences = calendar.GetOccurrences(start) + .TakeWhileBefore(end) + .ToList(); + + using (Assert.EnterMultipleScope()) + { + Assert.That(occurrences, Has.Count.EqualTo(12)); + Assert.That(occurrences[0].Start.Zone.Id, Is.EqualTo("America/Los_Angeles")); + } + } } diff --git a/Ical.Net.Tests/ProgramTest.cs b/Ical.Net.Tests/ProgramTest.cs index a87991833..dc74ce2a2 100644 --- a/Ical.Net.Tests/ProgramTest.cs +++ b/Ical.Net.Tests/ProgramTest.cs @@ -1,4 +1,4 @@ -// +// // Copyright ical.net project maintainers and contributors. // Licensed under the MIT license. // @@ -6,6 +6,7 @@ using System; using System.Linq; using Ical.Net.DataTypes; +using NodaTime; using NUnit.Framework; namespace Ical.Net.Tests; @@ -49,24 +50,26 @@ public void Merge1() var evt1 = iCal1.Events.First(); var evt2 = iCal1.Events.Skip(1).First(); + var tz = iCal1.TimeZoneProvider[_tzid]; + // Get occurrences for the first event var occurrences = evt1.GetOccurrences( - new CalDateTime(1996, 1, 1).ToZonedDateTime(_tzid)) - .TakeWhileBefore(new CalDateTime(2000, 1, 1).ToZonedDateTime(_tzid).ToInstant()).ToList(); + new LocalDate(1996, 1, 1).AtStartOfDayInZone(tz)) + .TakeWhileBefore(new LocalDate(2000, 1, 1).AtStartOfDayInZone(tz).ToInstant()).ToList(); - var dateTimes = new[] + var dateTimes = new LocalDate[] { - new CalDateTime(1997, 9, 10, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1997, 9, 11, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1997, 9, 12, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1997, 9, 13, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1997, 9, 14, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1997, 9, 15, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1999, 3, 10, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1999, 3, 11, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1999, 3, 12, 9, 0, 0, _tzid).ToZonedDateTime(), - new CalDateTime(1999, 3, 13, 9, 0, 0, _tzid).ToZonedDateTime(), - }; + new(1997, 9, 10), + new(1997, 9, 11), + new(1997, 9, 12), + new(1997, 9, 13), + new(1997, 9, 14), + new(1997, 9, 15), + new(1999, 3, 10), + new(1999, 3, 11), + new(1999, 3, 12), + new(1999, 3, 13), + }.Select(x => x.At(new LocalTime(9, 0)).InZoneStrictly(tz)).ToArray(); for (var i = 0; i < dateTimes.Length; i++) { @@ -79,30 +82,30 @@ public void Merge1() // Get occurrences for the 2nd event occurrences = evt2.GetOccurrences( - new CalDateTime(1996, 1, 1).ToZonedDateTime(_tzid)) - .TakeWhileBefore(new CalDateTime(1998, 4, 1).ToZonedDateTime(_tzid).ToInstant()).ToList(); + new LocalDate(1996, 1, 1).AtStartOfDayInZone(tz)) + .TakeWhileBefore(new LocalDate(1998, 4, 1).AtStartOfDayInZone(tz).ToInstant()).ToList(); - var dateTimes1 = new[] + var dateTimes1 = new LocalDate[] { - new CalDateTime(1997, 9, 2, 9, 0, 0, _tzid), - new CalDateTime(1997, 9, 9, 9, 0, 0, _tzid), - new CalDateTime(1997, 9, 16, 9, 0, 0, _tzid), - new CalDateTime(1997, 9, 23, 9, 0, 0, _tzid), - new CalDateTime(1997, 9, 30, 9, 0, 0, _tzid), - new CalDateTime(1997, 11, 4, 9, 0, 0, _tzid), - new CalDateTime(1997, 11, 11, 9, 0, 0, _tzid), - new CalDateTime(1997, 11, 18, 9, 0, 0, _tzid), - new CalDateTime(1997, 11, 25, 9, 0, 0, _tzid), - new CalDateTime(1998, 1, 6, 9, 0, 0, _tzid), - new CalDateTime(1998, 1, 13, 9, 0, 0, _tzid), - new CalDateTime(1998, 1, 20, 9, 0, 0, _tzid), - new CalDateTime(1998, 1, 27, 9, 0, 0, _tzid), - new CalDateTime(1998, 3, 3, 9, 0, 0, _tzid), - new CalDateTime(1998, 3, 10, 9, 0, 0, _tzid), - new CalDateTime(1998, 3, 17, 9, 0, 0, _tzid), - new CalDateTime(1998, 3, 24, 9, 0, 0, _tzid), - new CalDateTime(1998, 3, 31, 9, 0, 0, _tzid) - }.Select(x => x.ToZonedDateTime()).ToArray(); + new (1997, 9, 2), + new (1997, 9, 9), + new (1997, 9, 16), + new (1997, 9, 23), + new (1997, 9, 30), + new (1997, 11, 4), + new (1997, 11, 11), + new (1997, 11, 18), + new (1997, 11, 25), + new (1998, 1, 6), + new (1998, 1, 13), + new (1998, 1, 20), + new (1998, 1, 27), + new (1998, 3, 3), + new (1998, 3, 10), + new (1998, 3, 17), + new (1998, 3, 24), + new (1998, 3, 31) + }.Select(x => x.At(new LocalTime(9, 0)).InZoneStrictly(tz)).ToArray(); using (Assert.EnterMultipleScope()) { diff --git a/Ical.Net.Tests/RecurrenceIdentifierTests.cs b/Ical.Net.Tests/RecurrenceIdentifierTests.cs index ac84b4080..f74cc1522 100644 --- a/Ical.Net.Tests/RecurrenceIdentifierTests.cs +++ b/Ical.Net.Tests/RecurrenceIdentifierTests.cs @@ -103,19 +103,5 @@ public void RecurrenceIdentifierSerializer_LowLevel() } } - [TestCase("20250831", RecurrenceRange.ThisInstance, 1)] // other earlier - [TestCase("20250901", RecurrenceRange.ThisInstance, 0)] // same date, same range - [TestCase("20250901", RecurrenceRange.ThisAndFuture, -1)] // same date, higher range - [TestCase("20250902", RecurrenceRange.ThisInstance, -1)] // other later - [TestCase("20250902", RecurrenceRange.ThisAndFuture, -1)] // other later, higher range - public void CompareToTests(string dt, RecurrenceRange range, int expected) - { - var self = new RecurrenceIdentifier(new CalDateTime("20250901"), RecurrenceRange.ThisInstance); - var other = new RecurrenceIdentifier(new CalDateTime(dt), range); - - Assert.That(self.CompareTo(other), Is.EqualTo(expected)); - Assert.That(self.CompareTo(null), Is.EqualTo(1)); - } - #pragma warning restore CS0618 // Type or member is obsolete } diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index e29930954..f98a631e2 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -15,7 +15,6 @@ using Ical.Net.Serialization; using Ical.Net.Serialization.DataTypes; using Ical.Net.Tests.TestHelpers; -using Ical.Net.Utility; using NodaTime; using NodaTime.Extensions; using NUnit.Framework; @@ -168,17 +167,19 @@ public void DailyUntil1() ProgramTest.TestCal(iCal); var evt = iCal.Events.First(); + var tz = iCal.TimeZoneProvider[_tzid]; + var occurrences = evt.GetOccurrences( - new CalDateTime(1997, 9, 1).ToZonedDateTime(_tzid)) - .TakeWhileBefore(new CalDateTime(1998, 1, 1).ToZonedDateTime(_tzid).ToInstant()) + new LocalDate(1997, 9, 1).AtStartOfDayInZone(tz)) + .TakeWhileBefore(new LocalDate(1998, 1, 1).AtStartOfDayInZone(tz).ToInstant()) .ToList(); - var dt = new CalDateTime(1997, 9, 2, 9, 0, 0).ToZonedDateTime(_tzid); + var dt = new LocalDateTime(1997, 9, 2, 9, 0, 0).InZoneStrictly(tz); var i = 0; while (dt.Year < 1998) { - if (dt.ToInstant() >= evt.Start!.ToZonedDateTime(_tzid).ToInstant() && - dt.ToInstant() < new CalDateTime(1997, 12, 24, 0, 0, 0).ToZonedDateTime(_tzid).ToInstant()) + if (dt.ToInstant() >= evt.Start!.ToZonedDateTime(iCal.TimeZoneProvider).ToInstant() && + dt.ToInstant() < new LocalDate(1997, 12, 24).AtStartOfDayInZone(tz).ToInstant()) { Assert.That(occurrences[i].Start, Is.EqualTo(dt), "Event should occur at " + dt); @@ -187,7 +188,7 @@ public void DailyUntil1() dt = dt.LocalDateTime .PlusDays(1) - .InZoneLeniently(_tzid); + .InZoneLeniently(tz); } } @@ -285,18 +286,20 @@ public void ByMonth1() ProgramTest.TestCal(iCal); var evt = iCal.Events.First(); + var tz = iCal.TimeZoneProvider[_tzid]; + var occurrences = evt.GetOccurrences( - new CalDateTime(1998, 1, 1).ToZonedDateTime(_tzid)) - .TakeWhileBefore(new CalDateTime(2000, 12, 31).ToZonedDateTime(_tzid).ToInstant()) + new LocalDate(1998, 1, 1).AtStartOfDayInZone(tz)) + .TakeWhileBefore(new LocalDate(2000, 12, 31).AtStartOfDayInZone(tz).ToInstant()) .ToList(); - var dt = new CalDateTime(1998, 1, 1, 9, 0, 0, _tzid).ToZonedDateTime(); + var dt = new LocalDateTime(1998, 1, 1, 9, 0).InZoneStrictly(tz); var i = 0; while (dt.Year < 2001) { - if (dt.ToInstant() >= evt.Start!.ToZonedDateTime(_tzid).ToInstant() && + if (dt.ToInstant() >= evt.Start!.ToZonedOrDefault(tz, iCal.TimeZoneProvider).ToInstant() && dt.Month == 1 && - dt.ToInstant() <= new CalDateTime(2000, 1, 31, 9, 0, 0, _tzid).ToZonedDateTime().ToInstant()) + dt.ToInstant() <= new LocalDateTime(2000, 1, 31, 9, 0).InZoneStrictly(tz).ToInstant()) { Assert.That(occurrences[i].Start, Is.EqualTo(dt), "Event should occur at " + dt); i++; @@ -304,7 +307,7 @@ public void ByMonth1() dt = dt.LocalDateTime .PlusDays(1) - .InZoneLeniently(_tzid); + .InZoneLeniently(tz); } } @@ -1579,7 +1582,7 @@ public void Secondly_DefinedNumberOfOccurrences_ShouldSucceed() { var iCal = Calendar.Load(IcsFiles.Secondly1)!; - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var start = new LocalDateTime(2007, 6, 21, 8, 0).InZoneStrictly(tz); var end = new LocalDateTime(2007, 6, 21, 8, 1).InZoneStrictly(tz); @@ -1600,7 +1603,7 @@ public void Minutely_DefinedNumberOfOccurrences_ShouldSucceed() { var iCal = Calendar.Load(IcsFiles.Minutely1)!; - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var start = new LocalDateTime(2007, 6, 21, 8, 0, 0).InZoneStrictly(tz); var end = new LocalDateTime(2007, 6, 21, 12, 0, 1).InZoneStrictly(tz); // End period is exclusive, not inclusive. @@ -1621,7 +1624,7 @@ public void Hourly_DefinedNumberOfOccurrences_ShouldSucceed() { var iCal = Calendar.Load(IcsFiles.Hourly1)!; - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var start = new LocalDateTime(2007, 6, 21, 8, 0, 0).InZoneStrictly(tz); var end = new LocalDateTime(2007, 6, 25, 8, 0, 1).InZoneStrictly(tz); // End period is exclusive, not inclusive. @@ -2015,7 +2018,8 @@ public void Bug2912657() new Period(new CalDateTime(2009, 12, 9, 2, 00, 00, localTzid), Duration.FromMinutes(30)), new Period(new CalDateTime(2009, 12, 10, 2, 00, 00, localTzid), Duration.FromMinutes(30)) }, - 0 + 0, + "America/New_York" ); // Weekly with UNTIL value @@ -2027,7 +2031,8 @@ public void Bug2912657() { new Period(new CalDateTime(2009, 12, 4, 2, 00, 00, localTzid), Duration.FromMinutes(30)) }, - 1 + 1, + "America/New_York" ); // Weekly with COUNT=2 @@ -2040,7 +2045,8 @@ public void Bug2912657() new Period(new CalDateTime(2009, 12, 4, 2, 00, 00, localTzid), Duration.FromMinutes(30)), new Period(new CalDateTime(2009, 12, 11, 2, 00, 00, localTzid), Duration.FromMinutes(30)) }, - 2 + 2, + "America/New_York" ); } @@ -2594,7 +2600,7 @@ public void RecurrenceRule1() var pattern = new RecurrenceRule("FREQ=SECONDLY;INTERVAL=10"); var startDate = new CalDateTime(2008, 3, 30, 23, 59, 40); - var fromDate = startDate.ToZonedDateTime(_tzid); + var fromDate = startDate.ToZonedDateTime("US/Eastern"); var toDate = new CalDateTime(2008, 3, 31, 0, 0, 11); var occurrences = pattern.Evaluate(startDate, fromDate) @@ -2786,7 +2792,7 @@ public void Test4() public void Recurrence_WithOutOfBoundsUtc_ShouldFailWithCorrectException(string ical, bool shouldThrow) { var cal = Calendar.Load(ical)!; - Assert.That(() => cal.GetOccurrences(DateUtil.GetZone(_tzid)).ToList(), + Assert.That(() => cal.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[_tzid]).ToList(), shouldThrow ? Throws.InstanceOf() : Throws.Nothing); } @@ -2984,20 +2990,22 @@ public void EventWithZonedRecurrenceId_Should_ReplaceOriginalEvent_Occurrence() var calendar = Calendar.Load(ical)!; - var orderedOccurrences = calendar.GetOccurrences(DateUtil.GetZone("Europe/Bucharest")) + var tz = calendar.TimeZoneProvider["Europe/Bucharest"]; + + var orderedOccurrences = calendar.GetOccurrences(tz) .Take(10) .ToList(); - var expectedSept1Start = CalDateTime.FromDateTime(DateTime.Parse("2016-09-01T16:30:00", CultureInfo.InvariantCulture), "Europe/Bucharest").ToZonedDateTime(); - var expectedSept1End = CalDateTime.FromDateTime(DateTime.Parse("2016-09-01T22:00:00", CultureInfo.InvariantCulture), "Europe/Bucharest").ToZonedDateTime(); + var expectedSept1Start = new LocalDateTime(2016, 9, 1, 16, 30).InZoneStrictly(tz); + var expectedSept1End = new LocalDateTime(2016, 9 ,1, 22, 0).InZoneStrictly(tz); using (Assert.EnterMultipleScope()) { Assert.That(orderedOccurrences[3].Start, Is.EqualTo(expectedSept1Start)); Assert.That(orderedOccurrences[3].End, Is.EqualTo(expectedSept1End)); } - var expectedSept3Start = CalDateTime.FromDateTime(DateTime.Parse("2016-09-03T07:00:00", CultureInfo.InvariantCulture), "Europe/Bucharest").ToZonedDateTime(); - var expectedSept3End = CalDateTime.FromDateTime(DateTime.Parse("2016-09-03T12:30:00", CultureInfo.InvariantCulture), "Europe/Bucharest").ToZonedDateTime(); + var expectedSept3Start = new LocalDateTime(2016, 9, 3, 7, 0).InZoneStrictly(tz); + var expectedSept3End = new LocalDateTime(2016, 9, 3, 12, 30).InZoneStrictly(tz); using (Assert.EnterMultipleScope()) { Assert.That(orderedOccurrences[5].Start, Is.EqualTo(expectedSept3Start)); @@ -3108,7 +3116,7 @@ public void OneDayRange() End = CalDateTime.FromDateTime(DateTime.Parse("2019-06-08 00:00:00", CultureInfo.InvariantCulture)) }; - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; //Testing on both the first day and the next, results used to be different for (var i = 0; i <= 1; i++) @@ -3369,7 +3377,7 @@ RecurrenceRule GetRule() // Evaluate all tests in UTC so that INSTANCES match // the local date and time of the expected values. - var timeZone = DateUtil.GetZone("UTC"); + var timeZone = CalendarTimeZoneProviders.TzdbWithAliases["UTC"]; var startAt = testCase.StartAt?.ToZonedOrDefault(timeZone).ToInstant(); @@ -3503,7 +3511,7 @@ public void TestGetOccurrenceIndefinite() // Although the occurrences are unbounded, we can still call GetOccurrences without // specifying bounds, because the instances are only generated on enumeration. - var occurrences = calendar.GetOccurrences(DateUtil.GetZone(_tzid)); + var occurrences = calendar.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[_tzid]); var instances = occurrences.Take(100).ToList(); @@ -3592,7 +3600,7 @@ public void TestMaxIncrementCount(int? limit, string ical, bool expectException) ? Throws.Exception.TypeOf() : Throws.Nothing; - Assert.That(() => cal.GetOccurrences(DateUtil.GetZone(_tzid), options: options).ToList(), constraint); + Assert.That(() => cal.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[_tzid], options: options).ToList(), constraint); } [TestCase("FREQ=DAILY;INTERVAL=2;UNTIL=20250430T000000Z")] @@ -3692,7 +3700,7 @@ public void AmbiguousLocalTime_WithShortDurationOfRecurrence() """; var cal = Calendar.Load(ics)!; - var tz = DateUtil.GetZone("Europe/Vienna"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["Europe/Vienna"]; var occ = cal.GetOccurrences(tz).ToList(); @@ -3758,7 +3766,7 @@ public void GetOccurrences_WithMixedKindExDates_ShouldProperlyConsiderAll() // Should be considered only at the exact time cal.ExceptionDates.Add(new CalDateTime("20250703T150000")); - var occurrences = cal.GetOccurrences(DateUtil.GetZone(_tzid)) + var occurrences = cal.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[_tzid]) .Take(2) .Select(o => o.Start) .ToList(); @@ -3788,7 +3796,7 @@ public void GetOccurrences_WithMixedKindExDatesAndTz_ShouldProperlyConsiderAll() END:VCALENDAR """)!; - var occurrences = cal.GetOccurrences(DateUtil.GetZone(_tzid)) + var occurrences = cal.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[_tzid]) .Select(o => o.Start) .ToList(); @@ -3883,7 +3891,7 @@ public void GetOccurrences_WithPeriodStart_ShouldConsiderTzCorrectly(string dtSt var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone(periodStartTzId ?? _tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[periodStartTzId ?? _tzid]; var firstFewOccurrences = cal.GetOccurrences(tz).Take(3).ToList(); @@ -3925,7 +3933,7 @@ public void EventWithRecurrenceId_Should_ReplaceOriginalEvent_Occurrence(string END:VCALENDAR """)!; - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var occurrences = cal .GetOccurrences(tz).ToList(); @@ -3977,7 +3985,7 @@ public void EventWithRecurrenceId_LatestInOrderOverride_ShouldBeTaken() END:VCALENDAR """)!; - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var occurrences = cal .GetOccurrences(tz).ToList(); @@ -4031,7 +4039,7 @@ public void EventWithRecurrenceId_LatestSequence_ShouldBeTaken() END:VCALENDAR """)!; - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var occurrences = cal .GetOccurrences(tz).ToList(); @@ -4068,7 +4076,7 @@ public void SkippedOccurrenceOnWeeklyRule() // Test moved from former GetOccurre var calendar = new Calendar(); calendar.Events.Add(vEvent); - var tz = DateUtil.GetZone("UTC"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["UTC"]; var intervalStart = eventStart.ToZonedOrDefault(tz); var intervalEnd = intervalStart.LocalDateTime @@ -4109,7 +4117,7 @@ public void GetOccurrences_ShouldReturnCorrectStartAndEndTimes() calendar.Events.Add(vEvent); calendar.Events.Add(vEvent2); - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var searchStart = new CalDateTime(2015, 12, 29).ToZonedOrDefault(tz); var searchEnd = new CalDateTime(2017, 02, 10).ToZonedOrDefault(tz).ToInstant(); @@ -4190,10 +4198,10 @@ public void GetOccurrencesWithRecurrenceIdShouldEnumerate() END:VCALENDAR """; - var tz = DateUtil.GetZone("W. Europe Standard Time"); var collection = Calendar.Load(ical)!; - var startCheck = new CalDateTime(2016, 11, 11).ToZonedOrDefault(tz); + var tz = collection.TimeZoneProvider["W. Europe Standard Time"]; + var startCheck = new LocalDate(2016, 11, 11).AtStartOfDayInZone(tz); var occurrences = collection.GetOccurrences(startCheck) .TakeWhileBefore(startCheck.LocalDateTime.PlusMonths(1).InZoneLeniently(tz).ToInstant()).ToList(); @@ -4207,7 +4215,7 @@ public void GetOccurrencesWithRecurrenceIdShouldEnumerate() new("20161128T120100", "W. Europe Standard Time"), new("20161205T000100", "W. Europe Standard Time"), new("20161205T120100", "W. Europe Standard Time") - }.Select(x => x.ToZonedDateTime()).ToList(); + }.Select(x => x.ToZonedDateTime(collection.TimeZoneProvider)).ToList(); // 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. @@ -4249,7 +4257,7 @@ public void GetOccurrencesWithRecurrenceId_DateOnly_ShouldEnumerate() END:VCALENDAR """; - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var collection = Calendar.Load(ical)!; var startCheck = new CalDateTime(2023, 10, 1).ToZonedOrDefault(tz); @@ -4281,7 +4289,7 @@ public void GetOccurrencesWithRecurrenceId_DateOnly_ShouldEnumerate() [Test] public void CalendarCollection_GetOccurrences_ShouldEnumerateAndMerge() { - var tz = DateUtil.GetZone(_tzid); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[_tzid]; var cal1 = new Calendar(); cal1.Events.Add( new CalendarEvent { DtStart = new CalDateTime(2025, 1, 1), DtEnd = new CalDateTime(2025, 1, 2) }); @@ -4417,7 +4425,7 @@ public void GetOccurrences_FloatingRecurrenceId_UsesEvaluationTimeZone() var cal = Calendar.Load(ical)!; - var tz = DateUtil.GetZone("America/New_York"); + var tz = cal.TimeZoneProvider["America/New_York"]; var from = new LocalDateTime(2025, 01, 01, 0, 0).InZoneStrictly(tz); var to = new LocalDateTime(2025, 01, 02, 0, 0).InZoneStrictly(tz).ToInstant(); @@ -4435,7 +4443,7 @@ public void GetOccurrences_FloatingRecurrenceId_UsesEvaluationTimeZone() Assert.That(a, Is.EqualTo(expected)); // Evaluate again for another time zone that is 1 hour behind - tz = DateUtil.GetZone("America/Chicago"); + tz = cal.TimeZoneProvider["America/Chicago"]; from = new LocalDateTime(2025, 01, 01, 0, 0).InZoneStrictly(tz); to = new LocalDateTime(2025, 01, 02, 0, 0).InZoneStrictly(tz).ToInstant(); @@ -4481,7 +4489,7 @@ public void GetOccurrences_BackwardsDaylightSaving_25Hours(string start, string? var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var result = cal.GetOccurrences(tz.AtStartOfDay(new LocalDate(2025, 11, 2))) .TakeWhileBefore(tz.AtStartOfDay(new LocalDate(2025, 11, 3)).ToInstant()) .Select(x => x.Start) @@ -4508,7 +4516,7 @@ public void GetOccurrences_BackwardsDaylightSaving_25Hours_Shifted(string start, var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var result = cal.GetOccurrences(tz.AtStrictly(new LocalDateTime(2025, 11, 2, 0, 15))) .TakeWhileBefore(tz.AtStartOfDay(new LocalDate(2025, 11, 3)).ToInstant()) .Select(x => x.Period) @@ -4535,7 +4543,7 @@ public void GetOccurrences_BackwardsDaylightSaving_25Hours_NoDuration(string sta var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var result = cal.GetOccurrences(tz.AtStrictly(new LocalDateTime(2025, 11, 2, 0, 15))) .TakeWhileBefore(tz.AtStartOfDay(new LocalDate(2025, 11, 3)).ToInstant()) .Select(x => x.Period) @@ -4562,7 +4570,7 @@ public void GetOccurrences_BackwardsDaylightSaving_SpanFirstHour(string start, s var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var result = cal.GetOccurrences(tz.AtStrictly(new LocalDateTime(2025, 11, 2, 0, 15))) .TakeWhileBefore(tz.AtStartOfDay(new LocalDate(2025, 11, 3)).ToInstant()) .Select(x => x.Period) @@ -4587,8 +4595,8 @@ public void YearlyByMonthByDay_AroundForwardDaylightSaving_ShiftsHourOnlyOnTrans var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); - var result = cal.GetOccurrences(evt.Start.ToZonedDateTime()) + var tz = cal.TimeZoneProvider["America/New_York"]; + var result = cal.GetOccurrences(evt.Start.ToZonedDateTime(cal.TimeZoneProvider)) .Select(x => x.Start) .ToList(); @@ -4616,8 +4624,8 @@ public void Monthly_OnForwardDaylightSaving_ShiftsHourOnlyOnTransitionDay() var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); - var result = cal.GetOccurrences(evt.Start.ToZonedDateTime()) + var tz = cal.TimeZoneProvider["America/New_York"]; + var result = cal.GetOccurrences(evt.Start.ToZonedDateTime(cal.TimeZoneProvider)) .Select(x => x.Start) .ToList(); @@ -4645,7 +4653,7 @@ public void MonthlyUtcEvent_OnForwardDaylightSaving_HourShiftsAfterTransition() var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var result = cal.GetOccurrences(tz, evt.Start.ToZonedDateTime().ToInstant()) .Select(x => x.Start) .ToList(); @@ -4674,7 +4682,7 @@ public void MonthlyByDayByHour_AroundForwardDaylightSaving_CountIsCorrect() var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var result = cal.GetOccurrences(tz, evt.Start.ToZonedDateTime().ToInstant()) .Select(x => x.Start) .ToList(); @@ -4703,7 +4711,7 @@ public void MonthlyByDayByHourByMinute_AroundForwardDaylightSaving_CountIsCorrec var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var result = cal.GetOccurrences(tz, evt.Start.ToZonedDateTime().ToInstant()) .Select(x => x.Start) .ToList(); @@ -4737,7 +4745,7 @@ public void MonthlyByDayByHour_AroundBackwardDaylightSaving_CountIsCorrect() var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var result = cal.GetOccurrences(tz, evt.Start.ToZonedDateTime().ToInstant()) .Select(x => x.Start) .ToList(); @@ -4766,7 +4774,7 @@ public void Hourly_BackwardDaylightSaving_CountIsCorrect() var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var start = new LocalDate(2025, 11, 2) .AtStartOfDayInZone(tz); @@ -4798,7 +4806,7 @@ public void Hourly_BackwardDaylightSaving_StartAtTransition_CountIsCorrect() var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var start = new OffsetDateTime(new(2025, 11, 2, 1, 0), tz.MinOffset) .InZone(tz); @@ -4830,7 +4838,7 @@ public void StartingAfterCount_ProducesNoValues() var cal = new Calendar(); cal.Events.Add(evt); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var start = new LocalDate(2025, 1, 4).AtStartOfDayInZone(tz); var result = cal.GetOccurrences(start) .Select(x => x.Start) diff --git a/Ical.Net.Tests/RecurrenceWithExDateTests.cs b/Ical.Net.Tests/RecurrenceWithExDateTests.cs index 8ad1be709..8d34da212 100644 --- a/Ical.Net.Tests/RecurrenceWithExDateTests.cs +++ b/Ical.Net.Tests/RecurrenceWithExDateTests.cs @@ -8,7 +8,6 @@ using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using Ical.Net.Serialization; -using Ical.Net.Utility; using NodaTime; using NUnit.Framework; @@ -57,7 +56,7 @@ public void ShouldNotOccurOnLocalExceptionDate(bool useExDateWithTime) var ics = serializer.SerializeToString(calendar)!; var deserializedCalendar = Calendar.Load(ics)!; - var occurrences = deserializedCalendar.GetOccurrences(DateUtil.GetZone(timeZoneId)).ToList(); + var occurrences = deserializedCalendar.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[timeZoneId]).ToList(); using (Assert.EnterMultipleScope()) { @@ -96,7 +95,7 @@ public void ShouldNotOccurOnUtcExceptionDate() """; var cal = Calendar.Load(ics)!; - var occurrences = cal.GetOccurrences(DateUtil.GetZone("GMT")).ToList(); + var occurrences = cal.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["GMT"]).ToList(); var serializer = new CalendarSerializer(); ics = serializer.SerializeToString(cal); @@ -144,7 +143,7 @@ public void MultipleExclusionDatesSameTimeZoneShouldBeExcluded() """; var cal = Calendar.Load(ics)!; - var occurrences = cal.GetOccurrences(DateUtil.GetZone("Europe/Berlin")).ToList(); + var occurrences = cal.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["Europe/Berlin"]).ToList(); var serializer = new CalendarSerializer(); ics = serializer.SerializeToString(cal); @@ -206,7 +205,7 @@ public void MultipleExclusionDatesDifferentZoneShouldBeExcluded() 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(DateUtil.GetZone("America/New_York")).ToList(); + var occurrences = cal.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); // Occurrences: // October 25, 2023, 09:00 AM (EDT, UTC-4) diff --git a/Ical.Net.Tests/RecurrenceWithRDateTests.cs b/Ical.Net.Tests/RecurrenceWithRDateTests.cs index 38d188873..fd1328d9d 100644 --- a/Ical.Net.Tests/RecurrenceWithRDateTests.cs +++ b/Ical.Net.Tests/RecurrenceWithRDateTests.cs @@ -1,4 +1,4 @@ -// +// // Copyright ical.net project maintainers and contributors. // Licensed under the MIT license. // @@ -9,7 +9,6 @@ using Ical.Net.DataTypes; using Ical.Net.Evaluation; using Ical.Net.Serialization; -using Ical.Net.Utility; using NodaTime; using NUnit.Framework; using Duration = Ical.Net.DataTypes.Duration; @@ -37,7 +36,7 @@ public void RDate_SingleDateTime_IsProcessedCorrectly() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone("America/New_York")).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); using (Assert.EnterMultipleScope()) { @@ -70,7 +69,7 @@ public void RDate_SingleDateOnly_IsProcessedCorrectly() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone("America/New_York")).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); using (Assert.EnterMultipleScope()) { @@ -105,7 +104,7 @@ public void RDate_MultipleDates_WithTimeZones_AreProcessedCorrectly() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone(tzId)).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[tzId]).ToList(); using (Assert.EnterMultipleScope()) { @@ -144,7 +143,7 @@ public void RDate_PeriodsWithTimezone_AreProcessedCorrectly() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal)!; - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone(tzId)).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[tzId]).ToList(); using (Assert.EnterMultipleScope()) { @@ -161,7 +160,7 @@ public void RDate_PeriodsWithTimezone_AreProcessedCorrectly() // Deserialization cal = Calendar.Load(ics)!; - occurrences = cal.Events.First().GetOccurrences(DateUtil.GetZone(tzId)).ToList(); + occurrences = cal.Events.First().GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases[tzId]).ToList(); using (Assert.EnterMultipleScope()) { @@ -192,7 +191,7 @@ public void RDate_MixedDatesAndPeriods_AreProcessedCorrectly() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone("America/New_York")).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); using (Assert.EnterMultipleScope()) { @@ -228,7 +227,7 @@ public void RDate_DifferentTimeZones_AreProcessedCorrectly() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone("America/New_York")).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); using (Assert.EnterMultipleScope()) { @@ -262,7 +261,7 @@ public void RDate_DateOnlyWithDurationAndDateTime_AreProcessedCorrectly() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone("America/New_York")).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); using (Assert.EnterMultipleScope()) { @@ -299,7 +298,7 @@ public void RDate_OverlappingPeriods_AreProcessedCorrectly() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone("America/New_York")).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); using (Assert.EnterMultipleScope()) { @@ -333,7 +332,7 @@ public void RDate_LargeNumberOfDates_ShouldBeLineFolded() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var occurrences = calendarEvent.GetOccurrences(tz).ToList(); using (Assert.EnterMultipleScope()) @@ -371,7 +370,7 @@ public void RDate_DuplicateDates_ShouldBeSerializedJustOnce() var serializer = new CalendarSerializer(); var ics = serializer.SerializeToString(cal); - var occurrences = calendarEvent.GetOccurrences(DateUtil.GetZone("America/New_York")).ToList(); + var occurrences = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); using (Assert.EnterMultipleScope()) { @@ -401,7 +400,7 @@ public void RDate_DateOnly_WithExactDuration_ShouldThrow() void action() { - _ = calendarEvent.GetOccurrences(DateUtil.GetZone("America/New_York")).ToList(); + _ = calendarEvent.GetOccurrences(CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]).ToList(); } Assert.That(action, Throws.InstanceOf()); diff --git a/Ical.Net.Tests/TestExtensions.cs b/Ical.Net.Tests/TestExtensions.cs index 2bc9c87e5..f139b00ab 100644 --- a/Ical.Net.Tests/TestExtensions.cs +++ b/Ical.Net.Tests/TestExtensions.cs @@ -34,17 +34,22 @@ public static IEnumerable Evaluate(this RecurrenceRule pattern, C public static IEnumerable Evaluate(this RecurrenceRule pattern, CalDateTime referenceDate, ZonedDateTime periodStart, EvaluationOptions? options = null) { - return new RecurrenceRuleEvaluator(pattern, referenceDate, periodStart, options).Evaluate(); + return new RecurrenceRuleEvaluator(pattern, referenceDate, periodStart, CalendarTimeZoneProviders.TzdbWithAliases, options).Evaluate(); } - public static ZonedDateTime InZoneLeniently(this LocalDateTime value, string zoneId) + public static ZonedDateTime ToZonedDateTime(this CalDateTime value, string zoneId) { - return value.InZoneLeniently(DateUtil.GetZone(zoneId)); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[zoneId]; + return value.ToZonedOrDefault(tz, CalendarTimeZoneProviders.TzdbWithAliases).WithZone(tz); } - public static ZonedDateTime ToZonedDateTime(this CalDateTime value, string zoneId) + public static ZonedDateTime ToZonedDateTime(this CalDateTime value) + { + return value.ToZonedDateTime(CalendarTimeZoneProviders.TzdbWithAliases); + } + + public static ZonedDateTime ToZonedOrDefault(this CalDateTime value, DateTimeZone timeZone) { - var tz = DateUtil.GetZone(zoneId); - return value.ToZonedOrDefault(tz).WithZone(tz); + return value.ToZonedOrDefault(timeZone, CalendarTimeZoneProviders.TzdbWithAliases); } } diff --git a/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs b/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs index 90584b1b7..d4f042a98 100644 --- a/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs +++ b/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs @@ -6,7 +6,6 @@ using System; using System.Linq; using Ical.Net.DataTypes; -using Ical.Net.Utility; using NodaTime; using NUnit.Framework; @@ -26,12 +25,12 @@ public static void AssertOccurrences( { var evt = cal.Events.Skip(eventIndex).First(); - var tz = DateUtil.GetZone(timeZone ?? _tzid); - var start = fromDate?.ToZonedOrDefault(tz).ToInstant(); + var tz = cal.TimeZoneProvider[timeZone ?? _tzid]; + var start = fromDate?.ToZonedOrDefault(tz, cal.TimeZoneProvider).ToInstant(); var occurrences = toDate == null ? evt.GetOccurrences(tz, start).ToList() - : evt.GetOccurrences(tz, start).TakeWhileBefore(toDate.ToZonedOrDefault(tz).ToInstant()).ToList(); + : evt.GetOccurrences(tz, start).TakeWhileBefore(toDate.ToZonedOrDefault(tz, cal.TimeZoneProvider).ToInstant()).ToList(); using (Assert.EnterMultipleScope()) { @@ -44,7 +43,7 @@ public static void AssertOccurrences( { for (var i = 0; i < expectedPeriods.Length; i++) { - var start2 = expectedPeriods[i].StartTime.ToZonedOrDefault(tz).WithZone(tz); + var start2 = expectedPeriods[i].StartTime.ToZonedOrDefault(tz, cal.TimeZoneProvider).WithZone(tz); ZonedDateTime end; @@ -57,7 +56,7 @@ public static void AssertOccurrences( } else if (expectedPeriods[i].EndTime is { } periodEnd) { - end = periodEnd.ToZonedOrDefault(tz).WithZone(tz); + end = periodEnd.ToZonedOrDefault(tz, cal.TimeZoneProvider).WithZone(tz); } else { diff --git a/Ical.Net.Tests/TodoTest.cs b/Ical.Net.Tests/TodoTest.cs index b83a61494..2ecf9018e 100644 --- a/Ical.Net.Tests/TodoTest.cs +++ b/Ical.Net.Tests/TodoTest.cs @@ -1,4 +1,4 @@ -// +// // Copyright ical.net project maintainers and contributors. // Licensed under the MIT license. // @@ -7,7 +7,6 @@ using System.Linq; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; -using Ical.Net.Utility; using NUnit.Framework; namespace Ical.Net.Tests; @@ -96,7 +95,7 @@ public void Todo_WithFutureStart_AndNoDuration_ShouldSucceed() // periodStart is in the future, so filtering the first occurrence will also require // looking at the todo's duration, which is unset/null. It must therefore be ignored. - var tz = DateUtil.GetZone("America/New_York"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["America/New_York"]; var start = today.Date.PlusDays(2).AtStartOfDayInZone(tz); var firstOccurrence = todo.GetOccurrences(start).FirstOrDefault(); diff --git a/Ical.Net.Tests/VTimeZoneTest.cs b/Ical.Net.Tests/VTimeZoneTest.cs index fc9caef0a..61e7efb7d 100644 --- a/Ical.Net.Tests/VTimeZoneTest.cs +++ b/Ical.Net.Tests/VTimeZoneTest.cs @@ -4,12 +4,15 @@ // using System; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using Ical.Net.Serialization; using Ical.Net.Utility; +using NodaTime; +using NodaTime.TimeZones; using NUnit.Framework; namespace Ical.Net.Tests; @@ -17,15 +20,15 @@ namespace Ical.Net.Tests; public class VTimeZoneTest { [Test, Category("VTimeZone")] - public void InvalidTzIdShouldThrowException() + public void CustomIdShouldNotThrow() { - Assert.Throws(() => new VTimeZone("shouldFail")); + Assert.DoesNotThrow(() => new VTimeZone("shouldFail")); } [Test, Category("VTimeZone")] - public void VTimeZoneFromDateTimeZoneNullZoneShouldThrowException() + public void InvalidTzIdFromDateTimeZoneShouldThrow() { - Assert.Throws(() => CreateTestCalendar("shouldFail")); + Assert.Throws(() => CreateTestCalendar("shouldFail")); } [Test, Category("VTimeZone")] @@ -264,6 +267,208 @@ public void VTimeZoneAmericaDetroitShouldSerializeProperly() } } + [Test] + public void CalendarWithCustomTimeZonesLoads() + { + var data = """ + BEGIN:VCALENDAR + PRODID:ical.net + BEGIN:VTIMEZONE + TZID:Custom Time Zone + BEGIN:STANDARD + DTSTART:16010101T030000 + TZOFFSETFROM:+0200 + TZOFFSETTO:+0100 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=4SU;BYMONTH=10 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:16010101T020000 + TZOFFSETFROM:+0100 + TZOFFSETTO:+0200 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 + END:DAYLIGHT + END:VTIMEZONE + BEGIN:VEVENT + UID:446677788899995465 + SUMMARY:My Event + DTSTART;TZID=Custom Time Zone:20240423T114500 + DTEND;TZID=Custom Time Zone:20240423T120000 + DTSTAMP:20250306T143151Z + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(data)!; + + Assert.That(cal.TimeZones, Is.Not.Empty); + + var customTimeZone = cal.TimeZoneProvider["Custom Time Zone"]; + var start = Instant.FromUtc(2024, 1, 1, 0, 0).InZone(customTimeZone); + var results = cal.GetOccurrences(start).ToList(); + + Assert.That(results, Has.Count.EqualTo(1)); + + Assert.That(results[0].Start, Is.EqualTo(new LocalDateTime(2024, 4, 23, 11, 45).InZoneStrictly(customTimeZone))); + } + + [Test] + public void CalendarWithCustomTimeZonesFailsWithTzdbOnlyProvider() + { + var data = """ + BEGIN:VCALENDAR + PRODID:ical.net + BEGIN:VTIMEZONE + TZID:Custom Time Zone + BEGIN:STANDARD + DTSTART:16010101T030000 + TZOFFSETFROM:+0200 + TZOFFSETTO:+0100 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=4SU;BYMONTH=10 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:16010101T020000 + TZOFFSETFROM:+0100 + TZOFFSETTO:+0200 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 + END:DAYLIGHT + END:VTIMEZONE + BEGIN:VEVENT + UID:446677788899995465 + SUMMARY:My Event + DTSTART;TZID=Custom Time Zone:20240423T114500 + DTEND;TZID=Custom Time Zone:20240423T120000 + DTSTAMP:20250306T143151Z + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(data)!; + + // Only allow strict Tzdb with no extra aliases + cal.TimeZoneProvider = DateTimeZoneProviders.Tzdb; + + Assert.That(cal.TimeZones, Is.Not.Empty); + + Assert.Throws(() => + { + // Time zone does not exist in Tzdb + var customTimeZone = cal.TimeZoneProvider["Custom Time Zone"]; + }); + + Assert.Throws(() => + { + // Time zone does not exist in Tzdb + var customTimeZone = cal.TimeZoneProvider["Eastern Standard Time"]; + }); + + var tz = cal.TimeZoneProvider["America/New_York"]; + var start = Instant.FromUtc(2024, 1, 1, 0, 0).InZone(tz); + + Assert.Throws(() => + { + // Should attempt to evaluate event time zone using Tzdb and fail + var _ = cal.GetOccurrences(start).ToList(); + }); + } + + [Test] + public void CalendarWithVTimeZoneOnlyFailsToEvaluateUnknownEventTimeZone() + { + var data = """ + BEGIN:VCALENDAR + PRODID:ical.net + BEGIN:VTIMEZONE + TZID:Custom Time Zone + BEGIN:STANDARD + DTSTART:16010101T030000 + TZOFFSETFROM:+0200 + TZOFFSETTO:+0100 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=4SU;BYMONTH=10 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:16010101T020000 + TZOFFSETFROM:+0100 + TZOFFSETTO:+0200 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 + END:DAYLIGHT + END:VTIMEZONE + BEGIN:VEVENT + UID:446677788899995465 + SUMMARY:My Event + DTSTART;TZID=America/New_York:20240423T114500 + DTEND;TZID=America/New_York:20240423T120000 + DTSTAMP:20250306T143151Z + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(data)!; + + // Only allow VTIMEZONE events + cal.TimeZoneProvider = cal.CreateTimeZoneProvider(); + + var tz = cal.TimeZoneProvider["Custom Time Zone"]; + var start = Instant.FromUtc(2024, 1, 1, 0, 0).InZone(tz); + + Assert.Throws(() => + { + // Fails to evaluate because America/New_York is not defined + var _ = cal.GetOccurrences(start).ToList(); + }); + } + + + [Test] + public void CalendarWithVTimeZoneOnlyCanEvaluateUsingUnknownTimeZone() + { + var data = """ + BEGIN:VCALENDAR + PRODID:ical.net + BEGIN:VTIMEZONE + TZID:Custom Time Zone + BEGIN:STANDARD + DTSTART:16010101T030000 + TZOFFSETFROM:+0200 + TZOFFSETTO:+0100 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=4SU;BYMONTH=10 + END:STANDARD + BEGIN:DAYLIGHT + DTSTART:16010101T020000 + TZOFFSETFROM:+0100 + TZOFFSETTO:+0200 + RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=3 + END:DAYLIGHT + END:VTIMEZONE + BEGIN:VEVENT + UID:446677788899995465 + SUMMARY:My Event + DTSTART;TZID=Custom Time Zone:20240423T114500 + DTEND;TZID=Custom Time Zone:20240423T120000 + DTSTAMP:20250306T143151Z + END:VEVENT + END:VCALENDAR + """; + + var cal = Calendar.Load(data)!; + + // Only allow VTIMEZONE events + cal.TimeZoneProvider = cal.CreateTimeZoneProvider(); + + var tz = DateTimeZoneProviders.Tzdb["America/New_York"]; + var start = Instant.FromUtc(2024, 1, 1, 0, 0).InZone(tz); + + List results = null!; + + Assert.DoesNotThrow(() => + { + // America/New_York is not defined in the calendar, but it + // should still be able to evaluate. + results = cal.GetOccurrences(start).ToList(); + }); + + Assert.That(results, Has.Count.EqualTo(1)); + } + private static Calendar CreateTestCalendar(string tzId, DateTime? earliestTime = null, bool includeHistoricalData = true) { var iCal = new Calendar(); diff --git a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs index 0ccfd0de0..ebc46fa4b 100644 --- a/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs +++ b/Ical.Net.Tests/WikiSamples/RecurrenceWikiTests.cs @@ -72,7 +72,7 @@ public void Introduction() }; // Get all occurrences of the series. - var tz = TimeZoneResolvers.Default("UTC"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["UTC"]; IEnumerable allOccurrences = calendarEvent.GetOccurrences(tz); Assert.That(allOccurrences.Count(), Is.EqualTo(5)); @@ -131,7 +131,7 @@ public void DailyIntervalCount() var generatedIcs = calendarSerializer.SerializeToString(calendar)!; // Calculate all occurrences - var tz = TimeZoneResolvers.Default("Europe/Zurich"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["Europe/Zurich"]; var occurrences = calendar.GetOccurrences(tz).ToList(); Assert.That(occurrences, Has.Count.EqualTo(2)); @@ -178,6 +178,7 @@ public void YearlyByMonthDayUntil() // Wiki code start // Create the CalendarEvent + var tz = CalendarTimeZoneProviders.TzdbWithAliases["Europe/Zurich"]; var start = new CalDateTime(2025, 07, 10, 09, 00, 00, "Europe/Zurich"); var recurrence = new RecurrenceRule { @@ -185,7 +186,7 @@ public void YearlyByMonthDayUntil() ByMonthDay = [10, 12], Until = start.ToLocalDateTime() .PlusYears(2) - .InZoneLeniently("Europe/Zurich") + .InZoneLeniently(tz) .WithZone(DateTimeZone.Utc) .ToCalDateTime() }; @@ -206,7 +207,6 @@ public void YearlyByMonthDayUntil() var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences - var tz = TimeZoneResolvers.Default("Europe/Zurich"); IEnumerable occurrences = calendar.GetOccurrences(tz); // Wiki code end @@ -285,7 +285,7 @@ public void MonthlyByDayCountRDate() var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences - var tz = TimeZoneResolvers.Default("Europe/Zurich"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["Europe/Zurich"]; IEnumerable occurrences = calendar.GetOccurrences(tz); // Wiki code end @@ -360,7 +360,7 @@ public void HourlyUntilExDate() var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences - var tz = TimeZoneResolvers.Default("UTC"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["UTC"]; IEnumerable occurrences = calendar.GetOccurrences(tz); // Wiki code end @@ -457,7 +457,7 @@ public void DailyIntervalCountMoved() var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences - var tz = TimeZoneResolvers.Default("Europe/Zurich"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["Europe/Zurich"]; IEnumerable occurrences = calendar.GetOccurrences(tz); // Wiki code end @@ -539,7 +539,7 @@ public void RecurrenceWithTimeZoneChanges() var generatedIcs = calendarSerializer.SerializeToString(calendar); // Calculate all occurrences - var tz = TimeZoneResolvers.Default("Europe/Zurich"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["Europe/Zurich"]; IEnumerable occurrences = calendar.GetOccurrences(tz); // Wiki code end @@ -603,7 +603,7 @@ public void GetFirstOccurrenceOfAllCalendarEvents() End = start.PlusYears(10).InUtc().PlusHours(1).ToCalDateTime() }); - var tz = TimeZoneResolvers.Default("UTC"); + var tz = CalendarTimeZoneProviders.TzdbWithAliases["UTC"]; var occurrences = calendar.Events .SelectMany(ev => ev.GetOccurrences(tz).Take(1)) diff --git a/Ical.Net/Calendar.cs b/Ical.Net/Calendar.cs index feec54497..5f179727f 100644 --- a/Ical.Net/Calendar.cs +++ b/Ical.Net/Calendar.cs @@ -16,6 +16,7 @@ using Ical.Net.Serialization; using Ical.Net.Utility; using NodaTime; +using NodaTime.TimeZones; namespace Ical.Net; @@ -233,7 +234,7 @@ public virtual IEnumerable GetOccurrences(DateTimeZone timeZone, /// /// This is used to identify the *latest* modification for each recurring instance. /// - private static Dictionary<(string Uid, Instant RecurrenceId), IUniqueComponent> GetRecurrenceIdsAndUids( + private Dictionary<(string Uid, Instant RecurrenceId), IUniqueComponent> GetRecurrenceIdsAndUids( DateTimeZone timeZone, IEnumerable children) { @@ -275,7 +276,7 @@ public virtual IEnumerable GetOccurrences(DateTimeZone timeZone, /// Checks if an occurrence has not been replaced/overridden by a more /// recent modification (based on UID and RecurrenceId). /// - private static bool IsUnmodifiedOccurrence( + private bool IsUnmodifiedOccurrence( DateTimeZone timeZone, Occurrence occurrence, Dictionary<(string Uid, Instant RecurrenceId), IUniqueComponent> recurrenceIdsAndUids) @@ -304,7 +305,7 @@ private static bool IsUnmodifiedOccurrence( return !recurrenceIdsAndUids.ContainsKey((uc.Uid, occurrence.Start.ToInstant())); } - private static (string, Instant) GetOccurrenceKey(DateTimeZone timeZone, string uid, RecurrenceIdentifier rid) + private (string, Instant) GetOccurrenceKey(DateTimeZone timeZone, string uid, RecurrenceIdentifier rid) { // Evaluate the RECURRENCE-ID start time as an Instant to identify the // exact occurrence, using the evaluation time zone for floating values. @@ -319,7 +320,7 @@ private static (string, Instant) GetOccurrenceKey(DateTimeZone timeZone, string // means that the RECURRENCE-ID could identify different occurrences // depending on the evaluation time zone. var startTime = rid.StartTime - .ToZonedOrDefault(timeZone) + .ToZonedOrDefault(timeZone, TimeZoneProvider) .ToInstant(); return (uid, startTime); @@ -434,4 +435,55 @@ public VTimeZone AddLocalTimeZone(DateTime earliestDateTimeToSupport, bool inclu this.AddChild(tz); return tz; } + +#if NET9_0_OR_GREATER + private readonly System.Threading.Lock _timeZoneProviderLock = new(); +#else + private readonly object _timeZoneProviderLock = new(); +#endif + + /// + /// The used during evaluation of + /// components within this calendar. The default provider is + /// + /// with this calendar's VTIMEZONE components as the fallback. + /// + public IDateTimeZoneProvider TimeZoneProvider + { + get + { + lock (_timeZoneProviderLock) + { + return field ??= CalendarTimeZoneProviders + .Combine(CalendarTimeZoneProviders.TzdbWithAliases, CreateTimeZoneProvider()); + } + } + set + { + lock (_timeZoneProviderLock) { field = value; } + } + } + + /// + /// Creates a new using this calendar's + /// VTIMEZONE components. + /// + public IDateTimeZoneProvider CreateTimeZoneProvider() + => new DateTimeZoneCache(TimeZoneSource); + + internal IDateTimeZoneSource TimeZoneSource => field ??= new CalendarDateTimeZoneSource(this); + + private sealed class CalendarDateTimeZoneSource( + Calendar calendar) : IDateTimeZoneSource + { + public string VersionId => Components.Calendar; + + public DateTimeZone ForId(string id) + => calendar._mTimeZones.FirstOrDefault(x => x.TzId == id)?.ToDateTimeZone() + ?? throw new ArgumentException("Time zone ID not found"); + + public IEnumerable GetIds() => calendar._mTimeZones.Select(x => x.TzId!); + + public string? GetSystemDefaultId() => null; + } } diff --git a/Ical.Net/CalendarComponents/FreeBusy.cs b/Ical.Net/CalendarComponents/FreeBusy.cs index a365a457a..78cdc41cd 100644 --- a/Ical.Net/CalendarComponents/FreeBusy.cs +++ b/Ical.Net/CalendarComponents/FreeBusy.cs @@ -23,12 +23,15 @@ public class FreeBusy : UniqueComponent, IMergeable return null; } + var timeZoneProvider = obj is Calendar cal + ? cal.TimeZoneProvider : CalendarTimeZoneProviders.TzdbWithAliases; + var startInstant = freeBusyRequest.Start? - .ToZonedOrDefault(timeZone) + .ToZonedOrDefault(timeZone, timeZoneProvider) .ToInstant(); var endInstant = freeBusyRequest.End? - .ToZonedOrDefault(timeZone) + .ToZonedOrDefault(timeZone, timeZoneProvider) .ToInstant(); var occurrences = occ.GetOccurrences(timeZone, startInstant, options) @@ -199,7 +202,7 @@ public virtual FreeBusyStatus GetFreeBusyStatus(DataTypes.Period? period) /// public virtual FreeBusyStatus GetFreeBusyStatus(CalDateTime? dt) { - return GetFreeBusyStatus(dt?.ToZonedOrDefault(DateTimeZone.Utc).ToInstant()); + return GetFreeBusyStatus(dt?.ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProvider).ToInstant()); } public virtual FreeBusyStatus GetFreeBusyStatus(Instant? dt) diff --git a/Ical.Net/CalendarComponents/RecurringComponent.cs b/Ical.Net/CalendarComponents/RecurringComponent.cs index f6de9fbad..b50229711 100644 --- a/Ical.Net/CalendarComponents/RecurringComponent.cs +++ b/Ical.Net/CalendarComponents/RecurringComponent.cs @@ -332,7 +332,7 @@ private List GetAbsoluteAlarmOccurrences( var absoluteOccurrences = new List(); foreach (var alarm in absoluteAlarms) { - var baseFireTime = alarm.Trigger!.DateTime!.ToZonedDateTime(); + var baseFireTime = alarm.Trigger!.DateTime!.ToZonedDateTime(CalendarTimeZoneProvider); foreach (var start in alarm.GetFireTimes(baseFireTime)) { diff --git a/Ical.Net/CalendarComponents/VTimeZone.cs b/Ical.Net/CalendarComponents/VTimeZone.cs index 06b02325b..f7678c8d9 100644 --- a/Ical.Net/CalendarComponents/VTimeZone.cs +++ b/Ical.Net/CalendarComponents/VTimeZone.cs @@ -3,14 +3,15 @@ // Licensed under the MIT license. // +using System; +using System.Collections.Generic; +using System.Linq; using Ical.Net.DataTypes; using Ical.Net.Proxies; using Ical.Net.Utility; using NodaTime; +using NodaTime.Extensions; using NodaTime.TimeZones; -using System; -using System.Collections.Generic; -using System.Linq; namespace Ical.Net.CalendarComponents; @@ -20,10 +21,10 @@ namespace Ical.Net.CalendarComponents; public class VTimeZone : CalendarComponent { public static VTimeZone FromLocalTimeZone() - => FromDateTimeZone(DefaultTimeZoneResolver.LocalDateTimeZone.Id); + => FromDateTimeZone(DateTimeZoneProviders.Tzdb.GetSystemDefault().Id); public static VTimeZone FromLocalTimeZone(DateTime earliestDateTimeToSupport, bool includeHistoricalData) - => FromDateTimeZone(DefaultTimeZoneResolver.LocalDateTimeZone.Id, earliestDateTimeToSupport, includeHistoricalData); + => FromDateTimeZone(DateTimeZoneProviders.Tzdb.GetSystemDefault().Id, earliestDateTimeToSupport, includeHistoricalData); public static VTimeZone FromSystemTimeZone(TimeZoneInfo tzinfo) => FromSystemTimeZone(tzinfo, new DateTime(DateTime.Now.Year, 1, 1), false); @@ -36,7 +37,24 @@ public static VTimeZone FromDateTimeZone(string tzId) public static VTimeZone FromDateTimeZone(string tzId, DateTime earliestDateTimeToSupport, bool includeHistoricalData) { - var vTimeZone = new VTimeZone(tzId); + var tz = CalendarTimeZoneProviders.TzdbWithAliases[tzId]; + return FromDateTimeZone(tz, tzId, earliestDateTimeToSupport, includeHistoricalData); + } + + public static VTimeZone FromDateTimeZone(DateTimeZone tz) + => FromDateTimeZone(tz, new DateTime(DateTime.Now.Year, 1, 1), includeHistoricalData: false); + + public static VTimeZone FromDateTimeZone(DateTimeZone tz, DateTime earliestDateTimeToSupport, bool includeHistoricalData) + => FromDateTimeZone(tz, tz.Id, earliestDateTimeToSupport, includeHistoricalData); + + internal static VTimeZone FromDateTimeZone( + DateTimeZone tz, + string timeZoneAlias, + DateTime earliestDateTimeToSupport, + bool includeHistoricalData) + { + // Use the alias as the VTIMEZONE TZID value + var vTimeZone = new VTimeZone(tz, timeZoneAlias); var earliestYear = 1900; var earliestMonth = earliestDateTimeToSupport.Month; @@ -60,7 +78,7 @@ public static VTimeZone FromDateTimeZone(string tzId, DateTime earliestDateTimeT // Only include historical data if asked to do so. Otherwise, // use only the most recent adjustment rules available. - var intervals = vTimeZone._nodaZone.GetZoneIntervals(earliest, Instant.FromDateTimeOffset(DateTimeOffset.Now)) + var intervals = tz.GetZoneIntervals(earliest, Instant.FromDateTimeOffset(DateTimeOffset.Now)) .Where(z => z.HasStart && z.Start != Instant.MinValue) .ToList(); @@ -68,17 +86,17 @@ public static VTimeZone FromDateTimeZone(string tzId, DateTime earliestDateTimeT var matchingStandardIntervals = new List(); // if there are no intervals, create at least one standard interval - if (!intervals.Any()) + if (intervals.Count == 0) { - var start = new DateTimeOffset(new DateTime(earliestYear, 1, 1), new TimeSpan(vTimeZone._nodaZone.MaxOffset.Ticks)); + var start = new DateTimeOffset(new DateTime(earliestYear, 1, 1), new TimeSpan(tz.MaxOffset.Ticks)); var interval = new ZoneInterval( - name: vTimeZone._nodaZone.Id, + name: tz.Id, start: Instant.FromDateTimeOffset(start), end: Instant.FromDateTimeOffset(start) + NodaTime.Duration.FromHours(1), - wallOffset: vTimeZone._nodaZone.MinOffset, + wallOffset: tz.MinOffset, savings: Offset.Zero); intervals.Add(interval); - var zoneInfo = CreateTimeZoneInfo(intervals, new List(), true, true); + var zoneInfo = CreateTimeZoneInfo(intervals, [], true, true); vTimeZone.AddChild(zoneInfo); } else @@ -273,15 +291,21 @@ public IntervalRecurrenceRule(ZoneInterval interval) Frequency = FrequencyType.Yearly; ByMonth.Add(interval.IsoLocalStart.Month); - var date = interval.IsoLocalStart.ToDateTimeUnspecified(); - var weekday = date.DayOfWeek; - var weekNumber = DateUtil.WeekOfMonth(date); + var weekday = interval.IsoLocalStart.DayOfWeek.ToDayOfWeek(); + var weekNumber = WeekOfMonth(interval.IsoLocalStart); if (weekNumber >= 4) ByDay.Add(new WeekDay(weekday, -1)); // Almost certainly likely last X-day of month. Avoid issues with 4/5 sundays in different year/months. Ideally, use the nodazone tz database rule for this interval instead. else ByDay.Add(new WeekDay(weekday, weekNumber)); } + + private static int WeekOfMonth(LocalDateTime d) + { + var isExact = d.Day % 7 == 0; + var offset = isExact ? 0 : 1; + return (int) Math.Floor(d.Day / 7.0) + offset; + } } public VTimeZone() @@ -289,6 +313,15 @@ public VTimeZone() Name = Components.Timezone; } + /// + /// Creates a VTIMEZONE component using the specified time zone ID. + /// + /// It is recommended to use an ID from . + /// + /// The will be set if the time zone ID matches + /// a time zone in . + /// + /// The TZID property value. public VTimeZone(string tzId) : this() { if (string.IsNullOrWhiteSpace(tzId)) @@ -297,10 +330,17 @@ public VTimeZone(string tzId) : this() } TzId = tzId; - Location = _nodaZone.Id; + + // Time zone ID could be a standard ID or a custom one + Location = CalendarTimeZoneProviders.TzdbWithAliases.GetZoneOrNull(tzId)?.Id; + } + + internal VTimeZone(DateTimeZone tz, string timeZoneAlias) + { + TzId = timeZoneAlias; + Location = tz.Id; } - private DateTimeZone _nodaZone = DateTimeZone.Utc; // must initialize private string? _tzId; public virtual string? TzId { @@ -326,20 +366,7 @@ public virtual string? TzId return; } - _nodaZone = DateUtil.GetZone(value); - var id = _nodaZone.Id; - if (string.IsNullOrWhiteSpace(id)) - { - throw new ArgumentException($"Unrecognized time zone id: {value}"); - } - - if (!string.Equals(id, value, StringComparison.OrdinalIgnoreCase)) - { - //It was a BCL time zone, so we should use the original value - id = value; - } - - _tzId = id; + _tzId = value; Properties.Set("TZID", value); } } @@ -367,4 +394,123 @@ public string? Location } public ICalendarObjectList TimeZoneInfos => new CalendarObjectListProxy(Children); + + internal DateTimeZone ToDateTimeZone() => CalendarDateTimeZone.From(this); + + private sealed class CalendarDateTimeZone : DateTimeZone + { + private readonly List intervals; + + public static CalendarDateTimeZone From(VTimeZone data) + { + if (data.TzId == null) + { + throw new Exception("Time zone ID must be set"); + } + + var info = data.TimeZoneInfos + .Select(x => x.Copy()!) + .ToList(); + + var min = Offset.Zero; + var max = Offset.Zero; + + void CheckMinMax(UtcOffset? utcOffset) + { + if (utcOffset != null) + { + var from = Offset.FromTimeSpan(utcOffset.Offset); + if (from < min) + { + min = from; + } + + if (from > max) + { + max = from; + } + } + } + + foreach (var x in info) + { + CheckMinMax(x.OffsetFrom); + CheckMinMax(x.OffsetTo); + } + + var tz = new CalendarDateTimeZone(data.TzId, false, min, max, info); + + return tz; + } + + private CalendarDateTimeZone(string id, bool isFixed, Offset minOffset, Offset maxOffset, List intervals) + : base(id, isFixed, minOffset, maxOffset) + { + this.intervals = intervals; + } + + public override ZoneInterval GetZoneInterval(Instant instant) + { + Instant? start = null; + Instant? end = null; + VTimeZoneInfo? info = null; + + var previousYear = instant.InUtc() + .LocalDateTime + .PlusYears(-1) + .InUtc() + .ToInstant(); + + var timeZoneChanges = CollectionHelpers.OrderedMergeMany( + intervals.Select(x => x.GetOccurrences(Utc, previousYear))); + + foreach (var occurrence in timeZoneChanges) + { + var changeInstant = occurrence.Start.ToInstant(); + + if (changeInstant > instant) + { + end = changeInstant; + break; + } + else + { + info = (VTimeZoneInfo) occurrence.Source; + start = changeInstant; + } + } + + if (info == null) + { + // Fixed offset time zone + var maxStart = Instant.MinValue; + var fixedTimeZones = intervals.Where(x => x.RecurrenceRule == null); + + foreach (var tz in fixedTimeZones) + { + var tzStart = tz.Start!.ToLocalDateTime().InUtc().ToInstant(); + if (tzStart > maxStart && tzStart <= instant) + { + maxStart = tzStart; + info = tz; + } + } + } + + if (info == null) + { + throw new Exception("Could not find zone interval"); + } + + var name = info.TimeZoneName ?? "Unknown"; + + var wallOffset = Offset.FromTimeSpan(info.OffsetTo!.Offset); + + var savings = info.Name == Components.Standard + ? Offset.Zero + : Offset.FromTimeSpan(info.OffsetTo!.Offset) - wallOffset; + + return new ZoneInterval(name, start, end, wallOffset, savings); + } + } } diff --git a/Ical.Net/CalendarObject.cs b/Ical.Net/CalendarObject.cs index 015163c9e..f852ad815 100644 --- a/Ical.Net/CalendarObject.cs +++ b/Ical.Net/CalendarObject.cs @@ -1,4 +1,4 @@ -// +// // Copyright ical.net project maintainers and contributors. // Licensed under the MIT license. // @@ -6,6 +6,7 @@ using System; using System.Runtime.Serialization; using Ical.Net.Collections; +using NodaTime; namespace Ical.Net; @@ -120,6 +121,13 @@ public virtual Calendar? Calendar protected set => throw new NotSupportedException(); } + /// + /// The TimeZoneResolver of the associated Calendar, OR the default TimeZoneResolver + /// if there is no associated Calendar. + /// + internal IDateTimeZoneProvider CalendarTimeZoneProvider + => Calendar?.TimeZoneProvider ?? CalendarTimeZoneProviders.TzdbWithAliases; + public virtual int Line { get; set; } public virtual int Column { get; set; } diff --git a/Ical.Net/CalendarTimeZoneProviders.cs b/Ical.Net/CalendarTimeZoneProviders.cs new file mode 100644 index 000000000..5bf43a129 --- /dev/null +++ b/Ical.Net/CalendarTimeZoneProviders.cs @@ -0,0 +1,223 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using NodaTime; +using NodaTime.TimeZones; + +namespace Ical.Net; + +public static class CalendarTimeZoneProviders +{ + /// + /// Gets a time zone provider that returns time zones from the TZDB, + /// allowing for common aliases and mappable Windows IDs to be used + /// to find the canonical TZDB time zones. + /// + public static readonly IDateTimeZoneProvider TzdbWithAliases = new InternalTzdbWithAliases(); + + /// + /// The same as except time zone + /// lookups are case-insensitive. This is non-standard and should + /// not be used unless necessary. + /// + public static IDateTimeZoneProvider TzdbWithAliasesIgnoreCase => _tzdbWithAliasesIgnoreCase.Value; + + private static readonly Lazy _tzdbWithAliasesIgnoreCase + = new(static () => new InternalTzdbWithAliasesIgnoreCase(), isThreadSafe: true); + + public static IDateTimeZoneProvider Combine(params IDateTimeZoneProvider[] providers) + => new CombinedDateTimeZoneProvider(providers); + + private sealed class CombinedDateTimeZoneProvider : IDateTimeZoneProvider + { + private readonly IDateTimeZoneProvider[] providers; + + public ReadOnlyCollection Ids { get; } + + /// + /// A map of IDs to providers so that the same ID + /// always uses the same time zone provider. + /// + private readonly ReadOnlyDictionary providerMap; + + public CombinedDateTimeZoneProvider(params IDateTimeZoneProvider[] providers) + { + this.providers = providers; + + var idMap = new Dictionary(); + foreach (var provider in providers) + { + foreach (var id in provider.Ids) + { + if (!idMap.ContainsKey(id)) + { + idMap.Add(id, provider); + } + } + } + + providerMap = new ReadOnlyDictionary(idMap); + + // List supported IDs as the from the ID mapping to + // make sure all IDs are unique. + var idList = new List(providerMap.Keys); + idList.Sort(StringComparer.Ordinal); + Ids = new ReadOnlyCollection(idList); + } + + public DateTimeZone this[string id] + { + get + { + var zone = GetZoneOrNull(id) + ?? throw new DateTimeZoneNotFoundException($"Time zone ID {id} is unknown"); + + return zone; + } + } + + public string VersionId => field ??= "combined: " + string.Join(", ", providers.Select(x => x.VersionId)); + + + public DateTimeZone GetSystemDefault() => throw new NotImplementedException(); + + public DateTimeZone? GetZoneOrNull(string id) + { + foreach (var source in providers) + { + var zone = source.GetZoneOrNull(id); + + if (zone != null) + { + return zone; + } + } + + return null; + } + } + + + /// + /// The TZDB but allows time zone ID to match by common aliases. + /// + private sealed class InternalTzdbWithAliases : IDateTimeZoneProvider + { + public DateTimeZone this[string id] + { + get + { + var zone = GetZoneOrNull(id) + ?? throw new DateTimeZoneNotFoundException($"Time zone ID {id} could not be found in TZDB"); + + return zone; + } + } + + public string VersionId => DateTimeZoneProviders.Tzdb.VersionId; + + /// + /// This returns the canonical TZDB list only. + /// + public ReadOnlyCollection Ids => DateTimeZoneProviders.Tzdb.Ids; + + public DateTimeZone GetSystemDefault() => DateTimeZoneProviders.Tzdb.GetSystemDefault(); + + public DateTimeZone? GetZoneOrNull(string id) + { + // RFC allows a prefixing "/" to indicate a "unique ID + // in a globally defined time zone registry." Ignore since + // this is already searching the TZDB. + if (id.StartsWith("/", StringComparison.Ordinal)) + { + id = id.Substring(1); + } + + var p = DateTimeZoneProviders.Tzdb; + var zone = p.GetZoneOrNull(id); + if (zone is not null) + { + return zone; + } + + if (TzdbDateTimeZoneSource.Default.WindowsToTzdbIds.TryGetValue(id, out var tzdbId)) + { + zone = p.GetZoneOrNull(tzdbId); + if (zone is not null) + { + return zone; + } + } + + return p.GetZoneOrNull(id.Replace("-", "/")); + } + } + + /// + /// The TZDB but allows time zone ID to match by common aliases + /// with case-insensitive matching. + /// + private sealed class InternalTzdbWithAliasesIgnoreCase : IDateTimeZoneProvider + { + private readonly Dictionary _windowsMappingIgnoreCase = + TzdbDateTimeZoneSource.Default.WindowsMapping.PrimaryMapping + .ToDictionary(k => k.Key, v => v.Value, StringComparer.OrdinalIgnoreCase); + + /// + /// TZDB ID lookup with case-insensitive matching. This would be better + /// as a HashSet, but netstandard2.0 does not allow getting the actual + /// value from the HashSet. + /// + private readonly Dictionary _tzdbIgnoreCase = + DateTimeZoneProviders.Tzdb.Ids + .ToDictionary(x => x, x => x, StringComparer.OrdinalIgnoreCase); + + public DateTimeZone this[string id] + { + get + { + var zone = GetZoneOrNull(id) + ?? throw new DateTimeZoneNotFoundException($"Time zone ID {id} could not be found in TZDB"); + + return zone; + } + } + + public string VersionId => DateTimeZoneProviders.Tzdb.VersionId; + + /// + /// This returns the canonical TZDB list only. + /// + public ReadOnlyCollection Ids => DateTimeZoneProviders.Tzdb.Ids; + + public DateTimeZone GetSystemDefault() => DateTimeZoneProviders.Tzdb.GetSystemDefault(); + + public DateTimeZone? GetZoneOrNull(string id) + { + // RFC allows a prefixing "/" to indicate a "unique ID + // in a globally defined time zone registry." Ignore since + // this is already searching the TZDB. + if (id.StartsWith("/", StringComparison.Ordinal)) + { + id = id.Substring(1); + } + + DateTimeZone? zone = null; + + if (_tzdbIgnoreCase.TryGetValue(id, out var tzdbId) + || _tzdbIgnoreCase.TryGetValue(id.Replace("-", "/"), out tzdbId) + || _windowsMappingIgnoreCase.TryGetValue(id, out tzdbId)) + { + zone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(tzdbId); + } + + return zone; + } + } +} diff --git a/Ical.Net/CollectionExtensions.cs b/Ical.Net/CollectionExtensions.cs index 84ae23473..5cf452a68 100644 --- a/Ical.Net/CollectionExtensions.cs +++ b/Ical.Net/CollectionExtensions.cs @@ -34,7 +34,7 @@ public static class CollectionExtensions /// [Obsolete("Use NodaTime.Instant to specify period end.")] public static IEnumerable TakeWhileBefore(this IEnumerable sequence, CalDateTime periodEnd) - => sequence.TakeWhile(p => p.Start.ToInstant() < periodEnd.ToZonedOrDefault(DateTimeZone.Utc).ToInstant()); + => sequence.TakeWhile(p => p.Start.ToInstant() < periodEnd.ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProviders.TzdbWithAliases).ToInstant()); public static IEnumerable TakeWhileBefore(this IEnumerable sequence, Instant periodEnd) => sequence.TakeWhile(p => p.Start.ToInstant() < periodEnd); diff --git a/Ical.Net/DataTypes/CalDateTime.cs b/Ical.Net/DataTypes/CalDateTime.cs index a72364c42..883fd465e 100644 --- a/Ical.Net/DataTypes/CalDateTime.cs +++ b/Ical.Net/DataTypes/CalDateTime.cs @@ -8,7 +8,6 @@ using System.Globalization; using System.IO; using Ical.Net.Serialization.DataTypes; -using Ical.Net.Utility; using NodaTime; using NodaTime.Extensions; using NodaTime.Text; @@ -310,17 +309,6 @@ public static CalDateTime FromDateTime(DateTime value, string? tzId = null) /// A new with same date as the specified . public static CalDateTime FromDateTimeDate(DateTime value) => new(LocalDate.FromDateTime(value)); - /// - /// Converts this value to with . - /// - /// DATE values will default to . - /// - /// Values with a time zone will be converted to the UTC time zone. - /// Values without a time zone () will have the same local time. - /// - /// A value representing this value converted to the UTC time zone. - public DateTime ToDateTimeUtc() => ToZonedOrDefault(DateTimeZone.Utc).ToDateTimeUtc(); - /// /// Returns the local date and time as a with . /// @@ -359,14 +347,14 @@ public LocalDateTime ToLocalDateTime() /// /// A zoned date time representing this value as close as possible. /// Time zone is null - public ZonedDateTime ToZonedDateTime() + public ZonedDateTime ToZonedDateTime(IDateTimeZoneProvider dateTimeZoneProvider) { if (_tzId is null) { throw new InvalidOperationException("CalDateTime must have a time zone to convert to ZonedDateTime"); } - return DateUtil.GetZone(_tzId).AtLeniently(ToLocalDateTime()); + return dateTimeZoneProvider[_tzId].AtLeniently(ToLocalDateTime()); } /// @@ -379,14 +367,14 @@ public ZonedDateTime ToZonedDateTime() /// /// The time zone to use if this value has no time zone. /// A zoned date time representing this value in its own time zone or the specified time zone. - public ZonedDateTime ToZonedOrDefault(DateTimeZone defaultZone) + public ZonedDateTime ToZonedOrDefault(DateTimeZone defaultZone, IDateTimeZoneProvider dateTimeZoneProvider) { if (_tzId is null) { return ToLocalDateTime().InZoneLeniently(defaultZone); } - return DateUtil.GetZone(_tzId).AtLeniently(ToLocalDateTime()); + return dateTimeZoneProvider[_tzId].AtLeniently(ToLocalDateTime()); } /// diff --git a/Ical.Net/DataTypes/CalendarDataType.cs b/Ical.Net/DataTypes/CalendarDataType.cs index ce5caee4c..45a47fe9f 100644 --- a/Ical.Net/DataTypes/CalendarDataType.cs +++ b/Ical.Net/DataTypes/CalendarDataType.cs @@ -1,4 +1,4 @@ -// +// // Copyright ical.net project maintainers and contributors. // Licensed under the MIT license. // @@ -6,6 +6,7 @@ using System; using System.Runtime.Serialization; using Ical.Net.Proxies; +using NodaTime; namespace Ical.Net.DataTypes; @@ -125,6 +126,13 @@ public virtual ICalendarObject? AssociatedObject public virtual Calendar? Calendar => _associatedObject?.Calendar; + /// + /// The time zone provider of the associated Calendar, OR the default ical.net + /// provider if there is no associated Calendar. + /// + internal IDateTimeZoneProvider CalendarTimeZoneProvider + => Calendar?.TimeZoneProvider ?? CalendarTimeZoneProviders.TzdbWithAliases; + public virtual string? Language { get => Parameters.Get("LANGUAGE"); diff --git a/Ical.Net/DataTypes/FreeBusyEntry.cs b/Ical.Net/DataTypes/FreeBusyEntry.cs index 7fb158c43..e9e323f40 100644 --- a/Ical.Net/DataTypes/FreeBusyEntry.cs +++ b/Ical.Net/DataTypes/FreeBusyEntry.cs @@ -48,7 +48,8 @@ public override void CopyFrom(ICopyable obj) /// public bool Contains(CalDateTime? dt) { - return dt is not null && Contains(dt.ToZonedOrDefault(DateTimeZone.Utc).ToInstant()); + return dt is not null + && Contains(dt.ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProvider).ToInstant()); } /// @@ -58,7 +59,7 @@ public bool Contains(CalDateTime? dt) /// public bool Contains(Instant value) { - var startInstant = StartTime.ToZonedOrDefault(DateTimeZone.Utc).ToInstant(); + var startInstant = StartTime.ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProvider).ToInstant(); if (startInstant > value) { return false; @@ -67,7 +68,7 @@ public bool Contains(Instant value) Instant end; if (EndTime is { } endTime) { - end = endTime.ToZonedOrDefault(DateTimeZone.Utc).ToInstant(); + end = endTime.ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProvider).ToInstant(); } else if (Duration is { } duration) { @@ -102,11 +103,16 @@ public bool CollidesWith(Period? period) throw new ArgumentException("Period start time must be in UTC"); } - var start = StartTime.ToZonedOrDefault(DateTimeZone.Utc).ToInstant(); + var start = StartTime + .ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProvider) + .ToInstant(); + Instant end; if (EndTime is { } endTime) { - end = endTime.ToZonedOrDefault(DateTimeZone.Utc).ToInstant(); + end = endTime + .ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProvider) + .ToInstant(); } else if (Duration is { } duration) { @@ -120,7 +126,10 @@ public bool CollidesWith(Period? period) return false; } - var otherStart = period.StartTime.ToZonedOrDefault(DateTimeZone.Utc).ToInstant(); + var otherStart = period.StartTime + .ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProvider) + .ToInstant(); + Instant otherEnd; if (period.EndTime is { } periodEndTime) { @@ -129,7 +138,9 @@ public bool CollidesWith(Period? period) throw new ArgumentException("Period end time must be in UTC"); } - otherEnd = periodEndTime.ToZonedOrDefault(DateTimeZone.Utc).ToInstant(); + otherEnd = periodEndTime + .ToZonedOrDefault(DateTimeZone.Utc, CalendarTimeZoneProvider) + .ToInstant(); } else if (period.Duration is { } periodDuration) { diff --git a/Ical.Net/DataTypes/Occurrence.cs b/Ical.Net/DataTypes/Occurrence.cs index abd69a350..7bcc788e3 100644 --- a/Ical.Net/DataTypes/Occurrence.cs +++ b/Ical.Net/DataTypes/Occurrence.cs @@ -34,20 +34,10 @@ public Occurrence(IRecurrable recurrable, ZonedDateTime start, ZonedDateTime end /// /// Checks if the given date time is contained within the period. /// - public bool Contains(CalDateTime? value) + public bool Contains(Instant value) { - if (value is null) - { - return false; - } - - // Use floating time from the occurrence's time zone - var valueInstant = value - .ToZonedOrDefault(Start.Zone) - .ToInstant(); - - return Start.ToInstant() <= valueInstant - && End.ToInstant() > valueInstant; + return Start.ToInstant() <= value + && End.ToInstant() > value; } public bool Equals(Occurrence other) diff --git a/Ical.Net/DataTypes/Period.cs b/Ical.Net/DataTypes/Period.cs index 0c894259f..518982d9f 100644 --- a/Ical.Net/DataTypes/Period.cs +++ b/Ical.Net/DataTypes/Period.cs @@ -83,17 +83,9 @@ public Period(CalDateTime start, CalDateTime? end = null) if (end != null) { - bool isEndBeforeStart; - if (end.IsFloating) - { - // Both are floating, compare local times - isEndBeforeStart = end.ToLocalDateTime() < start.ToLocalDateTime(); - } - else - { - // Both are zoned, so compare instants - isEndBeforeStart = end.ToZonedOrDefault(DateTimeZone.Utc).ToInstant() < start.ToZonedOrDefault(DateTimeZone.Utc).ToInstant(); - } + // Both values are either floating or in the same time zone + // so only the local time needs to be compared + var isEndBeforeStart = end.ToLocalDateTime() < start.ToLocalDateTime(); if (isEndBeforeStart) throw new ArgumentException($"End time ({end}) must be greater than or equal to start time ({start}).", nameof(end)); diff --git a/Ical.Net/DataTypes/RecurrenceIdentifier.cs b/Ical.Net/DataTypes/RecurrenceIdentifier.cs index f802ef24e..1868b5164 100644 --- a/Ical.Net/DataTypes/RecurrenceIdentifier.cs +++ b/Ical.Net/DataTypes/RecurrenceIdentifier.cs @@ -19,7 +19,7 @@ namespace Ical.Net.DataTypes; /// This class is used to uniquely identify a particular occurrence within a recurring series. The /// identifier consists of a date and a recurrence range, which together specify the instance. /// -public class RecurrenceIdentifier : IComparable +public class RecurrenceIdentifier { /// /// Initializes a new instance with the specified start time and recurrence range. @@ -44,38 +44,6 @@ public RecurrenceIdentifier(CalDateTime start, RecurrenceRange? range = null) /// Gets or sets the recurrence range that determines the scope of the recurrence rule. /// public RecurrenceRange Range { get; set; } - - /// - /// Compares the current instance with another - /// object and returns an integer that indicates whether the current - /// instance precedes, follows, or occurs in the same position in the - /// sort order as the other object. - /// - /// - /// The comparison is performed first by the property. If the values are equal, the property is used as a tiebreaker. - /// - /// - /// The to compare with the current instance. - /// Can be . - /// - public int CompareTo(RecurrenceIdentifier? other) - { - if (other is null) - { - return 1; - } - - var startComparison = StartTime.ToZonedOrDefault(DateTimeZone.Utc).ToInstant() - .CompareTo(other.StartTime.ToZonedOrDefault(DateTimeZone.Utc).ToInstant()); - - if (startComparison != 0) - { - return startComparison; - } - - return Range.CompareTo(other.Range); - } } /// diff --git a/Ical.Net/DefaultTimeZoneResolver.cs b/Ical.Net/DefaultTimeZoneResolver.cs deleted file mode 100644 index 28b0d159a..000000000 --- a/Ical.Net/DefaultTimeZoneResolver.cs +++ /dev/null @@ -1,96 +0,0 @@ -// -// Copyright ical.net project maintainers and contributors. -// Licensed under the MIT license. -// - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using NodaTime; -using NodaTime.TimeZones; - -namespace Ical.Net; - -public static class DefaultTimeZoneResolver -{ - private static Dictionary InitializeWindowsMappings() - => TzdbDateTimeZoneSource.Default.WindowsMapping.PrimaryMapping - .ToDictionary(k => k.Key, v => v.Value, StringComparer.OrdinalIgnoreCase); - - private static readonly Lazy> _windowsMapping - = new Lazy>(InitializeWindowsMappings, LazyThreadSafetyMode.PublicationOnly); - - /// - /// Use this method to turn a raw string into a NodaTime DateTimeZone. It searches all time zone providers (IANA, BCL, serialization, etc) to see if - /// the string matches. If it doesn't, it walks each provider, and checks to see if the time zone the provider knows about is contained within the - /// target time zone string. Some older icalendar programs would generate nonstandard time zone strings, and this secondary check works around - /// that. - /// - /// A BCL, IANA, or serialization time zone identifier - /// Processing failed - /// The DateTimeZone if found or null otherwise. - public static DateTimeZone? GetZone(string? tzId) - { - var exMsg = $"Unrecognized time zone id {tzId}"; - - if (string.IsNullOrWhiteSpace(tzId)) - { - return DateTimeZoneProviders.Tzdb.GetSystemDefault(); - } - - if (tzId.StartsWith("/", StringComparison.OrdinalIgnoreCase)) - { - tzId = tzId.Substring(1, tzId.Length - 1); - } - - var zone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(tzId); - if (zone != null) - { - return zone; - } - - if (_windowsMapping.Value.TryGetValue(tzId, out var ianaZone)) - { - return DateTimeZoneProviders.Tzdb.GetZoneOrNull(ianaZone) ?? throw new ArgumentException(exMsg); - } - - zone = NodaTime.Xml.XmlSerializationSettings.DateTimeZoneProvider.GetZoneOrNull(tzId); - if (zone != null) - { - return zone; - } - - // US/Eastern is commonly represented as US-Eastern - var newTzId = tzId.Replace("-", "/"); - zone = NodaTime.Xml.XmlSerializationSettings.DateTimeZoneProvider.GetZoneOrNull(newTzId); - if (zone != null) - { - return zone; - } - - var providerId = DateTimeZoneProviders.Tzdb.Ids.FirstOrDefault(tzId.Contains); - if (providerId != null) - { - return DateTimeZoneProviders.Tzdb.GetZoneOrNull(providerId) ?? throw new ArgumentException(exMsg); - } - - if (_windowsMapping.Value.Keys - .Where(tzId.Contains) - .Any(pId => _windowsMapping.Value.TryGetValue(pId, out ianaZone)) - ) - { - return DateTimeZoneProviders.Tzdb.GetZoneOrNull(ianaZone!) ?? throw new ArgumentException(exMsg); - } - - providerId = NodaTime.Xml.XmlSerializationSettings.DateTimeZoneProvider.Ids.FirstOrDefault(tzId.Contains); - if (providerId != null) - { - return NodaTime.Xml.XmlSerializationSettings.DateTimeZoneProvider.GetZoneOrNull(providerId) ?? throw new ArgumentException(exMsg); - } - - return null; - } - - internal static readonly DateTimeZone LocalDateTimeZone = DateTimeZoneProviders.Tzdb.GetSystemDefault(); -} diff --git a/Ical.Net/Evaluation/EventEvaluator.cs b/Ical.Net/Evaluation/EventEvaluator.cs index 0a02a5c46..f26263892 100644 --- a/Ical.Net/Evaluation/EventEvaluator.cs +++ b/Ical.Net/Evaluation/EventEvaluator.cs @@ -22,49 +22,53 @@ public class EventEvaluator : RecurringEvaluator /// public EventEvaluator(CalendarEvent evt) : base(evt) { } + protected override IDateTimeZoneProvider TimeZoneProvider => CalendarEvent.CalendarTimeZoneProvider; + protected override EvaluationPeriod EvaluateRDate(DataTypes.Period rdate, DateTimeZone referenceTimeZone) - { - var start = rdate.StartTime.ToZonedOrDefault(referenceTimeZone); - - ZonedDateTime end; - if (rdate.Duration is { } duration) - { - if (!rdate.StartTime.HasTime && duration.HasTime) - { - throw new EvaluationException($"Unable to add time to date-only RDATE {rdate}"); - } - - end = start.LocalDateTime - .Plus(duration.GetNominalPart()) - .InZoneRelativeTo(start) - .Plus(duration.GetTimePart()); - } - else if (rdate.EndTime is { } dtEnd) - { - var exactDuration = dtEnd.ToZonedOrDefault(referenceTimeZone).ToInstant() - start.ToInstant(); - - if (exactDuration < Duration.Zero) - { - throw new InvalidOperationException("DtEnd is before DtStart"); - } - - end = start.Plus(exactDuration); - } - else - { - if (!rdate.StartTime.HasTime - && CalendarEvent.Duration is { } eventDuration - && eventDuration.HasTime) - { - throw new EvaluationException($"Unable to add time to date-only RDATE {rdate}"); - } - - // Use event - end = GetEnd(start); - } - - return new EvaluationPeriod(start, end); - } + { + var start = rdate.StartTime + .ToZonedOrDefault(referenceTimeZone, TimeZoneProvider); + + ZonedDateTime end; + if (rdate.Duration is { } duration) + { + if (!rdate.StartTime.HasTime && duration.HasTime) + { + throw new EvaluationException($"Unable to add time to date-only RDATE {rdate}"); + } + + end = start.LocalDateTime + .Plus(duration.GetNominalPart()) + .InZoneRelativeTo(start) + .Plus(duration.GetTimePart()); + } + else if (rdate.EndTime is { } dtEnd) + { + var exactDuration = dtEnd + .ToZonedOrDefault(referenceTimeZone, TimeZoneProvider).ToInstant() - start.ToInstant(); + + if (exactDuration < Duration.Zero) + { + throw new InvalidOperationException("DtEnd is before DtStart"); + } + + end = start.Plus(exactDuration); + } + else + { + if (!rdate.StartTime.HasTime + && CalendarEvent.Duration is { } eventDuration + && eventDuration.HasTime) + { + throw new EvaluationException($"Unable to add time to date-only RDATE {rdate}"); + } + + // Use event + end = GetEnd(start); + } + + return new EvaluationPeriod(start, end); + } protected override ZonedDateTime GetEnd(ZonedDateTime start) { @@ -114,7 +118,10 @@ protected override ZonedDateTime GetEnd(ZonedDateTime start) dtEnd = new(dtEnd.ToLocalDateTime(), dtStart.TzId); } - var exactDuration = dtEnd.ToZonedOrDefault(start.Zone).ToInstant() - dtStart.ToZonedOrDefault(start.Zone).ToInstant(); + var exactDuration = dtEnd + .ToZonedOrDefault(start.Zone, TimeZoneProvider) + .ToInstant() + .Minus(dtStart.ToZonedOrDefault(start.Zone, TimeZoneProvider).ToInstant()); if (exactDuration < Duration.Zero) { diff --git a/Ical.Net/Evaluation/RecurrenceRuleEvaluator.cs b/Ical.Net/Evaluation/RecurrenceRuleEvaluator.cs index a94c332fc..a9a9fc30a 100644 --- a/Ical.Net/Evaluation/RecurrenceRuleEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrenceRuleEvaluator.cs @@ -40,6 +40,7 @@ public RecurrenceRuleEvaluator( CalDateTime referenceDate, DateTimeZone timeZone, Instant? periodStart, + IDateTimeZoneProvider timeZoneProvider, EvaluationOptions? options) { _referenceDate = referenceDate; @@ -48,14 +49,14 @@ public RecurrenceRuleEvaluator( // Copy rule values _frequency = rule.Frequency; - _until = rule.Until?.ToZonedOrDefault(timeZone).ToInstant(); + _until = rule.Until?.ToZonedOrDefault(timeZone, timeZoneProvider).ToInstant(); _count = rule.Count; _interval = Math.Max(1, rule.Interval); _rule = ByRuleValues.From(rule); _firstDayOfWeek = rule.FirstDayOfWeek.ToIsoDayOfWeek(); _weekYearRule = WeekYearRules.ForMinDaysInFirstWeek(4, _firstDayOfWeek); - _zonedReferenceDate = referenceDate.ToZonedOrDefault(timeZone); + _zonedReferenceDate = referenceDate.ToZonedOrDefault(timeZone, timeZoneProvider); _referenceWeekNo = _weekYearRule.GetWeekOfWeekYear(_zonedReferenceDate.Date); } @@ -63,8 +64,9 @@ public RecurrenceRuleEvaluator( RecurrenceRule rule, CalDateTime referenceDate, ZonedDateTime periodStart, + IDateTimeZoneProvider timeZoneProvider, EvaluationOptions? options) - : this(rule, referenceDate, periodStart.Zone, periodStart.ToInstant(), options) { } + : this(rule, referenceDate, periodStart.Zone, periodStart.ToInstant(), timeZoneProvider, options) { } public IEnumerable Evaluate() { diff --git a/Ical.Net/Evaluation/RecurringEvaluator.cs b/Ical.Net/Evaluation/RecurringEvaluator.cs index e3090ef8f..09a59cf38 100644 --- a/Ical.Net/Evaluation/RecurringEvaluator.cs +++ b/Ical.Net/Evaluation/RecurringEvaluator.cs @@ -32,7 +32,7 @@ protected IEnumerable EvaluateRRule(CalDateTime referenceDate, Da if (Recurrable.RecurrenceRule is null) return []; - var ruleEvaluator = new RecurrenceRuleEvaluator(Recurrable.RecurrenceRule, referenceDate, timeZone, periodStart, options); + var ruleEvaluator = new RecurrenceRuleEvaluator(Recurrable.RecurrenceRule, referenceDate, timeZone, periodStart, TimeZoneProvider, options); return ruleEvaluator.Evaluate(); } @@ -66,6 +66,8 @@ protected SortedSet EvaluateRDate(DateTimeZone referenceTimeZo /// End of an occurrence protected abstract ZonedDateTime GetEnd(ZonedDateTime start); + protected abstract IDateTimeZoneProvider TimeZoneProvider { get; } + public virtual IEnumerable Evaluate( CalDateTime referenceDate, ZonedDateTime periodStart, @@ -78,7 +80,7 @@ public virtual IEnumerable Evaluate( EvaluationOptions? options) { // Evaluate recurrence in the reference zone - var zonedReference = referenceDate.ToZonedOrDefault(timeZone); + var zonedReference = referenceDate.ToZonedOrDefault(timeZone, TimeZoneProvider); // Only add referenceDate if there is no RecurrenceRule. This is in line // with RFC 5545 which requires DTSTART to match any RRULE. If it doesn't, the behaviour @@ -113,7 +115,7 @@ public virtual IEnumerable Evaluate( { // Exclude occurrences according to EXDATEs. var exDateExclusionsDateTime = new HashSet(EvaluateExDate(PeriodKind.DateTime) - .Select(x => x.StartTime.ToZonedOrDefault(zonedReference.Zone).ToInstant())); + .Select(x => x.StartTime.ToZonedOrDefault(zonedReference.Zone, TimeZoneProvider).ToInstant())); if (exDateExclusionsDateTime.Count > 0) { diff --git a/Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs b/Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs index 197dcf30e..002f3f500 100644 --- a/Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs +++ b/Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs @@ -1,4 +1,4 @@ -// +// // Copyright ical.net project maintainers and contributors. // Licensed under the MIT license. // @@ -18,6 +18,9 @@ protected VTimeZoneInfo TimeZoneInfo } protected override NodaTime.ZonedDateTime GetEnd(NodaTime.ZonedDateTime start) => start; + + protected override NodaTime.IDateTimeZoneProvider TimeZoneProvider => CalendarTimeZoneProviders.TzdbWithAliases; + protected override EvaluationPeriod EvaluateRDate(Period rdate, NodaTime.DateTimeZone referenceTimeZone) => throw new NotImplementedException(); diff --git a/Ical.Net/Evaluation/TodoEvaluator.cs b/Ical.Net/Evaluation/TodoEvaluator.cs index 5ef0f2377..9d9510a08 100644 --- a/Ical.Net/Evaluation/TodoEvaluator.cs +++ b/Ical.Net/Evaluation/TodoEvaluator.cs @@ -17,9 +17,11 @@ public class TodoEvaluator : RecurringEvaluator public TodoEvaluator(Todo todo) : base(todo) { } + protected override IDateTimeZoneProvider TimeZoneProvider => Todo.CalendarTimeZoneProvider; + protected override EvaluationPeriod EvaluateRDate(DataTypes.Period rdate, DateTimeZone referenceTimeZone) { - var start = rdate.StartTime.ToZonedOrDefault(referenceTimeZone); + var start = rdate.StartTime.ToZonedOrDefault(referenceTimeZone, TimeZoneProvider); ZonedDateTime end; if (rdate.Duration is { } duration) @@ -31,7 +33,10 @@ protected override EvaluationPeriod EvaluateRDate(DataTypes.Period rdate, DateTi } else if (rdate.EndTime is { } dtEnd) { - var exactDuration = dtEnd.ToZonedOrDefault(referenceTimeZone).ToInstant() - start.ToInstant(); + var exactDuration = dtEnd + .ToZonedOrDefault(referenceTimeZone, TimeZoneProvider) + .ToInstant() + .Minus(start.ToInstant()); if (exactDuration < NodaTime.Duration.Zero) { @@ -70,7 +75,11 @@ protected override ZonedDateTime GetEnd(ZonedDateTime start) if (Todo.Due is { } due && dtStart is not null) { - var exactDuration = due.ToZonedOrDefault(start.Zone).ToInstant() - dtStart.ToZonedOrDefault(start.Zone).ToInstant(); + var exactDuration = due + .ToZonedOrDefault(start.Zone, TimeZoneProvider) + .ToInstant() + .Minus(dtStart.ToZonedOrDefault(start.Zone, TimeZoneProvider).ToInstant()); + return start.Plus(exactDuration); } diff --git a/Ical.Net/TimeZoneResolvers.cs b/Ical.Net/TimeZoneResolvers.cs deleted file mode 100644 index 2c61d9f45..000000000 --- a/Ical.Net/TimeZoneResolvers.cs +++ /dev/null @@ -1,23 +0,0 @@ -// -// Copyright ical.net project maintainers and contributors. -// Licensed under the MIT license. -// - -using System; -using NodaTime; - -namespace Ical.Net; - -public static class TimeZoneResolvers -{ - /// - /// The default time zone resolver. - /// - public static Func Default - => tzId => DefaultTimeZoneResolver.GetZone(tzId) ?? throw new ArgumentException($"Unrecognized time zone id {tzId}", nameof(tzId)); - - /// - /// Gets or sets a function that returns the NodaTime DateTimeZone for the given TZID. - /// - public static Func TimeZoneResolver { get; set; } = Default; -} diff --git a/Ical.Net/Utility/DateUtil.cs b/Ical.Net/Utility/DateUtil.cs deleted file mode 100644 index 3d59b0e5b..000000000 --- a/Ical.Net/Utility/DateUtil.cs +++ /dev/null @@ -1,30 +0,0 @@ -// -// Copyright ical.net project maintainers and contributors. -// Licensed under the MIT license. -// - -using System; -using NodaTime; - -namespace Ical.Net.Utility; - -internal static class DateUtil -{ - /// - /// Returns the NodaTime DateTimeZone for the given TZID according to the - /// current time zone resolver set in TimeZoneResolvers.TimeZoneResolver. - /// - /// A time zone identifier - /// Unrecognized time zone id - public static DateTimeZone GetZone(string tzId) - => TimeZoneResolvers.TimeZoneResolver(tzId) ?? throw new ArgumentException($"Unrecognized time zone id {tzId}", nameof(tzId)); - - public static int WeekOfMonth(DateTime d) - { - var isExact = d.Day % 7 == 0; - var offset = isExact - ? 0 - : 1; - return (int) Math.Floor(d.Day / 7.0) + offset; - } -}