fix: RFC 9553 compliance for contacts (birthday, addresses)
This commit is contained in:
@@ -6,7 +6,7 @@ import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Co
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
import { useSmimeStore } from "@/stores/smime-store";
|
||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
||||
@@ -26,12 +26,23 @@ function formatPhoneFeatures(features?: Record<string, boolean>): string {
|
||||
return Object.keys(features).filter(k => features[k]).join(", ");
|
||||
}
|
||||
|
||||
function formatDate(dateInput: string | Record<string, unknown>): string {
|
||||
function formatDate(dateInput: AnniversaryDate): string {
|
||||
// Handle RFC 9553 PartialDate objects: { year?, month?, day?, calendarScale? }
|
||||
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||
const year = dateInput.year as number | undefined;
|
||||
const month = dateInput.month as number | undefined;
|
||||
const day = dateInput.day as number | undefined;
|
||||
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||
try {
|
||||
const d = new Date(dateInput.utc as string);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
return String(dateInput.utc);
|
||||
}
|
||||
const pd = dateInput as PartialDate;
|
||||
const year = pd.year;
|
||||
const month = pd.month;
|
||||
const day = pd.day;
|
||||
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const parts: string[] = [];
|
||||
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
||||
@@ -282,9 +293,11 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
||||
{addresses.map((a, i) => (
|
||||
<div key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
||||
<div>
|
||||
{a.fullAddress
|
||||
? a.fullAddress
|
||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||
{a.full || a.fullAddress
|
||||
? (a.full || a.fullAddress)
|
||||
: a.components && a.components.length > 0
|
||||
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
||||
</div>
|
||||
{a.timeZone && (
|
||||
|
||||
@@ -6,7 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook } from "@/lib/jmap/types";
|
||||
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress } from "@/lib/jmap/types";
|
||||
|
||||
interface EmailEntry {
|
||||
address: string;
|
||||
@@ -129,6 +129,67 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
|
||||
const findComponent = (kind: string) => contact?.name?.components?.find(c => c.kind === kind)?.value || "";
|
||||
|
||||
// Convert RFC 9553 AnniversaryDate to ISO date string for HTML date input
|
||||
function anniversaryDateToString(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);
|
||||
}
|
||||
|
||||
// Convert ISO date string back to RFC 9553 PartialDate for the server
|
||||
function stringToPartialDate(str: string): PartialDate {
|
||||
if (str.startsWith('--')) {
|
||||
const parts = str.substring(2).split('-');
|
||||
const pd: PartialDate = { month: parseInt(parts[0], 10) };
|
||||
if (parts[1]) pd.day = parseInt(parts[1], 10);
|
||||
return pd;
|
||||
}
|
||||
const parts = str.split('-');
|
||||
const pd: PartialDate = {};
|
||||
if (parts[0]) pd.year = parseInt(parts[0], 10);
|
||||
if (parts[1]) pd.month = parseInt(parts[1], 10);
|
||||
if (parts[2]) pd.day = parseInt(parts[2], 10);
|
||||
return pd;
|
||||
}
|
||||
|
||||
// Extract flat address fields from RFC 9553 components format
|
||||
function addressToFlat(a: ContactAddress): AddressEntry {
|
||||
if (a.components && a.components.length > 0) {
|
||||
const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
|
||||
return {
|
||||
street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '',
|
||||
locality: findComp('locality'),
|
||||
region: findComp('region'),
|
||||
postcode: findComp('postcode'),
|
||||
country: findComp('country'),
|
||||
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
street: a.street || '',
|
||||
locality: a.locality || '',
|
||||
region: a.region || '',
|
||||
postcode: a.postcode || '',
|
||||
country: a.country || '',
|
||||
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
|
||||
};
|
||||
}
|
||||
|
||||
const [prefix, setPrefix] = useState(findComponent("prefix"));
|
||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
||||
@@ -184,14 +245,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
|
||||
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
|
||||
if (contact?.addresses) {
|
||||
return Object.values(contact.addresses).map(a => ({
|
||||
street: a.street || "",
|
||||
locality: a.locality || "",
|
||||
region: a.region || "",
|
||||
postcode: a.postcode || "",
|
||||
country: a.country || "",
|
||||
context: a.contexts?.work ? "work" : a.contexts?.private ? "private" : "",
|
||||
}));
|
||||
return Object.values(contact.addresses).map(a => addressToFlat(a));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
@@ -210,7 +264,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
|
||||
if (contact?.anniversaries) {
|
||||
return Object.values(contact.anniversaries).map(a => ({
|
||||
date: a.date,
|
||||
date: anniversaryDateToString(a.date),
|
||||
kind: a.kind,
|
||||
}));
|
||||
}
|
||||
@@ -336,12 +390,13 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
|
||||
const addressesMap: Record<string, ContactCard["addresses"] extends Record<string, infer V> ? V : never> = {};
|
||||
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
if (a.street.trim()) obj.street = a.street.trim();
|
||||
if (a.locality.trim()) obj.locality = a.locality.trim();
|
||||
if (a.region.trim()) obj.region = a.region.trim();
|
||||
if (a.postcode.trim()) obj.postcode = a.postcode.trim();
|
||||
if (a.country.trim()) obj.country = a.country.trim();
|
||||
const components: Array<{ kind: string; value: string }> = [];
|
||||
if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() });
|
||||
if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() });
|
||||
if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() });
|
||||
if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() });
|
||||
if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() });
|
||||
const obj: Record<string, unknown> = { components, isOrdered: true, defaultSeparator: ", " };
|
||||
if (a.context) obj.contexts = { [a.context]: true };
|
||||
// @ts-expect-error - dynamic build
|
||||
addressesMap[`a${i}`] = obj;
|
||||
@@ -357,7 +412,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
||||
|
||||
const anniversariesMap: Record<string, ContactAnniversary> = {};
|
||||
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
|
||||
anniversariesMap[`an${i}`] = { date: a.date.trim(), kind: a.kind };
|
||||
anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind };
|
||||
});
|
||||
|
||||
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
|
||||
|
||||
@@ -706,7 +706,11 @@ function ContactSidebarPanel({
|
||||
<SidebarSection icon={MapPin} title="Addresses">
|
||||
{addresses.map((a, i) => (
|
||||
<div key={i} className="text-sm text-muted-foreground">
|
||||
{[a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||
{a.full || a.fullAddress
|
||||
? (a.full || a.fullAddress)
|
||||
: a.components && a.components.length > 0
|
||||
? a.components.filter(c => c.kind !== 'separator').map(c => c.value).filter(Boolean).join(", ")
|
||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
||||
</div>
|
||||
))}
|
||||
</SidebarSection>
|
||||
|
||||
+32
-1
@@ -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
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user