From f2f651e771a4eb82e3c5db077579beebb07d7fd3 Mon Sep 17 00:00:00 2001 From: axunonb Date: Mon, 4 Nov 2024 22:35:03 +0100 Subject: [PATCH] Refactor CalDateTime and DateTimeSerializer CalDateTime * to use DateOnly and TimeOnly types * Added SetValue(DateOnly date, TimeOnly? time, DateTimeKind kind) to set the value of the date and time properly. * Refactored `ToString` overloads to convert the date and time to a string representation. * marked some properties as obsolete. * Updated related tests and methods to accommodate these changes. * Increase code coverage > 90% DateTimeSerializer * Added validation in the `SerializeToString` method to ensure the TZID parameter is correctly handled based on whether the date-time is in UTC. * Improved the Deserialize method to handle both full date-time and date-only strings, initializing the DateOnly and TimeOnly parts accordingly. * Increase code coverage > 90% Added new package reference `Portable.System.DateTimeOnly` for netstandard2.0 and netstandard2.1 for compatibility with net6.0 and later. Fixes #630 --- Ical.Net.Tests/AlarmTest.cs | 2 +- Ical.Net.Tests/CalDateTimeTests.cs | 97 +++-- Ical.Net.Tests/DeserializationTests.cs | 19 +- Ical.Net.Tests/RecurrenceTests.cs | 4 +- Ical.Net.Tests/SimpleDeserializationTests.cs | 21 +- Ical.Net/CalendarComponents/CalendarEvent.cs | 30 +- Ical.Net/CalendarComponents/VTimeZone.cs | 5 +- Ical.Net/DataTypes/CalDateTime.cs | 334 ++++++++++-------- Ical.Net/DataTypes/Trigger.cs | 4 +- .../Evaluation/RecurrencePatternEvaluator.cs | 7 +- Ical.Net/Evaluation/RecurrenceUtil.cs | 2 +- Ical.Net/Ical.Net.csproj | 3 + .../DataTypes/DateTimeSerializer.cs | 78 ++-- .../DataTypes/RecurrencePatternSerializer.cs | 2 +- 14 files changed, 345 insertions(+), 263 deletions(-) diff --git a/Ical.Net.Tests/AlarmTest.cs b/Ical.Net.Tests/AlarmTest.cs index 1974e2d0..fc91c8b8 100644 --- a/Ical.Net.Tests/AlarmTest.cs +++ b/Ical.Net.Tests/AlarmTest.cs @@ -1,4 +1,4 @@ -using Ical.Net.DataTypes; +using Ical.Net.DataTypes; using NUnit.Framework; using System; using System.Collections.Generic; diff --git a/Ical.Net.Tests/CalDateTimeTests.cs b/Ical.Net.Tests/CalDateTimeTests.cs index 106a2745..060ccb18 100644 --- a/Ical.Net.Tests/CalDateTimeTests.cs +++ b/Ical.Net.Tests/CalDateTimeTests.cs @@ -1,11 +1,11 @@ -using System.Collections.Generic; -using System.Globalization; -using Ical.Net.CalendarComponents; +using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using NUnit.Framework; using System; using System.Collections; using System.Collections.Generic; +using System.Drawing; +using System.Globalization; namespace Ical.Net.Tests { @@ -13,6 +13,7 @@ public class CalDateTimeTests { private static readonly DateTime _now = DateTime.Now; private static readonly DateTime _later = _now.AddHours(1); + private static CalendarEvent GetEventWithRecurrenceRules(string tzId) { var dailyForFiveDays = new RecurrencePattern(FrequencyType.Daily, 1) @@ -34,7 +35,7 @@ private static CalendarEvent GetEventWithRecurrenceRules(string tzId) public void ToTimeZoneTests(CalendarEvent calendarEvent, string targetTimeZone) { var startAsUtc = calendarEvent.Start.AsUtc; - + var convertedStart = calendarEvent.Start.ToTimeZone(targetTimeZone); var convertedAsUtc = convertedStart.AsUtc; @@ -91,13 +92,15 @@ public static IEnumerable AsDateTimeOffsetTestCases() var convertedToNySummer = new CalDateTime(summerDate, "UTC"); convertedToNySummer.TzId = nyTzId; yield return new TestCaseData(convertedToNySummer) - .SetName("Summer UTC DateTime converted to NY time zone by setting TzId returns a DateTimeOffset with UTC-4") + .SetName( + "Summer UTC DateTime converted to NY time zone by setting TzId returns a DateTimeOffset with UTC-4") .Returns(new DateTimeOffset(summerDate, nySummerOffset)); var noTz = new CalDateTime(summerDate); var currentSystemOffset = TimeZoneInfo.Local.GetUtcOffset(summerDate); yield return new TestCaseData(noTz) - .SetName($"Summer DateTime with no time zone information returns the system-local's UTC offset ({currentSystemOffset})") + .SetName( + $"Summer DateTime with no time zone information returns the system-local's UTC offset ({currentSystemOffset})") .Returns(new DateTimeOffset(summerDate, currentSystemOffset)); } @@ -154,34 +157,70 @@ public static IEnumerable DateTimeKindOverrideTestCases() } [Test, TestCaseSource(nameof(ToStringTestCases))] - public string ToStringTests(DateTime localDateTime, string format, IFormatProvider formatProvider) - => new CalDateTime(localDateTime, "Pacific/Auckland").ToString(format, formatProvider); + public string ToStringTests(CalDateTime calDateTime, string format, IFormatProvider formatProvider) + => calDateTime.ToString(format, formatProvider); - public static IEnumerable ToStringTestCases() + public static IEnumerable ToStringTestCases() { - yield return new TestCaseData(new DateTime(2022, 8, 30, 10, 30, 0), null, null) - .Returns($"{new DateTime(2022, 8, 30, 10, 30, 0)} Pacific/Auckland") - .SetName("Date and time with current culture formatting returns string using BCL current culture formatted date and time"); - - yield return new TestCaseData(new DateTime(2022, 8, 30), null, null) - .Returns($"{new DateTime(2022, 8, 30):d} Pacific/Auckland") - .SetName("Date only with current culture formatting returns string using BCL current culture formatted date"); - - yield return new TestCaseData(new DateTime(2022, 8, 30, 10, 30, 0), "o", null) - .Returns("2022-08-30T10:30:00.0000000+12:00 Pacific/Auckland") - .SetName("Date and time formatted using format string with no culture returns string using BCL formatter"); + yield return new TestCaseData(new CalDateTime(2024, 8, 30, 10, 30, 0, tzId: "Pacific/Auckland"), "O", null) + .Returns("2024-08-30T10:30:00.0000000+12:00 Pacific/Auckland") + .SetName("Date and time with 'O' format arg, default culture"); + + yield return new TestCaseData(new CalDateTime(2024, 8, 30, tzId: "Pacific/Auckland"), "O", null) + .Returns("08/30/2024 Pacific/Auckland") + .SetName("Date only with 'O' format arg, default culture"); + + yield return new TestCaseData(new CalDateTime(2024, 8, 30, 10, 30, 0, tzId: "Pacific/Auckland"), "O", + CultureInfo.GetCultureInfo("fr-FR")) + .Returns("2024-08-30T10:30:00.0000000+12:00 Pacific/Auckland") + .SetName("Date and time with 'O' format arg, French culture"); + + yield return new TestCaseData(new CalDateTime(2024, 8, 30, 10, 30, 0, tzId: "Pacific/Auckland"), + "yyyy-MM-dd", CultureInfo.InvariantCulture) + .Returns("2024-08-30 Pacific/Auckland") + .SetName("Date and time with custom format, default culture"); + + yield return new TestCaseData(new CalDateTime(2024, 8, 30, 10, 30, 0, tzId: "Pacific/Auckland"), + "MM/dd/yyyy HH:mm:ss", CultureInfo.GetCultureInfo("FR")) + .Returns("08/30/2024 10:30:00 Pacific/Auckland") + .SetName("Date and time with format and 'FR' CultureInfo"); + + yield return new TestCaseData(new CalDateTime(2024, 8, 30, tzId: "Pacific/Auckland"), null, + CultureInfo.GetCultureInfo("IT")) + .Returns("30/08/2024 Pacific/Auckland") + .SetName("Date only with 'IT' CultureInfo and default format arg"); + } - yield return new TestCaseData(new DateTime(2022, 8, 30), "o", null) - .Returns("2022-08-30T00:00:00.0000000+12:00 Pacific/Auckland") - .SetName("Date and time formatted using format string with no culture returns string using BCL formatter"); + [Test] + public void Simple_PropertyAndMethod_Tests() + { + var dt = new DateTime(2025, 1, 2, 10, 20, 30, DateTimeKind.Utc); + var c = new CalDateTime(dt, tzId: "Europe/Berlin"); - yield return new TestCaseData(new DateTime(2022, 8, 30, 10, 30, 0), null, CultureInfo.InvariantCulture) - .Returns("08/30/2022 10:30:00 Pacific/Auckland") - .SetName("Date and time with format provider"); + var c2 = new CalDateTime(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, c.TzId, null); + var c3 = new CalDateTime(new DateOnly(dt.Year, dt.Month, dt.Day), + new TimeOnly(dt.Hour, dt.Minute, dt.Second), c.TzId); - yield return new TestCaseData(new DateTime(2022, 8, 30), null, CultureInfo.InvariantCulture) - .Returns("08/30/2022 Pacific/Auckland") - .SetName("Date only with current format provider"); + Assert.Multiple(() => + { + Assert.That(c2.Ticks, Is.EqualTo(c3.Ticks)); + Assert.That(c2.TzId, Is.EqualTo(c3.TzId)); + Assert.That(CalDateTime.UtcNow.Value.Kind, Is.EqualTo(DateTimeKind.Utc)); + Assert.That(c.Millisecond, Is.EqualTo(0)); + Assert.That(c.Ticks, Is.EqualTo(dt.Ticks)); + Assert.That(c.DayOfYear, Is.EqualTo(dt.DayOfYear)); + Assert.That(c.TimeOfDay, Is.EqualTo(dt.TimeOfDay)); + Assert.That(c.Subtract(TimeSpan.FromSeconds(dt.Second)).Value.Second, Is.EqualTo(0)); + Assert.That(c.AddYears(1).Value, Is.EqualTo(dt.AddYears(1))); + Assert.That(c.AddMonths(1).Value, Is.EqualTo(dt.AddMonths(1))); + Assert.That(c.AddMinutes(1).Value, Is.EqualTo(dt.AddMinutes(1))); + Assert.That(c.AddSeconds(15).Value, Is.EqualTo(dt.AddSeconds(15))); + Assert.That(c.AddMilliseconds(100).Value, Is.EqualTo(dt.AddMilliseconds(0))); // truncated + Assert.That(c.AddMilliseconds(1000).Value, Is.EqualTo(dt.AddMilliseconds(1000))); + Assert.That(c.AddTicks(1).Value, Is.EqualTo(dt.AddTicks(0))); // truncated + Assert.That(c.AddTicks(TimeSpan.FromMinutes(1).Ticks).Value, Is.EqualTo(dt.AddTicks(TimeSpan.FromMinutes(1).Ticks))); + Assert.That(c.ToString("dd.MM.yyyy"), Is.EqualTo("02.01.2025 Europe/Berlin")); + }); } } } diff --git a/Ical.Net.Tests/DeserializationTests.cs b/Ical.Net.Tests/DeserializationTests.cs index e32d6fb0..b4bf0403 100644 --- a/Ical.Net.Tests/DeserializationTests.cs +++ b/Ical.Net.Tests/DeserializationTests.cs @@ -448,22 +448,13 @@ public void Transparency2() Assert.That(evt.Transparency, Is.EqualTo(TransparencyType.Transparent)); } - /// - /// Tests that DateTime values that are out-of-range are still parsed correctly - /// and set to the closest representable date/time in .NET. - /// [Test] - public void DateTime1() + public void DateTime1_Unrepresentable_DateTimeArgs_ShouldThrow() { - var iCal = Calendar.Load(IcsFiles.DateTime1); - Assert.That(iCal.Events, Has.Count.EqualTo(6)); - - var evt = iCal.Events["nc2o66s0u36iesitl2l0b8inn8@google.com"]; - Assert.That(evt, Is.Not.Null); - - // The "Created" date is out-of-bounds. It should be coerced to the - // closest representable date/time. - Assert.That(evt.Created.Value, Is.EqualTo(DateTime.MinValue)); + Assert.That(() => + { + _ = Calendar.Load(IcsFiles.DateTime1); + }, Throws.Exception.TypeOf()); } [Test] diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 03998218..fd8ef298 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -2828,7 +2828,7 @@ public void UsHolidays() [TestCase("SECONDLY", 1, true)] [TestCase("MINUTELY", 60, true)] [TestCase("HOURLY", 3600, true)] - [TestCase("DAILY", 24*3600, false)] + [TestCase("DAILY", 24*3600, true)] public void Evaluate1(string freq, int secsPerInterval, bool hasTime) { Calendar cal = new Calendar(); @@ -2837,7 +2837,7 @@ public void Evaluate1(string freq, int secsPerInterval, bool hasTime) evt.Summary = "Event summary"; // Start at midnight, UTC time - evt.Start = new CalDateTime(DateTime.SpecifyKind(DateTime.Today, DateTimeKind.Utc)) { HasTime = false }; + evt.Start = new CalDateTime(DateTime.SpecifyKind(DateTime.Today, DateTimeKind.Utc)); // { HasTime = false }; // This case (DTSTART of type DATE and FREQ=MINUTELY) is undefined in RFC 5545. // ical.net handles the case by pretending DTSTART has the time set to midnight. diff --git a/Ical.Net.Tests/SimpleDeserializationTests.cs b/Ical.Net.Tests/SimpleDeserializationTests.cs index ee72332a..a7ebbe1a 100644 --- a/Ical.Net.Tests/SimpleDeserializationTests.cs +++ b/Ical.Net.Tests/SimpleDeserializationTests.cs @@ -451,22 +451,15 @@ public void Transparency2() Assert.That(evt.Transparency, Is.EqualTo(TransparencyType.Transparent)); } - /// - /// Tests that DateTime values that are out-of-range are still parsed correctly - /// and set to the closest representable date/time in .NET. - /// [Test, Category("Deserialization")] - public void DateTime1() + public void DateTime1_Unrepresentable_DateTimeArgs_ShouldThrow() { - var iCal = SimpleDeserializer.Default.Deserialize(new StringReader(IcsFiles.DateTime1)).Cast().Single(); - Assert.That(iCal.Events, Has.Count.EqualTo(6)); - - var evt = iCal.Events["nc2o66s0u36iesitl2l0b8inn8@google.com"]; - Assert.That(evt, Is.Not.Null); - - // The "Created" date is out-of-bounds. It should be coerced to the - // closest representable date/time. - Assert.That(evt.Created.Value, Is.EqualTo(DateTime.MinValue)); + Assert.That(() => + { + _ = SimpleDeserializer.Default.Deserialize(new StringReader(IcsFiles.DateTime1)) + .Cast() + .Single(); + }, Throws.Exception.TypeOf()); } [Test, Category("Deserialization"), Ignore("Ignore until @thoemy commits the EventStatus.ics file")] diff --git a/Ical.Net/CalendarComponents/CalendarEvent.cs b/Ical.Net/CalendarComponents/CalendarEvent.cs index 272c9f9a..b43cc9fb 100644 --- a/Ical.Net/CalendarComponents/CalendarEvent.cs +++ b/Ical.Net/CalendarComponents/CalendarEvent.cs @@ -126,11 +126,37 @@ public virtual bool IsAllDay // has a time value. if (Start != null) { - Start.HasTime = !value; + if (value) + { + // Ensure time part is not set + var dt = new CalDateTime(Start); + dt.SetValue(DateOnly.FromDateTime(Start.Value), null, Start.Value.Kind); + Start = dt; + } + else + { + // Ensure time part is set + var dt = new CalDateTime(Start); + dt.SetValue(DateOnly.FromDateTime(Start.Value), TimeOnly.FromDateTime(Start.Value), Start.Value.Kind); + Start = dt; + } } if (End != null) { - End.HasTime = !value; + if (value) + { + // Ensure time part is not set + var dt = new CalDateTime(End); + dt.SetValue(DateOnly.FromDateTime(End.Value), null, End.Value.Kind); + End = dt; + } + else + { + // Ensure time part is set + var dt = new CalDateTime(End); + dt.SetValue(DateOnly.FromDateTime(End.Value), TimeOnly.FromDateTime(End.Value), End.Value.Kind); + End = dt; + } } if (value && Start != null && End != null && Equals(Start.Date, End.Date)) diff --git a/Ical.Net/CalendarComponents/VTimeZone.cs b/Ical.Net/CalendarComponents/VTimeZone.cs index 24b3ca5f..f52f8965 100644 --- a/Ical.Net/CalendarComponents/VTimeZone.cs +++ b/Ical.Net/CalendarComponents/VTimeZone.cs @@ -1,4 +1,4 @@ -using Ical.Net.DataTypes; +using Ical.Net.DataTypes; using Ical.Net.Proxies; using Ical.Net.Utility; using NodaTime; @@ -177,7 +177,7 @@ private static VTimeZoneInfo CreateTimeZoneInfo(List matchedInterv timeZoneInfo.TimeZoneName = oldestInterval.Name; var start = oldestInterval.IsoLocalStart.ToDateTimeUnspecified() + delta; - timeZoneInfo.Start = new CalDateTime(start) { HasTime = true }; + timeZoneInfo.Start = new CalDateTime(start); if (isRRule) { @@ -244,7 +244,6 @@ private static void PopulateTimeZoneInfoRecurrenceDates(VTimeZoneInfo tzi, List< continue; } - date.HasTime = true; periodList.Add(date); tzi.RecurrenceDates.Add(periodList); } diff --git a/Ical.Net/DataTypes/CalDateTime.cs b/Ical.Net/DataTypes/CalDateTime.cs index 3418149b..ca8130b4 100644 --- a/Ical.Net/DataTypes/CalDateTime.cs +++ b/Ical.Net/DataTypes/CalDateTime.cs @@ -2,6 +2,7 @@ using Ical.Net.Utility; using NodaTime; using System; +using System.Globalization; using System.IO; namespace Ical.Net.DataTypes @@ -10,7 +11,7 @@ namespace Ical.Net.DataTypes /// The iCalendar equivalent of the .NET class. /// /// In addition to the features of the class, the - /// class handles time zone differences, and integrates seamlessly into the iCalendar framework. + /// class handles time zones, and integrates seamlessly into the iCalendar framework. /// /// public sealed class CalDateTime : EncodableDataType, IDateTime @@ -19,17 +20,26 @@ public sealed class CalDateTime : EncodableDataType, IDateTime public static CalDateTime Today => new CalDateTime(DateTime.Today); - private bool _hasDate; - private bool _hasTime; + public static CalDateTime UtcNow => new CalDateTime(DateTime.UtcNow); + private DateOnly? _dateOnly; + private TimeOnly? _timeOnly; + + /// + /// This constructor is required for the SerializerFactory to work. + /// public CalDateTime() { } public CalDateTime(IDateTime value) { - Initialize(value.Value, value.TzId, null); + if (value.HasTime) + Initialize(new DateOnly(value.Year, value.Month, value.Day), new TimeOnly(value.Hour, value.Minute, value.Second), value.Date.Kind, value.TzId, null); + else + Initialize(new DateOnly(value.Year, value.Month, value.Day), null, value.Date.Kind, value.TzId, null); } - public CalDateTime(DateTime value) : this(value, null) { } + public CalDateTime(DateTime value) : this(value, value.Kind == DateTimeKind.Utc ? "UTC" : null) + { } /// /// Specifying a `tzId` value will override `value`'s `DateTimeKind` property. If the time zone specified is UTC, the underlying `DateTimeKind` will be @@ -38,29 +48,38 @@ public CalDateTime(DateTime value) : this(value, null) { } /// public CalDateTime(DateTime value, string tzId) { - Initialize(value, tzId, null); + Initialize(new DateOnly(value.Year, value.Month, value.Day), new TimeOnly(value.Hour, value.Minute, value.Second), value.Date.Kind, tzId, null); } public CalDateTime(int year, int month, int day, int hour, int minute, int second) { - Initialize(year, month, day, hour, minute, second, null, null); - HasTime = true; + Initialize(new DateOnly(year, month, day), new TimeOnly(hour, minute, second), DateTimeKind.Unspecified, null, null); } public CalDateTime(int year, int month, int day, int hour, int minute, int second, string tzId) { - Initialize(year, month, day, hour, minute, second, tzId, null); - HasTime = true; + Initialize(new DateOnly(year, month, day), new TimeOnly(hour, minute, second), DateTimeKind.Unspecified, tzId, null); } public CalDateTime(int year, int month, int day, int hour, int minute, int second, string tzId, Calendar cal) { - Initialize(year, month, day, hour, minute, second, tzId, cal); - HasTime = true; + Initialize(new DateOnly(year, month, day), new TimeOnly(hour, minute, second), DateTimeKind.Unspecified, tzId, cal); + } + + public CalDateTime(int year, int month, int day) + { + Initialize(new DateOnly(year, month, day), null, DateTimeKind.Unspecified, null, null); } - public CalDateTime(int year, int month, int day) : this(year, month, day, 0, 0, 0) { } - public CalDateTime(int year, int month, int day, string tzId) : this(year, month, day, 0, 0, 0, tzId) { } + public CalDateTime(int year, int month, int day, string tzId) + { + Initialize(new DateOnly(year, month, day), null, DateTimeKind.Unspecified, tzId, null); + } + + public CalDateTime(DateOnly date, TimeOnly time, string tzId = null) + { + Initialize(date, time, DateTimeKind.Unspecified, tzId, null); + } public CalDateTime(string value) { @@ -68,55 +87,58 @@ public CalDateTime(string value) CopyFrom(serializer.Deserialize(new StringReader(value)) as ICopyable); } - private void Initialize(int year, int month, int day, int hour, int minute, int second, string tzId, Calendar cal) + /// + /// Sets the date/time for so that it + /// can be used as a date-only or a date-time value. + /// + /// + /// + /// + public void SetValue(DateOnly date, TimeOnly? time, DateTimeKind kind) { - Initialize(CoerceDateTime(year, month, day, hour, minute, second, DateTimeKind.Local), tzId, cal); + _value = time.HasValue + ? new DateTime(date.Year, date.Month, date.Day, time.Value.Hour, time.Value.Minute, time.Value.Second, kind) + : new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, kind); + + _dateOnly = date; + _timeOnly = time; } - private void Initialize(DateTime value, string tzId, Calendar cal) + private void Initialize(DateOnly date, TimeOnly? time, DateTimeKind kind, string tzId, Calendar cal) { - if (!string.IsNullOrWhiteSpace(tzId) && !tzId.Equals("UTC", StringComparison.OrdinalIgnoreCase)) + _dateOnly = date; + _timeOnly = time; + + if ((!string.IsNullOrWhiteSpace(tzId) && !tzId.Equals("UTC", StringComparison.OrdinalIgnoreCase)) + || (string.IsNullOrEmpty(tzId) && kind == DateTimeKind.Local)) { // Definitely local - value = DateTime.SpecifyKind(value, DateTimeKind.Local); TzId = tzId; + + _value = time.HasValue + ? new DateTime(date.Year, date.Month, date.Day, time.Value.Hour, time.Value.Minute, time.Value.Second, DateTimeKind.Local) + : new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Local); } - else if (string.Equals("UTC", tzId, StringComparison.OrdinalIgnoreCase) || value.Kind == DateTimeKind.Utc) + else if (string.Equals("UTC", tzId, StringComparison.OrdinalIgnoreCase) || kind == DateTimeKind.Utc) { - // Probably UTC - value = DateTime.SpecifyKind(value, DateTimeKind.Utc); + // It is UTC TzId = "UTC"; - } - - Value = new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, value.Kind); - HasDate = true; - HasTime = value.Second != 0 || value.Minute != 0 || value.Hour != 0; - AssociatedObject = cal; - } - - private DateTime CoerceDateTime(int year, int month, int day, int hour, int minute, int second, DateTimeKind kind) - { - var dt = DateTime.MinValue; - // NOTE: determine if a date/time value exceeds the representable date/time values in .NET. - // If so, let's automatically adjust the date/time to compensate. - // FIXME: should we have a parsing setting that will throw an exception - // instead of automatically adjusting the date/time value to the - // closest representable date/time? - try + _value = time.HasValue + ? new DateTime(date.Year, date.Month, date.Day, time.Value.Hour, time.Value.Minute, time.Value.Second, DateTimeKind.Utc) + : new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Utc); + } + else { - if (year > 9999) - { - dt = DateTime.MaxValue; - } - else if (year > 0) - { - dt = new DateTime(year, month, day, hour, minute, second, kind); - } + // Unspecified + TzId = string.Empty; + + _value = time.HasValue + ? new DateTime(date.Year, date.Month, date.Day, time.Value.Hour, time.Value.Minute, time.Value.Second, DateTimeKind.Unspecified) + : new DateTime(date.Year, date.Month, date.Day, 0, 0, 0, DateTimeKind.Unspecified); } - catch { } - return dt; + AssociatedObject = cal; } public override ICalendarObject AssociatedObject @@ -136,16 +158,20 @@ public override void CopyFrom(ICopyable obj) { base.CopyFrom(obj); - var dt = obj as IDateTime; - if (dt == null) + if (obj is not IDateTime dt) { return; } + if (dt is CalDateTime calDt) + { + // Maintain the private date/time only flags form the original object + _dateOnly = calDt._dateOnly; + _timeOnly = calDt._timeOnly; + } + + // Copy the underlying DateTime value and time zone ID _value = dt.Value; - _hasDate = dt.HasDate; - _hasTime = dt.HasTime; - // String assignments create new instances TzId = dt.TzId; AssociateWith(dt); @@ -154,7 +180,7 @@ public override void CopyFrom(ICopyable obj) public bool Equals(CalDateTime other) => this == other; - public override bool Equals(object other) + public override bool Equals(object? other) => other is IDateTime && (CalDateTime)other == this; public override int GetHashCode() @@ -238,7 +264,6 @@ public DateTime AsSystemLocal } } - private DateTime _asUtc = DateTime.MinValue; /// /// Returns a representation of the DateTime in Coordinated Universal Time (UTC) /// @@ -246,67 +271,106 @@ public DateTime AsUtc { get { - if (_asUtc == DateTime.MinValue) + // In order of weighting: + // 1) Specified TzId + // 2) Value having a DateTimeKind.Utc + // 3) Use the OS's time zone + DateTime asUtc; + + if (!string.IsNullOrWhiteSpace(TzId)) + { + var asLocal = DateUtil.ToZonedDateTimeLeniently(Value, TzId); + return asLocal.ToDateTimeUtc(); + } + + if (IsUtc || Value.Kind == DateTimeKind.Utc) { - // In order of weighting: - // 1) Specified TzId - // 2) Value having a DateTimeKind.Utc - // 3) Use the OS's time zone - - if (!string.IsNullOrWhiteSpace(TzId)) - { - var asLocal = DateUtil.ToZonedDateTimeLeniently(Value, TzId); - _asUtc = asLocal.ToDateTimeUtc(); - } - else if (IsUtc || Value.Kind == DateTimeKind.Utc) - { - _asUtc = DateTime.SpecifyKind(Value, DateTimeKind.Utc); - } - else - { - _asUtc = DateTime.SpecifyKind(Value, DateTimeKind.Local).ToUniversalTime(); - } + asUtc = DateTime.SpecifyKind(Value, DateTimeKind.Utc); + return asUtc; } - return _asUtc; + + asUtc = DateTime.SpecifyKind(Value, DateTimeKind.Local).ToUniversalTime(); + return asUtc; } } private DateTime _value; + + /// + /// Gets/sets the underlying DateTime value stored. + /// Use instead. + /// public DateTime Value { get => _value; + + [Obsolete("This setter is depreciated and will be removed in a future version. Use SetValue instead.", false)] set { + // Kind must be checked in addition to the value, + // as the value can be the same but the Kind different. if (_value == value && _value.Kind == value.Kind) { return; } - _asUtc = DateTime.MinValue; + // Maintain the initial date/time only flags + // if the date/time parts are unchanged. + // This is a temporary workaround. + + if (value.Date != _value.Date) + _dateOnly = DateOnly.FromDateTime(value); + + if (value.TimeOfDay != _value.TimeOfDay) + _timeOnly = TimeOnly.FromDateTime(value); + _value = value; } } + /// + /// Returns true if the underlying DateTime value is in UTC. + /// public bool IsUtc => _value.Kind == DateTimeKind.Utc; + /// + /// Returns true if the underlying DateTime has a 'date' part (year, month, day), + /// otherwise the DateTime is considered a 'time' only. + /// public bool HasDate { - get => _hasDate; - set => _hasDate = value; + get => _dateOnly.HasValue; + [Obsolete("This setter is depreciated and will be removed in a future version. Use SetValue instead.", false)] + set + { + if (value && !_dateOnly.HasValue) _dateOnly = new DateOnly(Value.Year, Value.Month, Value.Day); + if (!value) _dateOnly = null; + } } + /// + /// Returns true if the underlying DateTime has a 'time' part (hour, minute, second), + /// otherwise the DateTime is considered a 'date' only. + /// public bool HasTime { - get => _hasTime; - set => _hasTime = value; + get => _timeOnly.HasValue; + [Obsolete("This setter is depreciated and will be removed in a future version. Use SetValue instead.", false)] + set + { + if (value && !_timeOnly.HasValue) _timeOnly = new TimeOnly(Value.Hour, Value.Minute, Value.Hour); + if (!value) _timeOnly = null; + } } private string _tzId = string.Empty; /// - /// Setting the TzId to a local time zone will set Value.Kind to Local. Setting TzId to UTC will set Value.Kind to Utc. If the incoming value is null - /// or whitespace, Value.Kind will be set to Unspecified. Setting the TzId will NOT incur a UTC offset conversion under any circumstances. To convert - /// to another time zone, use the ToTimeZone() method. + /// Setting the to a local time zone will set to . + /// Setting to UTC will set to . + /// If the value is set to or whitespace, will be . + /// Setting the TzId will NOT incur a UTC offset conversion. + /// To convert to another time zone, use . /// public string TzId { @@ -325,14 +389,12 @@ public string TzId return; } - _asUtc = DateTime.MinValue; - var isEmpty = string.IsNullOrWhiteSpace(value); if (isEmpty) { Parameters.Remove("TZID"); _tzId = null; - Value = DateTime.SpecifyKind(Value, DateTimeKind.Local); + _value = DateTime.SpecifyKind(_value, DateTimeKind.Local); return; } @@ -340,7 +402,7 @@ public string TzId ? DateTimeKind.Utc : DateTimeKind.Local; - Value = DateTime.SpecifyKind(Value, kind); + _value = DateTime.SpecifyKind(_value, kind); Parameters.Set("TZID", value); _tzId = value; } @@ -373,15 +435,10 @@ public string TzId public TimeSpan TimeOfDay => Value.TimeOfDay; /// - /// Returns a representation of the IDateTime in the specified time zone + /// Returns a representation of the in the time zone /// public IDateTime ToTimeZone(string tzId) { - if (string.IsNullOrWhiteSpace(tzId)) - { - throw new ArgumentException("You must provide a valid time zone id", nameof(tzId)); - } - // If TzId is empty, it's a system-local datetime, so we should use the system time zone as the starting point. var originalTzId = string.IsNullOrWhiteSpace(TzId) ? TimeZoneInfo.Local.Id @@ -396,7 +453,8 @@ public IDateTime ToTimeZone(string tzId) } /// - /// Returns a DateTimeOffset representation of the Value. If a TzId is specified, it will use that time zone's UTC offset, otherwise it will use the + /// Returns a representation of the . + /// If a TzId is specified, it will use that time zone's UTC offset, otherwise it will use the /// system-local time zone. /// public DateTimeOffset AsDateTimeOffset => @@ -404,12 +462,14 @@ public IDateTime ToTimeZone(string tzId) ? new DateTimeOffset(AsSystemLocal) : DateUtil.ToZonedDateTimeLeniently(Value, TzId).ToDateTimeOffset(); + /// public IDateTime Add(TimeSpan ts) => this + ts; public IDateTime Subtract(TimeSpan ts) => this - ts; public TimeSpan Subtract(IDateTime dt) => this - dt; + /// public IDateTime AddYears(int years) { var dt = Copy(); @@ -417,69 +477,68 @@ public IDateTime AddYears(int years) return dt; } + /// public IDateTime AddMonths(int months) { - var dt = Copy(); - dt.Value = Value.AddMonths(months); + var dt = Copy(); + var newValue = Value.AddMonths(months); + dt.SetValue(DateOnly.FromDateTime(newValue), dt._timeOnly, newValue.Kind); return dt; } + /// public IDateTime AddDays(int days) { - var dt = Copy(); - dt.Value = Value.AddDays(days); + var dt = Copy(); + var newValue = Value.AddDays(days); + dt.SetValue(DateOnly.FromDateTime(newValue), dt._timeOnly, newValue.Kind); return dt; } + /// public IDateTime AddHours(int hours) { - var dt = Copy(); - if (!dt.HasTime && hours % 24 > 0) - { - dt.HasTime = true; - } - dt.Value = Value.AddHours(hours); + var dt = Copy(); + var newValue = Value.AddHours(hours); + dt.SetValue(DateOnly.FromDateTime(newValue), TimeOnly.FromDateTime(newValue), newValue.Kind); return dt; } + /// public IDateTime AddMinutes(int minutes) { - var dt = Copy(); - if (!dt.HasTime && minutes % 1440 > 0) - { - dt.HasTime = true; - } - dt.Value = Value.AddMinutes(minutes); + var dt = Copy(); + var newValue = Value.AddMinutes(minutes); + dt.SetValue(DateOnly.FromDateTime(newValue), TimeOnly.FromDateTime(newValue), newValue.Kind); return dt; } + /// public IDateTime AddSeconds(int seconds) { - var dt = Copy(); - if (!dt.HasTime && seconds % 86400 > 0) - { - dt.HasTime = true; - } - dt.Value = Value.AddSeconds(seconds); + var dt = Copy(); + var newValue = Value.AddSeconds(seconds); + dt.SetValue(DateOnly.FromDateTime(newValue), TimeOnly.FromDateTime(newValue), newValue.Kind); return dt; } + /// + /// Milliseconds less than full seconds get truncated. public IDateTime AddMilliseconds(int milliseconds) { - var dt = Copy(); - if (!dt.HasTime && milliseconds % 86400000 > 0) - { - dt.HasTime = true; - } - dt.Value = Value.AddMilliseconds(milliseconds); + var dt = Copy(); + var newValue = Value.AddMilliseconds(milliseconds); + dt.SetValue(DateOnly.FromDateTime(newValue), TimeOnly.FromDateTime(newValue), newValue.Kind); return dt; } + /// + /// Ticks less than full seconds get truncated. public IDateTime AddTicks(long ticks) { - var dt = Copy(); - dt.HasTime = true; - dt.Value = Value.AddTicks(ticks); + var dt = Copy(); + var newValue = Value.AddTicks(ticks); + dt.SetValue(DateOnly.FromDateTime(newValue), TimeOnly.FromDateTime(newValue), newValue.Kind); return dt; } @@ -526,25 +585,22 @@ public int CompareTo(IDateTime dt) public string ToString(string format, IFormatProvider formatProvider) { - var tz = TimeZoneName; - if (!string.IsNullOrEmpty(tz)) - { - tz = " " + tz; - } + formatProvider ??= CultureInfo.InvariantCulture; + var dateTimeOffset = AsDateTimeOffset; - if (format != null) + // Use the .NET format options to format the DateTimeOffset + + if (HasTime && !HasDate) { - return Value.ToString(format, formatProvider) + tz; + return $"{dateTimeOffset.TimeOfDay.ToString(format, formatProvider)} {_tzId}"; } + if (HasTime && HasDate) { - return Value.ToString(formatProvider) + tz; + return $"{dateTimeOffset.ToString(format, formatProvider)} {_tzId}"; } - if (HasTime) - { - return Value.TimeOfDay + tz; - } - return Value.ToString("d", formatProvider) + tz; + + return $"{dateTimeOffset.ToString("d", formatProvider)} {_tzId}"; } } } \ No newline at end of file diff --git a/Ical.Net/DataTypes/Trigger.cs b/Ical.Net/DataTypes/Trigger.cs index ee9e8b66..feeea43a 100644 --- a/Ical.Net/DataTypes/Trigger.cs +++ b/Ical.Net/DataTypes/Trigger.cs @@ -31,8 +31,8 @@ public virtual IDateTime DateTime // DateTime and Duration are mutually exclusive Duration = null; - // Do not allow timeless date/time values - _mDateTime.HasTime = true; + // Ensure date/time has a time part + _mDateTime = new CalDateTime(_mDateTime.Value); } } diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index f91a644f..c19f1ea0 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -908,9 +908,6 @@ private Period CreatePeriod(DateTime dt, IDateTime referenceDate) // with the reference date. IDateTime newDt = new CalDateTime(dt, referenceDate.TzId); - // NOTE: fixes bug #2938007 - hasTime missing - newDt.HasTime = referenceDate.HasTime; - newDt.AssociateWith(referenceDate); // Create a period from the new date/time. @@ -932,8 +929,8 @@ public override HashSet Evaluate(IDateTime referenceDate, DateTime perio // This case is not defined by RFC 5545. We handle it by evaluating the rule // as if referenceDate had a time (i.e. set to midnight). - referenceDate = referenceDate.Copy(); - referenceDate.HasTime = true; + // Ensure referenceDate has a time part + referenceDate = new CalDateTime(referenceDate); } // Create a recurrence pattern suitable for use during evaluation. diff --git a/Ical.Net/Evaluation/RecurrenceUtil.cs b/Ical.Net/Evaluation/RecurrenceUtil.cs index 7708e893..6d15c713 100644 --- a/Ical.Net/Evaluation/RecurrenceUtil.cs +++ b/Ical.Net/Evaluation/RecurrenceUtil.cs @@ -15,7 +15,7 @@ public static void ClearEvaluation(IRecurrable recurrable) } public static HashSet GetOccurrences(IRecurrable recurrable, IDateTime dt, bool includeReferenceDateInResults) => GetOccurrences(recurrable, - new CalDateTime(dt.AsSystemLocal.Date), new CalDateTime(dt.AsSystemLocal.Date.AddDays(1).AddSeconds(-1)), includeReferenceDateInResults); + new CalDateTime(dt.Date), new CalDateTime(dt.Date.AddDays(1).AddSeconds(-1)), includeReferenceDateInResults); public static HashSet GetOccurrences(IRecurrable recurrable, IDateTime periodStart, IDateTime periodEnd, bool includeReferenceDateInResults) { diff --git a/Ical.Net/Ical.Net.csproj b/Ical.Net/Ical.Net.csproj index 335d33f4..0a514633 100644 --- a/Ical.Net/Ical.Net.csproj +++ b/Ical.Net/Ical.Net.csproj @@ -8,6 +8,9 @@ + + + <_Parameter1>Ical.Net.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100a1f790f70176d52efbd248577bdb292be2d0acc62f3227c523e267d64767f207f81536c77bb91d17031a5afbc2d69cd3b5b3b9c98fa8df2cd363ec90a08639a1213ad70079eff666bcc14cf6574b899f4ad0eac672c8f763291cb1e0a2304d371053158cb398b2e6f9eeb45db7d1b4d2bbba1f985676c5ca4602fab3671d34bf diff --git a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs index f96fbbff..cc4d3c7a 100644 --- a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs @@ -6,43 +6,27 @@ namespace Ical.Net.Serialization.DataTypes { + /// + /// A serializer for the data type. + /// public class DateTimeSerializer : EncodableDataTypeSerializer { + /// + /// This constructor is required for the SerializerFactory to work. + /// public DateTimeSerializer() { } + /// + /// Creates a new instance of the class. + /// + /// public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } - private DateTime CoerceDateTime(int year, int month, int day, int hour, int minute, int second, DateTimeKind kind) - { - var dt = DateTime.MinValue; - - // NOTE: determine if a date/time value exceeds the representable date/time values in .NET. - // If so, let's automatically adjust the date/time to compensate. - // FIXME: should we have a parsing setting that will throw an exception - // instead of automatically adjusting the date/time value to the - // closest representable date/time? - try - { - if (year > 9999) - { - dt = DateTime.MaxValue; - } - else if (year > 0) - { - dt = new DateTime(year, month, day, hour, minute, second, kind); - } - } - catch { } - - return dt; - } - public override Type TargetType => typeof(CalDateTime); public override string SerializeToString(object obj) { - var dt = obj as IDateTime; - if (dt == null) + if (obj is not IDateTime dt) { return null; } @@ -77,7 +61,7 @@ public override string SerializeToString(object obj) dt.SetValueType("DATE"); } - var value = new StringBuilder(); + var value = new StringBuilder(512); value.Append($"{dt.Year:0000}{dt.Month:00}{dt.Day:00}"); if (dt.HasTime) { @@ -92,16 +76,16 @@ public override string SerializeToString(object obj) return Encode(dt, value.ToString()); } - private const RegexOptions _ciCompiled = RegexOptions.Compiled | RegexOptions.IgnoreCase; - internal static readonly Regex DateOnlyMatch = new Regex(@"^((\d{4})(\d{2})(\d{2}))?$", _ciCompiled, RegexDefaults.Timeout); - internal static readonly Regex FullDateTimePatternMatch = new Regex(@"^((\d{4})(\d{2})(\d{2}))T((\d{2})(\d{2})(\d{2})(Z)?)$", _ciCompiled, RegexDefaults.Timeout); + private const RegexOptions Options = RegexOptions.Compiled | RegexOptions.IgnoreCase; + internal static readonly Regex DateOnlyMatch = new Regex(@"^((\d{4})(\d{2})(\d{2}))?$", Options, RegexDefaults.Timeout); + internal static readonly Regex FullDateTimePatternMatch = new Regex(@"^((\d{4})(\d{2})(\d{2}))T((\d{2})(\d{2})(\d{2})(Z)?)$", Options, RegexDefaults.Timeout); public override object Deserialize(TextReader tr) { var value = tr.ReadToEnd(); - var dt = CreateAndAssociate() as IDateTime; - if (dt == null) + // CalDateTime is defined as the Target type + if (CreateAndAssociate() is not CalDateTime dt) { return null; } @@ -119,28 +103,21 @@ public override object Deserialize(TextReader tr) { return null; } - var now = DateTime.Now; - var year = now.Year; - var month = now.Month; - var date = now.Day; - var hour = 0; - var minute = 0; - var second = 0; + var datePart = new DateOnly(); // Initialize. At this point, we know that the date part is present + TimeOnly? timePart = null; if (match.Groups[1].Success) { - dt.HasDate = true; - year = Convert.ToInt32(match.Groups[2].Value); - month = Convert.ToInt32(match.Groups[3].Value); - date = Convert.ToInt32(match.Groups[4].Value); + datePart = new DateOnly(Convert.ToInt32(match.Groups[2].Value), + Convert.ToInt32(match.Groups[3].Value), + Convert.ToInt32(match.Groups[4].Value)); } if (match.Groups.Count >= 6 && match.Groups[5].Success) { - dt.HasTime = true; - hour = Convert.ToInt32(match.Groups[6].Value); - minute = Convert.ToInt32(match.Groups[7].Value); - second = Convert.ToInt32(match.Groups[8].Value); + timePart = new TimeOnly(Convert.ToInt32(match.Groups[6].Value), + Convert.ToInt32(match.Groups[7].Value), + Convert.ToInt32(match.Groups[8].Value)); } var isUtc = match.Groups[9].Success; @@ -153,8 +130,9 @@ public override object Deserialize(TextReader tr) dt.TzId = "UTC"; } - dt.Value = CoerceDateTime(year, month, date, hour, minute, second, kind); + dt.SetValue(datePart, timePart, kind); + return dt; } } -} \ No newline at end of file +} diff --git a/Ical.Net/Serialization/DataTypes/RecurrencePatternSerializer.cs b/Ical.Net/Serialization/DataTypes/RecurrencePatternSerializer.cs index 3d11fea0..e6fd5d5b 100644 --- a/Ical.Net/Serialization/DataTypes/RecurrencePatternSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/RecurrencePatternSerializer.cs @@ -142,8 +142,8 @@ public override string SerializeToString(object obj) var serializer = factory.Build(typeof(IDateTime), SerializationContext) as IStringSerializer; if (serializer != null) { + // Ensure util has a time part IDateTime until = new CalDateTime(recur.Until); - until.HasTime = true; values.Add("UNTIL=" + serializer.SerializeToString(until)); } }