diff --git a/lib/ical/recur_iterator.js b/lib/ical/recur_iterator.js index f0a15637..6439bf70 100644 --- a/lib/ical/recur_iterator.js +++ b/lib/ical/recur_iterator.js @@ -233,7 +233,7 @@ class RecurIterator { this.last.second = this.setup_defaults("BYSECOND", "SECONDLY", this.dtstart.second); this.last.minute = this.setup_defaults("BYMINUTE", "MINUTELY", this.dtstart.minute); this.last.hour = this.setup_defaults("BYHOUR", "HOURLY", this.dtstart.hour); - let dayOffset = this.last.day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day); + this.last.day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day); this.last.month = this.setup_defaults("BYMONTH", "MONTHLY", this.dtstart.month); if (this.rule.freq == "WEEKLY") { @@ -262,77 +262,79 @@ class RecurIterator { this._nextByYearDay(); } - if (this.rule.freq == "MONTHLY" && this.has_by_data("BYDAY")) { - let tempLast = null; - let initLast = this.last.clone(); - let daysInMonth = Time.daysInMonth(this.last.month, this.last.year); - - // Check every weekday in BYDAY with relative dow and pos. - for (let bydow of this.by_data.BYDAY) { - this.last = initLast.clone(); - let [pos, dow] = this.ruleDayOfWeek(bydow); - let dayOfMonth = this.last.nthWeekDay(dow, pos); - - // If |pos| >= 6, the byday is invalid for a monthly rule. - if (pos >= 6 || pos <= -6) { - throw new Error("Malformed values in BYDAY part"); - } + if (this.rule.freq == "MONTHLY") { + if (this.has_by_data("BYDAY")) { + let tempLast = null; + let initLast = this.last.clone(); + let daysInMonth = Time.daysInMonth(this.last.month, this.last.year); + + // Check every weekday in BYDAY with relative dow and pos. + for (let bydow of this.by_data.BYDAY) { + this.last = initLast.clone(); + let [pos, dow] = this.ruleDayOfWeek(bydow); + let dayOfMonth = this.last.nthWeekDay(dow, pos); + + // If |pos| >= 6, the byday is invalid for a monthly rule. + if (pos >= 6 || pos <= -6) { + throw new Error("Malformed values in BYDAY part"); + } - // If a Byday with pos=+/-5 is not in the current month it - // must be searched in the next months. - if (dayOfMonth > daysInMonth || dayOfMonth <= 0) { - // Skip if we have already found a "last" in this month. - if (tempLast && tempLast.month == initLast.month) { - continue; + // If a Byday with pos=+/-5 is not in the current month it + // must be searched in the next months. + if (dayOfMonth > daysInMonth || dayOfMonth <= 0) { + // Skip if we have already found a "last" in this month. + if (tempLast && tempLast.month == initLast.month) { + continue; + } + while (dayOfMonth > daysInMonth || dayOfMonth <= 0) { + this.increment_month(); + daysInMonth = Time.daysInMonth(this.last.month, this.last.year); + dayOfMonth = this.last.nthWeekDay(dow, pos); + } } - while (dayOfMonth > daysInMonth || dayOfMonth <= 0) { - this.increment_month(); - daysInMonth = Time.daysInMonth(this.last.month, this.last.year); - dayOfMonth = this.last.nthWeekDay(dow, pos); + + this.last.day = dayOfMonth; + if (!tempLast || this.last.compare(tempLast) < 0) { + tempLast = this.last.clone(); } } - - this.last.day = dayOfMonth; - if (!tempLast || this.last.compare(tempLast) < 0) { - tempLast = this.last.clone(); + this.last = tempLast.clone(); + + //XXX: This feels like a hack, but we need to initialize + // the BYMONTHDAY case correctly and byDayAndMonthDay handles + // this case. It accepts a special flag which will avoid incrementing + // the initial value without the flag days that match the start time + // would be missed. + if (this.has_by_data('BYMONTHDAY')) { + this._byDayAndMonthDay(true); } - } - this.last = tempLast.clone(); - - //XXX: This feels like a hack, but we need to initialize - // the BYMONTHDAY case correctly and byDayAndMonthDay handles - // this case. It accepts a special flag which will avoid incrementing - // the initial value without the flag days that match the start time - // would be missed. - if (this.has_by_data('BYMONTHDAY')) { - this._byDayAndMonthDay(true); - } - - if (this.last.day > daysInMonth || this.last.day == 0) { - throw new Error("Malformed values in BYDAY part"); - } - } else if (this.has_by_data("BYMONTHDAY")) { - // Attempting to access `this.last.day` will cause the date to be normalised. - // So it will never be a negative value or more than the number of days in the month. - // We keep the value in a separate variable instead. - // Change the day value so that normalisation won't change the month. - this.last.day = 1; - let daysInMonth = Time.daysInMonth(this.last.month, this.last.year); + if (this.last.day > daysInMonth || this.last.day == 0) { + throw new Error("Malformed values in BYDAY part"); + } + } else if (this.has_by_data("BYMONTHDAY")) { + // Change the day value so that normalisation won't change the month. + this.last.day = 1; - if (dayOffset < 0) { - // A negative value represents days before the end of the month. - this.last.day = daysInMonth + dayOffset + 1; - } else if (this.by_data.BYMONTHDAY[0] > daysInMonth) { - // There's no occurrence in this month, find the next valid month. - // The longest possible sequence of skipped months is February-April-June, - // so we might need to call next_month up to three times. - if (!this.next_month() && !this.next_month() && !this.next_month()) { - throw new Error("No possible occurrences"); + // Get a sorted list of days in the starting month that match the rule. + let normalized = this.normalizeByMonthDayRules( + this.last.year, + this.last.month, + this.rule.parts.BYMONTHDAY + ).filter(d => d >= this.last.day); + + if (normalized.length) { + // There's at least one valid day, use it. + this.last.day = normalized[0]; + this.by_data.BYMONTHDAY = normalized; + } else { + // There's no occurrence in this month, find the next valid month. + // The longest possible sequence of skipped months is February-April-June, + // so we might need to call next_month up to three times. + if (!this.next_month() && !this.next_month() && !this.next_month()) { + throw new Error("No possible occurrences"); + } } - } else { - // Otherwise, reset the day. - this.last.day = dayOffset; } } } @@ -513,7 +515,10 @@ class RecurIterator { let rule; for (; ruleIdx < len; ruleIdx++) { - rule = rules[ruleIdx]; + rule = parseInt(rules[ruleIdx], 10); + if (isNaN(rule)) { + throw new Error('Invalid BYMONTHDAY value'); + } // if this rule falls outside of given // month discard it. @@ -747,6 +752,9 @@ class RecurIterator { if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) { this.by_indices.BYMONTHDAY = 0; this.increment_month(); + if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) { + return 0; + } } let daysInMonth = Time.daysInMonth(this.last.month, this.last.year); @@ -762,7 +770,6 @@ class RecurIterator { } else { this.last.day = day; } - } else { this.increment_month(); let daysInMonth = Time.daysInMonth(this.last.month, this.last.year); @@ -835,7 +842,6 @@ class RecurIterator { } next_year() { - if (this.next_hour() == 0) { return 0; } @@ -844,6 +850,13 @@ class RecurIterator { this.days_index = 0; do { this.increment_year(this.rule.interval); + if (this.has_by_data("BYMONTHDAY")) { + this.by_data.BYMONTHDAY = this.normalizeByMonthDayRules( + this.last.year, + this.last.month, + this.rule.parts.BYMONTHDAY + ); + } this.expand_year_days(this.last.year); } while (this.days.length == 0); } @@ -854,19 +867,19 @@ class RecurIterator { } _nextByYearDay() { - let doy = this.days[this.days_index]; - let year = this.last.year; - if (doy < 1) { - // Time.fromDayOfYear(doy, year) indexes relative to the - // start of the given year. That is different from the - // semantics of BYYEARDAY where negative indexes are an - // offset from the end of the given year. - doy += 1; - year += 1; - } - let next = Time.fromDayOfYear(doy, year); - this.last.day = next.day; - this.last.month = next.month; + let doy = this.days[this.days_index]; + let year = this.last.year; + if (doy < 1) { + // Time.fromDayOfYear(doy, year) indexes relative to the + // start of the given year. That is different from the + // semantics of BYYEARDAY where negative indexes are an + // offset from the end of the given year. + doy += 1; + year += 1; + } + let next = Time.fromDayOfYear(doy, year); + this.last.day = next.day; + this.last.month = next.month; } /** @@ -953,9 +966,19 @@ class RecurIterator { this.increment_year(years); } } + + if (this.has_by_data("BYMONTHDAY")) { + this.by_data.BYMONTHDAY = this.normalizeByMonthDayRules( + this.last.year, + this.last.month, + this.rule.parts.BYMONTHDAY + ); + } } increment_year(inc) { + // Don't jump into the next month if this.last is Feb 29. + this.last.day = 1; this.last.year += inc; } @@ -1177,6 +1200,14 @@ class RecurIterator { } else { this.days = []; } + + let daysInYear = Time.isLeapYear(aYear) ? 366 : 365; + this.days.sort((a, b) => { + if (a < 0) a += daysInYear + 1; + if (b < 0) b += daysInYear + 1; + return a - b; + }); + return 0; } diff --git a/test/recur_iterator_test.js b/test/recur_iterator_test.js index 27d6d571..3ba1bb89 100644 --- a/test/recur_iterator_test.js +++ b/test/recur_iterator_test.js @@ -642,6 +642,95 @@ suite('recur_iterator', function() { ] }); + // Weirdly ordered BYMONTHDAY. Occurrences should be in chronological order. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=28,-4,11', { + dtStart: '2022-01-01T08:00:00', + dates: [ + '2022-01-11T08:00:00', + '2022-01-28T08:00:00', + '2022-02-11T08:00:00', + '2022-02-25T08:00:00', + '2022-02-28T08:00:00', + '2022-03-11T08:00:00', + '2022-03-28T08:00:00', + '2022-04-11T08:00:00', + '2022-04-27T08:00:00', + '2022-04-28T08:00:00', + ] + }); + + testRRULE('FREQ=MONTHLY;BYMONTHDAY=-4,28,11', { + dtStart: '2022-01-01T08:00:00', + dates: [ + '2022-01-11T08:00:00', + '2022-01-28T08:00:00', + '2022-02-11T08:00:00', + '2022-02-25T08:00:00', + '2022-02-28T08:00:00', + '2022-03-11T08:00:00', + '2022-03-28T08:00:00', + '2022-04-11T08:00:00', + '2022-04-27T08:00:00', + '2022-04-28T08:00:00', + ] + }); + + // Multiple BYMONTHDAYs, checks that we start on the right one. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=6,12,18,24', { + dtStart: '2024-04-03T01:00:00', + dates: [ + '2024-04-06T01:00:00', + '2024-04-12T01:00:00', + '2024-04-18T01:00:00', + '2024-04-24T01:00:00', + '2024-05-06T01:00:00', + ] + }); + + testRRULE('FREQ=MONTHLY;BYMONTHDAY=6,12,18,24', { + dtStart: '2024-04-09T01:00:00', + dates: [ + '2024-04-12T01:00:00', + '2024-04-18T01:00:00', + '2024-04-24T01:00:00', + '2024-05-06T01:00:00', + '2024-05-12T01:00:00', + ] + }); + + testRRULE('FREQ=MONTHLY;BYMONTHDAY=6,12,18,24', { + dtStart: '2024-04-15T01:00:00', + dates: [ + '2024-04-18T01:00:00', + '2024-04-24T01:00:00', + '2024-05-06T01:00:00', + '2024-05-12T01:00:00', + '2024-05-18T01:00:00', + ] + }); + + testRRULE('FREQ=MONTHLY;BYMONTHDAY=6,12,18,24', { + dtStart: '2024-04-21T01:00:00', + dates: [ + '2024-04-24T01:00:00', + '2024-05-06T01:00:00', + '2024-05-12T01:00:00', + '2024-05-18T01:00:00', + '2024-05-24T01:00:00', + ] + }); + + testRRULE('FREQ=MONTHLY;BYMONTHDAY=6,12,18,24', { + dtStart: '2024-04-27T01:00:00', + dates: [ + '2024-05-06T01:00:00', + '2024-05-12T01:00:00', + '2024-05-18T01:00:00', + '2024-05-24T01:00:00', + '2024-06-06T01:00:00', + ] + }); + // Last day of the month, monthly. testRRULE('FREQ=MONTHLY;BYMONTHDAY=-1', { dtStart: '2015-01-01T08:00:00', @@ -1053,6 +1142,36 @@ suite('recur_iterator', function() { ] }); + // BYYEARDAY with positive and negative rules. + // Occurrences should be in chronological order. + testRRULE('FREQ=YEARLY;BYYEARDAY=359,-7', { + dtStart: '2024-01-01', + dates: [ + '2024-12-24', // 359 + '2024-12-25', // -7 + '2025-12-25', + '2026-12-25', + '2027-12-25', + '2028-12-24', + '2028-12-25', + ] + }); + + // BYYEARDAY with negative and positive rules. + // Occurrences should be in chronological order. + testRRULE('FREQ=YEARLY;BYYEARDAY=-7,359', { + dtStart: '2024-01-01', + dates: [ + '2024-12-24', // 359 + '2024-12-25', // -7 + '2025-12-25', + '2026-12-25', + '2027-12-25', + '2028-12-24', + '2028-12-25', + ] + }); + /* * Leap-year test for February 29th * @@ -1072,7 +1191,61 @@ suite('recur_iterator', function() { }); */ + // Multiple BYYEARDAYs, checks that we start on the right one. + testRRULE('FREQ=YEARLY;BYYEARDAY=73,146,219,292', { + dtStart: '2024-02-06T01:00:00', + dates: [ + '2024-03-13T01:00:00', + '2024-05-25T01:00:00', + '2024-08-06T01:00:00', + '2024-10-18T01:00:00', + '2025-03-14T01:00:00', + ] + }); + testRRULE('FREQ=YEARLY;BYYEARDAY=73,146,219,292', { + dtStart: '2024-04-19T01:00:00', + dates: [ + '2024-05-25T01:00:00', + '2024-08-06T01:00:00', + '2024-10-18T01:00:00', + '2025-03-14T01:00:00', + '2025-05-26T01:00:00', + ] + }); + + testRRULE('FREQ=YEARLY;BYYEARDAY=73,146,219,292', { + dtStart: '2024-07-01T01:00:00', + dates: [ + '2024-08-06T01:00:00', + '2024-10-18T01:00:00', + '2025-03-14T01:00:00', + '2025-05-26T01:00:00', + '2025-08-07T01:00:00', + ] + }); + + testRRULE('FREQ=YEARLY;BYYEARDAY=73,146,219,292', { + dtStart: '2024-09-12T01:00:00', + dates: [ + '2024-10-18T01:00:00', + '2025-03-14T01:00:00', + '2025-05-26T01:00:00', + '2025-08-07T01:00:00', + '2025-10-19T01:00:00', + ] + }); + + testRRULE('FREQ=YEARLY;BYYEARDAY=73,146,219,292', { + dtStart: '2024-11-24T01:00:00', + dates: [ + '2025-03-14T01:00:00', + '2025-05-26T01:00:00', + '2025-08-07T01:00:00', + '2025-10-19T01:00:00', + '2026-03-14T01:00:00', + ] + }); }); }); });