mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +00:00
fix: save contacts with calendar or scheduling links and re-import exported vCards by UID
This commit is contained in:
+3
-1
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+19
-16
@@ -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<Co
|
||||
USING,
|
||||
);
|
||||
const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
|
||||
all.push(...list);
|
||||
all.push(...list.map(contactFromWire));
|
||||
}
|
||||
return all;
|
||||
}
|
||||
@@ -238,12 +231,15 @@ export async function createContact(
|
||||
): Promise<ContactCard> {
|
||||
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<void> {
|
||||
const account = accountId || getContactsAccountId();
|
||||
const patch = { ...stripClientFields(changes) } as Record<string, unknown>;
|
||||
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<void> {
|
||||
export async function deleteAddressBook(
|
||||
id: string,
|
||||
accountId?: string,
|
||||
options?: { removeContents?: boolean },
|
||||
): Promise<void> {
|
||||
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<string, SetError> }>(res);
|
||||
|
||||
@@ -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<Map<number, string>>(new Map());
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const [result, setResult] = React.useState<{ imported: number; failed: number } | null>(null);
|
||||
const [result, setResult] = React.useState<ContactImportResult | null>(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({
|
||||
<Text style={styles.successText}>
|
||||
{t('contacts.import.success', '{count, plural, one {1 contact imported} other {# contacts imported}}', { count: result.imported })}
|
||||
</Text>
|
||||
{result.updated > 0 && (
|
||||
<Text style={styles.successText}>
|
||||
{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 },
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{result.failed > 0 && (
|
||||
<Text style={[styles.successText, { color: c.error }]}>
|
||||
{t(
|
||||
@@ -201,6 +210,18 @@ export default function ContactImportSheet({
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{result.failures.length > 0 && (
|
||||
<ScrollView style={styles.failureList} contentContainerStyle={{ gap: spacing.xs }}>
|
||||
{result.failures.map((f, i) => (
|
||||
<Text key={i} style={styles.failureText}>
|
||||
<Text style={styles.failureName}>
|
||||
{f.name || t('contacts.import.unnamed', 'Unnamed contact')}
|
||||
</Text>
|
||||
{': '}{f.reason}
|
||||
</Text>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onPress={onClose}>{t('common.done', 'Done')}</Button>
|
||||
</View>
|
||||
) : 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',
|
||||
|
||||
@@ -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<string, Record<string, unknown>>)[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<string, { place?: Record<string, unknown> }>)[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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, { kind?: string; uri?: string } | null>;
|
||||
schedulingAddresses?: Record<string, { uri?: string } | null>;
|
||||
}
|
||||
|
||||
/** vCard imports store flat fields; RFC 9553 wants `components` and `full`. */
|
||||
function addressToWire(address: ContactAddress): Record<string, unknown> {
|
||||
const { street: _s, locality: _l, region: _r, postcode: _p, country: _c, fullAddress, ...rest } = address;
|
||||
const out: Record<string, unknown> = { ...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<string, unknown> {
|
||||
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<ContactCard>, mode: Mode): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
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<T extends ContactCard>(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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -100,10 +100,14 @@ export interface ContactsState {
|
||||
fetchContactMedia: (id: string) => Promise<Pick<ContactCard, 'media'> | null>;
|
||||
deleteContact: (id: string) => Promise<void>;
|
||||
bulkDelete: (ids: string[]) => Promise<void>;
|
||||
/**
|
||||
* 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<ContactCard>[],
|
||||
addressBookId: string,
|
||||
) => Promise<{ imported: number; failed: number }>;
|
||||
) => Promise<ContactImportResult>;
|
||||
|
||||
addContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
removeContactKeyword: (id: string, keyword: string) => Promise<void>;
|
||||
@@ -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<ContactsState>()(
|
||||
|
||||
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<string, ContactCard>();
|
||||
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<ContactCard>) => {
|
||||
// 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<ContactsState>()(
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user