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';
8 AttendeeClearPartResult,
11 VcalDateOrDateTimeProperty,
14 } from '../interfaces/calendar';
15 import type { RequireOnly } from '../interfaces/utils';
16 import { fromInternalAttendee } from './attendees';
19 CALENDAR_ENCRYPTED_FIELDS,
20 CALENDAR_SIGNED_FIELDS,
21 NOTIFICATION_TYPE_API,
23 SHARED_ENCRYPTED_FIELDS,
26 USER_ENCRYPTED_FIELDS,
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'>) => {
54 export const getRecurrenceId = ({ 'recurrence-id': recurrenceId }: Pick<VcalVeventComponent, 'recurrence-id'>) => {
58 export const getRecurrenceIdDate = (component: VcalVeventComponent) => {
59 const rawRecurrenceId = getRecurrenceId(component);
60 if (!rawRecurrenceId || !rawRecurrenceId.value) {
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) {
80 const vcalPart = parse(calendarClearTextPart.Data);
81 const vevent = getIsCalendar(vcalPart) ? vcalPart.components?.find(getIsEventComponent) : undefined;
85 return getIsVeventCancelled(vevent);
88 export const withUid = <T>(properties: VcalVeventComponent & T): VcalVeventComponent & T => {
94 uid: { value: generateProtonCalendarUID() },
98 export const withDtstamp = <T>(
99 properties: RequireOnly<VcalVeventComponent, 'uid' | 'component' | 'dtstart'> & T,
101 ): VcalVeventComponent & T => {
102 if (properties.dtstamp) {
103 return properties as VcalVeventComponent & T;
105 const timestampToUse = timestamp !== undefined ? timestamp : +serverTime();
108 dtstamp: dateTimeToProperty(fromUTCDate(new Date(timestampToUse)), true),
112 export const withSummary = <T>(properties: VcalVeventComponent & T): VcalVeventComponent & T => {
113 if (properties.summary) {
118 summary: { value: '' },
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
132 export const withMandatoryPublishFields = <T>(
133 properties: VcalVeventComponent & T,
135 ): VcalVeventComponent & T => {
136 const eventTitle = getDisplayTitle(properties.summary?.value);
140 components: properties.components?.map((component) =>
141 withVAlarmMandatoryPublishFields(component, eventTitle, email)
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
156 (getIsAllDay(properties) ? Math.floor((utcDtEnd - utcDtStart) / DAY) <= 1 : utcDtStart === utcDtEnd);
159 return omit(properties, ['dtend']);
166 * Used to removed `rrule` field in Reply ICS for invite single edit when recurrence-id is filled
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']);
178 export const withRequiredProperties = <T>(properties: VcalVeventComponent & T): VcalVeventComponent & T => {
179 return withDtstamp(withUid(properties));
182 export const getSharedPart = (properties: VcalVeventComponent) => {
184 [SIGNED]: pick(properties, SHARED_SIGNED_FIELDS),
185 [ENCRYPTED_AND_SIGNED]: pick(properties, SHARED_ENCRYPTED_FIELDS),
189 export const getCalendarPart = (properties: VcalVeventComponent) => {
191 [SIGNED]: pick(properties, CALENDAR_SIGNED_FIELDS),
192 [ENCRYPTED_AND_SIGNED]: pick(properties, CALENDAR_ENCRYPTED_FIELDS),
196 export const getUserPart = (veventProperties: VcalVeventComponent) => {
198 [SIGNED]: pick(veventProperties, USER_SIGNED_FIELDS),
199 [ENCRYPTED_AND_SIGNED]: pick(veventProperties, USER_ENCRYPTED_FIELDS),
203 export const getAttendeesPart = (
204 veventProperties: VcalVeventComponent
206 [CLEAR_TEXT]: AttendeeClearPartResult[];
207 [ENCRYPTED_AND_SIGNED]: Partial<VcalVeventComponent>;
209 const formattedAttendees: { [CLEAR_TEXT]: AttendeeClearPartResult[]; attendee: AttendeePart[] } = {
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);
221 if (!formattedAttendees.attendee.length) {
223 [ENCRYPTED_AND_SIGNED]: {},
228 const result: Pick<VcalVeventComponent, 'uid' | 'attendee'> = {
229 uid: veventProperties.uid,
230 attendee: formattedAttendees.attendee,
234 [ENCRYPTED_AND_SIGNED]: result,
235 [CLEAR_TEXT]: formattedAttendees[CLEAR_TEXT],
239 const toResult = (veventProperties: Partial<VcalVeventComponent>, veventComponents: VcalValarmComponent[] = []) => {
240 // Add PRODID to identify the author of the last event modification
245 components: veventComponents,
252 * Ignores the result if the vevent does not contain anything more than the required set (uid, dtstamp, and children).
254 const toResultOptimized = (
255 veventProperties: Partial<VcalVeventComponent>,
256 veventComponents: VcalValarmComponent[] = []
258 return hasMoreThan(REQUIRED_SET, veventProperties) || veventComponents.length
259 ? toResult(veventProperties, veventComponents)
263 export const toApiNotifications = (components?: VcalValarmComponent[], dtstart?: VcalDateOrDateTimeProperty) => {
268 return components.map(({ trigger, action }) => {
270 action.value.toLowerCase() === 'email' ? NOTIFICATION_TYPE_API.EMAIL : NOTIFICATION_TYPE_API.DEVICE;
272 if (getIsAbsoluteTrigger(trigger)) {
274 throw new Error('Cannot convert absolute trigger without DTSTART');
276 const relativeTrigger = {
277 value: absoluteToRelativeTrigger(trigger, dtstart),
282 Trigger: toTriggerString(relativeTrigger.value),
288 Trigger: toTriggerString(trigger.value),
294 * Split the internal vevent component into the parts expected by the API.
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);
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],
314 [SIGNED]: toResultOptimized(calendarPart[SIGNED]),
315 [ENCRYPTED_AND_SIGNED]: toResultOptimized(calendarPart[ENCRYPTED_AND_SIGNED]),
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,
324 // Nothing to sign for now
326 [ENCRYPTED_AND_SIGNED]: toResultOptimized(attendeesPart[ENCRYPTED_AND_SIGNED]),
327 [CLEAR_TEXT]: attendeesPart[CLEAR_TEXT],
329 notificationsPart: toApiNotifications(components),
333 export const getCalendarSignedPartWithExdate = ({ components, ...properties }: VcalVeventComponent) => {
334 return toResult(pick(properties, [...CALENDAR_SIGNED_FIELDS, 'exdate']));