Skip to content

Telerik's RadScheduleView interop

Rian Stockbower edited this page Jun 5, 2018 · 1 revision

It's possible to do a bunch of normalization to interop between Telerik's RadScheduleView controls, and ical.net using iCalendar data directly. In fact, that's probably the "right" way to do the interop, because that's the intent of the iCalendar spec.

Nonetheless there may be reasons that's not palatable or performant enough. To that end, I have written a TelerikConverter that maps between IAppointment and ical.net's CalendarEvent.

Each method is written the way it is for a reason. Especially the cases where you think you can return an empty collection instead of null: you can't. Telerik treats an empty collection as a "try everything from now until the end of time" in one of its setters. If something is empty, you must return null. I learned that the hard way.

The converter

public static class TelerikConverter
{
    /// <summary>
    /// Telerik's RadScheduleView calendar controls model recurrences with a single RRULE + multiple EXDATEs. Telerik controls do NOT support more than one
    /// RRULE per Appointment. We can assume that events that we want to consume with Telerik only have one RRULE, because those events were likely created
    /// using Telerik controls in the first place.
    /// </summary>
    public static TelerikRecurrencePattern GetRecurrencePattern(RecurrencePattern icalRRule)
    {
        if (icalRRule == null)
        {
            return null;
        }

        var telerikPattern = new TelerikRecurrencePattern
        {
            FirstDayOfWeek = GetFirstDayOfWeek(icalRRule),
            Interval = GetInterval(icalRRule),
            Frequency = GetRecurrenceFrequency(icalRRule),
            MaxOccurrences = GetMaxOccurrences(icalRRule),
            RecursUntil = GetRecursUntil(icalRRule),
            DaysOfWeekMask = GetDaysOfWeekMask(icalRRule),
            DayOrdinal = GetDayOrdinal(icalRRule),
            MonthOfYear = GetMonthOfYear(icalRRule),
            DaysOfMonth = GetDaysOfMonth(icalRRule),
            HoursOfDay = GetHoursOfDay(icalRRule),
            MinutesOfHour = GetMinutesOfHour(icalRRule),
        };
        return telerikPattern;
    }

    /// <summary>
    /// Converts ical.net's FirstDayOfWeek to Telerik's FirstDayOfWeek, which are identical.
    /// </summary>
    public static DayOfWeek GetFirstDayOfWeek(RecurrencePattern icalRRule)
        => icalRRule?.FirstDayOfWeek ?? DayOfWeek.Monday;

    /// <summary>
    /// Convert's ical.net Interval property to Telerik's Interval property, which are identical.
    /// </summary>
    public static int GetInterval(RecurrencePattern icalRRule)
        => icalRRule?.Interval ?? 1;

    /// <summary>
    /// Converts ical.net's FrequencyType enum to Telerik's RecurrenceFrequency enum
    /// </summary>
    public static RecurrenceFrequency GetRecurrenceFrequency(RecurrencePattern icalRRule)
    {
        switch (icalRRule.Frequency)
        {
            case FrequencyType.None:
                return RecurrenceFrequency.None;
            case FrequencyType.Secondly:
                return RecurrenceFrequency.Secondly;
            case FrequencyType.Minutely:
                return RecurrenceFrequency.Minutely;
            case FrequencyType.Hourly:
                return RecurrenceFrequency.Hourly;
            case FrequencyType.Daily:
                return RecurrenceFrequency.Daily;
            case FrequencyType.Weekly:
                return RecurrenceFrequency.Weekly;
            case FrequencyType.Monthly:
                return RecurrenceFrequency.Monthly;
            case FrequencyType.Yearly:
                return RecurrenceFrequency.Yearly;
            default:
                throw new ArgumentOutOfRangeException(nameof(icalRRule.Frequency), icalRRule.Frequency, null);
        }
    }

    /// <summary>
    /// Telerik controls use `null` to mean `int.MaxValue` for the maximum number of occurrences. ical.net uses 0 to represent the same thing.
    /// Returns null if the RRULE's COUNT is 0.
    /// </summary>
    public static int? GetMaxOccurrences(RecurrencePattern icalRRule)
    {
        if (icalRRule == null || icalRRule.Count <= 0)
        {
            return null;
        }

        return icalRRule.Count;
    }

    /// <summary>
    /// Convert's ical.net's Until property to Telerik's RecursUntil property.
    /// 
    /// Telerik uses `null` to represent `DateTime.MaxValue`, whereas ical.net uses `DateTime.MinValue` to represent an unbounded end. Returns null if the
    /// event is unbounded.
    /// </summary>
    public static DateTime? GetRecursUntil(RecurrencePattern icalRRule)
    {
        if (icalRRule == null || icalRRule.Until == DateTime.MinValue)
        {
            return null;
        }

        return icalRRule.Until;
    }

    /// <summary>
    /// Converts ical.net's ByDay collection to Telerik's RecurrenceDay bitmask. If the RRULE has a DAILY frequency, returns EveryDay. If there are no days
    /// specified, returns RecurrenceDays.None.
    /// </summary>
    public static RecurrenceDays GetDaysOfWeekMask(RecurrencePattern icalRRule)
    {
        if (icalRRule.Frequency == FrequencyType.Daily)
        {
            return RecurrenceDays.EveryDay;
        }

        if (icalRRule.ByDay == null || icalRRule.ByDay.Count == 0)
        {
            return RecurrenceDays.None;
        }

        var mask = RecurrenceDays.None;
        foreach (var day in icalRRule.ByDay?.Select(d => d.DayOfWeek))
        {
            switch (day)
            {
                case DayOfWeek.Sunday:
                    mask |= RecurrenceDays.Sunday;
                    break;
                case DayOfWeek.Monday:
                    mask |= RecurrenceDays.Monday;
                    break;
                case DayOfWeek.Tuesday:
                    mask |= RecurrenceDays.Tuesday;
                    break;
                case DayOfWeek.Wednesday:
                    mask |= RecurrenceDays.Wednesday;
                    break;
                case DayOfWeek.Thursday:
                    mask |= RecurrenceDays.Thursday;
                    break;
                case DayOfWeek.Friday:
                    mask |= RecurrenceDays.Friday;
                    break;
                case DayOfWeek.Saturday:
                    mask |= RecurrenceDays.Saturday;
                    break;
                default:
                    throw new ArgumentOutOfRangeException();
            }
        }

        return mask;
    }

    /// <summary>
    /// Converts ical.net's ByDay list of WeekDays to Telerik's integer DayOrdinal property.
    /// 
    /// DayOrdinal refers to the nth occurrence of a day. Negative values subtract, so -2 would be the second to last DayOfWeek of the recurring interval
    /// (week, month, or year). ical.net models this idea differently, setting it as the Offset property on a WeekDay object. Telerik controls only support
    /// a single DayOrdinal value, and since these events were created by Telerik controls, it's reasonable to assume that there will only be one value,
    /// and to throw an exception if there's more than one.
    ///
    /// Calendar event examples from the United States:
    /// - Thanksgiving is the fourth Thursday in November
    /// - Presidents' Day is the third Monday in February
    ///
    /// Returns null if there are no ordinal days specified in the recurrence rule.
    /// </summary>
    public static int? GetDayOrdinal(RecurrencePattern icalRRule)
    {
        if (icalRRule?.ByDay?.Any() != true)
        {
            return null;
        }

        return icalRRule.ByDay.SingleOrDefault(d => d.Offset <= 366 && d.Offset >= -366)?.Offset;
    }

    /// <summary>
    /// MonthOfYear refers to the ordinal month of the year. Valid values are 1-12. ical.net supports multiple months of the year for an RRULE, but
    /// Telerik does not. Since these events were created by Telerik controls, it's reasonable to assume that there will only be one value, and to
    /// throw an exception if there's more than one.
    ///
    /// Returns null if there are none.
    /// </summary>
    public static int? GetMonthOfYear(RecurrencePattern icalRRule)
    {
        if (icalRRule?.ByMonth?.Any() != true)
        {
            return null;
        }

        return icalRRule.ByMonth?.SingleOrDefault(m => m > 0 && m <= 13);
    }

    /// <summary>
    /// Converts ical.net's ByMonthDay property to Telerik's DaysOfMonth property. Returns null if there are none.
    /// </summary>
    public static int[] GetDaysOfMonth(RecurrencePattern icalRRule)
    {
        if (icalRRule?.ByMonthDay?.Any() != true)
        {
            return null;
        }

        return icalRRule.ByMonthDay.ToArray();
    }

    /// <summary>
    /// Converts ical.net's ByHour property to Telerik's HoursOfDay property. Returns null if there are none.
    /// </summary>
    public static int[] GetHoursOfDay(RecurrencePattern icalRRule)
    {
        if (icalRRule?.ByHour?.Any() != true)
        {
            return null;
        }

        return icalRRule.ByHour.ToArray();
    }

    /// <summary>
    /// Converts ical.net's ByMinute property to Telerik's MinutesOfHour property. Returns null if there are none.
    /// </summary>
    public static int[] GetMinutesOfHour(RecurrencePattern icalRRule)
    {
        if (icalRRule?.ByMinute?.Any() != true)
        {
            return null;
        }

        return icalRRule.ByMinute.ToArray();
    }

    /// <summary>
    /// Converts ical.net's collection of EXDATE PeriodLists to Telerik's IExceptionOccurrence. Returns null if there are none.
    /// </summary>
    public static List<IExceptionOccurrence> GetExceptionOccurrences(IList<IPeriodList> exDates)
    {
        if (exDates?.Any() != true)
        {
            return null;
        }

        var exceptions = exDates
            .SelectMany(periodList => periodList)
            .Select(period => (IExceptionOccurrence)new ExceptionOccurrence { ExceptionDate = period.StartTime.Value })
            .ToList();
        return exceptions;
    }
}

The unit tests

public class TelerikConverterTests
{
    [Test, TestCaseSource(nameof(GetDaysOfWeekMask_TestCases))]
    public RecurrenceDays GetDaysOfWeekMask_Tests(List<IWeekDay> weekDays)
    {
        var rPattern = new IcalRecurrencePattern
        {
            ByDay = weekDays,
        };
        return TelerikConverter.GetDaysOfWeekMask(rPattern);
    }

    public static IEnumerable<ITestCaseData> GetDaysOfWeekMask_TestCases()
    {
        var monFri = new List<IWeekDay> {new WeekDay(DayOfWeek.Monday), new WeekDay(DayOfWeek.Friday)};
        const RecurrenceDays expectedMonFri = RecurrenceDays.Monday | RecurrenceDays.Friday;
        yield return new TestCaseData(monFri)
            .Returns(expectedMonFri)
            .SetName("DayOfWeek = Monday + Friday");

        var weekend = new List<IWeekDay> {new WeekDay(DayOfWeek.Saturday), new WeekDay(DayOfWeek.Sunday)};
        yield return new TestCaseData(weekend)
            .Returns(RecurrenceDays.WeekendDays)
            .SetName("DayOfWeek = Saturday + Sunday returns RecurrenceDays.WeekendDays");

        var everyDay = Enum.GetValues(typeof(DayOfWeek))
            .Cast<DayOfWeek>()
            .Select(d => (IWeekDay)new WeekDay(d))
            .ToList();
        yield return new TestCaseData(everyDay)
            .Returns(RecurrenceDays.EveryDay)
            .SetName("DayOfWeek = every day returns RecurrenceDays.EveryDay");
    }

    [Test]
    public void MatchingDefaultValuesShouldMatch()
    {
        // As absurd as this appears, I wrote a bug asserting a wrong default value and this test catches it.
        var icalRRule = new IcalRecurrencePattern("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
        var converted = TelerikConverter.GetRecurrencePattern(icalRRule);
        
        Assert.AreEqual(icalRRule.Interval, converted.Interval);
    }

    [Test, TestCaseSource(nameof(BoundaryConversionTestCases))]
    public void BoundaryConversionTests(IcalRecurrencePattern rrule, DateTime? expectedUntil, int? expectedCount)
    {
        var actualUntil = TelerikConverter.GetRecursUntil(rrule);
        Assert.AreEqual(expectedUntil, actualUntil);

        var actualOccurrenceCount = TelerikConverter.GetMaxOccurrences(rrule);
        Assert.AreEqual(expectedCount, actualOccurrenceCount);
    }

    public static IEnumerable<ITestCaseData> BoundaryConversionTestCases()
    {
        var bothUnbounded = new IcalRecurrencePattern("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
        yield return new TestCaseData(bothUnbounded, null, null)
            .SetName("Telerik represents recurring appointments with no Until or MaxOccurrence boundaries as null");

        var countBounded = new IcalRecurrencePattern("FREQ=DAILY;COUNT=5");
        yield return new TestCaseData(countBounded, null, 5)
            .SetName("RRULE bounded with a COUNT should have a null UNTIL value, and a non-null COUNT value");

        var untilBounded = new IcalRecurrencePattern("FREQ=DAILY;UNTIL=19730429T070000Z");
        var expectedUntil = DateTime.SpecifyKind(DateTime.Parse("1973-04-29T07:00:00"), DateTimeKind.Utc);
        yield return new TestCaseData(untilBounded, expectedUntil, null)
            .SetName("RRULE bounded with an UNTIL should have a null COUNT value and a non-null UNTIL value");
    }

    [Test]
    public void SymmetricDeserializationTests()
    {
        // This test takes about 1.5 seconds to run. Tests are (more or less) in order from fastest to slowest.
        var icalEvents = DeserializeIcalEvents(_icalSortedByUid);
        var telerikAppointments = LoadWithTelerik(_icalSortedByUid);
        Assert.AreEqual(icalEvents.Count, telerikAppointments.Count);

        var telerikConverterAppointments = icalEvents
            .AsParallel()
            .AsOrdered()
            .Select(calendarEvent => TelerikCalendarFactory.Factory.GetAppointment(calendarEvent))
            .ToList();
        Assert.AreEqual(telerikConverterAppointments.Count, telerikAppointments.Count);

        for (var i = 0; i < telerikConverterAppointments.Count; i++)
        {
            var converterAppt = telerikConverterAppointments[i];
            var telerikOrig = telerikAppointments[i];

            Assert.AreEqual(converterAppt.IsAllDayEvent, telerikOrig.IsAllDayEvent);

            // The Subject/Summary differs between the incoming ical appointment and the final DowntimeViewModel where we build summaries dynamically
            // so we don't need to assert Subject equality
            Assert.AreEqual(converterAppt.Start, telerikOrig.Start);
            Assert.AreEqual(converterAppt.End, telerikOrig.End);

            // We can't (yet) assert that the TimeZones match because we are coercing them to UTC, even though they are local.
            // TODO: Uncomment this check when we have real, local time zones working with Telerik
            //Assert.AreEqual(left.TimeZone, right.TimeZone);

            CollectionAssert.AreEqual(converterAppt.Resources, telerikOrig.Resources);

            var converterPattern = converterAppt.RecurrenceRule?.Pattern;
            var originalPattern = telerikOrig.RecurrenceRule?.Pattern;
            var patternsAreNotNull = converterPattern != null && originalPattern != null;
            if (patternsAreNotNull)
            {
                Assert.AreEqual(converterPattern.MaxOccurrences, originalPattern.MaxOccurrences);
                Assert.AreEqual(converterPattern.RecursUntil, originalPattern.RecursUntil);
                Assert.AreEqual(converterPattern.FirstDayOfWeek, originalPattern.FirstDayOfWeek);
                Assert.AreEqual(converterPattern.Interval, originalPattern.Interval);
                Assert.AreEqual(converterPattern.Frequency, originalPattern.Frequency);

                Assert.AreEqual(converterPattern.DaysOfWeekMask, originalPattern.DaysOfWeekMask);
                Assert.AreEqual(converterPattern.DayOrdinal, originalPattern.DayOrdinal);
                Assert.AreEqual(converterPattern.MonthOfYear, originalPattern.MonthOfYear);
                CollectionAssert.AreEqual(converterPattern.DaysOfMonth, originalPattern.DaysOfMonth);
                CollectionAssert.AreEqual(converterPattern.HoursOfDay, originalPattern.HoursOfDay);
                CollectionAssert.AreEqual(converterPattern.MinutesOfHour, originalPattern.MinutesOfHour);
            }
            else
            {
                Assert.IsTrue(converterPattern == null && originalPattern == null);
            }

            var converterExceptions = converterAppt.RecurrenceRule?.Exceptions.Select(e => e.ExceptionDate);
            var originalExceptions = telerikOrig.RecurrenceRule?.Exceptions.Select(e => e.ExceptionDate);
            CollectionAssert.AreEqual(converterExceptions, originalExceptions);
        }

        var searchStart = DateTime.SpecifyKind(DateTime.Parse("2018-01-01T00:00:00Z"), DateTimeKind.Utc);
        var searchEnd = DateTime.SpecifyKind(DateTime.Parse("2019-01-01T00:00:00Z"), DateTimeKind.Utc);

        var icalOccurrences = new HashSet<IcalOccurrence>(icalEvents.AsParallel().SelectMany(e => e.GetOccurrences(searchStart, searchEnd)));
        var telerikOccurrences = new HashSet<TelerikOccurrence>(telerikAppointments.AsParallel().SelectMany(a => a.GetOccurrences(searchStart, searchEnd)));
        Assert.AreEqual(icalOccurrences.Count, telerikOccurrences.Count);

        var telerikConverterOccurrences = new HashSet<TelerikOccurrence>(telerikConverterAppointments.AsParallel().SelectMany(a => a.GetOccurrences(searchStart, searchEnd)));
        Assert.AreEqual(telerikConverterOccurrences.Count, telerikOccurrences.Count);

        // Each instance of IAppointment is subtly different: subjects could be different, Resource collections could be different. It should be
        // sufficiently thorough to assert identical counts for the first pass, followed by contriving simpler Occurrence objects that don't include references
        // to their parent Appointments.
        var converterNoMaster = new HashSet<TelerikOccurrence>(telerikConverterOccurrences
            .Select(o => TelerikOccurrence.CreateOccurrence(master: null, start: o.Start, end: o.End)));
        var originalNoMaster = new HashSet<TelerikOccurrence>(telerikOccurrences
            .Select(o => TelerikOccurrence.CreateOccurrence(master: null, start: o.Start, end: o.End)));
        Assert.IsTrue(converterNoMaster.SetEquals(originalNoMaster));
    }

    private static List<IEvent> DeserializeIcalEvents(string calendar)
    {
        using (var reader = new StringReader(calendar))
        {
            return Calendar.LoadFromStream(reader)
                .SelectMany(cal => cal.Events)
                .ToList();
        }
    }

    private static List<IAppointment> LoadWithTelerik(string calendar)
    {
        var importer = new AppointmentCalendarImporter();
        using (var reader = new StringReader(calendar))
        {
            var appointments = importer.Import(reader).ToList();

            // This is always empty. Is that right?
            //var withResources = appointments.Where(a => a.Resources.Count > 0).ToList();

            return appointments;
        }
    }

    private const string _icalSortedByUid = @""; // Stick your serialized calendar data in here