mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: always write the contact's full name so vCards carry FN #430
`name.full` feeds the vCard FN, which is mandatory; strict CardDAV clients such as Apple Contacts drop cards without it. The app left it out or let it go stale on several write paths: - the contact form (and "Add to contacts", which goes through it) only sent `full` when the optional display-name field was filled in - editing a card put its stored `full` into that field, so renaming the person wrote the old name back as FN - vCard import dropped FN once it had split it into components Port the webmail's fix (a210b22e): the form derives `full` from the name components in display order unless a display name of its own is set, and the edit form only shows `full` when it differs from what would be derived, so a derived one is recomputed on every save. Import keeps FN verbatim whether it comes before or after N, and a card with N but no FN gets a derived `full` too.
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
deriveFullName,
|
||||||
getContactDisplayName,
|
getContactDisplayName,
|
||||||
|
getCustomFullName,
|
||||||
getContactPhotoUri,
|
getContactPhotoUri,
|
||||||
normalizeContactPhotoUri,
|
normalizeContactPhotoUri,
|
||||||
partialDateToString,
|
partialDateToString,
|
||||||
@@ -80,3 +82,43 @@ describe('stringToPartialDate', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('name.full for the vCard FN (#430)', () => {
|
||||||
|
const person = (full: string | undefined, components: Array<{ kind: string; value: string }>): ContactCard => ({
|
||||||
|
id: 'c',
|
||||||
|
addressBookIds: {},
|
||||||
|
name: { components, isOrdered: true, ...(full !== undefined ? { full } : {}) },
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins the name components in display order, like the webmail form', () => {
|
||||||
|
expect(deriveFullName([
|
||||||
|
{ kind: 'surname', value: 'Doe' },
|
||||||
|
{ kind: 'generation', value: 'Jr.' },
|
||||||
|
{ kind: 'given', value: ' John ' },
|
||||||
|
{ kind: 'title', value: 'Dr.' },
|
||||||
|
{ kind: 'given2', value: 'Q' },
|
||||||
|
])).toBe('Dr. John Q Doe Jr.');
|
||||||
|
// Legacy vCard-style kinds count too.
|
||||||
|
expect(deriveFullName([{ kind: 'prefix', value: 'Ms.' }, { kind: 'given', value: 'Ann' }])).toBe('Ms. Ann');
|
||||||
|
expect(deriveFullName(undefined)).toBe('');
|
||||||
|
expect(deriveFullName([])).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a full that repeats the components as derived, so an edit cannot leave it stale', () => {
|
||||||
|
const card = person('John Doe', [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }]);
|
||||||
|
expect(getCustomFullName(card)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a display name of its own', () => {
|
||||||
|
const card = person('Johnny D', [{ kind: 'given', value: 'John' }, { kind: 'surname', value: 'Doe' }]);
|
||||||
|
expect(getCustomFullName(card)).toBe('Johnny D');
|
||||||
|
expect(getCustomFullName(person('Solo', []))).toBe('Solo');
|
||||||
|
expect(getCustomFullName(person(undefined, [{ kind: 'given', value: 'A' }]))).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an organization card whose full is the organization name as derived', () => {
|
||||||
|
const org = person('Acme Corp', []);
|
||||||
|
expect(getCustomFullName(org, 'Acme Corp')).toBe('');
|
||||||
|
expect(getCustomFullName(org, 'Acme Inc')).toBe('Acme Corp');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ import {
|
|||||||
import type { ContactCard } from '../../api/types';
|
import type { ContactCard } from '../../api/types';
|
||||||
|
|
||||||
describe('parseVCard', () => {
|
describe('parseVCard', () => {
|
||||||
|
it('keeps FN as name.full so the written card carries the mandatory FN (#430)', () => {
|
||||||
|
const fnOnly = parseVCard('BEGIN:VCARD\r\nVERSION:3.0\r\nFN:John Doe\r\nEND:VCARD');
|
||||||
|
expect(fnOnly[0].name?.full).toBe('John Doe');
|
||||||
|
|
||||||
|
// FN before N: N replaces the components but must keep full.
|
||||||
|
const fnFirst = parseVCard('BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Dr. John Doe Jr.\r\nN:Doe;John;;;\r\nEND:VCARD');
|
||||||
|
expect(fnFirst[0].name?.full).toBe('Dr. John Doe Jr.');
|
||||||
|
|
||||||
|
// N before FN: FN fills full on the existing name.
|
||||||
|
const nFirst = parseVCard('BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;John;;;\r\nFN:Dr. John Doe Jr.\r\nEND:VCARD');
|
||||||
|
expect(nFirst[0].name?.full).toBe('Dr. John Doe Jr.');
|
||||||
|
expect(nFirst[0].name?.components?.find((c) => c.kind === 'given')?.value).toBe('John');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives name.full from N when a card has no FN (#430)', () => {
|
||||||
|
const [card] = parseVCard('BEGIN:VCARD\r\nVERSION:3.0\r\nN:Doe;John;Q;Dr.;Jr.\r\nEND:VCARD');
|
||||||
|
expect(card.name?.full).toBe('Dr. John Q Doe Jr.');
|
||||||
|
});
|
||||||
|
|
||||||
it('parses a basic 3.0 card with name, email, and phone', () => {
|
it('parses a basic 3.0 card with name, email, and phone', () => {
|
||||||
const vcf = [
|
const vcf = [
|
||||||
'BEGIN:VCARD',
|
'BEGIN:VCARD',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ContactCard,
|
ContactCard,
|
||||||
AnniversaryDate,
|
AnniversaryDate,
|
||||||
|
NameComponent,
|
||||||
PartialDate,
|
PartialDate,
|
||||||
Timestamp,
|
Timestamp,
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
@@ -59,6 +60,39 @@ export function getPrimaryTitle(contact: ContactCard): string {
|
|||||||
return title?.name || '';
|
return title?.name || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `name.full` derived from the name components in display order (prefix,
|
||||||
|
* given, middle, surname, suffix), as the webmail's form writes it. Every
|
||||||
|
* write should carry `full`: the vCard FN is built from it, FN is mandatory
|
||||||
|
* (RFC 6350 §6.2.1), and strict CardDAV clients such as Apple Contacts drop
|
||||||
|
* cards without it (#430).
|
||||||
|
*/
|
||||||
|
export function deriveFullName(components: readonly NameComponent[] | undefined): string {
|
||||||
|
const find = (...kinds: string[]) =>
|
||||||
|
components?.find((c) => kinds.includes(c.kind))?.value?.trim() || '';
|
||||||
|
return [
|
||||||
|
find('title', 'prefix'),
|
||||||
|
find('given'),
|
||||||
|
find('given2', 'additional', 'middle'),
|
||||||
|
find('surname'),
|
||||||
|
find('generation', 'suffix'),
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's `name.full` when it is a display name of its own. Empty when it
|
||||||
|
* only repeats what `deriveFullName` (or, on an organization card, the
|
||||||
|
* organization name) gives, so an edit form re-derives it from the edited
|
||||||
|
* fields on save instead of writing back a stale copy (#430).
|
||||||
|
*/
|
||||||
|
export function getCustomFullName(contact: ContactCard, orgName?: string): string {
|
||||||
|
const full = contact.name?.full?.trim() || '';
|
||||||
|
if (!full) return '';
|
||||||
|
if (full === deriveFullName(contact.name?.components)) return '';
|
||||||
|
if (orgName && full === orgName.trim()) return '';
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
export function getContactInitials(contact: ContactCard): string {
|
export function getContactInitials(contact: ContactCard): string {
|
||||||
const name = getContactDisplayName(contact).trim();
|
const name = getContactDisplayName(contact).trim();
|
||||||
if (!name) return '?';
|
if (!name) return '?';
|
||||||
|
|||||||
+15
-2
@@ -6,6 +6,7 @@ import type {
|
|||||||
PartialDate,
|
PartialDate,
|
||||||
} from '../api/types';
|
} from '../api/types';
|
||||||
import { generateUUID } from './uuid';
|
import { generateUUID } from './uuid';
|
||||||
|
import { deriveFullName } from './contact-utils';
|
||||||
|
|
||||||
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
|
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
|
||||||
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
||||||
@@ -573,6 +574,8 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
|
|
||||||
switch (propName) {
|
switch (propName) {
|
||||||
case 'FN':
|
case 'FN':
|
||||||
|
// Keep the verbatim FN in `full` (RFC 9553): it feeds the mandatory
|
||||||
|
// vCard FN on re-export, which strict clients require (#430).
|
||||||
if (!card.name) {
|
if (!card.name) {
|
||||||
const parts = val.split(' ');
|
const parts = val.split(' ');
|
||||||
const components: NameComponent[] = [];
|
const components: NameComponent[] = [];
|
||||||
@@ -582,7 +585,9 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
} else if (parts.length === 1) {
|
} else if (parts.length === 1) {
|
||||||
components.push({ kind: 'given', value: parts[0] });
|
components.push({ kind: 'given', value: parts[0] });
|
||||||
}
|
}
|
||||||
card.name = { components, isOrdered: true };
|
card.name = { components, isOrdered: true, full: val };
|
||||||
|
} else if (!card.name.full) {
|
||||||
|
card.name.full = val;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -599,7 +604,8 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
if (nParts[0]) components.push({ kind: 'surname', value: nParts[0] });
|
if (nParts[0]) components.push({ kind: 'surname', value: nParts[0] });
|
||||||
if (nParts[4]) components.push({ kind: 'generation', value: nParts[4] });
|
if (nParts[4]) components.push({ kind: 'generation', value: nParts[4] });
|
||||||
if (components.length > 0) {
|
if (components.length > 0) {
|
||||||
card.name = { components, isOrdered: true };
|
// Preserve `full` captured from FN when FN preceded N.
|
||||||
|
card.name = { ...card.name, components, isOrdered: true };
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1162,6 +1168,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A card without FN (invalid, but seen in the wild) still gets a `full`,
|
||||||
|
// derived from N like the contact form does, so the server writes an FN.
|
||||||
|
if (card.name && !card.name.full) {
|
||||||
|
const derived = deriveFullName(card.name.components);
|
||||||
|
if (derived) card.name.full = derived;
|
||||||
|
}
|
||||||
|
|
||||||
const hasName = (card.name && (card.name.components?.length ?? 0) > 0) || !!card.name?.full;
|
const hasName = (card.name && (card.name.components?.length ?? 0) > 0) || !!card.name?.full;
|
||||||
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
|
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
|
||||||
// An organization name identifies the card just as well as a personal name.
|
// An organization name identifies the card just as well as a personal name.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useContactsStore, selectGroupMembers } from '../stores/contacts-store';
|
|||||||
import {
|
import {
|
||||||
getContactKeywords, getContactDisplayName, getContactPrimaryEmail,
|
getContactKeywords, getContactDisplayName, getContactPrimaryEmail,
|
||||||
normalizeContactPhotoUri, partialDateToString, stringToPartialDate,
|
normalizeContactPhotoUri, partialDateToString, stringToPartialDate,
|
||||||
|
deriveFullName, getCustomFullName,
|
||||||
} from '../lib/contact-utils';
|
} from '../lib/contact-utils';
|
||||||
import { splitMailbox } from '../lib/rfc5322-mailbox';
|
import { splitMailbox } from '../lib/rfc5322-mailbox';
|
||||||
import Dialog from '../components/Dialog';
|
import Dialog from '../components/Dialog';
|
||||||
@@ -146,7 +147,6 @@ function contactToForm(contact: ContactCard, memberIds: string[]): FormState {
|
|||||||
const middle = findNameComponent(contact, 'given2', 'additional', 'middle');
|
const middle = findNameComponent(contact, 'given2', 'additional', 'middle');
|
||||||
const surname = findNameComponent(contact, 'surname');
|
const surname = findNameComponent(contact, 'surname');
|
||||||
const suffix = findNameComponent(contact, 'generation', 'suffix');
|
const suffix = findNameComponent(contact, 'generation', 'suffix');
|
||||||
const full = contact.name?.full || '';
|
|
||||||
|
|
||||||
const nicknames = contact.nicknames
|
const nicknames = contact.nicknames
|
||||||
? Object.values(contact.nicknames).map((n) => n.name || '').filter(Boolean)
|
? Object.values(contact.nicknames).map((n) => n.name || '').filter(Boolean)
|
||||||
@@ -232,6 +232,9 @@ function contactToForm(contact: ContactCard, memberIds: string[]): FormState {
|
|||||||
const isOrg = contact.kind
|
const isOrg = contact.kind
|
||||||
? contact.kind === 'org'
|
? contact.kind === 'org'
|
||||||
: !(given || surname) && !!rawOrgs[0]?.name;
|
: !(given || surname) && !!rawOrgs[0]?.name;
|
||||||
|
// Only a display name of its own goes in the field; a derived one is
|
||||||
|
// derived again on save, so it can't go stale when the name changes.
|
||||||
|
const full = getCustomFullName(contact, isOrg ? rawOrgs[0]?.name : undefined);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isOrg, prefix, given, middle, surname, suffix, full,
|
isOrg, prefix, given, middle, surname, suffix, full,
|
||||||
@@ -285,9 +288,10 @@ function formToPatch(
|
|||||||
if (form.suffix.trim()) components.push({ kind: 'generation', value: form.suffix.trim() });
|
if (form.suffix.trim()) components.push({ kind: 'generation', value: form.suffix.trim() });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without personal name components, carry the organization name in
|
// Always send `name.full`: the vCard FN is built from it and is mandatory
|
||||||
// `name.full` so servers and other clients have something to display.
|
// (#430). Without personal name components, carry the organization name
|
||||||
const full = form.full.trim() || (form.isOrg ? orgName : '');
|
// so servers and other clients have something to display.
|
||||||
|
const full = form.full.trim() || (form.isOrg ? orgName : deriveFullName(components));
|
||||||
const name: ContactCard['name'] | undefined =
|
const name: ContactCard['name'] | undefined =
|
||||||
components.length > 0 || full
|
components.length > 0 || full
|
||||||
? { ...(components.length > 0 ? { components, isOrdered: true } : {}), ...(full ? { full } : {}) }
|
? { ...(components.length > 0 ? { components, isOrdered: true } : {}), ...(full ? { full } : {}) }
|
||||||
|
|||||||
Reference in New Issue
Block a user