Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / packages / shared / lib / contacts / helpers / csv.ts
blob766c83c3a9685bf683f5de9556440a69aae86923
1 import Papa from 'papaparse';
3 import { IMPORT_CONTACT_ERROR_TYPE, ImportContactError } from '@proton/shared/lib/contacts/errors/ImportContactError';
4 import { getContactId, getSupportedContactProperties, splitErrors } from '@proton/shared/lib/contacts/helpers/import';
5 import isTruthy from '@proton/utils/isTruthy';
6 import range from '@proton/utils/range';
8 import { getAllTypes } from '../../helpers/contacts';
9 import type {
10     ParsedCsvContacts,
11     PreVcardProperty,
12     PreVcardsContact,
13     PreVcardsProperty,
14 } from '../../interfaces/contacts/Import';
15 import type { VCardContact, VCardKey, VCardProperty } from '../../interfaces/contacts/VCard';
16 import { createContactPropertyUid, fromVCardProperties, generateNewGroupName } from '../properties';
17 import { combine, getFirstValue, standarize, toPreVcard } from './csvFormat';
19 interface PapaParseOnCompleteArgs {
20     data?: string[][];
21     errors?: any[];
24 /**
25  * Get all csv properties and corresponding contacts values from a csv file.
26  * If there are errors when parsing the csv, throw
27  * @dev  contacts[i][j] : value for property headers[j] of contact i
28  */
29 export const readCsv = async (file: File) => {
30     const {
31         headers,
32         contacts: parsedContacts,
33         errors,
34     }: { headers: string[]; contacts: string[][]; errors: any[] } = await new Promise((resolve, reject) => {
35         const onComplete = ({ data = [], errors = [] }: PapaParseOnCompleteArgs = {}) =>
36             resolve({ headers: data[0], contacts: data.slice(1), errors });
38         Papa.parse(file, {
39             // If true, the first row of parsed data will be interpreted as field names. An array of field names will be returned in meta,
40             // and each row of data will be an object of values keyed by field name instead of a simple array.
41             // Rows with a different number of fields from the header row will produce an error.
42             header: false,
43             // If true, numeric and Boolean data will be converted to their type instead of remaining strings.
44             dynamicTyping: false,
45             complete: onComplete,
46             error: reject,
47             // If true, lines that are completely empty will be skipped. An empty line is defined to be one which evaluates to empty string.
48             skipEmptyLines: true,
49         });
50     });
52     if (errors.length) {
53         throw new Error('Error when reading csv file');
54     }
56     // Papaparse will produce data according to the CSV content
57     // There is no security about having same numbers of fields on all lines
58     // So we do a pass of sanitization to clean up data
60     const headersLength = headers.length;
61     const contacts = parsedContacts.map((contact) => {
62         if (contact.length === headersLength) {
63             return contact;
64         }
65         if (contact.length > headersLength) {
66             return contact.slice(0, headersLength);
67         }
68         return [...contact, ...range(0, headersLength - contact.length).map(() => '')];
69     });
71     return { headers, contacts };
74 /**
75  * Transform csv properties and csv contacts into pre-vCard contacts.
76  * @param {Object} csvData
77  * @param {Array<String>} csvData.headers           Array of csv properties
78  * @param {Array<Array<String>>} csvData.contacts   Array of csv contacts
79  *
80  * @return {Array<Array<Object>>}                   pre-vCard contacts
81  *
82  * @dev  Some csv property may be assigned to several pre-vCard contacts,
83  *       so an array of new headers is returned together with the pre-vCard contacts
84  */
85 const parse = ({ headers = [], contacts = [] }: ParsedCsvContacts): PreVcardsProperty[] => {
86     if (!contacts.length) {
87         return [];
88     }
89     const { headers: enrichedHeaders, contacts: standardContacts } = standarize({ headers, contacts }) || {};
90     if (!enrichedHeaders?.length || !standardContacts?.length) {
91         return [];
92     }
94     const translator = enrichedHeaders.map(toPreVcard);
96     return standardContacts
97         .map((contact) =>
98             contact
99                 .map((value, i) => translator[i](value))
100                 // some headers can be mapped to several properties, so we need to flatten
101                 .flat()
102         )
103         .map((contact) => contact.filter((preVcard) => !!preVcard)) as PreVcardsProperty[];
107  * Transform csv properties and csv contacts into pre-vCard contacts,
108  * re-arranging them in the process
110  * @dev  headers are arranged as headers = [[group of headers to be combined in a vCard], ...]
111  *       preVcardContacts is an array of pre-vCard contacts, each of them containing pre-vCards
112  *       arranged in the same way as the headers:
113  *       preVcardContacts = [[[group of pre-vCard properties to be combined], ...], ...]
114  */
115 export const prepare = ({ headers = [], contacts = [] }: ParsedCsvContacts) => {
116     const preVcardContacts = parse({ headers, contacts });
117     if (!preVcardContacts.length) {
118         return [];
119     }
121     // detect csv properties to be combined in preVcardContacts and split header indices
122     const nonCombined: number[] = [];
123     const combined = preVcardContacts[0].reduce<{ [key: string]: number[] }>(
124         (acc, { combineInto, combineIndex: j }, i) => {
125             if (combineInto) {
126                 if (!acc[combineInto]) {
127                     acc[combineInto] = [];
128                 }
129                 acc[combineInto][j as number] = i;
130                 // combined will look like e.g.
131                 // { 'fn-main': [2, <empty item(s)>, 3, 5, 1], 'fn-yomi': [<empty item(s)>, 6, 7] }
132                 return acc;
133             }
134             nonCombined.push(i);
135             return acc;
136         },
137         {}
138     );
140     for (const combination of Object.keys(combined)) {
141         // remove empty items from arrays in combined
142         combined[combination] = combined[combination].filter((n) => n !== null);
143     }
145     // Arrange pre-vCards respecting the original ordering outside header groups
146     const preparedPreVcardContacts: PreVcardsContact[] = contacts.map(() => []);
147     for (const [i, indices] of Object.values(combined).entries()) {
148         preparedPreVcardContacts.forEach((contact) => contact.push([]));
149         indices.forEach((index) => {
150             preparedPreVcardContacts.forEach((contact, k) =>
151                 contact[i].push({
152                     ...preVcardContacts[k][index],
153                 })
154             );
155         });
156     }
157     for (const index of nonCombined) {
158         preparedPreVcardContacts.forEach((contact, k) => contact.push([preVcardContacts[k][index]]));
159     }
161     return preparedPreVcardContacts;
165  * Combine pre-vCards properties into a single vCard one
166  * @param preVCards     Array of pre-vCards properties
167  * @return               vCard property
168  */
169 // In csv.ts
170 export const toVCard = (preVCards: PreVcardProperty[]): VCardProperty | undefined => {
171     if (!preVCards.length) {
172         return;
173     }
175     const { pref, field, type, custom } = preVCards[0];
176     const types = getAllTypes();
177     // Need to get the default type if the field has a second dropdown to be displayed
178     const defaultType = types[field]?.[0]?.value as VCardKey | undefined;
179     const params: { [key: string]: string } = {};
181     if (type !== undefined || defaultType !== undefined) {
182         params.type = (type || defaultType) as string;
183     }
185     if (pref !== undefined) {
186         params.pref = String(pref);
187     }
189     // Check if combine has the field method before calling it
190     const combineMethod = combine[field as keyof typeof combine];
191     if (!combineMethod) {
192         // Handle unknown fields as custom notes
193         return {
194             field: 'note',
195             value: custom ? combine.custom(preVCards) : getFirstValue(preVCards),
196             params,
197             uid: createContactPropertyUid(),
198         };
199     }
201     return {
202         field,
203         value: custom ? combine.custom(preVCards) : combineMethod(preVCards),
204         params,
205         uid: createContactPropertyUid(),
206     };
210  * This helper sanitizes multiple-valued email properties that we may get from a CSV import
211  * The RFC does not allow the EMAIL property to have multiple values: https://datatracker.ietf.org/doc/html/rfc6350#section-6.4.2
212  * Instead, one should use multiple single-valued email properties
213  */
214 const sanitizeEmailProperties = (contacts: VCardContact[]): VCardContact[] => {
215     return contacts.map((contact) => {
216         if (!contact.email) {
217             return contact;
218         }
220         const existingGroups = contact.email.map(({ group }) => group).filter(isTruthy);
222         return {
223             ...contact,
224             email: contact.email
225                 .flatMap((property) => {
226                     if (!Array.isArray(property.value)) {
227                         return [property];
228                     }
229                     // If the property is an email having an array of emails as value
230                     return property.value.map((value) => {
231                         return { ...property, value };
232                     });
233                 })
234                 .map((property) => {
235                     if (property.group) {
236                         return property;
237                     }
238                     const group = generateNewGroupName(existingGroups);
239                     existingGroups.push(group);
240                     return { ...property, group };
241                 }),
242         };
243     });
247  * Transform pre-vCards contacts into vCard contacts
248  */
249 export const toVCardContacts = (
250     preVcardsContacts: PreVcardsContact[]
251 ): { errors: ImportContactError[]; rest: VCardContact[] } => {
252     const vcards = preVcardsContacts.map((preVcardsContact) => {
253         const contact = fromVCardProperties(preVcardsContact.map(toVCard).filter(isTruthy));
255         const contactId = getContactId(contact);
257         try {
258             return getSupportedContactProperties(contact);
259         } catch (error: any) {
260             return new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.EXTERNAL_ERROR, contactId, error);
261         }
262     });
264     const { errors, rest: parsedVcardContacts } = splitErrors(vcards);
266     const contacts = sanitizeEmailProperties(parsedVcardContacts);
268     return { errors, rest: contacts };