From c572071fd96bdb27c687bbd8e19cbf9a0bcd0c63 Mon Sep 17 00:00:00 2001 From: Rian Stockbower Date: Tue, 7 Nov 2017 14:59:35 -0500 Subject: [PATCH] Better time zone handling: - Use local time zones instead of doing everything in terms of UTC - Set and compare DateTimeKind during serialization and deserialization - Get rid of IsUniversalTime property in IDateTime - IsUtc property is get-only - AsSystemLocal no longer needs to do contortions to answer the question - ToTimeZone returns local or UTC time zones for BCL, serialization, and IANA zones - Better heuristics for determining whether a CalDateTime is UTC or local - TzId setter now does all the state maintenance for UTC vs local bookkeeping, and all other related properties are get-only - Consistent, ordinal string comparison in GetZone - Truncating parts of DateTimes doesn't suck anymore - Slightly better intellisense documentation - Fixed a broken unit test - Better local vs UTC time zone handling for Unit RRULEs with unit tests - Fixed a bug in the RecurrencePatternEvaluator where tzId wasn't taking into account - MatchTimeZone is less awkward and shorter Issues: #331, #330 --- .../Ical.Net/Ical.Net.UnitTests/AlarmTest.cs | 6 +- .../Ical.Net.UnitTests/CalDateTimeTests.cs | 68 ++++++++ .../Ical.Net.UnitTests/CalendarEventTest.cs | 2 +- .../DocumentationExamples.cs | 2 +- .../Ical.Net.UnitTests/GetOccurrenceTests.cs | 20 ++- .../Ical.Net.UnitTests/RecurrenceTests.cs | 42 +++++ .../Ical.Net.UnitTests/SerializationTests.cs | 2 +- .../SimpleDeserializationTests.cs | 2 - .../Ical.Net/Components/UniqueComponent.cs | 11 +- .../Ical.Net/DataTypes/CalDateTime.cs | 153 ++++++++++-------- .../Ical.Net/DataTypes/RecurrencePattern.cs | 16 +- .../Evaluation/RecurrencePatternEvaluator.cs | 2 +- .../Interfaces/DataTypes/IDateTime.cs | 6 +- .../DataTypes/DateTimeSerializer.cs | 104 +++++++----- .../Serializers/Other/StringSerializer.cs | 1 - .../Serializers/Other/UriSerializer.cs | 1 - .../Ical.Net/Ical.Net/Utility/DateUtil.cs | 54 ++++--- .../Ical.Net/Utility/RecurrenceUtil.cs | 19 +-- 18 files changed, 344 insertions(+), 167 deletions(-) create mode 100644 net-core/Ical.Net/Ical.Net.UnitTests/CalDateTimeTests.cs diff --git a/net-core/Ical.Net/Ical.Net.UnitTests/AlarmTest.cs b/net-core/Ical.Net/Ical.Net.UnitTests/AlarmTest.cs index 8b1c26f52..29df47b03 100644 --- a/net-core/Ical.Net/Ical.Net.UnitTests/AlarmTest.cs +++ b/net-core/Ical.Net/Ical.Net.UnitTests/AlarmTest.cs @@ -1,9 +1,9 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; using Ical.Net.DataTypes; using Ical.Net.Interfaces.DataTypes; - using NUnit.Framework; namespace Ical.Net.UnitTests @@ -22,10 +22,12 @@ public void TestAlarm(string calendarString, List dates, CalDateTime // Poll all alarms that occurred between Start and End var alarms = evt.PollAlarms(start, end); + var utcDates = new HashSet(dates.Select(d => d.AsUtc)); + //Only compare the UTC values here, since we care about the time coordinate when the alarm fires, and nothing else foreach (var alarm in alarms.Select(a => a.DateTime.AsUtc)) { - Assert.IsTrue(dates.Select(d => d.AsUtc).Contains(alarm), "Alarm triggers at " + alarm + ", but it should not."); + Assert.IsTrue(utcDates.Contains(alarm), "Alarm triggers at " + alarm + ", but it should not."); } Assert.IsTrue(dates.Count == alarms.Count, "There were " + alarms.Count + " alarm occurrences; there should have been " + dates.Count + "."); } diff --git a/net-core/Ical.Net/Ical.Net.UnitTests/CalDateTimeTests.cs b/net-core/Ical.Net/Ical.Net.UnitTests/CalDateTimeTests.cs new file mode 100644 index 000000000..341fe06ac --- /dev/null +++ b/net-core/Ical.Net/Ical.Net.UnitTests/CalDateTimeTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using Ical.Net.DataTypes; +using NUnit.Framework; +using NUnit.Framework.Interfaces; + +namespace Ical.Net.UnitTests +{ + 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) + { + Count = 5, + }; + + var calendarEvent = new CalendarEvent + { + Start = new CalDateTime(_now, tzId), + End = new CalDateTime(_later, tzId), + RecurrenceRules = new List { dailyForFiveDays }, + Resources = new List(new[] { "Foo", "Bar", "Baz" }), + }; + return calendarEvent; + } + + [Test, TestCaseSource(nameof(ToTimeZoneTestCases))] + public void ToTimeZoneTests(CalendarEvent calendarEvent, string targetTimeZone) + { + var startAsUtc = calendarEvent.Start.AsUtc; + + var convertedStart = calendarEvent.Start.ToTimeZone(targetTimeZone); + var convertedAsUtc = convertedStart.AsUtc; + + Assert.AreEqual(startAsUtc, convertedAsUtc); + } + + public static IEnumerable ToTimeZoneTestCases() + { + const string bclCst = "Central Standard Time"; + const string bclEastern = "Eastern Standard Time"; + var bclEvent = GetEventWithRecurrenceRules(bclCst); + yield return new TestCaseData(bclEvent, bclEastern) + .SetName($"BCL to BCL: {bclCst} to {bclEastern}"); + + const string ianaNy = "America/New_York"; + const string ianaRome = "Europe/Rome"; + var ianaEvent = GetEventWithRecurrenceRules(ianaNy); + + yield return new TestCaseData(ianaEvent, ianaRome) + .SetName($"IANA to IANA: {ianaNy} to {ianaRome}"); + + const string utc = "UTC"; + var utcEvent = GetEventWithRecurrenceRules(utc); + yield return new TestCaseData(utcEvent, utc) + .SetName("UTC to UTC"); + + yield return new TestCaseData(bclEvent, ianaRome) + .SetName($"BCL to IANA: {bclCst} to {ianaRome}"); + + yield return new TestCaseData(ianaEvent, bclCst) + .SetName($"IANA to BCL: {ianaNy} to {bclCst}"); + } + } +} diff --git a/net-core/Ical.Net/Ical.Net.UnitTests/CalendarEventTest.cs b/net-core/Ical.Net/Ical.Net.UnitTests/CalendarEventTest.cs index 9f16516cd..16adfe1f9 100644 --- a/net-core/Ical.Net/Ical.Net.UnitTests/CalendarEventTest.cs +++ b/net-core/Ical.Net/Ical.Net.UnitTests/CalendarEventTest.cs @@ -121,7 +121,7 @@ public void EnsureDTSTAMPisOfTypeUTC() }; cal.Events.Add(evt); - Assert.IsTrue(evt.DtStamp.IsUniversalTime, "DTSTAMP should always be of type UTC."); + Assert.IsTrue(evt.DtStamp.IsUtc, "DTSTAMP should always be of type UTC."); } /// diff --git a/net-core/Ical.Net/Ical.Net.UnitTests/DocumentationExamples.cs b/net-core/Ical.Net/Ical.Net.UnitTests/DocumentationExamples.cs index 2973cf860..df74cf26b 100644 --- a/net-core/Ical.Net/Ical.Net.UnitTests/DocumentationExamples.cs +++ b/net-core/Ical.Net/Ical.Net.UnitTests/DocumentationExamples.cs @@ -21,7 +21,7 @@ public void Daily_Test() //Recur daily through the end of the day, July 31, 2016 var recurrenceRule = new RecurrencePattern(FrequencyType.Daily, 1) { - Until = DateTime.Parse("2016-07-31T11:59:59") + Until = DateTime.Parse("2016-07-31T23:59:59") }; vEvent.RecurrenceRules = new List {recurrenceRule}; diff --git a/net-core/Ical.Net/Ical.Net.UnitTests/GetOccurrenceTests.cs b/net-core/Ical.Net/Ical.Net.UnitTests/GetOccurrenceTests.cs index 08531cbd8..d9055665b 100644 --- a/net-core/Ical.Net/Ical.Net.UnitTests/GetOccurrenceTests.cs +++ b/net-core/Ical.Net/Ical.Net.UnitTests/GetOccurrenceTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Linq; using Ical.Net.DataTypes; +using Ical.Net.Interfaces.DataTypes; using Ical.Net.Utility; using NUnit.Framework; @@ -68,16 +69,19 @@ public void SkippedOccurrenceOnWeeklyPattern() var intervalStart = eventStart; var intervalEnd = intervalStart.AddDays(7 * evaluationsCount); - var occurrences = RecurrenceUtil.GetOccurrences(vEvent, intervalStart, intervalEnd, false) - .Select(o => o.Period.StartTime) - .OrderBy(dt => dt) - .ToList(); - Assert.AreEqual(evaluationsCount, occurrences.Count); + var occurrences = RecurrenceUtil.GetOccurrences( + recurrable: vEvent, + periodStart: intervalStart, + periodEnd: intervalEnd, + includeReferenceDateInResults: false); + var occurrenceSet = new HashSet(occurrences.Select(o => o.Period.StartTime)); - for (var currentOccurrence = intervalStart.AsUtc; currentOccurrence.CompareTo(intervalEnd.AsUtc) < 0; currentOccurrence = currentOccurrence.AddDays(7)) + Assert.AreEqual(evaluationsCount, occurrenceSet.Count); + + for (var currentOccurrence = intervalStart; currentOccurrence.CompareTo(intervalEnd) < 0; currentOccurrence = (CalDateTime)currentOccurrence.AddDays(7)) { - Assert.IsTrue(occurrences.Contains(new CalDateTime(currentOccurrence)), - $"Collection does not contain {currentOccurrence}, but it is a {currentOccurrence.DayOfWeek}"); + var contains = occurrenceSet.Contains(currentOccurrence); + Assert.IsTrue(contains, $"Collection does not contain {currentOccurrence}, but it is a {currentOccurrence.DayOfWeek}"); } } diff --git a/net-core/Ical.Net/Ical.Net.UnitTests/RecurrenceTests.cs b/net-core/Ical.Net/Ical.Net.UnitTests/RecurrenceTests.cs index 1e1ec3297..e1717ffce 100644 --- a/net-core/Ical.Net/Ical.Net.UnitTests/RecurrenceTests.cs +++ b/net-core/Ical.Net/Ical.Net.UnitTests/RecurrenceTests.cs @@ -11,9 +11,11 @@ using Ical.Net.Interfaces.DataTypes; using Ical.Net.Interfaces.Evaluation; using Ical.Net.Serialization; +using Ical.Net.Serialization.iCalendar.Serializers; using Ical.Net.Serialization.iCalendar.Serializers.DataTypes; using Ical.Net.Utility; using NUnit.Framework; +using NUnit.Framework.Interfaces; using static Ical.Net.UnitTests.SerializationHelpers; namespace Ical.Net.UnitTests @@ -3378,5 +3380,45 @@ public void ManyExclusionDatesEqualityTesting() Assert.AreEqual(exDatesA, exDatesB); } + + [Test, TestCaseSource(nameof(UntilTimeZoneSerializationTestCases))] + public void UntilTimeZoneSerializationTests(string tzid, DateTimeKind expectedKind) + { + var until = DateTime.SpecifyKind(_now.AddDays(7), expectedKind); + + var rrule = new RecurrencePattern(FrequencyType.Daily) + { + Until = until, + }; + var e = new CalendarEvent + { + Start = new CalDateTime(_now, tzid), + End = new CalDateTime(_later, tzid) + }; + e.RecurrenceRules.Add(rrule); + var calendar = new Calendar + { + Events = { e }, + }; + + var serializer = new CalendarSerializer(); + + var serialized = serializer.SerializeToString(calendar); + var deserializedKind = (serializer.Deserialize(new StringReader(serialized)) as CalendarCollection).First() + .Events.First() + .RecurrenceRules.First().Until.Kind; + + Assert.AreEqual(expectedKind, deserializedKind); + } + + public static IEnumerable UntilTimeZoneSerializationTestCases() + { + yield return new TestCaseData("America/New_York", DateTimeKind.Local) + .SetName("IANA time time zone results in a local DateTimeKind"); + yield return new TestCaseData("Eastern Standard Time", DateTimeKind.Local) + .SetName("BCL time zone results in a Local DateTimeKind"); + yield return new TestCaseData("UTC", DateTimeKind.Utc) + .SetName("UTC results in DateTimeKind.Utc"); + } } } diff --git a/net-core/Ical.Net/Ical.Net.UnitTests/SerializationTests.cs b/net-core/Ical.Net/Ical.Net.UnitTests/SerializationTests.cs index 663f141ae..56682e53c 100644 --- a/net-core/Ical.Net/Ical.Net.UnitTests/SerializationTests.cs +++ b/net-core/Ical.Net/Ical.Net.UnitTests/SerializationTests.cs @@ -135,7 +135,7 @@ public static string InspectSerializedSection(string serialized, string sectionN static string CalDateString(IDateTime cdt) { var returnVar = $"{cdt.Year}{cdt.Month:D2}{cdt.Day:D2}T{cdt.Hour:D2}{cdt.Minute:D2}{cdt.Second:D2}"; - if (cdt.IsUniversalTime) + if (cdt.IsUtc) { return returnVar + 'Z'; } diff --git a/net-core/Ical.Net/Ical.Net.UnitTests/SimpleDeserializationTests.cs b/net-core/Ical.Net/Ical.Net.UnitTests/SimpleDeserializationTests.cs index 6557241ad..eb1ebe544 100644 --- a/net-core/Ical.Net/Ical.Net.UnitTests/SimpleDeserializationTests.cs +++ b/net-core/Ical.Net/Ical.Net.UnitTests/SimpleDeserializationTests.cs @@ -1,7 +1,5 @@ using Ical.Net.DataTypes; using Ical.Net.General; -using Ical.Net.Interfaces; -using Ical.Net.Interfaces.Components; using Ical.Net.Interfaces.DataTypes; using Ical.Net.Interfaces.General; using Ical.Net.Serialization; diff --git a/net-core/Ical.Net/Ical.Net/Components/UniqueComponent.cs b/net-core/Ical.Net/Ical.Net/Components/UniqueComponent.cs index 93c103602..0e545c1a5 100644 --- a/net-core/Ical.Net/Ical.Net/Components/UniqueComponent.cs +++ b/net-core/Ical.Net/Ical.Net/Components/UniqueComponent.cs @@ -4,6 +4,7 @@ using Ical.Net.DataTypes; using Ical.Net.Interfaces.Components; using Ical.Net.Interfaces.DataTypes; +using Ical.Net.Utility; namespace Ical.Net { @@ -41,12 +42,10 @@ private void EnsureProperties() // See https://sourceforge.net/projects/dday-ical/forums/forum/656447/topic/3754354 if (DtStamp == null) { - // Here, we don't simply set to DateTime.Now because DateTime.Now contains milliseconds, and - // the iCalendar standard doesn't care at all about milliseconds. Therefore, when comparing - // two calendars, one generated, and one loaded from file, they may be functionally identical, - // but be determined to be different due to millisecond differences. - var now = DateTime.SpecifyKind(DateTime.Today.Add(DateTime.UtcNow.TimeOfDay), DateTimeKind.Utc); - DtStamp = new CalDateTime(now); + // icalendar RFC doesn't care about sub-second time resolution, so shave off everything smaller than seconds. + + var utcNow = DateTime.UtcNow.Truncate(TimeSpan.FromSeconds(1)); //DateTimeKind.Utc is preserved + DtStamp = new CalDateTime(utcNow, "UTC"); } } diff --git a/net-core/Ical.Net/Ical.Net/DataTypes/CalDateTime.cs b/net-core/Ical.Net/Ical.Net/DataTypes/CalDateTime.cs index 8b01e747c..837d63a0d 100644 --- a/net-core/Ical.Net/Ical.Net/DataTypes/CalDateTime.cs +++ b/net-core/Ical.Net/Ical.Net/DataTypes/CalDateTime.cs @@ -4,6 +4,7 @@ using Ical.Net.Interfaces.General; using Ical.Net.Serialization.iCalendar.Serializers.DataTypes; using Ical.Net.Utility; +using NodaTime; namespace Ical.Net.DataTypes { @@ -23,7 +24,6 @@ public sealed class CalDateTime : EncodableDataType, IDateTime private DateTime _value; private bool _hasDate; private bool _hasTime; - private bool _isUniversalTime; public CalDateTime() { } @@ -73,16 +73,27 @@ private void Initialize(int year, int month, int day, int hour, int minute, int private void Initialize(DateTime value, string tzId, Calendar cal) { - if (value.Kind == DateTimeKind.Utc) + if (!string.IsNullOrWhiteSpace(tzId) && !tzId.Equals("UTC", StringComparison.OrdinalIgnoreCase)) { - IsUniversalTime = true; + // Definitely local + value = DateTime.SpecifyKind(value, DateTimeKind.Local); + TzId = tzId; + } + else if (string.Equals("UTC", tzId, StringComparison.OrdinalIgnoreCase) || value.Kind == DateTimeKind.Utc) + { + // Probably UTC + value = DateTime.SpecifyKind(value, DateTimeKind.Utc); + TzId = "UTC"; + } + else + { + // Ambiguous, but probably local + value = DateTime.SpecifyKind(value, DateTimeKind.Unspecified); } - // Convert all incoming values to UTC. - Value = new DateTime(value.Year, value.Month, value.Day, value.Hour, value.Minute, value.Second, DateTimeKind.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; - TzId = tzId; AssociatedObject = cal; } @@ -128,15 +139,16 @@ public override void CopyFrom(ICopyable obj) base.CopyFrom(obj); var dt = obj as IDateTime; - if (dt != null) + if (dt == null) { - _value = dt.Value; - _isUniversalTime = dt.IsUniversalTime; - _hasDate = dt.HasDate; - _hasTime = dt.HasTime; - - AssociateWith(dt); + return; } + + _value = dt.Value; + _hasDate = dt.HasDate; + _hasTime = dt.HasTime; + + AssociateWith(dt); } private bool Equals(CalDateTime other) => Value.Equals(other.Value) @@ -198,56 +210,40 @@ public override int GetHashCode() public static implicit operator CalDateTime(DateTime left) => new CalDateTime(left); /// - /// Converts the date/time to this computer's local date/time. + /// Converts the date/time to the date/time of the computer running the program /// - public DateTime AsSystemLocal - { - get - { - if (!HasTime) - { - return DateTime.SpecifyKind(Value.Date, DateTimeKind.Local); - } - if (IsUniversalTime) - { - return Value.ToLocalTime(); - } - return AsUtc.ToLocalTime(); - } - } - - private DateTime _utc; + public DateTime AsSystemLocal => HasTime + ? Value.ToLocalTime() + : Value.ToLocalTime().Date; /// - /// Converts the date/time to UTC (Coordinated Universal Time) + /// Returns a representation of the DateTime in Coordinated Universal Time (UTC) /// public DateTime AsUtc { get { - if (IsUniversalTime) + // In order of weighting: + // 1) Specified TzId + // 2) Value having a DateTimeKind.Utc + + if (!string.IsNullOrWhiteSpace(TzId)) { - _utc = DateTime.SpecifyKind(_value, DateTimeKind.Utc); - return _utc; + var asLocal = DateUtil.ToZonedDateTimeLeniently(Value, TzId); + return asLocal.ToDateTimeUtc(); } - if (!string.IsNullOrWhiteSpace(TzId)) + + if (IsUtc || Value.Kind == DateTimeKind.Utc) { - var newUtc = DateUtil.ToZonedDateTimeLeniently(Value, TzId); - _utc = newUtc.ToDateTimeUtc(); - return _utc; + return DateTime.SpecifyKind(_value, DateTimeKind.Utc); } - _utc = DateTime.SpecifyKind(Value, DateTimeKind.Local).ToUniversalTime(); - // Fallback to the OS-conversion - return _utc; + // Fall back to the OS conversion + return DateTime.SpecifyKind(Value, DateTimeKind.Local).ToUniversalTime(); } } - public bool IsUniversalTime - { - get => _isUniversalTime; - set => _isUniversalTime = value; - } + public bool IsUtc => _value.Kind == DateTimeKind.Utc; public string TimeZoneName => TzId; @@ -270,25 +266,45 @@ public bool HasTime } 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. + /// public string TzId { get { - if (IsUniversalTime) + if (string.IsNullOrWhiteSpace(_tzId)) { - return "UTC"; + _tzId = Parameters.Get("TZID"); } - return !string.IsNullOrWhiteSpace(_tzId) - ? _tzId - : Parameters.Get("TZID"); + return _tzId; } set { - if (!Equals(TzId, value)) + if (string.Equals(_tzId, value, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var isEmpty = string.IsNullOrWhiteSpace(value); + if (isEmpty) { - Parameters.Set("TZID", value); - _tzId = value; + Parameters.Remove("TZID"); + _tzId = null; + Value = DateTime.SpecifyKind(Value, DateTimeKind.Local); + return; } + + var kind = string.Equals(value, "UTC", StringComparison.OrdinalIgnoreCase) + ? DateTimeKind.Utc + : DateTimeKind.Local; + + Value = DateTime.SpecifyKind(Value, kind); + Parameters.Set("TZID", value); + _tzId = value; } } @@ -316,22 +332,27 @@ public string TzId public TimeSpan TimeOfDay => Value.TimeOfDay; - public IDateTime ToTimeZone(string newTimeZone) + /// + /// Returns a representation of the IDateTime in the specified time zone + /// + public IDateTime ToTimeZone(string tzId) { - if (string.IsNullOrWhiteSpace(newTimeZone)) - { - throw new ArgumentException("You must provide a valid TZID to the ToTimeZone() method", nameof(newTimeZone)); - } - if (Calendar == null) + if (string.IsNullOrWhiteSpace(tzId)) { - throw new Exception("The iCalDateTime object must have an iCalendar associated with it in order to use TimeZones."); + throw new ArgumentException("You must provide a valid time zone id", nameof(tzId)); } - var newDt = string.IsNullOrWhiteSpace(TzId) - ? DateUtil.ToZonedDateTimeLeniently(Value, newTimeZone).ToDateTimeUtc() - : DateUtil.FromTimeZoneToTimeZone(Value, TzId, newTimeZone).ToDateTimeUtc(); + // 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 + : TzId; + + var zonedOriginal = DateUtil.ToZonedDateTimeLeniently(Value, originalTzId); + var converted = zonedOriginal.WithZone(DateUtil.GetZone(tzId)); - return new CalDateTime(newDt, newTimeZone); + return converted.Zone == DateTimeZone.Utc + ? new CalDateTime(converted.ToDateTimeUtc(), tzId) + : new CalDateTime(DateTime.SpecifyKind(converted.ToDateTimeUnspecified(), DateTimeKind.Local), tzId); } public IDateTime Add(TimeSpan ts) => this + ts; diff --git a/net-core/Ical.Net/Ical.Net/DataTypes/RecurrencePattern.cs b/net-core/Ical.Net/Ical.Net/DataTypes/RecurrencePattern.cs index cf5b763cf..bd6fcc393 100644 --- a/net-core/Ical.Net/Ical.Net/DataTypes/RecurrencePattern.cs +++ b/net-core/Ical.Net/Ical.Net/DataTypes/RecurrencePattern.cs @@ -17,10 +17,24 @@ public class RecurrencePattern : EncodableDataType private int _interval = int.MinValue; private RecurrenceRestrictionType? _restrictionType; private RecurrenceEvaluationModeType? _evaluationMode; + public FrequencyType Frequency { get; set; } - public DateTime Until { get; set; } = DateTime.MinValue; + private DateTime _until = DateTime.MinValue; + public DateTime Until + { + get => _until; + set + { + if (_until == value && _until.Kind == value.Kind) + { + return; + } + + _until = value; + } + } public int Count { get; set; } = int.MinValue; diff --git a/net-core/Ical.Net/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/net-core/Ical.Net/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index 2869c7076..4576cde4a 100644 --- a/net-core/Ical.Net/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/net-core/Ical.Net/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -67,7 +67,7 @@ private RecurrencePattern ProcessRecurrencePattern(IDateTime referenceDate) // Convert the UNTIL value to one that matches the same time information as the reference date if (r.Until != DateTime.MinValue) { - r.Until = DateUtil.MatchTimeZone(referenceDate, new CalDateTime(r.Until)).Value; + r.Until = DateUtil.MatchTimeZone(referenceDate, new CalDateTime(r.Until, referenceDate.TzId)).Value; } if (r.Frequency > FrequencyType.Secondly && r.BySecond.Count == 0 && referenceDate.HasTime diff --git a/net-core/Ical.Net/Ical.Net/Interfaces/DataTypes/IDateTime.cs b/net-core/Ical.Net/Ical.Net/Interfaces/DataTypes/IDateTime.cs index 83948a3f4..ce0d3e1e2 100644 --- a/net-core/Ical.Net/Ical.Net/Interfaces/DataTypes/IDateTime.cs +++ b/net-core/Ical.Net/Ical.Net/Interfaces/DataTypes/IDateTime.cs @@ -18,7 +18,7 @@ public interface IDateTime : IEncodableDataType, IComparable, IFormat /// Gets/sets whether the Value of this date/time represents /// a universal time. /// - bool IsUniversalTime { get; set; } + bool IsUtc { get; } /// /// Gets the time zone name this time is in, if it references a time zone. @@ -28,7 +28,7 @@ public interface IDateTime : IEncodableDataType, IComparable, IFormat /// /// Gets/sets the underlying DateTime value stored. This should always /// use DateTimeKind.Utc, regardless of its actual representation. - /// Use IsUniversalTime along with the TZID to control how this + /// Use IsUtc along with the TZID to control how this /// date/time is handled. /// DateTime Value { get; set; } @@ -102,7 +102,7 @@ public interface IDateTime : IEncodableDataType, IComparable, IFormat /// Converts the date/time value to a local time /// within the specified time zone. /// - IDateTime ToTimeZone(string newTimeZone); + IDateTime ToTimeZone(string tzId); IDateTime Add(TimeSpan ts); IDateTime Subtract(TimeSpan ts); diff --git a/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/DataTypes/DateTimeSerializer.cs b/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/DataTypes/DateTimeSerializer.cs index a221f60a6..19e8c470e 100644 --- a/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/DataTypes/DateTimeSerializer.cs +++ b/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/DataTypes/DateTimeSerializer.cs @@ -43,21 +43,32 @@ private DateTime CoerceDateTime(int year, int month, int day, int hour, int minu public override string SerializeToString(object obj) { var dt = obj as IDateTime; + if (dt == null) + { + return null; + } // RFC 5545 3.3.5: // The date with UTC time, or absolute time, is identified by a LATIN // CAPITAL LETTER Z suffix character, the UTC designator, appended to // the time value. The "TZID" property parameter MUST NOT be applied to DATE-TIME // properties whose time values are specified in UTC. - if (dt.IsUniversalTime) + + var kind = dt.IsUtc + ? DateTimeKind.Utc + : DateTimeKind.Local; + + if (dt.IsUtc) { dt.Parameters.Remove("TZID"); - - } else if (!string.IsNullOrWhiteSpace(dt.TzId)) + } + else if (!string.IsNullOrWhiteSpace(dt.TzId)) { dt.Parameters.Set("TZID", dt.TzId); } + DateTime.SpecifyKind(dt.Value, kind); + // FIXME: what if DATE is the default value type for this? // Also, what if the DATE-TIME value type is specified on something // where DATE-TIME is the default value type? It should be removed @@ -72,7 +83,7 @@ public override string SerializeToString(object obj) if (dt.HasTime) { value.Append($"T{dt.Hour:00}{dt.Minute:00}{dt.Second:00}"); - if (dt.IsUniversalTime && string.IsNullOrWhiteSpace(dt.Parameters.Get("TZID"))) + if (dt.IsUtc) { value.Append("Z"); } @@ -91,55 +102,60 @@ public override object Deserialize(TextReader tr) var value = tr.ReadToEnd(); var dt = CreateAndAssociate() as IDateTime; - if (dt != null) + if (dt == null) { - // Decode the value as necessary - value = Decode(dt, value); + return null; + } - var match = FullDateTimePatternMatch.Match(value); - if (!match.Success) - { - match = DateOnlyMatch.Match(value); - } + // Decode the value as necessary + value = Decode(dt, value); - if (!match.Success) - { - return null; - } - var now = DateTime.Now; + var match = FullDateTimePatternMatch.Match(value); + if (!match.Success) + { + match = DateOnlyMatch.Match(value); + } - var year = now.Year; - var month = now.Month; - var date = now.Day; - var hour = 0; - var minute = 0; - var second = 0; + if (!match.Success) + { + return null; + } + var now = DateTime.Now; - 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); - } - 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); - } + var year = now.Year; + var month = now.Month; + var date = now.Day; + var hour = 0; + var minute = 0; + var second = 0; - if (match.Groups[9].Success) - { - dt.IsUniversalTime = true; - } + 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); + } + 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); + } - dt.Value = CoerceDateTime(year, month, date, hour, minute, second, DateTimeKind.Utc); - return dt; + var isUtc = match.Groups[9].Success; + var kind = isUtc + ? DateTimeKind.Utc + : DateTimeKind.Local; + + if (isUtc) + { + dt.TzId = "UTC"; } - return null; + dt.Value = CoerceDateTime(year, month, date, hour, minute, second, kind); + return dt; } } } \ No newline at end of file diff --git a/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/Other/StringSerializer.cs b/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/Other/StringSerializer.cs index 4df536c68..1bcd57e14 100644 --- a/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/Other/StringSerializer.cs +++ b/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/Other/StringSerializer.cs @@ -8,7 +8,6 @@ using Ical.Net.Interfaces.General; using Ical.Net.Interfaces.Serialization; using Ical.Net.Serialization.iCalendar.Serializers.DataTypes; -using Ical.Net.Utility; namespace Ical.Net.Serialization.iCalendar.Serializers.Other { diff --git a/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/Other/UriSerializer.cs b/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/Other/UriSerializer.cs index 293c217e4..f33caa918 100644 --- a/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/Other/UriSerializer.cs +++ b/net-core/Ical.Net/Ical.Net/Serialization/iCalendar/Serializers/Other/UriSerializer.cs @@ -3,7 +3,6 @@ using Ical.Net.DataTypes; using Ical.Net.Interfaces.General; using Ical.Net.Serialization.iCalendar.Serializers.DataTypes; -using Ical.Net.Utility; namespace Ical.Net.Serialization.iCalendar.Serializers.Other { diff --git a/net-core/Ical.Net/Ical.Net/Utility/DateUtil.cs b/net-core/Ical.Net/Ical.Net/Utility/DateUtil.cs index 6d9237e45..5d3db2be3 100644 --- a/net-core/Ical.Net/Ical.Net/Utility/DateUtil.cs +++ b/net-core/Ical.Net/Ical.Net/Utility/DateUtil.cs @@ -8,25 +8,26 @@ namespace Ical.Net.Utility { - internal class DateUtil + internal static class DateUtil { public static IDateTime StartOfDay(IDateTime dt) => dt.AddHours(-dt.Hour).AddMinutes(-dt.Minute).AddSeconds(-dt.Second); public static IDateTime EndOfDay(IDateTime dt) => StartOfDay(dt).AddDays(1).AddTicks(-1); - public static DateTime GetSimpleDateTimeData(IDateTime dt) => DateTime.SpecifyKind(dt.Value, dt.IsUniversalTime ? DateTimeKind.Utc : DateTimeKind.Local); + public static DateTime GetSimpleDateTimeData(IDateTime dt) + => DateTime.SpecifyKind(dt.Value, dt.IsUtc ? DateTimeKind.Utc : DateTimeKind.Local); public static DateTime SimpleDateTimeToMatch(IDateTime dt, IDateTime toMatch) { - if (toMatch.IsUniversalTime && dt.IsUniversalTime) + if (toMatch.IsUtc && dt.IsUtc) { return dt.Value; } - if (toMatch.IsUniversalTime) + if (toMatch.IsUtc) { return dt.Value.ToUniversalTime(); } - if (dt.IsUniversalTime) + if (dt.IsUtc) { return dt.Value.ToLocalTime(); } @@ -44,19 +45,14 @@ public static IDateTime MatchTimeZone(IDateTime dt1, IDateTime dt2) // same context (i.e. evaluation). if (dt1.TzId != null) { - if (!string.Equals(dt1.TzId, copy.TzId)) - { - return copy.ToTimeZone(dt1.TzId); - } - return copy; + return string.Equals(dt1.TzId, copy.TzId, StringComparison.OrdinalIgnoreCase) + ? copy + : copy.ToTimeZone(dt1.TzId); } - if (dt1.IsUniversalTime) - { - // The first date/time is in UTC time, convert! - return new CalDateTime(copy.AsUtc); - } - // The first date/time is in local time, convert! - return new CalDateTime(copy.AsSystemLocal); + + return dt1.IsUtc + ? new CalDateTime(copy.AsUtc) + : new CalDateTime(copy.AsSystemLocal); } public static DateTime AddWeeks(DateTime dt, int interval, DayOfWeek firstDayOfWeek) @@ -86,6 +82,14 @@ public static DateTime FirstDayOfWeek(DateTime dt, DayOfWeek firstDayOfWeek, out private static readonly Dictionary _windowsMapping = TzdbDateTimeZoneSource.Default.WindowsMapping.PrimaryMapping.ToDictionary(k => k.Key, v => v.Value, StringComparer.OrdinalIgnoreCase); public static readonly DateTimeZone LocalDateTimeZone = DateTimeZoneProviders.Tzdb.GetSystemDefault(); + + /// + /// Use this method to turn a raw string into a NodaTime DateTimeZone. It searches all time zone providers (IANA, BCL, serialization, etc) to see if + /// the string matches. If it doesn't, it walks each provider, and checks to see if the time zone the provider knows about is contained within the + /// target time zone string. Some older icalendar programs would generate nonstandard time zone strings, and this secondary check works around + /// that. + /// + /// A BCL, IANA, or serialization time zone identifier public static DateTimeZone GetZone(string tzId) { if (string.IsNullOrWhiteSpace(tzId)) @@ -93,7 +97,7 @@ public static DateTimeZone GetZone(string tzId) return LocalDateTimeZone; } - if (tzId.StartsWith("/")) + if (tzId.StartsWith("/", StringComparison.OrdinalIgnoreCase)) { tzId = tzId.Substring(1, tzId.Length - 1); } @@ -104,8 +108,7 @@ public static DateTimeZone GetZone(string tzId) return zone; } - string ianaZone; - if (_windowsMapping.TryGetValue(tzId, out ianaZone)) + if (_windowsMapping.TryGetValue(tzId, out var ianaZone)) { return DateTimeZoneProviders.Tzdb.GetZoneOrNull(ianaZone); } @@ -182,5 +185,16 @@ public static ZonedDateTime FromTimeZoneToTimeZone(DateTime dateTime, DateTimeZo } public static bool IsSerializationTimeZone(DateTimeZone zone) => DateTimeZoneProviders.Serialization.GetZoneOrNull(zone.Id) != null; + + /// + /// Truncate to the specified TimeSpan's magnitude. For example, to truncate to the nearest second, use TimeSpan.FromSeconds(1) + /// + /// + /// + /// + public static DateTime Truncate(this DateTime dateTime, TimeSpan timeSpan) + => timeSpan == TimeSpan.Zero + ? dateTime + : dateTime.AddTicks(-(dateTime.Ticks % timeSpan.Ticks)); } } \ No newline at end of file diff --git a/net-core/Ical.Net/Ical.Net/Utility/RecurrenceUtil.cs b/net-core/Ical.Net/Ical.Net/Utility/RecurrenceUtil.cs index 660268a88..2ae6048da 100644 --- a/net-core/Ical.Net/Ical.Net/Utility/RecurrenceUtil.cs +++ b/net-core/Ical.Net/Ical.Net/Utility/RecurrenceUtil.cs @@ -11,16 +11,16 @@ internal class RecurrenceUtil { public static void ClearEvaluation(IRecurrable recurrable) { - var evaluator = recurrable.GetService(typeof (IEvaluator)) as IEvaluator; + var evaluator = recurrable.GetService(typeof(IEvaluator)) as IEvaluator; evaluator?.Clear(); } - 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); + 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); public static HashSet GetOccurrences(IRecurrable recurrable, IDateTime periodStart, IDateTime periodEnd, bool includeReferenceDateInResults) { - var evaluator = recurrable.GetService(typeof (IEvaluator)) as IEvaluator; + var evaluator = recurrable.GetService(typeof(IEvaluator)) as IEvaluator; if (evaluator == null || recurrable.Start == null) { return new HashSet(); @@ -32,13 +32,14 @@ public static HashSet GetOccurrences(IRecurrable recurrable, IDateTi // Change the time zone of periodStart/periodEnd as needed // so they can be used during the evaluation process. - periodStart = DateUtil.MatchTimeZone(start, periodStart); - periodEnd = DateUtil.MatchTimeZone(start, periodEnd); - var periods = evaluator.Evaluate(start, DateUtil.GetSimpleDateTimeData(periodStart), DateUtil.GetSimpleDateTimeData(periodEnd), includeReferenceDateInResults); + periodStart.TzId = start.TzId; + periodEnd.TzId = start.TzId; - var otherOccurrences = - from p in periods + var periods = evaluator.Evaluate(start, DateUtil.GetSimpleDateTimeData(periodStart), DateUtil.GetSimpleDateTimeData(periodEnd), + includeReferenceDateInResults); + + var otherOccurrences = from p in periods let endTime = p.EndTime ?? p.StartTime where endTime.GreaterThan(periodStart) && p.StartTime.LessThanOrEqual(periodEnd) select new Occurrence(recurrable, p);