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';
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 {
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
29 export const readCsv = async (file: File) => {
32 contacts: parsedContacts,
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 });
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.
43 // If true, numeric and Boolean data will be converted to their type instead of remaining strings.
47 // If true, lines that are completely empty will be skipped. An empty line is defined to be one which evaluates to empty string.
53 throw new Error('Error when reading csv file');
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) {
65 if (contact.length > headersLength) {
66 return contact.slice(0, headersLength);
68 return [...contact, ...range(0, headersLength - contact.length).map(() => '')];
71 return { headers, contacts };
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
80 * @return {Array<Array<Object>>} pre-vCard contacts
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
85 const parse = ({ headers = [], contacts = [] }: ParsedCsvContacts): PreVcardsProperty[] => {
86 if (!contacts.length) {
89 const { headers: enrichedHeaders, contacts: standardContacts } = standarize({ headers, contacts }) || {};
90 if (!enrichedHeaders?.length || !standardContacts?.length) {
94 const translator = enrichedHeaders.map(toPreVcard);
96 return standardContacts
99 .map((value, i) => translator[i](value))
100 // some headers can be mapped to several properties, so we need to flatten
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], ...], ...]
115 export const prepare = ({ headers = [], contacts = [] }: ParsedCsvContacts) => {
116 const preVcardContacts = parse({ headers, contacts });
117 if (!preVcardContacts.length) {
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) => {
126 if (!acc[combineInto]) {
127 acc[combineInto] = [];
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] }
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);
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) =>
152 ...preVcardContacts[k][index],
157 for (const index of nonCombined) {
158 preparedPreVcardContacts.forEach((contact, k) => contact.push([preVcardContacts[k][index]]));
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
170 export const toVCard = (preVCards: PreVcardProperty[]): VCardProperty | undefined => {
171 if (!preVCards.length) {
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;
185 if (pref !== undefined) {
186 params.pref = String(pref);
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
195 value: custom ? combine.custom(preVCards) : getFirstValue(preVCards),
197 uid: createContactPropertyUid(),
203 value: custom ? combine.custom(preVCards) : combineMethod(preVCards),
205 uid: createContactPropertyUid(),
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
214 const sanitizeEmailProperties = (contacts: VCardContact[]): VCardContact[] => {
215 return contacts.map((contact) => {
216 if (!contact.email) {
220 const existingGroups = contact.email.map(({ group }) => group).filter(isTruthy);
225 .flatMap((property) => {
226 if (!Array.isArray(property.value)) {
229 // If the property is an email having an array of emails as value
230 return property.value.map((value) => {
231 return { ...property, value };
235 if (property.group) {
238 const group = generateNewGroupName(existingGroups);
239 existingGroups.push(group);
240 return { ...property, group };
247 * Transform pre-vCards contacts into vCard contacts
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);
258 return getSupportedContactProperties(contact);
259 } catch (error: any) {
260 return new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.EXTERNAL_ERROR, contactId, error);
264 const { errors, rest: parsedVcardContacts } = splitErrors(vcards);
266 const contacts = sanitizeEmailProperties(parsedVcardContacts);
268 return { errors, rest: contacts };