diff --git a/lib/ical/recur.js b/lib/ical/recur.js index fab211da..3cf7d810 100644 --- a/lib/ical/recur.js +++ b/lib/ical/recur.js @@ -361,13 +361,13 @@ if (min !== undefined && value < min) { throw new Error( - type + ': invalid value "' + value + '" must be > ' + min + type + ': invalid value "' + value + '" must be >= ' + min ); } if (max !== undefined && value > max) { throw new Error( - type + ': invalid value "' + value + '" must be < ' + min + type + ': invalid value "' + value + '" must be <= ' + max ); } diff --git a/lib/ical/recur_iterator.js b/lib/ical/recur_iterator.js index 4c76c4c3..d2478fdc 100644 --- a/lib/ical/recur_iterator.js +++ b/lib/ical/recur_iterator.js @@ -214,8 +214,25 @@ ICAL.RecurIterator = (function() { 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); - this.last.day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day); + var day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day); + // In case of BYMONTHDAY, the day must be set on the correct month otherwise + // the automatic normalization might cause a wrong "last" date. this.last.month = this.setup_defaults("BYMONTH", "MONTHLY", this.dtstart.month); + var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + var monthCounter = 12; + while (Math.abs(day) > daysInMonth && monthCounter--) { + this.increment_month(); + daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + } + if (monthCounter < 0) { + // A combination of numeric values in BYMONTHDAY and BYMONTH makes + // impossible to find a day that matches the rule. + throw new Error("Wrong combination of numeric values in BYMONTHDAY and BYMONTH"); + } + if (day < 0) { + day += daysInMonth + 1; + } + this.last.day = day; if (this.rule.freq == "WEEKLY") { if ("BYDAY" in parts) { @@ -248,7 +265,7 @@ ICAL.RecurIterator = (function() { if (this.rule.freq == "MONTHLY" && this.has_by_data("BYDAY")) { var tempLast = null; var initLast = this.last.clone(); - var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); // Check every weekday in BYDAY with relative dow and pos. for (var i in this.by_data.BYDAY) { @@ -295,6 +312,7 @@ ICAL.RecurIterator = (function() { // would be missed. if (this.has_by_data('BYMONTHDAY')) { this._byDayAndMonthDay(true); + daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); } if (this.last.day > daysInMonth || this.last.day == 0) { @@ -303,8 +321,8 @@ ICAL.RecurIterator = (function() { } else if (this.has_by_data("BYMONTHDAY")) { if (this.last.day < 0) { - var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); - this.last.day = daysInMonth + this.last.day + 1; + daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + this.last.day += daysInMonth + 1; } } @@ -726,14 +744,16 @@ ICAL.RecurIterator = (function() { var day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY]; if (day < 0) { - day = daysInMonth + day + 1; + day += daysInMonth + 1; } if (day > daysInMonth) { this.last.day = 1; data_valid = this.is_day_in_byday(this.last); - } else { + } else if (day > 0) { this.last.day = day; + } else { + data_valid = 0; } } else { diff --git a/test/recur_iterator_test.js b/test/recur_iterator_test.js index 09063a37..dfb680d2 100644 --- a/test/recur_iterator_test.js +++ b/test/recur_iterator_test.js @@ -448,7 +448,7 @@ suite('recur_iterator', function() { ] }); - //buisness days for 31 occurances' + //business days for 31 occurrences' testRRULE('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', { dates: [ '2012-01-02T09:00:00', '2012-01-03T09:00:00', '2012-01-04T09:00:00', '2012-01-05T09:00:00', '2012-01-06T09:00:00', @@ -513,7 +513,6 @@ suite('recur_iterator', function() { ] }); - // monthly, the third instance of tu,we,th testRRULE('FREQ=MONTHLY;COUNT=3;BYDAY=TU,WE,TH;BYSETPOS=3', { byCount: true, @@ -526,7 +525,7 @@ suite('recur_iterator', function() { //monthly, each month last day that is monday testRRULE('FREQ=MONTHLY;BYMONTHDAY=-1;BYDAY=MO', { - dtStart: '2012-01-01T09:00:00', + dtStart: '2012-02-01T09:00:00', dates: [ '2012-04-30T09:00:00', '2012-12-31T09:00:00' @@ -666,7 +665,7 @@ suite('recur_iterator', function() { ] }); - // monthly, bymonthday + // monthly, bymonthday, the last day of the month testRRULE('FREQ=MONTHLY;BYMONTHDAY=-1', { dtStart: '2015-01-01T08:00:00', dates: [ @@ -676,6 +675,85 @@ suite('recur_iterator', function() { ] }); + // monthly, bymonthday the last day of the month with interval + testRRULE('FREQ=MONTHLY;BYMONTHDAY=-1;INTERVAL=3', { + dtStart: '2016-04-20T08:00:00Z', + dates: [ + '2016-04-30T08:00:00Z', + '2016-07-31T08:00:00Z', + '2016-10-31T08:00:00Z', + '2017-01-31T08:00:00Z' + ] + }); + + // monthly, bymonthday the second to last day of the month with interval + testRRULE('FREQ=MONTHLY;BYMONTHDAY=-2;INTERVAL=2', { + dtStart: '2016-04-20T08:00:00Z', + dates: [ + '2016-04-29T08:00:00Z', + '2016-06-29T08:00:00Z', + '2016-08-30T08:00:00Z', + '2016-10-30T08:00:00Z', + '2016-12-30T08:00:00Z', + '2017-02-27T08:00:00Z' + ] + }); + + // monthly, bymonthday the 31st to last day of the month + testRRULE('FREQ=MONTHLY;BYMONTHDAY=-31', { + dtStart: '2016-02-08T08:00:00Z', + dates: [ + '2016-03-01T08:00:00Z', + '2016-05-01T08:00:00Z', + '2016-07-01T08:00:00Z', + '2016-08-01T08:00:00Z', + '2016-10-01T08:00:00Z' + ] + }); + + // monthly, bymonthday the 31st to last day of the month with interval + testRRULE('FREQ=MONTHLY;BYMONTHDAY=-31;INTERVAL=4', { + dtStart: '2016-02-08T08:00:00Z', + dates: [ + '2016-10-01T08:00:00Z', + '2017-10-01T08:00:00Z', + '2018-10-01T08:00:00Z', + '2019-10-01T08:00:00Z' + ] + }); + + // monthly, bymonthday=31 starting from a month with less than 31 days + testRRULE('FREQ=MONTHLY;BYMONTHDAY=31', { + dtStart: '2016-02-08T08:00:00Z', + dates: [ + '2016-03-31T08:00:00Z', + '2016-05-31T08:00:00Z', + '2016-07-31T08:00:00Z', + '2016-08-31T08:00:00Z' + ] + }); + + // monthly, bymonthday=31 starting from a month with less than 31 days with interval + testRRULE('FREQ=MONTHLY;BYMONTHDAY=31;INTERVAL=2', { + dtStart: '2016-02-08T08:00:00Z', + dates: [ + '2016-08-31T08:00:00Z', + '2016-10-31T08:00:00Z', + '2016-12-31T08:00:00Z', + '2017-08-31T08:00:00Z' + ] + }); + + // monthly, bymonthday=-31 byday=MO with interval: first occurrence far in the future from start date + testRRULE('FREQ=MONTHLY;INTERVAL=9;BYMONTHDAY=-31;BYDAY=MO', { + dtStart: '2016-03-08T08:00:00Z', + dates: [ + '2025-12-01T08:00:00Z', + '2031-12-01T08:00:00Z', + '2049-03-01T08:00:00Z' + ] + }); + // monthly + by month testRRULE('FREQ=MONTHLY;BYMONTH=1,3,6,9,12', { dates: [ @@ -694,9 +772,9 @@ suite('recur_iterator', function() { '2015-03-09T08:00:00Z', '2015-03-13T08:00:00Z', '2015-03-23T08:00:00Z', - '2015-03-27T08:00:00Z', + '2015-03-27T08:00:00Z' ] - }) + }); testRRULE('FREQ=MONTHLY;BYDAY=MO,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4', { dtStart: '2015-04-01T08:00:00Z', byCount: true, @@ -706,7 +784,7 @@ suite('recur_iterator', function() { '2015-04-17T08:00:00Z', '2015-04-27T08:00:00Z' ] - }) + }); testRRULE('FREQ=MONTHLY;BYDAY=MO,SA;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=4', { dtStart: '2015-04-01T08:00:00Z', byCount: true, @@ -716,22 +794,22 @@ suite('recur_iterator', function() { '2015-04-25T08:00:00Z', '2015-04-27T08:00:00Z' ] - }) + }); testRRULE('FREQ=MONTHLY;BYDAY=SU,FR;BYMONTHDAY=1,3,5,7,9,11,13,15,17,19,21,23,25,27,29,31;COUNT=9', { dtStart: '2015-02-28T08:00:00Z', byCount: true, dates: [ - "2015-03-01T08:00:00Z", - "2015-03-13T08:00:00Z", - "2015-03-15T08:00:00Z", - "2015-03-27T08:00:00Z", - "2015-03-29T08:00:00Z", - "2015-04-03T08:00:00Z", - "2015-04-05T08:00:00Z", - "2015-04-17T08:00:00Z", - "2015-04-19T08:00:00Z" - ] - }) + '2015-03-01T08:00:00Z', + '2015-03-13T08:00:00Z', + '2015-03-15T08:00:00Z', + '2015-03-27T08:00:00Z', + '2015-03-29T08:00:00Z', + '2015-04-03T08:00:00Z', + '2015-04-05T08:00:00Z', + '2015-04-17T08:00:00Z', + '2015-04-19T08:00:00Z' + ] + }); }); suite('YEARLY', function() { @@ -842,26 +920,62 @@ suite('recur_iterator', function() { //Weekly Sunday, Monday, Tuesday with count=3 testRRULE('FREQ=WEEKLY;COUNT=3;BYDAY=MO,SU,TU', { - dtStart: '2017-07-30', - byCount: true, - dates: [ - '2017-07-30', - '2017-07-31', - '2017-08-01' - ] + dtStart: '2017-07-30', + byCount: true, + dates: [ + '2017-07-30', + '2017-07-31', + '2017-08-01' + ] }); //Weekly Sunday, Wednesday with count=5 testRRULE('FREQ=WEEKLY;COUNT=5;BYDAY=SU,WE', { - dtStart: '2017-04-23', - byCount: true, - dates: [ - '2017-04-23', - '2017-04-26', - '2017-04-30', - '2017-05-03', - '2017-05-07' - ] + dtStart: '2017-04-23', + byCount: true, + dates: [ + '2017-04-23', + '2017-04-26', + '2017-04-30', + '2017-05-03', + '2017-05-07' + ] + }); + + //yearly, byDay,byMonth. The 4th Thursday of November (Thanksgiving Day) + testRRULE('FREQ=YEARLY;BYDAY=4TH;BYMONTH=11', { + dates: [ + '2016-11-24', + '2017-11-23', + '2018-11-22', + '2019-11-28', + '2020-11-26', + '2021-11-25' + ] + }); + + //yearly, byDay,byMonth,bySetPos. The 4th Thursday of November (Thanksgiving Day) + testRRULE('FREQ=YEARLY;BYDAY=TH;BYSETPOS=4;BYMONTH=11', { + dates: [ + '2016-11-24', + '2017-11-23', + '2018-11-22', + '2019-11-28', + '2020-11-26', + '2021-11-25' + ] + }); + + //yearly, byDay,byMonthday,byMonth with interval. The first Tuesday after November 1 (USA presidential elections) + testRRULE('FREQ=YEARLY;BYDAY=TU;BYMONTHDAY=2,3,4,5,6,7,8;BYMONTH=11;INTERVAL=4', { + dates: [ + '2004-11-02', + '2008-11-04', + '2012-11-06', + '2016-11-08', + '2020-11-03', + '2024-11-05' + ] }); //yearly, byMonth, byweekNo @@ -995,7 +1109,7 @@ suite('recur_iterator', function() { dates: [ '2012-02-28', '2012-02-29', - '2012-03-01', + '2012-03-01' ] }); @@ -1005,7 +1119,7 @@ suite('recur_iterator', function() { dates: [ '2013-02-28', '2013-03-01', - '2013-03-02', + '2013-03-02' ] }); diff --git a/test/recur_test.js b/test/recur_test.js index 9a426be7..5a2af185 100644 --- a/test/recur_test.js +++ b/test/recur_test.js @@ -55,7 +55,7 @@ suite('recur', function() { BYWEEKNO: [3], BYMONTHDAY: [2] } - }, 'BYWEEKNO does not fit to BYMONTHDAY'); + }, 'BYWEEKNO does not fit to BYMONTHDAY'); checkThrow({ freq: 'MONTHLY', @@ -85,6 +85,14 @@ suite('recur', function() { } }, 'Malformed values in BYDAY part', '1970-02-01T00:00:00Z'); + checkThrow({ + freq: 'MONTHLY', + parts: { + BYMONTHDAY: [30, 31], + BYMONTH: [2] + } + }, 'Wrong combination of numeric values in BYMONTHDAY and BYMONTH', '1970-02-01T00:00:00Z'); + checkDate({ freq: 'SECONDLY', parts: { @@ -296,6 +304,9 @@ suite('recur', function() { verifyFail('BYYEARDAY=367', /BYYEARDAY/); verifyFail('BYYEARDAY=-367', /BYYEARDAY/); + verifyFail('BYMONTHDAY=32', /BYMONTHDAY/); + verifyFail('BYMONTHDAY=-32', /BYMONTHDAY/); + verify('FREQ=MONTHLY;BYMONTHDAY=+3', { freq: 'MONTHLY', parts: { BYMONTHDAY: [3] } @@ -442,6 +453,26 @@ suite('recur', function() { }, /invalid BYDAY value/); }); + test('invalid numeric bymonthday > 31', function() { + assert.throws(function() { + var rec = ICAL.Recur.fromString("FREQ=MONTHLY;BYMONTHDAY=32"); + }, /BYMONTHDAY: invalid value "32" must be <= 31/); + }); + + test('invalid numeric bymonthday < -31', function() { + assert.throws(function() { + var rec = ICAL.Recur.fromString("FREQ=MONTHLY;BYMONTHDAY=-33"); + }, /BYMONTHDAY: invalid value "-33" must be >= -31/); + }); + + test('invalid combination BYMONTH with BYMONTHDAY', function() { + var rec = ICAL.Recur.fromString("FREQ=MONTHLY;BYMONTHDAY=31;BYMONTH=4,6"); + var dtstart = ICAL.Time.fromString("2017-02-11T08:00:00"); + assert.throws(function() { + var iter = rec.iterator(dtstart); + }, /Wrong combination of numeric values in BYMONTHDAY and BYMONTH/); + }); + test('extra structured recur values', function() { var rec = ICAL.Recur.fromString("RSCALE=ISLAMIC-CIVIL;FREQ=YEARLY;BYMONTH=9"); assert.equal(rec.rscale, "ISLAMIC-CIVIL");