From 9fdbb62205e0d3ded16076d364e5df0420e39bc1 Mon Sep 17 00:00:00 2001 From: Linus Rath Date: Wed, 18 Mar 2026 17:55:29 +0100 Subject: [PATCH] fix: update gender handling to use speakToAs structure and adjust localization keys --- components/contacts/contact-detail.tsx | 13 ++++--- components/contacts/contact-form.tsx | 23 +++++++----- lib/__tests__/vcard.test.ts | 4 +- lib/jmap/types.ts | 5 ++- lib/vcard.ts | 51 ++++++++++++++++++++++---- locales/de/common.json | 10 ++--- locales/en/common.json | 10 ++--- locales/es/common.json | 10 ++--- locales/fr/common.json | 10 ++--- locales/it/common.json | 10 ++--- locales/ja/common.json | 10 ++--- locales/nl/common.json | 10 ++--- locales/pt/common.json | 10 ++--- 13 files changed, 112 insertions(+), 64 deletions(-) diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 9d69d210..ae05be43 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -351,13 +351,16 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className } )} - {contact.gender && (contact.gender.sex || contact.gender.identity) && ( + {contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
- {contact.gender.sex && {t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}} - {contact.gender.identity && ( - {contact.gender.sex ? " — " : ""}{contact.gender.identity} - )} + {contact.speakToAs.grammaticalGender && {t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}} + {contact.speakToAs.pronouns && (() => { + const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns; + return firstPronoun ? ( + {contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun} + ) : null; + })()}
)} diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx index 657be3a2..7ef14167 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -235,8 +235,10 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { contact?.notes ? Object.values(contact.notes)[0]?.note || "" : "" ); - const [genderSex, setGenderSex] = useState(contact?.gender?.sex || ""); - const [genderIdentity, setGenderIdentity] = useState(contact?.gender?.identity || ""); + const [genderSex, setGenderSex] = useState(contact?.speakToAs?.grammaticalGender || ""); + const [genderIdentity, setGenderIdentity] = useState( + contact?.speakToAs?.pronouns ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || "" : "" + ); const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || ""); const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || ""); const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || ""); @@ -371,8 +373,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) { notes: note.trim() ? { n0: { note: note.trim() } } : undefined, - gender: (genderSex.trim() || genderIdentity.trim()) - ? { sex: genderSex.trim() || undefined, identity: genderIdentity.trim() || undefined } + speakToAs: (genderSex.trim() || genderIdentity.trim()) + ? { + grammaticalGender: genderSex.trim() || undefined, + pronouns: genderIdentity.trim() ? { p0: { pronouns: genderIdentity.trim() } } : undefined, + } : undefined, calendarUri: calendarUri.trim() || undefined, schedulingUri: schedulingUri.trim() || undefined, @@ -737,11 +742,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts index acca5283..ccff3dc7 100644 --- a/lib/__tests__/vcard.test.ts +++ b/lib/__tests__/vcard.test.ts @@ -226,7 +226,7 @@ describe("parseVCard", () => { expect(result).toHaveLength(1); const card = result[0]; - expect(card.gender).toEqual({ sex: "F", identity: "Female" }); + expect(card.speakToAs).toEqual({ grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } }); expect(card.media?.m0).toEqual({ kind: "logo", uri: "https://example.com/logo.png", @@ -331,7 +331,7 @@ describe("generateVCard", () => { components: [{ kind: "given", value: "Jane" }], isOrdered: true, }, - gender: { sex: "F", identity: "Female" }, + speakToAs: { grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } }, media: { m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" }, m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" }, diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index e7457149..c6748c21 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -183,7 +183,10 @@ export interface ContactCard { relatedTo?: Record; keywords?: Record; members?: Record; - gender?: { sex?: string; identity?: string }; + speakToAs?: { + grammaticalGender?: string; + pronouns?: Record }>; + }; calendarUri?: string; schedulingUri?: string; freeBusyUri?: string; diff --git a/lib/vcard.ts b/lib/vcard.ts index 6d0be68f..8fbc819f 100644 --- a/lib/vcard.ts +++ b/lib/vcard.ts @@ -1,5 +1,29 @@ import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types"; +const VCARD_SEX_TO_GENDER: Record = { + M: "masculine", + F: "feminine", + O: "other", + N: "none", + U: "unknown", +}; + +const GENDER_TO_VCARD_SEX: Record = { + masculine: "M", + feminine: "F", + other: "O", + none: "N", + unknown: "U", +}; + +function vcardSexToGrammaticalGender(sex: string): string { + return VCARD_SEX_TO_GENDER[sex.toUpperCase()] || sex.toLowerCase(); +} + +function grammaticalGenderToVcardSex(gender: string): string { + return GENDER_TO_VCARD_SEX[gender.toLowerCase()] || ""; +} + function unfoldLines(vcf: string): string { return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); } @@ -390,9 +414,17 @@ function buildContact(raw: Record): ContactCard | null { case "GENDER": { const gParts = val.split(";"); - card.gender = {}; - if (gParts[0]) card.gender.sex = gParts[0]; - if (gParts[1]) card.gender.identity = gParts[1]; + const sexCode = gParts[0]?.toUpperCase(); + const identityText = gParts[1]; + if (sexCode || identityText) { + card.speakToAs = {}; + if (sexCode) { + card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode); + } + if (identityText) { + card.speakToAs.pronouns = { p0: { pronouns: identityText } }; + } + } break; } @@ -684,10 +716,15 @@ function generateSingleVCard(contact: ContactCard): string { } } - if (contact.gender) { - const sex = contact.gender.sex || ""; - const identity = contact.gender.identity || ""; - lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`); + if (contact.speakToAs) { + const sex = contact.speakToAs.grammaticalGender + ? grammaticalGenderToVcardSex(contact.speakToAs.grammaticalGender) + : ""; + const pronouns = contact.speakToAs.pronouns; + const identity = pronouns ? Object.values(pronouns)[0]?.pronouns || "" : ""; + if (sex || identity) { + lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`); + } } if (contact.calendarUri) { diff --git a/locales/de/common.json b/locales/de/common.json index 0734ead9..2e1d1da0 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interesse", "personal_other": "Sonstiges", "gender": "Geschlecht", - "gender_M": "Männlich", - "gender_F": "Weiblich", - "gender_O": "Andere", - "gender_N": "Nicht zutreffend", - "gender_U": "Unbekannt", + "gender_masculine": "Männlich", + "gender_feminine": "Weiblich", + "gender_other": "Andere", + "gender_none": "Nicht zutreffend", + "gender_unknown": "Unbekannt", "calendar": "Kalender", "calendar_uri": "Kalender-URL", "scheduling_uri": "Terminplanungs-URL", diff --git a/locales/en/common.json b/locales/en/common.json index cdede18f..4bc6ebb2 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1505,11 +1505,11 @@ "personal_interest": "Interest", "personal_other": "Other", "gender": "Gender", - "gender_M": "Male", - "gender_F": "Female", - "gender_O": "Other", - "gender_N": "Not applicable", - "gender_U": "Unknown", + "gender_masculine": "Male", + "gender_feminine": "Female", + "gender_other": "Other", + "gender_none": "Not applicable", + "gender_unknown": "Unknown", "calendar": "Calendar", "calendar_uri": "Calendar URL", "scheduling_uri": "Scheduling URL", diff --git a/locales/es/common.json b/locales/es/common.json index 0213bb17..60051267 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interés", "personal_other": "Otro", "gender": "Género", - "gender_M": "Masculino", - "gender_F": "Femenino", - "gender_O": "Otro", - "gender_N": "No aplicable", - "gender_U": "Desconocido", + "gender_masculine": "Masculino", + "gender_feminine": "Femenino", + "gender_other": "Otro", + "gender_none": "No aplicable", + "gender_unknown": "Desconocido", "calendar": "Calendario", "calendar_uri": "URL del calendario", "scheduling_uri": "URL de programación", diff --git a/locales/fr/common.json b/locales/fr/common.json index 51cfc93b..8419df01 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Intérêt", "personal_other": "Autre", "gender": "Genre", - "gender_M": "Masculin", - "gender_F": "Féminin", - "gender_O": "Autre", - "gender_N": "Non applicable", - "gender_U": "Inconnu", + "gender_masculine": "Masculin", + "gender_feminine": "Féminin", + "gender_other": "Autre", + "gender_none": "Non applicable", + "gender_unknown": "Inconnu", "calendar": "Calendrier", "calendar_uri": "URL du calendrier", "scheduling_uri": "URL de planification", diff --git a/locales/it/common.json b/locales/it/common.json index 010eec90..93d29fd7 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interesse", "personal_other": "Altro", "gender": "Genere", - "gender_M": "Maschile", - "gender_F": "Femminile", - "gender_O": "Altro", - "gender_N": "Non applicabile", - "gender_U": "Sconosciuto", + "gender_masculine": "Maschile", + "gender_feminine": "Femminile", + "gender_other": "Altro", + "gender_none": "Non applicabile", + "gender_unknown": "Sconosciuto", "calendar": "Calendario", "calendar_uri": "URL del calendario", "scheduling_uri": "URL di pianificazione", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7d1fd31d..7be34e16 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "興味", "personal_other": "その他", "gender": "性別", - "gender_M": "男性", - "gender_F": "女性", - "gender_O": "その他", - "gender_N": "該当なし", - "gender_U": "不明", + "gender_masculine": "男性", + "gender_feminine": "女性", + "gender_other": "その他", + "gender_none": "該当なし", + "gender_unknown": "不明", "calendar": "カレンダー", "calendar_uri": "カレンダーURL", "scheduling_uri": "スケジュールURL", diff --git a/locales/nl/common.json b/locales/nl/common.json index 775bf993..801877f0 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interesse", "personal_other": "Overig", "gender": "Geslacht", - "gender_M": "Man", - "gender_F": "Vrouw", - "gender_O": "Anders", - "gender_N": "Niet van toepassing", - "gender_U": "Onbekend", + "gender_masculine": "Man", + "gender_feminine": "Vrouw", + "gender_other": "Anders", + "gender_none": "Niet van toepassing", + "gender_unknown": "Onbekend", "calendar": "Kalender", "calendar_uri": "Kalender-URL", "scheduling_uri": "Planning-URL", diff --git a/locales/pt/common.json b/locales/pt/common.json index accd419a..bb0d6af2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1491,11 +1491,11 @@ "personal_interest": "Interesse", "personal_other": "Outro", "gender": "Gênero", - "gender_M": "Masculino", - "gender_F": "Feminino", - "gender_O": "Outro", - "gender_N": "Não aplicável", - "gender_U": "Desconhecido", + "gender_masculine": "Masculino", + "gender_feminine": "Feminino", + "gender_other": "Outro", + "gender_none": "Não aplicável", + "gender_unknown": "Desconhecido", "calendar": "Calendário", "calendar_uri": "URL do calendário", "scheduling_uri": "URL de agendamento",