mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
This commit is contained in:
@@ -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:_$!<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
@@ -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: "_$!<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").
|
||||
@@ -273,6 +287,84 @@ function parseParams(paramStr: string): Record<string, string> {
|
||||
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']);
|
||||
|
||||
function typeToPhoneFeatures(typeStr: string | undefined): Record<string, boolean> | undefined {
|
||||
@@ -297,6 +389,53 @@ function typeToContext(typeStr: string | undefined): Record<string, boolean> | 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<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 {
|
||||
if (!contexts) return '';
|
||||
if (contexts.work) return 'WORK';
|
||||
@@ -304,24 +443,57 @@ function contextToType(contexts: Record<string, boolean> | 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<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[] {
|
||||
const text = unfoldLines(vcfString);
|
||||
const lines = joinQpSoftBreaks(text.split('\n'));
|
||||
const contacts: ContactCard[] = [];
|
||||
let current: Record<string, string[]> | 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<string, string[]>): 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<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)) {
|
||||
// splitRespectingQuotes so a quoted param value containing ";" survives.
|
||||
@@ -468,7 +674,7 @@ function buildContact(raw: Record<string, string[]>): 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<string, string[]>): 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<string, string[]>): 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<string, string[]>): 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/<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
|
||||
// they don't appear in any catch-all default.
|
||||
case 'VERSION':
|
||||
@@ -859,8 +1120,11 @@ function buildContact(raw: Record<string, string[]>): 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<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 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user