From e650cb2d0acfeb25b919eecc9a917d132969beb3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:37:44 +0200 Subject: [PATCH] fix: import vCard dates as RFC 9553 PartialDate and map vendor X- extensions (#224, #701) --- src/lib/__tests__/vcard.test.ts | 206 +++++++++++++++++++ src/lib/vcard.ts | 341 +++++++++++++++++++++++++++++--- 2 files changed, 522 insertions(+), 25 deletions(-) diff --git a/src/lib/__tests__/vcard.test.ts b/src/lib/__tests__/vcard.test.ts index f64b7d04..96e62dc5 100644 --- a/src/lib/__tests__/vcard.test.ts +++ b/src/lib/__tests__/vcard.test.ts @@ -120,3 +120,209 @@ describe('detectDuplicates', () => { 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:_$!!$_', + '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:_$!!$_', + ); + 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:_$!!$_', + '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); + }); +}); diff --git a/src/lib/vcard.ts b/src/lib/vcard.ts index ffd7ada0..f3f99860 100644 --- a/src/lib/vcard.ts +++ b/src/lib/vcard.ts @@ -116,17 +116,31 @@ function findValueColon(line: string): number { } // vCard properties may carry a group prefix: "item1.EMAIL:foo@bar". -// Strip the prefix and return the bare property name + params component. -function stripGroupPrefix(keyPart: string): string { +// Split off the prefix and return it alongside the bare property name + +// 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('.'); - if (dot < 0) return keyPart; + if (dot < 0) return { group: '', key: keyPart }; const before = keyPart.substring(0, dot); // 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 // grammar (ALPHA / DIGIT / "-"). - if (before.includes(';')) return keyPart; - if (!/^[A-Za-z0-9-]+$/.test(before)) return keyPart; - return keyPart.substring(dot + 1); + if (before.includes(';')) return { group: '', key: keyPart }; + if (!/^[A-Za-z0-9-]+$/.test(before)) return { group: '', key: keyPart }; + return { group: before.toLowerCase(), key: keyPart.substring(dot + 1) }; +} + +// Apple wraps its built-in label names in a sentinel: "_$!!$_". +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"). @@ -273,6 +287,84 @@ function parseParams(paramStr: string): Record { 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 = { + '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 = { + 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 = { + '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 = { + '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']); function typeToPhoneFeatures(typeStr: string | undefined): Record | undefined { @@ -297,6 +389,53 @@ function typeToContext(typeStr: string | undefined): Record | u 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): 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 | undefined): string { if (!contexts) return ''; if (contexts.work) return 'WORK'; @@ -304,24 +443,57 @@ function contextToType(contexts: Record | undefined): string { 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 { + const groupLabels = new Map(); + 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 = {}; + 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[] { const text = unfoldLines(vcfString); const lines = joinQpSoftBreaks(text.split('\n')); const contacts: ContactCard[] = []; - let current: Record | null = null; + let current: RawProperty[] | null = null; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) continue; if (trimmed.toUpperCase() === 'BEGIN:VCARD') { - current = {}; + current = []; continue; } if (trimmed.toUpperCase() === 'END:VCARD') { if (current) { - const card = buildContact(current); + const card = buildContact(collapseProperties(current)); if (card) contacts.push(card); } current = null; @@ -331,10 +503,8 @@ export function parseVCard(vcfString: string): ContactCard[] { if (current) { const colonIdx = findValueColon(trimmed); if (colonIdx < 1) continue; - const keyPart = stripGroupPrefix(trimmed.substring(0, colonIdx)); - const value = trimmed.substring(colonIdx + 1); - if (!current[keyPart]) current[keyPart] = []; - current[keyPart].push(value); + const { group, key } = splitGroupPrefix(trimmed.substring(0, colonIdx)); + current.push({ group, key, value: trimmed.substring(colonIdx + 1) }); } } @@ -348,6 +518,42 @@ function buildContact(raw: Record): ContactCard | null { // because the BDAY/DEATHDATE entry may appear in any order. let birthPlace: 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, + ) => { + 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, + 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)) { // splitRespectingQuotes so a quoted param value containing ";" survives. @@ -468,7 +674,7 @@ function buildContact(raw: Record): ContactCard | null { case 'NICKNAME': { if (!card.nicknames) card.nicknames = {}; - card.nicknames.n0 = { name: val }; + card.nicknames[`n${Object.keys(card.nicknames).length}`] = { name: val }; break; } @@ -573,8 +779,7 @@ function buildContact(raw: Record): ContactCard | null { } case 'BDAY': { - if (!card.anniversaries) card.anniversaries = {}; - card.anniversaries.a0 = { kind: 'birth', date: val }; + addAnniversary('birth', val, params); break; } @@ -588,17 +793,13 @@ function buildContact(raw: Record): ContactCard | null { case 'ANNIVERSARY': case 'X-ANNIVERSARY': { - if (!card.anniversaries) card.anniversaries = {}; - const idx = Object.keys(card.anniversaries).length; - card.anniversaries[`a${idx}`] = { kind: 'wedding', date: val }; + addAnniversary('wedding', val, params); break; } case 'DEATHDATE': case 'X-DEATHDATE': { - if (!card.anniversaries) card.anniversaries = {}; - const idx = Object.keys(card.anniversaries).length; - card.anniversaries[`a${idx}`] = { kind: 'death', date: val }; + addAnniversary('death', val, params); break; } @@ -851,6 +1052,66 @@ function buildContact(raw: Record): ContactCard | null { 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/;;;;;… + 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 // they don't appear in any catch-all default. case 'VERSION': @@ -859,8 +1120,11 @@ function buildContact(raw: Record): ContactCard | null { case 'X-ABLABEL': break; - default: + default: { + const service = X_ONLINE_SERVICES[propName]; + if (service) addOnlineService(val, service, params, pref); break; + } } } } @@ -888,9 +1152,21 @@ function buildContact(raw: Record): 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 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; } @@ -928,12 +1204,23 @@ function generateSingleVCard(contact: ContactCard): string { const suffix = findKind('generation', 'suffix'); 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) { lines.push(`FN:${encodeValue(fn)}`); 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) { for (const nick of Object.values(contact.nicknames)) { lines.push(`NICKNAME:${encodeValue(nick.name)}`); @@ -1035,6 +1322,10 @@ function generateSingleVCard(contact: ContactCard): string { if (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}`); } } }