diff --git a/.vscode/settings.json b/.vscode/settings.json index 6b1911bfc..965424966 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,7 +23,8 @@ "vector": "cpp", "__locale": "cpp", "locale": "cpp", - "thread": "cpp" + "thread": "cpp", + "typeinfo": "cpp" }, "git.ignoreLimitWarning": true } diff --git a/app/internal_packages/contacts/lib/AddContactToolbar.tsx b/app/internal_packages/contacts/lib/AddContactToolbar.tsx index 5ccc70326..e135da49f 100644 --- a/app/internal_packages/contacts/lib/AddContactToolbar.tsx +++ b/app/internal_packages/contacts/lib/AddContactToolbar.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Store, ContactsPerspective } from './Store'; import { localized, Actions, AccountStore } from 'mailspring-exports'; -import * as Icons from './icons'; +import * as Icons from './Icons'; import { ListensToFluxStore, BindGlobalCommands } from 'mailspring-component-kit'; interface AddContactToolbarProps { diff --git a/app/internal_packages/contacts/lib/ContactDetail.tsx b/app/internal_packages/contacts/lib/ContactDetail.tsx index d7eb1c763..5a40279a2 100644 --- a/app/internal_packages/contacts/lib/ContactDetail.tsx +++ b/app/internal_packages/contacts/lib/ContactDetail.tsx @@ -10,7 +10,7 @@ import { } from 'mailspring-exports'; import { isEqual } from 'underscore'; import { FocusContainer, ListensToFluxStore, ScrollRegion } from 'mailspring-component-kit'; -import { parse, ContactBase, ContactInteractorMetadata, apply } from './ContactController'; +import { parse, ContactBase, ContactInteractorMetadata, apply } from './ContactInfoMapping'; import { ContactDetailRead } from './ContactDetailRead'; import { ContactDetailEdit } from './ContactDetailEdit'; import { Store, ContactsPerspective } from './Store'; diff --git a/app/internal_packages/contacts/lib/ContactDetailEdit.tsx b/app/internal_packages/contacts/lib/ContactDetailEdit.tsx index c9501da59..ae0ecee79 100644 --- a/app/internal_packages/contacts/lib/ContactDetailEdit.tsx +++ b/app/internal_packages/contacts/lib/ContactDetailEdit.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Contact } from 'mailspring-exports'; -import { ContactBase } from './ContactController'; +import { ContactBase } from './ContactInfoMapping'; import { YYMMDDInput } from './YYMMDDInput'; import { ListEditor } from './ListEditor'; import { TypeaheadFreeInput } from './TypeaheadFreeInput'; -import * as Icons from './icons'; +import * as Icons from './Icons'; import { ContactProfilePhoto } from 'mailspring-component-kit'; const BaseTypes = ['Home', 'Work', 'Other']; diff --git a/app/internal_packages/contacts/lib/ContactDetailRead.tsx b/app/internal_packages/contacts/lib/ContactDetailRead.tsx index a019d334d..5c3f3290a 100644 --- a/app/internal_packages/contacts/lib/ContactDetailRead.tsx +++ b/app/internal_packages/contacts/lib/ContactDetailRead.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { Account, Contact, AccountStore, ContactGroup } from 'mailspring-exports'; import { ContactProfilePhoto, RetinaImg } from 'mailspring-component-kit'; -import * as Icons from './icons'; +import * as Icons from './Icons'; import { Store } from './Store'; -import { ContactBase, ContactInteractorMetadata } from './ContactController'; +import { ContactBase, ContactInteractorMetadata } from './ContactInfoMapping'; export const ContactDetailRead = ({ data, diff --git a/app/internal_packages/contacts/lib/ContactController.ts b/app/internal_packages/contacts/lib/ContactInfoMapping.ts similarity index 81% rename from app/internal_packages/contacts/lib/ContactController.ts rename to app/internal_packages/contacts/lib/ContactInfoMapping.ts index 30d12909f..65709774b 100644 --- a/app/internal_packages/contacts/lib/ContactController.ts +++ b/app/internal_packages/contacts/lib/ContactInfoMapping.ts @@ -2,6 +2,12 @@ import vCard from 'vcf'; import { ContactInfoGoogle, ContactInfoVCF, Contact, Utils } from 'mailspring-exports'; import * as VCFHelpers from './VCFHelpers'; +/** +This file contains business logic that maps two separate "contact.info" formats onto +a shared "ContactBase" interface. This sucks, but it's much easier to implement in JS +than in the sync engine, and necessary because Google's CardDav support is very bad +and we need to use their "Google People" API instead. +*/ export interface ContactBase { name: { displayName: string; @@ -41,18 +47,19 @@ export interface ContactParseResult { data: ContactBase; } -function safeParseVCF(vcf: string) { +function safeParseVCard(vcard: string) { // normalize \n line endings to \r\n - vcf = vcf.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); + vcard = vcard.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'); // ensure the VERSION line is the first line after BEGIN. // FastMail (and maybe others) do not honor the spec's order. - const version = vcf.match(/\r\nVERSION:[ \d.]+\r\n/)[0]; - vcf = vcf.replace(/\r\nVERSION:[ \d.]+\r\n/, '\r\n'); - vcf = vcf.replace(`BEGIN:VCARD\r\n`, `BEGIN:VCARD${version}`); - return new vCard().parse(vcf); + const version = vcard.match(/\r\nVERSION:[ \d.]+\r\n/)[0]; + vcard = vcard.replace(/\r\nVERSION:[ \d.]+\r\n/, '\r\n'); + vcard = vcard.replace(`BEGIN:VCARD\r\n`, `BEGIN:VCARD${version}`); + return new vCard().parse(vcard); } +/** Parse a Contact with no `info` into the shared details format. */ export function fromContact({ name, email }: Contact): ContactParseResult { const nameParts = name.split(' '); @@ -76,8 +83,13 @@ export function fromContact({ name, email }: Contact): ContactParseResult { }; } +/** Parse a Contact with a CardDAV VCard (v3 or v4) into the shared details format. + This takes a considerable amount of work because VCards allow many properties to + be defined more than once, and the parser we use just silently exposes things as + either an array or a single value. +*/ export function fromVCF(info: ContactInfoVCF): ContactParseResult { - const card = safeParseVCF(info.vcf); + const card = safeParseVCard(info.vcf); const name = VCFHelpers.asSingle(card.get('n')); const org = VCFHelpers.asSingle(card.get('org')); const photo = VCFHelpers.asSingle(card.get('photo')); @@ -92,29 +104,20 @@ export function fromVCF(info: ContactInfoVCF): ContactParseResult { let photoURL = photo ? photo._data : undefined; if (photoURL && new URL(photoURL).host.endsWith('contacts.icloud.com')) { // connecting to iCloud for contact photos requires authentication - // and it's difficult to reach from here. + // and it's difficult to reach from here. No photos for now :( photoURL = undefined; } - let nameParts = (name ? name._data : '').split(';'); - return { metadata: { origin: 'CardDAV', readonly: false, }, data: { - name: { - givenName: nameParts[1] || '', - familyName: nameParts[0] || '', - honorificPrefix: nameParts[3] || '', - honorificSuffix: nameParts[4] || '', - displayName: `${nameParts[3] || ''} ${nameParts[1]} ${nameParts[0]} ${nameParts[4] || - ''}`.trim(), - }, + name: VCFHelpers.parseName(name), + company: org ? org._data.split(';')[0] : '', nicknames: VCFHelpers.parseValueAndTypeCollection(nicknames), title: title ? VCFHelpers.removeRandomSemicolons(title._data) : '', - company: org ? org._data.split(';')[0] : '', phoneNumbers: VCFHelpers.parseValueAndTypeCollection(tels), emailAddresses: VCFHelpers.parseValueAndTypeCollection(emails), urls: VCFHelpers.parseValueAndTypeCollection(urls), @@ -127,11 +130,16 @@ export function fromVCF(info: ContactInfoVCF): ContactParseResult { }; } +/** Apply the changes from the UI back to a Contact with it's info in the VCard format. +Note that we only "set" fields that are changed to avoid smashing data you didn't even +touch in the UI, just in case we do it in a lossy way. +*/ export function applyToVCF(contact: Contact, changes: Partial) { if (contact.source !== 'carddav' || !('vcf' in contact.info)) { throw new Error('applyToVCF invoked with wrong contact type.'); } - const card = safeParseVCF(contact.info.vcf); + const card = safeParseVCard(contact.info.vcf); + for (const key of Object.keys(changes)) { if (key === 'name') { const name = card.get('n'); @@ -168,6 +176,9 @@ export function applyToVCF(contact: Contact, changes: Partial) { contact.info = Object.assign(contact.info, { vcf: card.toString().replace(/\n/g, '\r\n') }); } +/* Parse a Contact with Google People info into the shared details format. + We don't support multipl names or multiple organizations, but otherwise mostly + a pass-through. */ export function fromGoogle(info: ContactInfoGoogle): ContactParseResult { return { metadata: { @@ -189,6 +200,10 @@ export function fromGoogle(info: ContactInfoGoogle): ContactParseResult { }; } +/** applyToGoogle: Apply the changes from the UI back to a Contact with it's + * info in the Google People format. Note we only modify changed parts of the + * JSON. + */ export function applyToGoogle(contact: Contact, changes: Partial) { const { info, source } = contact; diff --git a/app/internal_packages/contacts/lib/VCFHelpers.ts b/app/internal_packages/contacts/lib/VCFHelpers.ts index 3ccd6a36e..50c052de4 100644 --- a/app/internal_packages/contacts/lib/VCFHelpers.ts +++ b/app/internal_packages/contacts/lib/VCFHelpers.ts @@ -1,4 +1,4 @@ -import { ContactBase } from './ContactController'; +import { ContactBase } from './ContactInfoMapping'; export const asArray = (obj: any | Array) => { if (obj instanceof Array) return obj; @@ -10,6 +10,8 @@ export const asSingle = (obj: any | Array) => { return obj; }; +/** Apply an array of items to a VCard. The underlying API requires that we `set` to +clear the array and then `add` subsequent items. */ export const setArray = (attr: string, card: any, values: { value: string; type?: string }[]) => { values.forEach(({ value, type }, idx) => { const params = {}; @@ -29,6 +31,8 @@ export const parseBirthday = (date: string) => { return { year: Number(year), month: Number(month), day: Number(day) }; }; +/** Serialize into {value: YYYY-MM-DD} with NO exceptions, regardless of what + you typed into the boxes. */ export const serializeBirthday = ({ date, }: { @@ -113,6 +117,18 @@ export const serializeAddress = (item: ContactBase['addresses'][0]) => { return { value, type: item.type }; }; +export const parseName = (name: { _data: string } | null) => { + const parts = (name ? name._data : '').split(';'); + + return { + givenName: parts[1] || '', + familyName: parts[0] || '', + honorificPrefix: parts[3] || '', + honorificSuffix: parts[4] || '', + displayName: `${parts[3] || ''} ${parts[1]} ${parts[0]} ${parts[4] || ''}`.trim(), + }; +}; + export const formatDisplayName = (name: ContactBase['name']) => { return `${name.givenName} ${name.familyName}`; };