1 import { c } from 'ttag';
3 import { CryptoProxy, serverTime } from '@proton/crypto';
4 import { arrayToHexString, binaryStringToArray } from '@proton/crypto/lib/utils';
5 import type { TelemetryReport } from '@proton/shared/lib/api/telemetry';
6 import { TelemetryIcsSurgeryEvents, TelemetryMeasurementGroups } from '@proton/shared/lib/api/telemetry';
7 import { sendMultipleTelemetryReports, sendTelemetryReport } from '@proton/shared/lib/helpers/metrics';
8 import { captureMessage } from '@proton/shared/lib/helpers/sentry';
9 import isTruthy from '@proton/utils/isTruthy';
10 import truncate from '@proton/utils/truncate';
11 import unique from '@proton/utils/unique';
13 import { getEventByUID } from '../../api/calendars';
14 import type { Options as FormatOptions } from '../../date-fns-utc/format';
15 import formatUTC from '../../date-fns-utc/format';
16 import { getSupportedTimezone, toUTCDate } from '../../date/timezone';
17 import { readFileAsString } from '../../helpers/file';
18 import { dateLocale } from '../../i18n';
19 import type { Api, SimpleMap } from '../../interfaces';
24 VcalCalendarComponentWithMaybeErrors,
26 VcalVcalendarWithMaybeErrors,
28 VcalVtimezoneComponent,
29 } from '../../interfaces/calendar';
30 import { ICAL_METHOD, IMPORT_ERROR_TYPE, MAX_CALENDARS_PAID, MAX_IMPORT_EVENTS } from '../constants';
31 import getComponentFromCalendarEvent from '../getComponentFromCalendarEvent';
32 import { generateVeventHashUID, getNaiveDomainFromUID, getOriginalUID } from '../helper';
33 import { IMPORT_EVENT_ERROR_TYPE, ImportEventError } from '../icsSurgery/ImportEventError';
34 import { getSupportedCalscale } from '../icsSurgery/vcal';
35 import { getLinkedDateTimeProperty, getSupportedEvent, withSupportedDtstamp } from '../icsSurgery/vevent';
36 import { getVeventWithoutErrors, parseVcalendarWithRecoveryAndMaybeErrors, serialize } from '../vcal';
42 getIsFreebusyComponent,
43 getIsJournalComponent,
45 getIsTimezoneComponent,
47 getIsVcalErrorComponent,
49 } from '../vcalHelper';
50 import { ImportFileError } from './ImportFileError';
52 const icsHashesForImportTelemetry = new Set<string>();
53 const icsFilesForIcsParsingTelemetry = new Set<string>();
56 * Send telemetry event if we got some fails during import process, so that we know how common errors are, and which error users are facing
58 export const sendImportErrorTelemetryReport = async ({
63 errors: ImportEventError[];
67 if (errors.length === 0 || icsHashesForImportTelemetry.has(hash)) {
71 const reports: TelemetryReport[] = errors.map(({ type, componentIdentifiers: { component, prodId, domain } }) => {
72 const dimensions: SimpleMap<string> = {
73 reason: IMPORT_EVENT_ERROR_TYPE[type],
79 const report: TelemetryReport = {
80 measurementGroup: TelemetryMeasurementGroups.calendarIcsSurgery,
81 event: TelemetryIcsSurgeryEvents.import,
88 await sendMultipleTelemetryReports({
93 icsHashesForImportTelemetry.add(hash);
96 export const sendTelemetryEventParsingError = (api: Api, error: IMPORT_ERROR_TYPE, filename: string | undefined) => {
97 if (!filename || icsFilesForIcsParsingTelemetry.has(filename)) {
101 void sendTelemetryReport({
103 measurementGroup: TelemetryMeasurementGroups.calendarIcsSurgery,
104 event: TelemetryIcsSurgeryEvents.ics_parsing,
106 parsing_error: error,
109 icsFilesForIcsParsingTelemetry.add(filename);
112 export const parseIcs = async (ics: File) => {
113 const filename = ics.name;
115 const icsAsString = await readFileAsString(ics);
116 const hashPromise = CryptoProxy.computeHash({ algorithm: 'unsafeSHA1', data: binaryStringToArray(icsAsString) })
117 .then((result) => arrayToHexString(result))
118 .catch((error: any) => {
119 captureMessage('Failed to hash ics', {
123 return 'failed_to_hash';
126 throw new ImportFileError(IMPORT_ERROR_TYPE.FILE_EMPTY, filename);
128 const parsedVcalendar = parseVcalendarWithRecoveryAndMaybeErrors(icsAsString) as VcalVcalendarWithMaybeErrors;
129 if (parsedVcalendar.component?.toLowerCase() !== 'vcalendar') {
130 throw new ImportFileError(IMPORT_ERROR_TYPE.INVALID_CALENDAR, filename);
132 const { method, prodid, calscale, components, 'x-wr-timezone': xWrTimezone } = parsedVcalendar;
133 const supportedCalscale = getSupportedCalscale(calscale);
134 const supportedMethod = getIcalMethod(method);
136 if (!supportedMethod) {
137 throw new ImportFileError(IMPORT_ERROR_TYPE.INVALID_METHOD, filename);
139 if (!components?.length) {
140 throw new ImportFileError(IMPORT_ERROR_TYPE.NO_EVENTS, filename);
142 if (components.length > MAX_IMPORT_EVENTS) {
143 throw new ImportFileError(IMPORT_ERROR_TYPE.TOO_MANY_EVENTS, filename);
148 calscale: supportedCalscale,
149 xWrTimezone: xWrTimezone?.value,
150 method: supportedMethod,
151 prodId: prodid.value,
152 hashedIcs: await hashPromise,
155 if (e instanceof ImportFileError) {
158 throw new ImportFileError(IMPORT_ERROR_TYPE.FILE_CORRUPTED, filename);
163 * Get a string that can identify an imported component
165 export const getComponentIdentifier = (
166 vcalComponent: VcalCalendarComponentWithMaybeErrors | VcalErrorComponent,
167 options: FormatOptions = { locale: dateLocale }
169 if (getIsVcalErrorComponent(vcalComponent)) {
172 if (getIsTimezoneComponent(vcalComponent)) {
173 return vcalComponent.tzid.value || '';
175 const uid = 'uid' in vcalComponent ? vcalComponent.uid?.value : undefined;
176 const originalUid = getOriginalUID(uid);
180 if (getIsEventComponent(vcalComponent)) {
181 const { summary, dtstart } = vcalComponent;
182 const shortTitle = truncate(summary?.value || '');
186 if (dtstart?.value) {
187 const format = getIsPropertyAllDay(dtstart) ? 'PP' : 'PPpp';
188 return formatUTC(toUTCDate(dtstart.value), format, options);
190 return c('Error importing event').t`No UID, title or start time`;
195 const extractGuessTzid = (components: (VcalCalendarComponentWithMaybeErrors | VcalErrorComponent)[]) => {
196 const vtimezones = components.filter((componentOrError): componentOrError is VcalVtimezoneComponent => {
197 if (getIsVcalErrorComponent(componentOrError)) {
200 return getIsTimezoneComponent(componentOrError);
202 if (vtimezones.length === 1) {
203 // we do not have guarantee that the VcalVtimezoneComponent's in vtimezones are propper, so better use optional chaining
204 const guessTzid = vtimezones[0]?.tzid?.value;
205 return guessTzid ? getSupportedTimezone(guessTzid) : undefined;
209 interface ExtractSupportedEventArgs {
212 vcalComponent: VcalCalendarComponentWithMaybeErrors | VcalErrorComponent;
213 hasXWrTimezone: boolean;
214 formatOptions?: FormatOptions;
215 calendarTzid?: string;
217 canImportEventColor?: boolean;
219 export const extractSupportedEvent = async ({
222 vcalComponent: vcalComponentWithMaybeErrors,
228 }: ExtractSupportedEventArgs) => {
229 const componentId = getComponentIdentifier(vcalComponentWithMaybeErrors, formatOptions);
230 const isInvitation = method !== ICAL_METHOD.PUBLISH;
231 if (getIsVcalErrorComponent(vcalComponentWithMaybeErrors)) {
232 throw new ImportEventError({
233 errorType: IMPORT_EVENT_ERROR_TYPE.EXTERNAL_ERROR,
234 componentIdentifiers: { component: 'error', componentId, prodId, domain: '' },
235 externalError: vcalComponentWithMaybeErrors.error,
238 if (getIsTodoComponent(vcalComponentWithMaybeErrors)) {
239 throw new ImportEventError({
240 errorType: IMPORT_EVENT_ERROR_TYPE.TODO_FORMAT,
241 componentIdentifiers: { component: 'vtodo', componentId, prodId, domain: '' },
244 if (getIsJournalComponent(vcalComponentWithMaybeErrors)) {
245 throw new ImportEventError({
246 errorType: IMPORT_EVENT_ERROR_TYPE.JOURNAL_FORMAT,
247 componentIdentifiers: { component: 'vjournal', componentId, prodId, domain: '' },
250 if (getIsFreebusyComponent(vcalComponentWithMaybeErrors)) {
251 throw new ImportEventError({
252 errorType: IMPORT_EVENT_ERROR_TYPE.FREEBUSY_FORMAT,
253 componentIdentifiers: { component: 'vfreebusy', componentId, prodId, domain: '' },
256 if (getIsTimezoneComponent(vcalComponentWithMaybeErrors)) {
257 if (!getSupportedTimezone(vcalComponentWithMaybeErrors.tzid.value)) {
258 throw new ImportEventError({
259 errorType: IMPORT_EVENT_ERROR_TYPE.TIMEZONE_FORMAT,
260 componentIdentifiers: { component: 'vtimezone', componentId, prodId, domain: '' },
263 throw new ImportEventError({
264 errorType: IMPORT_EVENT_ERROR_TYPE.TIMEZONE_IGNORE,
265 componentIdentifiers: { component: 'vtimezone', componentId, prodId, domain: '' },
268 if (!getIsEventComponent(vcalComponentWithMaybeErrors)) {
269 throw new ImportEventError({
270 errorType: IMPORT_EVENT_ERROR_TYPE.WRONG_FORMAT,
271 componentIdentifiers: { component: 'unknown', componentId, prodId, domain: '' },
274 const vcalComponent = getVeventWithoutErrors(vcalComponentWithMaybeErrors);
275 if (!getHasDtStart(vcalComponent)) {
276 throw new ImportEventError({
277 errorType: IMPORT_EVENT_ERROR_TYPE.DTSTART_MISSING,
278 componentIdentifiers: { component: 'vevent', componentId, prodId, domain: '' },
281 const validVevent = withSupportedDtstamp(vcalComponent, +serverTime());
282 const generateHashUid = !validVevent.uid?.value || isInvitation;
284 if (generateHashUid) {
286 value: await generateVeventHashUID(serialize(vcalComponent), vcalComponent?.uid?.value),
290 const componentIdentifiers = {
294 domain: getNaiveDomainFromUID(validVevent.uid.value),
297 return getSupportedEvent({
298 vcalVeventComponent: validVevent,
303 isEventInvitation: false,
304 generatedHashUid: generateHashUid,
305 componentIdentifiers,
310 export const getSupportedEventsOrErrors = async ({
320 components: (VcalCalendarComponentWithMaybeErrors | VcalErrorComponent)[];
323 formatOptions?: FormatOptions;
325 xWrTimezone?: string;
326 primaryTimezone: string;
327 canImportEventColor?: boolean;
329 if (calscale?.toLowerCase() !== 'gregorian') {
331 new ImportEventError({
332 errorType: IMPORT_EVENT_ERROR_TYPE.NON_GREGORIAN,
333 componentIdentifiers: { component: 'vcalendar', componentId: '', prodId, domain: '' },
337 const hasXWrTimezone = !!xWrTimezone;
338 const calendarTzid = xWrTimezone ? getSupportedTimezone(xWrTimezone) : undefined;
339 const guessTzid = extractGuessTzid(components) || primaryTimezone;
340 const supportedEvents = await Promise.all(
341 components.map(async (vcalComponent) => {
343 const supportedEvent = await extractSupportedEvent({
353 return supportedEvent;
360 return supportedEvents.filter(isTruthy);
364 * Split an array of events into those which have a recurrence id and those which don't
366 export const splitByRecurrenceId = (events: VcalVeventComponent[]) => {
367 return events.reduce<{
368 withoutRecurrenceId: VcalVeventComponent[];
369 withRecurrenceId: (VcalVeventComponent & Required<Pick<VcalVeventComponent, 'recurrence-id'>>)[];
372 if (!getHasRecurrenceId(event)) {
373 acc.withoutRecurrenceId.push(event);
375 acc.withRecurrenceId.push(event);
379 { withoutRecurrenceId: [], withRecurrenceId: [] }
383 export const splitErrors = <T>(events: (T | ImportEventError)[]) => {
384 return events.reduce<{ errors: ImportEventError[]; rest: T[] }>(
386 if (event instanceof ImportEventError) {
387 acc.errors.push(event);
389 acc.rest.push(event);
393 { errors: [], rest: [] }
397 // Separate errors that we want to hide
398 export const splitHiddenErrors = (errors: ImportEventError[]) => {
399 return errors.reduce<{ hidden: ImportEventError[]; visible: ImportEventError[] }>(
401 if (error.type === IMPORT_EVENT_ERROR_TYPE.NO_OCCURRENCES) {
402 // Importing an event without occurrences is the same as not importing it
403 acc.hidden.push(error);
405 acc.visible.push(error);
409 { hidden: [], visible: [] }
413 const getParentEventFromApi = async (uid: string, api: Api, calendarId: string) => {
415 const { Events } = await api<{ Events: CalendarEvent[] }>({
419 PageSize: MAX_CALENDARS_PAID,
423 const [parentEvent] = Events.filter(({ CalendarID }) => CalendarID === calendarId);
427 const parentComponent = getComponentFromCalendarEvent(parentEvent);
428 if (getHasRecurrenceId(parentComponent)) {
429 // it wouldn't be a parent then
433 vcalComponent: parentComponent,
434 calendarEvent: parentEvent,
441 interface GetSupportedEventsWithRecurrenceIdArgs {
442 eventsWithRecurrenceId: (VcalVeventComponent & Required<Pick<VcalVeventComponent, 'recurrence-id'>>)[];
443 parentEvents: ImportedEvent[];
448 export const getSupportedEventsWithRecurrenceId = async ({
449 eventsWithRecurrenceId,
454 }: GetSupportedEventsWithRecurrenceIdArgs) => {
455 // map uid -> parent event
456 const mapParentEvents = parentEvents.reduce<
458 vcalComponent: VcalVeventComponent;
459 calendarEvent: CalendarEvent;
462 acc[event.component.uid.value] = {
463 vcalComponent: event.component,
464 calendarEvent: event.response.Response.Event,
469 // complete the map with parent events in the DB
470 const uidsToFetch = unique(
471 eventsWithRecurrenceId.filter(({ uid }) => !mapParentEvents[uid.value]).map(({ uid }) => uid.value)
473 const result = await Promise.all(uidsToFetch.map((uid) => getParentEventFromApi(uid, api, calendarId)));
474 result.forEach((parentEvent, i) => {
475 mapParentEvents[uidsToFetch[i]] = parentEvent;
478 return eventsWithRecurrenceId.map((event) => {
479 const uid = event.uid.value;
480 const componentIdentifiers = {
482 componentId: getComponentIdentifier(event),
484 domain: getNaiveDomainFromUID(event.uid.value),
486 const parentEvent = mapParentEvents[uid];
488 return new ImportEventError({
489 errorType: IMPORT_EVENT_ERROR_TYPE.PARENT_EVENT_MISSING,
490 componentIdentifiers,
493 const parentComponent = parentEvent.vcalComponent;
494 if (!parentComponent.rrule) {
495 return new ImportEventError({
496 errorType: IMPORT_EVENT_ERROR_TYPE.SINGLE_EDIT_UNSUPPORTED,
497 componentIdentifiers,
500 const recurrenceId = event['recurrence-id'];
502 const parentDtstart = parentComponent.dtstart;
503 const supportedRecurrenceId = getLinkedDateTimeProperty({
504 property: recurrenceId,
505 componentIdentifiers,
506 linkedIsAllDay: getIsPropertyAllDay(parentDtstart),
507 linkedTzid: getPropertyTzid(parentDtstart),
509 return { ...event, 'recurrence-id': supportedRecurrenceId };
511 if (e instanceof ImportEventError) {
514 return new ImportEventError({
515 errorType: IMPORT_EVENT_ERROR_TYPE.VALIDATION_ERROR,
516 componentIdentifiers,
522 export const extractTotals = (model: ImportCalendarModel) => {
523 const { eventsParsed, totalEncrypted, totalImported, visibleErrors, hiddenErrors } = model;
524 const totalToImport = eventsParsed.length + hiddenErrors.length;
525 const totalToProcess = 2 * totalToImport; // count encryption and submission equivalently for the progress
526 const totalEncryptedFake = totalEncrypted + hiddenErrors.length;
527 const totalImportedFake = totalImported + hiddenErrors.length;
528 const totalVisibleErrors = visibleErrors.length;
529 const totalProcessed = totalEncryptedFake + totalImportedFake + totalVisibleErrors;
533 totalImported: totalImportedFake,