diff --git a/ical.js b/ical.js index e7dc784..0457ffd 100644 --- a/ical.js +++ b/ical.js @@ -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+)/); + 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 @@ -251,8 +261,6 @@ 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; } @@ -260,13 +268,20 @@ const dateParameter = function (name) { // 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+)/); + 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 @@ -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), @@ -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= @@ -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; + 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, '')}`; } diff --git a/test/test.js b/test/test.js index 434115a..301b0e0 100644 --- a/test/test.js +++ b/test/test.js @@ -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); } } }, @@ -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); diff --git a/test/test21-mod.ics b/test/test21-mod.ics new file mode 100644 index 0000000..c0a0a32 --- /dev/null +++ b/test/test21-mod.ics @@ -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 diff --git a/test/test22.ics b/test/test22.ics new file mode 100644 index 0000000..2c0af1f --- /dev/null +++ b/test/test22.ics @@ -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