diff --git a/lib/calendar/exdate.js b/lib/calendar/exdate.js new file mode 100644 index 00000000..60fa78d9 --- /dev/null +++ b/lib/calendar/exdate.js @@ -0,0 +1,9 @@ +import { toUTCDate } from '../date/timezone'; + +export const createExdateMap = (exdate = []) => { + return exdate.reduce((acc, dateProperty) => { + const localExclude = toUTCDate(dateProperty.value); + acc[+localExclude] = true; + return acc; + }, {}); +}; diff --git a/lib/calendar/recurring.js b/lib/calendar/recurring.js index 41d5bd5a..6bb1903b 100644 --- a/lib/calendar/recurring.js +++ b/lib/calendar/recurring.js @@ -4,6 +4,7 @@ import { getInternalDateTimeValue, internalValueToIcalValue } from './vcal'; import { getPropertyTzid, isIcalAllDay, propertyToUTCDate } from './vcalConverter'; import { addDays, addMilliseconds, differenceInCalendarDays, max, MILLISECONDS_IN_MINUTE } from '../date-fns-utc'; import { convertUTCDateTimeToZone, fromUTCDate, toUTCDate } from '../date/timezone'; +import { createExdateMap } from './exdate'; const YEAR_IN_MS = Date.UTC(1971, 0, 1); @@ -13,30 +14,43 @@ export const isIcalRecurring = ({ rrule }) => { const isInInterval = (a1, a2, b1, b2) => a1 <= b2 && a2 >= b1; -const fillOccurrencesBetween = (start, end, iterator, eventDuration, internalDtstart, isAllDay) => { +const fillOccurrencesBetween = ({ + interval: [start, end], + iterator, + eventDuration, + originalDtstart, + isAllDay, + exdateMap +}) => { const result = []; let next; // eslint-disable-next-line no-cond-assign while ((next = iterator.next())) { const localStart = toUTCDate(getInternalDateTimeValue(next)); + if (exdateMap[+localStart]) { + continue; + } + const localEnd = isAllDay ? addDays(localStart, eventDuration) : addMilliseconds(localStart, eventDuration); - const utcStart = propertyToUTCDate({ - value: { - ...internalDtstart.value, - ...fromUTCDate(localStart) - }, - parameters: internalDtstart.parameters - }); + const utcStart = isAllDay + ? localStart + : propertyToUTCDate({ + value: { + ...originalDtstart.value, + ...fromUTCDate(localStart) + }, + parameters: originalDtstart.parameters + }); const utcEnd = isAllDay - ? addDays(utcStart, eventDuration) + ? localEnd : propertyToUTCDate({ value: { - ...internalDtstart.value, - ...fromUTCDate(addMilliseconds(localStart, eventDuration)) + ...originalDtstart.value, + ...fromUTCDate(localEnd) }, - parameters: internalDtstart.parameters + parameters: originalDtstart.parameters }); if (utcStart > end) { @@ -44,7 +58,13 @@ const fillOccurrencesBetween = (start, end, iterator, eventDuration, internalDts } if (isInInterval(utcStart, utcEnd, start, end)) { - result.push([utcStart, utcEnd]); + result.push({ + localStart, + localEnd, + utcStart, + utcEnd, + occurrenceNumber: iterator.occurrence_number + }); } } return result; @@ -71,39 +91,71 @@ const getModifiedUntilRrule = (internalRrule, startTzid) => { }; }; -export const getOccurrencesBetween = (component, start, end, cache = {}) => { - const { dtstart: internalDtstart, dtend: internalDtEnd, rrule: internalRrule } = component; +const getOccurrenceSetup = (component) => { + const { dtstart: internalDtstart, dtend: internalDtEnd, rrule: internalRrule, exdate: internalExdate } = component; + + const isAllDay = isIcalAllDay(component); + const dtstartType = isAllDay ? 'date' : 'date-time'; + + // Pretend the (local) date is in UTC time to keep the absolute times. + const dtstart = internalValueToIcalValue(dtstartType, { ...internalDtstart.value, isUTC: true }); + // Since the local date is pretended in UTC time, the until has to be converted into a fake local UTC time too + const modifiedRrule = getModifiedUntilRrule(internalRrule, getPropertyTzid(internalDtstart)); + + const utcStart = propertyToUTCDate(internalDtstart); + const rawEnd = propertyToUTCDate(internalDtEnd); + const modifiedEnd = isAllDay + ? addDays(rawEnd, -1) // All day event range is non-inclusive + : rawEnd; + const utcEnd = max(utcStart, modifiedEnd); + + const eventDuration = isAllDay + ? differenceInCalendarDays(utcEnd, utcStart) + : differenceInMinutes(utcEnd, utcStart) * MILLISECONDS_IN_MINUTE; + + return { + dtstart, + utcStart, + isAllDay, + eventDuration, + modifiedRrule, + exdateMap: createExdateMap(internalExdate) + }; +}; + +export const getIsMoreThanOccurrence = (component, n = 1, cache = {}) => { + if (!cache.start) { + cache.start = getOccurrenceSetup(component); + } + const { dtstart, modifiedRrule, exdateMap } = cache.start; + const rrule = internalValueToIcalValue('recur', modifiedRrule.value); + const iterator = rrule.iterator(dtstart); + + let next; + let count = 0; + // eslint-disable-next-line no-cond-assign + while ((next = iterator.next())) { + const localStart = toUTCDate(getInternalDateTimeValue(next)); + if (exdateMap[+localStart]) { + continue; + } + count++; + if (count > n) { + break; + } + } + return count > n; +}; + +export const getOccurrencesBetween = (component, start, end, cache = {}) => { if (!cache.start) { - const isAllDay = isIcalAllDay(component); - const dtstartType = isAllDay ? 'date' : 'date-time'; - - // Pretend the (local) date is in UTC time to keep the absolute times. - const dtstart = internalValueToIcalValue(dtstartType, { ...internalDtstart.value, isUTC: true }); - // Since the local date is pretended in UTC time, the until has to be converted into a fake local UTC time too - const modifiedRrule = getModifiedUntilRrule(internalRrule, getPropertyTzid(internalDtstart)); - - const utcStart = propertyToUTCDate(internalDtstart); - const rawEnd = propertyToUTCDate(internalDtEnd); - const modifiedEnd = isAllDay - ? addDays(rawEnd, -1) // All day event range is non-inclusive - : rawEnd; - const utcEnd = max(utcStart, modifiedEnd); - - const eventDuration = isAllDay - ? differenceInCalendarDays(utcEnd, utcStart) - : differenceInMinutes(utcEnd, utcStart) * MILLISECONDS_IN_MINUTE; - - cache.start = { - dtstart, - utcStart, - isAllDay, - eventDuration, - modifiedRrule - }; + cache.start = getOccurrenceSetup(component); } - const { eventDuration, isAllDay, utcStart, dtstart, modifiedRrule } = cache.start; + const { dtstart: originalDtstart } = component; + + const { eventDuration, isAllDay, utcStart, dtstart, modifiedRrule, exdateMap } = cache.start; // If it starts after the current end, ignore it if (utcStart > end) { @@ -117,14 +169,14 @@ export const getOccurrencesBetween = (component, start, end, cache = {}) => { const interval = [start - YEAR_IN_MS, end + YEAR_IN_MS]; try { - const result = fillOccurrencesBetween( - interval[0], - interval[1], + const result = fillOccurrencesBetween({ + interval, iterator, eventDuration, - internalDtstart, - isAllDay - ); + originalDtstart, + isAllDay, + exdateMap + }); cache.iteration = { iterator, @@ -138,5 +190,5 @@ export const getOccurrencesBetween = (component, start, end, cache = {}) => { } } - return cache.iteration.result.filter(([eventStart, eventEnd]) => isInInterval(+eventStart, +eventEnd, start, end)); + return cache.iteration.result.filter(({ utcStart, utcEnd }) => isInInterval(+utcStart, +utcEnd, start, end)); }; diff --git a/lib/calendar/vcal.js b/lib/calendar/vcal.js index 7040e199..fbf6d515 100644 --- a/lib/calendar/vcal.js +++ b/lib/calendar/vcal.js @@ -151,7 +151,7 @@ const getProperty = (name, { value, parameters }) => { const type = specificType || property.type; - if (property.isMultiValue) { + if (property.isMultiValue && Array.isArray(value)) { property.setValues(value.map((val) => internalValueToIcalValue(type, val, restParameters))); } else { property.setValue(internalValueToIcalValue(type, value, restParameters)); @@ -260,7 +260,14 @@ const fromIcalProperties = (properties = []) => { acc[name] = []; } - acc[name].push(propertyAsObject); + // Exdate can be both an array and multivalue, force it to only be an array + if (name === 'exdate') { + const normalizedValues = values.map((value) => ({ ...propertyAsObject, value })); + + acc[name] = acc[name].concat(normalizedValues); + } else { + acc[name].push(propertyAsObject); + } return acc; }, {}); diff --git a/lib/calendar/veventHelper.js b/lib/calendar/veventHelper.js index 0e7c624a..77e4e572 100644 --- a/lib/calendar/veventHelper.js +++ b/lib/calendar/veventHelper.js @@ -5,7 +5,7 @@ import { fromInternalAttendee } from './attendees'; const { ENCRYPTED_AND_SIGNED, SIGNED, CLEAR } = CALENDAR_CARD_TYPE; -export const SHARED_SIGNED_FIELDS = ['uid', 'dtstamp', 'dtstart', 'dtend', 'rrule', 'transp', 'vtimezone']; +export const SHARED_SIGNED_FIELDS = ['uid', 'dtstamp', 'dtstart', 'dtend', 'rrule', 'exdate', 'transp', 'vtimezone']; export const SHARED_ENCRYPTED_FIELDS = ['uid', 'dtstamp', 'created', 'description', 'summary', 'location']; export const CALENDAR_SIGNED_FIELDS = ['uid', 'dtstamp']; diff --git a/test/calendar/recurring.spec.js b/test/calendar/recurring.spec.js index 930d123b..55fc76c8 100644 --- a/test/calendar/recurring.spec.js +++ b/test/calendar/recurring.spec.js @@ -1,6 +1,24 @@ import { parse } from '../../lib/calendar/vcal'; import { getOccurrencesBetween } from '../../lib/calendar/recurring'; +const stringifyResult = (result) => { + return result.map(({ utcStart, utcEnd, occurrenceNumber }) => { + return `${utcStart.toISOString()} - ${utcEnd.toISOString()} | ${occurrenceNumber}`; + }); +}; + +const stringifyResultFull = (result) => { + return result.map(({ localStart, localEnd, utcStart, utcEnd, occurrenceNumber }) => { + return `${localStart.toISOString()} - ${localEnd.toISOString()} | ${utcStart.toISOString()} - ${utcEnd.toISOString()} | ${occurrenceNumber}`; + }); +}; + +const stringifyResultSimple = (result) => { + return result.map(({ utcStart, utcEnd }) => { + return `${utcStart.toISOString()} - ${utcEnd.toISOString()}`; + }); +}; + describe('recurring', () => { const component = { dtstart: { @@ -29,26 +47,33 @@ describe('recurring', () => { expect(result).toEqual([]); }); + it('should get initial occurrences between a range', () => { + const result = getOccurrencesBetween(component, Date.UTC(2018, 1, 1), Date.UTC(2019, 1, 3)); + + expect(stringifyResult(result)).toEqual([ + '2019-01-30T01:30:00.000Z - 2019-01-30T02:30:00.000Z | 1', + '2019-01-31T01:30:00.000Z - 2019-01-31T02:30:00.000Z | 2', + '2019-02-01T01:30:00.000Z - 2019-02-01T02:30:00.000Z | 3', + '2019-02-02T01:30:00.000Z - 2019-02-02T02:30:00.000Z | 4' + ]); + }); + it('should get occurrences between a range', () => { const result = getOccurrencesBetween(component, Date.UTC(2019, 2, 1), Date.UTC(2019, 2, 3)); - expect( - result.map(([start, end]) => `${new Date(start).toISOString()} - ${new Date(end).toISOString()}`) - ).toEqual([ - '2019-03-01T01:30:00.000Z - 2019-03-01T02:30:00.000Z', - '2019-03-02T01:30:00.000Z - 2019-03-02T02:30:00.000Z' + expect(stringifyResult(result)).toEqual([ + '2019-03-01T01:30:00.000Z - 2019-03-01T02:30:00.000Z | 31', + '2019-03-02T01:30:00.000Z - 2019-03-02T02:30:00.000Z | 32' ]); }); it('should get occurrences between a dst range', () => { const result = getOccurrencesBetween(component, Date.UTC(2019, 9, 26), Date.UTC(2019, 9, 29)); - expect( - result.map(([start, end]) => `${new Date(start).toISOString()} - ${new Date(end).toISOString()}`) - ).toEqual([ - '2019-10-26T00:30:00.000Z - 2019-10-26T01:30:00.000Z', - '2019-10-27T01:30:00.000Z - 2019-10-27T02:30:00.000Z', - '2019-10-28T01:30:00.000Z - 2019-10-28T02:30:00.000Z' + expect(stringifyResultFull(result)).toEqual([ + '2019-10-26T02:30:00.000Z - 2019-10-26T03:30:00.000Z | 2019-10-26T00:30:00.000Z - 2019-10-26T01:30:00.000Z | 270', + '2019-10-27T02:30:00.000Z - 2019-10-27T03:30:00.000Z | 2019-10-27T01:30:00.000Z - 2019-10-27T02:30:00.000Z | 271', + '2019-10-28T02:30:00.000Z - 2019-10-28T03:30:00.000Z | 2019-10-28T01:30:00.000Z - 2019-10-28T02:30:00.000Z | 272' ]); }); @@ -63,11 +88,9 @@ describe('recurring', () => { const cache = {}; const result1 = getOccurrencesBetween(component, Date.UTC(2019, 2, 1), Date.UTC(2019, 2, 3), cache); const result2 = getOccurrencesBetween(component, Date.UTC(2031, 2, 1), Date.UTC(2031, 2, 3), cache); - expect( - result2.map(([start, end]) => `${new Date(start).toISOString()} - ${new Date(end).toISOString()}`) - ).toEqual([ - '2031-03-01T01:30:00.000Z - 2031-03-01T02:30:00.000Z', - '2031-03-02T01:30:00.000Z - 2031-03-02T02:30:00.000Z' + expect(stringifyResult(result2)).toEqual([ + '2031-03-01T01:30:00.000Z - 2031-03-01T02:30:00.000Z | 4414', + '2031-03-02T01:30:00.000Z - 2031-03-02T02:30:00.000Z | 4415' ]); }); @@ -80,9 +103,7 @@ RRULE:FREQ=WEEKLY;BYDAY=SU,MO,TU,WE,TH,FR,SA;COUNT=3 END:VEVENT`); const cache = {}; const result = getOccurrencesBetween(component, Date.UTC(2020, 0, 1), Date.UTC(2020, 2, 1), cache); - expect( - result.map(([start, end]) => `${new Date(start).toISOString()} - ${new Date(end).toISOString()}`) - ).toEqual([ + expect(stringifyResultSimple(result)).toEqual([ '2020-01-29T00:00:00.000Z - 2020-01-29T00:00:00.000Z', '2020-01-30T00:00:00.000Z - 2020-01-30T00:00:00.000Z', '2020-01-31T00:00:00.000Z - 2020-01-31T00:00:00.000Z' @@ -98,9 +119,7 @@ RRULE:FREQ=WEEKLY;BYDAY=SU,MO,TU,WE,TH,FR,SA;UNTIL=20200131 END:VEVENT`); const cache = {}; const result = getOccurrencesBetween(component, Date.UTC(2020, 0, 1), Date.UTC(2020, 2, 1), cache); - expect( - result.map(([start, end]) => `${new Date(start).toISOString()} - ${new Date(end).toISOString()}`) - ).toEqual([ + expect(stringifyResultSimple(result)).toEqual([ '2020-01-29T00:00:00.000Z - 2020-01-29T00:00:00.000Z', '2020-01-30T00:00:00.000Z - 2020-01-30T00:00:00.000Z', '2020-01-31T00:00:00.000Z - 2020-01-31T00:00:00.000Z' @@ -116,9 +135,7 @@ RRULE:FREQ=WEEKLY;BYDAY=SU,MO,TU,WE,TH,FR,SA;UNTIL=20200131T235959Z END:VEVENT`); const cache = {}; const result = getOccurrencesBetween(component, Date.UTC(2020, 0, 1), Date.UTC(2020, 2, 1), cache); - expect( - result.map(([start, end]) => `${new Date(start).toISOString()} - ${new Date(end).toISOString()}`) - ).toEqual([ + expect(stringifyResultSimple(result)).toEqual([ '2020-01-29T13:00:00.000Z - 2020-01-29T13:30:00.000Z', '2020-01-30T13:00:00.000Z - 2020-01-30T13:30:00.000Z', '2020-01-31T13:00:00.000Z - 2020-01-31T13:30:00.000Z' @@ -134,9 +151,7 @@ RRULE:FREQ=WEEKLY;BYDAY=SU,MO,TU,WE,TH,FR,SA;UNTIL=20200201T105959Z END:VEVENT`); const cache = {}; const result = getOccurrencesBetween(component, Date.UTC(2020, 0, 1), Date.UTC(2020, 2, 1), cache); - expect( - result.map(([start, end]) => `${new Date(start).toISOString()} - ${new Date(end).toISOString()}`) - ).toEqual([ + expect(stringifyResultSimple(result)).toEqual([ '2020-01-29T11:00:00.000Z - 2020-01-29T12:30:00.000Z', '2020-01-30T11:00:00.000Z - 2020-01-30T12:30:00.000Z', '2020-01-31T11:00:00.000Z - 2020-01-31T12:30:00.000Z' @@ -153,13 +168,71 @@ END:VEVENT `); const cache = {}; const result = getOccurrencesBetween(component, Date.UTC(2020, 0, 1), Date.UTC(2021, 2, 1), cache); - expect( - result.map(([start, end]) => `${new Date(start).toISOString()} - ${new Date(end).toISOString()}`) - ).toEqual([ + expect(stringifyResultSimple(result)).toEqual([ '2020-01-26T00:00:00.000Z - 2020-01-26T00:00:00.000Z', '2020-02-01T00:00:00.000Z - 2020-02-01T00:00:00.000Z', '2020-02-02T00:00:00.000Z - 2020-02-02T00:00:00.000Z', '2020-02-08T00:00:00.000Z - 2020-02-08T00:00:00.000Z' ]); }); + + it('should fill occurrences for an event with an exdate', () => { + const component = parse(` +BEGIN:VEVENT +RRULE:FREQ=DAILY;COUNT=6 +DTSTART;TZID=Europe/Zurich:20200309T043000 +DTEND;TZID=Europe/Zurich:20200309T063000 +EXDATE;TZID=Europe/Zurich:20200311T043000 +EXDATE;TZID=Europe/Zurich:20200313T043000 +END:VEVENT +`); + const cache = {}; + const result = getOccurrencesBetween(component, Date.UTC(2020, 0, 1), Date.UTC(2021, 4, 1), cache); + expect(stringifyResult(result)).toEqual([ + '2020-03-09T03:30:00.000Z - 2020-03-09T05:30:00.000Z | 1', + '2020-03-10T03:30:00.000Z - 2020-03-10T05:30:00.000Z | 2', + '2020-03-12T03:30:00.000Z - 2020-03-12T05:30:00.000Z | 4', + '2020-03-14T03:30:00.000Z - 2020-03-14T05:30:00.000Z | 6' + ]); + }); + + it('should fill occurrences for an all day event with an exdate', () => { + const component = parse(` +BEGIN:VEVENT +RRULE:FREQ=DAILY;COUNT=6 +DTSTART;VALUE=DATE:20200201 +DTEND;VALUE=DATE:20200202 +EXDATE;VALUE=DATE:20200201 +EXDATE;VALUE=DATE:20200202 +EXDATE;VALUE=DATE:20200203 +END:VEVENT +`); + const cache = {}; + const result = getOccurrencesBetween(component, Date.UTC(2020, 0, 1), Date.UTC(2021, 4, 1), cache); + expect(stringifyResultSimple(result)).toEqual([ + '2020-02-04T00:00:00.000Z - 2020-02-04T00:00:00.000Z', + '2020-02-05T00:00:00.000Z - 2020-02-05T00:00:00.000Z', + '2020-02-06T00:00:00.000Z - 2020-02-06T00:00:00.000Z' + ]); + }); + + it('should fill occurrences for a UTC date with an exdate', () => { + const component = parse(` +BEGIN:VEVENT +RRULE:FREQ=DAILY;COUNT=6 +DTSTART:20200201T030000Z +DTEND:20200201T040000Z +EXDATE:20200202T030000Z +EXDATE:20200203T030000Z +EXDATE:20200204T030000Z +END:VEVENT +`); + const cache = {}; + const result = getOccurrencesBetween(component, Date.UTC(2020, 0, 1), Date.UTC(2021, 4, 1), cache); + expect(stringifyResultSimple(result)).toEqual([ + '2020-02-01T03:00:00.000Z - 2020-02-01T04:00:00.000Z', + '2020-02-05T03:00:00.000Z - 2020-02-05T04:00:00.000Z', + '2020-02-06T03:00:00.000Z - 2020-02-06T04:00:00.000Z' + ]); + }); }); diff --git a/test/calendar/vcal.spec.js b/test/calendar/vcal.spec.js index 81b7a6f2..320dfb27 100644 --- a/test/calendar/vcal.spec.js +++ b/test/calendar/vcal.spec.js @@ -683,6 +683,32 @@ describe('calendar', () => { expect(trimAll(result)).toEqual(trimAll(allDayVevent)); }); + it('should normalize exdate', () => { + const veventWithExdate = `BEGIN:VEVENT +RRULE:FREQ=DAILY;COUNT=6 +DTSTART;TZID=Europe/Zurich:20200309T043000 +DTEND;TZID=Europe/Zurich:20200309T063000 +EXDATE:19960402T010000Z,19960403T010000Z,19960404T010000Z +EXDATE;TZID=Europe/Zurich:20200311T043000 +EXDATE;TZID=Europe/Zurich:20200313T043000 +EXDATE;VALUE=DATE:20200311 +END:VEVENT +`; + const normalizedVevent = `BEGIN:VEVENT +RRULE:FREQ=DAILY;COUNT=6 +DTSTART;TZID=Europe/Zurich:20200309T043000 +DTEND;TZID=Europe/Zurich:20200309T063000 +EXDATE:19960402T010000Z +EXDATE:19960403T010000Z +EXDATE:19960404T010000Z +EXDATE;TZID=Europe/Zurich:20200311T043000 +EXDATE;TZID=Europe/Zurich:20200313T043000 +EXDATE;VALUE=DATE:20200311 +END:VEVENT`; + const result = serialize(parse(veventWithExdate)); + expect(trimAll(result)).toEqual(trimAll(normalizedVevent)); + }); + it('should parse trigger string', () => { expect(fromTriggerString('-PT30M')).toEqual({ weeks: 0,