From e6d3cb7183e98d4355e66df59769da11b24603e6 Mon Sep 17 00:00:00 2001 From: Markus Minichmayr Date: Sat, 15 Feb 2025 22:35:27 +0100 Subject: [PATCH 1/4] Test: Update affected test cases to use well-known time zones to avoid falling back to local tz. --- .../Calendars/Recurrence/Bug2912657.ics | Bin 1974 -> 1708 bytes .../Calendars/Recurrence/Bug2916581.ics | 10 +++++----- .../Calendars/Recurrence/Bug2959692.ics | 6 +++--- .../Calendars/Recurrence/Bug2966236.ics | 6 +++--- .../Calendars/Serialization/Bug2938007.ics | 4 ++-- Ical.Net.Tests/RecurrenceTests.cs | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Ical.Net.Tests/Calendars/Recurrence/Bug2912657.ics b/Ical.Net.Tests/Calendars/Recurrence/Bug2912657.ics index 6b05fea3ab1a51e4ba186b4b7db4a05d9de1f861..a74d54ad418be2e07909f9712c7f53f4fd6dc1e0 100644 GIT binary patch delta 211 zcmdnSzlL`M7vtm?tR|Bym^36@8A=(781fkk7*ZMZ83Gs*fxJwHVur~Zn5^(gN=)9y brnK3F*@h9HDuPBBY!+fQqQHp7+t?TZePA$e delta 457 zcmZ3(yN!PX7h`=0gC~P8gDXQ6gFk~GkmhCJVh929JQ-XVtQgcIwWcu|=%)L`F&Y%6 zuki!PIssL=1LggI^1(p0jzE?RkPHHnAbA&{7(_e>$g*NEVlaS0Lm+Jk6oc`BbUoZc zKcEpHhgdOyEK*?5V35q3mgkhF30L9I;0A=jKs6yiItb`sU!ZDjppg&*VNOR@7XnlT zQe(hi304JC;|g{##POQwu7^p60WCoZnaR6Z+$Y;HY6zf*$>a-+5|g7?l`teWUtla_ X#1O@;d;@b83CbsLVD^}NhxGygtWjGu diff --git a/Ical.Net.Tests/Calendars/Recurrence/Bug2916581.ics b/Ical.Net.Tests/Calendars/Recurrence/Bug2916581.ics index 98ff24c6f..b22808b20 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/Bug2916581.ics +++ b/Ical.Net.Tests/Calendars/Recurrence/Bug2916581.ics @@ -4,7 +4,7 @@ PRODID:-//Microsoft Corporation//Windows Calendar 1.0//EN CALSCALE:GREGORIAN METHOD:PUBLISH BEGIN:VTIMEZONE -TZID:大阪、札幌、東京 +TZID:Asia/Tokyo BEGIN:STANDARD DTSTART:20000101T000000 TZNAME:東京 (標準時) @@ -14,16 +14,16 @@ END:STANDARD END:VTIMEZONE BEGIN:VEVENT DTSTAMP:20091217T220735Z -DTSTART;TZID=大阪、札幌、東京:20091225T110000 -DTEND;TZID=大阪、札幌、東京:20091225T113000 +DTSTART;TZID=Asia/Tokyo:20091225T110000 +DTEND;TZID=Asia/Tokyo:20091225T113000 RRULE:FREQ=WEEKLY SUMMARY:Friday Job UID:55BEC619-0C7B-48C0-8A17-2F358EA69DDD END:VEVENT BEGIN:VEVENT DTSTAMP:20091217T220758Z -DTSTART;TZID=大阪、札幌、東京:20091226T110000 -DTEND;TZID=大阪、札幌、東京:20091226T113000 +DTSTART;TZID=Asia/Tokyo:20091226T110000 +DTEND;TZID=Asia/Tokyo:20091226T113000 RRULE:FREQ=WEEKLY SUMMARY:Saturday Job UID:AD43DEDA-7DE4-4921-BCC2-A66DDFADA41D diff --git a/Ical.Net.Tests/Calendars/Recurrence/Bug2959692.ics b/Ical.Net.Tests/Calendars/Recurrence/Bug2959692.ics index 27a7ecc0f..c609622ba 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/Bug2959692.ics +++ b/Ical.Net.Tests/Calendars/Recurrence/Bug2959692.ics @@ -2,7 +2,7 @@ BEGIN:VCALENDAR PRODID:-//Microsoft Corporation//Outlook 11.0 MIMEDIR//EN VERSION:2.0 METHOD:PUBLISH BEGIN:VTIMEZONE -TZID:Sarajewo, Skopie, Warszawa, Zagrzeb +TZID:Europe/Warsaw BEGIN:STANDARD DTSTART:20071028T030000 RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=-1SU;BYMONTH=10 @@ -20,8 +20,8 @@ END:DAYLIGHT END:VTIMEZONE BEGIN:VEVENT ORGANIZER:MAILTO:majk@majk.com -DTSTART;TZID="Sarajewo, Skopie, Warszawa, Zagrzeb":20080103T170000 -DTEND;TZID="Sarajewo, Skopie, Warszawa, Zagrzeb":20080103T173000 +DTSTART;TZID="Europe/Warsaw":20080103T170000 +DTEND;TZID="Europe/Warsaw":20080103T173000 RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TH;WKST=MO TRANSP:OPAQUE SEQUENCE:0 diff --git a/Ical.Net.Tests/Calendars/Recurrence/Bug2966236.ics b/Ical.Net.Tests/Calendars/Recurrence/Bug2966236.ics index 5fed03103..539e9014f 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/Bug2966236.ics +++ b/Ical.Net.Tests/Calendars/Recurrence/Bug2966236.ics @@ -3,7 +3,7 @@ PRODID:-//Microsoft Corporation//Outlook 11.0 MIMEDIR//EN VERSION:2.0 METHOD:PUBLISH BEGIN:VTIMEZONE -TZID:Beijing +TZID:Asia/Shanghai BEGIN:STANDARD DTSTART:16010101T000000 TZOFFSETFROM:+0800 @@ -12,8 +12,8 @@ TZNAME:Standard Time END:STANDARD END:VTIMEZONE BEGIN:VEVENT -DTSTART;TZID="Beijing":20100119T080000 -DTEND;TZID="Beijing":20100119T083000 +DTSTART;TZID="Asia/Shanghai":20100119T080000 +DTEND;TZID="Asia/Shanghai":20100119T083000 RRULE:FREQ=DAILY;INTERVAL=7;WKST=SU TRANSP:OPAQUE SEQUENCE:0 diff --git a/Ical.Net.Tests/Calendars/Serialization/Bug2938007.ics b/Ical.Net.Tests/Calendars/Serialization/Bug2938007.ics index e2186d828..b84b9f1cd 100644 --- a/Ical.Net.Tests/Calendars/Serialization/Bug2938007.ics +++ b/Ical.Net.Tests/Calendars/Serialization/Bug2938007.ics @@ -3,8 +3,8 @@ PRODID:-//Google Inc//Google Calendar 70.9054//EN VERSION:2.0 BEGIN:VEVENT DTSTAMP:20100123T152945Z -DTSTART;TZID=大阪、札幌、東京:20100117T000000 -DTEND;TZID=大阪、札幌、東京:20100117T003000 +DTSTART;TZID=Asia/Tokyo:20100117T000000 +DTEND;TZID=Asia/Tokyo:20100117T003000 RRULE:FREQ=WEEKLY;COUNT=3 SUMMARY:WeeklyTest UID:4F1AD425-E0AE-4480-8393-CA754C5870DE diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 5407899d7..9bde58ce5 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -2291,7 +2291,7 @@ public void Yearly1() public void Bug2912657() { var iCal = Calendar.Load(IcsFiles.Bug2912657); - var localTzid = iCal.TimeZones[0].TzId; + var localTzid = iCal.Events.First().Start.TzId; // Daily recurrence EventOccurrenceTest( From 96e335826b7d8259cc109a2b0d4a48c6acd063e8 Mon Sep 17 00:00:00 2001 From: Markus Minichmayr Date: Sat, 15 Feb 2025 21:10:35 +0100 Subject: [PATCH 2/4] Stop resolving unknown tzids to the local timezone. --- Ical.Net/CalendarComponents/VTimeZone.cs | 2 +- Ical.Net/Utility/DateUtil.cs | 9 +-------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/Ical.Net/CalendarComponents/VTimeZone.cs b/Ical.Net/CalendarComponents/VTimeZone.cs index b7b82a071..773080b5d 100644 --- a/Ical.Net/CalendarComponents/VTimeZone.cs +++ b/Ical.Net/CalendarComponents/VTimeZone.cs @@ -318,7 +318,7 @@ public virtual string TzId Properties.Remove("TZID"); } - _nodaZone = DateUtil.GetZone(value, useLocalIfNotFound: false); + _nodaZone = DateUtil.GetZone(value); var id = _nodaZone.Id; if (string.IsNullOrWhiteSpace(id)) { diff --git a/Ical.Net/Utility/DateUtil.cs b/Ical.Net/Utility/DateUtil.cs index 24ca0a46a..45ee26d26 100644 --- a/Ical.Net/Utility/DateUtil.cs +++ b/Ical.Net/Utility/DateUtil.cs @@ -57,10 +57,8 @@ private static Dictionary InitializeWindowsMappings() /// that. /// /// A BCL, IANA, or serialization time zone identifier - /// If true, this method will return the system local time zone if tzId doesn't match a known time zone identifier. - /// Otherwise, it will throw an exception. /// Unrecognized time zone id - public static DateTimeZone GetZone(string tzId, bool useLocalIfNotFound = true) + public static DateTimeZone GetZone(string tzId) { var exMsg = $"Unrecognized time zone id {tzId}"; @@ -119,11 +117,6 @@ public static DateTimeZone GetZone(string tzId, bool useLocalIfNotFound = true) return NodaTime.Xml.XmlSerializationSettings.DateTimeZoneProvider.GetZoneOrNull(providerId) ?? throw new ArgumentException(exMsg); } - if (useLocalIfNotFound) - { - return LocalDateTimeZone; - } - throw new ArgumentException(exMsg); } From ea2bcf2b1cf8b5beaa5fc3f9987630627b702a4f Mon Sep 17 00:00:00 2001 From: Markus Minichmayr Date: Sat, 15 Feb 2025 21:34:05 +0100 Subject: [PATCH 3/4] Move time zone resolving logic to `DefaultTimeZoneResolver` --- Ical.Net/CalendarComponents/VTimeZone.cs | 4 +- Ical.Net/DefaultTimeZoneResolver.cs | 96 ++++++++++++++++++++++++ Ical.Net/Utility/DateUtil.cs | 85 ++------------------- 3 files changed, 103 insertions(+), 82 deletions(-) create mode 100644 Ical.Net/DefaultTimeZoneResolver.cs diff --git a/Ical.Net/CalendarComponents/VTimeZone.cs b/Ical.Net/CalendarComponents/VTimeZone.cs index 773080b5d..d563cdd12 100644 --- a/Ical.Net/CalendarComponents/VTimeZone.cs +++ b/Ical.Net/CalendarComponents/VTimeZone.cs @@ -20,10 +20,10 @@ namespace Ical.Net.CalendarComponents; public class VTimeZone : CalendarComponent { public static VTimeZone FromLocalTimeZone() - => FromDateTimeZone(DateUtil.LocalDateTimeZone.Id); + => FromDateTimeZone(DefaultTimeZoneResolver.LocalDateTimeZone.Id); public static VTimeZone FromLocalTimeZone(DateTime earliestDateTimeToSupport, bool includeHistoricalData) - => FromDateTimeZone(DateUtil.LocalDateTimeZone.Id, earliestDateTimeToSupport, includeHistoricalData); + => FromDateTimeZone(DefaultTimeZoneResolver.LocalDateTimeZone.Id, earliestDateTimeToSupport, includeHistoricalData); public static VTimeZone FromSystemTimeZone(TimeZoneInfo tzinfo) => FromSystemTimeZone(tzinfo, new DateTime(DateTime.Now.Year, 1, 1), false); diff --git a/Ical.Net/DefaultTimeZoneResolver.cs b/Ical.Net/DefaultTimeZoneResolver.cs new file mode 100644 index 000000000..e78bde3c3 --- /dev/null +++ b/Ical.Net/DefaultTimeZoneResolver.cs @@ -0,0 +1,96 @@ +// +// 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/Utility/DateUtil.cs b/Ical.Net/Utility/DateUtil.cs index 45ee26d26..8672ecbdf 100644 --- a/Ical.Net/Utility/DateUtil.cs +++ b/Ical.Net/Utility/DateUtil.cs @@ -5,12 +5,8 @@ #nullable enable using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; using Ical.Net.DataTypes; using NodaTime; -using NodaTime.TimeZones; namespace Ical.Net.Utility; @@ -41,84 +37,15 @@ public static DateTime FirstDayOfWeek(DateTime dt, DayOfWeek firstDayOfWeek, out return dt; } - private static readonly Lazy> _windowsMapping - = new Lazy>(InitializeWindowsMappings, LazyThreadSafetyMode.PublicationOnly); - - private static Dictionary InitializeWindowsMappings() - => TzdbDateTimeZoneSource.Default.WindowsMapping.PrimaryMapping - .ToDictionary(k => k.Key, v => v.Value, StringComparer.OrdinalIgnoreCase); - - public static readonly DateTimeZone LocalDateTimeZone = DateTimeZoneProviders.Tzdb.GetSystemDefault(); - /// - /// 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. + /// Returns the NodaTime DateTimeZone for the given TZID according to the + /// current time zone resolver set in TimeZoneResolvers.TimeZoneResolver. /// - /// A BCL, IANA, or serialization time zone identifier + /// A time zone identifier /// Unrecognized time zone id public static DateTimeZone GetZone(string tzId) - { - var exMsg = $"Unrecognized time zone id {tzId}"; - - if (string.IsNullOrWhiteSpace(tzId)) - { - return LocalDateTimeZone; - } - - 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); - } - - throw new ArgumentException(exMsg); - } + => DefaultTimeZoneResolver.GetZone(tzId) + ?? throw new ArgumentException($"Unrecognized time zone id {tzId}"); public static ZonedDateTime AddYears(ZonedDateTime zonedDateTime, int years) { @@ -170,8 +97,6 @@ public static ZonedDateTime FromTimeZoneToTimeZone(DateTime dateTime, DateTimeZo return newZone; } - public static bool IsSerializationTimeZone(DateTimeZone zone) => NodaTime.Xml.XmlSerializationSettings.DateTimeZoneProvider.GetZoneOrNull(zone.Id) != null; - /// /// Truncate to the specified TimeSpan's magnitude. For example, to truncate to the nearest second, use TimeSpan.FromSeconds(1) /// From 1937bbc1da9f3813415365c0a2733186e8332ca3 Mon Sep 17 00:00:00 2001 From: Markus Minichmayr Date: Sat, 15 Feb 2025 22:44:58 +0100 Subject: [PATCH 4/4] Make time zone resolving configurable. --- Ical.Net/TimeZoneResolvers.cs | 22 ++++++++++++++++++++++ Ical.Net/Utility/DateUtil.cs | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 Ical.Net/TimeZoneResolvers.cs diff --git a/Ical.Net/TimeZoneResolvers.cs b/Ical.Net/TimeZoneResolvers.cs new file mode 100644 index 000000000..669e37d27 --- /dev/null +++ b/Ical.Net/TimeZoneResolvers.cs @@ -0,0 +1,22 @@ +// +// 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); + + /// + /// 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 index 8672ecbdf..52f60a4c7 100644 --- a/Ical.Net/Utility/DateUtil.cs +++ b/Ical.Net/Utility/DateUtil.cs @@ -44,7 +44,7 @@ public static DateTime FirstDayOfWeek(DateTime dt, DayOfWeek firstDayOfWeek, out /// A time zone identifier /// Unrecognized time zone id public static DateTimeZone GetZone(string tzId) - => DefaultTimeZoneResolver.GetZone(tzId) + => (TimeZoneResolvers.TimeZoneResolver ?? throw new InvalidOperationException())(tzId) ?? throw new ArgumentException($"Unrecognized time zone id {tzId}"); public static ZonedDateTime AddYears(ZonedDateTime zonedDateTime, int years)