fix: update gender handling to use speakToAs structure and adjust localization keys

This commit is contained in:
Linus Rath
2026-03-18 17:55:29 +01:00
parent 2edbf379e2
commit 9fdbb62205
13 changed files with 112 additions and 64 deletions
+8 -5
View File
@@ -351,13 +351,16 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
</Section>
)}
{contact.gender && (contact.gender.sex || contact.gender.identity) && (
{contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns) && (
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
<div className="text-sm">
{contact.gender.sex && <span>{t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}</span>}
{contact.gender.identity && (
<span className="text-muted-foreground">{contact.gender.sex ? " — " : ""}{contact.gender.identity}</span>
)}
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
{contact.speakToAs.pronouns && (() => {
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
return firstPronoun ? (
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
) : null;
})()}
</div>
</Section>
)}
+14 -9
View File
@@ -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) {
<label className="text-xs text-muted-foreground mb-1 block">{t("gender_sex")}</label>
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
<option value=""></option>
<option value="M">{t("gender_male")}</option>
<option value="F">{t("gender_female")}</option>
<option value="O">{t("gender_other")}</option>
<option value="N">{t("gender_none")}</option>
<option value="U">{t("gender_unknown")}</option>
<option value="masculine">{t("gender_male")}</option>
<option value="feminine">{t("gender_female")}</option>
<option value="other">{t("gender_other")}</option>
<option value="none">{t("gender_none")}</option>
<option value="unknown">{t("gender_unknown")}</option>
</Select>
</div>
<div>
+2 -2
View File
@@ -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" },
+4 -1
View File
@@ -183,7 +183,10 @@ export interface ContactCard {
relatedTo?: Record<string, ContactRelation>;
keywords?: Record<string, boolean>;
members?: Record<string, boolean>;
gender?: { sex?: string; identity?: string };
speakToAs?: {
grammaticalGender?: string;
pronouns?: Record<string, { pronouns: string; pref?: number; contexts?: Record<string, boolean> }>;
};
calendarUri?: string;
schedulingUri?: string;
freeBusyUri?: string;
+43 -6
View File
@@ -1,5 +1,29 @@
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types";
const VCARD_SEX_TO_GENDER: Record<string, string> = {
M: "masculine",
F: "feminine",
O: "other",
N: "none",
U: "unknown",
};
const GENDER_TO_VCARD_SEX: Record<string, string> = {
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<string, string[]>): 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,11 +716,16 @@ function generateSingleVCard(contact: ContactCard): string {
}
}
if (contact.gender) {
const sex = contact.gender.sex || "";
const identity = contact.gender.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) {
lines.push(`CALURI:${contact.calendarUri}`);
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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",
+5 -5
View File
@@ -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",