1 import { getDaysInMonth } from '../../date-fns-utc';
3 convertUTCDateTimeToZone,
4 convertZonedDateTimeToUTC,
8 } from '../../date/timezone';
9 import { omit, pick } from '../../helpers/object';
10 import type { RequireSome } from '../../interfaces';
12 VcalDateOrDateTimeProperty,
13 VcalDateOrDateTimeValue,
17 VcalRrulePropertyValue,
19 } from '../../interfaces/calendar/VcalModel';
23 FREQUENCY_COUNT_MAX_INVITATION,
24 FREQUENCY_INTERVALS_MAX,
27 } from '../constants';
28 import { propertyToUTCDate } from '../vcalConverter';
29 import { getIsDateTimeValue, getIsPropertyAllDay, getPropertyTzid } from '../vcalHelper';
30 import { getOccurrences } from './recurring';
32 export const getIsStandardByday = (byday = ''): byday is VcalDaysKeys => {
33 return /^(SU|MO|TU|WE|TH|FR|SA)$/.test(byday);
36 export const getIsStandardBydayArray = (byday: (string | undefined)[]): byday is VcalDaysKeys[] => {
37 return !byday.some((day) => !getIsStandardByday(day));
40 export const getPositiveSetpos = (date: Date) => {
41 const dayOfMonth = date.getUTCDate();
42 const shiftedDayOfMonth = dayOfMonth - 1;
43 return Math.floor(shiftedDayOfMonth / 7) + 1;
45 export const getNegativeSetpos = (date: Date) => {
46 const dayOfMonth = date.getUTCDate();
47 const daysInMonth = getDaysInMonth(date);
49 // return -1 if it's the last occurrence in the month
50 return Math.ceil((dayOfMonth - daysInMonth) / 7) - 1;
53 export const getDayAndSetpos = (byday?: string, bysetpos?: number) => {
55 const alternativeBydayMatch = /^([-+]?\d{1})(SU|MO|TU|WE|TH|FR|SA$)/.exec(byday);
56 if (alternativeBydayMatch) {
57 const [, pos, day] = alternativeBydayMatch;
58 return { day, setpos: +pos };
61 const result: { day?: string; setpos?: number } = {};
66 result.setpos = bysetpos;
71 export const getRruleValue = (rrule?: VcalRruleProperty) => {
75 return { ...rrule.value };
78 const getSupportedFreq = (freq: VcalRruleFreqValue): freq is 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY' => {
79 const supportedFreqs = ['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'];
80 return freq ? supportedFreqs.includes(freq) : false;
83 export const getSupportedRruleProperties = (rrule: VcalRrulePropertyValue, isInvitation = false) => {
84 const { freq } = rrule;
104 if (freq === 'DAILY') {
111 'byyearday', // supported but invalid
114 if (freq === 'WEEKLY') {
122 'byyearday', // supported but invalid
125 if (freq === 'MONTHLY') {
135 'byyearday', // supported but invalid
138 if (freq === 'YEARLY') {
139 return ['freq', 'count', 'interval', 'until', 'wkst', 'bymonthday', 'bymonth', 'byyearday'];
141 return ['freq', 'count', 'interval', 'until', 'wkst', 'bysetpos', 'byday', 'bymonthday', 'bymonth', 'byyearday'];
143 const ALLOWED_BYSETPOS = [-1, 1, 2, 3, 4];
145 export const getIsSupportedSetpos = (setpos: number) => {
146 return ALLOWED_BYSETPOS.includes(setpos);
149 const isLongArray = <T>(arg: T | T[] | undefined): arg is T[] => {
150 return Array.isArray(arg) && arg.length > 1;
153 const getHasUnsupportedProperties = (rruleProperty: VcalRrulePropertyValue, isInvitation = false) => {
154 const rruleProperties = Object.entries(rruleProperty)
155 .filter(([, value]) => value !== undefined)
156 .map(([field]) => field);
157 const supportedRruleProperties = getSupportedRruleProperties(rruleProperty, isInvitation);
159 return rruleProperties.some((property) => !supportedRruleProperties.includes(property));
163 * Given an rrule, return true it's one of the non-custom rules that we support
165 export const getIsRruleSimple = (rrule?: VcalRrulePropertyValue): boolean => {
169 const { freq, count, interval, until, bysetpos, byday, bymonth, bymonthday, byyearday } = rrule;
170 if (!freq || getHasUnsupportedProperties(rrule)) {
173 const isBasicSimple = (!interval || interval === 1) && !count && !until;
174 if (freq === FREQUENCY.DAILY) {
175 return isBasicSimple;
177 if (freq === FREQUENCY.WEEKLY) {
178 if (isLongArray(byday)) {
181 return isBasicSimple;
183 if (freq === FREQUENCY.MONTHLY) {
184 if (byday || isLongArray(bymonthday) || bysetpos) {
187 return isBasicSimple;
189 if (freq === FREQUENCY.YEARLY) {
190 if (isLongArray(bymonthday) || isLongArray(bymonth) || byyearday) {
193 return isBasicSimple;
199 * Given an rrule property, return true if it's one of our custom rules (the limits for COUNT and interval are not taken into account).
200 * If the event is not recurring or the rrule is not supported, return false.
202 export const getIsRruleCustom = (rrule?: VcalRrulePropertyValue): boolean => {
206 const { freq, count, interval, until, bysetpos, byday, bymonth, bymonthday, byyearday } = rrule;
207 if (!freq || getHasUnsupportedProperties(rrule)) {
210 const isBasicCustom = (interval && interval > 1) || (count && count >= 1) || !!until;
211 if (freq === FREQUENCY.DAILY) {
212 return isBasicCustom;
214 if (freq === FREQUENCY.WEEKLY) {
215 return isLongArray(byday) || isBasicCustom;
217 if (freq === FREQUENCY.MONTHLY) {
218 if (isLongArray(byday) || isLongArray(bymonthday) || isLongArray(bysetpos)) {
221 const { setpos } = getDayAndSetpos(byday, bysetpos);
222 return (setpos && !!byday) || isBasicCustom;
224 if (freq === FREQUENCY.YEARLY) {
225 if (isLongArray(bymonthday) || isLongArray(bymonth) || isLongArray(byyearday)) {
228 return isBasicCustom;
233 export const getIsRruleSupported = (rruleProperty?: VcalRrulePropertyValue, isInvitation = false) => {
234 if (!rruleProperty) {
237 const hasUnsupportedProperties = getHasUnsupportedProperties(rruleProperty, isInvitation);
238 if (hasUnsupportedProperties) {
241 const { freq, interval = 1, count, until, byday, bysetpos, bymonthday, bymonth, byyearday } = rruleProperty;
242 const supportedFreq = getSupportedFreq(freq);
243 if (!supportedFreq) {
246 if (interval > FREQUENCY_INTERVALS_MAX[freq]) {
250 if (count > (isInvitation ? FREQUENCY_COUNT_MAX_INVITATION : FREQUENCY_COUNT_MAX)) {
255 if ('isUTC' in until && until.isUTC) {
256 if (+toUTCDate(until) > +MAXIMUM_DATE_UTC) {
260 if (+toLocalDate(until) > +MAXIMUM_DATE) {
264 if (freq === 'DAILY') {
266 return !hasUnsupportedProperties;
270 if (freq === 'WEEKLY') {
272 return !hasUnsupportedProperties;
276 if (freq === 'MONTHLY') {
278 return !hasUnsupportedProperties;
280 if (isLongArray(byday) || isLongArray(bysetpos) || isLongArray(bymonthday)) {
283 // byday and bysetpos must both be absent or both present. If they are present, bymonthday should not be present
284 const { setpos, day } = getDayAndSetpos(byday, bysetpos);
285 if (!!day && !!setpos) {
286 return getIsStandardByday(day) && getIsSupportedSetpos(setpos) && !bymonthday;
288 if (+!!day ^ +!!setpos) {
293 if (freq === 'YEARLY') {
295 if (bymonthday && !bymonth) {
296 // These RRULEs are problematic as ICAL.js does not expand them properly.
297 // The API will reject them, so we want to block them as well
300 return !hasUnsupportedProperties;
302 if (isLongArray(bymonthday) || isLongArray(bymonth) || isLongArray(byyearday)) {
305 if (bymonthday && !bymonth) {
313 export const getSupportedUntil = ({
318 until: VcalDateOrDateTimeValue;
319 dtstart: VcalDateOrDateTimeProperty;
322 const isAllDay = getIsPropertyAllDay(dtstart);
323 const tzid = getPropertyTzid(dtstart) || 'UTC';
325 const startDateUTC = propertyToUTCDate(dtstart);
326 const untilDateUTC = toUTCDate(until);
327 const startsAfterUntil = +startDateUTC > +untilDateUTC;
329 const adjustedUntil = startsAfterUntil ? fromUTCDate(startDateUTC) : until;
331 // According to the RFC, we should use UTC dates if and only if the event is not all-day.
333 // we should use a floating date in this case
334 if (getIsDateTimeValue(adjustedUntil)) {
335 // try to guess the right UNTIL
336 const untilGuess = convertUTCDateTimeToZone(adjustedUntil, guessTzid);
338 year: untilGuess.year,
339 month: untilGuess.month,
344 year: adjustedUntil.year,
345 month: adjustedUntil.month,
346 day: adjustedUntil.day,
350 const zonedUntilDateTime = getIsDateTimeValue(adjustedUntil)
351 ? pick(adjustedUntil, ['year', 'month', 'day', 'hours', 'minutes', 'seconds'])
352 : { ...pick(adjustedUntil, ['year', 'month', 'day']), hours: 0, minutes: 0, seconds: 0 };
353 const zonedUntil = convertUTCDateTimeToZone(zonedUntilDateTime, tzid);
354 // Pick end of day in the event start date timezone
355 const zonedEndOfDay = { ...zonedUntil, hours: 23, minutes: 59, seconds: 59 };
356 const utcEndOfDay = convertZonedDateTimeToUTC(zonedEndOfDay, tzid);
358 return { ...utcEndOfDay, isUTC: true };
361 export const getSupportedRrule = (
362 vevent: RequireSome<Partial<VcalVeventComponent>, 'dtstart'>,
363 isInvitation = false,
365 ): VcalRruleProperty | undefined => {
366 if (!vevent.rrule?.value) {
369 const { dtstart, rrule } = vevent;
370 const { until } = rrule.value;
371 const supportedRrule = { ...rrule };
374 const supportedUntil = getSupportedUntil({
379 supportedRrule.value.until = supportedUntil;
381 if (!getIsRruleSupported(rrule.value, isInvitation)) {
384 return supportedRrule;
387 export const getHasOccurrences = (vevent: RequireSome<Partial<VcalVeventComponent>, 'dtstart'>) =>
388 !!getOccurrences({ component: vevent, maxCount: 1 }).length;
390 export const getHasConsistentRrule = (vevent: RequireSome<Partial<VcalVeventComponent>, 'dtstart'>) => {
391 const { rrule } = vevent;
397 const { freq, until, count, byyearday } = rrule.value;
399 if (byyearday && freq !== 'YEARLY') {
400 // According to the RFC, the BYYEARDAY rule part MUST NOT be specified when the FREQ
401 // rule part is set to DAILY, WEEKLY, or MONTHLY
405 if (until && count !== undefined) {
409 // make sure DTSTART matches the pattern of the recurring series (we exclude EXDATE and COUNT/UNTIL here)
410 const rruleValueWithNoCountOrUntil = omit(rrule.value, ['count', 'until']);
411 const [first] = getOccurrences({
412 component: omit({ ...vevent, rrule: { value: rruleValueWithNoCountOrUntil } }, ['exdate']),
420 if (+first.localStart !== +toUTCDate(vevent.dtstart.value)) {