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 { Avatar } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
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 { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||||
import { useSmimeStore } from "@/stores/smime-store";
|
import { useSmimeStore } from "@/stores/smime-store";
|
||||||
import { parseCertificatePemOrDer, extractCertificateInfo } from "@/lib/smime/certificate-utils";
|
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(", ");
|
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 PartialDate objects: { year?, month?, day?, calendarScale? }
|
||||||
|
// Handle RFC 9553 Timestamp objects: { "@type": "Timestamp", utc: "..." }
|
||||||
if (typeof dateInput === 'object' && dateInput !== null) {
|
if (typeof dateInput === 'object' && dateInput !== null) {
|
||||||
const year = dateInput.year as number | undefined;
|
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
|
||||||
const month = dateInput.month as number | undefined;
|
try {
|
||||||
const day = dateInput.day as number | undefined;
|
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 monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (month && monthNames[month - 1]) parts.push(monthNames[month - 1]);
|
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) => (
|
{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 key={i} className="text-sm space-y-0.5 rounded-md border border-border/60 bg-muted/30 p-3">
|
||||||
<div>
|
<div>
|
||||||
{a.fullAddress
|
{a.full || a.fullAddress
|
||||||
? a.fullAddress
|
? (a.full || a.fullAddress)
|
||||||
: [a.street, a.locality, a.region, a.postcode, a.country].filter(Boolean).join(", ")}
|
: 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} />}
|
{a.contexts && <ContextBadge contexts={a.contexts} />}
|
||||||
</div>
|
</div>
|
||||||
{a.timeZone && (
|
{a.timeZone && (
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { cn } from "@/lib/utils";
|
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 {
|
interface EmailEntry {
|
||||||
address: string;
|
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 || "";
|
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 [prefix, setPrefix] = useState(findComponent("prefix"));
|
||||||
const [givenName, setGivenName] = useState(findComponent("given"));
|
const [givenName, setGivenName] = useState(findComponent("given"));
|
||||||
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
const [additionalName, setAdditionalName] = useState(findComponent("additional"));
|
||||||
@@ -184,14 +245,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
|
const [addresses, setAddresses] = useState<AddressEntry[]>(() => {
|
||||||
if (contact?.addresses) {
|
if (contact?.addresses) {
|
||||||
return Object.values(contact.addresses).map(a => ({
|
return Object.values(contact.addresses).map(a => addressToFlat(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 [];
|
return [];
|
||||||
});
|
});
|
||||||
@@ -210,7 +264,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
|
const [anniversaries, setAnniversaries] = useState<AnniversaryEntry[]>(() => {
|
||||||
if (contact?.anniversaries) {
|
if (contact?.anniversaries) {
|
||||||
return Object.values(contact.anniversaries).map(a => ({
|
return Object.values(contact.anniversaries).map(a => ({
|
||||||
date: a.date,
|
date: anniversaryDateToString(a.date),
|
||||||
kind: a.kind,
|
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> = {};
|
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) => {
|
addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
|
||||||
const obj: Record<string, unknown> = {};
|
const components: Array<{ kind: string; value: string }> = [];
|
||||||
if (a.street.trim()) obj.street = a.street.trim();
|
if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() });
|
||||||
if (a.locality.trim()) obj.locality = a.locality.trim();
|
if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() });
|
||||||
if (a.region.trim()) obj.region = a.region.trim();
|
if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() });
|
||||||
if (a.postcode.trim()) obj.postcode = a.postcode.trim();
|
if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() });
|
||||||
if (a.country.trim()) obj.country = a.country.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 };
|
if (a.context) obj.contexts = { [a.context]: true };
|
||||||
// @ts-expect-error - dynamic build
|
// @ts-expect-error - dynamic build
|
||||||
addressesMap[`a${i}`] = obj;
|
addressesMap[`a${i}`] = obj;
|
||||||
@@ -357,7 +412,7 @@ export function ContactForm({ contact, addressBooks, onSave, onCancel }: Contact
|
|||||||
|
|
||||||
const anniversariesMap: Record<string, ContactAnniversary> = {};
|
const anniversariesMap: Record<string, ContactAnniversary> = {};
|
||||||
anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
|
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> = {};
|
const personalInfoMap: Record<string, ContactPersonalInfo> = {};
|
||||||
|
|||||||
@@ -706,7 +706,11 @@ function ContactSidebarPanel({
|
|||||||
<SidebarSection icon={MapPin} title="Addresses">
|
<SidebarSection icon={MapPin} title="Addresses">
|
||||||
{addresses.map((a, i) => (
|
{addresses.map((a, i) => (
|
||||||
<div key={i} className="text-sm text-muted-foreground">
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|||||||
+32
-1
@@ -252,7 +252,20 @@ export interface ContactTitle {
|
|||||||
organizationId?: string;
|
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 {
|
export interface ContactAddress {
|
||||||
|
// RFC 9553 format
|
||||||
|
components?: AddressComponent[];
|
||||||
|
full?: string;
|
||||||
|
isOrdered?: boolean;
|
||||||
|
defaultSeparator?: string;
|
||||||
|
// Legacy flat fields (from vCard import)
|
||||||
street?: string;
|
street?: string;
|
||||||
locality?: string;
|
locality?: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
@@ -284,9 +297,27 @@ export interface ContactMedia {
|
|||||||
mediaType?: string;
|
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 {
|
export interface ContactAnniversary {
|
||||||
|
'@type'?: 'Anniversary';
|
||||||
kind: 'birth' | 'death' | 'wedding' | 'other';
|
kind: 'birth' | 'death' | 'wedding' | 'other';
|
||||||
date: string;
|
date: AnniversaryDate;
|
||||||
place?: ContactAddress;
|
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> = {
|
const VCARD_SEX_TO_GENDER: Record<string, string> = {
|
||||||
M: "masculine",
|
M: "masculine",
|
||||||
@@ -598,14 +620,30 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
for (const addr of Object.values(contact.addresses)) {
|
for (const addr of Object.values(contact.addresses)) {
|
||||||
const type = contextToType(addr.contexts);
|
const type = contextToType(addr.contexts);
|
||||||
const typeParam = type ? `;TYPE=${type}` : "";
|
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 = [
|
const parts = [
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
addr.street || "",
|
street,
|
||||||
addr.locality || "",
|
locality,
|
||||||
addr.region || "",
|
region,
|
||||||
addr.postcode || "",
|
postcode,
|
||||||
addr.country || "",
|
country,
|
||||||
];
|
];
|
||||||
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
|
lines.push(`ADR${typeParam}:${parts.map(encodeValue).join(";")}`);
|
||||||
}
|
}
|
||||||
@@ -613,12 +651,13 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
|
|
||||||
if (contact.anniversaries) {
|
if (contact.anniversaries) {
|
||||||
for (const ann of Object.values(contact.anniversaries)) {
|
for (const ann of Object.values(contact.anniversaries)) {
|
||||||
|
const dateStr = anniversaryDateToVcardString(ann.date);
|
||||||
if (ann.kind === "birth") {
|
if (ann.kind === "birth") {
|
||||||
lines.push(`BDAY:${ann.date}`);
|
lines.push(`BDAY:${dateStr}`);
|
||||||
} else if (ann.kind === "wedding") {
|
} else if (ann.kind === "wedding") {
|
||||||
lines.push(`ANNIVERSARY:${ann.date}`);
|
lines.push(`ANNIVERSARY:${dateStr}`);
|
||||||
} else if (ann.kind === "death") {
|
} else if (ann.kind === "death") {
|
||||||
lines.push(`DEATHDATE:${ann.date}`);
|
lines.push(`DEATHDATE:${dateStr}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user