diff --git a/lib/ical/recur_iterator.js b/lib/ical/recur_iterator.js index 3cb85c14..6d8d6a34 100644 --- a/lib/ical/recur_iterator.js +++ b/lib/ical/recur_iterator.js @@ -353,26 +353,29 @@ class RecurIterator { 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; - - // 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; + if (this.has_by_data("BYSETPOS")) { + this._initMonthlyByMonthDaySetPos(); } 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 InvalidRecurrenceRuleError(); + // Change the day value so that normalisation won't change the month. + this.last.day = 1; + // 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 InvalidRecurrenceRuleError(); + } } } } @@ -802,28 +805,32 @@ class RecurIterator { } } } else if (this.has_by_data("BYMONTHDAY")) { - this.by_indices.BYMONTHDAY++; + if (this.has_by_data("BYSETPOS")) { + data_valid = this._nextMonthlyByMonthDaySetPos(); + } else { + this.by_indices.BYMONTHDAY++; - 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; + 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); - let day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY]; + let daysInMonth = Time.daysInMonth(this.last.month, this.last.year); + let day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY]; - if (day < 0) { - day = daysInMonth + day + 1; - } + if (day < 0) { + day = daysInMonth + day + 1; + } - if (day > daysInMonth) { - this.last.day = 1; - data_valid = this.is_day_in_byday(this.last); - } else { - this.last.day = day; + if (day > daysInMonth) { + this.last.day = 1; + data_valid = this.is_day_in_byday(this.last); + } else { + this.last.day = day; + } } } else { this.increment_month(); @@ -1359,6 +1366,92 @@ class RecurIterator { return 0; } + /** + * Find the first BYMONTHDAY in the current month at a BYSETPOS position + * on or after minDay. + * + * @private + * @param {Number} minDay + * @return {Number|null} + */ + _findByMonthDaySetPos(minDay) { + let monthDays = this.normalizeByMonthDayRules( + this.last.year, + this.last.month, + this.rule.parts.BYMONTHDAY + ); + + for (let spIndex = 0; spIndex < monthDays.length; spIndex++) { + let day = monthDays[spIndex]; + if (day < minDay) { + continue; + } + if (this.check_set_position(spIndex + 1) || + this.check_set_position(spIndex - monthDays.length)) { + return day; + } + } + + return null; + } + + /** + * Initialize MONTHLY iteration with BYMONTHDAY and BYSETPOS. + * + * @private + */ + _initMonthlyByMonthDaySetPos() { + let day = this._findByMonthDaySetPos(this.last.day); + let attempts = 0; + + while (!day && attempts++ < 48) { + this.last.day = 1; + this.increment_month(); + day = this._findByMonthDaySetPos(1); + } + + if (!day) { + throw new InvalidRecurrenceRuleError(); + } + + this.last.day = day; + this.by_data.BYMONTHDAY = this.normalizeByMonthDayRules( + this.last.year, + this.last.month, + this.rule.parts.BYMONTHDAY + ); + } + + /** + * Advance MONTHLY iteration with BYMONTHDAY and BYSETPOS. + * + * @private + * @return {Number} 1 if a valid day was found, 0 otherwise + */ + _nextMonthlyByMonthDaySetPos() { + let day = this._findByMonthDaySetPos(this.last.day + 1); + let attempts = 0; + + while (!day && attempts++ < 48) { + this.last.day = 1; + this.increment_month(); + day = this._findByMonthDaySetPos(1); + } + + if (!day) { + return 0; + } + + this.last.day = day; + this.by_data.BYMONTHDAY = this.normalizeByMonthDayRules( + this.last.year, + this.last.month, + this.rule.parts.BYMONTHDAY + ); + + return 1; + } + /** * Checks if given value is in BYSETPOS. * diff --git a/test/recur_iterator_test.js b/test/recur_iterator_test.js index 085e871a..cb5ab471 100644 --- a/test/recur_iterator_test.js +++ b/test/recur_iterator_test.js @@ -866,6 +866,130 @@ suite('recur_iterator', function() { "2015-04-19T08:00:00Z" ] }); + // Choose the last valid day among a list of month-end candidates. + // This covers February and months with 30 or 31 days. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1', { + dtStart: '2026-01-01', + dates: [ + '2026-01-31', + '2026-02-28', + '2026-03-31', + '2026-04-30', + '2026-05-31', + '2026-06-30', + '2026-07-31', + '2026-08-31', + '2026-09-30', + '2026-10-31', + '2026-11-30', + '2026-12-31', + ] + }); + // Pick the first occurrence from multiple BYMONTHDAY values. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=1;COUNT=3', { + dtStart: '2016-01-01', + byCount: true, + dates: [ + '2016-01-01', + '2016-02-01', + '2016-03-01', + ] + }); + // Pick the second occurrence from the same BYMONTHDAY list. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=2;COUNT=3', { + dtStart: '2016-01-01', + byCount: true, + dates: [ + '2016-01-15', + '2016-02-15', + '2016-03-15', + ] + }); + // Pick the last occurrence from the same BYMONTHDAY list. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=-1;COUNT=3', { + dtStart: '2016-01-01', + byCount: true, + dates: [ + '2016-01-15', + '2016-02-15', + '2016-03-15', + ] + }); + // Pick multiple positions from the same sorted list, verifying order. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=1,2;COUNT=4', { + dtStart: '2016-01-01', + byCount: true, + dates: [ + '2016-01-01', + '2016-01-15', + '2016-02-01', + '2016-02-15', + ] + }); + // Combine negative BYMONTHDAY normalization with BYSETPOS selection. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=-1;BYSETPOS=1;COUNT=3', { + dtStart: '2016-01-01', + byCount: true, + dates: [ + '2016-01-31', + '2016-02-29', + '2016-03-31', + ] + }); + // Mixed positive and negative BYMONTHDAY values with BYSETPOS=-1. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=-1,31;BYSETPOS=-1;COUNT=4', { + dtStart: '2016-01-01', + byCount: true, + dates: [ + '2016-01-31', + '2016-02-29', + '2016-03-31', + '2016-04-30', + ] + }); + // Start mid-month and pick the second BYMONTHDAY occurrence each month. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=5,15,25;BYSETPOS=2;COUNT=4', { + dtStart: '2016-01-16', + byCount: true, + dates: [ + '2016-02-15', + '2016-03-15', + '2016-04-15', + '2016-05-15', + ] + }); + // Select the last valid candidate from a list that spans both 30- and 31-day months. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=31,30;BYSETPOS=-1;COUNT=4', { + dtStart: '2016-01-01', + byCount: true, + dates: [ + '2016-01-31', + '2016-03-31', + '2016-04-30', + '2016-05-31', + ] + }); + // 31st with BYSETPOS=1 across a long UNTIL range, ensuring months without + // a 31st are skipped and the recurrence continues correctly. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=31;BYSETPOS=1;UNTIL=20161231', { + dtStart: '2016-01-01', + until: true, + dates: [ + '2016-01-31', + '2016-03-31', + '2016-05-31', + '2016-07-31', + '2016-08-31', + '2016-10-31', + '2016-12-31', + ] + }); + // BYSETPOS is too large for the list of matching BYMONTHDAY values, + // so the rule produces no occurrences. + testRRULE('FREQ=MONTHLY;BYMONTHDAY=1,15;BYSETPOS=3', { + dtStart: '2016-01-01', + noInstance: true + }); }); suite('YEARLY', function() {