From dfca6b9f3e2e57b4e8e3ac2a753c3c8e4dccdbd6 Mon Sep 17 00:00:00 2001 From: axunonb Date: Tue, 20 May 2025 15:37:35 +0200 Subject: [PATCH 1/3] Fix: Avoid repeated lenient timezone conversions If a `CalDateTime` is instantiated from a lenient timezone conversion, this leniently determined value must not be converted again. Reasoning: Lenient conversions resolve non-existing or ambiguous times caused by DST transitions. Resolves #737 --- Ical.Net.Tests/RecurrenceTests.cs | 33 ++++++++++++++++++++++ Ical.Net/DataTypes/CalDateTime.cs | 47 ++++++++++++++++++++----------- Ical.Net/DataTypes/Period.cs | 5 ++-- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 8cb4b1725..5cf7199b1 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -4111,4 +4111,37 @@ public void Disallowed_Recurrence_RangeChecks_Should_Throw() Assert.That(() => serializer.CheckRange("a", (int?) 0, 1, 2, false), Throws.TypeOf()); }); } + + [Test] + public void AmbiguousLocalTime_WithShortDurationOfRecurrence() + { + // Short recurrence falls into an ambiguous local time + // for the end time of the second occurrence because + // of DST transition on 2025-10-25 03:00 + // See also: https://github.com/ical-org/ical.net/issues/737 + var ics = """ + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTART;TZID=Europe/Vienna:20201024T023000 + DURATION:PT45M + RRULE:FREQ=DAILY;UNTIL=20201025T013000Z + END:VEVENT + END:VCALENDAR + """; + var cal = Calendar.Load(ics)!; + var occ = cal.GetOccurrences().ToList(); + + Assert.Multiple(() => + { + Assert.That(occ.Count, Is.EqualTo(2)); + + Assert.That(occ[0].Period.StartTime, Is.EqualTo(new CalDateTime(2020, 10, 24, 2, 30, 0, "Europe/Vienna"))); + Assert.That(occ[0].Period.EndTime, Is.EqualTo(new CalDateTime(2020, 10, 24, 3, 15, 0, "Europe/Vienna"))); + Assert.That(occ[0].Period.EffectiveDuration, Is.EqualTo(new Duration(0, 0, 0, 45, 0))); + + Assert.That(occ[1].Period.StartTime, Is.EqualTo(new CalDateTime(2020, 10, 25, 2, 30, 0, "Europe/Vienna"))); + Assert.That(occ[1].Period.EndTime, Is.EqualTo(new CalDateTime(2020, 10, 25, 2, 15, 0, "Europe/Vienna"))); + Assert.That(occ[1].Period.EffectiveDuration, Is.EqualTo(new Duration(0, 0, 0, 45, 0))); + }); + } } diff --git a/Ical.Net/DataTypes/CalDateTime.cs b/Ical.Net/DataTypes/CalDateTime.cs index f653bcd48..20cf9ad09 100644 --- a/Ical.Net/DataTypes/CalDateTime.cs +++ b/Ical.Net/DataTypes/CalDateTime.cs @@ -29,6 +29,9 @@ public sealed class CalDateTime : IComparable, IFormattable // The time part that is used to return the Value property. private TimeOnly? _timeOnly; + // Track if this instance was created with ToTimeZone(...) + private ZonedDateTime? _zonedDateTime; + /// /// The timezone ID for Universal Coordinated Time (UTC). /// @@ -60,6 +63,11 @@ private CalDateTime() // required for the SerializerFactory to work } + internal CalDateTime(ZonedDateTime zonedDateTime, string? tzId) : this(zonedDateTime.ToDateTimeUnspecified(), tzId) + { + _zonedDateTime = zonedDateTime; + } + /// /// Creates a new instance of the class. /// The instance will represent an RFC 5545, Section 3.3.5, DATE-TIME value, if is . @@ -210,6 +218,7 @@ private void Initialize(DateOnly dateOnly, TimeOnly? timeOnly, string? tzId) { _dateOnly = dateOnly; _timeOnly = TruncateTimeToSeconds(timeOnly); + _zonedDateTime = null; _tzId = tzId switch { @@ -218,13 +227,13 @@ private void Initialize(DateOnly dateOnly, TimeOnly? timeOnly, string? tzId) }; } - /// private void CopyFrom(CalDateTime calDt) { // Maintain the private date/time backing fields _dateOnly = calDt._dateOnly; _timeOnly = TruncateTimeToSeconds(calDt._timeOnly); _tzId = calDt._tzId; + _zonedDateTime = calDt._zonedDateTime; } public bool Equals(CalDateTime? other) => this == other; @@ -306,7 +315,7 @@ public override int GetHashCode() /// it means that the is considered as local time for every timezone: /// The returned is unchanged, but with . /// - public DateTime AsUtc => DateTime.SpecifyKind(ToTimeZone(UtcTzId).Value, DateTimeKind.Utc); + public DateTime AsUtc => _zonedDateTime?.ToDateTimeUtc() ?? DateTime.SpecifyKind(ToTimeZone(UtcTzId).Value, DateTimeKind.Utc); /// /// Gets the date and time value in the ISO calendar as a type with . @@ -463,13 +472,19 @@ public CalDateTime ToTimeZone(string? otherTzId) } else { - var zonedOriginal = DateUtil.ToZonedDateTimeLeniently(Value, TzId!); - converted = zonedOriginal.WithZone(DateUtil.GetZone(otherTzId)); + // If the current instance was created from ToTimeZone(...), it will have the _zonedDateTime set. + // Using this value avoids wrong (double) conversions when the original date/time + // was in a non-existing time due to DST changes and the value was determined leniently. + var zonedOriginal = + _zonedDateTime ?? DateUtil.ToZonedDateTimeLeniently(Value, TzId!); + + converted = otherTzId != TzId + ? zonedOriginal.WithZone(DateUtil.GetZone(otherTzId)) + : zonedOriginal; } - return converted.Zone == DateTimeZone.Utc - ? new CalDateTime(converted.ToDateTimeUtc(), UtcTzId) - : new CalDateTime(converted.ToDateTimeUnspecified(), otherTzId); + // Create a new instance with the converted date/time and the new timezone. + return new CalDateTime(converted, otherTzId); } /// @@ -547,7 +562,7 @@ public Duration Subtract(CalDateTime dt) } internal CalDateTime Copy() - => new CalDateTime(_dateOnly, _timeOnly, _tzId); + => new CalDateTime(_dateOnly, _timeOnly, _tzId) { _zonedDateTime = _zonedDateTime}; /// public CalDateTime AddYears(int years) @@ -654,17 +669,15 @@ public int CompareTo(CalDateTime? dt) public string ToString(string? format, IFormatProvider? formatProvider) { formatProvider ??= CultureInfo.InvariantCulture; - var dateTimeOffset = DateUtil.ToZonedDateTimeLeniently(Value, _tzId ?? string.Empty).ToDateTimeOffset(); - + var dateTimeOffset = + _zonedDateTime?.ToDateTimeOffset() + ?? DateUtil.ToZonedDateTimeLeniently(Value, _tzId ?? string.Empty).ToDateTimeOffset(); + // Use the .NET format options to format the DateTimeOffset var tzIdString = _tzId is not null ? $" {_tzId}" : string.Empty; - if (HasTime) - { - return $"{dateTimeOffset.ToString(format, formatProvider)}{tzIdString}"; - } - - // No time part - return $"{DateOnly.FromDateTime(dateTimeOffset.Date).ToString(format ?? "d", formatProvider)}{tzIdString}"; + return HasTime + ? $"{dateTimeOffset.ToString(format, formatProvider)}{tzIdString}" + : $"{DateOnly.FromDateTime(dateTimeOffset.Date).ToString(format ?? "d", formatProvider)}{tzIdString}"; } } diff --git a/Ical.Net/DataTypes/Period.cs b/Ical.Net/DataTypes/Period.cs index 842880a18..c6d295fe9 100644 --- a/Ical.Net/DataTypes/Period.cs +++ b/Ical.Net/DataTypes/Period.cs @@ -77,8 +77,9 @@ public Period(CalDateTime start, CalDateTime? end = null) throw new ArgumentException( $"Start time ({start}) and end time ({end}) must both have a time or both be date-only."); - if (end != null && end.LessThan(start)) - throw new ArgumentException($"End time ({end}) must be greater than start time ({start}).", nameof(end)); + // Although the timezones are the same, the start and end times may be in different DST offsets. + if (end != null && end.AsUtc < start.AsUtc) + throw new ArgumentException($"End time ({end}) as UTC must be greater than start time ({start}) as UTC.", nameof(end)); _startTime = start; _endTime = end; From 07cd4b84b274bf62d13bc52f34ab024927af5c36 Mon Sep 17 00:00:00 2001 From: axunonb Date: Wed, 21 May 2025 13:03:24 +0200 Subject: [PATCH 2/3] Add evaluation layer to `CalDateTime` * Introduced `CalDateTimeExtensions` for extension methods on `CalDateTime` * Moved `CalDateTime` arithmetic and conversion methods to `CalDateTimeExtensions` * Adjusted related classes with new references Note: * `CalDateTimeExtensions.ToTimeZone` links the input `CalDateTime` with its `ZonedDateTime` representation, using a cache with weak references to `CalDateTime`. * This way, once a `CalDateTime` is resolved to a `ZonedDateTime`, the `ZonedDateTime` will be used for further conversions. Why cache `ZonedDateTime`? * If a `CalDateTime` is instantiated from a lenient timezone conversion, this leniently determined value must not be converted again. * Lenient conversions resolve non-existing or ambiguous times caused by DST transitions. Open: * When and how to clear the cache? * Introduce `CalDateTimeZoned`, inhertiting from `CalDateTime` with``ZonedDateTime` as a property? --- Ical.Net.Benchmarks/ApplicationWorkflows.cs | 1 + Ical.Net.Benchmarks/CalDateTimePerfTests.cs | 1 + Ical.Net.Benchmarks/OccurencePerfTests.cs | 1 + Ical.Net.Tests/CalDateTimeTests.cs | 27 +- Ical.Net.Tests/PeriodListWrapperTests.cs | 1 + Ical.Net.Tests/RecurrenceTests.cs | 8 +- Ical.Net.Tests/RecurrenceWithRDateTests.cs | 1 + Ical.Net.Tests/SerializationTests.cs | 1 + Ical.Net/CalendarComponents/VTimeZone.cs | 1 + Ical.Net/CalendarExtensions.cs | 1 + Ical.Net/DataTypes/CalDateTime.cs | 216 +------------- Ical.Net/DataTypes/Duration.cs | 1 + Ical.Net/DataTypes/Period.cs | 5 +- Ical.Net/DataTypes/Trigger.cs | 1 + Ical.Net/Evaluation/CalDateTimeExtensions.cs | 287 +++++++++++++++++++ Ical.Net/Utility/DateUtil.cs | 3 +- 16 files changed, 331 insertions(+), 225 deletions(-) create mode 100644 Ical.Net/Evaluation/CalDateTimeExtensions.cs diff --git a/Ical.Net.Benchmarks/ApplicationWorkflows.cs b/Ical.Net.Benchmarks/ApplicationWorkflows.cs index 92aa8b29e..1cd134f74 100644 --- a/Ical.Net.Benchmarks/ApplicationWorkflows.cs +++ b/Ical.Net.Benchmarks/ApplicationWorkflows.cs @@ -9,6 +9,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Ical.Net.Evaluation; namespace Ical.Net.Benchmarks; diff --git a/Ical.Net.Benchmarks/CalDateTimePerfTests.cs b/Ical.Net.Benchmarks/CalDateTimePerfTests.cs index 9fcd21725..52b5f248f 100644 --- a/Ical.Net.Benchmarks/CalDateTimePerfTests.cs +++ b/Ical.Net.Benchmarks/CalDateTimePerfTests.cs @@ -6,6 +6,7 @@ using BenchmarkDotNet.Attributes; using Ical.Net.DataTypes; using System; +using Ical.Net.Evaluation; namespace Ical.Net.Benchmarks; diff --git a/Ical.Net.Benchmarks/OccurencePerfTests.cs b/Ical.Net.Benchmarks/OccurencePerfTests.cs index a17ab58ce..ebba8d9fe 100644 --- a/Ical.Net.Benchmarks/OccurencePerfTests.cs +++ b/Ical.Net.Benchmarks/OccurencePerfTests.cs @@ -9,6 +9,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Ical.Net.Evaluation; namespace Ical.Net.Benchmarks; diff --git a/Ical.Net.Tests/CalDateTimeTests.cs b/Ical.Net.Tests/CalDateTimeTests.cs index 6463a1581..368b70767 100644 --- a/Ical.Net.Tests/CalDateTimeTests.cs +++ b/Ical.Net.Tests/CalDateTimeTests.cs @@ -12,6 +12,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using Ical.Net.Evaluation; namespace Ical.Net.Tests; @@ -55,10 +56,10 @@ public void ToTimeZoneFloating() [Test, TestCaseSource(nameof(ToTimeZoneTestCases))] public void ToTimeZoneTests(CalendarEvent calendarEvent, string targetTimeZone) { - var startAsUtc = calendarEvent.Start.AsUtc; + var startAsUtc = calendarEvent.Start!.AsUtc(); var convertedStart = calendarEvent.Start.ToTimeZone(targetTimeZone); - var convertedAsUtc = convertedStart.AsUtc; + var convertedAsUtc = convertedStart.AsUtc(); Assert.That(convertedAsUtc, Is.EqualTo(startAsUtc)); } @@ -96,11 +97,11 @@ public void SameDateTimeWithDifferentTzIdShouldReturnSameUtc() var someTime = DateTimeOffset.Parse("2018-05-21T11:35:00-04:00", CultureInfo.InvariantCulture); var someDt = new CalDateTime(someTime.DateTime, "America/New_York"); - var firstUtc = someDt.AsUtc; + var firstUtc = someDt.AsUtc(); Assert.That(firstUtc, Is.EqualTo(someTime.UtcDateTime)); someDt = new CalDateTime(someTime.DateTime, "Europe/Berlin"); - var berlinUtc = someDt.AsUtc; + var berlinUtc = someDt.AsUtc(); Assert.That(berlinUtc, Is.Not.EqualTo(firstUtc)); } @@ -190,15 +191,15 @@ public static IEnumerable DateTimeArithmeticTestCases() yield return new TestCaseData(new Func(dt => dt.AddHours(1))) .Returns(dateTime.AddHours(1)) - .SetName($"{nameof(CalDateTime.AddHours)} 1 hour"); + .SetName($"{nameof(CalDateTimeExtensions.AddHours)} 1 hour"); yield return new TestCaseData(new Func(dt => dt.Add(Duration.FromSeconds(30)))) .Returns(dateTime.Add(TimeSpan.FromSeconds(30))) - .SetName($"{nameof(CalDateTime.Add)} 30 seconds"); + .SetName($"{nameof(CalDateTimeExtensions.Add)} 30 seconds"); yield return new TestCaseData(new Func(dt => dt.AddMinutes(70))) .Returns(dateTime.AddMinutes(70)) - .SetName($"{nameof(CalDateTime.AddMinutes)} 70 minutes"); + .SetName($"{nameof(CalDateTimeExtensions.AddMinutes)} 70 minutes"); } [Test, TestCaseSource(nameof(EqualityTestCases))] @@ -259,27 +260,27 @@ public static IEnumerable DateOnlyValidArithmeticTestCases() yield return new TestCaseData(new Func(dt => dt.Add(-Duration.FromDays(1)))) .Returns((dateTime.AddDays(-1), false)) - .SetName($"{nameof(CalDateTime.Add)} -1 day TimeSpan"); + .SetName($"{nameof(CalDateTimeExtensions.Add)} -1 day TimeSpan"); yield return new TestCaseData(new Func(dt => dt.AddYears(1))) .Returns((dateTime.AddYears(1), false)) - .SetName($"{nameof(CalDateTime.AddYears)} 1 year"); + .SetName($"{nameof(CalDateTimeExtensions.AddYears)} 1 year"); yield return new TestCaseData(new Func(dt => dt.AddMonths(2))) .Returns((dateTime.AddMonths(2), false)) - .SetName($"{nameof(CalDateTime.AddMonths)} 2 months"); + .SetName($"{nameof(CalDateTimeExtensions.AddMonths)} 2 months"); yield return new TestCaseData(new Func(dt => dt.AddDays(7))) .Returns((dateTime.AddDays(7), false)) - .SetName($"{nameof(CalDateTime.AddDays)} 7 days"); + .SetName($"{nameof(CalDateTimeExtensions.AddDays)} 7 days"); yield return new TestCaseData(new Func(dt => dt.Add(Duration.FromDays(1)))) .Returns((dateTime.Add(TimeSpan.FromDays(1)), false)) - .SetName($"{nameof(CalDateTime.Add)} 1 day TimeSpan"); + .SetName($"{nameof(CalDateTimeExtensions.Add)} 1 day TimeSpan"); yield return new TestCaseData(new Func(dt => dt.Add(Duration.Zero))) .Returns((dateTime.Add(TimeSpan.Zero), false)) - .SetName($"{nameof(CalDateTime.Add)} TimeSpan.Zero"); + .SetName($"{nameof(CalDateTimeExtensions.Add)} TimeSpan.Zero"); } [Test] diff --git a/Ical.Net.Tests/PeriodListWrapperTests.cs b/Ical.Net.Tests/PeriodListWrapperTests.cs index 995d392f1..2e7d585b1 100644 --- a/Ical.Net.Tests/PeriodListWrapperTests.cs +++ b/Ical.Net.Tests/PeriodListWrapperTests.cs @@ -7,6 +7,7 @@ using System.Linq; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; +using Ical.Net.Evaluation; using Ical.Net.Serialization; using NUnit.Framework; diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 5cf7199b1..6af6e7563 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -3986,7 +3986,7 @@ public void TestDtStartTimezone(string? tzId) var cal = Calendar.Load(icalText)!; var evt = cal.Events.First(); var ev = new EventEvaluator(evt); - var occurrences = ev.Evaluate(evt.DtStart!, evt.DtStart!.ToTimeZone(tzId), null).TakeWhileBefore(evt.DtStart.AddMinutes(61).ToTimeZone(tzId)); + var occurrences = ev.Evaluate(evt.DtStart!, evt.DtStart!.ToTimeZone(tzId), null).TakeWhileBefore(evt.DtStart!.AddMinutes(61).ToTimeZone(tzId)); var occurrencesStartTimes = occurrences.Select(x => x.StartTime).Take(2).ToList(); var expectedStartTimes = new[] @@ -4115,6 +4115,8 @@ public void Disallowed_Recurrence_RangeChecks_Should_Throw() [Test] public void AmbiguousLocalTime_WithShortDurationOfRecurrence() { + CalDateTimeExtensions.CleanupCache(); + // Short recurrence falls into an ambiguous local time // for the end time of the second occurrence because // of DST transition on 2025-10-25 03:00 @@ -4130,11 +4132,11 @@ public void AmbiguousLocalTime_WithShortDurationOfRecurrence() """; var cal = Calendar.Load(ics)!; var occ = cal.GetOccurrences().ToList(); - + Assert.Multiple(() => { Assert.That(occ.Count, Is.EqualTo(2)); - + Assert.That(occ[0].Period.StartTime, Is.EqualTo(new CalDateTime(2020, 10, 24, 2, 30, 0, "Europe/Vienna"))); Assert.That(occ[0].Period.EndTime, Is.EqualTo(new CalDateTime(2020, 10, 24, 3, 15, 0, "Europe/Vienna"))); Assert.That(occ[0].Period.EffectiveDuration, Is.EqualTo(new Duration(0, 0, 0, 45, 0))); diff --git a/Ical.Net.Tests/RecurrenceWithRDateTests.cs b/Ical.Net.Tests/RecurrenceWithRDateTests.cs index fe072e982..022c4526b 100644 --- a/Ical.Net.Tests/RecurrenceWithRDateTests.cs +++ b/Ical.Net.Tests/RecurrenceWithRDateTests.cs @@ -9,6 +9,7 @@ using Ical.Net.CalendarComponents; using Ical.Net.Collections; using Ical.Net.DataTypes; +using Ical.Net.Evaluation; using Ical.Net.Serialization; using Ical.Net.Utility; using NUnit.Framework; diff --git a/Ical.Net.Tests/SerializationTests.cs b/Ical.Net.Tests/SerializationTests.cs index 80e92cedc..86777452a 100644 --- a/Ical.Net.Tests/SerializationTests.cs +++ b/Ical.Net.Tests/SerializationTests.cs @@ -12,6 +12,7 @@ using System.Text.RegularExpressions; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; +using Ical.Net.Evaluation; using Ical.Net.Serialization; using Ical.Net.Serialization.DataTypes; using Ical.Net.Utility; diff --git a/Ical.Net/CalendarComponents/VTimeZone.cs b/Ical.Net/CalendarComponents/VTimeZone.cs index 64f78304e..e190ffbe3 100644 --- a/Ical.Net/CalendarComponents/VTimeZone.cs +++ b/Ical.Net/CalendarComponents/VTimeZone.cs @@ -11,6 +11,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Ical.Net.Evaluation; namespace Ical.Net.CalendarComponents; diff --git a/Ical.Net/CalendarExtensions.cs b/Ical.Net/CalendarExtensions.cs index 1da2c3ae1..d97396509 100644 --- a/Ical.Net/CalendarExtensions.cs +++ b/Ical.Net/CalendarExtensions.cs @@ -6,6 +6,7 @@ using System; using System.Globalization; using Ical.Net.DataTypes; +using Ical.Net.Evaluation; namespace Ical.Net; diff --git a/Ical.Net/DataTypes/CalDateTime.cs b/Ical.Net/DataTypes/CalDateTime.cs index 20cf9ad09..0888d9979 100644 --- a/Ical.Net/DataTypes/CalDateTime.cs +++ b/Ical.Net/DataTypes/CalDateTime.cs @@ -4,11 +4,9 @@ // using Ical.Net.Serialization.DataTypes; -using Ical.Net.Utility; -using NodaTime; using System; -using System.Globalization; using System.IO; +using Ical.Net.Evaluation; namespace Ical.Net.DataTypes; @@ -29,9 +27,6 @@ public sealed class CalDateTime : IComparable, IFormattable // The time part that is used to return the Value property. private TimeOnly? _timeOnly; - // Track if this instance was created with ToTimeZone(...) - private ZonedDateTime? _zonedDateTime; - /// /// The timezone ID for Universal Coordinated Time (UTC). /// @@ -63,11 +58,6 @@ private CalDateTime() // required for the SerializerFactory to work } - internal CalDateTime(ZonedDateTime zonedDateTime, string? tzId) : this(zonedDateTime.ToDateTimeUnspecified(), tzId) - { - _zonedDateTime = zonedDateTime; - } - /// /// Creates a new instance of the class. /// The instance will represent an RFC 5545, Section 3.3.5, DATE-TIME value, if is . @@ -218,7 +208,6 @@ private void Initialize(DateOnly dateOnly, TimeOnly? timeOnly, string? tzId) { _dateOnly = dateOnly; _timeOnly = TruncateTimeToSeconds(timeOnly); - _zonedDateTime = null; _tzId = tzId switch { @@ -231,9 +220,8 @@ private void CopyFrom(CalDateTime calDt) { // Maintain the private date/time backing fields _dateOnly = calDt._dateOnly; - _timeOnly = TruncateTimeToSeconds(calDt._timeOnly); + _timeOnly = calDt._timeOnly; _tzId = calDt._tzId; - _zonedDateTime = calDt._zonedDateTime; } public bool Equals(CalDateTime? other) => this == other; @@ -258,28 +246,28 @@ public override int GetHashCode() { return left != null && right != null - && ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value < right.Value : left.AsUtc < right.AsUtc); + && ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value < right.Value : left.AsUtc() < right.AsUtc()); } public static bool operator >(CalDateTime? left, CalDateTime? right) { return left != null && right != null - && ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value > right.Value : left.AsUtc > right.AsUtc); + && ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value > right.Value : left.AsUtc() > right.AsUtc()); } public static bool operator <=(CalDateTime? left, CalDateTime? right) { return left != null && right != null - && ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value <= right.Value : left.AsUtc <= right.AsUtc); + && ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value <= right.Value : left.AsUtc() <= right.AsUtc()); } public static bool operator >=(CalDateTime? left, CalDateTime? right) { return left != null && right != null - && ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value >= right.Value : left.AsUtc >= right.AsUtc); + && ((left.IsFloating || right.IsFloating || left.TzId == right.TzId) ? left.Value >= right.Value : left.AsUtc() >= right.AsUtc()); } public static bool operator ==(CalDateTime? left, CalDateTime? right) @@ -309,14 +297,6 @@ public override int GetHashCode() return !(left == right); } - /// - /// Converts the date/time to UTC (Coordinated Universal Time) - /// If == - /// it means that the is considered as local time for every timezone: - /// The returned is unchanged, but with . - /// - public DateTime AsUtc => _zonedDateTime?.ToDateTimeUtc() ?? DateTime.SpecifyKind(ToTimeZone(UtcTzId).Value, DateTimeKind.Utc); - /// /// Gets the date and time value in the ISO calendar as a type with . /// The value has no associated timezone.
@@ -445,170 +425,6 @@ public DateTime Value return new TimeOnly(time.Value.Hour, time.Value.Minute, time.Value.Second); } - /// - /// Any values are truncated to seconds, because - /// RFC 5545, Section 3.3.5 does not allow for fractional seconds. - /// - private static TimeOnly? TruncateTimeToSeconds(DateTime dateTime) => new TimeOnly(dateTime.Hour, dateTime.Minute, dateTime.Second); - - /// - /// Converts the to a date/time - /// within the specified timezone. - /// - /// If == - /// it means that the is considered as local time for every timezone: - /// The returned is unchanged and the is set as . - /// - public CalDateTime ToTimeZone(string? otherTzId) - { - if (otherTzId is null) - return new CalDateTime(Value, null, HasTime); - - ZonedDateTime converted; - if (IsFloating) - { - // Make sure, we properly fix the time if it doesn't exist in the target tz. - converted = DateUtil.ToZonedDateTimeLeniently(Value, otherTzId); - } - else - { - // If the current instance was created from ToTimeZone(...), it will have the _zonedDateTime set. - // Using this value avoids wrong (double) conversions when the original date/time - // was in a non-existing time due to DST changes and the value was determined leniently. - var zonedOriginal = - _zonedDateTime ?? DateUtil.ToZonedDateTimeLeniently(Value, TzId!); - - converted = otherTzId != TzId - ? zonedOriginal.WithZone(DateUtil.GetZone(otherTzId)) - : zonedOriginal; - } - - // Create a new instance with the converted date/time and the new timezone. - return new CalDateTime(converted, otherTzId); - } - - /// - /// Add the specified to this instance/>. - /// - /// - /// In correspondence to RFC5545, the weeks and day fields of a duration are considered nominal durations while the time fields are considered exact values. - /// - /// - /// Thrown when attempting to add a time span to a date-only instance, - /// and the time span is not a multiple of full days. - /// - public CalDateTime Add(Duration d) - { - if (!HasTime && d.HasTime) - { - throw new InvalidOperationException($"This instance represents a 'date-only' value '{ToString()}'. Only multiples of full days can be added to a 'date-only' instance, not '{d}'"); - } - - // RFC 5545 3.3.6: - // If the property permits, multiple "duration" values are specified by a COMMA-separated - // list of values.The format is based on the[ISO.8601.2004] complete representation basic - // format with designators for the duration of time.The format can represent nominal - // durations(weeks and days) and accurate durations(hours, minutes, and seconds). - // Note that unlike[ISO.8601.2004], this value type doesn't support the "Y" and "M" - // designators to specify durations in terms of years and months. - // - // The duration of a week or a day depends on its position in the calendar. In the case - // of discontinuities in the time scale, such as the change from standard time to daylight - // time and back, the computation of the exact duration requires the subtraction or - // addition of the change of duration of the discontinuity.Leap seconds MUST NOT be - // considered when computing an exact duration.When computing an exact duration, the - // greatest order time components MUST be added first, that is, the number of days MUST be - // added first, followed by the number of hours, number of minutes, and number of seconds. - - (TimeSpan? nominalPart, TimeSpan? exactPart) dt; - if (TzId is null) - dt = (d.ToTimeSpanUnspecified(), null); - else - dt = (d.HasDate ? d.DateAsTimeSpan : null, d.HasTime ? d.TimeAsTimeSpan : null); - - var newDateTime = this; - if (dt.nominalPart is not null) - newDateTime = new CalDateTime(newDateTime.Value.Add(dt.nominalPart.Value), TzId, HasTime); - - if (dt.exactPart is not null) - newDateTime = new CalDateTime(newDateTime.AsUtc.Add(dt.exactPart.Value), UtcTzId, HasTime); - - if (TzId is not null) - // Convert to the original timezone even if already set to ensure we're not in a non-existing time. - newDateTime = newDateTime.ToTimeZone(TzId); - - return newDateTime; - } - - /// Returns a new from subtracting the specified from to the value of this instance. - /// - public TimeSpan SubtractExact(CalDateTime dt) => AsUtc - dt.AsUtc; - - /// - /// Returns a new from subtracting the specified from to the value of this instance. - /// - /// - /// - /// - public Duration Subtract(CalDateTime dt) - { - if (this.TzId is not null) - return SubtractExact(dt).ToDurationExact(); - - if (dt.HasTime != HasTime) - throw new InvalidOperationException($"Trying to calculate the difference between dates of different types. An instance of type DATE cannot be subtracted from a DATE-TIME and vice versa: {ToString()} - {dt.ToString()}"); - - return (Value - dt.Value).ToDuration(); - } - - internal CalDateTime Copy() - => new CalDateTime(_dateOnly, _timeOnly, _tzId) { _zonedDateTime = _zonedDateTime}; - - /// - public CalDateTime AddYears(int years) - { - var dt = Copy(); - dt._dateOnly = dt._dateOnly.AddYears(years); - return dt; - } - - /// - public CalDateTime AddMonths(int months) - { - var dt = Copy(); - dt._dateOnly = dt._dateOnly.AddMonths(months); - return dt; - } - - /// - public CalDateTime AddDays(int days) - { - var dt = Copy(); - dt._dateOnly = dt._dateOnly.AddDays(days); - return dt; - } - - /// - /// - /// Thrown when attempting to add a time span to a date-only instance, - /// and the time span is not a multiple of full days. - /// - public CalDateTime AddHours(int hours) => Add(Duration.FromHours(hours)); - - /// - /// - /// Thrown when attempting to add a time span to a date-only instance, - /// and the time span is not a multiple of full days. - /// - public CalDateTime AddMinutes(int minutes) => Add(Duration.FromMinutes(minutes)); - - /// - /// - /// Thrown when attempting to add a time span to a date-only instance, - /// and the time span is not a multiple of full days. - /// - public CalDateTime AddSeconds(int seconds) => Add(Duration.FromSeconds(seconds)); - /// /// Returns if the current instance is less than . /// @@ -660,24 +476,12 @@ public int CompareTo(CalDateTime? dt) } /// - public override string ToString() => ToString(null, null); + public override string ToString() => this.ToString(null, null); /// - public string ToString(string? format) => ToString(format, null); + public string ToString(string? format) => this.ToString(format, null); /// - public string ToString(string? format, IFormatProvider? formatProvider) - { - formatProvider ??= CultureInfo.InvariantCulture; - var dateTimeOffset = - _zonedDateTime?.ToDateTimeOffset() - ?? DateUtil.ToZonedDateTimeLeniently(Value, _tzId ?? string.Empty).ToDateTimeOffset(); - - // Use the .NET format options to format the DateTimeOffset - var tzIdString = _tzId is not null ? $" {_tzId}" : string.Empty; - - return HasTime - ? $"{dateTimeOffset.ToString(format, formatProvider)}{tzIdString}" - : $"{DateOnly.FromDateTime(dateTimeOffset.Date).ToString(format ?? "d", formatProvider)}{tzIdString}"; - } + public string ToString(string? format, IFormatProvider? formatProvider) // part of IFormattable + => CalDateTimeExtensions.ToString(this, format, formatProvider); } diff --git a/Ical.Net/DataTypes/Duration.cs b/Ical.Net/DataTypes/Duration.cs index 6fd3dc070..b8ce43299 100644 --- a/Ical.Net/DataTypes/Duration.cs +++ b/Ical.Net/DataTypes/Duration.cs @@ -5,6 +5,7 @@ using System; using System.IO; +using Ical.Net.Evaluation; using Ical.Net.Serialization.DataTypes; namespace Ical.Net.DataTypes; diff --git a/Ical.Net/DataTypes/Period.cs b/Ical.Net/DataTypes/Period.cs index c6d295fe9..97c7b6f8a 100644 --- a/Ical.Net/DataTypes/Period.cs +++ b/Ical.Net/DataTypes/Period.cs @@ -4,6 +4,7 @@ // using System; +using Ical.Net.Evaluation; using Ical.Net.Serialization.DataTypes; namespace Ical.Net.DataTypes; @@ -78,7 +79,7 @@ public Period(CalDateTime start, CalDateTime? end = null) $"Start time ({start}) and end time ({end}) must both have a time or both be date-only."); // Although the timezones are the same, the start and end times may be in different DST offsets. - if (end != null && end.AsUtc < start.AsUtc) + if (end != null && end.AsUtc() < start.AsUtc()) throw new ArgumentException($"End time ({end}) as UTC must be greater than start time ({start}) as UTC.", nameof(end)); _startTime = start; @@ -261,7 +262,7 @@ public int CompareTo(Period? other) return 1; } - if (StartTime.AsUtc.Equals(other.StartTime.AsUtc)) + if (StartTime.AsUtc().Equals(other.StartTime.AsUtc())) { return 0; } diff --git a/Ical.Net/DataTypes/Trigger.cs b/Ical.Net/DataTypes/Trigger.cs index 21bf6d965..051833027 100644 --- a/Ical.Net/DataTypes/Trigger.cs +++ b/Ical.Net/DataTypes/Trigger.cs @@ -5,6 +5,7 @@ using System; using System.IO; +using Ical.Net.Evaluation; using Ical.Net.Serialization.DataTypes; namespace Ical.Net.DataTypes; diff --git a/Ical.Net/Evaluation/CalDateTimeExtensions.cs b/Ical.Net/Evaluation/CalDateTimeExtensions.cs new file mode 100644 index 000000000..944ea95e6 --- /dev/null +++ b/Ical.Net/Evaluation/CalDateTimeExtensions.cs @@ -0,0 +1,287 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Concurrent; +using System.Globalization; +using Ical.Net.DataTypes; +using Ical.Net.Utility; +using NodaTime; + +namespace Ical.Net.Evaluation; + +/// +/// This class belongs to the evaluation layer of the library. +/// It provides extension methods for the class. +/// +public static class CalDateTimeExtensions +{ + /// + /// This struct is used as a key for the cache. + /// It holds a weak reference to the object. + /// + private struct WeakCalDateTimeKey : IEquatable + { + private readonly WeakReference _weakRef; + private readonly int _hashCode; + + public WeakCalDateTimeKey(CalDateTime calDateTime) + { + _weakRef = new WeakReference(calDateTime); + _hashCode = calDateTime.GetHashCode(); + } + + public bool TryGetTarget(out CalDateTime? target) => _weakRef.TryGetTarget(out target); + + public bool Equals(WeakCalDateTimeKey other) + { + if (!_weakRef.TryGetTarget(out var thisTarget) || !other._weakRef.TryGetTarget(out var otherTarget)) + return false; + + return ReferenceEquals(thisTarget, otherTarget) || thisTarget.Equals(otherTarget); + } + + public override bool Equals(object? obj) => obj is WeakCalDateTimeKey other && Equals(other); + + public override int GetHashCode() => _hashCode; + } + + /// + /// The cache for storing resolved objects for objects. + /// This is a thread-safe dictionary that uses weak references to avoid memory leaks. + /// + private static readonly ConcurrentDictionary _cache = new(); + + /// + /// Stores a in the cache for the given . + /// + private static void AddToCache(CalDateTime calDateTime, ZonedDateTime zonedDateTime) + { + var key = new WeakCalDateTimeKey(calDateTime); + _cache[key] = zonedDateTime; + } + + /// + /// Resolves a to a , + /// using the cache if possible. + /// + public static ZonedDateTime? ResolveZonedDateTime(CalDateTime calDateTime) + { + var key = new WeakCalDateTimeKey(calDateTime); + + // Try to get from cache + if (_cache.TryGetValue(key, out var zoned)) + return zoned; + + // Compute and cache + ZonedDateTime? result = null; + if (calDateTime.IsUtc) + { + result = LocalDateTime.FromDateTime(calDateTime.Value).InZoneStrictly(DateTimeZone.Utc); + } + else if (!calDateTime.IsFloating) + { + var zone = DateUtil.GetZone(calDateTime.TzId!); + result = DateUtil.ToZonedDateTimeLeniently(calDateTime.Value, zone.Id); + } + + if (result.HasValue) + _cache[key] = result.Value; + + return result; + } + + /// + /// Removes dead entries from the cache. + /// + public static void CleanupCache() + { + foreach (var key in _cache.Keys) + { + if (!key.TryGetTarget(out _)) + _cache.TryRemove(key, out _); + } + } + + /// + /// Converts the date/time to UTC (Coordinated Universal Time) + /// If == + /// it means that the is considered as local time for every timezone: + /// The returned is unchanged, but with . + /// + public static DateTime AsUtc(this CalDateTime calDateTime) + => DateTime.SpecifyKind(ToTimeZone(calDateTime, CalDateTime.UtcTzId).Value, DateTimeKind.Utc); + + /// + /// Converts the to a date/time + /// within the specified timezone. + /// + /// If == + /// it means that the is considered as local time for every timezone: + /// The returned is unchanged and the is set as . + /// + /// The CalDateTime instance. + /// The target time zone ID, or null for floating. + /// A new CalDateTime in the specified time zone. + public static CalDateTime ToTimeZone(this CalDateTime calDateTime, string? otherTzId) + { + if (otherTzId is null) + return new CalDateTime(calDateTime.Value, null, calDateTime.HasTime); + + var zoned = ResolveZonedDateTime(calDateTime); + + ZonedDateTime converted; + if (calDateTime.IsFloating) + { + // Make sure, we properly fix the time if it doesn't exist in the target tz. + converted = DateUtil.ToZonedDateTimeLeniently(calDateTime.Value, otherTzId); + } + else + { + converted = otherTzId != calDateTime.TzId + ? zoned!.Value.WithZone(DateUtil.GetZone(otherTzId)) + : zoned!.Value; + } + var convCalDt = new CalDateTime(converted.ToDateTimeUnspecified(), otherTzId, calDateTime.HasTime); + AddToCache(convCalDt, converted); + return convCalDt; + } + + /// + /// Add the specified to this instance/>. + /// + /// + /// In correspondence to RFC5545, the weeks and day fields of a duration are considered nominal durations while the time fields are considered exact values. + /// + /// + /// Thrown when attempting to add a time span to a date-only instance, + /// and the time span is not a multiple of full days. + /// + public static CalDateTime Add(this CalDateTime calDateTime, DataTypes.Duration d) + { + if (!calDateTime.HasTime && d.HasTime) + { + throw new InvalidOperationException($"This instance represents a 'date-only' value '{calDateTime.ToString()}'. Only multiples of full days can be added to a 'date-only' instance, not '{d}'"); + } + + // RFC 5545 3.3.6: + // If the property permits, multiple "duration" values are specified by a COMMA-separated + // list of values.The format is based on the[ISO.8601.2004] complete representation basic + // format with designators for the duration of time.The format can represent nominal + // durations(weeks and days) and accurate durations(hours, minutes, and seconds). + // Note that unlike[ISO.8601.2004], this value type doesn't support the "Y" and "M" + // designators to specify durations in terms of years and months. + // + // The duration of a week or a day depends on its position in the calendar. In the case + // of discontinuities in the time scale, such as the change from standard time to daylight + // time and back, the computation of the exact duration requires the subtraction or + // addition of the change of duration of the discontinuity.Leap seconds MUST NOT be + // considered when computing an exact duration.When computing an exact duration, the + // greatest order time components MUST be added first, that is, the number of days MUST be + // added first, followed by the number of hours, number of minutes, and number of seconds. + + (TimeSpan? nominalPart, TimeSpan? exactPart) dt; + if (calDateTime.TzId is null) + dt = (d.ToTimeSpanUnspecified(), null); + else + dt = (d.HasDate ? d.DateAsTimeSpan : null, d.HasTime ? d.TimeAsTimeSpan : null); + + var newDateTime = calDateTime; + if (dt.nominalPart is not null) + newDateTime = new CalDateTime(newDateTime.Value.Add(dt.nominalPart.Value), calDateTime.TzId, calDateTime.HasTime); + + if (dt.exactPart is not null) + newDateTime = new CalDateTime(AsUtc(newDateTime).Add(dt.exactPart.Value), CalDateTime.UtcTzId, calDateTime.HasTime); + + if (calDateTime.TzId is not null) + // Convert to the original timezone even if already set to ensure we're not in a non-existing time. + newDateTime = ToTimeZone(newDateTime, calDateTime.TzId); + + return newDateTime; + } + + public static string ToString(this CalDateTime calDateTime, string? format, IFormatProvider? formatProvider) + { + formatProvider ??= CultureInfo.InvariantCulture; + var dateTimeOffset = + ResolveZonedDateTime(calDateTime)?.ToDateTimeOffset() + ?? DateUtil.ToZonedDateTimeLeniently(calDateTime.Value, calDateTime.TzId ?? string.Empty).ToDateTimeOffset(); + + // Use the .NET format options to format the DateTimeOffset + var tzIdString = calDateTime.TzId is not null ? $" {calDateTime.TzId}" : string.Empty; + + return calDateTime.HasTime + ? $"{dateTimeOffset.ToString(format, formatProvider)}{tzIdString}" + : $"{DateOnly.FromDateTime(dateTimeOffset.Date).ToString(format ?? "d", formatProvider)}{tzIdString}"; + } + + internal static CalDateTime Copy(this CalDateTime calDateTime) + { + var copy = new CalDateTime(calDateTime.Date, calDateTime.Time, calDateTime.TzId); + + if (calDateTime.IsFloating) + return copy; + + AddToCache(copy, AsZonedDateTime(calDateTime)); + return copy; + } + + /// + /// Returns the NodaTime ZonedDateTime for the given CalDateTime. + /// + /// Undefined for floating date/time. + internal static ZonedDateTime AsZonedDateTime(this CalDateTime calDateTime) => ResolveZonedDateTime(calDateTime) ?? throw new InvalidOperationException("Timezone not found."); + + /// Returns a new from subtracting the specified from to the value of this instance. + public static TimeSpan SubtractExact(this CalDateTime dt, CalDateTime other) => dt.AsUtc() - other.AsUtc(); + + /// + /// Returns a new from subtracting the specified from to the value of this instance. + /// + /// + public static DataTypes.Duration Subtract(this CalDateTime dt, CalDateTime other) + { + if (dt.TzId is not null) + return SubtractExact(dt, other).ToDurationExact(); + + if (dt.HasTime != other.HasTime) + throw new InvalidOperationException($"Trying to calculate the difference between dates of different types. An instance of type DATE cannot be subtracted from a DATE-TIME and vice versa: {dt.ToString()} - {other.ToString()}"); + + return (dt.Value - other.Value).ToDuration(); + } + + /// + public static CalDateTime AddYears(this CalDateTime dt, int years) + => new(dt.Date.AddYears(years), dt.Time, dt.TzId); + + /// + public static CalDateTime AddMonths(this CalDateTime dt, int months) + => new(dt.Date.AddMonths(months), dt.Time, dt.TzId); + + /// + public static CalDateTime AddDays(this CalDateTime dt, int days) + => new(dt.Date.AddDays(days), dt.Time, dt.TzId); + + /// + /// + /// Thrown when attempting to add a time span to a date-only instance, + /// and the time span is not a multiple of full days. + /// + public static CalDateTime AddHours(this CalDateTime calDateTime, int hours) => Add(calDateTime, DataTypes.Duration.FromHours(hours)); + + /// + /// + /// Thrown when attempting to add a time span to a date-only instance, + /// and the time span is not a multiple of full days. + /// + public static CalDateTime AddMinutes(this CalDateTime calDateTime, int minutes) => Add(calDateTime, DataTypes.Duration.FromMinutes(minutes)); + + /// + /// + /// Thrown when attempting to add a time span to a date-only instance, + /// and the time span is not a multiple of full days. + /// + public static CalDateTime AddSeconds(this CalDateTime calDateTime, int seconds) => Add(calDateTime,DataTypes.Duration.FromSeconds(seconds)); +} diff --git a/Ical.Net/Utility/DateUtil.cs b/Ical.Net/Utility/DateUtil.cs index e961aa4f0..d49722fb5 100644 --- a/Ical.Net/Utility/DateUtil.cs +++ b/Ical.Net/Utility/DateUtil.cs @@ -5,6 +5,7 @@ using System; using Ical.Net.DataTypes; +using Ical.Net.Evaluation; using NodaTime; namespace Ical.Net.Utility; @@ -80,7 +81,7 @@ public static ZonedDateTime ToZonedDateTimeLeniently(DateTime dateTime, string t // Thus, TZID=America/New_York:20070311T023000 indicates March 11, // 2007 at 3:30 A.M. EDT (UTC-04:00), one hour after 1:30 A.M. EST // (UTC-05:00). - var lenientZonedDateTime = localDt.InZoneLeniently(zone).WithZone(zone); + var lenientZonedDateTime = localDt.InZoneLeniently(zone); return lenientZonedDateTime; } From e127d2722447e21d36371f9a8d9423399c37be27 Mon Sep 17 00:00:00 2001 From: axunonb Date: Thu, 22 May 2025 18:12:28 +0200 Subject: [PATCH 3/3] Replace `ConcurrentDictionary` with `ConditionalWeakTable` This is the link for `CalDateTime` objects to is `ZonedDateTime` representation after the first call of `CalDateTimeExtensions.ToTimeZone`. All subsequent conversions use the cached `ZonedDateTime`. Costs for this link are very low. --- Ical.Net.Tests/RecurrenceTests.cs | 2 - Ical.Net/Evaluation/CalDateTimeExtensions.cs | 74 ++++++-------------- 2 files changed, 22 insertions(+), 54 deletions(-) diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 6af6e7563..eafb5075c 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -4115,8 +4115,6 @@ public void Disallowed_Recurrence_RangeChecks_Should_Throw() [Test] public void AmbiguousLocalTime_WithShortDurationOfRecurrence() { - CalDateTimeExtensions.CleanupCache(); - // Short recurrence falls into an ambiguous local time // for the end time of the second occurrence because // of DST transition on 2025-10-25 03:00 diff --git a/Ical.Net/Evaluation/CalDateTimeExtensions.cs b/Ical.Net/Evaluation/CalDateTimeExtensions.cs index 944ea95e6..ea8553947 100644 --- a/Ical.Net/Evaluation/CalDateTimeExtensions.cs +++ b/Ical.Net/Evaluation/CalDateTimeExtensions.cs @@ -4,8 +4,8 @@ // using System; -using System.Collections.Concurrent; using System.Globalization; +using System.Runtime.CompilerServices; using Ical.Net.DataTypes; using Ical.Net.Utility; using NodaTime; @@ -18,62 +18,44 @@ namespace Ical.Net.Evaluation; ///
public static class CalDateTimeExtensions { - /// - /// This struct is used as a key for the cache. - /// It holds a weak reference to the object. - /// - private struct WeakCalDateTimeKey : IEquatable + private sealed class ZonedDateTimeBox(ZonedDateTime value) { - private readonly WeakReference _weakRef; - private readonly int _hashCode; - - public WeakCalDateTimeKey(CalDateTime calDateTime) - { - _weakRef = new WeakReference(calDateTime); - _hashCode = calDateTime.GetHashCode(); - } - - public bool TryGetTarget(out CalDateTime? target) => _weakRef.TryGetTarget(out target); - - public bool Equals(WeakCalDateTimeKey other) - { - if (!_weakRef.TryGetTarget(out var thisTarget) || !other._weakRef.TryGetTarget(out var otherTarget)) - return false; - - return ReferenceEquals(thisTarget, otherTarget) || thisTarget.Equals(otherTarget); - } - - public override bool Equals(object? obj) => obj is WeakCalDateTimeKey other && Equals(other); - - public override int GetHashCode() => _hashCode; + public ZonedDateTime Value { get; } = value; } /// - /// The cache for storing resolved objects for objects. + /// The cache for storing resolved objects for objects /// This is a thread-safe dictionary that uses weak references to avoid memory leaks. + /// Dead keys are automatically removed from the cache. + /// Key and Value must be reference types, so we use to box the value. + /// + /// If a is instantiated from a lenient timezone conversion, this leniently + /// determined must not be converted again. + /// Lenient conversions resolve non-existing or ambiguous times caused by DST transitions. + /// + /// Therefore, when converting from one timezone to another, the conversion is done with the + /// object created at the time of (first) conversion, + /// which is stored in the cache. /// - private static readonly ConcurrentDictionary _cache = new(); + private static readonly ConditionalWeakTable _cache = new(); /// /// Stores a in the cache for the given . /// private static void AddToCache(CalDateTime calDateTime, ZonedDateTime zonedDateTime) - { - var key = new WeakCalDateTimeKey(calDateTime); - _cache[key] = zonedDateTime; - } + => _cache.Add(calDateTime, new ZonedDateTimeBox(zonedDateTime)); /// /// Resolves a to a , /// using the cache if possible. /// - public static ZonedDateTime? ResolveZonedDateTime(CalDateTime calDateTime) + private static ZonedDateTime? ResolveZonedDateTime(CalDateTime calDateTime) { - var key = new WeakCalDateTimeKey(calDateTime); - // Try to get from cache - if (_cache.TryGetValue(key, out var zoned)) - return zoned; + if (_cache.TryGetValue(calDateTime, out var zoned)) + { + return zoned.Value; + } // Compute and cache ZonedDateTime? result = null; @@ -88,23 +70,11 @@ private static void AddToCache(CalDateTime calDateTime, ZonedDateTime zonedDateT } if (result.HasValue) - _cache[key] = result.Value; + AddToCache(calDateTime, result.Value); return result; } - /// - /// Removes dead entries from the cache. - /// - public static void CleanupCache() - { - foreach (var key in _cache.Keys) - { - if (!key.TryGetTarget(out _)) - _cache.TryRemove(key, out _); - } - } - /// /// Converts the date/time to UTC (Coordinated Universal Time) /// If ==