Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / packages / shared / lib / contacts / helpers / import.ts
blobf64d8aea290bce812bca843ddc72ab479d14ccde
1 import { c } from 'ttag';
3 import isTruthy from '@proton/utils/isTruthy';
4 import truncate from '@proton/utils/truncate';
6 import { CONTACT_CARD_TYPE, FORBIDDEN_LABEL_NAMES } from '../../constants';
7 import { normalize } from '../../helpers/string';
8 import type {
9     ContactGroup,
10     ContactMetadata,
11     ImportCategories,
12     ImportedContact,
13     SimpleEncryptedContact,
14 } from '../../interfaces/contacts';
15 import { IMPORT_GROUPS_ACTION } from '../../interfaces/contacts';
16 import type { ACCEPTED_EXTENSIONS, EncryptedContact, ImportContactsModel } from '../../interfaces/contacts/Import';
17 import { EXTENSION } from '../../interfaces/contacts/Import';
18 import type { VCardContact, VCardProperty } from '../../interfaces/contacts/VCard';
19 import type { SimpleMap } from '../../interfaces/utils';
20 import { MAX_CONTACT_ID_CHARS_DISPLAY } from '../constants';
21 import { IMPORT_CONTACT_ERROR_TYPE, ImportContactError } from '../errors/ImportContactError';
22 import { createContactPropertyUid } from '../properties';
23 import { getSupportedContactName } from '../surgery';
24 import { getContactHasName, parseToVCard } from '../vcard';
26 export const getIsAcceptedExtension = (extension: string): extension is ACCEPTED_EXTENSIONS => {
27     return Object.values(EXTENSION).includes(extension as EXTENSION);
30 export const getHasPreVcardsContacts = (
31     model: ImportContactsModel
32 ): model is ImportContactsModel & Required<Pick<ImportContactsModel, 'preVcardsContacts'>> => {
33     return !!model.preVcardsContacts;
36 export const naiveExtractPropertyValue = (vcard: string, property: string) => {
37     const contentLineSeparator = vcard.includes('\r\n') ? '\r\n' : '\n';
38     const contentLineSeparatorLength = contentLineSeparator.length;
39     // Vcard properties typically have parameters and value, e.g.: FN;PID=1.1:J. Doe
40     const indexOfPropertyName = vcard.toLowerCase().indexOf(`${contentLineSeparator}${property.toLowerCase()}`);
41     const indexOfPropertyValue = vcard.indexOf(':', indexOfPropertyName);
42     if (indexOfPropertyName === -1 || indexOfPropertyValue === -1) {
43         return;
44     }
45     // take into account possible folding
46     let indexOfNextField = vcard.indexOf(contentLineSeparator, indexOfPropertyValue);
47     let value = vcard.substring(indexOfPropertyValue + 1, indexOfNextField);
49     while (vcard[indexOfNextField + contentLineSeparatorLength] === ' ') {
50         const oldIndex = indexOfNextField;
51         indexOfNextField = vcard.indexOf(contentLineSeparator, oldIndex + contentLineSeparatorLength);
52         value += vcard.substring(oldIndex + contentLineSeparatorLength + 1, indexOfNextField);
53     }
55     return value;
58 /**
59  * Try to get a string that identifies a contact. This will be used in case of errors
60  */
61 export const getContactId = (vcardOrVCardContact: string | VCardContact) => {
62     // translator: When having an error importing a contact for which we can't find a name, we display an error message `Contact ${contactId}: error description` with contactId = 'unknown'
63     const unknownString = c('Import contact. Contact identifier').t`unknown`;
64     if (typeof vcardOrVCardContact !== 'string') {
65         const fn = vcardOrVCardContact.fn?.[0]?.value;
66         if (fn) {
67             return fn;
68         }
69         const email = vcardOrVCardContact.email?.[0]?.value;
70         if (email) {
71             return email;
72         }
73         return unknownString;
74     }
75     const FNvalue = naiveExtractPropertyValue(vcardOrVCardContact, 'FN');
77     return FNvalue ? truncate(FNvalue, MAX_CONTACT_ID_CHARS_DISPLAY) : unknownString;
80 export const getSupportedContactProperties = (contact: VCardContact) => {
81     if (!getContactHasName(contact)) {
82         const contactId = getContactId(contact);
84         const supportedContactName = getSupportedContactName(contact);
86         if (!supportedContactName) {
87             throw new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.MISSING_FN, contactId);
88         }
90         const supportedFnProperty: VCardProperty<string> = {
91             field: 'fn',
92             uid: createContactPropertyUid(),
93             value: supportedContactName,
94         };
96         contact.fn = [supportedFnProperty];
97     }
99     return contact;
102 export const getSupportedContact = (vcard: string) => {
103     try {
104         const contactId = getContactId(vcard);
106         if (naiveExtractPropertyValue(vcard, 'VERSION') === '2.1') {
107             throw new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.UNSUPPORTED_VCARD_VERSION, contactId);
108         }
110         return getSupportedContactProperties(parseToVCard(vcard));
111     } catch (error: any) {
112         if (error instanceof ImportContactError) {
113             throw error;
114         }
115         const contactId = getContactId(vcard);
116         throw new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.EXTERNAL_ERROR, contactId, error);
117     }
120 export const getSupportedContacts = (vcards: string[]) => {
121     return vcards
122         .map((vcard) => {
123             try {
124                 return getSupportedContact(vcard);
125             } catch (error: any) {
126                 if (error instanceof ImportContactError) {
127                     return error;
128                 }
129                 const contactId = getContactId(vcard);
130                 return new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.EXTERNAL_ERROR, contactId, error);
131             }
132         })
133         .filter(isTruthy);
136 export const haveCategories = (contacts: ImportedContact[]) => {
137     return contacts.some(({ categories }) => categories.some((category) => category.contactEmailIDs?.length));
141  * Extract the info about categories relevant for importing groups, i.e.
142  * extract categories with corresponding contact email ids (if any) for a submitted contact
143  */
144 export const extractContactImportCategories = (
145     contact: ContactMetadata,
146     { categories, contactEmails }: EncryptedContact
147 ) => {
148     const withGroup = categories.map(({ name, group }) => {
149         const matchingContactEmailIDs = contactEmails
150             .filter(
151                 ({ group: emailGroup }) =>
152                     // If category group is not defined, we consider it applies to all email
153                     group === undefined ||
154                     // If category group is defined, we consider it has to match with the email group
155                     emailGroup === group
156             )
157             .map(({ email }) => {
158                 const { ID } = contact.ContactEmails.find(({ Email }) => Email === email) || {};
159                 return ID;
160             })
161             .filter(isTruthy);
163         if (!matchingContactEmailIDs.length) {
164             return { name };
165         }
166         return { name, contactEmailIDs: matchingContactEmailIDs };
167     });
168     const categoriesMap = withGroup.reduce<SimpleMap<string[]>>((acc, { name, contactEmailIDs = [] }) => {
169         const category = acc[name];
170         if (category && contactEmailIDs.length) {
171             category.push(...contactEmailIDs);
172         } else {
173             acc[name] = [...contactEmailIDs];
174         }
175         return acc;
176     }, {});
178     return Object.entries(categoriesMap).map(([name, contactEmailIDs]) => ({ name, contactEmailIDs }));
182  * Given a list of imported contacts, get a list of the categories that can be imported, each of them with
183  * a list of contactEmailIDs or contactIDs plus total number of contacts that would go into the category
184  */
185 export const getImportCategories = (contacts: ImportedContact[]) => {
186     const allCategoriesMap = contacts.reduce<
187         SimpleMap<Pick<ImportCategories, 'contactEmailIDs' | 'contactIDs' | 'totalContacts'>>
188     >((acc, { contactID, categories, contactEmailIDs: contactEmailIDsOfContact }) => {
189         if (
190             // No categories to consider
191             !categories.length ||
192             // We ignore groups on contact with no emails
193             !contactEmailIDsOfContact.length
194         ) {
195             return acc;
196         }
197         categories.forEach(({ name, contactEmailIDs = [] }) => {
198             const category = acc[name];
199             if (contactEmailIDs.length === 0) {
200                 // We ignore groups on contact if no emails are assigned
201                 return;
202             }
203             if (!category) {
204                 if (contactEmailIDs.length === contactEmailIDsOfContact.length) {
205                     acc[name] = { contactEmailIDs: [], contactIDs: [contactID], totalContacts: 1 };
206                 } else {
207                     acc[name] = { contactEmailIDs: [...contactEmailIDs], contactIDs: [], totalContacts: 1 };
208                 }
209             } else if (contactEmailIDs.length === contactEmailIDsOfContact.length) {
210                 acc[name] = {
211                     contactEmailIDs: category.contactEmailIDs,
212                     contactIDs: [...category.contactIDs, contactID],
213                     totalContacts: category.totalContacts + 1,
214                 };
215             } else {
216                 acc[name] = {
217                     contactEmailIDs: [...category.contactEmailIDs, ...contactEmailIDs],
218                     contactIDs: category.contactIDs,
219                     totalContacts: category.totalContacts + 1,
220                 };
221             }
222         });
223         return acc;
224     }, {});
226     return Object.entries(allCategoriesMap)
227         .map(([name, value]) => {
228             if (!value) {
229                 return;
230             }
231             return {
232                 name,
233                 contactEmailIDs: value.contactEmailIDs,
234                 contactIDs: value.contactIDs,
235                 totalContacts: value.totalContacts,
236             };
237         })
238         .filter(isTruthy);
241 export const getImportCategoriesModel = (contacts: ImportedContact[], groups: ContactGroup[] = []) => {
242     const categories = getImportCategories(contacts).map((category) => {
243         const existingGroup = groups.find(({ Name }) => Name === category.name);
244         const action = existingGroup && groups.length ? IMPORT_GROUPS_ACTION.MERGE : IMPORT_GROUPS_ACTION.CREATE;
245         const targetGroup = existingGroup || groups[0];
246         const targetName = existingGroup ? '' : category.name;
247         const result: ImportCategories = {
248             ...category,
249             action,
250             targetGroup,
251             targetName,
252         };
253         if (action === IMPORT_GROUPS_ACTION.CREATE && FORBIDDEN_LABEL_NAMES.includes(normalize(targetName))) {
254             result.error = c('Error').t`Invalid name`;
255         }
256         return result;
257     });
258     return categories;
261 export const splitErrors = <T>(contacts: (T | ImportContactError)[]) => {
262     return contacts.reduce<{ errors: ImportContactError[]; rest: T[] }>(
263         (acc, contact) => {
264             if (contact instanceof ImportContactError) {
265                 acc.errors.push(contact);
266             } else {
267                 acc.rest.push(contact);
268             }
269             return acc;
270         },
271         { errors: [], rest: [] }
272     );
276  * Split encrypted contacts depending on having the CATEGORIES property.
277  */
278 export const splitEncryptedContacts = (contacts: SimpleEncryptedContact[] = []) =>
279     contacts.reduce<{ withCategories: SimpleEncryptedContact[]; withoutCategories: SimpleEncryptedContact[] }>(
280         (acc, contact) => {
281             const {
282                 contact: { Cards, error },
283             } = contact;
284             if (error) {
285                 return acc;
286             }
287             if (Cards.some(({ Type, Data }) => Type === CONTACT_CARD_TYPE.CLEAR_TEXT && Data.includes('CATEGORIES'))) {
288                 acc.withCategories.push(contact);
289             } else {
290                 acc.withoutCategories.push(contact);
291             }
292             return acc;
293         },
294         { withCategories: [], withoutCategories: [] }
295     );