Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / calendar / icsSurgery / ics.ts
blob38cc89260437d92621d525b7d9b8b00c19177867
1 import { parse } from '@proton/shared/lib/calendar/vcal';
2 import { PROPERTIES } from '@proton/shared/lib/calendar/vcalDefinition';
3 import type { VcalCalendarComponent } from '@proton/shared/lib/interfaces/calendar';
5 import { captureMessage } from '../../helpers/sentry';
7 /**
8  * If a vcalendar ics does not have the proper enclosing, add it
9  */
10 export const reformatVcalEnclosing = (vcal = '') => {
11     let sanitized = vcal;
12     if (!sanitized.startsWith('BEGIN:VCALENDAR')) {
13         sanitized = `BEGIN:VCALENDAR\r\n${sanitized}`;
14     }
15     if (!sanitized.endsWith('END:VCALENDAR')) {
16         sanitized = `${sanitized}\r\nEND:VCALENDAR`;
17     }
18     return sanitized;
21 /**
22  * Naively extract lines in a vcalendar string
23  */
24 const getNaiveLines = (vcal = '', separator = '\r\n') => {
25     const separatedLines = vcal.split(separator);
26     if (separator === '\n') {
27         return separatedLines;
28     }
29     // split possible remaining line breaks
30     return separatedLines.flatMap((line) => line.split('\n'));
33 /**
34  * Extract naively the vcal field in a vcal line
35  */
36 const getNaiveField = (line: string) => {
37     const splitByParamsLine = line.split(';');
38     if (splitByParamsLine.length > 1) {
39         return splitByParamsLine[0];
40     }
41     const splitByValue = line.split(':');
42     if (splitByValue.length > 1) {
43         return splitByValue[0];
44     }
45     return '';
48 /**
49  * Unfold lines assuming they were folded properly
50  */
51 export const unfoldLines = (vcal = '', separator = '\r\n') => {
52     const separatedLines = vcal.split(separator);
54     return separatedLines.reduce((acc, line) => {
55         if (line.startsWith(' ')) {
56             return `${acc}${line.slice(1)}`;
57         }
58         return acc ? `${acc}${separator}${line}` : line;
59     }, '');
62 /**
63  * Naively try to reformat badly formatted line breaks in a vcalendar string
64  */
65 export const reformatLineBreaks = (vcal = '') => {
66     // try to guess the line separator of the ics (some providers use '\n' instead of the RFC-compliant '\r\n')
67     const separator = vcal.includes('\r\n') ? '\r\n' : '\n';
68     const lines = getNaiveLines(vcal, separator);
69     return lines.reduce((acc, line) => {
70         const field = getNaiveField(line);
71         if (!field) {
72             // if not a field line, it should be folded
73             return `${acc}${separator} ${line}`;
74         }
75         // make sure we did not get a false positive for the field line
76         const lowerCaseField = field.toLowerCase();
77         if (
78             PROPERTIES.has(lowerCaseField) ||
79             lowerCaseField.startsWith('x-') ||
80             ['begin', 'end'].includes(lowerCaseField)
81         ) {
82             // field lines should not be folded
83             return acc ? `${acc}${separator}${line}` : line;
84         }
85         // fall back to folding
86         return `${acc}${separator} ${line}`;
87     }, '');
90 /**
91  * Fix errors in the formatting of date-times:
92  * * Add missing VALUE=DATE for date values
93  * * Complete with zeroes incomplete date-times
94  * * Trim spaces
95  * * Transforms ISO (and partial ISO) formatting and removes milliseconds
96  * * Remove duplicate Zulu markers
97  */
98 export const reformatDateTimes = (vcal = '') => {
99     const separator = vcal.includes('\r\n') ? '\r\n' : '\n';
100     const unfoldedVcal = unfoldLines(vcal, separator);
101     const unfoldedLines = unfoldedVcal.split(separator);
103     return unfoldedLines
104         .map((line) => {
105             const field = getNaiveField(line).trim().toLowerCase();
107             if (['dtstart', 'dtend', 'dtstamp', 'last-modified', 'created', 'recurrence-id'].includes(field)) {
108                 // In case the line matches the ISO standard, we replace it by the ICS standard
109                 // We do the replacement in two steps to account for providers that use a partial ISO
110                 const partiallyStandardizedLine = line.replace(/(\d\d\d\d)-(\d\d)-(\d\d)(.*)/, `$1$2$3$4`);
111                 const standardizedLine = partiallyStandardizedLine.replace(
112                     /(\d{8})[Tt](\d\d):(\d\d):(\d\d).*?([Zz]*$)/,
113                     `$1T$2$3$4$5`
114                 );
115                 const parts = standardizedLine
116                     .split(':')
117                     // trim possible spaces in the parts
118                     .map((part) => part.trim());
119                 const totalParts = parts.length;
121                 if (totalParts === 2 && /^\d{8}$/.test(parts[1])) {
122                     // it's a date value
123                     return parts[0].includes(';VALUE=DATE')
124                         ? `${parts[0]}:${parts[1]}`
125                         : `${parts[0]};VALUE=DATE:${parts[1]}`;
126                 }
128                 return parts
129                     .map((part, i) => {
130                         if (i < totalParts - 1) {
131                             return part;
132                         } else {
133                             // naively the value will be here
134                             const match = part.match(/[Zz]+$/);
135                             const endingZs = match ? match[0].length : 0;
136                             const isUTC = !!endingZs;
137                             const dateTime = isUTC ? part.slice(0, -endingZs) : part;
138                             const [date = '', time = ''] = dateTime.split(/[Tt]/);
139                             if (date.length !== 8) {
140                                 // we cannot recover; we do no surgery and an error will be thrown
141                                 return part;
142                             }
143                             if (time.length < 6) {
144                                 const completeDateTime = `${date}T${time.padEnd(6, '0')}`;
146                                 return isUTC ? `${completeDateTime}Z` : completeDateTime;
147                             }
148                             if (time.length > 6) {
149                                 const reducedDateTime = `${date}T${time.slice(0, 6)}`;
151                                 return isUTC ? `${reducedDateTime}Z` : reducedDateTime;
152                             }
153                             return isUTC ? `${date}T${time}Z` : `${date}T${time}`;
154                         }
155                     })
156                     .join(':');
157             } else {
158                 return line;
159             }
160         })
161         .join(separator);
164 export const pruneOrganizer = (vcal = '') => {
165     const separator = vcal.includes('\r\n') ? '\r\n' : '\n';
166     const unfoldedVcal = unfoldLines(vcal, separator);
167     const unfoldedLines = unfoldedVcal.split(separator);
169     const withPrunedOrganizer = unfoldedLines.filter((line) => !line.startsWith('ORGANIZER')).join(separator);
171     return withPrunedOrganizer;
175  * Same as the parse function, but trying to recover performing ICS surgery directly on the vcal string
176  */
177 export const parseWithRecovery = (
178     vcal: string,
179     retry: {
180         retryLineBreaks?: boolean;
181         retryEnclosing?: boolean;
182         retryDateTimes?: boolean;
183         retryOrganizer?: boolean;
184     } = { retryLineBreaks: true, retryEnclosing: true, retryDateTimes: true, retryOrganizer: true },
185     reportToSentryData?: { calendarID: string; eventID: string }
186 ): VcalCalendarComponent => {
187     const { retryLineBreaks, retryEnclosing, retryDateTimes, retryOrganizer } = retry;
188     try {
189         return parse(vcal);
190     } catch (e: any) {
191         const reportIfNeeded = (text: string, errorMessage: string) => {
192             if (reportToSentryData) {
193                 captureMessage(text, {
194                     level: 'info',
195                     extra: {
196                         ...reportToSentryData,
197                         errorMessage,
198                     },
199                 });
200             }
201         };
202         const message = e.message.toLowerCase();
203         // try to recover from line break errors
204         const couldBeLineBreakError =
205             message.includes('missing parameter value') || message.includes('invalid line (no token ";" or ":")');
206         if (couldBeLineBreakError && retryLineBreaks) {
207             reportIfNeeded('Unparseable event due to bad folding', message);
208             const reformattedVcal = reformatLineBreaks(vcal);
209             return parseWithRecovery(reformattedVcal, { ...retry, retryLineBreaks: false });
210         }
211         // try to recover from enclosing errors
212         if (message.includes('invalid ical body') && retryEnclosing) {
213             reportIfNeeded('Unparseable event due to enclosing errors', message);
214             const reformattedVcal = reformatVcalEnclosing(vcal);
215             return parseWithRecovery(reformattedVcal, { ...retry, retryEnclosing: false });
216         }
217         // try to recover from datetimes error
218         const couldBeDateTimeError =
219             message.includes('invalid date-time value') || message.includes('could not extract integer from');
220         if (couldBeDateTimeError && retryDateTimes) {
221             reportIfNeeded('Unparseable event due to badly formatted datetime', message);
222             const reformattedVcal = reformatDateTimes(vcal);
223             return parseWithRecovery(reformattedVcal, { ...retry, retryDateTimes: false });
224         }
226         // try to recover from organizer error
227         const couldBeOrganizerError = message.includes("missing parameter value in 'organizer");
228         if (couldBeOrganizerError && retryOrganizer) {
229             reportIfNeeded('Unparseable event due badly formatted organizer', message);
230             const reformattedVcal = pruneOrganizer(vcal);
231             return parseWithRecovery(reformattedVcal, { ...retry, retryOrganizer: false });
232         }
234         throw e;
235     }
239  * Helper needed to parse events in our own DB due to other clients saving events with bad folding
240  */
241 export const parseWithFoldingRecovery = (
242     vcal: string,
243     reportToSentryData?: { calendarID: string; eventID: string }
244 ) => {
245     return parseWithRecovery(vcal, { retryLineBreaks: true }, reportToSentryData);