Merge branch 'fix/sentry-issue' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / recurrence / rrule.ts
blob919c0f0402a29758ba38169739109a4ade457d0e
1 import { getDaysInMonth } from '../../date-fns-utc';
2 import {
3     convertUTCDateTimeToZone,
4     convertZonedDateTimeToUTC,
5     fromUTCDate,
6     toLocalDate,
7     toUTCDate,
8 } from '../../date/timezone';
9 import { omit, pick } from '../../helpers/object';
10 import type { RequireSome } from '../../interfaces';
11 import type {
12     VcalDateOrDateTimeProperty,
13     VcalDateOrDateTimeValue,
14     VcalDaysKeys,
15     VcalRruleFreqValue,
16     VcalRruleProperty,
17     VcalRrulePropertyValue,
18     VcalVeventComponent,
19 } from '../../interfaces/calendar/VcalModel';
20 import {
21     FREQUENCY,
22     FREQUENCY_COUNT_MAX,
23     FREQUENCY_COUNT_MAX_INVITATION,
24     FREQUENCY_INTERVALS_MAX,
25     MAXIMUM_DATE,
26     MAXIMUM_DATE_UTC,
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) => {
54     if (byday) {
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 };
59         }
60     }
61     const result: { day?: string; setpos?: number } = {};
62     if (byday) {
63         result.day = byday;
64     }
65     if (bysetpos) {
66         result.setpos = bysetpos;
67     }
68     return result;
71 export const getRruleValue = (rrule?: VcalRruleProperty) => {
72     if (!rrule) {
73         return;
74     }
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;
86     if (isInvitation) {
87         return [
88             'freq',
89             'count',
90             'interval',
91             'until',
92             'wkst',
93             'bysetpos',
94             'bysecond',
95             'byminute',
96             'byhour',
97             'byday',
98             'byweekno',
99             'bymonthday',
100             'bymonth',
101             'byyearday',
102         ];
103     }
104     if (freq === 'DAILY') {
105         return [
106             'freq',
107             'count',
108             'interval',
109             'until',
110             'wkst',
111             'byyearday', // supported but invalid
112         ];
113     }
114     if (freq === 'WEEKLY') {
115         return [
116             'freq',
117             'count',
118             'interval',
119             'until',
120             'wkst',
121             'byday',
122             'byyearday', // supported but invalid
123         ];
124     }
125     if (freq === 'MONTHLY') {
126         return [
127             'freq',
128             'count',
129             'interval',
130             'until',
131             'wkst',
132             'bymonthday',
133             'byday',
134             'bysetpos',
135             'byyearday', // supported but invalid
136         ];
137     }
138     if (freq === 'YEARLY') {
139         return ['freq', 'count', 'interval', 'until', 'wkst', 'bymonthday', 'bymonth', 'byyearday'];
140     }
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
164  */
165 export const getIsRruleSimple = (rrule?: VcalRrulePropertyValue): boolean => {
166     if (!rrule) {
167         return false;
168     }
169     const { freq, count, interval, until, bysetpos, byday, bymonth, bymonthday, byyearday } = rrule;
170     if (!freq || getHasUnsupportedProperties(rrule)) {
171         return false;
172     }
173     const isBasicSimple = (!interval || interval === 1) && !count && !until;
174     if (freq === FREQUENCY.DAILY) {
175         return isBasicSimple;
176     }
177     if (freq === FREQUENCY.WEEKLY) {
178         if (isLongArray(byday)) {
179             return false;
180         }
181         return isBasicSimple;
182     }
183     if (freq === FREQUENCY.MONTHLY) {
184         if (byday || isLongArray(bymonthday) || bysetpos) {
185             return false;
186         }
187         return isBasicSimple;
188     }
189     if (freq === FREQUENCY.YEARLY) {
190         if (isLongArray(bymonthday) || isLongArray(bymonth) || byyearday) {
191             return false;
192         }
193         return isBasicSimple;
194     }
195     return false;
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.
201  */
202 export const getIsRruleCustom = (rrule?: VcalRrulePropertyValue): boolean => {
203     if (!rrule) {
204         return false;
205     }
206     const { freq, count, interval, until, bysetpos, byday, bymonth, bymonthday, byyearday } = rrule;
207     if (!freq || getHasUnsupportedProperties(rrule)) {
208         return false;
209     }
210     const isBasicCustom = (interval && interval > 1) || (count && count >= 1) || !!until;
211     if (freq === FREQUENCY.DAILY) {
212         return isBasicCustom;
213     }
214     if (freq === FREQUENCY.WEEKLY) {
215         return isLongArray(byday) || isBasicCustom;
216     }
217     if (freq === FREQUENCY.MONTHLY) {
218         if (isLongArray(byday) || isLongArray(bymonthday) || isLongArray(bysetpos)) {
219             return false;
220         }
221         const { setpos } = getDayAndSetpos(byday, bysetpos);
222         return (setpos && !!byday) || isBasicCustom;
223     }
224     if (freq === FREQUENCY.YEARLY) {
225         if (isLongArray(bymonthday) || isLongArray(bymonth) || isLongArray(byyearday)) {
226             return false;
227         }
228         return isBasicCustom;
229     }
230     return false;
233 export const getIsRruleSupported = (rruleProperty?: VcalRrulePropertyValue, isInvitation = false) => {
234     if (!rruleProperty) {
235         return false;
236     }
237     const hasUnsupportedProperties = getHasUnsupportedProperties(rruleProperty, isInvitation);
238     if (hasUnsupportedProperties) {
239         return false;
240     }
241     const { freq, interval = 1, count, until, byday, bysetpos, bymonthday, bymonth, byyearday } = rruleProperty;
242     const supportedFreq = getSupportedFreq(freq);
243     if (!supportedFreq) {
244         return false;
245     }
246     if (interval > FREQUENCY_INTERVALS_MAX[freq]) {
247         return false;
248     }
249     if (count) {
250         if (count > (isInvitation ? FREQUENCY_COUNT_MAX_INVITATION : FREQUENCY_COUNT_MAX)) {
251             return false;
252         }
253     }
254     if (until) {
255         if ('isUTC' in until && until.isUTC) {
256             if (+toUTCDate(until) > +MAXIMUM_DATE_UTC) {
257                 return false;
258             }
259         }
260         if (+toLocalDate(until) > +MAXIMUM_DATE) {
261             return false;
262         }
263     }
264     if (freq === 'DAILY') {
265         if (isInvitation) {
266             return !hasUnsupportedProperties;
267         }
268         return true;
269     }
270     if (freq === 'WEEKLY') {
271         if (isInvitation) {
272             return !hasUnsupportedProperties;
273         }
274         return true;
275     }
276     if (freq === 'MONTHLY') {
277         if (isInvitation) {
278             return !hasUnsupportedProperties;
279         }
280         if (isLongArray(byday) || isLongArray(bysetpos) || isLongArray(bymonthday)) {
281             return false;
282         }
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;
287         }
288         if (+!!day ^ +!!setpos) {
289             return false;
290         }
291         return true;
292     }
293     if (freq === 'YEARLY') {
294         if (isInvitation) {
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
298                 return false;
299             }
300             return !hasUnsupportedProperties;
301         }
302         if (isLongArray(bymonthday) || isLongArray(bymonth) || isLongArray(byyearday)) {
303             return false;
304         }
305         if (bymonthday && !bymonth) {
306             return false;
307         }
308         return true;
309     }
310     return false;
313 export const getSupportedUntil = ({
314     until,
315     dtstart,
316     guessTzid = 'UTC',
317 }: {
318     until: VcalDateOrDateTimeValue;
319     dtstart: VcalDateOrDateTimeProperty;
320     guessTzid?: string;
321 }) => {
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.
332     if (isAllDay) {
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);
337             return {
338                 year: untilGuess.year,
339                 month: untilGuess.month,
340                 day: untilGuess.day,
341             };
342         }
343         return {
344             year: adjustedUntil.year,
345             month: adjustedUntil.month,
346             day: adjustedUntil.day,
347         };
348     }
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,
364     guessTzid?: string
365 ): VcalRruleProperty | undefined => {
366     if (!vevent.rrule?.value) {
367         return;
368     }
369     const { dtstart, rrule } = vevent;
370     const { until } = rrule.value;
371     const supportedRrule = { ...rrule };
373     if (until) {
374         const supportedUntil = getSupportedUntil({
375             until,
376             dtstart,
377             guessTzid,
378         });
379         supportedRrule.value.until = supportedUntil;
380     }
381     if (!getIsRruleSupported(rrule.value, isInvitation)) {
382         return;
383     }
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;
393     if (!rrule?.value) {
394         return true;
395     }
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
402         return false;
403     }
405     if (until && count !== undefined) {
406         return false;
407     }
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']),
413         maxCount: 1,
414     });
416     if (!first) {
417         return false;
418     }
420     if (+first.localStart !== +toUTCDate(vevent.dtstart.value)) {
421         return false;
422     }
424     return true;