mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 00:06:17 +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', () => {
|
||||
// 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) => {
|
||||
const form = contactToForm(card, []);
|
||||
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=' } });
|
||||
|
||||
const vcf = contactsToVCard(out);
|
||||
expect(vcf).toContain(`PHOTO;ENCODING=b;TYPE=image/jpeg:${Buffer.from(JPEG_BYTES).toString('base64')}`);
|
||||
expect(vcf).toContain('PHOTO;ENCODING=b;TYPE=image/png:iVBORw0KGgo=');
|
||||
const unfolded = vcf.replace(/\r\n /g, '');
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -45,7 +45,8 @@ describe('parseVCard', () => {
|
||||
expect(emails[0].contexts).toEqual({ work: true });
|
||||
const phones = Object.values(card.phones ?? {});
|
||||
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 surname = card.name?.components?.find((c) => c.kind === 'surname')?.value;
|
||||
expect(given).toBe('Jane');
|
||||
@@ -115,6 +116,58 @@ describe('generateVCard', () => {
|
||||
expect(out.match(/BEGIN: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', () => {
|
||||
@@ -185,7 +238,7 @@ describe('anniversary dates (issue #224)', () => {
|
||||
|
||||
it('round-trips a birthday through generateVCard', () => {
|
||||
const exported = generateVCard([parseOne('BDAY:19850412')]);
|
||||
expect(exported).toContain('BDAY:1985-04-12');
|
||||
expect(exported).toContain('BDAY:19850412');
|
||||
const reparsed = parseVCard(exported)[0];
|
||||
expect(Object.values(reparsed.anniversaries || {})[0]?.date).toEqual({
|
||||
year: 1985, month: 4, day: 12,
|
||||
@@ -194,7 +247,7 @@ describe('anniversary dates (issue #224)', () => {
|
||||
|
||||
it('round-trips a day/month-only birthday', () => {
|
||||
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)
|
||||
.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 } } },
|
||||
};
|
||||
const exported = generateVCard([card]);
|
||||
expect(exported).toContain('X-ABDATE:2015-09-20');
|
||||
expect(exported).toContain('X-ABDATE:20150920');
|
||||
const reparsed = parseVCard(exported)[0];
|
||||
expect(Object.values(reparsed.anniversaries || {})).toEqual([
|
||||
{ kind: 'other', date: { year: 2015, month: 9, day: 20 } },
|
||||
@@ -399,10 +452,10 @@ describe('photo export', () => {
|
||||
d: { kind: 'photo', uri: 'data:;base64,DDDD' },
|
||||
})]);
|
||||
const lines = vcf.split('\r\n');
|
||||
expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/png:AAAA');
|
||||
expect(lines).toContain('LOGO;ENCODING=b;TYPE=image/jpeg:BBBB');
|
||||
expect(lines).toContain('PHOTO;ENCODING=b;TYPE=image/jpeg:CCCC');
|
||||
expect(lines).toContain('PHOTO;ENCODING=b:DDDD');
|
||||
expect(lines).toContain('PHOTO:data:image/png;base64,AAAA');
|
||||
expect(lines).toContain('LOGO:data:image/jpeg;base64,BBBB');
|
||||
expect(lines).toContain('PHOTO:data:image/jpeg;base64,CCCC');
|
||||
expect(lines).toContain('PHOTO:data:;base64,DDDD');
|
||||
});
|
||||
|
||||
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) => ({
|
||||
number: p.number,
|
||||
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:
|
||||
p.features?.cell ? 'cell'
|
||||
p.features?.mobile || p.features?.cell ? 'mobile'
|
||||
: p.features?.fax ? 'fax'
|
||||
: p.features?.pager ? 'pager'
|
||||
: p.features?.video ? 'video'
|
||||
|
||||
+129
-36
@@ -9,19 +9,21 @@ import { generateUUID } from './uuid';
|
||||
import { deriveFullName } from './contact-utils';
|
||||
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 {
|
||||
if (typeof date === 'string') return date;
|
||||
if (date && typeof date === 'object') {
|
||||
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;
|
||||
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) {
|
||||
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) {
|
||||
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';
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!typeStr) return undefined;
|
||||
const types = typeStr.toUpperCase().split(',');
|
||||
const features: Record<string, boolean> = {};
|
||||
for (const t of types) {
|
||||
if (PHONE_FEATURE_TYPES.has(t)) {
|
||||
features[t.toLowerCase()] = true;
|
||||
}
|
||||
const feature = TEL_TYPE_TO_PHONE_FEATURE[t];
|
||||
if (feature) features[feature] = true;
|
||||
}
|
||||
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 {
|
||||
if (!typeStr) return undefined;
|
||||
const types = typeStr.toUpperCase().split(',');
|
||||
@@ -1185,19 +1225,68 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
|
||||
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 {
|
||||
return contacts.map(generateSingleVCard).join('\r\n');
|
||||
return contacts.map(generateSingleVCard).join('');
|
||||
}
|
||||
|
||||
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) {
|
||||
lines.push(`UID:${contact.uid}`);
|
||||
}
|
||||
|
||||
if (contact.prodId) {
|
||||
lines.push(`PRODID:${contact.prodId}`);
|
||||
// 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.kind) {
|
||||
@@ -1205,7 +1294,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
if (contact.updated) {
|
||||
lines.push(`REV:${contact.updated}`);
|
||||
lines.push(`REV:${toVcardTimestamp(contact.updated)}`);
|
||||
}
|
||||
|
||||
const components = contact.name?.components || [];
|
||||
@@ -1224,8 +1313,10 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
|| Object.values(contact.organizations || {})[0]?.name
|
||||
|| '';
|
||||
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(`FN:${encodeValue(fn)}`);
|
||||
}
|
||||
|
||||
// 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 ctxType = contextToType(phone.contexts);
|
||||
if (ctxType) typeParts.push(ctxType);
|
||||
if (phone.features) {
|
||||
for (const feat of Object.keys(phone.features)) {
|
||||
if (phone.features[feat]) typeParts.push(feat.toUpperCase());
|
||||
}
|
||||
}
|
||||
typeParts.push(...phoneFeaturesToTypes(phone.features));
|
||||
const params: string[] = [];
|
||||
if (typeParts.length > 0) params.push(`TYPE=${typeParts.join(',')}`);
|
||||
if (phone.pref) params.push(`PREF=${phone.pref}`);
|
||||
@@ -1326,16 +1413,16 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
const dateStr = anniversaryDateToVcardString(ann.date);
|
||||
if (ann.kind === 'birth') {
|
||||
if (dateStr) lines.push(`BDAY:${dateStr}`);
|
||||
if (ann.place?.fullAddress) {
|
||||
lines.push(`BIRTHPLACE:${encodeValue(ann.place.fullAddress)}`);
|
||||
}
|
||||
// `full` as the server stores it (contactToWire), fullAddress as an
|
||||
// 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') {
|
||||
if (dateStr) lines.push(`ANNIVERSARY:${dateStr}`);
|
||||
} else if (ann.kind === 'death') {
|
||||
if (dateStr) lines.push(`DEATHDATE:${dateStr}`);
|
||||
if (ann.place?.fullAddress) {
|
||||
lines.push(`DEATHPLACE:${encodeValue(ann.place.fullAddress)}`);
|
||||
}
|
||||
const place = ann.place?.full || ann.place?.fullAddress;
|
||||
if (place) lines.push(`DEATHPLACE:${encodeValue(place)}`);
|
||||
} else if (dateStr) {
|
||||
// No standard property for an unclassified date; X-ABDATE is the one
|
||||
// the common exporters (and our own importer) understand.
|
||||
@@ -1389,8 +1476,13 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
if (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 typeParam = relType ? `;TYPE=${relType}` : '';
|
||||
lines.push(`RELATED${typeParam}:${uri}`);
|
||||
const params: string[] = [];
|
||||
// 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.
|
||||
const parsed = parseBase64DataUri(media.uri);
|
||||
if (parsed?.data) {
|
||||
const type = parsed.type || media.mediaType;
|
||||
lines.push(`${prop};ENCODING=b${type ? `;TYPE=${type}` : ''}:${parsed.data}`);
|
||||
// vCard 4.0 carries inline media as a data: URI (no ENCODING=b).
|
||||
const type = parsed.type || media.mediaType || '';
|
||||
lines.push(`${prop}:data:${type};base64,${parsed.data}`);
|
||||
}
|
||||
} else {
|
||||
const mt = media.mediaType ? `;MEDIATYPE=${media.mediaType}` : '';
|
||||
@@ -1490,7 +1583,7 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
if (contact.addresses) {
|
||||
for (const addr of Object.values(contact.addresses)) {
|
||||
if (addr.coordinates) {
|
||||
lines.push(`GEO:${addr.coordinates}`);
|
||||
lines.push(`GEO:${toGeoUri(addr.coordinates)}`);
|
||||
}
|
||||
if (addr.timeZone) {
|
||||
lines.push(`TZ:${addr.timeZone}`);
|
||||
@@ -1526,12 +1619,12 @@ function generateSingleVCard(contact: ContactCard): string {
|
||||
}
|
||||
|
||||
if (contact.created) {
|
||||
// RFC 9554 §3.1 - CREATED is a timestamp; emit as-is for round-trip.
|
||||
lines.push(`CREATED:${contact.created}`);
|
||||
// RFC 9554 §3.1 - CREATED is a timestamp.
|
||||
lines.push(`CREATED:${toVcardTimestamp(contact.created)}`);
|
||||
}
|
||||
|
||||
lines.push('END:VCARD');
|
||||
return lines.join('\r\n');
|
||||
return lines.map((line) => `${foldLine(line)}\r\n`).join('');
|
||||
}
|
||||
|
||||
export function detectDuplicates(
|
||||
|
||||
@@ -62,6 +62,7 @@ function contextLabel(key: string, t: TranslateFn): string {
|
||||
function phoneFeatureLabel(key: string, t: TranslateFn): string {
|
||||
switch (key) {
|
||||
case 'voice': return t('contacts.form.phone_voice', 'Voice');
|
||||
case 'mobile':
|
||||
case 'cell': return t('contacts.form.phone_cell', 'Mobile');
|
||||
case 'fax': return t('contacts.form.phone_fax', 'Fax');
|
||||
case 'pager': return t('contacts.form.phone_pager', 'Pager');
|
||||
|
||||
@@ -45,7 +45,7 @@ function formOptions(t: TranslateFn) {
|
||||
const phoneFeatures: Option[] = [
|
||||
{ value: '', label: t('contacts.form.phone', 'Phone') },
|
||||
{ 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: 'pager', label: t('contacts.form.phone_pager', 'Pager') },
|
||||
{ value: 'video', label: t('contacts.form.phone_video', 'Video') },
|
||||
|
||||
Reference in New Issue
Block a user