fix: RFC 9553 compliance for contacts (birthday, addresses)

This commit is contained in:
Linus Rath
2026-03-19 13:33:53 +01:00
parent 2edf2fab89
commit 77514bd054
5 changed files with 178 additions and 36 deletions
+32 -1
View File
@@ -252,7 +252,20 @@ export interface ContactTitle {
organizationId?: string;
}
// RFC 9553 AddressComponent
export interface AddressComponent {
kind: 'room' | 'apartment' | 'floor' | 'building' | 'number' | 'name' | 'block' | 'subDistrict' | 'district' | 'locality' | 'region' | 'postcode' | 'country' | 'direction' | 'landmark' | 'postOfficeBox' | 'separator' | string;
value: string;
phonetic?: string;
}
export interface ContactAddress {
// RFC 9553 format
components?: AddressComponent[];
full?: string;
isOrdered?: boolean;
defaultSeparator?: string;
// Legacy flat fields (from vCard import)
street?: string;
locality?: string;
region?: string;
@@ -284,9 +297,27 @@ export interface ContactMedia {
mediaType?: string;
}
// RFC 9553 PartialDate
export interface PartialDate {
'@type'?: 'PartialDate';
year?: number;
month?: number;
day?: number;
calendarScale?: string;
}
// RFC 9553 Timestamp
export interface Timestamp {
'@type': 'Timestamp';
utc: string;
}
export type AnniversaryDate = string | PartialDate | Timestamp;
export interface ContactAnniversary {
'@type'?: 'Anniversary';
kind: 'birth' | 'death' | 'wedding' | 'other';
date: string;
date: AnniversaryDate;
place?: ContactAddress;
}
+48 -9
View File
@@ -1,4 +1,26 @@
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types";
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
// Convert RFC 9553 AnniversaryDate (PartialDate|Timestamp|string) to vCard date string
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];
}
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')}`;
}
if (pd.month && pd.day) {
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')}`;
}
if (pd.year) return String(pd.year);
}
return String(date);
}
const VCARD_SEX_TO_GENDER: Record<string, string> = {
M: "masculine",
@@ -598,14 +620,30 @@ function generateSingleVCard(contact: ContactCard): string {
for (const addr of Object.values(contact.addresses)) {
const type = contextToType(addr.contexts);
const typeParam = type ? `;TYPE=${type}` : "";
let street = addr.street || "";
let locality = addr.locality || "";
let region = addr.region || "";
let postcode = addr.postcode || "";
let country = addr.country || "";
// RFC 9553 components-based address: extract flat fields for vCard ADR
if (addr.components && addr.components.length > 0) {
const findComp = (kind: string) => addr.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
const number = findComp('number');
const name = findComp('name');
street = street || [number, name].filter(Boolean).join(' ');
locality = locality || findComp('locality');
region = region || findComp('region');
postcode = postcode || findComp('postcode');
country = country || findComp('country');
}
const parts = [
"",
"",
addr.street || "",
addr.locality || "",
addr.region || "",
addr.postcode || "",
addr.country || "",
street,
locality,
region,
postcode,
country,
];
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
}
@@ -613,12 +651,13 @@ function generateSingleVCard(contact: ContactCard): string {
if (contact.anniversaries) {
for (const ann of Object.values(contact.anniversaries)) {
const dateStr = anniversaryDateToVcardString(ann.date);
if (ann.kind === "birth") {
lines.push(`BDAY:${ann.date}`);
lines.push(`BDAY:${dateStr}`);
} else if (ann.kind === "wedding") {
lines.push(`ANNIVERSARY:${ann.date}`);
lines.push(`ANNIVERSARY:${dateStr}`);
} else if (ann.kind === "death") {
lines.push(`DEATHDATE:${ann.date}`);
lines.push(`DEATHDATE:${dateStr}`);
}
}
}