diff --git a/locales/rn/en.json b/locales/rn/en.json index a1042d59..98ab9795 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -256,7 +256,9 @@ "into_address_book": "Import into address book", "into_named": "Import into {name}", "no_address_book": "Create an address book before importing contacts.", - "picker_failed": "Could not open the file picker" + "picker_failed": "Could not open the file picker", + "unnamed": "Unnamed contact", + "updated_count": "{count, plural, one {1 contact was already there and has been updated.} other {# contacts were already there and have been updated.}}" }, "no_tags_hint": "No tags yet. Type one above.", "picker": { diff --git a/src/api/__tests__/contacts.test.ts b/src/api/__tests__/contacts.test.ts index 35ceacdc..c919cf4a 100644 --- a/src/api/__tests__/contacts.test.ts +++ b/src/api/__tests__/contacts.test.ts @@ -174,6 +174,41 @@ describe('contacts operations', () => { const result = await getContacts(['c1']); expect(result).toEqual(contacts); }); + + it('reads the calendar and scheduling links into the form fields', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['ContactCard/get', { list: [{ + id: 'c1', + calendars: { cal: { kind: 'calendar', uri: 'https://cal' } }, + schedulingAddresses: { s: { uri: 'mailto:s@x' } }, + }] }, '0']], + }); + const [card] = await getContacts(['c1']); + expect(card).toMatchObject({ calendarUri: 'https://cal', schedulingUri: 'mailto:s@x' }); + }); + }); + + // Stalwart refuses the whole card over calendarUri ("Invalid property."). + describe('calendar, scheduling and free/busy links', () => { + it('creates them as RFC 9553 calendars and scheduling addresses', async () => { + mockRequest + .mockResolvedValueOnce({ methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']] }) + .mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [] }, '0']] }); + await createContact({ name: { full: 'A' }, calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:a@x' }, 'ab-1'); + const create = mockRequest.mock.calls[0][0][0][1].create['new-contact']; + expect(create).not.toHaveProperty('calendarUri'); + expect(create).not.toHaveProperty('freeBusyUri'); + expect(create).not.toHaveProperty('schedulingUri'); + expect(Object.keys(create.calendars)).toEqual(['cal', 'fb']); + expect(create.schedulingAddresses.sched.uri).toBe('mailto:a@x'); + }); + + it('clears them on an update', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/set', { updated: { c1: null } }, '0']] }); + await updateContact('c1', { calendarUri: null, schedulingUri: null } as never); + const patch = mockRequest.mock.calls[0][0][0][1].update.c1; + expect(patch).toEqual({ calendars: null, schedulingAddresses: null }); + }); }); describe('getAllContacts', () => { @@ -445,6 +480,15 @@ describe('contacts operations', () => { const call = mockRequest.mock.calls[0][0][0]; expect(call[1].destroy).toEqual(['ab-1']); + expect(call[1]).not.toHaveProperty('onDestroyRemoveContents'); + }); + + it('takes the cards along when asked (RFC 9610 addressBookHasContents)', async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['AddressBook/set', { destroyed: ['ab-1'] }, '0']], + }); + await deleteAddressBook('ab-1', undefined, { removeContents: true }); + expect(mockRequest.mock.calls[0][0][0][1].onDestroyRemoveContents).toBe(true); }); it('throws when destroy fails', async () => { diff --git a/src/api/contacts.ts b/src/api/contacts.ts index cdd9afb8..5330c0ec 100644 --- a/src/api/contacts.ts +++ b/src/api/contacts.ts @@ -3,6 +3,7 @@ import { JMAPSetError } from './jmap-result'; import { CAPABILITIES } from './types'; import type { ContactCard, ContactMedia, AddressBook } from './types'; import { generateUUID } from '../lib/uuid'; +import { contactFromWire, contactToWire } from '../lib/contact-wire'; const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]; @@ -42,14 +43,6 @@ function setError(err: SetError | undefined, fallback: string): JMAPSetError { return new JMAPSetError(err?.type ?? 'setError', setErrorMessage(err, fallback), err?.properties); } -/** - * Properties an update never clears (webmail contactToWire). A null `id` fails - * the whole update ("The id property is immutable.") and so does a null - * `addressBookIds`; a null `uid` is refused by Stalwart 1.0 and on 0.16 - * silently deletes the card's UID. - */ -const NEVER_CLEARED_KEYS = ['id', 'uid', 'addressBookIds'] as const; - /** * Primary account for the contacts capability (RFC 8620 `primaryAccounts`), * falling back to the mail primary. Contacts can live in a different account @@ -180,7 +173,7 @@ export async function getContacts(ids: string[], accountId?: string): Promise(res).list ?? []; - all.push(...list); + all.push(...list.map(contactFromWire)); } return all; } @@ -238,12 +231,15 @@ export async function createContact( ): Promise { const account = accountId || getContactsAccountId(); const data = stripClientFields(contact); + // The calendar, scheduling and SOURCE links and a vCard's flat address + // fields as RFC 9553 has them: Stalwart refuses the whole card otherwise. + const wire = contactToWire(data, 'create'); const res = await jmapClient.request( [['ContactCard/set', { accountId: account, create: { 'new-contact': { - ...data, + ...wire, // Stalwart stores the card without one if omitted (#644), which // breaks group membership and CardDAV round-trips. uid: data.uid || `urn:uuid:${generateUUID()}`, @@ -285,10 +281,7 @@ export async function updateContact( accountId?: string, ): Promise { const account = accountId || getContactsAccountId(); - const patch = { ...stripClientFields(changes) } as Record; - for (const key of NEVER_CLEARED_KEYS) { - if (patch[key] == null) delete patch[key]; - } + const patch = contactToWire(stripClientFields(changes), 'update'); const res = await jmapClient.request( [['ContactCard/set', { accountId: account, update: { [id]: patch } }, '0']], USING, @@ -398,10 +391,20 @@ export async function setDefaultAddressBook(id: string, accountId?: string): Pro methodResult(res); } -export async function deleteAddressBook(id: string, accountId?: string): Promise { +export async function deleteAddressBook( + id: string, + accountId?: string, + options?: { removeContents?: boolean }, +): Promise { const account = accountId || getContactsAccountId(); + // Without onDestroyRemoveContents a book that still holds cards is + // refused with `addressBookHasContents` (RFC 9610 §2.3; Stalwart 1.0). const res = await jmapClient.request( - [['AddressBook/set', { accountId: account, destroy: [id] }, '0']], + [['AddressBook/set', { + accountId: account, + destroy: [id], + ...(options?.removeContents ? { onDestroyRemoveContents: true } : {}), + }, '0']], USING, ); const result = methodResult<{ notDestroyed?: Record }>(res); diff --git a/src/components/contacts/ContactImportSheet.tsx b/src/components/contacts/ContactImportSheet.tsx index 0a86f1f1..f87a409a 100644 --- a/src/components/contacts/ContactImportSheet.tsx +++ b/src/components/contacts/ContactImportSheet.tsx @@ -8,7 +8,7 @@ import { Upload, X, Check, FileText, AlertTriangle, BookUser } from '../icons'; import type { ContactCard } from '../../api/types'; import { parseVCard, detectDuplicates } from '../../lib/vcard'; import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils'; -import { useContactsStore } from '../../stores/contacts-store'; +import { useContactsStore, type ContactImportResult } from '../../stores/contacts-store'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; @@ -56,7 +56,7 @@ export default function ContactImportSheet({ const [duplicates, setDuplicates] = React.useState>(new Map()); const [error, setError] = React.useState(null); const [importing, setImporting] = React.useState(false); - const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null); + const [result, setResult] = React.useState(null); const slideY = React.useRef(new Animated.Value(900)).current; // Centred on a wide window, clear of side insets in landscape. @@ -156,7 +156,7 @@ export default function ContactImportSheet({ try { const outcome = await importContacts(toImport, targetBookId); setResult(outcome); - onImported?.(outcome.imported); + onImported?.(outcome.imported + outcome.updated); } catch (e) { setError(e instanceof Error ? e.message : t('contacts.import.failed', 'Import failed')); } finally { @@ -192,6 +192,15 @@ export default function ContactImportSheet({ {t('contacts.import.success', '{count, plural, one {1 contact imported} other {# contacts imported}}', { count: result.imported })} + {result.updated > 0 && ( + + {t( + 'contacts.import.updated_count', + '{count, plural, one {1 contact was already there and has been updated.} other {# contacts were already there and have been updated.}}', + { count: result.updated }, + )} + + )} {result.failed > 0 && ( {t( @@ -201,6 +210,18 @@ export default function ContactImportSheet({ )} )} + {result.failures.length > 0 && ( + + {result.failures.map((f, i) => ( + + + {f.name || t('contacts.import.unnamed', 'Unnamed contact')} + + {': '}{f.reason} + + ))} + + )} ) : parsed.length === 0 ? ( @@ -352,7 +373,10 @@ function makeStyles(c: ThemePalette) { width: 56, height: 56, borderRadius: radius.full, backgroundColor: c.primary, alignItems: 'center', justifyContent: 'center', }, - successText: { ...typography.bodyMedium, color: c.text }, + successText: { ...typography.bodyMedium, color: c.text, textAlign: 'center' }, + failureList: { maxHeight: 200, alignSelf: 'stretch' }, + failureText: { ...typography.caption, color: c.mutedForeground }, + failureName: { ...typography.caption, color: c.text, fontWeight: '600' }, targetRow: { flexDirection: 'row', alignItems: 'center', diff --git a/src/lib/__tests__/contact-wire.test.ts b/src/lib/__tests__/contact-wire.test.ts new file mode 100644 index 00000000..96e50f64 --- /dev/null +++ b/src/lib/__tests__/contact-wire.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect } from 'vitest'; +import { contactFromWire, contactToWire } from '../contact-wire'; +import { parseVCard } from '../vcard'; +import type { ContactCard } from '../../api/types'; + +describe('contactToWire', () => { + // Stalwart refuses a card with calendarUri / schedulingUri / freeBusyUri + // as properties ("Invalid property."). + it('writes the calendar, scheduling and free/busy links as RFC 9553 entries', () => { + const wire = contactToWire({ + name: { full: 'A' }, + calendarUri: 'https://cal.example/a', + freeBusyUri: 'https://cal.example/a/fb', + schedulingUri: 'mailto:a@example.com', + }, 'create'); + expect(wire).not.toHaveProperty('calendarUri'); + expect(wire).not.toHaveProperty('freeBusyUri'); + expect(wire).not.toHaveProperty('schedulingUri'); + expect(wire.calendars).toEqual({ + cal: { '@type': 'Calendar', kind: 'calendar', uri: 'https://cal.example/a' }, + fb: { '@type': 'Calendar', kind: 'freeBusy', uri: 'https://cal.example/a/fb' }, + }); + expect(wire.schedulingAddresses).toEqual({ sched: { '@type': 'SchedulingAddress', uri: 'mailto:a@example.com' } }); + }); + + it('clears the links on an update that empties them', () => { + const wire = contactToWire({ calendarUri: null as never, schedulingUri: null as never }, 'update'); + expect(wire.calendars).toBeNull(); + expect(wire.schedulingAddresses).toBeNull(); + }); + + it('leaves the links alone on an update that does not name them', () => { + const wire = contactToWire({ name: { full: 'B' } }, 'update'); + expect(wire).not.toHaveProperty('calendars'); + expect(wire).not.toHaveProperty('schedulingAddresses'); + }); + + it('never clears id, uid or the address books', () => { + const wire = contactToWire({ uid: undefined, addressBookIds: null as never, notes: undefined }, 'update'); + expect(wire).toEqual({ notes: null }); + }); + + // vCard import: flat ADR fields, LABEL, SOURCE and BIRTHPLACE. + it('writes what a vCard import holds as RFC 9553 has it', () => { + const [card] = parseVCard([ + 'BEGIN:VCARD', + 'VERSION:3.0', + 'FN:Max Muster', + 'ADR;TYPE=HOME;LABEL="Hauptstr. 1, Berlin":;;Hauptstr. 1;Berlin;;10115;Germany', + 'SOURCE:https://dir.example/max.vcf', + 'BDAY:19800102', + 'BIRTHPLACE:Hamburg', + 'END:VCARD', + ].join('\r\n')); + const { id: _id, addressBookIds: _b, ...data } = card; + const wire = contactToWire(data, 'create'); + const address = Object.values(wire.addresses as Record>)[0]; + for (const flat of ['street', 'locality', 'region', 'postcode', 'country', 'fullAddress']) { + expect(address).not.toHaveProperty(flat); + } + expect(address.components).toEqual([ + { kind: 'name', value: 'Hauptstr. 1' }, + { kind: 'locality', value: 'Berlin' }, + { kind: 'postcode', value: '10115' }, + { kind: 'country', value: 'Germany' }, + ]); + expect(address.full).toBe('Hauptstr. 1, Berlin'); + expect(wire).not.toHaveProperty('source'); + expect(wire.directories).toEqual({ source: { '@type': 'Directory', kind: 'entry', uri: 'https://dir.example/max.vcf' } }); + const birth = Object.values(wire.anniversaries as Record }>)[0]; + expect(birth.place).toEqual({ full: 'Hamburg' }); + }); + + it('drops the client-only account fields', () => { + const wire = contactToWire({ originalId: 'x', accountId: 'a', accountName: 'n', isShared: true, name: { full: 'C' } }, 'create'); + expect(wire).toEqual({ name: { full: 'C' } }); + }); +}); + +describe('contactFromWire', () => { + it('fills the flat links from what the server stores', () => { + const card = contactFromWire({ + id: 'c', + addressBookIds: {}, + calendars: { x: { kind: 'calendar', uri: 'https://cal' }, y: { kind: 'freeBusy', uri: 'https://fb' } }, + schedulingAddresses: { s: { uri: 'mailto:s@x' } }, + directories: { d: { kind: 'entry', uri: 'https://src' } }, + } as ContactCard); + expect(card).toMatchObject({ + calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:s@x', source: 'https://src', + }); + }); + + it('round-trips through the wire form', () => { + const app = { calendarUri: 'https://cal', freeBusyUri: 'https://fb', schedulingUri: 'mailto:s@x' }; + const back = contactFromWire({ id: 'c', addressBookIds: {}, ...contactToWire(app, 'create') } as ContactCard); + expect(back).toMatchObject(app); + }); +}); diff --git a/src/lib/contact-wire.ts b/src/lib/contact-wire.ts new file mode 100644 index 00000000..700328b4 --- /dev/null +++ b/src/lib/contact-wire.ts @@ -0,0 +1,137 @@ +import type { AddressComponent, ContactAddress, ContactAnniversary, ContactCard } from '../api/types'; + +/** + * Translation between the ContactCard shape the app works with and the RFC + * 9553 card the server stores (webmail lib/jmap/contact-wire.ts). + * + * ContactCard/set rejects a whole card over a single property it does not + * know ("Invalid property."), so client-side fields must never reach it, and + * the app's flat conveniences are written as their JSContact equivalents: + * the vCard CALURI / FBURL / CALADRURI / SOURCE links, and the flat street, + * locality... of a vCard ADR. + */ + +/** Fields the client adds to cards it lists; not part of RFC 9553. */ +const CLIENT_ONLY_KEYS = ['originalId', 'accountId', 'accountName', 'isShared'] as const; + +/** vCard CALURI / FBURL / CALADRURI / SOURCE, kept flat in the app. */ +const URI_KEYS = ['calendarUri', 'freeBusyUri', 'schedulingUri', 'source'] as const; + +/** + * Properties an update never clears. A null `id` fails the whole update ("The + * id property is immutable.") and so does a null `addressBookIds` ("Invalid + * patch operation for addressBookIds."). A null `uid` is refused by Stalwart + * 1.0 ("You cannot change the UID of a contact.") and on 0.16 silently + * deletes the card's UID. + */ +const NEVER_CLEARED_KEYS = ['id', 'uid', 'addressBookIds'] as const; + +const LEGACY_ADDRESS_FIELDS: Array<[keyof ContactAddress, AddressComponent['kind']]> = [ + ['street', 'name'], + ['locality', 'locality'], + ['region', 'region'], + ['postcode', 'postcode'], + ['country', 'country'], +]; + +/** The RFC 9553 calendar, scheduling and directory entries the app reads. */ +interface WireLinks { + calendars?: Record; + schedulingAddresses?: Record; +} + +/** vCard imports store flat fields; RFC 9553 wants `components` and `full`. */ +function addressToWire(address: ContactAddress): Record { + const { street: _s, locality: _l, region: _r, postcode: _p, country: _c, fullAddress, ...rest } = address; + const out: Record = { ...rest }; + if (!address.components?.length) { + const components = LEGACY_ADDRESS_FIELDS + .map(([field, kind]) => ({ kind, value: (address[field] as string | undefined)?.trim() ?? '' })) + .filter((c) => c.value); + if (components.length) { + out.components = components; + out.isOrdered = true; + out.defaultSeparator = ', '; + } + } + if (fullAddress && !address.full) out.full = fullAddress; + return out; +} + +/** A BIRTHPLACE / DEATHPLACE read from a vCard is an address too. */ +function anniversaryToWire(anniversary: ContactAnniversary): Record { + if (!anniversary.place) return { ...anniversary }; + return { ...anniversary, place: addressToWire(anniversary.place) }; +} + +type Mode = 'create' | 'update'; + +/** + * The card as ContactCard/set expects it. On `update`, a key that is present + * with the value `undefined` means "the user cleared it" and is sent as + * `null`: JSON drops undefined, and an omitted property keeps its old value. + * `id`, `uid` and `addressBookIds` without a value are left out instead. + */ +export function contactToWire(card: Partial, mode: Mode): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(card)) { + if ((CLIENT_ONLY_KEYS as readonly string[]).includes(key)) continue; + if ((URI_KEYS as readonly string[]).includes(key)) continue; + if (mode === 'update' && value == null && (NEVER_CLEARED_KEYS as readonly string[]).includes(key)) continue; + if (value === undefined) { + if (mode === 'update') out[key] = null; + continue; + } + out[key] = value; + } + + if (card.addresses) { + out.addresses = Object.fromEntries( + Object.entries(card.addresses).map(([id, a]) => [id, addressToWire(a)]), + ); + } + if (card.anniversaries) { + out.anniversaries = Object.fromEntries( + Object.entries(card.anniversaries).map(([id, a]) => [id, anniversaryToWire(a)]), + ); + } + + // Rebuild the calendar links only when the caller manages them, so an + // update that does not mention them leaves the server's entries alone. + if ('calendarUri' in card || 'freeBusyUri' in card) { + const calendars: Record = {}; + if (card.calendarUri) calendars.cal = { '@type': 'Calendar', kind: 'calendar', uri: card.calendarUri }; + if (card.freeBusyUri) calendars.fb = { '@type': 'Calendar', kind: 'freeBusy', uri: card.freeBusyUri }; + if (Object.keys(calendars).length) out.calendars = calendars; + else if (mode === 'update') out.calendars = null; + } + if ('schedulingUri' in card) { + if (card.schedulingUri) out.schedulingAddresses = { sched: { '@type': 'SchedulingAddress', uri: card.schedulingUri } }; + else if (mode === 'update') out.schedulingAddresses = null; + } + if (card.source) { + // vCard SOURCE is a JSContact directory entry (RFC 9553 §2.6.2). + const directories = { ...(card.directories ?? {}) } as Record; + directories.source = { '@type': 'Directory', kind: 'entry', uri: card.source }; + out.directories = directories; + } + return out; +} + +/** Fill the app's flat URI fields from the RFC 9553 properties. */ +export function contactFromWire(card: T): T { + const links = card as T & WireLinks; + const calendars = Object.values(links.calendars ?? {}); + const calendarUri = card.calendarUri ?? calendars.find((c) => c?.kind === 'calendar')?.uri; + const freeBusyUri = card.freeBusyUri ?? calendars.find((c) => c?.kind === 'freeBusy')?.uri; + const schedulingUri = card.schedulingUri ?? Object.values(links.schedulingAddresses ?? {})[0]?.uri; + const source = card.source ?? Object.values(card.directories ?? {}).find((d) => d?.kind === 'entry')?.uri; + if (!calendarUri && !freeBusyUri && !schedulingUri && !source) return card; + return { + ...card, + ...(calendarUri ? { calendarUri } : {}), + ...(freeBusyUri ? { freeBusyUri } : {}), + ...(schedulingUri ? { schedulingUri } : {}), + ...(source ? { source } : {}), + }; +} diff --git a/src/stores/__tests__/contacts-store.test.ts b/src/stores/__tests__/contacts-store.test.ts index 69a0b2f5..90d0c808 100644 --- a/src/stores/__tests__/contacts-store.test.ts +++ b/src/stores/__tests__/contacts-store.test.ts @@ -407,9 +407,42 @@ describe('contacts-store', () => { [{ id: 'import-1', name: { full: 'A' } }, { id: 'import-2', name: { full: 'B' } }], 'ab-1', ); - expect(result).toEqual({ imported: 1, failed: 1 }); + expect(result).toEqual({ + imported: 1, updated: 0, failed: 1, failures: [{ name: 'B', reason: 'invalid' }], + }); expect(useContactsStore.getState().contacts).toHaveLength(1); }); + + // Re-importing the app's own export: Stalwart refuses a second card with + // a UID the account has ("Contact with UID ... already exists"). + it('updates a card whose UID the account already has instead of creating it', async () => { + useContactsStore.setState({ + addressBooks: [{ id: 'ab-1', name: 'P' }], + contacts: [card('c1', { uid: 'urn:uuid:aaa', name: { full: 'Old' } })], + }); + const result = await useContactsStore.getState().importContacts( + [{ id: 'import-1', uid: 'urn:uuid:aaa', addressBookIds: { x: true }, name: { full: 'New' } }], + 'ab-1', + ); + expect(mockCreateContact).not.toHaveBeenCalled(); + expect(mockUpdateContact).toHaveBeenCalledWith('c1', { name: { full: 'New' } }, undefined); + expect(result).toMatchObject({ imported: 0, updated: 1, failed: 0 }); + expect(useContactsStore.getState().contacts[0].name?.full).toBe('New'); + }); + + it('updates the card the server names when the list does not hold it', async () => { + useContactsStore.setState({ addressBooks: [{ id: 'ab-1', name: 'P' }] }); + mockCreateContact.mockRejectedValueOnce( + new Error('Contact with UID urn:uuid:bbb already exists with id m. (uid)'), + ); + mockGetAllContacts.mockResolvedValue([]); + const result = await useContactsStore.getState().importContacts( + [{ id: 'import-1', uid: 'urn:uuid:bbb', name: { full: 'B' } }], + 'ab-1', + ); + expect(mockUpdateContact).toHaveBeenCalledWith('m', { name: { full: 'B' } }, undefined); + expect(result).toMatchObject({ imported: 0, updated: 1, failed: 0, failures: [] }); + }); }); describe('address books', () => { @@ -420,6 +453,8 @@ describe('contacts-store', () => { }); mockDeleteAddressBook.mockResolvedValue(undefined); await useContactsStore.getState().deleteAddressBook('ab-2'); + // A book that still holds cards is refused without it (Stalwart 1.0). + expect(mockDeleteAddressBook).toHaveBeenCalledWith('ab-2', undefined, { removeContents: true }); expect(useContactsStore.getState().addressBooks.map((b) => b.id)).toEqual(['ab-1']); expect(useContactsStore.getState().contacts.map((c) => c.id)).toEqual(['c1']); }); diff --git a/src/stores/contacts-store.ts b/src/stores/contacts-store.ts index 647a06c8..55bbef0b 100644 --- a/src/stores/contacts-store.ts +++ b/src/stores/contacts-store.ts @@ -100,10 +100,14 @@ export interface ContactsState { fetchContactMedia: (id: string) => Promise | null>; deleteContact: (id: string) => Promise; bulkDelete: (ids: string[]) => Promise; + /** + * Creates the cards in the book; one whose UID the account already has is + * updated in place instead (a re-import of an export). + */ importContacts: ( contacts: Partial[], addressBookId: string, - ) => Promise<{ imported: number; failed: number }>; + ) => Promise; addContactKeyword: (id: string, keyword: string) => Promise; removeContactKeyword: (id: string, keyword: string) => Promise; @@ -142,6 +146,31 @@ export interface ContactsState { reset: () => void; } +/** A card an import could not save, and the server's reason. */ +export interface ContactImportFailure { + name: string; + reason: string; +} + +export interface ContactImportResult { + imported: number; + /** Cards whose UID the account already had, updated in place. */ + updated: number; + failed: number; + failures: ContactImportFailure[]; +} + +/** + * The card a create was refused for because its UID is taken, as Stalwart + * names it (0.16 and 1.0): "Contact with UID urn:uuid:... already exists + * with id b." + */ +export function existingUidCardId(err: unknown): string | null { + if (!(err instanceof Error)) return null; + const m = /already exists with id ([^\s.]+)/i.exec(err.message); + return m ? m[1] : null; +} + function persistCategory(category: ContactCategory): void { void AsyncStorage.setItem(SELECTED_CATEGORY_STORAGE_KEY, JSON.stringify(category)).catch( (err) => console.warn('[contacts-store] persist category failed', err), @@ -548,27 +577,69 @@ export const useContactsStore = create()( importContacts: async (incoming, addressBookId) => { let imported = 0; - let failed = 0; + let updated = 0; + let refetch = false; + const failures: ContactImportFailure[] = []; const { originalId, accountId, book } = bookTarget(addressBookId); const { check } = generationGuard(); + // Stalwart keeps one card per UID in an account and refuses a second + // ("Contact with UID ... already exists"), so re-importing an export + // failed card by card. A card the account already has is updated. + const byUid = new Map(); + for (const c of get().contacts) { + // As the server compares them: "x" and "urn:uuid:x" are two UIDs. + if (c.uid && (c.isShared ? c.accountId : undefined) === accountId) byUid.set(c.uid, c); + } + const updateExisting = async (serverId: string, data: Partial) => { + // The card stays in its own book and keeps its UID. + const { uid: _uid, ...patch } = data; + await apiUpdateContact(serverId, patch, accountId); + check(); + const storeId = accountId ? namespaceId(accountId, serverId) : serverId; + if (!get().contacts.some((c) => c.id === storeId)) { + refetch = true; + return; + } + set({ contacts: get().contacts.map((c) => (c.id === storeId ? { ...c, ...patch } : c)) }); + }; for (const contact of incoming) { check(); - let created: ContactCard; + // Strip the temporary client-side id; the server assigns a real one. + const { id: _id, addressBookIds: _abIds, ...data } = contact; + const known = data.uid ? byUid.get(data.uid) : undefined; try { - // Strip the temporary client-side id; the server assigns a real one. - const { id: _id, addressBookIds: _abIds, ...data } = contact; - created = tagCreated(await apiCreateContact(data, originalId, accountId), book); + if (known) { + await updateExisting(known.originalId || known.id, data); + updated++; + continue; + } + let created: ContactCard; + try { + created = tagCreated(await apiCreateContact(data, originalId, accountId), book); + } catch (err) { + // A card with this UID the list does not hold (not loaded yet, + // or in another book): the server names it. + const existingId = existingUidCardId(err); + if (!existingId) throw err; + await updateExisting(existingId, data); + updated++; + continue; + } + check(); + set({ contacts: [...get().contacts, created] }); + imported++; } catch (err) { - failed++; + if (err instanceof ContactsAccountChangedError) throw err; console.warn('[contacts-store] import failed for one contact', err); - continue; + failures.push({ + name: getContactDisplayName(contact as ContactCard) || getContactPrimaryEmail(contact as ContactCard), + reason: err instanceof Error ? err.message : String(err), + }); } - check(); - set({ contacts: [...get().contacts, created] }); - imported++; } - if (imported > 0) requestDeviceSync(CONTACTS_AUTHORITY); - return { imported, failed }; + if (imported > 0 || updated > 0) requestDeviceSync(CONTACTS_AUTHORITY); + if (refetch) void get().fetchContacts(); + return { imported, updated, failed: failures.length, failures }; }, addContactKeyword: async (id, keyword) => { @@ -727,9 +798,10 @@ export const useContactsStore = create()( deleteAddressBook: async (id) => { const { check } = generationGuard(); const { originalId, accountId } = bookTarget(id); - await apiDeleteAddressBook(originalId, accountId); + // The confirmation names the cards that go with it. + await apiDeleteAddressBook(originalId, accountId, { removeContents: true }); check(); - // Stalwart destroys the cards filed in the book along with it, so + // The server destroys the cards filed in the book along with it, so // drop them from the cache instead of leaving orphans under "All". set({ addressBooks: get().addressBooks.filter((b) => b.id !== id),