fix: import vCard dates as RFC 9553 PartialDate and map vendor X- extensions (#224, #701)

This commit is contained in:
Linus Rath
2026-08-29 17:37:44 +02:00
parent eb1dd6c844
commit e650cb2d0a
2 changed files with 522 additions and 25 deletions
+206
View File
@@ -120,3 +120,209 @@ describe('detectDuplicates', () => {
expect(detectDuplicates(existing, incoming).size).toBe(0); expect(detectDuplicates(existing, incoming).size).toBe(0);
}); });
}); });
describe('anniversary dates (issue #224)', () => {
const parseOne = (...props: string[]) =>
parseVCard(['BEGIN:VCARD', 'VERSION:4.0', 'FN:Test Person', ...props, 'END:VCARD'].join('\r\n'))[0];
it('parses BDAY into a structured PartialDate, not a raw string', () => {
// JSContact rejects a string date, which is why imported birthdays used
// to disappear once they reached the server.
const card = parseOne('BDAY:19850412');
const birth = Object.values(card.anniversaries || {}).find((a) => a.kind === 'birth');
expect(birth?.date).toEqual({ year: 1985, month: 4, day: 12 });
});
it.each([
['BDAY:1985-04-12', { year: 1985, month: 4, day: 12 }],
['BDAY:19850412T232050Z', { year: 1985, month: 4, day: 12 }],
['BDAY:1985-04-12T00:00:00Z', { year: 1985, month: 4, day: 12 }],
['BDAY:--0412', { month: 4, day: 12 }],
['BDAY:--04-12', { month: 4, day: 12 }],
['BDAY:--04', { month: 4 }],
['BDAY:---12', { day: 12 }],
['BDAY:1985-04', { year: 1985, month: 4 }],
['BDAY:1985', { year: 1985 }],
])('parses %s', (prop, expected) => {
const card = parseOne(prop);
expect(Object.values(card.anniversaries || {})[0]?.date).toEqual(expected);
});
it.each([
'BDAY;VALUE=text:circa 1800',
'BDAY:not-a-date',
'BDAY:1985-13-01',
'BDAY:',
])('drops the unrepresentable date in %s', (prop) => {
expect(parseOne(prop).anniversaries).toBeUndefined();
});
it('keeps BDAY and ANNIVERSARY side by side instead of overwriting', () => {
const card = parseOne('ANNIVERSARY:20100601', 'BDAY:19850412');
const kinds = Object.values(card.anniversaries || {}).map((a) => a.kind);
expect(kinds).toEqual(expect.arrayContaining(['wedding', 'birth']));
expect(Object.keys(card.anniversaries || {})).toHaveLength(2);
});
it('round-trips a birthday through generateVCard', () => {
const exported = generateVCard([parseOne('BDAY:19850412')]);
expect(exported).toContain('BDAY:1985-04-12');
const reparsed = parseVCard(exported)[0];
expect(Object.values(reparsed.anniversaries || {})[0]?.date).toEqual({
year: 1985, month: 4, day: 12,
});
});
it('round-trips a day/month-only birthday', () => {
const exported = generateVCard([parseOne('BDAY:--0412')]);
expect(exported).toContain('BDAY:--04-12');
expect(Object.values(parseVCard(exported)[0].anniversaries || {})[0]?.date)
.toEqual({ month: 4, day: 12 });
});
it('exports an unclassified anniversary as X-ABDATE and reads it back', () => {
const card: ContactCard = {
id: 'c-other',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Ann' }] },
anniversaries: { a0: { kind: 'other', date: { year: 2015, month: 9, day: 20 } } },
};
const exported = generateVCard([card]);
expect(exported).toContain('X-ABDATE:2015-09-20');
const reparsed = parseVCard(exported)[0];
expect(Object.values(reparsed.anniversaries || {})).toEqual([
{ kind: 'other', date: { year: 2015, month: 9, day: 20 } },
]);
});
});
describe('vendor extensions (issue #224)', () => {
const parseOne = (...props: string[]) =>
parseVCard(['BEGIN:VCARD', 'VERSION:3.0', 'FN:Test Person', ...props, 'END:VCARD'].join('\r\n'))[0];
it('imports X-ANDROID-CUSTOM nicknames', () => {
const card = parseOne('X-ANDROID-CUSTOM:vnd.android.cursor.item/nickname;Bobby;1;;;;;;;;;;;;;');
expect(Object.values(card.nicknames || {})).toEqual([{ name: 'Bobby' }]);
});
it('keeps every NICKNAME instead of overwriting the first', () => {
const card = parseOne('NICKNAME:JD', 'NICKNAME:Johnny');
expect(Object.values(card.nicknames || {})).toEqual([{ name: 'JD' }, { name: 'Johnny' }]);
});
it('imports X-ANDROID-CUSTOM contact events as anniversaries', () => {
const card = parseOne(
'X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;1985-04-12;3;;;;;;;;;;;;;',
'X-ANDROID-CUSTOM:vnd.android.cursor.item/contact_event;2010-06-01;1;;;;;;;;;;;;;',
);
expect(Object.values(card.anniversaries || {})).toEqual([
{ kind: 'birth', date: { year: 1985, month: 4, day: 12 } },
{ kind: 'wedding', date: { year: 2010, month: 6, day: 1 } },
]);
});
it('imports X-ANDROID-CUSTOM relations', () => {
const card = parseOne('X-ANDROID-CUSTOM:vnd.android.cursor.item/relation;Jane Doe;14;;;;;;;;;;;;;');
expect(card.relatedTo?.['Jane Doe']).toEqual({ relation: { spouse: true } });
});
it('imports Apple grouped X-ABDATE with its X-ABLABEL', () => {
const card = parseOne(
'item1.X-ABDATE:2010-06-01',
'item1.X-ABLABEL:_$!<Anniversary>!$_',
'item2.X-ABDATE:2015-09-20',
'item2.X-ABLABEL:First day at work',
);
expect(Object.values(card.anniversaries || {})).toEqual([
{ kind: 'wedding', date: { year: 2010, month: 6, day: 1 } },
{ kind: 'other', date: { year: 2015, month: 9, day: 20 } },
]);
});
it('applies a grouped X-ABLABEL to the property it labels', () => {
const card = parseOne(
'item1.TEL;TYPE=VOICE:+1-555-0100',
'item1.X-ABLABEL:Ski cabin',
'item2.EMAIL:side@example.com',
'item2.X-ABLABEL:_$!<Other>!$_',
);
expect(card.phones?.p0?.label).toBe('Ski cabin');
expect(card.emails?.e0?.label).toBe('Other');
});
it('still parses grouped properties that carry no label', () => {
const card = parseOne('item1.EMAIL;TYPE=INTERNET:grouped@example.com');
expect(card.emails?.e0?.address).toBe('grouped@example.com');
expect(card.emails?.e0?.label).toBeUndefined();
});
it('imports X-ABRELATEDNAMES and X-SPOUSE as relations', () => {
const card = parseOne(
'item1.X-ABRELATEDNAMES:Sam Smith',
'item1.X-ABLABEL:_$!<Brother>!$_',
'X-SPOUSE:Alex Smith',
);
expect(card.relatedTo?.['Sam Smith']).toEqual({ relation: { sibling: true } });
expect(card.relatedTo?.['Alex Smith']).toEqual({ relation: { spouse: true } });
});
it('imports X- instant-messaging handles as online services', () => {
const card = parseOne('X-SKYPE-USERNAME:jdoe', 'X-TWITTER:https://twitter.com/jdoe');
const services = Object.values(card.onlineServices || {});
expect(services).toEqual(expect.arrayContaining([
expect.objectContaining({ service: 'Skype', uri: 'jdoe', user: 'jdoe' }),
expect.objectContaining({ service: 'Twitter', uri: 'https://twitter.com/jdoe' }),
]));
expect(services.find((s) => s.service === 'Twitter')?.user).toBeUndefined();
});
it('imports X-GENDER and round-trips X-MAIDENNAME', () => {
const card = parseOne('N:Smith;Jane;;;', 'X-GENDER:Female', 'X-MAIDENNAME:Brown');
expect(card.speakToAs?.grammaticalGender).toBe('feminine');
expect(card.name?.components).toEqual(
expect.arrayContaining([{ kind: 'surname2', value: 'Brown' }]),
);
const reparsed = parseVCard(generateVCard([card]))[0];
expect(reparsed.name?.components).toEqual(
expect.arrayContaining([{ kind: 'surname2', value: 'Brown' }]),
);
});
it('leaves unknown X- properties alone', () => {
const card = parseOne('X-SOMETHING-ELSE:whatever');
expect(card.onlineServices).toBeUndefined();
expect(card.notes).toBeUndefined();
});
});
describe('organization-only cards (issue #701)', () => {
it('keeps a vCard that has only an organization name', () => {
const parsed = parseVCard([
'BEGIN:VCARD',
'VERSION:4.0',
'KIND:org',
'ORG:Acme Corp',
'TEL:+1-555-0100',
'END:VCARD',
].join('\r\n'));
expect(parsed).toHaveLength(1);
expect(parsed[0].kind).toBe('org');
expect(parsed[0].organizations?.o0.name).toBe('Acme Corp');
});
it('uses the organization name as FN for organization cards', () => {
const contact: ContactCard = {
id: 'c4',
addressBookIds: {},
kind: 'org',
organizations: { o0: { name: 'Acme Corp' } },
};
const vcf = generateVCard([contact]);
expect(vcf).toContain('KIND:org');
expect(vcf).toContain('FN:Acme Corp');
expect(vcf).toContain('ORG:Acme Corp');
expect(parseVCard(vcf)).toHaveLength(1);
});
});
+316 -25
View File
@@ -116,17 +116,31 @@ function findValueColon(line: string): number {
} }
// vCard properties may carry a group prefix: "item1.EMAIL:foo@bar". // vCard properties may carry a group prefix: "item1.EMAIL:foo@bar".
// Strip the prefix and return the bare property name + params component. // Split off the prefix and return it alongside the bare property name +
function stripGroupPrefix(keyPart: string): string { // params component. Apple exporters use the group to attach a label via a
// sibling "item1.X-ABLABEL:..." line, so the group has to survive parsing.
function splitGroupPrefix(keyPart: string): { group: string; key: string } {
const dot = keyPart.indexOf('.'); const dot = keyPart.indexOf('.');
if (dot < 0) return keyPart; if (dot < 0) return { group: '', key: keyPart };
const before = keyPart.substring(0, dot); const before = keyPart.substring(0, dot);
// Only treat as group if the segment before the dot has no ";" (which would // Only treat as group if the segment before the dot has no ";" (which would
// indicate it's actually a param boundary) and matches the RFC 6350 group // indicate it's actually a param boundary) and matches the RFC 6350 group
// grammar (ALPHA / DIGIT / "-"). // grammar (ALPHA / DIGIT / "-").
if (before.includes(';')) return keyPart; if (before.includes(';')) return { group: '', key: keyPart };
if (!/^[A-Za-z0-9-]+$/.test(before)) return keyPart; if (!/^[A-Za-z0-9-]+$/.test(before)) return { group: '', key: keyPart };
return keyPart.substring(dot + 1); return { group: before.toLowerCase(), key: keyPart.substring(dot + 1) };
}
// Apple wraps its built-in label names in a sentinel: "_$!<Anniversary>!$_".
function decodeAppleLabel(value: string): string {
const m = value.match(/^_\$!<(.*)>!\$_$/);
return m ? m[1] : value;
}
// Inverse of decodeParamValue - used when we synthesize an X-ABLABEL param
// from a grouped sibling property so it survives the normal param plumbing.
function encodeParamValue(s: string): string {
return s.replace(/\^/g, '^^').replace(/"/g, "^'").replace(/\n/g, '^n');
} }
// Strip URI scheme prefix from a value (e.g. "tel:+1-555" → "+1-555"). // Strip URI scheme prefix from a value (e.g. "tel:+1-555" → "+1-555").
@@ -273,6 +287,84 @@ function parseParams(paramStr: string): Record<string, string> {
return params; return params;
} }
// Non-standard IM / social properties emitted by Outlook, Thunderbird,
// Android, Apple and the various web exporters. All of them carry a handle or
// a profile URI, which maps onto an RFC 9553 OnlineService.
const X_ONLINE_SERVICES: Record<string, string> = {
'X-AIM': 'AIM',
'X-ICQ': 'ICQ',
'X-JABBER': 'Jabber',
'X-MSN': 'MSN',
'X-MS-IMADDRESS': 'Skype',
'X-YAHOO': 'Yahoo',
'X-SKYPE': 'Skype',
'X-SKYPE-USERNAME': 'Skype',
'X-GADUGADU': 'Gadu-Gadu',
'X-GROUPWISE': 'GroupWise',
'X-GOOGLE-TALK': 'Google Talk',
'X-GOOGLETALK': 'Google Talk',
'X-TWITTER': 'Twitter',
'X-MASTODON': 'Mastodon',
'X-MATRIX': 'Matrix',
'X-TELEGRAM': 'Telegram',
'X-SIGNAL': 'Signal',
'X-DISCORD': 'Discord',
'X-QQ': 'QQ',
'X-WECHAT': 'WeChat',
'X-FACEBOOK': 'Facebook',
'X-INSTAGRAM': 'Instagram',
'X-LINKEDIN': 'LinkedIn',
};
// Free-form relation labels (Apple X-ABRELATEDNAMES, X-SPOUSE / X-MANAGER /
// X-ASSISTANT, Android relation codes) mapped onto RFC 9553 relation types.
const RELATION_LABELS: Record<string, string> = {
spouse: 'spouse',
wife: 'spouse',
husband: 'spouse',
partner: 'sweetheart',
'domestic partner': 'sweetheart',
friend: 'friend',
child: 'child',
son: 'child',
daughter: 'child',
parent: 'parent',
father: 'parent',
mother: 'parent',
brother: 'sibling',
sister: 'sibling',
sibling: 'sibling',
relative: 'kin',
assistant: 'colleague',
manager: 'colleague',
colleague: 'colleague',
'referred by': 'contact',
};
// ContactsContract.CommonDataKinds.Relation type codes.
const ANDROID_RELATION_TYPES: Record<string, string> = {
'1': 'assistant', '2': 'brother', '3': 'child', '4': 'domestic partner',
'5': 'father', '6': 'friend', '7': 'manager', '8': 'mother', '9': 'parent',
'10': 'partner', '11': 'referred by', '12': 'relative', '13': 'sister',
'14': 'spouse',
};
// ContactsContract.CommonDataKinds.Event type codes (0 = custom).
const ANDROID_EVENT_KINDS: Record<string, 'birth' | 'wedding' | 'other'> = {
'1': 'wedding', '2': 'other', '3': 'birth',
};
function relationFromLabel(label: string): string | undefined {
return RELATION_LABELS[label.trim().toLowerCase()];
}
function anniversaryKindFromLabel(label: string | undefined): 'birth' | 'wedding' | 'other' {
const l = (label || '').toLowerCase();
if (l.includes('birth')) return 'birth';
if (l.includes('anniversary') || l.includes('wedding')) return 'wedding';
return 'other';
}
const PHONE_FEATURE_TYPES = new Set(['CELL', 'FAX', 'VOICE', 'PAGER', 'VIDEO', 'TEXT', 'TEXTPHONE']); const PHONE_FEATURE_TYPES = new Set(['CELL', 'FAX', 'VOICE', 'PAGER', 'VIDEO', 'TEXT', 'TEXTPHONE']);
function typeToPhoneFeatures(typeStr: string | undefined): Record<string, boolean> | undefined { function typeToPhoneFeatures(typeStr: string | undefined): Record<string, boolean> | undefined {
@@ -297,6 +389,53 @@ function typeToContext(typeStr: string | undefined): Record<string, boolean> | u
return ctx; return ctx;
} }
// Parse a vCard DATE / DATE-AND-OR-TIME value (RFC 6350 §4.3) into an
// RFC 9553 PartialDate.
//
// JSContact requires Anniversary.date to be a structured PartialDate (or
// Timestamp) object; a bare vCard string is not a valid value, so Stalwart
// drops the whole anniversary and imported BDAYs vanished (#224). Every
// anniversary we import therefore has to go through here.
//
// Accepts the basic and extended forms of: YYYYMMDD, YYYY-MM-DD, YYYY-MM,
// YYYY, --MMDD, --MM-DD, --MM and ---DD, with an optional trailing time
// component (e.g. "19850412T232050Z") which is discarded.
export function parseVcardDate(value: string, params: Record<string, string>): PartialDate | undefined {
// VALUE=text carries free-form prose ("circa 1800") that has no structured
// equivalent - better to drop it than to emit an invalid date.
if (params.VALUE?.toLowerCase() === 'text') return undefined;
const datePart = value.trim().split(/[T ]/)[0];
if (!datePart) return undefined;
const mk = (year?: number, month?: number, day?: number): PartialDate | undefined => {
if (month !== undefined && (month < 1 || month > 12)) return undefined;
if (day !== undefined && (day < 1 || day > 31)) return undefined;
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 : undefined;
};
const n = (s: string | undefined) => (s === undefined ? undefined : parseInt(s, 10));
// Reduced-accuracy forms first: "---DD" before "--MM" so the day-only form
// isn't mis-read as a month.
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 undefined;
}
function contextToType(contexts: Record<string, boolean> | undefined): string { function contextToType(contexts: Record<string, boolean> | undefined): string {
if (!contexts) return ''; if (!contexts) return '';
if (contexts.work) return 'WORK'; if (contexts.work) return 'WORK';
@@ -304,24 +443,57 @@ function contextToType(contexts: Record<string, boolean> | undefined): string {
return ''; return '';
} }
interface RawProperty {
group: string;
key: string;
value: string;
}
// Fold the flat property list into the "key with params" → values map that
// buildContact consumes, resolving Apple's grouped labels on the way:
// "item1.TEL:+1..." + "item1.X-ABLABEL:Ski cabin" becomes a TEL carrying an
// X-ABLABEL param, which every property handler already understands.
function collapseProperties(entries: RawProperty[]): Record<string, string[]> {
const groupLabels = new Map<string, string>();
for (const entry of entries) {
if (!entry.group) continue;
const propName = (splitRespectingQuotes(entry.key, ';')[0] || '').toUpperCase();
if (propName === 'X-ABLABEL') {
groupLabels.set(entry.group, decodeAppleLabel(decodeValue(entry.value)));
}
}
const raw: Record<string, string[]> = {};
for (const entry of entries) {
let key = entry.key;
const label = entry.group ? groupLabels.get(entry.group) : undefined;
if (label && !/(^|;)X-ABLABEL=/i.test(key)) {
key += `;X-ABLABEL="${encodeParamValue(label)}"`;
}
if (!raw[key]) raw[key] = [];
raw[key].push(entry.value);
}
return raw;
}
export function parseVCard(vcfString: string): ContactCard[] { export function parseVCard(vcfString: string): ContactCard[] {
const text = unfoldLines(vcfString); const text = unfoldLines(vcfString);
const lines = joinQpSoftBreaks(text.split('\n')); const lines = joinQpSoftBreaks(text.split('\n'));
const contacts: ContactCard[] = []; const contacts: ContactCard[] = [];
let current: Record<string, string[]> | null = null; let current: RawProperty[] | null = null;
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
if (trimmed.toUpperCase() === 'BEGIN:VCARD') { if (trimmed.toUpperCase() === 'BEGIN:VCARD') {
current = {}; current = [];
continue; continue;
} }
if (trimmed.toUpperCase() === 'END:VCARD') { if (trimmed.toUpperCase() === 'END:VCARD') {
if (current) { if (current) {
const card = buildContact(current); const card = buildContact(collapseProperties(current));
if (card) contacts.push(card); if (card) contacts.push(card);
} }
current = null; current = null;
@@ -331,10 +503,8 @@ export function parseVCard(vcfString: string): ContactCard[] {
if (current) { if (current) {
const colonIdx = findValueColon(trimmed); const colonIdx = findValueColon(trimmed);
if (colonIdx < 1) continue; if (colonIdx < 1) continue;
const keyPart = stripGroupPrefix(trimmed.substring(0, colonIdx)); const { group, key } = splitGroupPrefix(trimmed.substring(0, colonIdx));
const value = trimmed.substring(colonIdx + 1); current.push({ group, key, value: trimmed.substring(colonIdx + 1) });
if (!current[keyPart]) current[keyPart] = [];
current[keyPart].push(value);
} }
} }
@@ -348,6 +518,42 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
// because the BDAY/DEATHDATE entry may appear in any order. // because the BDAY/DEATHDATE entry may appear in any order.
let birthPlace: string | undefined; let birthPlace: string | undefined;
let deathPlace: string | undefined; let deathPlace: string | undefined;
// Deferred too: X-MAIDENNAME may precede the N property it belongs to.
let maidenName: string | undefined;
const addAnniversary = (
kind: 'birth' | 'death' | 'wedding' | 'other',
rawDate: string,
params: Record<string, string>,
) => {
const date = parseVcardDate(rawDate, params);
// A date we can't structure would be rejected by the server, so skip it
// rather than poison the whole card.
if (!date) return;
if (!card.anniversaries) card.anniversaries = {};
card.anniversaries[`a${Object.keys(card.anniversaries).length}`] = { kind, date };
};
const addRelation = (name: string, relation: string | undefined) => {
if (!name) return;
if (!card.relatedTo) card.relatedTo = {};
card.relatedTo[name] = { relation: relation ? { [relation]: true } : undefined };
};
const addOnlineService = (
uri: string,
service: string,
params: Record<string, string>,
pref: number | undefined,
) => {
if (!card.onlineServices) card.onlineServices = {};
const idx = Object.keys(card.onlineServices).length;
const svc: ContactOnlineService = { uri, service, pref, contexts: typeToContext(params.TYPE) };
// A bare handle is not a URI; RFC 9553 keeps those in `user`.
if (!/^[a-z][a-z0-9+.-]*:/i.test(uri)) svc.user = uri;
if (params['X-ABLABEL']) svc.label = params['X-ABLABEL'];
card.onlineServices[`u${idx}`] = svc;
};
for (const [fullKey, values] of Object.entries(raw)) { for (const [fullKey, values] of Object.entries(raw)) {
// splitRespectingQuotes so a quoted param value containing ";" survives. // splitRespectingQuotes so a quoted param value containing ";" survives.
@@ -468,7 +674,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
case 'NICKNAME': { case 'NICKNAME': {
if (!card.nicknames) card.nicknames = {}; if (!card.nicknames) card.nicknames = {};
card.nicknames.n0 = { name: val }; card.nicknames[`n${Object.keys(card.nicknames).length}`] = { name: val };
break; break;
} }
@@ -573,8 +779,7 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
} }
case 'BDAY': { case 'BDAY': {
if (!card.anniversaries) card.anniversaries = {}; addAnniversary('birth', val, params);
card.anniversaries.a0 = { kind: 'birth', date: val };
break; break;
} }
@@ -588,17 +793,13 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
case 'ANNIVERSARY': case 'ANNIVERSARY':
case 'X-ANNIVERSARY': { case 'X-ANNIVERSARY': {
if (!card.anniversaries) card.anniversaries = {}; addAnniversary('wedding', val, params);
const idx = Object.keys(card.anniversaries).length;
card.anniversaries[`a${idx}`] = { kind: 'wedding', date: val };
break; break;
} }
case 'DEATHDATE': case 'DEATHDATE':
case 'X-DEATHDATE': { case 'X-DEATHDATE': {
if (!card.anniversaries) card.anniversaries = {}; addAnniversary('death', val, params);
const idx = Object.keys(card.anniversaries).length;
card.anniversaries[`a${idx}`] = { kind: 'death', date: val };
break; break;
} }
@@ -851,6 +1052,66 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
break; break;
} }
// ---- Common vendor extensions ----
case 'X-ABDATE': {
// Apple custom date. The label ("Anniversary", "Birthday", or a
// user-defined string) arrives via the group's X-ABLABEL sibling.
addAnniversary(anniversaryKindFromLabel(params['X-ABLABEL']), val, params);
break;
}
case 'X-ABRELATEDNAMES': {
addRelation(val, relationFromLabel(params['X-ABLABEL'] || ''));
break;
}
case 'X-SPOUSE':
case 'X-MANAGER':
case 'X-ASSISTANT': {
addRelation(val, relationFromLabel(propName.substring(2)));
break;
}
case 'X-MAIDENNAME':
case 'X-BIRTHNAME': {
maidenName = val;
break;
}
case 'X-GENDER': {
// Outlook / Android write a word ("Male", "weiblich") where vCard
// 4.0's GENDER uses a single letter.
const g = val.trim().toLowerCase();
const mapped = g.startsWith('m') ? 'masculine'
: g.startsWith('f') || g.startsWith('w') ? 'feminine'
: g.startsWith('o') ? 'other'
: undefined;
if (mapped) {
if (!card.speakToAs) card.speakToAs = {};
card.speakToAs.grammaticalGender = mapped;
}
break;
}
case 'X-ANDROID-CUSTOM': {
// Android's catch-all for fields vCard has no property for:
// vnd.android.cursor.item/<kind>;<value>;<type>;;;…
const parts = val.split(';');
const mime = (parts[0] || '').toLowerCase();
const kind = mime.substring(mime.lastIndexOf('/') + 1);
const value = parts[1] || '';
if (!value) break;
if (kind === 'nickname') {
if (!card.nicknames) card.nicknames = {};
card.nicknames[`n${Object.keys(card.nicknames).length}`] = { name: value };
} else if (kind === 'contact_event') {
addAnniversary(ANDROID_EVENT_KINDS[parts[2]] || 'other', value, params);
} else if (kind === 'relation') {
addRelation(value, relationFromLabel(ANDROID_RELATION_TYPES[parts[2]] || ''));
}
break;
}
// Silently swallow purely structural / sync metadata properties so // Silently swallow purely structural / sync metadata properties so
// they don't appear in any catch-all default. // they don't appear in any catch-all default.
case 'VERSION': case 'VERSION':
@@ -859,8 +1120,11 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
case 'X-ABLABEL': case 'X-ABLABEL':
break; break;
default: default: {
const service = X_ONLINE_SERVICES[propName];
if (service) addOnlineService(val, service, params, pref);
break; break;
}
} }
} }
} }
@@ -888,9 +1152,21 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
} }
} }
// RFC 9553 has no dedicated "maiden name" kind; surname2 is the closest
// fit and is what the birth name occupies in a two-surname model.
if (maidenName) {
if (!card.name) card.name = { components: [], isOrdered: true };
if (!card.name.components) card.name.components = [];
if (!card.name.components.some((c) => c.kind === 'surname2')) {
card.name.components.push({ kind: 'surname2', value: maidenName });
}
}
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;
if (!hasName && !hasEmail && card.kind !== 'group') return null; // An organization name identifies the card just as well as a personal name.
const hasOrg = !!Object.values(card.organizations || {})[0]?.name;
if (!hasName && !hasEmail && !hasOrg && card.kind !== 'group') return null;
return card; return card;
} }
@@ -928,12 +1204,23 @@ function generateSingleVCard(contact: ContactCard): string {
const suffix = findKind('generation', 'suffix'); const suffix = findKind('generation', 'suffix');
const additional = findKind('given2', 'additional', 'middle'); const additional = findKind('given2', 'additional', 'middle');
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(' ') || contact.name?.full || ''; // FN is mandatory in vCard, so fall back to the organization name for org cards.
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(' ')
|| contact.name?.full
|| Object.values(contact.organizations || {})[0]?.name
|| '';
if (fn) { if (fn) {
lines.push(`FN:${encodeValue(fn)}`); lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`); lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
} }
// vCard has no second-surname slot; X-MAIDENNAME is the de-facto extension
// and is what we read back on import.
const surname2 = findKind('surname2');
if (surname2) {
lines.push(`X-MAIDENNAME:${encodeValue(surname2)}`);
}
if (contact.nicknames) { if (contact.nicknames) {
for (const nick of Object.values(contact.nicknames)) { for (const nick of Object.values(contact.nicknames)) {
lines.push(`NICKNAME:${encodeValue(nick.name)}`); lines.push(`NICKNAME:${encodeValue(nick.name)}`);
@@ -1035,6 +1322,10 @@ function generateSingleVCard(contact: ContactCard): string {
if (ann.place?.fullAddress) { if (ann.place?.fullAddress) {
lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`); lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`);
} }
} else if (dateStr) {
// No standard property for an unclassified date; X-ABDATE is the one
// the common exporters (and our own importer) understand.
lines.push(`X-ABDATE:${dateStr}`);
} }
} }
} }