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 8cb4b1725..eafb5075c 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[] @@ -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.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 f653bcd48..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; @@ -218,12 +216,11 @@ 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); + _timeOnly = calDt._timeOnly; _tzId = calDt._tzId; } @@ -249,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) @@ -300,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 => 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.
@@ -436,164 +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 - { - var zonedOriginal = DateUtil.ToZonedDateTimeLeniently(Value, TzId!); - converted = zonedOriginal.WithZone(DateUtil.GetZone(otherTzId)); - } - - return converted.Zone == DateTimeZone.Utc - ? new CalDateTime(converted.ToDateTimeUtc(), UtcTzId) - : new CalDateTime(converted.ToDateTimeUnspecified(), 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); - - /// - 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 . /// @@ -645,26 +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 = 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}"; - } + 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 842880a18..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; @@ -77,8 +78,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; @@ -260,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..ea8553947 --- /dev/null +++ b/Ical.Net/Evaluation/CalDateTimeExtensions.cs @@ -0,0 +1,257 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +using System; +using System.Globalization; +using System.Runtime.CompilerServices; +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 +{ + private sealed class ZonedDateTimeBox(ZonedDateTime value) + { + public ZonedDateTime Value { get; } = value; + } + + /// + /// 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 ConditionalWeakTable _cache = new(); + + /// + /// Stores a in the cache for the given . + /// + private static void AddToCache(CalDateTime calDateTime, ZonedDateTime zonedDateTime) + => _cache.Add(calDateTime, new ZonedDateTimeBox(zonedDateTime)); + + /// + /// Resolves a to a , + /// using the cache if possible. + /// + private static ZonedDateTime? ResolveZonedDateTime(CalDateTime calDateTime) + { + // Try to get from cache + if (_cache.TryGetValue(calDateTime, out var zoned)) + { + return zoned.Value; + } + + // 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) + AddToCache(calDateTime, result.Value); + + return result; + } + + /// + /// 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; }