Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / veventHelper.ts
blob02d6830a316ac6a55196ae96af53d2326a1035bf
1 import { serverTime } from '@proton/crypto';
2 import { absoluteToRelativeTrigger, getIsAbsoluteTrigger } from '@proton/shared/lib/calendar/alarms/trigger';
4 import { DAY } from '../constants';
5 import { fromUTCDate, toUTCDate } from '../date/timezone';
6 import { omit, pick } from '../helpers/object';
7 import type {
8     AttendeeClearPartResult,
9     AttendeePart,
10     CalendarEventData,
11     VcalDateOrDateTimeProperty,
12     VcalValarmComponent,
13     VcalVeventComponent,
14 } from '../interfaces/calendar';
15 import type { RequireOnly } from '../interfaces/utils';
16 import { fromInternalAttendee } from './attendees';
17 import {
18     CALENDAR_CARD_TYPE,
19     CALENDAR_ENCRYPTED_FIELDS,
20     CALENDAR_SIGNED_FIELDS,
21     NOTIFICATION_TYPE_API,
22     REQUIRED_SET,
23     SHARED_ENCRYPTED_FIELDS,
24     SHARED_SIGNED_FIELDS,
25     TAKEN_KEYS,
26     USER_ENCRYPTED_FIELDS,
27     USER_SIGNED_FIELDS,
28 } from './constants';
29 import { generateProtonCalendarUID, getDisplayTitle, hasMoreThan, wrap } from './helper';
30 import { withMandatoryPublishFields as withVAlarmMandatoryPublishFields } from './valarmHelper';
31 import { parse, serialize, toTriggerString } from './vcal';
32 import { prodId } from './vcalConfig';
33 import { dateTimeToProperty, propertyToUTCDate } from './vcalConverter';
34 import { getIsCalendar, getIsEventComponent, getIsPropertyAllDay, getIsVeventCancelled } from './vcalHelper';
36 const { ENCRYPTED_AND_SIGNED, SIGNED, CLEAR_TEXT } = CALENDAR_CARD_TYPE;
38 export const getIsAllDay = ({ dtstart }: Pick<VcalVeventComponent, 'dtstart'>) => {
39     return getIsPropertyAllDay(dtstart);
42 export const getUidValue = (component: VcalVeventComponent) => {
43     return component.uid.value;
46 export const getVeventColorValue = (component: VcalVeventComponent) => {
47     return component.color?.value;
50 export const getIsRecurring = ({ rrule }: Pick<VcalVeventComponent, 'rrule'>) => {
51     return !!rrule;
54 export const getRecurrenceId = ({ 'recurrence-id': recurrenceId }: Pick<VcalVeventComponent, 'recurrence-id'>) => {
55     return recurrenceId;
58 export const getRecurrenceIdDate = (component: VcalVeventComponent) => {
59     const rawRecurrenceId = getRecurrenceId(component);
60     if (!rawRecurrenceId || !rawRecurrenceId.value) {
61         return;
62     }
63     return toUTCDate(rawRecurrenceId.value);
66 export const getSequence = (event: VcalVeventComponent) => {
67     const sequence = +(event.sequence?.value || 0);
68     return Math.max(sequence, 0);
71 export const getReadableCard = (cards: CalendarEventData[]) => {
72     return cards.find(({ Type }) => [CLEAR_TEXT, SIGNED].includes(Type));
75 export const getIsEventCancelled = <T extends { CalendarEvents: CalendarEventData[] }>(event: T) => {
76     const calendarClearTextPart = getReadableCard(event.CalendarEvents);
77     if (!calendarClearTextPart) {
78         return;
79     }
80     const vcalPart = parse(calendarClearTextPart.Data);
81     const vevent = getIsCalendar(vcalPart) ? vcalPart.components?.find(getIsEventComponent) : undefined;
82     if (!vevent) {
83         return;
84     }
85     return getIsVeventCancelled(vevent);
88 export const withUid = <T>(properties: VcalVeventComponent & T): VcalVeventComponent & T => {
89     if (properties.uid) {
90         return properties;
91     }
92     return {
93         ...properties,
94         uid: { value: generateProtonCalendarUID() },
95     };
98 export const withDtstamp = <T>(
99     properties: RequireOnly<VcalVeventComponent, 'uid' | 'component' | 'dtstart'> & T,
100     timestamp?: number
101 ): VcalVeventComponent & T => {
102     if (properties.dtstamp) {
103         return properties as VcalVeventComponent & T;
104     }
105     const timestampToUse = timestamp !== undefined ? timestamp : +serverTime();
106     return {
107         ...properties,
108         dtstamp: dateTimeToProperty(fromUTCDate(new Date(timestampToUse)), true),
109     };
112 export const withSummary = <T>(properties: VcalVeventComponent & T): VcalVeventComponent & T => {
113     if (properties.summary) {
114         return properties;
115     }
116     return {
117         ...properties,
118         summary: { value: '' },
119     };
123  * Helper that takes a vEvent as it could be persisted in our database and returns one that is RFC-compatible for PUBLISH method
125  * According to RFC-5546, summary field is mandatory on vEvent for PUBLISH method (https://datatracker.ietf.org/doc/html/rfc5546#section-3.2.1)
126  * We also want to add RFC-5545 mandatory fields for vAlarms that we would not have already set persisted in our database
128  * @param properties properties of the vEvent
129  * @param email email associated with the calendar containing the vevent
130  * @returns an RFC-compatible vEvent for PUBLISH method
131  */
132 export const withMandatoryPublishFields = <T>(
133     properties: VcalVeventComponent & T,
134     email: string
135 ): VcalVeventComponent & T => {
136     const eventTitle = getDisplayTitle(properties.summary?.value);
138     return withSummary({
139         ...properties,
140         components: properties.components?.map((component) =>
141             withVAlarmMandatoryPublishFields(component, eventTitle, email)
142         ),
143     });
146 type VeventWithRequiredDtStart<T> = RequireOnly<VcalVeventComponent, 'dtstart'> & T;
147 export const withoutRedundantDtEnd = <T>(
148     properties: VeventWithRequiredDtStart<T>
149 ): VeventWithRequiredDtStart<T> | Omit<VeventWithRequiredDtStart<T>, 'dtend'> => {
150     const utcDtStart = +propertyToUTCDate(properties.dtstart);
151     const utcDtEnd = properties.dtend ? +propertyToUTCDate(properties.dtend) : undefined;
153     // All day events date ranges are stored non-inclusively, so if a full day event has same start and end day, we can ignore it
154     const ignoreDtend =
155         !utcDtEnd ||
156         (getIsAllDay(properties) ? Math.floor((utcDtEnd - utcDtStart) / DAY) <= 1 : utcDtStart === utcDtEnd);
158     if (ignoreDtend) {
159         return omit(properties, ['dtend']);
160     }
162     return properties;
166  * Used to removed `rrule` field in Reply ICS for invite single edit when recurrence-id is filled
167  */
168 export const withoutRedundantRrule = <T>(
169     properties: VcalVeventComponent & T
170 ): (VcalVeventComponent & T) | Omit<VcalVeventComponent & T, 'rrule'> => {
171     if (Boolean(properties['recurrence-id']) && Boolean(properties.rrule)) {
172         return omit(properties, ['rrule']);
173     }
175     return properties;
178 export const withRequiredProperties = <T>(properties: VcalVeventComponent & T): VcalVeventComponent & T => {
179     return withDtstamp(withUid(properties));
182 export const getSharedPart = (properties: VcalVeventComponent) => {
183     return {
184         [SIGNED]: pick(properties, SHARED_SIGNED_FIELDS),
185         [ENCRYPTED_AND_SIGNED]: pick(properties, SHARED_ENCRYPTED_FIELDS),
186     };
189 export const getCalendarPart = (properties: VcalVeventComponent) => {
190     return {
191         [SIGNED]: pick(properties, CALENDAR_SIGNED_FIELDS),
192         [ENCRYPTED_AND_SIGNED]: pick(properties, CALENDAR_ENCRYPTED_FIELDS),
193     };
196 export const getUserPart = (veventProperties: VcalVeventComponent) => {
197     return {
198         [SIGNED]: pick(veventProperties, USER_SIGNED_FIELDS),
199         [ENCRYPTED_AND_SIGNED]: pick(veventProperties, USER_ENCRYPTED_FIELDS),
200     };
203 export const getAttendeesPart = (
204     veventProperties: VcalVeventComponent
205 ): {
206     [CLEAR_TEXT]: AttendeeClearPartResult[];
207     [ENCRYPTED_AND_SIGNED]: Partial<VcalVeventComponent>;
208 } => {
209     const formattedAttendees: { [CLEAR_TEXT]: AttendeeClearPartResult[]; attendee: AttendeePart[] } = {
210         [CLEAR_TEXT]: [],
211         attendee: [],
212     };
213     if (Array.isArray(veventProperties.attendee)) {
214         for (const attendee of veventProperties.attendee) {
215             const { clear, attendee: newAttendee } = fromInternalAttendee(attendee);
216             formattedAttendees[CLEAR_TEXT].push(clear);
217             formattedAttendees.attendee.push(newAttendee);
218         }
219     }
221     if (!formattedAttendees.attendee.length) {
222         return {
223             [ENCRYPTED_AND_SIGNED]: {},
224             [CLEAR_TEXT]: [],
225         };
226     }
228     const result: Pick<VcalVeventComponent, 'uid' | 'attendee'> = {
229         uid: veventProperties.uid,
230         attendee: formattedAttendees.attendee,
231     };
233     return {
234         [ENCRYPTED_AND_SIGNED]: result,
235         [CLEAR_TEXT]: formattedAttendees[CLEAR_TEXT],
236     };
239 const toResult = (veventProperties: Partial<VcalVeventComponent>, veventComponents: VcalValarmComponent[] = []) => {
240     // Add PRODID to identify the author of the last event modification
241     return wrap(
242         serialize({
243             ...veventProperties,
244             component: 'vevent',
245             components: veventComponents,
246         }),
247         prodId
248     );
252  * Ignores the result if the vevent does not contain anything more than the required set (uid, dtstamp, and children).
253  */
254 const toResultOptimized = (
255     veventProperties: Partial<VcalVeventComponent>,
256     veventComponents: VcalValarmComponent[] = []
257 ) => {
258     return hasMoreThan(REQUIRED_SET, veventProperties) || veventComponents.length
259         ? toResult(veventProperties, veventComponents)
260         : undefined;
263 export const toApiNotifications = (components?: VcalValarmComponent[], dtstart?: VcalDateOrDateTimeProperty) => {
264     if (!components) {
265         return [];
266     }
268     return components.map(({ trigger, action }) => {
269         const Type =
270             action.value.toLowerCase() === 'email' ? NOTIFICATION_TYPE_API.EMAIL : NOTIFICATION_TYPE_API.DEVICE;
272         if (getIsAbsoluteTrigger(trigger)) {
273             if (!dtstart) {
274                 throw new Error('Cannot convert absolute trigger without DTSTART');
275             }
276             const relativeTrigger = {
277                 value: absoluteToRelativeTrigger(trigger, dtstart),
278             };
280             return {
281                 Type,
282                 Trigger: toTriggerString(relativeTrigger.value),
283             };
284         }
286         return {
287             Type,
288             Trigger: toTriggerString(trigger.value),
289         };
290     });
294  * Split the internal vevent component into the parts expected by the API.
295  */
296 export const getVeventParts = ({ components, ...properties }: VcalVeventComponent) => {
297     const restProperties = omit(properties, [...TAKEN_KEYS, 'color']);
299     const sharedPart = getSharedPart(properties);
300     const calendarPart = getCalendarPart(properties);
301     const personalPart = getUserPart(properties);
302     const attendeesPart = getAttendeesPart(properties);
304     return {
305         sharedPart: {
306             [SIGNED]: toResult(sharedPart[SIGNED]),
307             // Store all the rest of the properties in the shared encrypted part
308             [ENCRYPTED_AND_SIGNED]: toResult({
309                 ...sharedPart[ENCRYPTED_AND_SIGNED],
310                 ...restProperties,
311             }),
312         },
313         calendarPart: {
314             [SIGNED]: toResultOptimized(calendarPart[SIGNED]),
315             [ENCRYPTED_AND_SIGNED]: toResultOptimized(calendarPart[ENCRYPTED_AND_SIGNED]),
316         },
317         personalPart: {
318             // Assume all sub-components are valarm that go in the personal part
319             [SIGNED]: toResultOptimized(personalPart[SIGNED], components),
320             // Nothing to encrypt for now
321             [ENCRYPTED_AND_SIGNED]: undefined,
322         },
323         attendeesPart: {
324             // Nothing to sign for now
325             [SIGNED]: undefined,
326             [ENCRYPTED_AND_SIGNED]: toResultOptimized(attendeesPart[ENCRYPTED_AND_SIGNED]),
327             [CLEAR_TEXT]: attendeesPart[CLEAR_TEXT],
328         },
329         notificationsPart: toApiNotifications(components),
330     };
333 export const getCalendarSignedPartWithExdate = ({ components, ...properties }: VcalVeventComponent) => {
334     return toResult(pick(properties, [...CALENDAR_SIGNED_FIELDS, 'exdate']));