Skip to content

Commit ae06bf4

Browse files
authored
Make occurrences in chronological order even if BYMONTHDAY or BYYEARDAY is not in order (#666)
The recurrence iterator has a problem where it makes occurrences in the same order as given in BYMONTHDAY or BYYEARDAY, which doesn't make sense - it should always return them in chronological order. This patch fixes it by pre-ordering the days that can be used when the month or year changes. A day that is valid for one month/year may be different or not valid for the next month/year, so the days need to be recomputed each time.
1 parent 3724e72 commit ae06bf4

File tree

2 files changed

+284
-80
lines changed

2 files changed

+284
-80
lines changed

lib/ical/recur_iterator.js

+111-80
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ class RecurIterator {
233233
this.last.second = this.setup_defaults("BYSECOND", "SECONDLY", this.dtstart.second);
234234
this.last.minute = this.setup_defaults("BYMINUTE", "MINUTELY", this.dtstart.minute);
235235
this.last.hour = this.setup_defaults("BYHOUR", "HOURLY", this.dtstart.hour);
236-
let dayOffset = this.last.day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day);
236+
this.last.day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day);
237237
this.last.month = this.setup_defaults("BYMONTH", "MONTHLY", this.dtstart.month);
238238

239239
if (this.rule.freq == "WEEKLY") {
@@ -262,77 +262,79 @@ class RecurIterator {
262262
this._nextByYearDay();
263263
}
264264

265-
if (this.rule.freq == "MONTHLY" && this.has_by_data("BYDAY")) {
266-
let tempLast = null;
267-
let initLast = this.last.clone();
268-
let daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
269-
270-
// Check every weekday in BYDAY with relative dow and pos.
271-
for (let bydow of this.by_data.BYDAY) {
272-
this.last = initLast.clone();
273-
let [pos, dow] = this.ruleDayOfWeek(bydow);
274-
let dayOfMonth = this.last.nthWeekDay(dow, pos);
275-
276-
// If |pos| >= 6, the byday is invalid for a monthly rule.
277-
if (pos >= 6 || pos <= -6) {
278-
throw new Error("Malformed values in BYDAY part");
279-
}
265+
if (this.rule.freq == "MONTHLY") {
266+
if (this.has_by_data("BYDAY")) {
267+
let tempLast = null;
268+
let initLast = this.last.clone();
269+
let daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
270+
271+
// Check every weekday in BYDAY with relative dow and pos.
272+
for (let bydow of this.by_data.BYDAY) {
273+
this.last = initLast.clone();
274+
let [pos, dow] = this.ruleDayOfWeek(bydow);
275+
let dayOfMonth = this.last.nthWeekDay(dow, pos);
276+
277+
// If |pos| >= 6, the byday is invalid for a monthly rule.
278+
if (pos >= 6 || pos <= -6) {
279+
throw new Error("Malformed values in BYDAY part");
280+
}
280281

281-
// If a Byday with pos=+/-5 is not in the current month it
282-
// must be searched in the next months.
283-
if (dayOfMonth > daysInMonth || dayOfMonth <= 0) {
284-
// Skip if we have already found a "last" in this month.
285-
if (tempLast && tempLast.month == initLast.month) {
286-
continue;
282+
// If a Byday with pos=+/-5 is not in the current month it
283+
// must be searched in the next months.
284+
if (dayOfMonth > daysInMonth || dayOfMonth <= 0) {
285+
// Skip if we have already found a "last" in this month.
286+
if (tempLast && tempLast.month == initLast.month) {
287+
continue;
288+
}
289+
while (dayOfMonth > daysInMonth || dayOfMonth <= 0) {
290+
this.increment_month();
291+
daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
292+
dayOfMonth = this.last.nthWeekDay(dow, pos);
293+
}
287294
}
288-
while (dayOfMonth > daysInMonth || dayOfMonth <= 0) {
289-
this.increment_month();
290-
daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
291-
dayOfMonth = this.last.nthWeekDay(dow, pos);
295+
296+
this.last.day = dayOfMonth;
297+
if (!tempLast || this.last.compare(tempLast) < 0) {
298+
tempLast = this.last.clone();
292299
}
293300
}
294-
295-
this.last.day = dayOfMonth;
296-
if (!tempLast || this.last.compare(tempLast) < 0) {
297-
tempLast = this.last.clone();
301+
this.last = tempLast.clone();
302+
303+
//XXX: This feels like a hack, but we need to initialize
304+
// the BYMONTHDAY case correctly and byDayAndMonthDay handles
305+
// this case. It accepts a special flag which will avoid incrementing
306+
// the initial value without the flag days that match the start time
307+
// would be missed.
308+
if (this.has_by_data('BYMONTHDAY')) {
309+
this._byDayAndMonthDay(true);
298310
}
299-
}
300-
this.last = tempLast.clone();
301-
302-
//XXX: This feels like a hack, but we need to initialize
303-
// the BYMONTHDAY case correctly and byDayAndMonthDay handles
304-
// this case. It accepts a special flag which will avoid incrementing
305-
// the initial value without the flag days that match the start time
306-
// would be missed.
307-
if (this.has_by_data('BYMONTHDAY')) {
308-
this._byDayAndMonthDay(true);
309-
}
310-
311-
if (this.last.day > daysInMonth || this.last.day == 0) {
312-
throw new Error("Malformed values in BYDAY part");
313-
}
314-
} else if (this.has_by_data("BYMONTHDAY")) {
315-
// Attempting to access `this.last.day` will cause the date to be normalised.
316-
// So it will never be a negative value or more than the number of days in the month.
317-
// We keep the value in a separate variable instead.
318311

319-
// Change the day value so that normalisation won't change the month.
320-
this.last.day = 1;
321-
let daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
312+
if (this.last.day > daysInMonth || this.last.day == 0) {
313+
throw new Error("Malformed values in BYDAY part");
314+
}
315+
} else if (this.has_by_data("BYMONTHDAY")) {
316+
// Change the day value so that normalisation won't change the month.
317+
this.last.day = 1;
322318

323-
if (dayOffset < 0) {
324-
// A negative value represents days before the end of the month.
325-
this.last.day = daysInMonth + dayOffset + 1;
326-
} else if (this.by_data.BYMONTHDAY[0] > daysInMonth) {
327-
// There's no occurrence in this month, find the next valid month.
328-
// The longest possible sequence of skipped months is February-April-June,
329-
// so we might need to call next_month up to three times.
330-
if (!this.next_month() && !this.next_month() && !this.next_month()) {
331-
throw new Error("No possible occurrences");
319+
// Get a sorted list of days in the starting month that match the rule.
320+
let normalized = this.normalizeByMonthDayRules(
321+
this.last.year,
322+
this.last.month,
323+
this.rule.parts.BYMONTHDAY
324+
).filter(d => d >= this.last.day);
325+
326+
if (normalized.length) {
327+
// There's at least one valid day, use it.
328+
this.last.day = normalized[0];
329+
this.by_data.BYMONTHDAY = normalized;
330+
} else {
331+
// There's no occurrence in this month, find the next valid month.
332+
// The longest possible sequence of skipped months is February-April-June,
333+
// so we might need to call next_month up to three times.
334+
if (!this.next_month() && !this.next_month() && !this.next_month()) {
335+
throw new Error("No possible occurrences");
336+
}
332337
}
333-
} else {
334-
// Otherwise, reset the day.
335-
this.last.day = dayOffset;
336338
}
337339
}
338340
}
@@ -513,7 +515,10 @@ class RecurIterator {
513515
let rule;
514516

515517
for (; ruleIdx < len; ruleIdx++) {
516-
rule = rules[ruleIdx];
518+
rule = parseInt(rules[ruleIdx], 10);
519+
if (isNaN(rule)) {
520+
throw new Error('Invalid BYMONTHDAY value');
521+
}
517522

518523
// if this rule falls outside of given
519524
// month discard it.
@@ -747,6 +752,9 @@ class RecurIterator {
747752
if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) {
748753
this.by_indices.BYMONTHDAY = 0;
749754
this.increment_month();
755+
if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) {
756+
return 0;
757+
}
750758
}
751759

752760
let daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
@@ -762,7 +770,6 @@ class RecurIterator {
762770
} else {
763771
this.last.day = day;
764772
}
765-
766773
} else {
767774
this.increment_month();
768775
let daysInMonth = Time.daysInMonth(this.last.month, this.last.year);
@@ -835,7 +842,6 @@ class RecurIterator {
835842
}
836843

837844
next_year() {
838-
839845
if (this.next_hour() == 0) {
840846
return 0;
841847
}
@@ -844,6 +850,13 @@ class RecurIterator {
844850
this.days_index = 0;
845851
do {
846852
this.increment_year(this.rule.interval);
853+
if (this.has_by_data("BYMONTHDAY")) {
854+
this.by_data.BYMONTHDAY = this.normalizeByMonthDayRules(
855+
this.last.year,
856+
this.last.month,
857+
this.rule.parts.BYMONTHDAY
858+
);
859+
}
847860
this.expand_year_days(this.last.year);
848861
} while (this.days.length == 0);
849862
}
@@ -854,19 +867,19 @@ class RecurIterator {
854867
}
855868

856869
_nextByYearDay() {
857-
let doy = this.days[this.days_index];
858-
let year = this.last.year;
859-
if (doy < 1) {
860-
// Time.fromDayOfYear(doy, year) indexes relative to the
861-
// start of the given year. That is different from the
862-
// semantics of BYYEARDAY where negative indexes are an
863-
// offset from the end of the given year.
864-
doy += 1;
865-
year += 1;
866-
}
867-
let next = Time.fromDayOfYear(doy, year);
868-
this.last.day = next.day;
869-
this.last.month = next.month;
870+
let doy = this.days[this.days_index];
871+
let year = this.last.year;
872+
if (doy < 1) {
873+
// Time.fromDayOfYear(doy, year) indexes relative to the
874+
// start of the given year. That is different from the
875+
// semantics of BYYEARDAY where negative indexes are an
876+
// offset from the end of the given year.
877+
doy += 1;
878+
year += 1;
879+
}
880+
let next = Time.fromDayOfYear(doy, year);
881+
this.last.day = next.day;
882+
this.last.month = next.month;
870883
}
871884

872885
/**
@@ -953,9 +966,19 @@ class RecurIterator {
953966
this.increment_year(years);
954967
}
955968
}
969+
970+
if (this.has_by_data("BYMONTHDAY")) {
971+
this.by_data.BYMONTHDAY = this.normalizeByMonthDayRules(
972+
this.last.year,
973+
this.last.month,
974+
this.rule.parts.BYMONTHDAY
975+
);
976+
}
956977
}
957978

958979
increment_year(inc) {
980+
// Don't jump into the next month if this.last is Feb 29.
981+
this.last.day = 1;
959982
this.last.year += inc;
960983
}
961984

@@ -1177,6 +1200,14 @@ class RecurIterator {
11771200
} else {
11781201
this.days = [];
11791202
}
1203+
1204+
let daysInYear = Time.isLeapYear(aYear) ? 366 : 365;
1205+
this.days.sort((a, b) => {
1206+
if (a < 0) a += daysInYear + 1;
1207+
if (b < 0) b += daysInYear + 1;
1208+
return a - b;
1209+
});
1210+
11801211
return 0;
11811212
}
11821213

0 commit comments

Comments
 (0)