diff --git a/Ical.Net.Tests/RecurrenceIdentifierTests.cs b/Ical.Net.Tests/RecurrenceIdentifierTests.cs new file mode 100644 index 00000000..11e5bf08 --- /dev/null +++ b/Ical.Net.Tests/RecurrenceIdentifierTests.cs @@ -0,0 +1,151 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +using Ical.Net.CalendarComponents; +using Ical.Net.DataTypes; +using Ical.Net.Serialization; +using Ical.Net.Serialization.DataTypes; +using Ical.Net.Utility; +using NUnit.Framework; + +namespace Ical.Net.Tests; + +[TestFixture] +internal class RecurrenceIdentifierTests +{ +#pragma warning disable CS0618 // Type or member is obsolete + + [TestCase(RecurrenceRange.ThisAndFuture, ";RANGE=THISANDFUTURE")] + [TestCase(RecurrenceRange.ThisInstance, "")] // This means no RANGE parameter + [TestCase(9999, "")] // Invalid values should be treated as ThisInstance + public void RecurrenceIdentifierWithTzId_ShouldSerializeCorrectly(RecurrenceRange range, string rangeString) + { + var evt = new CalendarEvent + { + RecurrenceIdentifier = new RecurrenceIdentifier(new CalDateTime(2025, 7, 1, 10, 0, 0, "America/New_York"), range) + }; + + var serializer = new EventSerializer(); + var serialized = serializer.SerializeToString(evt)!; + var expected = $"RECURRENCE-ID;TZID=America/New_York{rangeString}:20250701T100000"; + + Assert.That(serialized, Does.Contain(expected)); + } + + [TestCase(RecurrenceRange.ThisAndFuture, ";RANGE=THISANDFUTURE", "20250701T100000")] + [TestCase(RecurrenceRange.ThisAndFuture, ";VALUE=DATE;RANGE=THISANDFUTURE", "20250701")] + [TestCase(RecurrenceRange.ThisInstance, "", "20250701T100000")] + [TestCase(RecurrenceRange.ThisInstance, ";VALUE=DATE", "20250701")] + public void RecurrenceIdentifierWithoutTzId_ShouldSerializeCorrectly(RecurrenceRange range, string dtRangeString, string dateTime) + { + var evt = new CalendarEvent + { + RecurrenceIdentifier = new RecurrenceIdentifier(new CalDateTime(dateTime), range) + }; + + var serializer = new EventSerializer(); + var serialized = serializer.SerializeToString(evt)!; + var expected = $"RECURRENCE-ID{dtRangeString}:{dateTime}"; + + Assert.That(serialized, Does.Contain(expected)); + } + + [TestCase("20250701T100000", ";RANGE=THISANDFUTURE", RecurrenceRange.ThisAndFuture)] + [TestCase("20250701", ";VALUE=DATE;RANGE=THISANDFUTURE", RecurrenceRange.ThisAndFuture)] + [TestCase("20250701T100000", "", RecurrenceRange.ThisInstance)] + [TestCase("20250701", ";VALUE=DATE", RecurrenceRange.ThisInstance)] + [TestCase("20250701T100000", ";RANGE=invalid", RecurrenceRange.ThisInstance)] + public void RecurrenceIdentifierWithoutTzId_ShouldDeserializeCorrectly(string dt, string recId, RecurrenceRange expected) + { + var cal = $""" + BEGIN:VCALENDAR + BEGIN:VEVENT + DTSTAMP:20250928T221419Z + RECURRENCE-ID{recId}:{dt} + SEQUENCE:1 + UID:c03cbcb3-6b37-49d6-9e05-a06a34a3ee57 + END:VEVENT + END:VCALENDAR + """; + + var recurrenceId = Calendar.Load(cal)!.Events[0]!.RecurrenceIdentifier; + using (Assert.EnterMultipleScope()) + { + Assert.That(recurrenceId!.StartTime, Is.EqualTo(new CalDateTime(dt))); + Assert.That(recurrenceId.Range, Is.EqualTo(expected)); + } + } + + [Test] + public void RecurrenceId_IsCompatibleWith_RecurrenceIdentifier() + { + var dt = new CalDateTime("20250930"); + var evt1 = new CalendarEvent + { + RecurrenceId = dt + }; + + var evt2 = new CalendarEvent + { + RecurrenceIdentifier = new RecurrenceIdentifier(dt) + }; + + var evtFuture = new CalendarEvent + { + RecurrenceIdentifier = new RecurrenceIdentifier(dt, RecurrenceRange.ThisAndFuture) + }; + + using (Assert.EnterMultipleScope()) + { + Assert.That(evt1.RecurrenceId, Is.EqualTo(evt1.RecurrenceIdentifier?.StartTime)); + Assert.That(evt2.RecurrenceId, Is.EqualTo(evt2.RecurrenceIdentifier.StartTime)); + Assert.That(evt1.RecurrenceIdentifier?.Range, Is.EqualTo(RecurrenceRange.ThisInstance)); + // RecurrenceId only supports ThisInstance implicitly, + // so RecurrenceInstance with ThisAndFuture returns null + Assert.That(evtFuture.RecurrenceId, Is.Null); + } + } + + [Test] + public void RecurrenceIdentifierSerializer_LowLevel() + { + var recurrenceId = new RecurrenceIdentifier(new CalDateTime("20250930T140000", "Europe/Paris"), RecurrenceRange.ThisAndFuture); + var serializer = new RecurrenceIdentifierSerializer(); + + var serialized = serializer.SerializeToString(recurrenceId); + // Invalid parameter type should not throw, but return null + var serializedAsNull = serializer.SerializeToString(string.Empty); + + var param = ParameterProviderHelper.GetRecurrenceIdentifierParameters(recurrenceId); + + using (Assert.EnterMultipleScope()) + { + Assert.That(serializer.TargetType == recurrenceId.GetType()); + Assert.That(serialized, Is.EqualTo("20250930T140000")); + Assert.That(serializedAsNull, Is.Null); + Assert.That(param, Has.Exactly(2).Items); + Assert.That(param[0].Name, Is.EqualTo("TZID")); + Assert.That(param[0].Value, Is.EqualTo("Europe/Paris")); + Assert.That(param[1].Name, Is.EqualTo("RANGE")); + Assert.That(param[1].Value, Is.EqualTo("THISANDFUTURE")); + } + } + + [TestCase("20250831", RecurrenceRange.ThisInstance, 1)] // other earlier + [TestCase("20250901", RecurrenceRange.ThisInstance, 0)] // same date, same range + [TestCase("20250901", RecurrenceRange.ThisAndFuture, -1)] // same date, higher range + [TestCase("20250902", RecurrenceRange.ThisInstance, -1)] // other later + [TestCase("20250902", RecurrenceRange.ThisAndFuture, -1)] // other later, higher range + public void CompareToTests(string dt, RecurrenceRange range, int expected) + { + var self = new RecurrenceIdentifier(new CalDateTime("20250901"), RecurrenceRange.ThisInstance); + var other = new RecurrenceIdentifier(new CalDateTime(dt), range); + + Assert.That(self.CompareTo(other), Is.EqualTo(expected)); + Assert.That(self.CompareTo(null), Is.EqualTo(1)); + } + +#pragma warning restore CS0618 // Type or member is obsolete +} diff --git a/Ical.Net.Tests/VTimeZoneTest.cs b/Ical.Net.Tests/VTimeZoneTest.cs index 482702ca..d773ee57 100644 --- a/Ical.Net.Tests/VTimeZoneTest.cs +++ b/Ical.Net.Tests/VTimeZoneTest.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; @@ -108,14 +109,11 @@ public void VTimeZoneEuropeMoscowShouldSerializeProperly() // Unwrap the lines to make it easier to search for specific values var serialized = TextUtil.UnwrapLines(serializer.SerializeToString(iCal)); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(serialized, Does.Contain("TZID:Europe/Moscow"), "Time zone not found in serialization"); Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized"); Assert.That(serialized, Does.Contain("BEGIN:DAYLIGHT"), "The daylight timezone info was not serialized"); - }); - Assert.Multiple(() => - { Assert.That(serialized, Does.Contain("TZNAME:MSD"), "MSD was not serialized"); Assert.That(serialized, Does.Contain("TZNAME:MSK"), "MSK info was not serialized"); Assert.That(serialized, Does.Contain("TZNAME:MSD"), "MSD was not serialized"); @@ -127,7 +125,7 @@ public void VTimeZoneEuropeMoscowShouldSerializeProperly() Assert.That(serialized, Does.Contain("DTSTART:19171228T000000"), "DTSTART:19171228T000000 was not serialized"); // RDATE may contain multiple dates, separated by a comma Assert.That(Regex.IsMatch(serialized, $@"RDATE:.*\b19991031T030000\b", RegexOptions.Compiled, RegexDefaults.Timeout), Is.True, "RDATE:19731028T020000 was not serialized"); - }); + } } [Test, Category("VTimeZone")] @@ -137,7 +135,7 @@ public void VTimeZoneAmericaChicagoShouldSerializeProperly() var serializer = new CalendarSerializer(); var serialized = serializer.SerializeToString(iCal); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(serialized, Does.Contain("TZID:America/Chicago"), "Time zone not found in serialization"); Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized"); @@ -153,7 +151,7 @@ public void VTimeZoneAmericaChicagoShouldSerializeProperly() Assert.That(serialized, Does.Contain("DTSTART:19360301T020000"), "DTSTART:19360301T020000 was not serialized"); Assert.That(serialized, Does.Contain("DTSTART:20070311T020000"), "DTSTART:20070311T020000 was not serialized"); Assert.That(serialized, Does.Contain("DTSTART:20071104T020000"), "DTSTART:20071104T020000 was not serialized"); - }); + } } [Test, Category("VTimeZone")] @@ -163,7 +161,7 @@ public void VTimeZoneAmericaLosAngelesShouldSerializeProperly() var serializer = new CalendarSerializer(); var serialized = serializer.SerializeToString(iCal); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(serialized, Does.Contain("TZID:America/Los_Angeles"), "Time zone not found in serialization"); Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized"); @@ -176,10 +174,7 @@ public void VTimeZoneAmericaLosAngelesShouldSerializeProperly() Assert.That(serialized, Does.Contain("DTSTART:19180331T020000"), "DTSTART:19180331T020000 was not serialized"); Assert.That(serialized, Does.Contain("DTSTART:20071104T020000"), "DTSTART:20071104T020000 was not serialized"); Assert.That(serialized, Does.Contain("DTSTART:20070311T020000"), "DTSTART:20070311T020000 was not serialized"); - }); - - //Assert.IsTrue(serialized.Contains("TZURL:http://tzurl.org/zoneinfo/America/Los_Angeles"), "TZURL:http://tzurl.org/zoneinfo/America/Los_Angeles was not serialized"); - //Assert.IsTrue(serialized.Contains("RDATE:19600424T010000"), "RDATE:19600424T010000 was not serialized"); // NodaTime doesn't match with what tzurl has + } } [Test, Category("VTimeZone")] @@ -189,14 +184,14 @@ public void VTimeZoneEuropeOsloShouldSerializeProperly() var serializer = new CalendarSerializer(); var serialized = serializer.SerializeToString(iCal); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(serialized, Does.Contain("TZID:Europe/Oslo"), "Time zone not found in serialization"); Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized"); Assert.That(serialized, Does.Contain("BEGIN:DAYLIGHT"), "The daylight timezone info was not serialized"); Assert.That(serialized, Does.Contain("BYDAY=-1SU;BYMONTH=3"), "BYDAY=-1SU;BYMONTH=3 was not serialized"); Assert.That(serialized, Does.Contain("BYDAY=-1SU;BYMONTH=10"), "BYDAY=-1SU;BYMONTH=10 was not serialized"); - }); + } } @@ -208,7 +203,7 @@ public void VTimeZoneAmericaAnchorageShouldSerializeProperly() // Unwrap the lines to make it easier to search for specific values var serialized = TextUtil.UnwrapLines(serializer.SerializeToString(iCal)); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(serialized, Does.Contain("TZID:America/Anchorage"), "Time zone not found in serialization"); Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized"); @@ -224,7 +219,7 @@ public void VTimeZoneAmericaAnchorageShouldSerializeProperly() Assert.That(Regex.IsMatch(serialized, $@"RDATE:.*\b19801026T020000\b", RegexOptions.Compiled, RegexDefaults.Timeout), Is.True, "RDATE:19731028T020000 was not serialized"); Assert.That(serialized, Does.Not.Contain("RDATE:19670401/P1D"), "RDate was not properly serialized for vtimezone, should be RDATE:19670401T000000"); Assert.That(serialized, Does.Contain("DTSTART:19420209T020000"), "DTSTART:19420209T020000 was not serialized"); - }); + } } [Test, Category("VTimeZone")] @@ -234,7 +229,7 @@ public void VTimeZoneAmericaEirunepeShouldSerializeProperly() var serializer = new CalendarSerializer(); var serialized = serializer.SerializeToString(iCal); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(serialized, Does.Contain("TZID:America/Eirunepe"), "Time zone not found in serialization"); Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized"); @@ -245,10 +240,9 @@ public void VTimeZoneAmericaEirunepeShouldSerializeProperly() Assert.That(serialized, Does.Contain("DTSTART:19320401T000000"), "DTSTART:19320401T000000 was not serialized"); Assert.That(serialized, Does.Contain("DTSTART:20080624T000000"), "DTSTART:20080624T000000 was not serialized"); Assert.That(serialized, Does.Contain("DTSTART:19501201T000000"), "DTSTART:19501201T000000 was not serialized"); - }); - - // Should not contain the following - Assert.That(serialized, Does.Not.Contain("RDATE:19501201T000000/P1D"), "The RDATE was not serialized correctly, should be RDATE:19501201T000000"); + // Should not contain the following + Assert.That(serialized, Does.Not.Contain("RDATE:19501201T000000/P1D"), "The RDATE was not serialized correctly, should be RDATE:19501201T000000"); + } } [Test, Category("VTimeZone")] @@ -258,7 +252,7 @@ public void VTimeZoneAmericaDetroitShouldSerializeProperly() var serializer = new CalendarSerializer(); var serialized = serializer.SerializeToString(iCal); - Assert.Multiple(() => + using (Assert.EnterMultipleScope()) { Assert.That(serialized, Does.Contain("TZID:America/Detroit"), "Time zone not found in serialization"); Assert.That(serialized, Does.Contain("BEGIN:STANDARD"), "The standard timezone info was not serialized"); @@ -268,7 +262,45 @@ public void VTimeZoneAmericaDetroitShouldSerializeProperly() Assert.That(serialized, Does.Contain("TZNAME:EST"), "EST was not serialized"); Assert.That(serialized, Does.Contain("DTSTART:20070311T020000"), "DTSTART:20070311T020000 was not serialized"); Assert.That(serialized, Does.Contain("DTSTART:20071104T020000"), "DTSTART:20071104T020000 was not serialized"); - }); + } + } + + [Test, Category("VTimeZone")] + public void RecurrenceId_IsCompatibleWith_RecurrenceInstance() + { +#pragma warning disable CS0618 // Type or member is obsolete + var dt = new CalDateTime("20250930"); + + var iCal = CreateTestCalendar("America/Detroit"); + + var tzInfo1 = iCal.TimeZones.First().TimeZoneInfos.First(); + tzInfo1.RecurrenceIdentifier = new RecurrenceIdentifier(dt); + + iCal = CreateTestCalendar("America/Detroit"); + var tzInfo2 = iCal.TimeZones.First().TimeZoneInfos.First(); + tzInfo2.RecurrenceId = dt.AddDays(1); + + iCal = CreateTestCalendar("America/Detroit"); + var tzInfo3 = iCal.TimeZones.First().TimeZoneInfos.First(); + tzInfo3.RecurrenceIdentifier = new RecurrenceIdentifier(dt, RecurrenceRange.ThisAndFuture); + + using (Assert.EnterMultipleScope()) + { + Assert.That(tzInfo1.RecurrenceId, Is.EqualTo(tzInfo1.RecurrenceIdentifier.StartTime)); + Assert.That(tzInfo1.RecurrenceIdentifier.Range, Is.EqualTo(RecurrenceRange.ThisInstance)); + + Assert.That(tzInfo1.TzId, Is.EqualTo("America/Detroit")); + + Assert.That(tzInfo2.RecurrenceIdentifier!.StartTime, Is.EqualTo(dt.AddDays(1))); + Assert.That(tzInfo2.RecurrenceId, Is.EqualTo(dt.AddDays(1))); + Assert.That(tzInfo2.RecurrenceIdentifier.Range, Is.EqualTo(RecurrenceRange.ThisInstance)); + + // RecurrenceId only supports ThisInstance implicitly, + // so RecurrenceInstance with ThisAndFuture returns null + Assert.That(tzInfo3.RecurrenceIdentifier.Range, Is.EqualTo(RecurrenceRange.ThisAndFuture)); + Assert.That(tzInfo3.RecurrenceId, Is.Null); + } +#pragma warning restore CS0618 // Type or member is obsolete } private static Calendar CreateTestCalendar(string tzId, DateTime? earliestTime = null, bool includeHistoricalData = true) diff --git a/Ical.Net/CalendarComponents/RecurringComponent.cs b/Ical.Net/CalendarComponents/RecurringComponent.cs index e9f24ea2..967e8930 100644 --- a/Ical.Net/CalendarComponents/RecurringComponent.cs +++ b/Ical.Net/CalendarComponents/RecurringComponent.cs @@ -10,7 +10,6 @@ using Ical.Net.DataTypes; using Ical.Net.Evaluation; using Ical.Net.Proxies; -using Ical.Net.Utility; namespace Ical.Net.CalendarComponents; @@ -113,9 +112,30 @@ public virtual IList RecurrenceRules set => Properties.Set("RRULE", value); } + /// + /// Gets or sets the recurrence identifier for a specific instance of a recurring event. + /// + /// Use instead, which + /// supports the RANGE parameter for recurring events. + [Obsolete("Use RecurrenceIdentifier instead, which supports the RANGE parameter.")] public virtual CalDateTime? RecurrenceId { - get => Properties.Get("RECURRENCE-ID"); + get => RecurrenceIdentifier?.Range == RecurrenceRange.ThisInstance ? RecurrenceIdentifier.StartTime : null; + set => RecurrenceIdentifier = value is null ? null : new RecurrenceIdentifier(value, RecurrenceRange.ThisInstance); + } + + /// + /// Gets or sets the recurrence identifier for a specific instance of a recurring event. + /// + /// The sets the scope of the recurrence instance: + /// With , the instance is limited to the specific + /// occurrence identified by the .
+ /// With , the instance applies to the specified + /// and all future occurrences. + ///
+ public virtual RecurrenceIdentifier? RecurrenceIdentifier + { + get => Properties.Get("RECURRENCE-ID"); set => Properties.Set("RECURRENCE-ID", value); } diff --git a/Ical.Net/DataTypes/RecurrenceIdentifier.cs b/Ical.Net/DataTypes/RecurrenceIdentifier.cs new file mode 100644 index 00000000..c446558f --- /dev/null +++ b/Ical.Net/DataTypes/RecurrenceIdentifier.cs @@ -0,0 +1,92 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +using System; + +namespace Ical.Net.DataTypes; + +/// +/// Represents the identifier for a specific instance of a recurring event. +/// +/// +/// RECURRENCE-ID:20250401T133000Z: The instance for this date only +/// RECURRENCE-ID;RANGE=THISANDFUTURE:20250401T133000Z: This specifies the instance for this date and all future instances. +/// +/// +/// This class is used to uniquely identify a particular occurrence within a recurring series. The +/// identifier consists of a date and a recurrence range, which together specify the instance. +/// +public class RecurrenceIdentifier : IComparable +{ + /// + /// Initializes a new instance with the specified start time and recurrence range. + /// + /// The start time of the recurrence instance. + /// The recurrence range that defines the scope of the instance. + /// If is , the default value + /// is used. + public RecurrenceIdentifier(CalDateTime start, RecurrenceRange? range = null) + { + StartTime = start; + Range = range ?? RecurrenceRange.ThisInstance; + } + + /// + /// Gets or sets the start date and time of the specific instance within the recurring series + /// that this identifier refers to and that should get overridden. + /// + public CalDateTime StartTime { get; set; } + + /// + /// Gets or sets the recurrence range that determines the scope of the recurrence pattern. + /// + public RecurrenceRange Range { get; set; } + + /// + /// Compares the current instance with another + /// object and returns an integer that indicates whether the current + /// instance precedes, follows, or occurs in the same position in the + /// sort order as the other object. + /// + /// + /// The comparison is performed first by the property. If the values are equal, the property is used as a tiebreaker. + /// + /// + /// The to compare with the current instance. + /// Can be . + /// + public int CompareTo(RecurrenceIdentifier? other) + { + if (other is null) + { + return 1; + } + + var startComparison = StartTime.CompareTo(other.StartTime); + if (startComparison != 0) + { + return startComparison; + } + + return Range.CompareTo(other.Range); + } +} + +/// +/// The range of recurrence instances that a applies to. +/// +public enum RecurrenceRange +{ + /// + /// The scope is limited to the specific instance identified by + /// the . + /// + ThisInstance, + /// + /// Represents a date range that includes the specified date and all future dates. + /// + ThisAndFuture +} diff --git a/Ical.Net/Serialization/DataTypeMapper.cs b/Ical.Net/Serialization/DataTypeMapper.cs index 5eace1da..2064e4c2 100644 --- a/Ical.Net/Serialization/DataTypeMapper.cs +++ b/Ical.Net/Serialization/DataTypeMapper.cs @@ -47,7 +47,7 @@ public DataTypeMapper() AddPropertyMapping("PERCENT-COMPLETE", typeof(int), false); AddPropertyMapping("PRIORITY", typeof(int), false); AddPropertyMapping("RDATE", typeof(PeriodList), false); - AddPropertyMapping("RECURRENCE-ID", typeof(CalDateTime), false); + AddPropertyMapping("RECURRENCE-ID", typeof(RecurrenceIdentifier), false); AddPropertyMapping("RELATED-TO", typeof(string), false); AddPropertyMapping("REQUEST-STATUS", typeof(RequestStatus), false); AddPropertyMapping("REPEAT", typeof(int), false); diff --git a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs index 73848981..67fbc2e6 100644 --- a/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs +++ b/Ical.Net/Serialization/DataTypes/DateTimeSerializer.cs @@ -3,13 +3,14 @@ // Licensed under the MIT license. // -using Ical.Net.DataTypes; using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using System.Text.RegularExpressions; +using Ical.Net.DataTypes; +using Ical.Net.Utility; namespace Ical.Net.Serialization.DataTypes; @@ -112,18 +113,6 @@ public DateTimeSerializer(SerializationContext ctx) : base(ctx) { } return res; } - public IReadOnlyList GetParameters(object value) - { - if (value is not CalDateTime dt) - return []; - - var res = new List(2); - if (!dt.IsFloating && !dt.IsUtc) - res.Add(new CalendarParameter("TZID", dt.TzId)); - - if (!dt.HasTime) - res.Add(new CalendarParameter("VALUE", "DATE")); - - return res; - } + public IReadOnlyList GetParameters(object? value) + => ParameterProviderHelper.GetCalDateTimeParameters(value); } diff --git a/Ical.Net/Serialization/DataTypes/RecurrenceIdentifierSerializer.cs b/Ical.Net/Serialization/DataTypes/RecurrenceIdentifierSerializer.cs new file mode 100644 index 00000000..5ab6cf92 --- /dev/null +++ b/Ical.Net/Serialization/DataTypes/RecurrenceIdentifierSerializer.cs @@ -0,0 +1,96 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.IO; +using Ical.Net.DataTypes; +using Ical.Net.Logging; +using Ical.Net.Utility; + +namespace Ical.Net.Serialization.DataTypes; + +/// +/// Provides serialization and deserialization functionality for objects. +/// +public class RecurrenceIdentifierSerializer : SerializerBase, IParameterProvider +{ + private readonly ILogger _logger; + + /// + /// This constructor is required for the SerializerFactory to work. + /// + public RecurrenceIdentifierSerializer() + { + _logger = LoggingProvider.CreateLogger(); + } + + /// + /// Creates a new instance of the class. + /// + /// + public RecurrenceIdentifierSerializer(SerializationContext ctx) : base(ctx) + { + _logger = LoggingProvider.CreateLogger(); + } + + public override Type TargetType => typeof(RecurrenceIdentifier); + + public override string? SerializeToString(object? obj) + { + if (obj is not RecurrenceIdentifier rid) + { + return null; + } + + if (!Enum.IsDefined(typeof(RecurrenceRange), rid.Range)) + { + _logger.LogWarning("Ignored invalid RANGE parameter '{Range}' for RECURRENCE-ID", rid.Range); + } + + var factory = GetService(); + var dtSerializer = factory.Build(typeof(CalDateTime), SerializationContext) as DateTimeSerializer; + + return dtSerializer!.SerializeToString(rid.StartTime); + } + + public override object? Deserialize(TextReader tr) + { + var value = tr.ReadToEnd(); + + var parent = SerializationContext.Peek(); + + // The associated object is an ICalendarObject of type CalendarProperty + // that eventually contains a "RANGE" parameter deserialized in a prior step + var rangeString = (parent as ICalendarParameterCollectionContainer)?.Parameters.Get("RANGE")?.ToUpperInvariant(); + + RecurrenceRange recurrenceRange; + switch (rangeString) + { + case null: + case "": + recurrenceRange = RecurrenceRange.ThisInstance; + break; + case "THISANDFUTURE": + recurrenceRange = RecurrenceRange.ThisAndFuture; + break; + default: + recurrenceRange = RecurrenceRange.ThisInstance; + _logger.LogWarning("Ignored invalid RANGE parameter '{Range}' for RECURRENCE-ID", rangeString); + break; + } + + var factory = GetService(); + + var dtSerializer = factory.Build(typeof(CalDateTime), SerializationContext) as IStringSerializer; + + return dtSerializer!.Deserialize(new StringReader(value)) is not CalDateTime start + ? null + : new RecurrenceIdentifier(start, recurrenceRange); + } + + public IReadOnlyList GetParameters(object? value) + => ParameterProviderHelper.GetRecurrenceIdentifierParameters(value); +} diff --git a/Ical.Net/Serialization/IParameterProvider.cs b/Ical.Net/Serialization/IParameterProvider.cs index fdffe810..d623b34c 100644 --- a/Ical.Net/Serialization/IParameterProvider.cs +++ b/Ical.Net/Serialization/IParameterProvider.cs @@ -8,5 +8,5 @@ namespace Ical.Net.Serialization; internal interface IParameterProvider { - IReadOnlyList GetParameters(object value); + IReadOnlyList GetParameters(object? value); } diff --git a/Ical.Net/Serialization/PropertySerializer.cs b/Ical.Net/Serialization/PropertySerializer.cs index a085d83d..6b5fcdc2 100644 --- a/Ical.Net/Serialization/PropertySerializer.cs +++ b/Ical.Net/Serialization/PropertySerializer.cs @@ -89,7 +89,7 @@ private void SerializeValue(StringBuilder result, ICalendarProperty prop, object // Get the list of parameters we'll be serializing var parameterList = - (IList?)(value as ICalendarDataType)?.Parameters + (IList?)(value as ICalendarParameterCollectionContainer)?.Parameters ?? (valueSerializer as IParameterProvider)?.GetParameters(value).ToList() ?? (IList)prop.Parameters; diff --git a/Ical.Net/Serialization/SerializerFactory.cs b/Ical.Net/Serialization/SerializerFactory.cs index 06ae0616..d2f29db4 100644 --- a/Ical.Net/Serialization/SerializerFactory.cs +++ b/Ical.Net/Serialization/SerializerFactory.cs @@ -23,73 +23,44 @@ public SerializerFactory() /// /// Returns a serializer that can be used to serialize and object /// of type . - /// - /// TODO: Add support for caching. - /// + /// The fallback for unknown s is . /// /// The type of object to be serialized. /// The serialization context. public virtual ISerializer? Build(Type? objectType, SerializationContext ctx) { - if (objectType == null) + return objectType switch { - return null; - } - ISerializer s; - - if (typeof(Calendar).IsAssignableFrom(objectType)) - { - s = new CalendarSerializer(ctx); - } - else if (typeof(ICalendarComponent).IsAssignableFrom(objectType)) - { - s = typeof(CalendarEvent).IsAssignableFrom(objectType) - ? new EventSerializer(ctx) - : new ComponentSerializer(ctx); - } - else if (typeof(ICalendarProperty).IsAssignableFrom(objectType)) - { - s = new PropertySerializer(ctx); - } - else if (typeof(CalendarParameter).IsAssignableFrom(objectType)) - { - s = new ParameterSerializer(ctx); - } - else if (typeof(string).IsAssignableFrom(objectType)) - { - s = new StringSerializer(ctx); - } - else if (objectType.GetTypeInfo().IsEnum) - { - s = new EnumSerializer(objectType, ctx); - } - else if (typeof(Duration).IsAssignableFrom(objectType)) - { - s = new DurationSerializer(ctx); - } - else if (typeof(CalDateTime).IsAssignableFrom(objectType)) - { - s = new DateTimeSerializer(ctx); - } - else if (typeof(int).IsAssignableFrom(objectType)) - { - s = new IntegerSerializer(ctx); - } - else if (typeof(Uri).IsAssignableFrom(objectType)) - { - s = new UriSerializer(ctx); - } - else if (typeof(ICalendarDataType).IsAssignableFrom(objectType)) - { - s = _mDataTypeSerializerFactory.Build(objectType, ctx)!; - } - // Default to a string serializer, which simply calls - // ToString() on the value to serialize it. - else - { - s = new StringSerializer(ctx); - } - - return s; + null => null, + var t when typeof(Calendar).IsAssignableFrom(t) + => new CalendarSerializer(ctx), + var t when typeof(ICalendarComponent).IsAssignableFrom(t) + => typeof(CalendarEvent).IsAssignableFrom(t) + ? new EventSerializer(ctx) + : new ComponentSerializer(ctx), + var t when typeof(ICalendarProperty).IsAssignableFrom(t) + => new PropertySerializer(ctx), + var t when typeof(CalendarParameter).IsAssignableFrom(t) + => new ParameterSerializer(ctx), + var t when typeof(string).IsAssignableFrom(t) + => new StringSerializer(ctx), + var t when t.GetTypeInfo().IsEnum + => new EnumSerializer(t, ctx), + var t when typeof(Duration).IsAssignableFrom(t) + => new DurationSerializer(ctx), + var t when typeof(CalDateTime).IsAssignableFrom(t) + => new DateTimeSerializer(ctx), + var t when typeof(int).IsAssignableFrom(t) + => new IntegerSerializer(ctx), + var t when typeof(Uri).IsAssignableFrom(t) + => new UriSerializer(ctx), + var t when typeof(RecurrenceIdentifier).IsAssignableFrom(t) + => new RecurrenceIdentifierSerializer(ctx), + var t when typeof(ICalendarDataType).IsAssignableFrom(t) + => _mDataTypeSerializerFactory.Build(t, ctx)!, + // Default to a string serializer, which simply calls + // ToString() on the value to serialize it. + _ => new StringSerializer(ctx) + }; } } diff --git a/Ical.Net/Utility/ParameterProviderHelper.cs b/Ical.Net/Utility/ParameterProviderHelper.cs new file mode 100644 index 00000000..e5cf5f49 --- /dev/null +++ b/Ical.Net/Utility/ParameterProviderHelper.cs @@ -0,0 +1,65 @@ +// +// Copyright ical.net project maintainers and contributors. +// Licensed under the MIT license. +// + +using System.Collections.Generic; +using Ical.Net.DataTypes; + +namespace Ical.Net.Utility; + +/// +/// Helpers for classes that implement . +/// The method +/// should use the helper in this class to get parameters for specific types. +/// +internal static class ParameterProviderHelper +{ + /// + /// Retrieves a collection of calendar parameters based on the properties + /// of the specified value. + /// + /// + /// A list of objects representing + /// the parameters derived from the value. + /// If is not a , + /// an empty list is returned. + /// + public static List GetCalDateTimeParameters(object? value) + { + if (value is not CalDateTime dt) + return []; + + var param = new List(2); + if (dt is { IsFloating: false, IsUtc: false }) + param.Add(new CalendarParameter("TZID", dt.TzId)); + + if (!dt.HasTime) + param.Add(new CalendarParameter("VALUE", "DATE")); + + return param; + } + + /// + /// Generates a list of calendar parameters based on the provided recurrence identifier. + /// + /// + /// A list of objects representing the recurrence identifier parameters. The list + /// includes parameters for the start time and, if applicable, a "RANGE" parameter with the value "THISANDFUTURE". + /// Returns an empty list if is not a . + /// + public static List GetRecurrenceIdentifierParameters(object? value) + { + if (value is not RecurrenceIdentifier rid) + return []; + + var param = new List(3); + param.AddRange(GetCalDateTimeParameters(rid.StartTime)); + if (rid.Range == RecurrenceRange.ThisAndFuture) + { + param.Add(new CalendarParameter("RANGE", "THISANDFUTURE")); + } + + return param; + } +} diff --git a/Ical.Net/VTimeZoneInfo.cs b/Ical.Net/VTimeZoneInfo.cs index 4fb544bc..58bcefe5 100644 --- a/Ical.Net/VTimeZoneInfo.cs +++ b/Ical.Net/VTimeZoneInfo.cs @@ -3,6 +3,7 @@ // Licensed under the MIT license. // +using System; using System.Collections.Generic; using System.Runtime.Serialization; using Ical.Net.CalendarComponents; @@ -134,9 +135,30 @@ public virtual IList RecurrenceRules set => Properties.Set("RRULE", value); } + /// + /// Gets or sets the recurrence identifier for a specific instance of a recurring event. + /// + /// Use instead, which + /// supports the RANGE parameter for recurring events. + [Obsolete("Use RecurrenceIdentifier instead, which supports the RANGE parameter.")] public virtual CalDateTime? RecurrenceId { - get => Properties.Get("RECURRENCE-ID"); + get => RecurrenceIdentifier?.Range == RecurrenceRange.ThisInstance ? RecurrenceIdentifier.StartTime : null; + set => RecurrenceIdentifier = value is null ? null : new RecurrenceIdentifier(value, RecurrenceRange.ThisInstance); + } + + /// + /// Gets or sets the recurrence identifier for a specific instance of a recurring event. + /// + /// The sets the scope of the recurrence instance: + /// With , the instance is limited to the specific + /// occurrence identified by the .
+ /// With , the instance applies to the specified + /// and all future occurrences. + ///
+ public virtual RecurrenceIdentifier? RecurrenceIdentifier + { + get => Properties.Get("RECURRENCE-ID"); set => Properties.Set("RECURRENCE-ID", value); }