diff --git a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt index d703b4e2e..33878c75c 100644 --- a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt +++ b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt @@ -27,6 +27,67 @@ RRULE:FREQ=YEARLY;BYWEEKNO=1;BYDAY=MO,TU;INTERVAL=3;UNTIL=20320101 DTSTART:20241231 INSTANCES:20241231,20280103,20280104,20301230,20301231 + +# 30.12 in WEEK 1 with expansion on BYMONTH, BYMONTHDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=1,30;BYWEEKNO=1;UNTIL=20320101 +DTSTART:20241230 +INSTANCES:20241230,20251230,20301230,20311230 + +# 30.12 in WEEK 1 with expansion on BYYEARDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYYEARDAY=-2;BYWEEKNO=1;UNTIL=20320101 +DTSTART:20241230 +INSTANCES:20241230,20251230,20301230,20311230 + +# 30.12 in WEEK 52 with expansion on BYMONTH, BYMONTHDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=1,30;BYWEEKNO=52;UNTIL=20340101 +DTSTART:20271230 +INSTANCES:20271230,20281230,20291230,20331230 + +# 30.12 in WEEK 52 with expansion on BYYEARDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYYEARDAY=-2;BYWEEKNO=52;UNTIL=20340101 +DTSTART:20271230 +INSTANCES:20271230,20281230,20291230,20331230 + +# 30.12 in WEEK 53 with expansion on BYMONTH, BYMONTHDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=1,30;BYWEEKNO=53;UNTIL=20380101 +DTSTART:20261230 +INSTANCES:20261230,20321230,20371230 + +# 30.12 in WEEK 53 with expansion on BYYEARDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYYEARDAY=-2;BYWEEKNO=53;UNTIL=20380101 +DTSTART:20261230 +INSTANCES:20261230,20321230,20371230 + +# 2.1 in WEEK 1 with expansion on BYMONTH, BYMONTHDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=2,31;BYWEEKNO=1;UNTIL=20290102 +DTSTART:20250102 +INSTANCES:20250102,20260102,20290102 + +# 2.1 in WEEK 1 with expansion on BYYEARDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYYEARDAY=2;BYWEEKNO=1;UNTIL=20290102 +DTSTART:20250102 +INSTANCES:20250102,20260102,20290102 + +# 2.1 in WEEK 52 with expansion on BYMONTH, BYMONTHDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=2,31;BYWEEKNO=52;UNTIL=20390102 +DTSTART:20280102 +INSTANCES:20280102,20390102 + +# 2.1 in WEEK 52 with expansion on BYYEARDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYYEARDAY=2;BYWEEKNO=52;UNTIL=20390102 +DTSTART:20280102 +INSTANCES:20280102,20390102 + +# 2.1 in WEEK 53 with expansion on BYMONTH, BYMONTHDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=2,31;BYWEEKNO=53;UNTIL=20380102 +DTSTART:20270102 +INSTANCES:20270102,20330102,20380102 + +# 2.1 in WEEK 53 with expansion on BYYEARDAY, BYWEEKNO +RRULE:FREQ=YEARLY;BYYEARDAY=2;BYWEEKNO=53;UNTIL=20380102 +DTSTART:20270102 +INSTANCES:20270102,20330102,20380102 + # BYMONTHDAY with limit behaviour; reproduces #728 as reported by pinkfloydx33 RRULE:FREQ=DAILY;BYMONTHDAY=20,-2;UNTIL=20250401 DTSTART:20250220 diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index d425f7bfb..fad1b3e89 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -222,6 +222,18 @@ private IEnumerable EnumerateDates(DateTime originalDate, DateTime see } } + private struct ExpandContext + { + /// + /// Indicates whether the dates have been fully expanded. If true, subsequent parts should only limit, not expand. + /// + /// + /// This makes a difference in case of BYWEEKNO, which might span months and years. After it was applied (BYWEEKNO would + /// always expand), the subsequent parts mustn't expand. + /// + public bool DatesFullyExpanded { get; set; } + } + /// /// Returns a list of possible dates generated from the applicable BY* rules, using the specified date as a seed. /// @@ -231,12 +243,14 @@ private IEnumerable EnumerateDates(DateTime originalDate, DateTime see /// A list of possible dates. private ISet GetCandidates(DateTime date, RecurrencePattern pattern, bool?[] expandBehaviors) { + var expandContext = new ExpandContext() { DatesFullyExpanded = false }; + var dates = new List { date }; dates = GetMonthVariants(dates, pattern, expandBehaviors[0]); - dates = GetWeekNoVariants(dates, pattern, expandBehaviors[1]); - dates = GetYearDayVariants(dates, pattern, expandBehaviors[2]); - dates = GetMonthDayVariants(dates, pattern, expandBehaviors[3]); - dates = GetDayVariants(dates, pattern, expandBehaviors[4]); + dates = GetWeekNoVariants(dates, pattern, expandBehaviors[1], ref expandContext); + dates = GetYearDayVariants(dates, pattern, expandBehaviors[2], ref expandContext); + dates = GetMonthDayVariants(dates, pattern, expandBehaviors[3], ref expandContext); + dates = GetDayVariants(dates, pattern, expandBehaviors[4], ref expandContext); dates = GetHourVariants(dates, pattern, expandBehaviors[5]); dates = GetMinuteVariants(dates, pattern, expandBehaviors[6]); dates = GetSecondVariants(dates, pattern, expandBehaviors[7]); @@ -306,7 +320,7 @@ private List GetMonthVariants(List dates, RecurrencePattern /// /// The list of dates to which the BYWEEKNO rules will be applied. /// The modified list of dates after applying the BYWEEKNO rules. - private List GetWeekNoVariants(List dates, RecurrencePattern pattern, bool? expand) + private List GetWeekNoVariants(List dates, RecurrencePattern pattern, bool? expand, ref ExpandContext expandContext) { if (expand == null || pattern.ByWeekNo.Count == 0) { @@ -320,47 +334,47 @@ private List GetWeekNoVariants(List dates, RecurrencePattern // Expand behavior var weekNoDates = new List(); - foreach (var t in dates) + foreach ((var t, var weekNo) in dates.SelectMany(t => GetByWeekNoForYearNormalized(pattern, t.Year), (t, weekNo) => (t, weekNo))) { - foreach (var weekNo in GetByWeekNoForYearNormalized(pattern, t.Year)) - { - var date = t; + var date = t; - // Make sure we start from a reference date that is in a week that belongs to the current year. - // Its not important that the date lies in a certain week, but that the week belongs to the - // current year and that the week day is preserved. - if (date.Month == 1) - date = date.AddDays(7); - else if (date.Month >= 12) - date = date.AddDays(-7); + // Make sure we start from a reference date that is in a week that belongs to the current year. + // Its not important that the date lies in a certain week, but that the week belongs to the + // current year and that the week day is preserved. + if (date.Month == 1) + date = date.AddDays(7); + else if (date.Month >= 12) + date = date.AddDays(-7); - // Determine our current week number - var currWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); + // Determine our current week number + var currWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); - // Move ahead to the correct week of the year - date = date.AddDays((weekNo - currWeekNo) * 7); + // Move ahead to the correct week of the year + date = date.AddDays((weekNo - currWeekNo) * 7); - // Ignore the week if it doesn't belong to the current year. - if (Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek) == t.Year) - { - // Step backward single days until we're at the correct DayOfWeek - while (date.DayOfWeek != pattern.FirstDayOfWeek) - { - date = date.AddDays(-1); - } + // Ignore the week if it doesn't belong to the current year. + if (Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek) != t.Year) + continue; - for (var k = 0; k < 7; k++) - { - weekNoDates.Add(date); - date = date.AddDays(1); - } - } - } + // Step backward single days until we're at the correct DayOfWeek + date = GetFirstDayOfWeekDate(date, pattern.FirstDayOfWeek); + + weekNoDates.AddRange(Enumerable.Range(0, 7).Select(i => date.AddDays(i))); } + // subsequent parts should only limit, not expand + expandContext.DatesFullyExpanded = true; + + // Apply BYMONTH limit behavior, as we might have expanded over month/year boundaries + // in this method and BYMONTH has already been applied before, so wouldn't be again. + weekNoDates = GetMonthVariants(weekNoDates, pattern, expand: false); + return weekNoDates; } + private static DateTime GetFirstDayOfWeekDate(DateTime date, DayOfWeek firstDayOfWeek) + => date.AddDays(-((int) date.DayOfWeek + 7 - (int) firstDayOfWeek) % 7); + /// /// Normalize the BYWEEKNO values to be positive integers. /// @@ -378,14 +392,14 @@ private List GetByWeekNoForYearNormalized(RecurrencePattern pattern, int ye /// /// The list of dates to which the BYYEARDAY rules will be applied. /// The modified list of dates after applying the BYYEARDAY rules. - private List GetYearDayVariants(List dates, RecurrencePattern pattern, bool? expand) + private static List GetYearDayVariants(List dates, RecurrencePattern pattern, bool? expand, ref ExpandContext expandContext) { if (expand == null || pattern.ByYearDay.Count == 0) { return dates; } - if (expand.Value) + if (expand.Value && !expandContext.DatesFullyExpanded) { var yearDayDates = new List(dates.Count); foreach (var date in dates) @@ -397,6 +411,8 @@ private List GetYearDayVariants(List dates, RecurrencePatter // Ignore the BY values that don't fit into the current year (i.e. +-366 in non-leap-years). .Where(d => d.Year == date1.Year)); } + + expandContext.DatesFullyExpanded = true; return yearDayDates; } // Limit behavior @@ -434,14 +450,14 @@ private List GetYearDayVariants(List dates, RecurrencePatter /// /// The list of dates to which the BYMONTHDAY rules will be applied. /// The modified list of dates after applying the BYMONTHDAY rules. - private List GetMonthDayVariants(List dates, RecurrencePattern pattern, bool? expand) + private List GetMonthDayVariants(List dates, RecurrencePattern pattern, bool? expand, ref ExpandContext expandContext) { if (expand == null || pattern.ByMonthDay.Count == 0) { return dates; } - if (expand.Value) + if (expand.Value && !expandContext.DatesFullyExpanded) { var monthDayDates = new List(); foreach (var date in dates) @@ -455,6 +471,8 @@ select monthDay > 0 : date.AddDays(-date.Day + 1).AddMonths(1).AddDays(monthDay) ); } + + expandContext.DatesFullyExpanded = true; return monthDayDates; } @@ -500,14 +518,14 @@ select monthDay > 0 /// /// The list of dates to which BYDAY rules will be applied. /// The modified list of dates after applying BYDAY rules, or the original list if no BYDAY rules are specified. - private List GetDayVariants(List dates, RecurrencePattern pattern, bool? expand) + private List GetDayVariants(List dates, RecurrencePattern pattern, bool? expand, ref ExpandContext expandContext) { if (expand == null || pattern.ByDay.Count == 0) { return dates; } - if (expand.Value) + if (expand.Value && !expandContext.DatesFullyExpanded) { // Expand behavior var weekDayDates = new List(); @@ -519,6 +537,7 @@ private List GetDayVariants(List dates, RecurrencePattern pa } } + expandContext.DatesFullyExpanded = true; return weekDayDates; }