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';
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) {
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);
59 * Try to get a string that identifies a contact. This will be used in case of errors
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;
69 const email = vcardOrVCardContact.email?.[0]?.value;
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);
90 const supportedFnProperty: VCardProperty<string> = {
92 uid: createContactPropertyUid(),
93 value: supportedContactName,
96 contact.fn = [supportedFnProperty];
102 export const getSupportedContact = (vcard: string) => {
104 const contactId = getContactId(vcard);
106 if (naiveExtractPropertyValue(vcard, 'VERSION') === '2.1') {
107 throw new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.UNSUPPORTED_VCARD_VERSION, contactId);
110 return getSupportedContactProperties(parseToVCard(vcard));
111 } catch (error: any) {
112 if (error instanceof ImportContactError) {
115 const contactId = getContactId(vcard);
116 throw new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.EXTERNAL_ERROR, contactId, error);
120 export const getSupportedContacts = (vcards: string[]) => {
124 return getSupportedContact(vcard);
125 } catch (error: any) {
126 if (error instanceof ImportContactError) {
129 const contactId = getContactId(vcard);
130 return new ImportContactError(IMPORT_CONTACT_ERROR_TYPE.EXTERNAL_ERROR, contactId, error);
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
144 export const extractContactImportCategories = (
145 contact: ContactMetadata,
146 { categories, contactEmails }: EncryptedContact
148 const withGroup = categories.map(({ name, group }) => {
149 const matchingContactEmailIDs = contactEmails
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
157 .map(({ email }) => {
158 const { ID } = contact.ContactEmails.find(({ Email }) => Email === email) || {};
163 if (!matchingContactEmailIDs.length) {
166 return { name, contactEmailIDs: matchingContactEmailIDs };
168 const categoriesMap = withGroup.reduce<SimpleMap<string[]>>((acc, { name, contactEmailIDs = [] }) => {
169 const category = acc[name];
170 if (category && contactEmailIDs.length) {
171 category.push(...contactEmailIDs);
173 acc[name] = [...contactEmailIDs];
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
185 export const getImportCategories = (contacts: ImportedContact[]) => {
186 const allCategoriesMap = contacts.reduce<
187 SimpleMap<Pick<ImportCategories, 'contactEmailIDs' | 'contactIDs' | 'totalContacts'>>
188 >((acc, { contactID, categories, contactEmailIDs: contactEmailIDsOfContact }) => {
190 // No categories to consider
191 !categories.length ||
192 // We ignore groups on contact with no emails
193 !contactEmailIDsOfContact.length
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
204 if (contactEmailIDs.length === contactEmailIDsOfContact.length) {
205 acc[name] = { contactEmailIDs: [], contactIDs: [contactID], totalContacts: 1 };
207 acc[name] = { contactEmailIDs: [...contactEmailIDs], contactIDs: [], totalContacts: 1 };
209 } else if (contactEmailIDs.length === contactEmailIDsOfContact.length) {
211 contactEmailIDs: category.contactEmailIDs,
212 contactIDs: [...category.contactIDs, contactID],
213 totalContacts: category.totalContacts + 1,
217 contactEmailIDs: [...category.contactEmailIDs, ...contactEmailIDs],
218 contactIDs: category.contactIDs,
219 totalContacts: category.totalContacts + 1,
226 return Object.entries(allCategoriesMap)
227 .map(([name, value]) => {
233 contactEmailIDs: value.contactEmailIDs,
234 contactIDs: value.contactIDs,
235 totalContacts: value.totalContacts,
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 = {
253 if (action === IMPORT_GROUPS_ACTION.CREATE && FORBIDDEN_LABEL_NAMES.includes(normalize(targetName))) {
254 result.error = c('Error').t`Invalid name`;
261 export const splitErrors = <T>(contacts: (T | ImportContactError)[]) => {
262 return contacts.reduce<{ errors: ImportContactError[]; rest: T[] }>(
264 if (contact instanceof ImportContactError) {
265 acc.errors.push(contact);
267 acc.rest.push(contact);
271 { errors: [], rest: [] }
276 * Split encrypted contacts depending on having the CATEGORIES property.
278 export const splitEncryptedContacts = (contacts: SimpleEncryptedContact[] = []) =>
279 contacts.reduce<{ withCategories: SimpleEncryptedContact[]; withoutCategories: SimpleEncryptedContact[] }>(
282 contact: { Cards, error },
287 if (Cards.some(({ Type, Data }) => Type === CONTACT_CARD_TYPE.CLEAR_TEXT && Data.includes('CATEGORIES'))) {
288 acc.withCategories.push(contact);
290 acc.withoutCategories.push(contact);
294 { withCategories: [], withoutCategories: [] }