fix: export contacts as vCard 4.0 that other apps read in full #1407

This commit is contained in:
Linus Rath
2026-10-29 03:28:10 +01:00
parent 32f70c103d
commit 07fd03e284
7 changed files with 206 additions and 48 deletions
+8
View File
@@ -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: '' }];
+3 -2
View File
@@ -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();
}); });
+61 -8
View File
@@ -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', () => {
+3 -1
View File
@@ -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
View File
@@ -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(
+1
View File
@@ -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');
+1 -1
View File
@@ -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') },