mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: export contacts as vCard 4.0 that other apps read in full #1407
This commit is contained in:
@@ -33,6 +33,14 @@ const CARDS: Array<[string, ContactCard]> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
describe('contact form save', () => {
|
describe('contact form save', () => {
|
||||||
|
// RFC 9553 has "mobile"; "cell" is what older versions of the form wrote.
|
||||||
|
it('reads a "cell" phone as mobile and saves it as the RFC 9553 feature', () => {
|
||||||
|
const card: ContactCard = { ...base, phones: { p1: { number: '+1000', features: { cell: true } } } };
|
||||||
|
const form = contactToForm(card, []);
|
||||||
|
expect(form.phones[0].feature).toBe('mobile');
|
||||||
|
expect(formToPatch(form, card, false, [card]).phones).toEqual({ p1: { number: '+1000', features: { mobile: true } } });
|
||||||
|
});
|
||||||
|
|
||||||
it.each(CARDS)('a phone-only edit leaves the photo alone on Stalwart %s', (_version, card) => {
|
it.each(CARDS)('a phone-only edit leaves the photo alone on Stalwart %s', (_version, card) => {
|
||||||
const form = contactToForm(card, []);
|
const form = contactToForm(card, []);
|
||||||
form.phones = [{ number: '+2000', context: '', feature: '' }];
|
form.phones = [{ number: '+2000', context: '', feature: '' }];
|
||||||
|
|||||||
@@ -372,8 +372,9 @@ describe('inlineContactMediaForExport', () => {
|
|||||||
expect(out[1].media).toEqual({ photo: { kind: 'photo', mediaType: 'image/png', uri: 'data:image/png;base64,iVBORw0KGgo=' } });
|
expect(out[1].media).toEqual({ photo: { kind: 'photo', mediaType: 'image/png', uri: 'data:image/png;base64,iVBORw0KGgo=' } });
|
||||||
|
|
||||||
const vcf = contactsToVCard(out);
|
const vcf = contactsToVCard(out);
|
||||||
expect(vcf).toContain(`PHOTO;ENCODING=b;TYPE=image/jpeg:${Buffer.from(JPEG_BYTES).toString('base64')}`);
|
const unfolded = vcf.replace(/\r\n /g, '');
|
||||||
expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/png:iVBORw0KGgo=');
|
expect(unfolded).toContain(`PHOTO:data:image/jpeg;base64,${Buffer.from(JPEG_BYTES).toString('base64')}`);
|
||||||
|
expect(unfolded).toContain('PHOTO:data:image/png;base64,iVBORw0KGgo=');
|
||||||
expect(mockFetchBlob).not.toHaveBeenCalled();
|
expect(mockFetchBlob).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ describe('parseVCard', () => {
|
|||||||
expect(emails[0].contexts).toEqual({ work: true });
|
expect(emails[0].contexts).toEqual({ work: true });
|
||||||
const phones = Object.values(card.phones ?? {});
|
const phones = Object.values(card.phones ?? {});
|
||||||
expect(phones[0].number).toBe('+1-555-0100');
|
expect(phones[0].number).toBe('+1-555-0100');
|
||||||
expect(phones[0].features).toEqual({ cell: true });
|
// CELL is "mobile" in RFC 9553; "cell" is no JSContact feature.
|
||||||
|
expect(phones[0].features).toEqual({ mobile: true });
|
||||||
const given = card.name?.components?.find((c) => c.kind === 'given')?.value;
|
const given = card.name?.components?.find((c) => c.kind === 'given')?.value;
|
||||||
const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
|
const surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
|
||||||
expect(given).toBe('Jane');
|
expect(given).toBe('Jane');
|
||||||
@@ -115,6 +116,58 @@ describe('generateVCard', () => {
|
|||||||
expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1);
|
expect(out.match(/BEGIN:VCARD/g)).toHaveLength(1);
|
||||||
expect(out.match(/END:VCARD/g)).toHaveLength(1);
|
expect(out.match(/END:VCARD/g)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #1407: what Apple Contacts, Google and Outlook expect of a vCard 4.0.
|
||||||
|
describe('vCard 4.0 (#1407)', () => {
|
||||||
|
const card: ContactCard = {
|
||||||
|
id: 'c1',
|
||||||
|
addressBookIds: {},
|
||||||
|
uid: 'abc-123',
|
||||||
|
updated: '2026-10-29T08:15:00Z',
|
||||||
|
name: { components: [{ kind: 'given', value: 'Jane' }, { kind: 'surname', value: 'Doe' }] },
|
||||||
|
phones: {
|
||||||
|
p1: { number: '+1 555 0100', features: { mobile: true } },
|
||||||
|
p2: { number: '+1 555 0101', features: { cell: true } },
|
||||||
|
},
|
||||||
|
notes: { n1: { note: 'x'.repeat(200) } },
|
||||||
|
};
|
||||||
|
const out = generateVCard([card]);
|
||||||
|
const lines = out.split('\r\n');
|
||||||
|
|
||||||
|
it('says it is version 4.0 and ends every line with CRLF', () => {
|
||||||
|
expect(lines.slice(0, 2)).toEqual(['BEGIN:VCARD', 'VERSION:4.0']);
|
||||||
|
expect(out.endsWith('END:VCARD\r\n')).toBe(true);
|
||||||
|
expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes N before FN', () => {
|
||||||
|
expect(lines.indexOf('N:Doe;Jane;;;')).toBeGreaterThan(-1);
|
||||||
|
expect(lines.indexOf('N:Doe;Jane;;;')).toBeLessThan(lines.indexOf('FN:Jane Doe'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes mobiles as CELL, never MOBILE', () => {
|
||||||
|
expect(lines).toContain('TEL;TYPE=CELL:+1 555 0100');
|
||||||
|
expect(lines).toContain('TEL;TYPE=CELL:+1 555 0101');
|
||||||
|
expect(out).not.toContain('MOBILE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a UID without a scheme as text and writes REV in the basic format', () => {
|
||||||
|
expect(lines).toContain('UID;VALUE=text:abc-123');
|
||||||
|
expect(lines).toContain('REV:20261029T081500Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds lines longer than 75 octets', () => {
|
||||||
|
expect(lines.every((line) => line.length <= 75)).toBe(true);
|
||||||
|
expect(lines.some((line) => line.startsWith(' '))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads its own export back', () => {
|
||||||
|
const [back] = parseVCard(out);
|
||||||
|
expect(back.uid).toBe('abc-123');
|
||||||
|
expect(Object.values(back.phones ?? {}).map((p) => p.features)).toEqual([{ mobile: true }, { mobile: true }]);
|
||||||
|
expect(Object.values(back.notes ?? {})[0]?.note).toBe('x'.repeat(200));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('detectDuplicates', () => {
|
describe('detectDuplicates', () => {
|
||||||
@@ -185,7 +238,7 @@ describe('anniversary dates (issue #224)', () => {
|
|||||||
|
|
||||||
it('round-trips a birthday through generateVCard', () => {
|
it('round-trips a birthday through generateVCard', () => {
|
||||||
const exported = generateVCard([parseOne('BDAY:19850412')]);
|
const exported = generateVCard([parseOne('BDAY:19850412')]);
|
||||||
expect(exported).toContain('BDAY:1985-04-12');
|
expect(exported).toContain('BDAY:19850412');
|
||||||
const reparsed = parseVCard(exported)[0];
|
const reparsed = parseVCard(exported)[0];
|
||||||
expect(Object.values(reparsed.anniversaries || {})[0]?.date).toEqual({
|
expect(Object.values(reparsed.anniversaries || {})[0]?.date).toEqual({
|
||||||
year: 1985, month: 4, day: 12,
|
year: 1985, month: 4, day: 12,
|
||||||
@@ -194,7 +247,7 @@ describe('anniversary dates (issue #224)', () => {
|
|||||||
|
|
||||||
it('round-trips a day/month-only birthday', () => {
|
it('round-trips a day/month-only birthday', () => {
|
||||||
const exported = generateVCard([parseOne('BDAY:--0412')]);
|
const exported = generateVCard([parseOne('BDAY:--0412')]);
|
||||||
expect(exported).toContain('BDAY:--04-12');
|
expect(exported).toContain('BDAY:--0412');
|
||||||
expect(Object.values(parseVCard(exported)[0].anniversaries || {})[0]?.date)
|
expect(Object.values(parseVCard(exported)[0].anniversaries || {})[0]?.date)
|
||||||
.toEqual({ month: 4, day: 12 });
|
.toEqual({ month: 4, day: 12 });
|
||||||
});
|
});
|
||||||
@@ -207,7 +260,7 @@ describe('anniversary dates (issue #224)', () => {
|
|||||||
anniversaries: { a0: { kind: 'other', date: { year: 2015, month: 9, day: 20 } } },
|
anniversaries: { a0: { kind: 'other', date: { year: 2015, month: 9, day: 20 } } },
|
||||||
};
|
};
|
||||||
const exported = generateVCard([card]);
|
const exported = generateVCard([card]);
|
||||||
expect(exported).toContain('X-ABDATE:2015-09-20');
|
expect(exported).toContain('X-ABDATE:20150920');
|
||||||
const reparsed = parseVCard(exported)[0];
|
const reparsed = parseVCard(exported)[0];
|
||||||
expect(Object.values(reparsed.anniversaries || {})).toEqual([
|
expect(Object.values(reparsed.anniversaries || {})).toEqual([
|
||||||
{ kind: 'other', date: { year: 2015, month: 9, day: 20 } },
|
{ kind: 'other', date: { year: 2015, month: 9, day: 20 } },
|
||||||
@@ -399,10 +452,10 @@ describe('photo export', () => {
|
|||||||
d: { kind: 'photo', uri: 'data:;base64,DDDD' },
|
d: { kind: 'photo', uri: 'data:;base64,DDDD' },
|
||||||
})]);
|
})]);
|
||||||
const lines = vcf.split('\r\n');
|
const lines = vcf.split('\r\n');
|
||||||
expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/png:AAAA');
|
expect(lines).toContain('PHOTO:data:image/png;base64,AAAA');
|
||||||
expect(lines).toContain('LOGO;ENCODING=b;TYPE=image/jpeg:BBBB');
|
expect(lines).toContain('LOGO:data:image/jpeg;base64,BBBB');
|
||||||
expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:CCCC');
|
expect(lines).toContain('PHOTO:data:image/jpeg;base64,CCCC');
|
||||||
expect(lines).toContain('PHOTO;ENCODING=b:DDDD');
|
expect(lines).toContain('PHOTO:data:;base64,DDDD');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips a Stalwart 1.0 blob photo that was not downloaded into the card', () => {
|
it('skips a Stalwart 1.0 blob photo that was not downloaded into the card', () => {
|
||||||
|
|||||||
@@ -138,8 +138,10 @@ export function contactToForm(contact: ContactCard, memberIds: string[]): FormSt
|
|||||||
const phones = contact.phones ? Object.values(contact.phones).map((p) => ({
|
const phones = contact.phones ? Object.values(contact.phones).map((p) => ({
|
||||||
number: p.number,
|
number: p.number,
|
||||||
context: p.contexts?.work ? 'work' : p.contexts?.private ? 'private' : '',
|
context: p.contexts?.work ? 'work' : p.contexts?.private ? 'private' : '',
|
||||||
|
// RFC 9553 calls it "mobile"; cards saved by older versions of the app
|
||||||
|
// (and of the webmail) say "cell", which a save turns into "mobile".
|
||||||
feature:
|
feature:
|
||||||
p.features?.cell ? 'cell'
|
p.features?.mobile || p.features?.cell ? 'mobile'
|
||||||
: p.features?.fax ? 'fax'
|
: p.features?.fax ? 'fax'
|
||||||
: p.features?.pager ? 'pager'
|
: p.features?.pager ? 'pager'
|
||||||
: p.features?.video ? 'video'
|
: p.features?.video ? 'video'
|
||||||
|
|||||||
+129
-36
@@ -9,19 +9,21 @@ import { generateUUID } from './uuid';
|
|||||||
import { deriveFullName } from './contact-utils';
|
import { deriveFullName } from './contact-utils';
|
||||||
import { parseBase64DataUri } from './data-uri';
|
import { parseBase64DataUri } from './data-uri';
|
||||||
|
|
||||||
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
|
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to a vCard
|
||||||
|
// 4.0 date, which uses the ISO 8601 basic format (RFC 6350 §4.3.1): 19850412,
|
||||||
|
// --0412, 1985-04, 1985.
|
||||||
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
function anniversaryDateToVcardString(date: AnniversaryDate): string {
|
||||||
if (typeof date === 'string') return date;
|
if (typeof date === 'string') return date;
|
||||||
if (date && typeof date === 'object') {
|
if (date && typeof date === 'object') {
|
||||||
if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) {
|
if ('@type' in date && date['@type'] === 'Timestamp' && 'utc' in date) {
|
||||||
return (date as { utc: string }).utc.split('T')[0];
|
return (date as { utc: string }).utc.split('T')[0].replace(/-/g, '');
|
||||||
}
|
}
|
||||||
const pd = date as PartialDate;
|
const pd = date as PartialDate;
|
||||||
if (pd.year && pd.month && pd.day) {
|
if (pd.year && pd.month && pd.day) {
|
||||||
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
return `${String(pd.year).padStart(4, '0')}${String(pd.month).padStart(2, '0')}${String(pd.day).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
if (pd.month && pd.day) {
|
if (pd.month && pd.day) {
|
||||||
return `--${String(pd.month).padStart(2, '0')}-${String(pd.day).padStart(2, '0')}`;
|
return `--${String(pd.month).padStart(2, '0')}${String(pd.day).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
if (pd.year && pd.month) {
|
if (pd.year && pd.month) {
|
||||||
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`;
|
return `${String(pd.year).padStart(4, '0')}-${String(pd.month).padStart(2, '0')}`;
|
||||||
@@ -367,20 +369,58 @@ function anniversaryKindFromLabel(label: string | undefined): 'birth' | 'wedding
|
|||||||
return 'other';
|
return 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PHONE_FEATURE_TYPES = new Set(['CELL', 'FAX', 'VOICE', 'PAGER', 'VIDEO', 'TEXT', 'TEXTPHONE']);
|
// vCard TEL types and the RFC 9553 phone features they stand for. CELL is
|
||||||
|
// "mobile" in JSContact; a "cell" feature is no RFC 9553 one. MOBILE is no
|
||||||
|
// vCard type, but Bulwark's own exports used to write it.
|
||||||
|
const TEL_TYPE_TO_PHONE_FEATURE: Record<string, string> = {
|
||||||
|
CELL: 'mobile',
|
||||||
|
MOBILE: 'mobile',
|
||||||
|
VOICE: 'voice',
|
||||||
|
FAX: 'fax',
|
||||||
|
PAGER: 'pager',
|
||||||
|
VIDEO: 'video',
|
||||||
|
TEXT: 'text',
|
||||||
|
TEXTPHONE: 'textphone',
|
||||||
|
'MAIN-NUMBER': 'main-number',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PHONE_FEATURE_TO_TEL_TYPE: Record<string, string> = {
|
||||||
|
mobile: 'CELL',
|
||||||
|
cell: 'CELL',
|
||||||
|
voice: 'VOICE',
|
||||||
|
fax: 'FAX',
|
||||||
|
pager: 'PAGER',
|
||||||
|
video: 'VIDEO',
|
||||||
|
text: 'TEXT',
|
||||||
|
textphone: 'TEXTPHONE',
|
||||||
|
'main-number': 'MAIN-NUMBER',
|
||||||
|
};
|
||||||
|
|
||||||
function typeToPhoneFeatures(typeStr: string | undefined): Record<string, boolean> | undefined {
|
function typeToPhoneFeatures(typeStr: string | undefined): Record<string, boolean> | undefined {
|
||||||
if (!typeStr) return undefined;
|
if (!typeStr) return undefined;
|
||||||
const types = typeStr.toUpperCase().split(',');
|
const types = typeStr.toUpperCase().split(',');
|
||||||
const features: Record<string, boolean> = {};
|
const features: Record<string, boolean> = {};
|
||||||
for (const t of types) {
|
for (const t of types) {
|
||||||
if (PHONE_FEATURE_TYPES.has(t)) {
|
const feature = TEL_TYPE_TO_PHONE_FEATURE[t];
|
||||||
features[t.toLowerCase()] = true;
|
if (feature) features[feature] = true;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Object.keys(features).length > 0 ? features : undefined;
|
return Object.keys(features).length > 0 ? features : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function phoneFeaturesToTypes(features: Record<string, boolean> | undefined): string[] {
|
||||||
|
const types = new Set<string>();
|
||||||
|
for (const [feature, on] of Object.entries(features || {})) {
|
||||||
|
if (!on) continue;
|
||||||
|
const type = PHONE_FEATURE_TO_TEL_TYPE[feature.toLowerCase()];
|
||||||
|
if (type) {
|
||||||
|
types.add(type);
|
||||||
|
} else if (/^x-[a-z0-9-]+$/i.test(feature)) {
|
||||||
|
types.add(feature.toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...types];
|
||||||
|
}
|
||||||
|
|
||||||
function typeToContext(typeStr: string | undefined): Record<string, boolean> | undefined {
|
function typeToContext(typeStr: string | undefined): Record<string, boolean> | undefined {
|
||||||
if (!typeStr) return undefined;
|
if (!typeStr) return undefined;
|
||||||
const types = typeStr.toUpperCase().split(',');
|
const types = typeStr.toUpperCase().split(',');
|
||||||
@@ -1185,19 +1225,68 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VCARD_PRODID = '-//JMAP-Webmail//EN';
|
||||||
|
|
||||||
|
function hasUriScheme(value: string): boolean {
|
||||||
|
return /^[a-z][a-z0-9+.-]*:/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toVcardTimestamp(value: string): string {
|
||||||
|
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/i);
|
||||||
|
if (!m) return value;
|
||||||
|
const zone = (m[7] || '').replace(':', '').toUpperCase();
|
||||||
|
return `${m[1]}${m[2]}${m[3]}T${m[4]}${m[5]}${m[6]}${zone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGeoUri(coordinates: string): string {
|
||||||
|
if (/^geo:/i.test(coordinates)) return coordinates;
|
||||||
|
const m = coordinates.match(/^\s*(-?[\d.]+)\s*[;,]\s*(-?[\d.]+)\s*$/);
|
||||||
|
return m ? `geo:${m[1]},${m[2]}` : coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf8Length(char: string): number {
|
||||||
|
const code = char.codePointAt(0) ?? 0;
|
||||||
|
if (code < 0x80) return 1;
|
||||||
|
if (code < 0x800) return 2;
|
||||||
|
if (code < 0x10000) return 3;
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 6350 §3.2: lines longer than 75 octets are folded, the continuation
|
||||||
|
// starting with a space.
|
||||||
|
function foldLine(line: string): string {
|
||||||
|
const out: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let octets = 0;
|
||||||
|
for (const char of line) {
|
||||||
|
const size = utf8Length(char);
|
||||||
|
const limit = out.length === 0 ? 75 : 74;
|
||||||
|
if (octets + size > limit) {
|
||||||
|
out.push(current);
|
||||||
|
current = '';
|
||||||
|
octets = 0;
|
||||||
|
}
|
||||||
|
current += char;
|
||||||
|
octets += size;
|
||||||
|
}
|
||||||
|
out.push(current);
|
||||||
|
return out.join('\r\n ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contacts as vCard 4.0 (RFC 6350), each line folded and ended by CRLF
|
||||||
|
* (webmail #1407): dates in the basic format, CELL for mobiles, N before FN.
|
||||||
|
*/
|
||||||
export function generateVCard(contacts: ContactCard[]): string {
|
export function generateVCard(contacts: ContactCard[]): string {
|
||||||
return contacts.map(generateSingleVCard).join('\r\n');
|
return contacts.map(generateSingleVCard).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSingleVCard(contact: ContactCard): string {
|
function generateSingleVCard(contact: ContactCard): string {
|
||||||
const lines: string[] = ['BEGIN:VCARD', 'VERSION:3.0'];
|
const lines: string[] = ['BEGIN:VCARD', 'VERSION:4.0', `PRODID:${VCARD_PRODID}`];
|
||||||
|
|
||||||
if (contact.uid) {
|
if (contact.uid) {
|
||||||
lines.push(`UID:${contact.uid}`);
|
// UID is a URI in vCard 4.0; anything else is marked as text.
|
||||||
}
|
lines.push(hasUriScheme(contact.uid) ? `UID:${contact.uid}` : `UID;VALUE=text:${contact.uid}`);
|
||||||
|
|
||||||
if (contact.prodId) {
|
|
||||||
lines.push(`PRODID:${contact.prodId}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contact.kind) {
|
if (contact.kind) {
|
||||||
@@ -1205,7 +1294,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contact.updated) {
|
if (contact.updated) {
|
||||||
lines.push(`REV:${contact.updated}`);
|
lines.push(`REV:${toVcardTimestamp(contact.updated)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const components = contact.name?.components || [];
|
const components = contact.name?.components || [];
|
||||||
@@ -1224,8 +1313,10 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
|| Object.values(contact.organizations || {})[0]?.name
|
|| Object.values(contact.organizations || {})[0]?.name
|
||||||
|| '';
|
|| '';
|
||||||
if (fn) {
|
if (fn) {
|
||||||
lines.push(`FN:${encodeValue(fn)}`);
|
// vCard does not order properties, but some importers drop the first
|
||||||
|
// and last name unless N comes before FN (#1407).
|
||||||
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)}`);
|
||||||
|
lines.push(`FN:${encodeValue(fn)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// vCard has no second-surname slot; X-MAIDENNAME is the de-facto extension
|
// vCard has no second-surname slot; X-MAIDENNAME is the de-facto extension
|
||||||
@@ -1259,11 +1350,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
const typeParts: string[] = [];
|
const typeParts: string[] = [];
|
||||||
const ctxType = contextToType(phone.contexts);
|
const ctxType = contextToType(phone.contexts);
|
||||||
if (ctxType) typeParts.push(ctxType);
|
if (ctxType) typeParts.push(ctxType);
|
||||||
if (phone.features) {
|
typeParts.push(...phoneFeaturesToTypes(phone.features));
|
||||||
for (const feat of Object.keys(phone.features)) {
|
|
||||||
if (phone.features[feat]) typeParts.push(feat.toUpperCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const params: string[] = [];
|
const params: string[] = [];
|
||||||
if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(',')}`);
|
if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(',')}`);
|
||||||
if (phone.pref) params.push(`PREF=${phone.pref}`);
|
if (phone.pref) params.push(`PREF=${phone.pref}`);
|
||||||
@@ -1326,16 +1413,16 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
const dateStr = anniversaryDateToVcardString(ann.date);
|
const dateStr = anniversaryDateToVcardString(ann.date);
|
||||||
if (ann.kind === 'birth') {
|
if (ann.kind === 'birth') {
|
||||||
if (dateStr) lines.push(`BDAY:${dateStr}`);
|
if (dateStr) lines.push(`BDAY:${dateStr}`);
|
||||||
if (ann.place?.fullAddress) {
|
// `full` as the server stores it (contactToWire), fullAddress as an
|
||||||
lines.push(`BIRTHPLACE:${encodeValue(ann.place.fullAddress)}`);
|
// import holds it before the save.
|
||||||
}
|
const place = ann.place?.full || ann.place?.fullAddress;
|
||||||
|
if (place) lines.push(`BIRTHPLACE:${encodeValue(place)}`);
|
||||||
} else if (ann.kind === 'wedding') {
|
} else if (ann.kind === 'wedding') {
|
||||||
if (dateStr) lines.push(`ANNIVERSARY:${dateStr}`);
|
if (dateStr) lines.push(`ANNIVERSARY:${dateStr}`);
|
||||||
} else if (ann.kind === 'death') {
|
} else if (ann.kind === 'death') {
|
||||||
if (dateStr) lines.push(`DEATHDATE:${dateStr}`);
|
if (dateStr) lines.push(`DEATHDATE:${dateStr}`);
|
||||||
if (ann.place?.fullAddress) {
|
const place = ann.place?.full || ann.place?.fullAddress;
|
||||||
lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`);
|
if (place) lines.push(`DEATHPLACE:${encodeValue(place)}`);
|
||||||
}
|
|
||||||
} else if (dateStr) {
|
} else if (dateStr) {
|
||||||
// No standard property for an unclassified date; X-ABDATE is the one
|
// No standard property for an unclassified date; X-ABDATE is the one
|
||||||
// the common exporters (and our own importer) understand.
|
// the common exporters (and our own importer) understand.
|
||||||
@@ -1389,8 +1476,13 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
if (contact.relatedTo) {
|
if (contact.relatedTo) {
|
||||||
for (const [uri, rel] of Object.entries(contact.relatedTo)) {
|
for (const [uri, rel] of Object.entries(contact.relatedTo)) {
|
||||||
const relType = rel.relation ? Object.keys(rel.relation).find((k) => rel.relation![k]) : undefined;
|
const relType = rel.relation ? Object.keys(rel.relation).find((k) => rel.relation![k]) : undefined;
|
||||||
const typeParam = relType ? `;TYPE=${relType}` : '';
|
const params: string[] = [];
|
||||||
lines.push(`RELATED${typeParam}:${uri}`);
|
// RELATED is a URI by default (RFC 6350 §6.6.6); a bare name, as
|
||||||
|
// imported from Apple or Android, is text.
|
||||||
|
if (!hasUriScheme(uri)) params.push('VALUE=text');
|
||||||
|
if (relType) params.push(`TYPE=${relType}`);
|
||||||
|
const paramStr = params.length > 0 ? `;${params.join(';')}` : '';
|
||||||
|
lines.push(`RELATED${paramStr}:${hasUriScheme(uri) ? uri : encodeValue(uri)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,8 +1567,9 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
// (0.16.x) and `data:base64,` (0.16.0); the card's mediaType fills in.
|
// (0.16.x) and `data:base64,` (0.16.0); the card's mediaType fills in.
|
||||||
const parsed = parseBase64DataUri(media.uri);
|
const parsed = parseBase64DataUri(media.uri);
|
||||||
if (parsed?.data) {
|
if (parsed?.data) {
|
||||||
const type = parsed.type || media.mediaType;
|
// vCard 4.0 carries inline media as a data: URI (no ENCODING=b).
|
||||||
lines.push(`${prop};ENCODING=b${type ? `;TYPE=${type}` : ''}:${parsed.data}`);
|
const type = parsed.type || media.mediaType || '';
|
||||||
|
lines.push(`${prop}:data:${type};base64,${parsed.data}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : '';
|
const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : '';
|
||||||
@@ -1490,7 +1583,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
if (contact.addresses) {
|
if (contact.addresses) {
|
||||||
for (const addr of Object.values(contact.addresses)) {
|
for (const addr of Object.values(contact.addresses)) {
|
||||||
if (addr.coordinates) {
|
if (addr.coordinates) {
|
||||||
lines.push(`GEO:${addr.coordinates}`);
|
lines.push(`GEO:${toGeoUri(addr.coordinates)}`);
|
||||||
}
|
}
|
||||||
if (addr.timeZone) {
|
if (addr.timeZone) {
|
||||||
lines.push(`TZ:${addr.timeZone}`);
|
lines.push(`TZ:${addr.timeZone}`);
|
||||||
@@ -1526,12 +1619,12 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contact.created) {
|
if (contact.created) {
|
||||||
// RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip.
|
// RFC 9554 §3.1 - CREATED is a timestamp.
|
||||||
lines.push(`CREATED:${contact.created}`);
|
lines.push(`CREATED:${toVcardTimestamp(contact.created)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('END:VCARD');
|
lines.push('END:VCARD');
|
||||||
return lines.join('\r\n');
|
return lines.map((line) => `${foldLine(line)}\r\n`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function detectDuplicates(
|
export function detectDuplicates(
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ function contextLabel(key: string, t: TranslateFn): string {
|
|||||||
function phoneFeatureLabel(key: string, t: TranslateFn): string {
|
function phoneFeatureLabel(key: string, t: TranslateFn): string {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'voice': return t('contacts.form.phone_voice', 'Voice');
|
case 'voice': return t('contacts.form.phone_voice', 'Voice');
|
||||||
|
case 'mobile':
|
||||||
case 'cell': return t('contacts.form.phone_cell', 'Mobile');
|
case 'cell': return t('contacts.form.phone_cell', 'Mobile');
|
||||||
case 'fax': return t('contacts.form.phone_fax', 'Fax');
|
case 'fax': return t('contacts.form.phone_fax', 'Fax');
|
||||||
case 'pager': return t('contacts.form.phone_pager', 'Pager');
|
case 'pager': return t('contacts.form.phone_pager', 'Pager');
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ function formOptions(t: TranslateFn) {
|
|||||||
const phoneFeatures: Option[] = [
|
const phoneFeatures: Option[] = [
|
||||||
{ value: '', label: t('contacts.form.phone', 'Phone') },
|
{ value: '', label: t('contacts.form.phone', 'Phone') },
|
||||||
{ value: 'voice', label: t('contacts.form.phone_voice', 'Voice') },
|
{ value: 'voice', label: t('contacts.form.phone_voice', 'Voice') },
|
||||||
{ value: 'cell', label: t('contacts.form.phone_cell', 'Mobile') },
|
{ value: 'mobile', label: t('contacts.form.phone_cell', 'Mobile') },
|
||||||
{ value: 'fax', label: t('contacts.form.phone_fax', 'Fax') },
|
{ value: 'fax', label: t('contacts.form.phone_fax', 'Fax') },
|
||||||
{ value: 'pager', label: t('contacts.form.phone_pager', 'Pager') },
|
{ value: 'pager', label: t('contacts.form.phone_pager', 'Pager') },
|
||||||
{ value: 'video', label: t('contacts.form.phone_video', 'Video') },
|
{ value: 'video', label: t('contacts.form.phone_video', 'Video') },
|
||||||
|
|||||||
Reference in New Issue
Block a user