Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Corrected timezone handling for recurring events #231

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions ical.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,19 @@ function getTimeZone(value) {
// And offset is still present
if (tz && tz.startsWith('(')) {
// Extract just the offset
const regex = /[+|-]\d*:\d*/;
const offsetcomps = tz.match(/([+|-]\d+):?(\d+)/);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please explain why exactly you changed that regex and what the added lines following this change try to fix/change.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I try to map indications like (GMT +02:00) to matching IANA etcetera zones, if available. The advantage of those IANA zone names is that they can be processed by moment-timezone.

if (offsetcomps && offsetcomps[0]) {
if (offsetcomps[3] && offsetcomps[3] !== '00') {
// Unpack sub-hour offsets, even if they cannot be mapped
found = String(offsetcomps[0]);
} else {
// Map full-hour offsets to IANA Etc zones
const intoffset = -1 * Number.parseInt(offsetcomps[1], 10);
found = 'Etc/GMT' + (intoffset < 0 ? '' : '+') + intoffset;
}
}

tz = null;
found = tz.match(regex);
}

// Timezone not confirmed yet
Expand Down Expand Up @@ -251,22 +261,27 @@ const dateParameter = function (name) {
const tz1 = getTimeZone(tz);
if (tz1) {
tz = tz1;
// We have a confirmed timezone, don't use offset, may confuse DST/STD time
offset = '';
// Fix the parameters for later use
parameters[0] = 'TZID=' + tz;
}
}

// Watch out for offset timezones
// If the conversion above didn't find any matching IANA tz
// And offset is still present
if (tz && tz.startsWith('(')) {
// Extract just the offset
const regex = /[+|-]\d*:\d*/;
offset = tz.match(regex);
const offsetcomps = tz.match(/([+|-]\d+):?(\d+)/);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here, why is this regex change required?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The answer is the same as above.

if (offsetcomps && offsetcomps[0]) {
if (offsetcomps[3] && offsetcomps[3] !== '00') {
offset = String(offsetcomps[0]);
found = offset;
} else {
const intoffset = -1 * Number.parseInt(offsetcomps[1], 10);
found = 'Etc/GMT' + (intoffset < 0 ? '' : '+') + intoffset;
}
}

tz = null;
found = offset;
}

// Timezone not confirmed yet
Expand All @@ -278,7 +293,7 @@ const dateParameter = function (name) {
}

// Timezone confirmed or forced to offset
newDate = found ? moment.tz(value, 'YYYYMMDDTHHmmss' + offset, tz).toDate() : new Date(
newDate = found ? moment.tz(value, 'YYYYMMDDTHHmmss' + offset, found).toDate() : new Date(
Number.parseInt(comps[1], 10),
Number.parseInt(comps[2], 10) - 1,
Number.parseInt(comps[3], 10),
Expand Down Expand Up @@ -576,7 +591,6 @@ module.exports = {
// Recurrence rules are only valid for VEVENT, VTODO, and VJOURNAL.
// More specifically, we need to filter the VCALENDAR type because we might end up with a defined rrule
// due to the subtypes.

if ((value === 'VEVENT' || value === 'VTODO' || value === 'VJOURNAL') && curr.rrule) {
let rule = curr.rrule.replace('RRULE:', '');
// Make sure the rrule starts with FREQ=
Expand Down Expand Up @@ -613,7 +627,9 @@ module.exports = {
// If the original date has a TZID, add it
if (curr.start.tz) {
const tz = getTimeZone(curr.start.tz);
rule += `;DTSTART;TZID=${tz}:${curr.start.toISOString().replace(/[-:]/g, '')}`;
const tzoffset = moment.tz(tz).utcOffset() * -60000;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why -60000 is required here? This smells like a modification for DST/non-DST which might not be valid in other time zones. Please explain.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In fact, the minus in -60000 compensates with the minus in Date(curr.start - tzoffset) in the next line in order to avoid a plus to be interpreted as implicit string concatenation. The factor 60000 is for conversion from minutes to milliseconds.

const localISOTime = (new Date(curr.start - tzoffset)).toISOString().slice(0, -1);
rule += `;DTSTART;TZID=${tz}:${localISOTime.replace(/[-:]/g, '')}`;
} else {
rule += `;DTSTART=${curr.start.toISOString().replace(/[-:]/g, '')}`;
}
Expand Down
84 changes: 82 additions & 2 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,10 @@ vows
});
},
'tzid offset correctly applied'(event) {
const start = new Date('2002-10-28T22:00:00.000Z');
assert.equal(event.start.valueOf(), start.valueOf());
assert.ok(moment.tz.zone(event.start.tz), 'zone does not exist');
const ref = '2002-10-28T22:00:00Z';
const start = moment(event.start).tz(event.start.tz);
assert.equal(start.utc().format(), ref);
}
}
},
Expand Down Expand Up @@ -1038,6 +1040,84 @@ vows
assert.equal(event.end.toDateString(), new Date(2024, 1, 22).toDateString());
}
}
},
'with test22.ics (testing dtstart of rrule with timezones)': {
topic() {
return ical.parseFile('./test/test22.ics');
},
'first event': {
topic(events) {
return _.select(_.values(events), x => {
return x.uid === '000021a';
})[0];
},
'datetype is date-time'(topic) {
assert.equal(topic.datetype, 'date-time');
},
'has GMT+1 timezone'(topic) {
assert.equal(topic.start.tz, 'Europe/Berlin');
},
'starts 14 Jul 2022 @ 12:00:00 (UTC)'(topic) {
assert.equal(topic.start.toISOString(), '2022-07-14T12:00:00.000Z');
}
},
'recurring yearly frist event (14 july)': {
topic(events) {
const ev = _.select(_.values(events), x => {
return x.uid === '000021a';
})[0];
return ev.rrule.between(new Date(2023, 0, 1), new Date(2024, 0, 1))[0];
},
'dt start well set'(topic) {
assert.equal(topic.toDateString(), new Date(2023, 6, 14).toDateString());
},
'starts 14 Jul 2023 @ 12:00:00 (UTC)'(topic) {
assert.equal(topic.toISOString(), '2023-07-14T12:00:00.000Z');
}
},
'second event': {
topic(events) {
return _.select(_.values(events), x => {
return x.uid === '000021b';
})[0];
},
'datetype is date-time'(event) {
assert.equal(event.datetype, 'date-time');
},
'start date': {
topic(event) {
return event.start;
},
'has correct timezone'(start) {
assert.equal(start.tz, '(GMT +02:00)');
},
'starts 15 Jul 2022 @ 12:00:00 (UTC)'(start) {
assert.equal(start.toISOString(), '2022-07-15T12:00:00.000Z');
}
},
'has recurrences': {
topic(event) {
return event.rrule;
},
'that are defined'(rrule) {
assert.ok(rrule, 'no rrule defined');
},
'that have timezone info'(rrule) {
assert.ok(rrule.options.tzid, 'no tzid property on rrule');
},
'that keep correct timezone info in recurrences'(rrule) {
assert.equal(rrule.options.tzid, 'Etc/GMT-2');
}
},
'has a first recurrence': {
topic(event) {
return event.rrule.between(new Date(2023, 0, 1), new Date(2024, 0, 1))[0];
},
'that starts 15 Jul 2023 @ 12:00:00 (UTC)'(rc) {
assert.equal(rc.toISOString(), '2023-07-15T12:00:00.000Z');
}
}
}
}
})
.export(module);
21 changes: 21 additions & 0 deletions test/test21-mod.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
TRANSP:OPAQUE
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
CREATED:20221004T073016Z
LAST-MODIFIED:20221011T063437Z
DTSTAMP:20221011T063437Z
DTSTART;TZID="(GMT +01:00)":20221004T140000
DTEND;TZID="(GMT +01:00)":20221004T150000
SUMMARY:Music School
CLASS:PUBLIC
UID:0000021
X-MOZ-SNOOZE-TIME:20221004T113000Z
X-MICROSOFT-CDO-OWNER-CRITICAL-CHANGE:20221014T203413Z
X-MICROSOFT-CDO-ATTENDEE-CRITICAL-CHANGE:20221014T203413Z
X-MICROSOFT-CDO-APPT-SEQUENCE:0
X-MICROSOFT-CDO-OWNERAPPTID:-1
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
RRULE:FREQ=WEEKLY;UNTIL=20221201T020000;BYDAY=TU
END:VEVENT
END:VCALENDAR
26 changes: 26 additions & 0 deletions test/test22.ics
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:Fictitious Recurrence Test Calendar
BEGIN:VEVENT
CREATED:20221018T221500Z
DTSTAMP:20221019T171200Z
UID:000021a
SUMMARY:Party
RRULE:FREQ=YEARLY
DTSTART;TZID=Europe/Berlin:20220714T140000
DTEND;TZID=Europe/Berlin:20220714T210000
TRANSP:OPAQUE
SEQUENCE:5
END:VEVENT
BEGIN:VEVENT
CREATED:20221019T181700Z
DTSTAMP:20221019T191200Z
UID:000021b
SUMMARY:Party next day
RRULE:FREQ=YEARLY
DTSTART;TZID="(GMT +02:00)":20220715T140000
DTEND;TZID="(GMT +02:00)":20220715T210000
TRANSP:OPAQUE
SEQUENCE:5
END:VEVENT
END:VCALENDAR
Loading