mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: check ContactCard/set results, re-fetch created cards, assign UIDs and paginate contact queries (#644)
This commit is contained in:
@@ -1,8 +1,15 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
primaryAccounts: {} as Record<string, string>,
|
||||||
|
accounts: {} as Record<string, { name: string; isPersonal: boolean; accountCapabilities?: Record<string, unknown> }>,
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock('../jmap-client', () => ({
|
vi.mock('../jmap-client', () => ({
|
||||||
jmapClient: {
|
jmapClient: {
|
||||||
accountId: 'acc-1',
|
accountId: 'acc-1',
|
||||||
|
username: 'me@example.com',
|
||||||
|
get currentSession() { return session; },
|
||||||
request: vi.fn(),
|
request: vi.fn(),
|
||||||
getMaxObjectsInGet: () => 500,
|
getMaxObjectsInGet: () => 500,
|
||||||
},
|
},
|
||||||
@@ -11,13 +18,18 @@ vi.mock('../jmap-client', () => ({
|
|||||||
import { jmapClient } from '../jmap-client';
|
import { jmapClient } from '../jmap-client';
|
||||||
import {
|
import {
|
||||||
getAddressBooks,
|
getAddressBooks,
|
||||||
|
getAllAddressBooks,
|
||||||
|
getContactsAccountId,
|
||||||
|
getContactCapableAccountIds,
|
||||||
queryContacts,
|
queryContacts,
|
||||||
getContacts,
|
getContacts,
|
||||||
|
getAllContacts,
|
||||||
createContact,
|
createContact,
|
||||||
updateContact,
|
updateContact,
|
||||||
deleteContacts,
|
deleteContacts,
|
||||||
createAddressBook,
|
createAddressBook,
|
||||||
updateAddressBook,
|
updateAddressBook,
|
||||||
|
setDefaultAddressBook,
|
||||||
deleteAddressBook,
|
deleteAddressBook,
|
||||||
} from '../contacts';
|
} from '../contacts';
|
||||||
|
|
||||||
@@ -25,11 +37,31 @@ const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
session.primaryAccounts = {};
|
||||||
|
session.accounts = {};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('contacts operations', () => {
|
describe('contacts operations', () => {
|
||||||
|
describe('account resolution', () => {
|
||||||
|
it('prefers the contacts primary account over the mail account', () => {
|
||||||
|
expect(getContactsAccountId()).toBe('acc-1');
|
||||||
|
session.primaryAccounts = { 'urn:ietf:params:jmap:contacts': 'acc-contacts' };
|
||||||
|
expect(getContactsAccountId()).toBe('acc-contacts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists contacts-capable and non-personal accounts after the primary', () => {
|
||||||
|
session.accounts = {
|
||||||
|
'acc-1': { name: 'me', isPersonal: true },
|
||||||
|
'acc-team': { name: 'Team', isPersonal: false },
|
||||||
|
'acc-cal': { name: 'Cal', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:calendars': {} } },
|
||||||
|
'acc-ct': { name: 'Ct', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:contacts': {} } },
|
||||||
|
};
|
||||||
|
expect(getContactCapableAccountIds()).toEqual(['acc-1', 'acc-team', 'acc-ct']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('getAddressBooks', () => {
|
describe('getAddressBooks', () => {
|
||||||
it('should fetch address books', async () => {
|
it('should fetch address books with an explicit property list', async () => {
|
||||||
const books = [{ id: 'ab-1', name: 'Personal' }];
|
const books = [{ id: 'ab-1', name: 'Personal' }];
|
||||||
mockRequest.mockResolvedValue({
|
mockRequest.mockResolvedValue({
|
||||||
methodResponses: [['AddressBook/get', { list: books }, '0']],
|
methodResponses: [['AddressBook/get', { list: books }, '0']],
|
||||||
@@ -37,21 +69,64 @@ describe('contacts operations', () => {
|
|||||||
|
|
||||||
const result = await getAddressBooks();
|
const result = await getAddressBooks();
|
||||||
expect(result).toEqual(books);
|
expect(result).toEqual(books);
|
||||||
expect(mockRequest).toHaveBeenCalledWith(
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
[['AddressBook/get', { accountId: 'acc-1' }, '0']],
|
expect(call[0]).toBe('AddressBook/get');
|
||||||
expect.arrayContaining(['urn:ietf:params:jmap:contacts']),
|
expect(call[1].accountId).toBe('acc-1');
|
||||||
);
|
expect(call[1].properties).toEqual(expect.arrayContaining(['shareWith', 'myRights', 'isDefault']));
|
||||||
|
expect(mockRequest.mock.calls[0][1]).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:contacts']));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a method-level error', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['error', { type: 'serverFail', description: 'boom' }, '0']],
|
||||||
|
});
|
||||||
|
await expect(getAddressBooks()).rejects.toThrow('boom');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllAddressBooks', () => {
|
||||||
|
it('namespaces books from shared accounts and tags them', async () => {
|
||||||
|
session.accounts = {
|
||||||
|
'acc-1': { name: 'me', isPersonal: true },
|
||||||
|
'acc-team': { name: 'Team', isPersonal: false },
|
||||||
|
};
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Mine' }] }, '0']] })
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Shared' }] }, '0']] });
|
||||||
|
|
||||||
|
const books = await getAllAddressBooks();
|
||||||
|
expect(books).toHaveLength(2);
|
||||||
|
expect(books[0]).toMatchObject({ id: 'default', originalId: 'default', isShared: false });
|
||||||
|
expect(books[1]).toMatchObject({
|
||||||
|
id: 'acc-team:default', originalId: 'default', accountId: 'acc-team', accountName: 'Team', isShared: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps own books when a shared account fails', async () => {
|
||||||
|
session.accounts = {
|
||||||
|
'acc-1': { name: 'me', isPersonal: true },
|
||||||
|
'acc-team': { name: 'Team', isPersonal: false },
|
||||||
|
};
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['AddressBook/get', { list: [{ id: 'default', name: 'Mine' }] }, '0']] })
|
||||||
|
.mockRejectedValueOnce(new Error('forbidden'));
|
||||||
|
|
||||||
|
const books = await getAllAddressBooks();
|
||||||
|
expect(books.map((b) => b.id)).toEqual(['default']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('queryContacts', () => {
|
describe('queryContacts', () => {
|
||||||
it('should query contacts with text filter', async () => {
|
it('should query contacts with text filter', async () => {
|
||||||
mockRequest.mockResolvedValue({
|
mockRequest.mockResolvedValue({
|
||||||
methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'] }, '0']],
|
methodResponses: [['ContactCard/query', { ids: ['c1', 'c2'], total: 2 }, '0']],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await queryContacts({ text: 'john' });
|
const result = await queryContacts({ text: 'john' });
|
||||||
expect(result).toEqual(['c1', 'c2']);
|
expect(result).toEqual(['c1', 'c2']);
|
||||||
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
|
expect(call[1].filter).toEqual({ text: 'john' });
|
||||||
|
expect(call[1].position).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should omit filter when none provided', async () => {
|
it('should omit filter when none provided', async () => {
|
||||||
@@ -63,6 +138,28 @@ describe('contacts operations', () => {
|
|||||||
const call = mockRequest.mock.calls[0][0][0];
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
expect(call[1].filter).toBeUndefined();
|
expect(call[1].filter).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('walks position until every id is collected', async () => {
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['a', 'b'], total: 5 }, '0']] })
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c', 'd'], total: 5 }, '0']] })
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['e'], total: 5 }, '0']] });
|
||||||
|
|
||||||
|
const result = await queryContacts(undefined, 2);
|
||||||
|
expect(result).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(3);
|
||||||
|
expect(mockRequest.mock.calls[1][0][0][1].position).toBe(2);
|
||||||
|
expect(mockRequest.mock.calls[2][0][0][1].position).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops when the server returns a short page without a total', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['ContactCard/query', { ids: ['a'] }, '0']],
|
||||||
|
});
|
||||||
|
const result = await queryContacts(undefined, 2);
|
||||||
|
expect(result).toEqual(['a']);
|
||||||
|
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getContacts', () => {
|
describe('getContacts', () => {
|
||||||
@@ -77,21 +174,85 @@ describe('contacts operations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createContact', () => {
|
describe('getAllContacts', () => {
|
||||||
it('should create a contact in the specified address book', async () => {
|
it('namespaces cards and their book ids for shared accounts', async () => {
|
||||||
const created = { id: 'c-new', addressBookIds: { 'ab-1': true } };
|
session.accounts = {
|
||||||
mockRequest.mockResolvedValue({
|
'acc-1': { name: 'me', isPersonal: true },
|
||||||
methodResponses: [['ContactCard/set', { created: { 'new-contact': created } }, '0']],
|
'acc-team': { name: 'Team', isPersonal: false },
|
||||||
|
};
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c1'], total: 1 }, '0']] })
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c1', addressBookIds: { default: true } }] }, '0']] })
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['ContactCard/query', { ids: ['c9'], total: 1 }, '0']] })
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [{ id: 'c9', addressBookIds: { default: true } }] }, '0']] });
|
||||||
|
|
||||||
|
const cards = await getAllContacts();
|
||||||
|
expect(cards[0]).toEqual({ id: 'c1', addressBookIds: { default: true } });
|
||||||
|
expect(cards[1]).toMatchObject({
|
||||||
|
id: 'acc-team:c9',
|
||||||
|
originalId: 'c9',
|
||||||
|
addressBookIds: { 'acc-team:default': true },
|
||||||
|
accountId: 'acc-team',
|
||||||
|
isShared: true,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createContact', () => {
|
||||||
|
it('assigns a urn:uuid UID, then re-fetches the created card', async () => {
|
||||||
|
const full = {
|
||||||
|
id: 'c-new',
|
||||||
|
uid: 'urn:uuid:whatever',
|
||||||
|
addressBookIds: { 'ab-1': true },
|
||||||
|
name: { components: [{ kind: 'given', value: 'Jane' }] },
|
||||||
|
};
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [['ContactCard/get', { list: [full] }, '0']],
|
||||||
|
});
|
||||||
|
|
||||||
const result = await createContact(
|
const result = await createContact(
|
||||||
{ name: { components: [{ kind: 'given', value: 'Jane' }] } },
|
{ name: { components: [{ kind: 'given', value: 'Jane' }] } },
|
||||||
'ab-1',
|
'ab-1',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual(created);
|
expect(result).toEqual(full);
|
||||||
const call = mockRequest.mock.calls[0][0][0];
|
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
|
||||||
expect(call[1].create['new-contact'].addressBookIds).toEqual({ 'ab-1': true });
|
expect(create.addressBookIds).toEqual({ 'ab-1': true });
|
||||||
|
expect(create.uid).toMatch(/^urn:uuid:[0-9a-f-]{36}$/);
|
||||||
|
expect(mockRequest.mock.calls[1][0][0][0]).toBe('ContactCard/get');
|
||||||
|
expect(mockRequest.mock.calls[1][0][0][1].ids).toEqual(['c-new']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an existing uid and strips client-only fields', async () => {
|
||||||
|
mockRequest
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
methodResponses: [['ContactCard/set', { created: { 'new-contact': { id: 'c-new' } } }, '0']],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ methodResponses: [['ContactCard/get', { list: [] }, '0']] });
|
||||||
|
|
||||||
|
const result = await createContact(
|
||||||
|
{ uid: 'urn:uuid:keep', isShared: true, accountName: 'x', name: { full: 'A' } },
|
||||||
|
'ab-1',
|
||||||
|
);
|
||||||
|
const create = mockRequest.mock.calls[0][0][0][1].create['new-contact'];
|
||||||
|
expect(create.uid).toBe('urn:uuid:keep');
|
||||||
|
expect(create.isShared).toBeUndefined();
|
||||||
|
expect(create.accountName).toBeUndefined();
|
||||||
|
// Falls back to a client-side merge when the re-fetch comes back empty.
|
||||||
|
expect(result).toMatchObject({ id: 'c-new', uid: 'urn:uuid:keep', name: { full: 'A' }, addressBookIds: { 'ab-1': true } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws the server description when the card is rejected', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['ContactCard/set', {
|
||||||
|
notCreated: { 'new-contact': { type: 'invalidProperties', description: 'bad date', properties: ['anniversaries'] } },
|
||||||
|
}, '0']],
|
||||||
|
});
|
||||||
|
await expect(createContact({ name: { full: 'A' } }, 'ab-1')).rejects.toThrow('bad date (anniversaries)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,18 +267,46 @@ describe('contacts operations', () => {
|
|||||||
const call = mockRequest.mock.calls[0][0][0];
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
expect(call[1].update).toEqual({ c1: { kind: 'individual' } });
|
expect(call[1].update).toEqual({ c1: { kind: 'individual' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws when the server refuses the update', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['ContactCard/set', { notUpdated: { c1: { type: 'forbidden', description: 'read-only book' } } }, '0']],
|
||||||
|
});
|
||||||
|
await expect(updateContact('c1', { kind: 'org' })).rejects.toThrow('read-only book');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to the given account', async () => {
|
||||||
|
mockRequest.mockResolvedValue({ methodResponses: [['ContactCard/set', { updated: {} }, '0']] });
|
||||||
|
await updateContact('c1', { kind: 'org' }, 'acc-team');
|
||||||
|
expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('acc-team');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteContacts', () => {
|
describe('deleteContacts', () => {
|
||||||
it('should destroy contacts by ids', async () => {
|
it('should destroy contacts by ids and return the destroyed ones', async () => {
|
||||||
mockRequest.mockResolvedValue({
|
mockRequest.mockResolvedValue({
|
||||||
methodResponses: [['ContactCard/set', { destroyed: ['c1', 'c2'] }, '0']],
|
methodResponses: [['ContactCard/set', { destroyed: ['c1', 'c2'] }, '0']],
|
||||||
});
|
});
|
||||||
|
|
||||||
await deleteContacts(['c1', 'c2']);
|
const destroyed = await deleteContacts(['c1', 'c2']);
|
||||||
|
|
||||||
const call = mockRequest.mock.calls[0][0][0];
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
expect(call[1].destroy).toEqual(['c1', 'c2']);
|
expect(call[1].destroy).toEqual(['c1', 'c2']);
|
||||||
|
expect(destroyed).toEqual(['c1', 'c2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only the destroyed subset on partial failure', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['ContactCard/set', { destroyed: ['c1'], notDestroyed: { c2: { type: 'forbidden' } } }, '0']],
|
||||||
|
});
|
||||||
|
expect(await deleteContacts(['c1', 'c2'])).toEqual(['c1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when nothing was destroyed', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['ContactCard/set', { notDestroyed: { c1: { type: 'forbidden', description: 'nope' } } }, '0']],
|
||||||
|
});
|
||||||
|
await expect(deleteContacts(['c1'])).rejects.toThrow('nope');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,18 +332,36 @@ describe('contacts operations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('updateAddressBook', () => {
|
describe('updateAddressBook', () => {
|
||||||
it('forwards only settable properties', async () => {
|
it('forwards only settable properties and never isDefault', async () => {
|
||||||
mockRequest.mockResolvedValue({
|
mockRequest.mockResolvedValue({
|
||||||
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
|
methodResponses: [['AddressBook/set', { updated: { 'ab-1': null } }, '0']],
|
||||||
});
|
});
|
||||||
|
|
||||||
await updateAddressBook('ab-1', { name: 'Renamed', id: 'should-be-ignored' } as never);
|
await updateAddressBook('ab-1', { name: 'Renamed', isDefault: true, id: 'should-be-ignored' } as never);
|
||||||
|
|
||||||
const call = mockRequest.mock.calls[0][0][0];
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
|
expect(call[1].update).toEqual({ 'ab-1': { name: 'Renamed' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setDefaultAddressBook', () => {
|
||||||
|
it('uses onSuccessSetIsDefault', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['AddressBook/set', { updated: {} }, '0']],
|
||||||
|
});
|
||||||
|
await setDefaultAddressBook('ab-2');
|
||||||
|
const call = mockRequest.mock.calls[0][0][0];
|
||||||
|
expect(call[1]).toEqual({ accountId: 'acc-1', onSuccessSetIsDefault: 'ab-2' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws on a method error', async () => {
|
||||||
|
mockRequest.mockResolvedValue({
|
||||||
|
methodResponses: [['error', { type: 'invalidArguments', description: 'no such book' }, '0']],
|
||||||
|
});
|
||||||
|
await expect(setDefaultAddressBook('ab-x')).rejects.toThrow('no such book');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('deleteAddressBook', () => {
|
describe('deleteAddressBook', () => {
|
||||||
it('destroys the book by id', async () => {
|
it('destroys the book by id', async () => {
|
||||||
mockRequest.mockResolvedValue({
|
mockRequest.mockResolvedValue({
|
||||||
|
|||||||
+289
-60
@@ -1,9 +1,25 @@
|
|||||||
import { jmapClient } from './jmap-client';
|
import { jmapClient } from './jmap-client';
|
||||||
import { CAPABILITIES } from './types';
|
import { CAPABILITIES } from './types';
|
||||||
import type { ContactCard, AddressBook } from './types';
|
import type { ContactCard, AddressBook } from './types';
|
||||||
|
import { generateUUID } from '../lib/uuid';
|
||||||
|
|
||||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
|
const USING = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
|
||||||
|
|
||||||
|
// Stalwart's default property list for AddressBook/get omits shareWith, so
|
||||||
|
// existing shares would be invisible after a fresh login (webmail #257).
|
||||||
|
const ADDRESS_BOOK_PROPERTIES = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'sortOrder',
|
||||||
|
'isDefault',
|
||||||
|
'isSubscribed',
|
||||||
|
'shareWith',
|
||||||
|
'myRights',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SetError = { description?: string; type?: string; properties?: string[] };
|
||||||
|
|
||||||
function methodResult<T = any>(res: any, index = 0): T {
|
function methodResult<T = any>(res: any, index = 0): T {
|
||||||
const entry = res?.methodResponses?.[index];
|
const entry = res?.methodResponses?.[index];
|
||||||
if (!entry) throw new Error('JMAP: empty method response');
|
if (!entry) throw new Error('JMAP: empty method response');
|
||||||
@@ -14,40 +30,139 @@ function methodResult<T = any>(res: any, index = 0): T {
|
|||||||
return entry[1] as T;
|
return entry[1] as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAddressBooks(): Promise<AddressBook[]> {
|
function setErrorMessage(err: SetError | undefined, fallback: string): string {
|
||||||
const accountId = jmapClient.accountId;
|
if (!err) return fallback;
|
||||||
|
const detail = err.description || err.type || fallback;
|
||||||
|
return err.properties?.length ? `${detail} (${err.properties.join(', ')})` : detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primary account for the contacts capability (RFC 8620 `primaryAccounts`),
|
||||||
|
* falling back to the mail primary. Contacts can live in a different account
|
||||||
|
* than mail on some servers.
|
||||||
|
*/
|
||||||
|
export function getContactsAccountId(): string {
|
||||||
|
const id = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS];
|
||||||
|
return id || jmapClient.accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every account that may hold address books: the contacts primary first, then
|
||||||
|
* accounts that advertise the contacts capability or are non-personal (shared
|
||||||
|
* / group) accounts - Stalwart doesn't always advertise capabilities on group
|
||||||
|
* accounts even when they have contact resources. Mirrors the webmail's
|
||||||
|
* getContactCapableAccountIds().
|
||||||
|
*/
|
||||||
|
export function getContactCapableAccountIds(): string[] {
|
||||||
|
const primaryId = getContactsAccountId();
|
||||||
|
const accounts = jmapClient.currentSession?.accounts ?? {};
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const [id, account] of Object.entries(accounts)) {
|
||||||
|
if (id === primaryId) continue;
|
||||||
|
if (account.accountCapabilities?.[CAPABILITIES.CONTACTS] || account.isPersonal === false) {
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [primaryId, ...out];
|
||||||
|
}
|
||||||
|
|
||||||
|
function accountName(accountId: string): string {
|
||||||
|
return jmapClient.currentSession?.accounts?.[accountId]?.name || accountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Namespace an id from a non-primary account as `<accountId>:<id>`. */
|
||||||
|
export function namespaceId(accountId: string, id: string): string {
|
||||||
|
return `${accountId}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip client-only metadata before a card goes to the server. Shared-account
|
||||||
|
* cards carry namespaced ids and account tags that JMAP would reject as
|
||||||
|
* unknown properties.
|
||||||
|
*/
|
||||||
|
export function stripClientFields<T extends Partial<ContactCard>>(contact: T): Partial<ContactCard> {
|
||||||
|
const {
|
||||||
|
originalId: _oid, accountId: _aid, accountName: _an, isShared: _is,
|
||||||
|
...rest
|
||||||
|
} = contact as ContactCard;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAddressBooks(accountId?: string): Promise<AddressBook[]> {
|
||||||
|
const account = accountId || getContactsAccountId();
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['AddressBook/get', { accountId }, '0']],
|
[['AddressBook/get', { accountId: account, properties: ADDRESS_BOOK_PROPERTIES }, '0']],
|
||||||
USING,
|
USING,
|
||||||
);
|
);
|
||||||
return methodResult<{ list: AddressBook[] }>(res).list ?? [];
|
return methodResult<{ list: AddressBook[] }>(res).list ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryContacts(
|
/**
|
||||||
filter?: { text?: string; inAddressBook?: string },
|
* Address books across every contacts-capable account. Books from non-primary
|
||||||
limit = 1000,
|
* (shared / group) accounts get a namespaced id plus `accountId`, `accountName`
|
||||||
): Promise<string[]> {
|
* and `isShared`; a failing shared account never hides the user's own books.
|
||||||
// Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query).
|
*/
|
||||||
// Client sorts by display name after fetch.
|
export async function getAllAddressBooks(): Promise<AddressBook[]> {
|
||||||
const accountId = jmapClient.accountId;
|
const primaryId = getContactsAccountId();
|
||||||
const args: Record<string, unknown> = { accountId, limit };
|
const all: AddressBook[] = [];
|
||||||
if (filter && Object.keys(filter).length > 0) args.filter = filter;
|
for (const accountId of getContactCapableAccountIds()) {
|
||||||
const res = await jmapClient.request(
|
const isPrimary = accountId === primaryId;
|
||||||
[['ContactCard/query', args, '0']],
|
try {
|
||||||
USING,
|
const books = await getAddressBooks(accountId);
|
||||||
);
|
all.push(...books.map((book) => ({
|
||||||
return methodResult<{ ids: string[] }>(res).ids ?? [];
|
...book,
|
||||||
|
id: isPrimary ? book.id : namespaceId(accountId, book.id),
|
||||||
|
originalId: book.id,
|
||||||
|
accountId,
|
||||||
|
accountName: isPrimary ? (jmapClient.username || accountName(accountId)) : accountName(accountId),
|
||||||
|
isShared: !isPrimary,
|
||||||
|
})));
|
||||||
|
} catch (err) {
|
||||||
|
if (isPrimary) throw err;
|
||||||
|
console.warn(`[contacts] address books for shared account ${accountId} failed`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContacts(ids: string[]): Promise<ContactCard[]> {
|
export async function queryContacts(
|
||||||
|
filter?: { text?: string; inAddressBook?: string },
|
||||||
|
limit?: number,
|
||||||
|
accountId?: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
// Match webmail: no `sort` (unsupported by Stalwart for ContactCard/query).
|
||||||
|
// Client sorts by display name after fetch. The server may clamp `limit`
|
||||||
|
// below what we ask for, so walk `position` until the total is reached.
|
||||||
|
const account = accountId || getContactsAccountId();
|
||||||
|
const batchSize = limit ?? jmapClient.getMaxObjectsInGet();
|
||||||
|
const allIds: string[] = [];
|
||||||
|
let position = 0;
|
||||||
|
for (;;) {
|
||||||
|
const args: Record<string, unknown> = { accountId: account, position, limit: batchSize };
|
||||||
|
if (filter && Object.keys(filter).length > 0) args.filter = filter;
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[['ContactCard/query', args, '0']],
|
||||||
|
USING,
|
||||||
|
);
|
||||||
|
const result = methodResult<{ ids?: string[]; total?: number }>(res);
|
||||||
|
const ids = result.ids ?? [];
|
||||||
|
allIds.push(...ids);
|
||||||
|
const total = typeof result.total === 'number' ? result.total : -1;
|
||||||
|
if (ids.length === 0 || ids.length < batchSize || (total >= 0 && allIds.length >= total)) break;
|
||||||
|
position += ids.length;
|
||||||
|
}
|
||||||
|
return allIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContacts(ids: string[], accountId?: string): Promise<ContactCard[]> {
|
||||||
if (ids.length === 0) return [];
|
if (ids.length === 0) return [];
|
||||||
const accountId = jmapClient.accountId;
|
const account = accountId || getContactsAccountId();
|
||||||
const batchSize = jmapClient.getMaxObjectsInGet();
|
const batchSize = jmapClient.getMaxObjectsInGet();
|
||||||
const all: ContactCard[] = [];
|
const all: ContactCard[] = [];
|
||||||
for (let i = 0; i < ids.length; i += batchSize) {
|
for (let i = 0; i < ids.length; i += batchSize) {
|
||||||
const batch = ids.slice(i, i + batchSize);
|
const batch = ids.slice(i, i + batchSize);
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['ContactCard/get', { accountId, ids: batch }, '0']],
|
[['ContactCard/get', { accountId: account, ids: batch }, '0']],
|
||||||
USING,
|
USING,
|
||||||
);
|
);
|
||||||
const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
|
const list = methodResult<{ list: ContactCard[] }>(res).list ?? [];
|
||||||
@@ -56,93 +171,207 @@ export async function getContacts(ids: string[]): Promise<ContactCard[]> {
|
|||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tag a card fetched from `accountId` with the client-side account metadata. */
|
||||||
|
function tagContact(contact: ContactCard, accountId: string, isPrimary: boolean): ContactCard {
|
||||||
|
if (isPrimary) return contact;
|
||||||
|
const addressBookIds = contact.addressBookIds
|
||||||
|
? Object.fromEntries(
|
||||||
|
Object.entries(contact.addressBookIds).map(([bookId, v]) => [namespaceId(accountId, bookId), v]),
|
||||||
|
)
|
||||||
|
: contact.addressBookIds;
|
||||||
|
return {
|
||||||
|
...contact,
|
||||||
|
id: namespaceId(accountId, contact.id),
|
||||||
|
originalId: contact.id,
|
||||||
|
addressBookIds,
|
||||||
|
accountId,
|
||||||
|
accountName: accountName(accountId),
|
||||||
|
isShared: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact cards across every contacts-capable account, namespaced like
|
||||||
|
* getAllAddressBooks. A failing shared account is skipped; a failing primary
|
||||||
|
* account throws so the store can surface the error.
|
||||||
|
*/
|
||||||
|
export async function getAllContacts(): Promise<ContactCard[]> {
|
||||||
|
const primaryId = getContactsAccountId();
|
||||||
|
const all: ContactCard[] = [];
|
||||||
|
for (const accountId of getContactCapableAccountIds()) {
|
||||||
|
const isPrimary = accountId === primaryId;
|
||||||
|
try {
|
||||||
|
const ids = await queryContacts(undefined, undefined, accountId);
|
||||||
|
const cards = await getContacts(ids, accountId);
|
||||||
|
all.push(...cards.map((card) => tagContact(card, accountId, isPrimary)));
|
||||||
|
} catch (err) {
|
||||||
|
if (isPrimary) throw err;
|
||||||
|
console.warn(`[contacts] contacts for shared account ${accountId} failed`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContact(id: string, accountId?: string): Promise<ContactCard | null> {
|
||||||
|
const list = await getContacts([id], accountId);
|
||||||
|
return list[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createContact(
|
export async function createContact(
|
||||||
contact: Partial<ContactCard>,
|
contact: Partial<ContactCard>,
|
||||||
addressBookId: string,
|
addressBookId: string,
|
||||||
|
accountId?: string,
|
||||||
): Promise<ContactCard> {
|
): Promise<ContactCard> {
|
||||||
const accountId = jmapClient.accountId;
|
const account = accountId || getContactsAccountId();
|
||||||
|
const data = stripClientFields(contact);
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['ContactCard/set', {
|
[['ContactCard/set', {
|
||||||
accountId,
|
accountId: account,
|
||||||
create: {
|
create: {
|
||||||
'new-contact': { ...contact, addressBookIds: { [addressBookId]: true } },
|
'new-contact': {
|
||||||
|
...data,
|
||||||
|
// Stalwart stores the card without one if omitted (#644), which
|
||||||
|
// breaks group membership and CardDAV round-trips.
|
||||||
|
uid: data.uid || `urn:uuid:${generateUUID()}`,
|
||||||
|
addressBookIds: { [addressBookId]: true },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}, '0']],
|
}, '0']],
|
||||||
USING,
|
USING,
|
||||||
);
|
);
|
||||||
return res.methodResponses[0][1].created['new-contact'];
|
const result = methodResult<{
|
||||||
|
created?: Record<string, Partial<ContactCard>>;
|
||||||
|
notCreated?: Record<string, SetError>;
|
||||||
|
}>(res);
|
||||||
|
const created = result.created?.['new-contact'];
|
||||||
|
if (!created?.id) {
|
||||||
|
throw new Error(setErrorMessage(result.notCreated?.['new-contact'], 'Failed to create contact'));
|
||||||
|
}
|
||||||
|
// The set response only carries server-set properties (id, created,
|
||||||
|
// updated...). Re-fetch the full card so the store appends a complete row
|
||||||
|
// instead of an "Unnamed" stub; fall back to a client-side merge.
|
||||||
|
try {
|
||||||
|
const full = await getContact(created.id, account);
|
||||||
|
if (full) return full;
|
||||||
|
} catch {
|
||||||
|
// fall through to the merge below
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
uid: data.uid,
|
||||||
|
...created,
|
||||||
|
id: created.id,
|
||||||
|
addressBookIds: { [addressBookId]: true },
|
||||||
|
} as ContactCard;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContact(
|
export async function updateContact(
|
||||||
id: string,
|
id: string,
|
||||||
changes: Partial<ContactCard>,
|
changes: Partial<ContactCard>,
|
||||||
|
accountId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const accountId = jmapClient.accountId;
|
const account = accountId || getContactsAccountId();
|
||||||
await jmapClient.request(
|
|
||||||
[['ContactCard/set', { accountId, update: { [id]: changes } }, '0']],
|
|
||||||
USING,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteContacts(ids: string[]): Promise<void> {
|
|
||||||
const accountId = jmapClient.accountId;
|
|
||||||
await jmapClient.request(
|
|
||||||
[['ContactCard/set', { accountId, destroy: ids }, '0']],
|
|
||||||
USING,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAddressBook(name: string): Promise<AddressBook> {
|
|
||||||
const accountId = jmapClient.accountId;
|
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['AddressBook/set', { accountId, create: { 'new-book': { name } } }, '0']],
|
[['ContactCard/set', { accountId: account, update: { [id]: stripClientFields(changes) } }, '0']],
|
||||||
|
USING,
|
||||||
|
);
|
||||||
|
const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
|
||||||
|
const err = result.notUpdated?.[id];
|
||||||
|
if (err) throw new Error(setErrorMessage(err, 'Failed to update contact'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy cards. Returns the ids the server actually destroyed; throws when
|
||||||
|
* nothing could be destroyed (with the first server description) so a
|
||||||
|
* single-card delete surfaces the reason.
|
||||||
|
*/
|
||||||
|
export async function deleteContacts(ids: string[], accountId?: string): Promise<string[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const account = accountId || getContactsAccountId();
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[['ContactCard/set', { accountId: account, destroy: ids }, '0']],
|
||||||
|
USING,
|
||||||
|
);
|
||||||
|
const result = methodResult<{
|
||||||
|
destroyed?: string[];
|
||||||
|
notDestroyed?: Record<string, SetError>;
|
||||||
|
}>(res);
|
||||||
|
const failed = result.notDestroyed ?? {};
|
||||||
|
// Servers that omit `destroyed` still honoured everything not listed as failed.
|
||||||
|
const destroyed = result.destroyed ?? ids.filter((id) => !(id in failed));
|
||||||
|
if (destroyed.length === 0) {
|
||||||
|
const firstFailure = Object.values(failed)[0];
|
||||||
|
throw new Error(setErrorMessage(firstFailure, 'Failed to delete contact'));
|
||||||
|
}
|
||||||
|
return destroyed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAddressBook(name: string, accountId?: string): Promise<AddressBook> {
|
||||||
|
const account = accountId || getContactsAccountId();
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[['AddressBook/set', { accountId: account, create: { 'new-book': { name } } }, '0']],
|
||||||
USING,
|
USING,
|
||||||
);
|
);
|
||||||
const result = methodResult<{
|
const result = methodResult<{
|
||||||
created?: Record<string, AddressBook>;
|
created?: Record<string, AddressBook>;
|
||||||
notCreated?: Record<string, { description?: string; type?: string }>;
|
notCreated?: Record<string, SetError>;
|
||||||
}>(res);
|
}>(res);
|
||||||
const created = result.created?.['new-book'];
|
const created = result.created?.['new-book'];
|
||||||
if (created) return { ...created, name };
|
if (created) return { ...created, name };
|
||||||
const err = result.notCreated?.['new-book'];
|
throw new Error(setErrorMessage(result.notCreated?.['new-book'], 'Failed to create address book'));
|
||||||
throw new Error(err?.description || err?.type || 'Failed to create address book');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateAddressBook(
|
export async function updateAddressBook(
|
||||||
id: string,
|
id: string,
|
||||||
updates: Partial<AddressBook>,
|
updates: Partial<AddressBook>,
|
||||||
|
accountId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const accountId = jmapClient.accountId;
|
const account = accountId || getContactsAccountId();
|
||||||
// Only forward server-settable properties.
|
// Only forward server-settable properties. `isDefault` is deliberately not
|
||||||
const { name, description, sortOrder, isDefault } = updates as Record<string, unknown>;
|
// among them: it is read-only in AddressBook/set and including it fails the
|
||||||
|
// whole update with invalidProperties - use setDefaultAddressBook instead.
|
||||||
|
const { name, description, sortOrder } = updates as Record<string, unknown>;
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
if (name !== undefined) patch.name = name;
|
if (name !== undefined) patch.name = name;
|
||||||
if (description !== undefined) patch.description = description;
|
if (description !== undefined) patch.description = description;
|
||||||
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
if (sortOrder !== undefined) patch.sortOrder = sortOrder;
|
||||||
if (isDefault !== undefined) patch.isDefault = isDefault;
|
|
||||||
|
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['AddressBook/set', { accountId, update: { [id]: patch } }, '0']],
|
[['AddressBook/set', { accountId: account, update: { [id]: patch } }, '0']],
|
||||||
USING,
|
USING,
|
||||||
);
|
);
|
||||||
const result = methodResult<{ notUpdated?: Record<string, { description?: string; type?: string }> }>(res);
|
const result = methodResult<{ notUpdated?: Record<string, SetError> }>(res);
|
||||||
const err = result.notUpdated?.[id];
|
const err = result.notUpdated?.[id];
|
||||||
if (err) throw new Error(err.description || err.type || 'Failed to update address book');
|
if (err) throw new Error(setErrorMessage(err, 'Failed to update address book'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteAddressBook(id: string): Promise<void> {
|
/**
|
||||||
const accountId = jmapClient.accountId;
|
* Mark an address book as the account default. `isDefault` is read-only in
|
||||||
|
* AddressBook/set - the default is changed via the `onSuccessSetIsDefault`
|
||||||
|
* request argument instead (same shape as Calendar/set).
|
||||||
|
*/
|
||||||
|
export async function setDefaultAddressBook(id: string, accountId?: string): Promise<void> {
|
||||||
|
const account = accountId || getContactsAccountId();
|
||||||
const res = await jmapClient.request(
|
const res = await jmapClient.request(
|
||||||
[['AddressBook/set', { accountId, destroy: [id] }, '0']],
|
[['AddressBook/set', { accountId: account, onSuccessSetIsDefault: id }, '0']],
|
||||||
USING,
|
USING,
|
||||||
);
|
);
|
||||||
const result = methodResult<{ notDestroyed?: Record<string, { description?: string; type?: string }> }>(res);
|
methodResult(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAddressBook(id: string, accountId?: string): Promise<void> {
|
||||||
|
const account = accountId || getContactsAccountId();
|
||||||
|
const res = await jmapClient.request(
|
||||||
|
[['AddressBook/set', { accountId: account, destroy: [id] }, '0']],
|
||||||
|
USING,
|
||||||
|
);
|
||||||
|
const result = methodResult<{ notDestroyed?: Record<string, SetError> }>(res);
|
||||||
const err = result.notDestroyed?.[id];
|
const err = result.notDestroyed?.[id];
|
||||||
if (err) throw new Error(err.description || err.type || 'Failed to delete address book');
|
if (err) throw new Error(setErrorMessage(err, 'Failed to delete address book'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch all contact cards that live in a specific address book. */
|
/** Fetch all contact cards that live in a specific address book. */
|
||||||
export async function getContactsInBook(addressBookId: string): Promise<ContactCard[]> {
|
export async function getContactsInBook(addressBookId: string, accountId?: string): Promise<ContactCard[]> {
|
||||||
const ids = await queryContacts({ inAddressBook: addressBookId });
|
const ids = await queryContacts({ inAddressBook: addressBookId }, undefined, accountId);
|
||||||
return getContacts(ids);
|
return getContacts(ids, accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getContactDisplayName,
|
||||||
|
getContactPhotoUri,
|
||||||
|
normalizeContactPhotoUri,
|
||||||
|
partialDateToString,
|
||||||
|
stringToPartialDate,
|
||||||
|
} from '../contact-utils';
|
||||||
|
import type { ContactCard } from '../../api/types';
|
||||||
|
|
||||||
|
describe('getContactDisplayName', () => {
|
||||||
|
it('joins given + surname only, like the webmail', () => {
|
||||||
|
const c: ContactCard = {
|
||||||
|
id: 'c',
|
||||||
|
addressBookIds: {},
|
||||||
|
name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'middle', value: 'Q' }, { kind: 'surname', value: 'Doe' }] },
|
||||||
|
};
|
||||||
|
expect(getContactDisplayName(c)).toBe('Jane Doe');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeContactPhotoUri (#307)', () => {
|
||||||
|
it('adds a media type to data URIs that lack one', () => {
|
||||||
|
expect(normalizeContactPhotoUri('data:base64,AAAA')).toBe('data:image/jpeg;base64,AAAA');
|
||||||
|
expect(normalizeContactPhotoUri('data:;base64,AAAA', 'image/png')).toBe('data:image/png;base64,AAAA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves well-formed URIs alone', () => {
|
||||||
|
expect(normalizeContactPhotoUri('data:image/png;base64,AAAA')).toBe('data:image/png;base64,AAAA');
|
||||||
|
expect(normalizeContactPhotoUri('https://x/y.jpg')).toBe('https://x/y.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is applied by getContactPhotoUri', () => {
|
||||||
|
const c: ContactCard = { id: 'c', addressBookIds: {}, media: { m: { kind: 'photo', uri: 'data:base64,AAAA' } } };
|
||||||
|
expect(getContactPhotoUri(c)).toBe('data:image/jpeg;base64,AAAA');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('partialDateToString', () => {
|
||||||
|
it.each([
|
||||||
|
[{ year: 1990, month: 5, day: 4 }, '1990-05-04'],
|
||||||
|
[{ year: 1990, month: 5 }, '1990-05'],
|
||||||
|
[{ year: 1990 }, '1990'],
|
||||||
|
[{ month: 5, day: 4 }, '--05-04'],
|
||||||
|
[{ month: 5 }, '--05'],
|
||||||
|
[{ day: 4 }, '---04'],
|
||||||
|
])('is lossless for %j', (pd, expected) => {
|
||||||
|
expect(partialDateToString(pd)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles timestamps and strings', () => {
|
||||||
|
expect(partialDateToString({ '@type': 'Timestamp', utc: '2020-01-02T03:04:05Z' })).toBe('2020-01-02');
|
||||||
|
expect(partialDateToString('1999-12-31')).toBe('1999-12-31');
|
||||||
|
expect(partialDateToString(undefined)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stringToPartialDate', () => {
|
||||||
|
it.each([
|
||||||
|
['1990-05-04', { year: 1990, month: 5, day: 4 }],
|
||||||
|
['19900504', { year: 1990, month: 5, day: 4 }],
|
||||||
|
['1990-05-04T00:00:00Z', { year: 1990, month: 5, day: 4 }],
|
||||||
|
['1990-05', { year: 1990, month: 5 }],
|
||||||
|
['1990', { year: 1990 }],
|
||||||
|
['--05-04', { month: 5, day: 4 }],
|
||||||
|
['--0504', { month: 5, day: 4 }],
|
||||||
|
['--05', { month: 5 }],
|
||||||
|
['---04', { day: 4 }],
|
||||||
|
])('parses %s', (input, expected) => {
|
||||||
|
expect(stringToPartialDate(input)).toEqual(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['May 5', '1990-13-01', 'circa 1800', '', ' '])('rejects %s', (input) => {
|
||||||
|
expect(stringToPartialDate(input)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through partialDateToString', () => {
|
||||||
|
for (const s of ['1990-05-04', '1990-05', '1990', '--05-04', '--05', '---04']) {
|
||||||
|
expect(partialDateToString(stringToPartialDate(s)!)).toBe(s);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,11 +7,13 @@ import type {
|
|||||||
|
|
||||||
export function getContactDisplayName(contact: ContactCard): string {
|
export function getContactDisplayName(contact: ContactCard): string {
|
||||||
if (contact.name) {
|
if (contact.name) {
|
||||||
|
// given + surname only, like the webmail: keeps sort order, dedupe keys
|
||||||
|
// and initials identical across clients (the middle name is shown by the
|
||||||
|
// detail screen, not by the list).
|
||||||
if (contact.name.components && contact.name.components.length > 0) {
|
if (contact.name.components && contact.name.components.length > 0) {
|
||||||
const given = contact.name.components.find((c) => c.kind === 'given')?.value || '';
|
const given = contact.name.components.find((c) => c.kind === 'given')?.value || '';
|
||||||
const surname = contact.name.components.find((c) => c.kind === 'surname')?.value || '';
|
const surname = contact.name.components.find((c) => c.kind === 'surname')?.value || '';
|
||||||
const middle = contact.name.components.find((c) => c.kind === 'middle')?.value || '';
|
const full = [given, surname].filter(Boolean).join(' ');
|
||||||
const full = [given, middle, surname].filter(Boolean).join(' ');
|
|
||||||
if (full) return full;
|
if (full) return full;
|
||||||
}
|
}
|
||||||
if (contact.name.full) return contact.name.full;
|
if (contact.name.full) return contact.name.full;
|
||||||
@@ -68,14 +70,92 @@ export function getContactInitials(contact: ContactCard): string {
|
|||||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some JMAP servers (notably Stalwart, see webmail #307) emit photo data URIs
|
||||||
|
// without a mediatype, like `data:base64,...` or `data:;base64,...`. Per
|
||||||
|
// RFC 2397 the missing/empty mediatype defaults to `text/plain`, so RN's
|
||||||
|
// Image cannot decode the bytes. Rewrite to include a mediatype.
|
||||||
|
export function normalizeContactPhotoUri(uri: string, mediaType?: string): string {
|
||||||
|
const mime = mediaType && mediaType.includes('/') ? mediaType : 'image/jpeg';
|
||||||
|
if (uri.startsWith('data:base64,')) {
|
||||||
|
return `data:${mime};base64,${uri.slice('data:base64,'.length)}`;
|
||||||
|
}
|
||||||
|
if (uri.startsWith('data:;base64,')) {
|
||||||
|
return `data:${mime};base64,${uri.slice('data:;base64,'.length)}`;
|
||||||
|
}
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
export function getContactPhotoUri(contact: ContactCard): string | undefined {
|
export function getContactPhotoUri(contact: ContactCard): string | undefined {
|
||||||
if (!contact.media) return undefined;
|
if (!contact.media) return undefined;
|
||||||
for (const media of Object.values(contact.media)) {
|
for (const media of Object.values(contact.media)) {
|
||||||
if (media.kind === 'photo' && media.uri) return media.uri;
|
if (media.kind === 'photo' && media.uri) {
|
||||||
|
return normalizeContactPhotoUri(media.uri, media.mediaType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lossless PartialDate → input string: `1990-05-04`, `1990-05`, `1990`,
|
||||||
|
* `--05-04`, `--05`, `---04`. Never pads a missing month/day with `01` (that
|
||||||
|
* used to rewrite a year-only birthday to Jan 1 on the next save).
|
||||||
|
*/
|
||||||
|
export function partialDateToString(d: AnniversaryDate | undefined): string {
|
||||||
|
if (!d) return '';
|
||||||
|
if (typeof d === 'string') return d;
|
||||||
|
if (typeof d === 'object') {
|
||||||
|
if ('@type' in d && d['@type'] === 'Timestamp') {
|
||||||
|
return typeof d.utc === 'string' ? d.utc.split('T')[0] : '';
|
||||||
|
}
|
||||||
|
const pd = d as PartialDate;
|
||||||
|
const yr = pd.year ? String(pd.year).padStart(4, '0') : '';
|
||||||
|
const mo = pd.month ? String(pd.month).padStart(2, '0') : '';
|
||||||
|
const da = pd.day ? String(pd.day).padStart(2, '0') : '';
|
||||||
|
if (yr && mo && da) return `${yr}-${mo}-${da}`;
|
||||||
|
if (yr && mo) return `${yr}-${mo}`;
|
||||||
|
if (yr) return yr;
|
||||||
|
if (mo && da) return `--${mo}-${da}`;
|
||||||
|
if (mo) return `--${mo}`;
|
||||||
|
if (da) return `---${da}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a typed date into an RFC 9553 PartialDate. Accepts the extended and
|
||||||
|
* basic vCard forms (`YYYY-MM-DD`, `YYYYMMDD`, `YYYY-MM`, `YYYY`, `--MM-DD`,
|
||||||
|
* `--MMDD`, `--MM`, `---DD`) and an optional trailing time part, which is
|
||||||
|
* dropped. Returns null for anything else - Stalwart rejects string dates, so
|
||||||
|
* free text must never reach the server.
|
||||||
|
*/
|
||||||
|
export function stringToPartialDate(s: string): PartialDate | null {
|
||||||
|
const datePart = s.trim().split(/[T ]/)[0];
|
||||||
|
if (!datePart) return null;
|
||||||
|
const mk = (year?: number, month?: number, day?: number): PartialDate | null => {
|
||||||
|
if (month !== undefined && (month < 1 || month > 12)) return null;
|
||||||
|
if (day !== undefined && (day < 1 || day > 31)) return null;
|
||||||
|
const pd: PartialDate = {};
|
||||||
|
if (year !== undefined) pd.year = year;
|
||||||
|
if (month !== undefined) pd.month = month;
|
||||||
|
if (day !== undefined) pd.day = day;
|
||||||
|
return Object.keys(pd).length > 0 ? pd : null;
|
||||||
|
};
|
||||||
|
const n = (v: string | undefined) => (v === undefined ? undefined : parseInt(v, 10));
|
||||||
|
let m = datePart.match(/^---(\d{2})$/);
|
||||||
|
if (m) return mk(undefined, undefined, n(m[1]));
|
||||||
|
m = datePart.match(/^--(\d{2})-?(\d{2})$/);
|
||||||
|
if (m) return mk(undefined, n(m[1]), n(m[2]));
|
||||||
|
m = datePart.match(/^--(\d{2})$/);
|
||||||
|
if (m) return mk(undefined, n(m[1]));
|
||||||
|
m = datePart.match(/^(\d{4})-?(\d{2})-?(\d{2})$/);
|
||||||
|
if (m) return mk(n(m[1]), n(m[2]), n(m[3]));
|
||||||
|
m = datePart.match(/^(\d{4})-(\d{2})$/);
|
||||||
|
if (m) return mk(n(m[1]), n(m[2]));
|
||||||
|
m = datePart.match(/^(\d{4})$/);
|
||||||
|
if (m) return mk(n(m[1]));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const MONTH_NAMES_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const MONTH_NAMES_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
export function formatPartialDate(dateInput: AnniversaryDate | string | undefined): string {
|
export function formatPartialDate(dateInput: AnniversaryDate | string | undefined): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user