fix: update gender handling to use speakToAs structure and adjust localization keys
This commit is contained in:
@@ -351,13 +351,16 @@ export function ContactDetail({ contact, onEdit, onDelete, isMobile, className }
|
|||||||
</Section>
|
</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">
|
<Section icon={UserCircle} title={t("detail.gender")} category="personal">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{contact.gender.sex && <span>{t(`detail.gender_${contact.gender.sex.toUpperCase()}`, { defaultValue: contact.gender.sex })}</span>}
|
{contact.speakToAs.grammaticalGender && <span>{t(`detail.gender_${contact.speakToAs.grammaticalGender}`, { defaultValue: contact.speakToAs.grammaticalGender })}</span>}
|
||||||
{contact.gender.identity && (
|
{contact.speakToAs.pronouns && (() => {
|
||||||
<span className="text-muted-foreground">{contact.gender.sex ? " — " : ""}{contact.gender.identity}</span>
|
const firstPronoun = Object.values(contact.speakToAs!.pronouns!)[0]?.pronouns;
|
||||||
)}
|
return firstPronoun ? (
|
||||||
|
<span className="text-muted-foreground">{contact.speakToAs!.grammaticalGender ? " — " : ""}{firstPronoun}</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -235,8 +235,10 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
|||||||
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
|
contact?.notes ? Object.values(contact.notes)[0]?.note || "" : ""
|
||||||
);
|
);
|
||||||
|
|
||||||
const [genderSex, setGenderSex] = useState(contact?.gender?.sex || "");
|
const [genderSex, setGenderSex] = useState(contact?.speakToAs?.grammaticalGender || "");
|
||||||
const [genderIdentity, setGenderIdentity] = useState(contact?.gender?.identity || "");
|
const [genderIdentity, setGenderIdentity] = useState(
|
||||||
|
contact?.speakToAs?.pronouns ? Object.values(contact.speakToAs.pronouns)[0]?.pronouns || "" : ""
|
||||||
|
);
|
||||||
const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || "");
|
const [calendarUri, setCalendarUri] = useState(contact?.calendarUri || "");
|
||||||
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
|
const [schedulingUri, setSchedulingUri] = useState(contact?.schedulingUri || "");
|
||||||
const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || "");
|
const [freeBusyUri, setFreeBusyUri] = useState(contact?.freeBusyUri || "");
|
||||||
@@ -371,8 +373,11 @@ export function ContactForm({ contact, onSave, onCancel }: ContactFormProps) {
|
|||||||
notes: note.trim()
|
notes: note.trim()
|
||||||
? { n0: { note: note.trim() } }
|
? { n0: { note: note.trim() } }
|
||||||
: undefined,
|
: undefined,
|
||||||
gender: (genderSex.trim() || genderIdentity.trim())
|
speakToAs: (genderSex.trim() || genderIdentity.trim())
|
||||||
? { sex: genderSex.trim() || undefined, identity: genderIdentity.trim() || undefined }
|
? {
|
||||||
|
grammaticalGender: genderSex.trim() || undefined,
|
||||||
|
pronouns: genderIdentity.trim() ? { p0: { pronouns: genderIdentity.trim() } } : undefined,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
calendarUri: calendarUri.trim() || undefined,
|
calendarUri: calendarUri.trim() || undefined,
|
||||||
schedulingUri: schedulingUri.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>
|
<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">
|
<Select value={genderSex} onChange={(e) => setGenderSex(e.target.value)} className="w-full">
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
<option value="M">{t("gender_male")}</option>
|
<option value="masculine">{t("gender_male")}</option>
|
||||||
<option value="F">{t("gender_female")}</option>
|
<option value="feminine">{t("gender_female")}</option>
|
||||||
<option value="O">{t("gender_other")}</option>
|
<option value="other">{t("gender_other")}</option>
|
||||||
<option value="N">{t("gender_none")}</option>
|
<option value="none">{t("gender_none")}</option>
|
||||||
<option value="U">{t("gender_unknown")}</option>
|
<option value="unknown">{t("gender_unknown")}</option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ describe("parseVCard", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
const card = result[0];
|
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({
|
expect(card.media?.m0).toEqual({
|
||||||
kind: "logo",
|
kind: "logo",
|
||||||
uri: "https://example.com/logo.png",
|
uri: "https://example.com/logo.png",
|
||||||
@@ -331,7 +331,7 @@ describe("generateVCard", () => {
|
|||||||
components: [{ kind: "given", value: "Jane" }],
|
components: [{ kind: "given", value: "Jane" }],
|
||||||
isOrdered: true,
|
isOrdered: true,
|
||||||
},
|
},
|
||||||
gender: { sex: "F", identity: "Female" },
|
speakToAs: { grammaticalGender: "feminine", pronouns: { p0: { pronouns: "Female" } } },
|
||||||
media: {
|
media: {
|
||||||
m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" },
|
m0: { kind: "logo", uri: "https://example.com/logo.png", mediaType: "image/png" },
|
||||||
m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" },
|
m1: { kind: "sound", uri: "https://example.com/sound.ogg", mediaType: "audio/ogg" },
|
||||||
|
|||||||
+4
-1
@@ -183,7 +183,10 @@ export interface ContactCard {
|
|||||||
relatedTo?: Record<string, ContactRelation>;
|
relatedTo?: Record<string, ContactRelation>;
|
||||||
keywords?: Record<string, boolean>;
|
keywords?: Record<string, boolean>;
|
||||||
members?: 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;
|
calendarUri?: string;
|
||||||
schedulingUri?: string;
|
schedulingUri?: string;
|
||||||
freeBusyUri?: string;
|
freeBusyUri?: string;
|
||||||
|
|||||||
+44
-7
@@ -1,5 +1,29 @@
|
|||||||
import type { ContactCard, NameComponent, ContactMedia, ContactOnlineService } from "@/lib/jmap/types";
|
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 {
|
function unfoldLines(vcf: string): string {
|
||||||
return vcf.replace(/\r\n[ \t]/g, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
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": {
|
case "GENDER": {
|
||||||
const gParts = val.split(";");
|
const gParts = val.split(";");
|
||||||
card.gender = {};
|
const sexCode = gParts[0]?.toUpperCase();
|
||||||
if (gParts[0]) card.gender.sex = gParts[0];
|
const identityText = gParts[1];
|
||||||
if (gParts[1]) card.gender.identity = gParts[1];
|
if (sexCode || identityText) {
|
||||||
|
card.speakToAs = {};
|
||||||
|
if (sexCode) {
|
||||||
|
card.speakToAs.grammaticalGender = vcardSexToGrammaticalGender(sexCode);
|
||||||
|
}
|
||||||
|
if (identityText) {
|
||||||
|
card.speakToAs.pronouns = { p0: { pronouns: identityText } };
|
||||||
|
}
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,10 +716,15 @@ function generateSingleVCard(contact: ContactCard): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contact.gender) {
|
if (contact.speakToAs) {
|
||||||
const sex = contact.gender.sex || "";
|
const sex = contact.speakToAs.grammaticalGender
|
||||||
const identity = contact.gender.identity || "";
|
? grammaticalGenderToVcardSex(contact.speakToAs.grammaticalGender)
|
||||||
lines.push(`GENDER:${sex}${identity ? `;${identity}` : ""}`);
|
: "";
|
||||||
|
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) {
|
if (contact.calendarUri) {
|
||||||
|
|||||||
@@ -1491,11 +1491,11 @@
|
|||||||
"personal_interest": "Interesse",
|
"personal_interest": "Interesse",
|
||||||
"personal_other": "Sonstiges",
|
"personal_other": "Sonstiges",
|
||||||
"gender": "Geschlecht",
|
"gender": "Geschlecht",
|
||||||
"gender_M": "Männlich",
|
"gender_masculine": "Männlich",
|
||||||
"gender_F": "Weiblich",
|
"gender_feminine": "Weiblich",
|
||||||
"gender_O": "Andere",
|
"gender_other": "Andere",
|
||||||
"gender_N": "Nicht zutreffend",
|
"gender_none": "Nicht zutreffend",
|
||||||
"gender_U": "Unbekannt",
|
"gender_unknown": "Unbekannt",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"calendar_uri": "Kalender-URL",
|
"calendar_uri": "Kalender-URL",
|
||||||
"scheduling_uri": "Terminplanungs-URL",
|
"scheduling_uri": "Terminplanungs-URL",
|
||||||
|
|||||||
@@ -1505,11 +1505,11 @@
|
|||||||
"personal_interest": "Interest",
|
"personal_interest": "Interest",
|
||||||
"personal_other": "Other",
|
"personal_other": "Other",
|
||||||
"gender": "Gender",
|
"gender": "Gender",
|
||||||
"gender_M": "Male",
|
"gender_masculine": "Male",
|
||||||
"gender_F": "Female",
|
"gender_feminine": "Female",
|
||||||
"gender_O": "Other",
|
"gender_other": "Other",
|
||||||
"gender_N": "Not applicable",
|
"gender_none": "Not applicable",
|
||||||
"gender_U": "Unknown",
|
"gender_unknown": "Unknown",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"calendar_uri": "Calendar URL",
|
"calendar_uri": "Calendar URL",
|
||||||
"scheduling_uri": "Scheduling URL",
|
"scheduling_uri": "Scheduling URL",
|
||||||
|
|||||||
@@ -1491,11 +1491,11 @@
|
|||||||
"personal_interest": "Interés",
|
"personal_interest": "Interés",
|
||||||
"personal_other": "Otro",
|
"personal_other": "Otro",
|
||||||
"gender": "Género",
|
"gender": "Género",
|
||||||
"gender_M": "Masculino",
|
"gender_masculine": "Masculino",
|
||||||
"gender_F": "Femenino",
|
"gender_feminine": "Femenino",
|
||||||
"gender_O": "Otro",
|
"gender_other": "Otro",
|
||||||
"gender_N": "No aplicable",
|
"gender_none": "No aplicable",
|
||||||
"gender_U": "Desconocido",
|
"gender_unknown": "Desconocido",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"calendar_uri": "URL del calendario",
|
"calendar_uri": "URL del calendario",
|
||||||
"scheduling_uri": "URL de programación",
|
"scheduling_uri": "URL de programación",
|
||||||
|
|||||||
@@ -1491,11 +1491,11 @@
|
|||||||
"personal_interest": "Intérêt",
|
"personal_interest": "Intérêt",
|
||||||
"personal_other": "Autre",
|
"personal_other": "Autre",
|
||||||
"gender": "Genre",
|
"gender": "Genre",
|
||||||
"gender_M": "Masculin",
|
"gender_masculine": "Masculin",
|
||||||
"gender_F": "Féminin",
|
"gender_feminine": "Féminin",
|
||||||
"gender_O": "Autre",
|
"gender_other": "Autre",
|
||||||
"gender_N": "Non applicable",
|
"gender_none": "Non applicable",
|
||||||
"gender_U": "Inconnu",
|
"gender_unknown": "Inconnu",
|
||||||
"calendar": "Calendrier",
|
"calendar": "Calendrier",
|
||||||
"calendar_uri": "URL du calendrier",
|
"calendar_uri": "URL du calendrier",
|
||||||
"scheduling_uri": "URL de planification",
|
"scheduling_uri": "URL de planification",
|
||||||
|
|||||||
@@ -1491,11 +1491,11 @@
|
|||||||
"personal_interest": "Interesse",
|
"personal_interest": "Interesse",
|
||||||
"personal_other": "Altro",
|
"personal_other": "Altro",
|
||||||
"gender": "Genere",
|
"gender": "Genere",
|
||||||
"gender_M": "Maschile",
|
"gender_masculine": "Maschile",
|
||||||
"gender_F": "Femminile",
|
"gender_feminine": "Femminile",
|
||||||
"gender_O": "Altro",
|
"gender_other": "Altro",
|
||||||
"gender_N": "Non applicabile",
|
"gender_none": "Non applicabile",
|
||||||
"gender_U": "Sconosciuto",
|
"gender_unknown": "Sconosciuto",
|
||||||
"calendar": "Calendario",
|
"calendar": "Calendario",
|
||||||
"calendar_uri": "URL del calendario",
|
"calendar_uri": "URL del calendario",
|
||||||
"scheduling_uri": "URL di pianificazione",
|
"scheduling_uri": "URL di pianificazione",
|
||||||
|
|||||||
@@ -1491,11 +1491,11 @@
|
|||||||
"personal_interest": "興味",
|
"personal_interest": "興味",
|
||||||
"personal_other": "その他",
|
"personal_other": "その他",
|
||||||
"gender": "性別",
|
"gender": "性別",
|
||||||
"gender_M": "男性",
|
"gender_masculine": "男性",
|
||||||
"gender_F": "女性",
|
"gender_feminine": "女性",
|
||||||
"gender_O": "その他",
|
"gender_other": "その他",
|
||||||
"gender_N": "該当なし",
|
"gender_none": "該当なし",
|
||||||
"gender_U": "不明",
|
"gender_unknown": "不明",
|
||||||
"calendar": "カレンダー",
|
"calendar": "カレンダー",
|
||||||
"calendar_uri": "カレンダーURL",
|
"calendar_uri": "カレンダーURL",
|
||||||
"scheduling_uri": "スケジュールURL",
|
"scheduling_uri": "スケジュールURL",
|
||||||
|
|||||||
@@ -1491,11 +1491,11 @@
|
|||||||
"personal_interest": "Interesse",
|
"personal_interest": "Interesse",
|
||||||
"personal_other": "Overig",
|
"personal_other": "Overig",
|
||||||
"gender": "Geslacht",
|
"gender": "Geslacht",
|
||||||
"gender_M": "Man",
|
"gender_masculine": "Man",
|
||||||
"gender_F": "Vrouw",
|
"gender_feminine": "Vrouw",
|
||||||
"gender_O": "Anders",
|
"gender_other": "Anders",
|
||||||
"gender_N": "Niet van toepassing",
|
"gender_none": "Niet van toepassing",
|
||||||
"gender_U": "Onbekend",
|
"gender_unknown": "Onbekend",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"calendar_uri": "Kalender-URL",
|
"calendar_uri": "Kalender-URL",
|
||||||
"scheduling_uri": "Planning-URL",
|
"scheduling_uri": "Planning-URL",
|
||||||
|
|||||||
@@ -1491,11 +1491,11 @@
|
|||||||
"personal_interest": "Interesse",
|
"personal_interest": "Interesse",
|
||||||
"personal_other": "Outro",
|
"personal_other": "Outro",
|
||||||
"gender": "Gênero",
|
"gender": "Gênero",
|
||||||
"gender_M": "Masculino",
|
"gender_masculine": "Masculino",
|
||||||
"gender_F": "Feminino",
|
"gender_feminine": "Feminino",
|
||||||
"gender_O": "Outro",
|
"gender_other": "Outro",
|
||||||
"gender_N": "Não aplicável",
|
"gender_none": "Não aplicável",
|
||||||
"gender_U": "Desconhecido",
|
"gender_unknown": "Desconhecido",
|
||||||
"calendar": "Calendário",
|
"calendar": "Calendário",
|
||||||
"calendar_uri": "URL do calendário",
|
"calendar_uri": "URL do calendário",
|
||||||
"scheduling_uri": "URL de agendamento",
|
"scheduling_uri": "URL de agendamento",
|
||||||
|
|||||||
Reference in New Issue
Block a user