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';
8 * If a vcalendar ics does not have the proper enclosing, add it
10 export const reformatVcalEnclosing = (vcal = '') => {
12 if (!sanitized.startsWith('BEGIN:VCALENDAR')) {
13 sanitized = `BEGIN:VCALENDAR\r\n${sanitized}`;
15 if (!sanitized.endsWith('END:VCALENDAR')) {
16 sanitized = `${sanitized}\r\nEND:VCALENDAR`;
22 * Naively extract lines in a vcalendar string
24 const getNaiveLines = (vcal = '', separator = '\r\n') => {
25 const separatedLines = vcal.split(separator);
26 if (separator === '\n') {
27 return separatedLines;
29 // split possible remaining line breaks
30 return separatedLines.flatMap((line) => line.split('\n'));
34 * Extract naively the vcal field in a vcal line
36 const getNaiveField = (line: string) => {
37 const splitByParamsLine = line.split(';');
38 if (splitByParamsLine.length > 1) {
39 return splitByParamsLine[0];
41 const splitByValue = line.split(':');
42 if (splitByValue.length > 1) {
43 return splitByValue[0];
49 * Unfold lines assuming they were folded properly
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)}`;
58 return acc ? `${acc}${separator}${line}` : line;
63 * Naively try to reformat badly formatted line breaks in a vcalendar string
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);
72 // if not a field line, it should be folded
73 return `${acc}${separator} ${line}`;
75 // make sure we did not get a false positive for the field line
76 const lowerCaseField = field.toLowerCase();
78 PROPERTIES.has(lowerCaseField) ||
79 lowerCaseField.startsWith('x-') ||
80 ['begin', 'end'].includes(lowerCaseField)
82 // field lines should not be folded
83 return acc ? `${acc}${separator}${line}` : line;
85 // fall back to folding
86 return `${acc}${separator} ${line}`;
91 * Fix errors in the formatting of date-times:
92 * * Add missing VALUE=DATE for date values
93 * * Complete with zeroes incomplete date-times
95 * * Transforms ISO (and partial ISO) formatting and removes milliseconds
96 * * Remove duplicate Zulu markers
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);
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]*$)/,
115 const parts = standardizedLine
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])) {
123 return parts[0].includes(';VALUE=DATE')
124 ? `${parts[0]}:${parts[1]}`
125 : `${parts[0]};VALUE=DATE:${parts[1]}`;
130 if (i < totalParts - 1) {
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
143 if (time.length < 6) {
144 const completeDateTime = `${date}T${time.padEnd(6, '0')}`;
146 return isUTC ? `${completeDateTime}Z` : completeDateTime;
148 if (time.length > 6) {
149 const reducedDateTime = `${date}T${time.slice(0, 6)}`;
151 return isUTC ? `${reducedDateTime}Z` : reducedDateTime;
153 return isUTC ? `${date}T${time}Z` : `${date}T${time}`;
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
177 export const parseWithRecovery = (
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;
191 const reportIfNeeded = (text: string, errorMessage: string) => {
192 if (reportToSentryData) {
193 captureMessage(text, {
196 ...reportToSentryData,
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 });
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 });
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 });
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 });
239 * Helper needed to parse events in our own DB due to other clients saving events with bad folding
241 export const parseWithFoldingRecovery = (
243 reportToSentryData?: { calendarID: string; eventID: string }
245 return parseWithRecovery(vcal, { retryLineBreaks: true }, reportToSentryData);