From 24193489ec058a3cbcbf5f9d26c94b665a1c3b03 Mon Sep 17 00:00:00 2001 From: Markus Minichmayr Date: Wed, 27 Nov 2024 09:41:15 +0100 Subject: [PATCH] Recurrence evaluation: Implement support for negative `BYWEEKNO` values. (#654) * Test: Restore test `RRULE:FREQ=YEARLY;BYWEEKNO=1,2,-1,-2;BYDAY=TU;UNTIL=20170101T000000Z` and associated test failure. * Fix BYWEEKNO recurrence evaluation; consider negative _BY_ values. --- .../contrib/libical/icalrecur_test.out | 9 +++--- Ical.Net/CalendarExtensions.cs | 30 +++++++++++++++++++ .../Evaluation/RecurrencePatternEvaluator.cs | 19 ++++++++++-- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/Ical.Net.Tests/contrib/libical/icalrecur_test.out b/Ical.Net.Tests/contrib/libical/icalrecur_test.out index 87e3cbc7..8d957bae 100644 --- a/Ical.Net.Tests/contrib/libical/icalrecur_test.out +++ b/Ical.Net.Tests/contrib/libical/icalrecur_test.out @@ -340,11 +340,10 @@ DTSTART:20190101T100000 INSTANCES:20190102T100000,20190104T100000,20190116T100000,20190118T100000 PREV-INSTANCES:20190116T100000,20190104T100000,20190102T100000 -# TODO: FIX (see https://github.com/ical-org/ical.net/issues/618) -# RRULE:FREQ=YEARLY;BYWEEKNO=1,2,-1,-2;BYDAY=TU;UNTIL=20170101T000000Z -# DTSTART:20130101T000000 -# INSTANCES:20130101T000000,20130108T000000,20131217T000000,20131224T000000,20131231T000000,20140107T000000,20141216T000000,20141223T000000,20141230T000000,20150106T000000,20151222T000000,20151229T000000,20160105T000000,20160112T000000,20161220T000000,20161227T000000 -# PREV-INSTANCES:20161227T000000,20161220T000000,20160112T000000,20160105T000000,20151229T000000,20151222T000000,20150106T000000,20141230T000000,20141223T000000,20141216T000000,20140107T000000,20131231T000000,20131224T000000,20131217T000000,20130108T000000,20130101T000000 +RRULE:FREQ=YEARLY;BYWEEKNO=1,2,-1,-2;BYDAY=TU;UNTIL=20170101T000000Z +DTSTART:20130101T000000 +INSTANCES:20130101T000000,20130108T000000,20131217T000000,20131224T000000,20131231T000000,20140107T000000,20141216T000000,20141223T000000,20141230T000000,20150106T000000,20151222T000000,20151229T000000,20160105T000000,20160112T000000,20161220T000000,20161227T000000 +PREV-INSTANCES:20161227T000000,20161220T000000,20160112T000000,20160105T000000,20151229T000000,20151222T000000,20150106T000000,20141230T000000,20141223T000000,20141216T000000,20140107T000000,20131231T000000,20131224T000000,20131217T000000,20130108T000000,20130101T000000 RRULE:FREQ=YEARLY;BYWEEKNO=53;BYDAY=TU,SA;UNTIL=20170101T000000Z DTSTART:20130101T000000 diff --git a/Ical.Net/CalendarExtensions.cs b/Ical.Net/CalendarExtensions.cs index 6848ef29..8b0007f4 100644 --- a/Ical.Net/CalendarExtensions.cs +++ b/Ical.Net/CalendarExtensions.cs @@ -39,4 +39,34 @@ private static DateTime GetStartOfWeek(this DateTime t, DayOfWeek firstDayOfWeek var tn = ((int) t.DayOfWeek) % 7; return t.AddDays(-((tn + 7 - t0) % 7)); } + + /// + /// Calculate the year, the given date's week belongs to according to ISO 8601, as required by RFC 5545. + /// + /// + /// A date's nominal year may be different from the year, the week belongs to that the date is in. + /// I.e. the first and last week of the year may belong to a different year than the date's year. + /// E.g. for `2019-12-31` with first day of the week being Monday, the method will return 2020, + /// because the week that contains `2019-12-31` is the first week of 2020. + /// + public static int GetIso8601YearOfWeek(this System.Globalization.Calendar calendar, DateTime time, DayOfWeek firstDayOfWeek) + { + var year = time.Year; + if ((time.Month >= 12) && (calendar.GetIso8601WeekOfYear(time, firstDayOfWeek) == 1)) + year++; + else if ((time.Month == 1) && (calendar.GetIso8601WeekOfYear(time, firstDayOfWeek) >= 52)) + year--; + + return year; + } + + /// + /// Calculate the number of weeks in the given year according to ISO 8601, as required by RFC 5545. + /// + public static int GetIso8601WeeksInYear(this System.Globalization.Calendar calendar, int year, DayOfWeek firstDayOfWeek) + { + // The last week of the year is the week that contains the 4th-last day of the year (which is the 28th of December in Gregorian Calendar). + var testTime = new DateTime(year + 1, 1, 1, 0, 0, 0, DateTimeKind.Unspecified).AddDays(-4); + return calendar.GetIso8601WeekOfYear(testTime, firstDayOfWeek); + } } diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs index 118bdbc0..37633246 100644 --- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs +++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs @@ -395,7 +395,7 @@ private List GetWeekNoVariants(List dates, RecurrencePattern var weekNoDates = new List(); foreach (var t in dates) { - foreach (var weekNo in pattern.ByWeekNo) + foreach (var weekNo in GetByWeekNoForYearNormalized(pattern, t.Year)) { var date = t; // Determine our current week number @@ -429,6 +429,17 @@ private List GetWeekNoVariants(List dates, RecurrencePattern return weekNoDates; } + /// + /// Normalize the BYWEEKNO values to be positive integers. + /// + private List GetByWeekNoForYearNormalized(RecurrencePattern pattern, int year) + { + var weeksInYear = new Lazy(() => Calendar.GetIso8601WeeksInYear(year, pattern.FirstDayOfWeek)); + return pattern.ByWeekNo + .Select(weekNo => weekNo >= 0 ? weekNo : weeksInYear.Value + weekNo + 1) + .ToList(); + } + /// /// Applies BYYEARDAY rules specified in this Recur instance to the specified date list. /// If no BYYEARDAY rules are specified, the date list is returned unmodified. @@ -641,13 +652,14 @@ private List GetAbsWeekDays(DateTime date, WeekDay weekDay, Recurrence var nextWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); var currentWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); + var byWeekNoNormalized = GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek)); //When we manage weekly recurring pattern and we have boundary case: //Weekdays: Dec 31, Jan 1, Feb 1, Mar 1, Apr 1, May 1, June 1, Dec 31 - It's the 53th week of the year, but all another are 1st week number. //So we need an EXRULE for this situation, but only for weekly events while (currentWeekNo == weekNo || (nextWeekNo < weekNo && currentWeekNo == nextWeekNo && pattern.Frequency == FrequencyType.Weekly)) { - if ((pattern.ByWeekNo.Count == 0 || pattern.ByWeekNo.Contains(currentWeekNo)) + if ((byWeekNoNormalized.Count == 0 || byWeekNoNormalized.Contains(currentWeekNo)) && (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month))) { days.Add(date); @@ -668,11 +680,12 @@ private List GetAbsWeekDays(DateTime date, WeekDay weekDay, Recurrence date = date.AddDays(1); } + var byWeekNoNormalized = GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek)); while (date.Month == month) { var currentWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek); - if ((pattern.ByWeekNo.Count == 0 || pattern.ByWeekNo.Contains(currentWeekNo)) + if ((byWeekNoNormalized.Count == 0 || byWeekNoNormalized.Contains(currentWeekNo)) && (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month))) { days.Add(date);