From 1330b86524cd10e7decae42b0afe5188bd258f26 Mon Sep 17 00:00:00 2001 From: Markus Minichmayr Date: Mon, 9 Dec 2024 09:36:45 +0100 Subject: [PATCH] Let `GetOccurrences()` et al return an `IEnumerable` rather than `HashSet`. (#665) * CollectionHelpers: Introduce streaming operators on ordered `IEnumerable`s, `OrderedMerge`, `OrderedMergeMany`, `OrderedDistinct`. * Modify `IEvaluator.Evaluate()`, `IGetOccurrences*.GetOccurrences()`, etc. to return an ordered `IEnumerable` rather than a `HashSet` and modify the implementations to generate results on iteration rather than fully enumerating the full result set upfront. * IGetOccurrences, IEvaluator: Allow querying occurrences without specifying bounds. * Rename `IGetOccurrences.GetOccurrences(dt)` overload for getting the occurrences of a single day to `GetOccurrencesOfDay()` to avoid ambiguities with the other overloads. * CalendarCollection: Simplify code, avoid redundancies and add comments. * VTimeZoneInfo: Implement missing `GetHashCode()`. * Remove `GetOccurrences.GetOccurrencesOfDay()` and related. * NOSONAR * Add default params in overridden methods to make Sonarcloud happy. --- Ical.Net.Tests/CollectionHelpersTests.cs | 68 ++++++- Ical.Net.Tests/DeserializationTests.cs | 2 +- Ical.Net.Tests/DocumentationExamples.cs | 11 +- Ical.Net.Tests/GetOccurrenceTests.cs | 9 +- Ical.Net.Tests/RecurrenceTests.cs | 84 +++++--- Ical.Net.Tests/SimpleDeserializationTests.cs | 2 +- Ical.Net/Calendar.cs | 72 +++---- Ical.Net/CalendarCollection.cs | 92 ++------- .../CalendarComponents/RecurringComponent.cs | 11 +- Ical.Net/Evaluation/Evaluator.cs | 2 +- Ical.Net/Evaluation/EventEvaluator.cs | 28 ++- Ical.Net/Evaluation/IEvaluator.cs | 6 +- Ical.Net/Evaluation/PeriodListEvaluator.cs | 6 +- .../Evaluation/RecurrencePatternEvaluator.cs | 33 ++-- Ical.Net/Evaluation/RecurrenceUtil.cs | 35 ++-- Ical.Net/Evaluation/RecurringEvaluator.cs | 89 ++++----- Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs | 8 +- Ical.Net/Evaluation/TodoEvaluator.cs | 22 ++- Ical.Net/IGetOccurrences.cs | 46 +---- Ical.Net/Utility/CollectionHelpers.cs | 182 +++++++++++++++++- Ical.Net/Utility/DateUtil.cs | 3 + Ical.Net/VTimeZoneInfo.cs | 25 ++- 22 files changed, 502 insertions(+), 334 deletions(-) diff --git a/Ical.Net.Tests/CollectionHelpersTests.cs b/Ical.Net.Tests/CollectionHelpersTests.cs index d152e862..4bba743a 100644 --- a/Ical.Net.Tests/CollectionHelpersTests.cs +++ b/Ical.Net.Tests/CollectionHelpersTests.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using Ical.Net.DataTypes; +using Ical.Net.Utility; using NUnit.Framework; namespace Ical.Net.Tests; @@ -14,11 +15,7 @@ namespace Ical.Net.Tests; internal class CollectionHelpersTests { private static readonly DateTime _now = DateTime.UtcNow; - private static readonly DateTime _later = _now.AddHours(1); - private static readonly string _uid = Guid.NewGuid().ToString(); - private static List GetSimpleRecurrenceList() - => new List { new RecurrencePattern(FrequencyType.Daily, 1) { Count = 5 } }; private static List GetExceptionDates() => new List { new PeriodList { new Period(new CalDateTime(_now.AddDays(1).Date)) } }; @@ -37,4 +34,65 @@ public void ExDateTests() Assert.That(changedPeriod, Is.Not.EqualTo(GetExceptionDates())); } -} \ No newline at end of file + + [TestCase(new[] { 1, 3, 5, 7 }, new[] { 2, 4, 6 }, new[] { 1, 2, 3, 4, 5, 6, 7 })] + [TestCase(new int[] { }, new int[] { }, new int[] { })] + [TestCase(new int[] { }, new[] { 2, 4, 6 }, new[] { 2, 4, 6 })] + [TestCase(new[] { 2, 4, 6 }, new int[] { }, new[] { 2, 4, 6 })] + [TestCase(new[] { 3, 4 }, new int[] { 1, 2 }, new[] { 1, 2, 3, 4 })] + [TestCase(new[] { 1, 2, 3 }, new int[] { 2, 3, 4 }, new[] { 1, 2, 2, 3, 3, 4 })] + public void TestMerge(IList seq1, IList seq2, IList expected) + { + var result = seq1.OrderedMerge(seq2).ToList(); + + Assert.That(result, Is.EqualTo(expected)); + } + + [TestCase(new int[] { }, new int[] { }, new int[] { })] + [TestCase(new int[] { }, new[] { 2, 4, 6 }, new int[] { })] + [TestCase(new[] { 2, 4, 6 }, new int[] { }, new[] { 2, 4, 6 })] + [TestCase(new[] { 1, 2, 3, 5, 6, 7 }, new[] { 2, 4, 6 }, new[] { 1, 3, 5, 7 })] + public void TestMergeExclude(IList seq, IList exclude, IList expected) + { + var result = seq.OrderedExclude(exclude).ToList(); + + Assert.That(result, Is.EqualTo(expected)); + } + + private static IEnumerable GetNaturalNumbers() + { + var i = 1; + while (true) + yield return i++; + } + + [Test] + public void TestMergeIndefinite() + { + var result = GetNaturalNumbers().Select(x => x * 3).OrderedMerge(GetNaturalNumbers().Select(x => x * 2)) + .Take(7); + Assert.That(result, Is.EqualTo(new[] { 2, 3, 4, 6, 6, 8, 9 })); + } + + [Test] + public void TestMergeExcludeIndefinite() + { + var result = GetNaturalNumbers().Select(x => x * 3).OrderedExclude(GetNaturalNumbers().Select(x => x * 2)) + .Take(4); + Assert.That(result, Is.EqualTo(new[] { 3, 9, 15, 21 })); + } + + [Test] + public void TestMergeMulti() + { + var result = CollectionHelpers.OrderedMergeMany([[4], [2], [3], [1]], Comparer.Default); + Assert.That(result, Is.EqualTo(new[] { 1, 2, 3, 4 })); + } + + [Test] + public void TestOrderedDistinct() + { + var result = new[] { 1, 2, 2, 3 }.OrderedDistinct(); + Assert.That(result, Is.EqualTo(new[] { 1, 2, 3 })); + } +} diff --git a/Ical.Net.Tests/DeserializationTests.cs b/Ical.Net.Tests/DeserializationTests.cs index 2e3e76da..1156e754 100644 --- a/Ical.Net.Tests/DeserializationTests.cs +++ b/Ical.Net.Tests/DeserializationTests.cs @@ -473,7 +473,7 @@ public void Language4() public void Outlook2007_LineFolds1() { var iCal = Calendar.Load(IcsFiles.Outlook2007LineFolds); - var events = iCal.GetOccurrences(new CalDateTime(2009, 06, 20), new CalDateTime(2009, 06, 22)); + var events = iCal.GetOccurrences(new CalDateTime(2009, 06, 20), new CalDateTime(2009, 06, 22)).ToList(); Assert.That(events, Has.Count.EqualTo(1)); } diff --git a/Ical.Net.Tests/DocumentationExamples.cs b/Ical.Net.Tests/DocumentationExamples.cs index a7aecc6e..c76e20c5 100644 --- a/Ical.Net.Tests/DocumentationExamples.cs +++ b/Ical.Net.Tests/DocumentationExamples.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using NUnit.Framework; @@ -39,7 +40,7 @@ public void Daily_Test() // July 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31 var searchStart = DateTime.Parse("2016-07-20"); var searchEnd = DateTime.Parse("2016-08-05"); - var occurrences = calendar.GetOccurrences(searchStart, searchEnd); + var occurrences = calendar.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(12)); } @@ -64,7 +65,7 @@ public void EveryOtherTuesdayUntilTheEndOfTheYear_Test() // The first Tuesday is July 5. There should be 13 in total var searchStart = DateTime.Parse("2010-01-01"); var searchEnd = DateTime.Parse("2016-12-31"); - var tuesdays = vEvent.GetOccurrences(searchStart, searchEnd); + var tuesdays = vEvent.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(tuesdays, Has.Count.EqualTo(13)); } @@ -93,7 +94,7 @@ public void FourthThursdayOfNovember_Tests() var searchStart = DateTime.Parse("2000-01-01"); var searchEnd = DateTime.Parse("2017-01-01"); - var usThanksgivings = vEvent.GetOccurrences(searchStart, searchEnd); + var usThanksgivings = vEvent.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(usThanksgivings, Has.Count.EqualTo(17)); foreach (var thanksgiving in usThanksgivings) @@ -126,7 +127,7 @@ public void DailyExceptSunday_Test() // We are essentially counting all the days that aren't Sunday in 2016, so there should be 314 var searchStart = DateTime.Parse("2015-12-31"); var searchEnd = DateTime.Parse("2017-01-01"); - var occurrences = calendar.GetOccurrences(searchStart, searchEnd); + var occurrences = calendar.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(314)); } -} \ No newline at end of file +} diff --git a/Ical.Net.Tests/GetOccurrenceTests.cs b/Ical.Net.Tests/GetOccurrenceTests.cs index 47dfb941..1ccb105d 100644 --- a/Ical.Net.Tests/GetOccurrenceTests.cs +++ b/Ical.Net.Tests/GetOccurrenceTests.cs @@ -145,7 +145,7 @@ public void EnumerationChangedException() var calendar = GetCalendars(ical); var date = new DateTime(2016, 10, 11); - var occurrences = calendar.GetOccurrences(date); + var occurrences = calendar.GetOccurrences(date, date.AddDays(1)).ToList(); //We really want to make sure this doesn't explode Assert.That(occurrences, Has.Count.EqualTo(1)); @@ -209,9 +209,7 @@ public void GetOccurrencesWithRecurrenceIdShouldEnumerate() var collection = Calendar.Load(ical); var startCheck = new DateTime(2016, 11, 11); - var occurrences = collection.GetOccurrences(startCheck, startCheck.AddMonths(1)) - .OrderBy(x => x) - .ToList(); + var occurrences = collection.GetOccurrences(startCheck, startCheck.AddMonths(1)).ToList(); CalDateTime[] expectedStartDates = [ new CalDateTime("20161114T000100", "W. Europe Standard Time"), @@ -227,7 +225,6 @@ public void GetOccurrencesWithRecurrenceIdShouldEnumerate() // Specify end time that is between the original occurrence ta 20161128T0001 and the overridden one at 20161128T0030. // The overridden one shouldn't be returned, because it was replaced and the other one is in the future. var occurrences2 = collection.GetOccurrences(new CalDateTime(startCheck), new CalDateTime("20161128T002000", "W. Europe Standard Time")) - .OrderBy(x => x) .ToList(); Assert.Multiple(() => @@ -267,11 +264,9 @@ public void GetOccurrencesWithRecurrenceId_DateOnly_ShouldEnumerate() var collection = Calendar.Load(ical); var startCheck = new DateTime(2023, 10, 1); var occurrences = collection.GetOccurrences(startCheck, startCheck.AddMonths(1)) - .OrderBy(x => x) .ToList(); var occurrences2 = collection.GetOccurrences(new CalDateTime(startCheck), new CalDateTime(2023, 12, 31)) - .OrderBy(x => x) .ToList(); CalDateTime[] expectedStartDates = [ diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs index 201d67f8..e0f89cc8 100644 --- a/Ical.Net.Tests/RecurrenceTests.cs +++ b/Ical.Net.Tests/RecurrenceTests.cs @@ -1198,8 +1198,8 @@ public void WeekNoOrderingShouldNotMatter() var rpe1 = new RecurrencePatternEvaluator(new RecurrencePattern("FREQ=YEARLY;WKST=MO;BYDAY=MO;BYWEEKNO=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31,33,35,37,39,41,43,45,47,49,51,53")); var rpe2 = new RecurrencePatternEvaluator(new RecurrencePattern("FREQ=YEARLY;WKST=MO;BYDAY=MO;BYWEEKNO=53,51,49,47,45,43,41,39,37,35,33,31,29,27,25,23,21,19,17,15,13,11,9,7,5,3,1")); - var recurringPeriods1 = rpe1.Evaluate(new CalDateTime(start), start, end, false); - var recurringPeriods2 = rpe2.Evaluate(new CalDateTime(start), start, end, false); + var recurringPeriods1 = rpe1.Evaluate(new CalDateTime(start), start, end, false).ToList(); + var recurringPeriods2 = rpe2.Evaluate(new CalDateTime(start), start, end, false).ToList(); Assert.That(recurringPeriods2, Has.Count.EqualTo(recurringPeriods1.Count)); } @@ -2507,7 +2507,7 @@ public void BugByWeekNoNotWorking() var end = new DateTime(2019, 12, 31); var rpe = new RecurrencePatternEvaluator(new RecurrencePattern("FREQ=WEEKLY;BYDAY=MO;BYWEEKNO=2")); - var recurringPeriods = rpe.Evaluate(new CalDateTime(start, false), start, end, false); + var recurringPeriods = rpe.Evaluate(new CalDateTime(start, false), start, end, false).ToList(); Assert.That(recurringPeriods, Has.Count.EqualTo(1)); Assert.That(recurringPeriods.First().StartTime, Is.EqualTo(new CalDateTime(2019, 1, 7))); @@ -2552,7 +2552,7 @@ public void ReccurencePattern_MaxDate_StopsOnCount() evt.RecurrenceRules.Add(pattern); - var occurrences = evt.GetOccurrences(new DateTime(2018, 1, 1), DateTime.MaxValue); + var occurrences = evt.GetOccurrences(new DateTime(2018, 1, 1), DateTime.MaxValue).ToList(); Assert.That(occurrences, Has.Count.EqualTo(10), "There should be 10 occurrences of this event."); } @@ -2591,7 +2591,7 @@ public void Bug3119920() var serializer = new RecurrencePatternSerializer(); var rp = (RecurrencePattern)serializer.Deserialize(sr); var rpe = new RecurrencePatternEvaluator(rp); - var recurringPeriods = rpe.Evaluate(new CalDateTime(start), start, rp.Until, false); + var recurringPeriods = rpe.Evaluate(new CalDateTime(start), start, rp.Until, false).ToList(); var period = recurringPeriods.ElementAt(recurringPeriods.Count - 1); @@ -2623,7 +2623,7 @@ public void Bug3178652() evt.RecurrenceRules.Add(pattern); - var occurrences = evt.GetOccurrences(new DateTime(2011, 1, 1), new DateTime(2012, 1, 1)); + var occurrences = evt.GetOccurrences(new DateTime(2011, 1, 1), new DateTime(2012, 1, 1)).ToList(); Assert.That(occurrences, Has.Count.EqualTo(10), "There should be 10 occurrences of this event, one for each month except February and December."); } @@ -2669,19 +2669,19 @@ public void Issue432() var checkTime = DateTime.Parse("2019-01-04T08:00Z"); checkTime = checkTime.AddDays(i); //Valid asking for the exact moment - var occurrences = vEvent.GetOccurrences(checkTime, checkTime); + var occurrences = vEvent.GetOccurrences(checkTime, checkTime).ToList(); Assert.That(occurrences, Has.Count.EqualTo(1)); //Valid if asking for a range starting at the same moment - occurrences = vEvent.GetOccurrences(checkTime, checkTime.AddSeconds(1)); + occurrences = vEvent.GetOccurrences(checkTime, checkTime.AddSeconds(1)).ToList(); Assert.That(occurrences, Has.Count.EqualTo(1)); //Valid if asking for a range starting before and ending after - occurrences = vEvent.GetOccurrences(checkTime.AddSeconds(-1), checkTime.AddSeconds(1)); + occurrences = vEvent.GetOccurrences(checkTime.AddSeconds(-1), checkTime.AddSeconds(1)).ToList(); Assert.That(occurrences, Has.Count.EqualTo(1)); //Not valid if asking for a range starting before but ending at the same moment - occurrences = vEvent.GetOccurrences(checkTime.AddSeconds(-1), checkTime); + occurrences = vEvent.GetOccurrences(checkTime.AddSeconds(-1), checkTime).ToList(); Assert.That(occurrences.Count, Is.EqualTo(0)); } } @@ -2740,7 +2740,8 @@ public void UsHolidays() var occurrences = iCal.GetOccurrences( new CalDateTime(2006, 1, 1), - new CalDateTime(2006, 12, 31)); + new CalDateTime(2006, 12, 31)) + .ToList(); Assert.That(occurrences, Has.Count.EqualTo(items.Count), "The number of holidays did not evaluate correctly."); foreach (var o in occurrences) @@ -2875,32 +2876,32 @@ public void GetOccurrences1() var laterDateAndTime = new CalDateTime(2009, 11, 19, 11, 0, 0); var end = new CalDateTime(2009, 11, 23, 0, 0, 0); - var occurrences = evt.GetOccurrences(previousDateAndTime, end); + var occurrences = evt.GetOccurrences(previousDateAndTime, end).ToList(); Assert.That(occurrences, Has.Count.EqualTo(5)); - occurrences = evt.GetOccurrences(previousDateOnly, end); + occurrences = evt.GetOccurrences(previousDateOnly, end).ToList(); Assert.That(occurrences, Has.Count.EqualTo(5)); - occurrences = evt.GetOccurrences(laterDateOnly, end); + occurrences = evt.GetOccurrences(laterDateOnly, end).ToList(); Assert.That(occurrences, Has.Count.EqualTo(4)); - occurrences = evt.GetOccurrences(laterDateAndTime, end); + occurrences = evt.GetOccurrences(laterDateAndTime, end).ToList(); Assert.That(occurrences, Has.Count.EqualTo(3)); // Add ByHour "9" and "12" evt.RecurrenceRules[0].ByHour.Add(9); evt.RecurrenceRules[0].ByHour.Add(12); - occurrences = evt.GetOccurrences(previousDateAndTime, end); + occurrences = evt.GetOccurrences(previousDateAndTime, end).ToList(); Assert.That(occurrences, Has.Count.EqualTo(10)); - occurrences = evt.GetOccurrences(previousDateOnly, end); + occurrences = evt.GetOccurrences(previousDateOnly, end).ToList(); Assert.That(occurrences, Has.Count.EqualTo(10)); - occurrences = evt.GetOccurrences(laterDateOnly, end); + occurrences = evt.GetOccurrences(laterDateOnly, end).ToList(); Assert.That(occurrences, Has.Count.EqualTo(8)); - occurrences = evt.GetOccurrences(laterDateAndTime, end); + occurrences = evt.GetOccurrences(laterDateAndTime, end).ToList(); Assert.That(occurrences, Has.Count.EqualTo(7)); } @@ -3214,21 +3215,21 @@ public void AddExDateToEventAfterGetOccurrencesShouldRecomputeResult() var searchStart = _now.AddDays(-1); var searchEnd = _now.AddDays(7); var e = GetEventWithRecurrenceRules(); - var occurrences = e.GetOccurrences(searchStart, searchEnd); + var occurrences = e.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(5)); var exDate = _now.AddDays(1); var period = new Period(new CalDateTime(exDate, false)); var periodList = new PeriodList { period }; e.ExceptionDates.Add(periodList); - occurrences = e.GetOccurrences(searchStart, searchEnd); + occurrences = e.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(4)); //Specifying just a date should "black out" that date var excludeTwoDaysFromNow = _now.AddDays(2).Date; period = new Period(new CalDateTime(excludeTwoDaysFromNow, false)); periodList.Add(period); - occurrences = e.GetOccurrences(searchStart, searchEnd); + occurrences = e.GetOccurrences(searchStart, searchEnd).ToList(); Assert.That(occurrences, Has.Count.EqualTo(3)); } @@ -3319,7 +3320,7 @@ public void OneDayRange() checkTime = checkTime.AddDays(i); //Valid if asking for a range starting at the same moment - var occurrences = vEvent.GetOccurrences(checkTime, checkTime.AddDays(1)); + var occurrences = vEvent.GetOccurrences(checkTime, checkTime.AddDays(1)).ToList(); Assert.That(occurrences, Has.Count.EqualTo(i == 0 ? 1 : 0)); } } @@ -3342,19 +3343,19 @@ public void SpecificMinute() // Exactly on start time var testingTime = new DateTime(2019, 6, 7, 9, 0, 0); - var occurrences = vEvent.GetOccurrences(testingTime, testingTime); + var occurrences = vEvent.GetOccurrences(testingTime, testingTime).ToList(); Assert.That(occurrences, Has.Count.EqualTo(1)); // One second before end time testingTime = new DateTime(2019, 6, 7, 16, 59, 59); - occurrences = vEvent.GetOccurrences(testingTime, testingTime); + occurrences = vEvent.GetOccurrences(testingTime, testingTime).ToList(); Assert.That(occurrences, Has.Count.EqualTo(1)); // Exactly on end time testingTime = new DateTime(2019, 6, 7, 17, 0, 0); - occurrences = vEvent.GetOccurrences(testingTime, testingTime); + occurrences = vEvent.GetOccurrences(testingTime, testingTime).ToList(); Assert.That(occurrences.Count, Is.EqualTo(0)); } @@ -3611,7 +3612,7 @@ public void InclusiveRruleUntil() var startSearch = new CalDateTime(DateTime.Parse("2017-07-01T00:00:00"), timeZoneId); var endSearch = new CalDateTime(DateTime.Parse("2018-07-01T00:00:00"), timeZoneId); - var occurrences = firstEvent.GetOccurrences(startSearch, endSearch); + var occurrences = firstEvent.GetOccurrences(startSearch, endSearch).ToList(); Assert.That(occurrences, Has.Count.EqualTo(5)); } @@ -3805,8 +3806,35 @@ public void GetOccurrenceShouldExcludeDtEndFloating() var calendar = Calendar.Load(ical); // Set start date for occurrences to search to the end date of the event - var occurrences = calendar.GetOccurrences(new CalDateTime(2024, 12, 2)); + var occurrences = calendar.GetOccurrences(new CalDateTime(2024, 12, 2), new CalDateTime(2024, 12, 3)); Assert.That(occurrences, Is.Empty); } + + [Test] + public void TestGetOccurrenceIndefinite() + { + var ical = """ + BEGIN:VCALENDAR + VERSION:2.0 + BEGIN:VEVENT + DTSTART:20241130 + RRULE:FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR,SA + EXRULE:FREQ=DAILY;INTERVAL=3 + RDATE:20241201 + EXDATE:20241202 + END:VEVENT + END:VCALENDAR + """; + + var calendar = Calendar.Load(ical); + + // Although the occurrences are unbounded, we can still call GetOccurrences without + // specifying bounds, because the instances are only generated on enumeration. + var occurrences = calendar.GetOccurrences(); + + var instances = occurrences.Take(100).ToList(); + + Assert.That(instances.Count(), Is.EqualTo(100)); + } } diff --git a/Ical.Net.Tests/SimpleDeserializationTests.cs b/Ical.Net.Tests/SimpleDeserializationTests.cs index f2d6f754..b1c1f117 100644 --- a/Ical.Net.Tests/SimpleDeserializationTests.cs +++ b/Ical.Net.Tests/SimpleDeserializationTests.cs @@ -506,7 +506,7 @@ public void Language4() public void Outlook2007_LineFolds1() { var iCal = SimpleDeserializer.Default.Deserialize(new StringReader(IcsFiles.Outlook2007LineFolds)).Cast().Single(); - var events = iCal.GetOccurrences(new CalDateTime(2009, 06, 20), new CalDateTime(2009, 06, 22)); + var events = iCal.GetOccurrences(new CalDateTime(2009, 06, 20), new CalDateTime(2009, 06, 22)).ToList(); Assert.That(events, Has.Count.EqualTo(1)); } diff --git a/Ical.Net/Calendar.cs b/Ical.Net/Calendar.cs index b57d2243..d4b00e46 100644 --- a/Ical.Net/Calendar.cs +++ b/Ical.Net/Calendar.cs @@ -197,24 +197,6 @@ public VTimeZone AddTimeZone(VTimeZone tz) return tz; } - - /// - /// Returns a list of occurrences of each recurring component - /// for the date provided (). - /// - /// The date for which to return occurrences. Time is ignored on this parameter. - /// A list of occurrences that occur on the given date (). - public virtual HashSet GetOccurrences(IDateTime dt) - { - return GetOccurrences(new CalDateTime(dt.Date), new CalDateTime(dt.Date.AddDays(1))); - } - - /// - public virtual HashSet GetOccurrences(DateTime dt) - { - return GetOccurrences(new CalDateTime(DateOnly.FromDateTime(dt)), new CalDateTime(DateOnly.FromDateTime(dt.Date.AddDays(1)))); - } - /// /// Returns a list of occurrences of each recurring component /// that occur between and . @@ -222,39 +204,16 @@ public virtual HashSet GetOccurrences(DateTime dt) /// The beginning date/time of the range. /// The end date/time of the range. /// A list of occurrences that fall between the date/time arguments provided. - public virtual HashSet GetOccurrences(IDateTime startTime, IDateTime endTime) + public virtual IEnumerable GetOccurrences(IDateTime startTime = null, IDateTime endTime = null) => GetOccurrences(startTime, endTime); /// - public virtual HashSet GetOccurrences(DateTime startTime, DateTime endTime) - => GetOccurrences(new CalDateTime(DateOnly.FromDateTime(startTime), TimeOnly.FromDateTime(startTime)), new CalDateTime(DateOnly.FromDateTime(endTime), TimeOnly.FromDateTime(endTime))); - - /// - /// Returns all occurrences of components of type T that start on the date provided. - /// All components starting between 12:00:00AM and 11:59:59 PM will be - /// returned. - /// - /// This will first Evaluate() the date range required in order to - /// determine the occurrences for the date provided, and then return - /// the occurrences. - /// - /// - /// The date for which to return occurrences. Time is ignored on this parameter. - /// A list of Periods representing the occurrences of this object. - public virtual HashSet GetOccurrences(IDateTime dt) where T : IRecurringComponent - { - return GetOccurrences(new CalDateTime(dt.Date), new CalDateTime(dt.Date.AddDays(1))); - } - - /// - public virtual HashSet GetOccurrences(DateTime dt) where T : IRecurringComponent - { - return GetOccurrences(new CalDateTime(DateOnly.FromDateTime(dt)), new CalDateTime(DateOnly.FromDateTime(dt.Date.AddDays(1)))); - } + public virtual IEnumerable GetOccurrences(DateTime? startTime, DateTime? endTime) + => GetOccurrences(startTime?.AsCalDateTime(), endTime?.AsCalDateTime()); /// - public virtual HashSet GetOccurrences(DateTime startTime, DateTime endTime) where T : IRecurringComponent - => GetOccurrences(new CalDateTime(startTime), new CalDateTime(endTime)); + public virtual IEnumerable GetOccurrences(DateTime? startTime, DateTime? endTime) where T : IRecurringComponent + => GetOccurrences(startTime?.AsCalDateTime(), endTime?.AsCalDateTime()); /// /// Returns all occurrences of components of type T that start within the date range provided. @@ -263,7 +222,7 @@ public virtual HashSet GetOccurrences(DateTime startTime, DateTim /// /// The starting date range /// The ending date range - public virtual HashSet GetOccurrences(IDateTime startTime, IDateTime endTime) where T : IRecurringComponent + public virtual IEnumerable GetOccurrences(IDateTime startTime = null, IDateTime endTime = null) where T : IRecurringComponent { // These are the UID/RECURRENCE-ID combinations that replace other occurrences. var recurrenceIdsAndUids = this.Children.OfType() @@ -272,14 +231,27 @@ public virtual HashSet GetOccurrences(IDateTime startTime, IDateT .Where(r => r.Uid != null) .ToDictionary(x => x); - var occurrences = new HashSet(RecurringItems + var occurrences = RecurringItems .OfType() - .SelectMany(recurrable => recurrable.GetOccurrences(startTime, endTime)) + .Select(recurrable => recurrable.GetOccurrences(startTime, endTime)) + + // Enumerate the list of occurrences (not the occurrences themselves) now to ensure + // the initialization code is run, including validation and error handling. + // This way we receive validation errors early, not only when enumeration starts. + .ToList() //NOSONAR - deliberately enumerate here + + // Merge the individual sequences into a single one. Take advantage of them + // being ordered to avoid full enumeration. + .OrderedMergeMany() + + // Remove duplicates and take advantage of being ordered to avoid full enumeration. + .OrderedDistinct() + // Remove the occurrence if it has been replaced by a different one. .Where(r => (r.Source.RecurrenceId != null) || !(r.Source is IUniqueComponent) || - !recurrenceIdsAndUids.ContainsKey(new { ((IUniqueComponent) r.Source).Uid, Dt = r.Period.StartTime.Value }))); + !recurrenceIdsAndUids.ContainsKey(new { ((IUniqueComponent)r.Source).Uid, Dt = r.Period.StartTime.Value })); return occurrences; } diff --git a/Ical.Net/CalendarCollection.cs b/Ical.Net/CalendarCollection.cs index b89bfaa4..adaf8aa7 100644 --- a/Ical.Net/CalendarCollection.cs +++ b/Ical.Net/CalendarCollection.cs @@ -39,85 +39,33 @@ public static CalendarCollection Load(TextReader tr) return collection; } - public HashSet GetOccurrences(IDateTime dt) - { - var occurrences = new HashSet(); - foreach (var iCal in this) - { - occurrences.UnionWith(iCal.GetOccurrences(dt)); - } - return occurrences; - } + private IEnumerable GetOccurrences(Func> f) + => - public HashSet GetOccurrences(DateTime dt) - { - var occurrences = new HashSet(); - foreach (var iCal in this) - { - occurrences.UnionWith(iCal.GetOccurrences(dt)); - } - return occurrences; - } + // Get the sequence of occurrences for each calendar in the collection, + // which will result in a sequence of sequences of occurrences. + this.Select(f) - public HashSet GetOccurrences(IDateTime startTime, IDateTime endTime) - { - var occurrences = new HashSet(); - foreach (var iCal in this) - { - occurrences.UnionWith(iCal.GetOccurrences(startTime, endTime)); - } - return occurrences; - } + // Enumerate the list of occurrences (not the occurrences themselves) now to ensure + // the initialization code is run, including validation and error handling. + // This way we receive validation errors early, not only when enumeration starts. + .ToArray() //NOSONAR - deliberately enumerate here - public HashSet GetOccurrences(DateTime startTime, DateTime endTime) - { - var occurrences = new HashSet(); - foreach (var iCal in this) - { - occurrences.UnionWith(iCal.GetOccurrences(startTime, endTime)); - } - return occurrences; - } + // Merge the individual sequences into a single one. Take advantage of them + // being ordered to avoid full enumeration. + .OrderedMergeMany(); - public HashSet GetOccurrences(IDateTime dt) where T : IRecurringComponent - { - var occurrences = new HashSet(); - foreach (var iCal in this) - { - occurrences.UnionWith(iCal.GetOccurrences(dt)); - } - return occurrences; - } + public IEnumerable GetOccurrences(IDateTime startTime = null, IDateTime endTime = null) + => GetOccurrences(iCal => iCal.GetOccurrences(startTime, endTime)); - public HashSet GetOccurrences(DateTime dt) where T : IRecurringComponent - { - var occurrences = new HashSet(); - foreach (var iCal in this) - { - occurrences.UnionWith(iCal.GetOccurrences(dt)); - } - return occurrences; - } + public IEnumerable GetOccurrences(DateTime? startTime, DateTime? endTime) + => GetOccurrences(iCal => iCal.GetOccurrences(startTime, endTime)); - public HashSet GetOccurrences(IDateTime startTime, IDateTime endTime) where T : IRecurringComponent - { - var occurrences = new HashSet(); - foreach (var iCal in this) - { - occurrences.UnionWith(iCal.GetOccurrences(startTime, endTime)); - } - return occurrences; - } + public IEnumerable GetOccurrences(IDateTime startTime = null, IDateTime endTime = null) where T : IRecurringComponent + => GetOccurrences(iCal => iCal.GetOccurrences(startTime, endTime)); - public HashSet GetOccurrences(DateTime startTime, DateTime endTime) where T : IRecurringComponent - { - var occurrences = new HashSet(); - foreach (var iCal in this) - { - occurrences.UnionWith(iCal.GetOccurrences(startTime, endTime)); - } - return occurrences; - } + public IEnumerable GetOccurrences(DateTime? startTime, DateTime? endTime) where T : IRecurringComponent + => GetOccurrences(iCal => iCal.GetOccurrences(startTime, endTime)); private FreeBusy CombineFreeBusy(FreeBusy main, FreeBusy current) { diff --git a/Ical.Net/CalendarComponents/RecurringComponent.cs b/Ical.Net/CalendarComponents/RecurringComponent.cs index f77bfc33..2e6dec20 100644 --- a/Ical.Net/CalendarComponents/RecurringComponent.cs +++ b/Ical.Net/CalendarComponents/RecurringComponent.cs @@ -178,16 +178,11 @@ protected override void OnDeserializing(StreamingContext context) Initialize(); } - public virtual HashSet GetOccurrences(IDateTime dt) => RecurrenceUtil.GetOccurrences(this, dt, EvaluationIncludesReferenceDate); - - public virtual HashSet GetOccurrences(DateTime dt) - => RecurrenceUtil.GetOccurrences(this, new CalDateTime(dt), EvaluationIncludesReferenceDate); - - public virtual HashSet GetOccurrences(IDateTime startTime, IDateTime endTime) + public virtual IEnumerable GetOccurrences(IDateTime startTime = null, IDateTime endTime = null) => RecurrenceUtil.GetOccurrences(this, startTime, endTime, EvaluationIncludesReferenceDate); - public virtual HashSet GetOccurrences(DateTime startTime, DateTime endTime) - => RecurrenceUtil.GetOccurrences(this, new CalDateTime(startTime), new CalDateTime(endTime), EvaluationIncludesReferenceDate); + public virtual IEnumerable GetOccurrences(DateTime? startTime, DateTime? endTime) + => RecurrenceUtil.GetOccurrences(this, startTime?.AsCalDateTime(), endTime?.AsCalDateTime(), EvaluationIncludesReferenceDate); public virtual IList PollAlarms() => PollAlarms(null, null); diff --git a/Ical.Net/Evaluation/Evaluator.cs b/Ical.Net/Evaluation/Evaluator.cs index 014c00c6..1223e912 100644 --- a/Ical.Net/Evaluation/Evaluator.cs +++ b/Ical.Net/Evaluation/Evaluator.cs @@ -75,5 +75,5 @@ public virtual ICalendarObject AssociatedObject protected set => _mAssociatedObject = value; } - public abstract HashSet Evaluate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults); + public abstract IEnumerable Evaluate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults); } diff --git a/Ical.Net/Evaluation/EventEvaluator.cs b/Ical.Net/Evaluation/EventEvaluator.cs index 159fe6e2..4311c4e8 100644 --- a/Ical.Net/Evaluation/EventEvaluator.cs +++ b/Ical.Net/Evaluation/EventEvaluator.cs @@ -38,28 +38,26 @@ public EventEvaluator(CalendarEvent evt) : base(evt) { } /// The end date of the range to evaluate. /// /// - public override HashSet Evaluate(IDateTime referenceTime, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults) + public override IEnumerable Evaluate(IDateTime referenceTime, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults) { - // Evaluate recurrences normally - var periods = base.Evaluate(referenceTime, periodStart, periodEnd, includeReferenceDateInResults); - - foreach (var period in periods) + Period WithDuration(Period period) { - period.Duration = CalendarEvent.GetFirstDuration(); - period.EndTime = period.Duration == default + var duration = CalendarEvent.GetFirstDuration(); + var endTime = duration == default ? period.StartTime : period.StartTime.Add(CalendarEvent.GetFirstDuration()); - } - // Ensure each period has a duration - foreach (var period in periods.Where(p => p.EndTime == null)) - { - period.Duration = CalendarEvent.GetFirstDuration(); - period.EndTime = period.Duration == default - ? period.StartTime - : period.StartTime.Add(CalendarEvent.GetFirstDuration()); + return new Period(period.StartTime) + { + Duration = duration, + EndTime = endTime, + }; } + // Evaluate recurrences normally + var periods = base.Evaluate(referenceTime, periodStart, periodEnd, includeReferenceDateInResults) + .Select(WithDuration); + return periods; } } diff --git a/Ical.Net/Evaluation/IEvaluator.cs b/Ical.Net/Evaluation/IEvaluator.cs index 29cbb56e..778581f0 100644 --- a/Ical.Net/Evaluation/IEvaluator.cs +++ b/Ical.Net/Evaluation/IEvaluator.cs @@ -30,6 +30,8 @@ public interface IEvaluator /// This method evaluates using the as the beginning /// point. For example, for a WEEKLY occurrence, the /// determines the day of week that this item will recur on. + /// + /// Items are returned in ascending order. /// /// For events with very complex recurrence rules, this method may be a bottleneck /// during processing time, especially when this method is called for a large number @@ -41,8 +43,8 @@ public interface IEvaluator /// /// /// - /// A list of objects for + /// A sequence of objects for /// each date/time when this item occurs/recurs. /// - HashSet Evaluate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults); + IEnumerable Evaluate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults); } diff --git a/Ical.Net/Evaluation/PeriodListEvaluator.cs b/Ical.Net/Evaluation/PeriodListEvaluator.cs index 5d5b4f78..bae66048 100644 --- a/Ical.Net/Evaluation/PeriodListEvaluator.cs +++ b/Ical.Net/Evaluation/PeriodListEvaluator.cs @@ -18,9 +18,9 @@ public PeriodListEvaluator(PeriodList rdt) _mPeriodList = rdt; } - public override HashSet Evaluate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults) + public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults) { - var periods = new HashSet(); + var periods = new SortedSet(); if (includeReferenceDateInResults) { @@ -36,4 +36,4 @@ public override HashSet Evaluate(IDateTime referenceDate, DateTime perio periods.UnionWith(_mPeriodList); return periods; } -} \ No newline at end of file +} diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index dac88f24..046cb540 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -204,10 +204,9 @@ private void EnforceEvaluationRestrictions(RecurrencePattern pattern) /// For example, if the search start date (start) is Wed, Mar 23, 12:19PM, but the recurrence is Mon - Fri, 9:00AM - 5:00PM, /// the start dates returned should all be at 9:00AM, and not 12:19PM. /// - private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTime periodEnd, int maxCount, RecurrencePattern pattern, + private IEnumerable GetDates(IDateTime seed, DateTime? periodStart, DateTime? periodEnd, int maxCount, RecurrencePattern pattern, bool includeReferenceDateInResults) { - var dates = new HashSet(); // In the first step, we work with DateTime values, so we need to convert the IDateTime to DateTime var originalDate = DateUtil.GetSimpleDateTimeData(seed); var seedCopy = DateUtil.GetSimpleDateTimeData(seed); @@ -232,11 +231,20 @@ private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTim } } + // Do the enumeration in a separate method, as it is a generator method that is + // only executed after enumeration started. In order to do most validation upfront, + // do as many steps outside the generator as possible. + return EnumerateDates(originalDate, seedCopy, periodStart, periodEnd, maxCount, pattern); + } + + private IEnumerable EnumerateDates(DateTime originalDate, DateTime seedCopy, DateTime? periodStart, DateTime? periodEnd, int maxCount, RecurrencePattern pattern) + { var expandBehavior = RecurrenceUtil.GetExpandBehaviorList(pattern); var noCandidateIncrementCount = 0; var candidate = DateTime.MinValue; - while (maxCount < 0 || dates.Count < maxCount) + int dateCount = 0; + while (maxCount < 0 || dateCount < maxCount) { if (pattern.Until != DateTime.MinValue && candidate != DateTime.MinValue && candidate > pattern.Until) { @@ -248,7 +256,7 @@ private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTim break; } - if (pattern.Count >= 1 && dates.Count >= pattern.Count) + if (pattern.Count >= 1 && dateCount >= pattern.Count) { break; } @@ -264,7 +272,7 @@ private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTim { noCandidateIncrementCount = 0; - foreach (var t in candidates.OrderBy(c => c).Where(t => t >= originalDate)) + foreach (var t in candidates.Where(t => t >= originalDate)) { candidate = t; @@ -273,7 +281,7 @@ private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTim // from the previous year. // // exclude candidates that start at the same moment as periodEnd if the period is a range but keep them if targeting a specific moment - if (pattern.Count >= 1 && dates.Count >= pattern.Count) + if (pattern.Count >= 1 && dateCount >= pattern.Count) { break; } @@ -285,7 +293,8 @@ private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTim if (pattern.Until == DateTime.MinValue || candidate <= pattern.Until) { - dates.Add(candidate); + yield return candidate; + dateCount++; } } } @@ -300,8 +309,6 @@ private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTim IncrementDate(ref seedCopy, pattern, pattern.Interval); } - - return dates; } /// @@ -311,7 +318,7 @@ private HashSet GetDates(IDateTime seed, DateTime periodStart, DateTim /// /// /// A list of possible dates. - private List GetCandidates(DateTime date, RecurrencePattern pattern, bool?[] expandBehaviors) + private ISet GetCandidates(DateTime date, RecurrencePattern pattern, bool?[] expandBehaviors) { var dates = new List { date }; dates = GetMonthVariants(dates, pattern, expandBehaviors[0]); @@ -323,7 +330,7 @@ private List GetCandidates(DateTime date, RecurrencePattern pattern, b dates = GetMinuteVariants(dates, pattern, expandBehaviors[6]); dates = GetSecondVariants(dates, pattern, expandBehaviors[7]); dates = ApplySetPosRules(dates, pattern); - return dates; + return new SortedSet(dates); } /// @@ -948,7 +955,7 @@ private static Period CreatePeriod(DateTime dateTime, IDateTime referenceDate) /// End (excl.) of the period occurrences are generated for. /// Whether the referenceDate itself should be returned. Ignored as the reference data MUST equal the first occurrence of an RRULE. /// - public override HashSet Evaluate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults) + public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults) { if (Pattern.Frequency != FrequencyType.None && Pattern.Frequency < FrequencyType.Daily && !referenceDate.HasTime) { @@ -966,7 +973,7 @@ public override HashSet Evaluate(IDateTime referenceDate, DateTime perio var periodQuery = GetDates(referenceDate, periodStart, periodEnd, -1, pattern, includeReferenceDateInResults) .Select(dt => CreatePeriod(dt, referenceDate)); - return new HashSet(periodQuery); + return periodQuery; } private static DateTime MatchTimeZone(IDateTime reference, DateTime until) diff --git a/Ical.Net/Evaluation/RecurrenceUtil.cs b/Ical.Net/Evaluation/RecurrenceUtil.cs index 3e467ce7..50bd65fa 100644 --- a/Ical.Net/Evaluation/RecurrenceUtil.cs +++ b/Ical.Net/Evaluation/RecurrenceUtil.cs @@ -14,18 +14,15 @@ namespace Ical.Net.Evaluation; internal class RecurrenceUtil { - public static HashSet GetOccurrences(IRecurrable recurrable, IDateTime dt, bool includeReferenceDateInResults) - { - return GetOccurrences(recurrable, - new CalDateTime(dt.Date, dt.Time), new CalDateTime(dt.Date.AddDays(1)), includeReferenceDateInResults); - } + public static IEnumerable GetOccurrences(IRecurrable recurrable, IDateTime dt, bool includeReferenceDateInResults) => GetOccurrences(recurrable, + new CalDateTime(dt.Date), new CalDateTime(dt.Date.AddDays(1)), includeReferenceDateInResults); - public static HashSet GetOccurrences(IRecurrable recurrable, IDateTime periodStart, IDateTime periodEnd, bool includeReferenceDateInResults) + public static IEnumerable GetOccurrences(IRecurrable recurrable, IDateTime periodStart, IDateTime periodEnd, bool includeReferenceDateInResults) { var evaluator = recurrable.GetService(typeof(IEvaluator)) as IEvaluator; if (evaluator == null || recurrable.Start == null) { - return new HashSet(); + return []; } // Ensure the start time is associated with the object being queried @@ -35,21 +32,23 @@ 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 = new CalDateTime(periodStart.Date, periodStart.Time, start.TzId); - periodEnd = new CalDateTime(periodEnd.Date, periodEnd.Time, start.TzId); + if (periodStart != null) + periodStart = new CalDateTime(periodStart.Date, periodStart.Time, start.TzId); + if (periodEnd != null) + periodEnd = new CalDateTime(periodEnd.Date, periodEnd.Time, start.TzId); - var periods = evaluator.Evaluate(start, DateUtil.GetSimpleDateTimeData(periodStart), DateUtil.GetSimpleDateTimeData(periodEnd), + var periods = evaluator.Evaluate(start, periodStart?.Value, periodEnd?.Value, includeReferenceDateInResults); - var otherOccurrences = from p in periods - let endTime = p.EndTime ?? p.StartTime - where - (endTime.GreaterThan(periodStart) && p.StartTime.LessThan(periodEnd) || - (periodStart.Equals(periodEnd) && p.StartTime.LessThanOrEqual(periodStart) && endTime.GreaterThan(periodEnd))) || //A period that starts at the same time it ends - (p.StartTime.Equals(endTime) && periodStart.Equals(p.StartTime)) //An event that starts at the same time it ends - select new Occurrence(recurrable, p); + var occurrences = + from p in periods + let endTime = p.EndTime ?? p.StartTime + where + (((periodStart == null) || endTime.GreaterThan(periodStart)) && ((periodEnd == null) || p.StartTime.LessThan(periodEnd)) || + (periodStart.Equals(periodEnd) && p.StartTime.LessThanOrEqual(periodStart) && endTime.GreaterThan(periodEnd))) || //A period that starts at the same time it ends + (p.StartTime.Equals(endTime) && p.StartTime.Equals(periodStart)) //An event that starts at the same time it ends + select new Occurrence(recurrable, p); - var occurrences = new HashSet(otherOccurrences); return occurrences; } diff --git a/Ical.Net/Evaluation/RecurringEvaluator.cs b/Ical.Net/Evaluation/RecurringEvaluator.cs index 4151d1d9..0509d10e 100644 --- a/Ical.Net/Evaluation/RecurringEvaluator.cs +++ b/Ical.Net/Evaluation/RecurringEvaluator.cs @@ -8,6 +8,7 @@ using System.Linq; using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; +using Ical.Net.Utility; namespace Ical.Net.Evaluation; @@ -40,14 +41,12 @@ public RecurringEvaluator(IRecurrable obj) /// The beginning date of the range to evaluate. /// The end date of the range to evaluate. /// - protected HashSet EvaluateRRule(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults) + protected IEnumerable EvaluateRRule(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults) { if (Recurrable.RecurrenceRules == null || !Recurrable.RecurrenceRules.Any()) - { - return new HashSet(); - } + return []; - var periodsQuery = Recurrable.RecurrenceRules.SelectMany(rule => + var periodsQueries = Recurrable.RecurrenceRules.Select(rule => { var ruleEvaluator = rule.GetService(typeof(IEvaluator)) as IEvaluator; if (ruleEvaluator == null) @@ -55,27 +54,29 @@ protected HashSet EvaluateRRule(IDateTime referenceDate, DateTime period return Enumerable.Empty(); } return ruleEvaluator.Evaluate(referenceDate, periodStart, periodEnd, includeReferenceDateInResults); - }); + }) + // Enumerate the outer sequence (not the inner sequences of periods themselves) now to ensure + // the initialization code is run, including validation and error handling. + // This way we receive validation errors early, not only when enumeration starts. + .ToList(); //NOSONAR - deliberately enumerate here - var periods = new HashSet(periodsQuery); //Only add referenceDate if there are no RecurrenceRules defined if (includeReferenceDateInResults && (Recurrable.RecurrenceRules == null || !Recurrable.RecurrenceRules.Any())) { - periods.UnionWith(new[] { new Period(referenceDate) }); + periodsQueries.Add([new Period(referenceDate)]); } - return periods; + + return periodsQueries.OrderedMergeMany(); } /// Evaluates the RDate component. - protected HashSet EvaluateRDate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd) + protected IEnumerable EvaluateRDate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd) { if (Recurrable.RecurrenceDates == null || !Recurrable.RecurrenceDates.Any()) - { - return new HashSet(); - } + return []; - var recurrences = new HashSet(Recurrable.RecurrenceDates.SelectMany(rdate => rdate)); + var recurrences = new SortedSet(Recurrable.RecurrenceDates.SelectMany(rdate => rdate)); return recurrences; } @@ -85,14 +86,12 @@ protected HashSet EvaluateRDate(IDateTime referenceDate, DateTime period /// /// The beginning date of the range to evaluate. /// The end date of the range to evaluate. - protected HashSet EvaluateExRule(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd) + protected IEnumerable EvaluateExRule(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd) { if (Recurrable.ExceptionRules == null || !Recurrable.ExceptionRules.Any()) - { - return new HashSet(); - } + return []; - var exRuleEvaluatorQuery = Recurrable.ExceptionRules.SelectMany(exRule => + var exRuleEvaluatorQueries = Recurrable.ExceptionRules.Select(exRule => { var exRuleEvaluator = exRule.GetService(typeof(IEvaluator)) as IEvaluator; if (exRuleEvaluator == null) @@ -100,10 +99,13 @@ protected HashSet EvaluateExRule(IDateTime referenceDate, DateTime perio return Enumerable.Empty(); } return exRuleEvaluator.Evaluate(referenceDate, periodStart, periodEnd, false); - }); + }) + // Enumerate the outer sequence (not the inner sequences of periods themselves) now to ensure + // the initialization code is run, including validation and error handling. + // This way we receive validation errors early, not only when enumeration starts. + .ToList(); //NOSONAR - deliberately enumerate here - var exRuleExclusions = new HashSet(exRuleEvaluatorQuery); - return exRuleExclusions; + return exRuleEvaluatorQueries.OrderedMergeMany(); } /// @@ -112,26 +114,22 @@ protected HashSet EvaluateExRule(IDateTime referenceDate, DateTime perio /// /// The beginning date of the range to evaluate. /// The end date of the range to evaluate. - protected HashSet EvaluateExDate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd) + protected IEnumerable EvaluateExDate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd) { if (Recurrable.ExceptionDates == null || !Recurrable.ExceptionDates.Any()) - { - return new HashSet(); - } + return []; - var exDates = new HashSet(Recurrable.ExceptionDates.SelectMany(exDate => exDate)); + var exDates = new SortedSet(Recurrable.ExceptionDates.SelectMany(exDate => exDate)); return exDates; } - public override HashSet Evaluate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults) + public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults) { - var periods = new HashSet(); - var rruleOccurrences = EvaluateRRule(referenceDate, periodStart, periodEnd, includeReferenceDateInResults); //Only add referenceDate if there are no RecurrenceRules defined if (includeReferenceDateInResults && (Recurrable.RecurrenceRules == null || !Recurrable.RecurrenceRules.Any())) { - rruleOccurrences.UnionWith(new[] { new Period(referenceDate), }); + rruleOccurrences = rruleOccurrences.Append(new Period(referenceDate)); } var rdateOccurrences = EvaluateRDate(referenceDate, periodStart, periodEnd); @@ -139,22 +137,27 @@ public override HashSet Evaluate(IDateTime referenceDate, DateTime perio var exRuleExclusions = EvaluateExRule(referenceDate, periodStart, periodEnd); var exDateExclusions = EvaluateExDate(referenceDate, periodStart, periodEnd); - //Exclusions trump inclusions - periods.UnionWith(rruleOccurrences); - periods.UnionWith(rdateOccurrences); - periods.ExceptWith(exRuleExclusions); - periods.ExceptWith(exDateExclusions); - - var dateOverlaps = FindDateOverlaps(periods, exDateExclusions); - periods.ExceptWith(dateOverlaps); + var periods = + rruleOccurrences + .OrderedMerge(rdateOccurrences) + .OrderedDistinct() + .OrderedExclude(exRuleExclusions) + .OrderedExclude(exDateExclusions, Comparer.Create(CompareDateOverlap)); return periods; } - private static HashSet FindDateOverlaps(HashSet periods, HashSet dates) + /// + /// Compares whether the given period's date overlaps with the given EXDATE. The dates are + /// considered to overlap if they start at the same time, or the EXDATE is an all-day date + /// and the period's start date is the same as the EXDATE's date. + /// + private static int CompareDateOverlap(Period period, Period exDate) { - var datesWithoutTimes = new HashSet(dates.Where(d => !d.StartTime.HasTime).Select(d => d.StartTime.Value)); - var overlaps = new HashSet(periods.Where(p => datesWithoutTimes.Contains(p.StartTime.Value.Date))); - return overlaps; + var cmp = period.CompareTo(exDate); + if ((cmp != 0) && !exDate.StartTime.HasTime && (period.StartTime.Value.Date == exDate.StartTime.Value)) + cmp = 0; + + return cmp; } } diff --git a/Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs b/Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs index 651c0f7a..c43368f3 100644 --- a/Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs +++ b/Ical.Net/Evaluation/TimeZoneInfoEvaluator.cs @@ -20,17 +20,15 @@ protected VTimeZoneInfo TimeZoneInfo public TimeZoneInfoEvaluator(IRecurrable tzi) : base(tzi) { } - public override HashSet Evaluate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults) + public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults) { // Time zones must include an effective start date/time // and must provide an evaluator. if (TimeZoneInfo == null) - { - return new HashSet(); - } + return []; // Always include the reference date in the results var periods = base.Evaluate(referenceDate, periodStart, periodEnd, true); return periods; } -} \ No newline at end of file +} diff --git a/Ical.Net/Evaluation/TodoEvaluator.cs b/Ical.Net/Evaluation/TodoEvaluator.cs index c89c5787..756ca864 100644 --- a/Ical.Net/Evaluation/TodoEvaluator.cs +++ b/Ical.Net/Evaluation/TodoEvaluator.cs @@ -18,7 +18,7 @@ public class TodoEvaluator : RecurringEvaluator public TodoEvaluator(Todo todo) : base(todo) { } - internal HashSet EvaluateToPreviousOccurrence(IDateTime completedDate, IDateTime currDt) + internal IEnumerable EvaluateToPreviousOccurrence(IDateTime completedDate, IDateTime currDt) { var beginningDate = completedDate.Copy(); @@ -77,19 +77,19 @@ private void DetermineStartingRecurrence(RecurrencePattern recur, ref IDateTime } } - public override HashSet Evaluate(IDateTime referenceDate, DateTime periodStart, DateTime periodEnd, bool includeReferenceDateInResults) + public override IEnumerable Evaluate(IDateTime referenceDate, DateTime? periodStart, DateTime? periodEnd, bool includeReferenceDateInResults) { // TODO items can only recur if a start date is specified if (Todo.Start == null) + return []; + + Period PeriodWithDuration(Period p) { - return new HashSet(); - } + if (p.EndTime != null) + return p; - var periods = base.Evaluate(referenceDate, periodStart, periodEnd, includeReferenceDateInResults); + var period = p.Copy(); - // Ensure each period has a duration - foreach (var period in periods.Where(period => period.EndTime == null)) - { period.Duration = Todo.Duration; if (period.Duration != default) { @@ -99,7 +99,11 @@ public override HashSet Evaluate(IDateTime referenceDate, DateTime perio { period.Duration = Todo.Duration; } + + return period; } - return periods; + + return base.Evaluate(referenceDate, periodStart, periodEnd, includeReferenceDateInResults) + .Select(PeriodWithDuration); } } diff --git a/Ical.Net/IGetOccurrences.cs b/Ical.Net/IGetOccurrences.cs index 836ba45f..e88be53d 100644 --- a/Ical.Net/IGetOccurrences.cs +++ b/Ical.Net/IGetOccurrences.cs @@ -12,59 +12,29 @@ namespace Ical.Net; public interface IGetOccurrences { - /// - /// Returns all occurrences of this component that start on the date provided. - /// All components starting between 12:00:00AM and 11:59:59 PM will be - /// returned. - /// - /// This will first Evaluate() the date range required in order to - /// determine the occurrences for the date provided, and then return - /// the occurrences. - /// - /// - /// The date for which to return occurrences. - /// A list of Periods representing the occurrences of this object. - HashSet GetOccurrences(IDateTime dt); - - HashSet GetOccurrences(DateTime dt); - /// /// Returns all occurrences of this component that overlap with the date range provided. /// All components that overlap with the time range between and will be returned. /// /// The starting date range /// The ending date range - HashSet GetOccurrences(IDateTime startTime, IDateTime endTime); + /// An IEnumerable that calculates and returns Periods representing the occurrences of this object in ascending order. + IEnumerable GetOccurrences(IDateTime startTime = null, IDateTime endTime = null); - HashSet GetOccurrences(DateTime startTime, DateTime endTime); + IEnumerable GetOccurrences(DateTime? startTime, DateTime? endTime); } public interface IGetOccurrencesTyped : IGetOccurrences { - /// - /// Returns all occurrences of components of type T that start on the date provided. - /// All components starting between 12:00:00AM and 11:59:59 PM will be - /// returned. - /// - /// This will first Evaluate() the date range required in order to - /// determine the occurrences for the date provided, and then return - /// the occurrences. - /// - /// - /// The date for which to return occurrences. - /// A list of Periods representing the occurrences of this object. - HashSet GetOccurrences(IDateTime dt) where T : IRecurringComponent; - - HashSet GetOccurrences(DateTime dt) where T : IRecurringComponent; - /// /// Returns all occurrences of components of type T that start within the date range provided. /// All components occurring between and /// will be returned. /// - /// The starting date range - /// The ending date range - HashSet GetOccurrences(IDateTime startTime, IDateTime endTime) where T : IRecurringComponent; + /// The starting date range. If set to null, occurrences are returned from the beginning. + /// The ending date range. If set to null, occurrences are returned until the end. + /// An IEnumerable that calculates and returns Periods representing the occurrences of this object in ascending order. + IEnumerable GetOccurrences(IDateTime startTime = null, IDateTime endTime = null) where T : IRecurringComponent; - HashSet GetOccurrences(DateTime startTime, DateTime endTime) where T : IRecurringComponent; + IEnumerable GetOccurrences(DateTime? startTime, DateTime? endTime) where T : IRecurringComponent; } diff --git a/Ical.Net/Utility/CollectionHelpers.cs b/Ical.Net/Utility/CollectionHelpers.cs index 16f81b17..b152af3c 100644 --- a/Ical.Net/Utility/CollectionHelpers.cs +++ b/Ical.Net/Utility/CollectionHelpers.cs @@ -89,4 +89,184 @@ public static void AddRange(this ICollection destination, IEnumerable s destination.Add(element); } } -} \ No newline at end of file + + /// + /// Merge the given ordered sequences into a single ordered sequence. + /// + /// + /// Each input sequence must be ordered according to the given comparer. Duplicates are allowed and will be preserved. + /// + /// The method operates in a streaming manner, meaning it only enumerates the input sequences while the + /// output sequence is being enumerated, and can therefore handle indefinite sequences. + /// + public static IEnumerable OrderedMerge(this IEnumerable items, IEnumerable other) + => items.OrderedMerge(other, Comparer.Default); + + /// + /// Merge the given ordered sequences into a single ordered sequence. + /// + /// + /// Each input sequence must be ordered according to the given comparer. Duplicates are allowed and will be preserved. + /// + /// The method operates in a streaming manner, meaning it only enumerates the input sequences while the + /// output sequence is being enumerated, and can therefore handle indefinite sequences. + /// + public static IEnumerable OrderedMerge(this IEnumerable items, IEnumerable other, IComparer comparer) + { + using var it1 = items.GetEnumerator(); + using var it2 = other.GetEnumerator(); + + var has1 = it1.MoveNext(); + var has2 = it2.MoveNext(); + + while (has1 || has2) + { + var cmp = (has1, has2) switch + { + (true, false) => -1, + (false, true) => 1, + _ => comparer.Compare(it1.Current, it2.Current) + }; + + if (cmp <= 0) + { + yield return it1.Current; + has1 = it1.MoveNext(); + } + else + { + yield return it2.Current; + has2 = it2.MoveNext(); + } + } + } + + /// + /// Merge the given ordered sequences into a single ordered sequence. + /// + /// + /// Each input sequence must be ordered according to types default comparer. Duplicates are allowed and will be preserved. + /// + /// The method operates in a streaming manner, meaning it only enumerates the input sequences while the + /// output sequence is being enumerated, and can therefore handle indefinite sequences. + /// + public static IEnumerable OrderedMergeMany(this IEnumerable> sequences) + => OrderedMergeMany(sequences, Comparer.Default); + + /// + /// Merge the given ordered sequences into a single ordered sequence. + /// + /// + /// Each input sequence must be ordered according to the given comparer. Duplicates are allowed and will be preserved. + /// + /// The method operates in a streaming manner, meaning it only enumerates the input sequences while the + /// output sequence is being enumerated, and can therefore handle indefinite sequences. + /// + public static IEnumerable OrderedMergeMany(this IEnumerable> sequences, IComparer comparer) + { + var list = (sequences as IList>) ?? sequences.ToList(); + return OrderedMergeMany(list, 0, list.Count, comparer); + } + + private static IEnumerable OrderedMergeMany(this IList> sequences, int offs, int length, IComparer comparer) + { + if (length == 0) + return []; + + if (length == 1) + return sequences[offs]; + + // Compose as a tree to ensure O(N*log(N)) complexity. Composing as a simple chain + // would result in O(N*N) complexity, which wouldn't be a problem either, as + // the number of sequences usually is low. + var mid = length / 2; + var left = OrderedMergeMany(sequences, offs, mid, comparer); + var right = OrderedMergeMany(sequences, offs + mid, length - mid, comparer); + + return left.OrderedMerge(right, comparer); + } + + /// + /// Returns the elements of the first ordered sequence that are not present in the second ordered sequence. + /// + /// + /// Both sequences must be ordered according to the type's default comparer. + /// + /// The method operates in a streaming manner, meaning it only enumerates the input sequences while the + /// output sequence is being enumerated, and can therefore handle indefinite sequences. + /// + public static IEnumerable OrderedExclude(this IEnumerable items, IEnumerable exclude) + => items.OrderedExclude(exclude, Comparer.Default); + + /// + /// Returns the elements of the first ordered sequence that are not present in the second ordered sequence. + /// + /// + /// Both sequences must be ordered according to the specified comparer. + /// + /// The method operates in a streaming manner, meaning it only enumerates the input sequences while the + /// output sequence is being enumerated, and can therefore handle indefinite sequences. + /// + public static IEnumerable OrderedExclude(this IEnumerable items, IEnumerable exclude, IComparer comparer) + { + using var it = items.GetEnumerator(); + using var itEx = exclude.GetEnumerator(); + + var hasNextIt = it.MoveNext(); + var hasNextEx = itEx.MoveNext(); + + while (hasNextIt) + { + var cmp = hasNextEx ? comparer.Compare(it.Current, itEx.Current) : -1; + if (cmp <= 0) + { + if (cmp < 0) + yield return it.Current; + + hasNextIt = it.MoveNext(); + } + else + { + hasNextEx = itEx.MoveNext(); + } + } + } + + /// + /// Returns a sequence containing the items of the ordered input sequence, with duplicates removed. + /// + /// + /// The input sequence must be ordered according to the type's default equality comparer, such + /// that equal items are adjacent to each other. + /// + /// The method operates in a streaming manner, meaning it only enumerates the input sequence while the + /// output sequence is enumerated and can therefore handle indefinite sequences. + /// + public static IEnumerable OrderedDistinct(this IEnumerable items) + => items.OrderedDistinct(EqualityComparer.Default); + + /// + /// Returns a sequence containing the items of the ordered input sequence, with duplicates removed. + /// + /// + /// The input sequence must be ordered according to the type's default equality comparer, such + /// that equal items are adjacent to each other. + /// + /// The method operates in a streaming manner, meaning it only enumerates the input sequence while the + /// output sequence is enumerated and can therefore handle indefinite sequences. + /// + public static IEnumerable OrderedDistinct(this IEnumerable items, IEqualityComparer comparer) + { + var prev = default(T); + var first = true; + + foreach (var item in items) + { + if (first || !comparer.Equals(prev, item)) + yield return item; + + prev = item; + first = false; + } + } +} diff --git a/Ical.Net/Utility/DateUtil.cs b/Ical.Net/Utility/DateUtil.cs index d716fe97..f38944fa 100644 --- a/Ical.Net/Utility/DateUtil.cs +++ b/Ical.Net/Utility/DateUtil.cs @@ -24,6 +24,9 @@ public static IDateTime EndOfDay(IDateTime dt) public static DateTime GetSimpleDateTimeData(IDateTime dt) => dt.Value; + public static CalDateTime AsCalDateTime(this DateTime t) + => new CalDateTime(t); + public static DateTime AddWeeks(DateTime dt, int interval, DayOfWeek firstDayOfWeek) { // NOTE: fixes WeeklyUntilWkst2() eval. diff --git a/Ical.Net/VTimeZoneInfo.cs b/Ical.Net/VTimeZoneInfo.cs index 545a9631..3cfa12ed 100644 --- a/Ical.Net/VTimeZoneInfo.cs +++ b/Ical.Net/VTimeZoneInfo.cs @@ -9,6 +9,7 @@ using Ical.Net.CalendarComponents; using Ical.Net.DataTypes; using Ical.Net.Evaluation; +using Ical.Net.Utility; namespace Ical.Net; @@ -55,6 +56,18 @@ public override bool Equals(object obj) return base.Equals(obj); } + public override int GetHashCode() + { + unchecked + { + var hashCode = TimeZoneName?.GetHashCode() ?? 0; + hashCode = (hashCode * 397) ^ (OffsetFrom?.GetHashCode() ?? 0); + hashCode = (hashCode * 397) ^ (OffsetTo?.GetHashCode() ?? 0); + + return hashCode; + } + } + public virtual string TzId { get => @@ -159,15 +172,9 @@ public virtual IDateTime RecurrenceId set => Properties.Set("RECURRENCE-ID", value); } - public virtual HashSet GetOccurrences(IDateTime dt) - => RecurrenceUtil.GetOccurrences(this, dt, true); - - public virtual HashSet GetOccurrences(DateTime dt) - => RecurrenceUtil.GetOccurrences(this, new CalDateTime(dt), true); - - public virtual HashSet GetOccurrences(IDateTime startTime, IDateTime endTime) + public virtual IEnumerable GetOccurrences(IDateTime startTime = null, IDateTime endTime = null) => RecurrenceUtil.GetOccurrences(this, startTime, endTime, true); - public virtual HashSet GetOccurrences(DateTime startTime, DateTime endTime) - => RecurrenceUtil.GetOccurrences(this, new CalDateTime(startTime), new CalDateTime(endTime), true); + public virtual IEnumerable GetOccurrences(DateTime? startTime, DateTime? endTime) + => RecurrenceUtil.GetOccurrences(this, startTime?.AsCalDateTime(), endTime?.AsCalDateTime(), true); }