feat: allow contact cards for organizations #701

This commit is contained in:
Linus Rath
2026-07-30 19:44:36 +02:00
parent 348e032dce
commit e659fe3d38
29 changed files with 360 additions and 71 deletions
@@ -102,4 +102,98 @@ describe('ContactForm', () => {
expect.arrayContaining([expect.objectContaining({ kind: 'given', value: 'Jane' })])
);
});
it('saves an organization-only card when the organization type is selected', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_organization'));
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
const savedData = onSave.mock.calls[0][0];
expect(savedData.kind).toBe('org');
expect(savedData.organizations.o0.name).toBe('Acme Corp');
// No personal name components; the org name carries the display name instead.
expect(savedData.name.components).toBeUndefined();
expect(savedData.name.full).toBe('Acme Corp');
});
it('hides the personal name fields in organization mode', () => {
render(<ContactForm onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByPlaceholderText('given_name')).toBeInTheDocument();
fireEvent.click(screen.getByText('type_organization'));
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('surname')).not.toBeInTheDocument();
// The organization field moves into the identity section, so it appears once.
expect(screen.getAllByPlaceholderText('organization_placeholder')).toHaveLength(1);
});
it('still requires a name in organization mode', async () => {
const onSave = vi.fn();
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_organization'));
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(screen.getByText('name_required')).toBeInTheDocument();
});
expect(onSave).not.toHaveBeenCalled();
});
it('accepts an organization instead of a personal name in person mode', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ContactForm onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('section_work'));
fireEvent.change(screen.getByPlaceholderText('organization_placeholder'), { target: { value: 'Acme Corp' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
expect(onSave.mock.calls[0][0].name.full).toBe('Acme Corp');
});
it('opens an existing org card in organization mode', () => {
const orgContact: ContactCard = {
id: '2',
addressBookIds: {},
kind: 'org',
name: { full: 'Acme Corp' },
organizations: { o0: { name: 'Acme Corp' } },
};
render(<ContactForm contact={orgContact} onSave={vi.fn()} onCancel={vi.fn()} />);
expect(screen.queryByPlaceholderText('given_name')).not.toBeInTheDocument();
expect(screen.getByDisplayValue('Acme Corp')).toBeInTheDocument();
});
it('switches an org card back to a person', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const orgContact: ContactCard = {
id: '2',
addressBookIds: {},
kind: 'org',
name: { full: 'Acme Corp' },
organizations: { o0: { name: 'Acme Corp' } },
};
render(<ContactForm contact={orgContact} onSave={onSave} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('type_person'));
fireEvent.change(screen.getByPlaceholderText('given_name'), { target: { value: 'Jane' } });
fireEvent.submit(screen.getByText('save').closest('form')!);
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});
expect(onSave.mock.calls[0][0].kind).toBe('individual');
});
});
+3 -1
View File
@@ -171,7 +171,9 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const hasNickname = nicknames.length > 0;
const titleLine = jobTitles.length > 0 ? jobTitles.map(t => t.name).join(", ") : undefined;
const subtitleParts = [titleLine, orgs[0]?.name].filter(Boolean) as string[];
// On an organization card the org name is already the heading; don't repeat it.
const orgName = orgs[0]?.name;
const subtitleParts = [titleLine, orgName === name ? undefined : orgName].filter(Boolean) as string[];
const hasContactDetails = emails.length > 0 || phones.length > 0 || addresses.length > 0 || onlineServices.length > 0;
const hasWork = titles.length > 0 || orgs.length > 0;
const hasGender = !!(contact.speakToAs && (contact.speakToAs.grammaticalGender || contact.speakToAs.pronouns));
+79 -6
View File
@@ -266,6 +266,16 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
contact?.organizations ? (Object.values(contact.organizations)[0]?.units?.[0]?.name || "") : ""
);
// A card may describe an organization instead of a person (RFC 9553 kind "org").
// Older cards predate the explicit kind, so fall back to "has an org name but no
// personal name".
const [isOrg, setIsOrg] = useState(() => {
if (!contact) return false;
if (contact.kind) return contact.kind === "org";
const hasPersonName = !!(findComponent("given") || findComponent("surname"));
return !hasPersonName && !!Object.values(contact.organizations || {})[0]?.name;
});
const [jobTitle, setJobTitle] = useState(() => {
if (contact?.titles) {
const t = Object.values(contact.titles).find(t => t.kind !== "role");
@@ -424,7 +434,10 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
e.preventDefault();
setError(null);
if (!givenName.trim() && !surname.trim()) {
// An organization name identifies the card just as well as a personal name.
const orgName = organization.trim();
const hasPersonName = !!(givenName.trim() || surname.trim());
if (isOrg ? !orgName : (!hasPersonName && !orgName)) {
setError(t("name_required"));
return;
}
@@ -461,11 +474,19 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = [];
if (!isOrg) {
if (prefix.trim()) nameComponents.push({ kind: "title" as const, value: prefix.trim() });
if (givenName.trim()) nameComponents.push({ kind: "given" as const, value: givenName.trim() });
if (additionalName.trim()) nameComponents.push({ kind: "given2" as const, value: additionalName.trim() });
if (surname.trim()) nameComponents.push({ kind: "surname" as const, value: surname.trim() });
if (suffix.trim()) nameComponents.push({ kind: "generation" as const, value: suffix.trim() });
}
// Without personal name components, carry the organization name in `name.full`
// so servers and other clients have something to display.
const nameValue: ContactCard["name"] = nameComponents.length > 0
? { components: nameComponents, isOrdered: true }
: { full: orgName };
const titlesMap: Record<string, { name: string; kind?: "title" | "role" }> = {};
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
@@ -530,14 +551,20 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const mediaValue: Record<string, ContactMedia> | null | undefined =
Object.keys(mediaMap).length > 0 ? mediaMap : (hadMedia ? null : undefined);
// Only send `kind` when this form owns the answer: switching a card between
// person and organization. Leave other kinds (group, location, ...) untouched.
const kindValue: ContactCard["kind"] | undefined =
isOrg ? "org" : (contact?.kind === "org" ? "individual" : undefined);
const data: Partial<ContactCard> = {
name: { components: nameComponents, isOrdered: true },
name: nameValue,
...(kindValue ? { kind: kindValue } : {}),
nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
emails: Object.keys(emailsMap).length > 0 ? emailsMap : undefined,
phones: Object.keys(phonesMap).length > 0 ? phonesMap : undefined,
titles: Object.keys(titlesMap).length > 0 ? titlesMap : undefined,
organizations: organization.trim()
? { o0: { name: organization.trim(), units: orgUnits } }
organizations: orgName
? { o0: { name: orgName, units: orgUnits } }
: undefined,
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
@@ -570,7 +597,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
}
};
const previewName = [givenName, surname].filter(Boolean).join(" ").trim();
const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim();
const previewEmail = emails.find(e => e.address.trim())?.address.trim() || "";
return (
@@ -661,6 +688,47 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
)}
<FormSection icon={User} title={t("section_identity")}>
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">{t("contact_type")}</span>
<div role="radiogroup" aria-label={t("contact_type")} className="inline-flex gap-0.5 rounded-md border border-input p-0.5">
{[
{ org: false, label: t("type_person"), icon: User },
{ org: true, label: t("type_organization"), icon: Building },
].map(({ org, label, icon: Icon }) => (
<button
key={label}
type="button"
role="radio"
aria-checked={isOrg === org}
onClick={() => setIsOrg(org)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 text-xs rounded transition-colors",
isOrg === org
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-3.5 h-3.5" />
{label}
</button>
))}
</div>
</div>
{isOrg ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">
{t("organization")} <span className="text-red-500">*</span>
</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} autoFocus />
</div>
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("nickname")}</label>
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
) : (
<>
<div className="grid grid-cols-[auto_1fr_1fr_auto] gap-2">
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("prefix")}</label>
@@ -693,6 +761,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<Input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={t("nickname_placeholder")} />
</div>
</div>
</>
)}
</FormSection>
{/* Email */}
@@ -806,12 +876,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
</FormSection>
{/* Work & Organization */}
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!(organization || department || jobTitle || role)}>
<FormSection icon={Building} title={t("section_work")} collapsible defaultOpen={!!((organization && !isOrg) || department || jobTitle || role)}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{/* In organization mode the org name is the card's identity, edited above. */}
{!isOrg && (
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("organization")}</label>
<Input value={organization} onChange={(e) => setOrganization(e.target.value)} placeholder={t("organization_placeholder")} />
</div>
)}
<div>
<label className="text-xs text-muted-foreground mb-1 block">{t("department")}</label>
<Input value={department} onChange={(e) => setDepartment(e.target.value)} placeholder={t("department_placeholder")} />
+42
View File
@@ -445,6 +445,48 @@ describe("generateVCard", () => {
const vcf = generateVCard([contact]);
expect(vcf).toContain("NOTE:Has comma\\, semicolon\\; and newline\\nhere");
});
it("uses the organization name as FN for organization cards (issue #701)", () => {
const contact: ContactCard = {
id: "c4",
addressBookIds: {},
kind: "org",
name: { full: "Acme Corp" },
organizations: { o0: { name: "Acme Corp" } },
};
const vcf = generateVCard([contact]);
expect(vcf).toContain("KIND:org");
expect(vcf).toContain("FN:Acme Corp");
expect(vcf).toContain("ORG:Acme Corp");
});
it("falls back to ORG for FN when the card has no name at all", () => {
const contact: ContactCard = {
id: "c5",
addressBookIds: {},
kind: "org",
organizations: { o0: { name: "Acme Corp" } },
};
expect(generateVCard([contact])).toContain("FN:Acme Corp");
});
});
describe("organization-only cards (issue #701)", () => {
it("keeps a vCard that has only an organization name", () => {
const parsed = parseVCard([
"BEGIN:VCARD",
"VERSION:4.0",
"KIND:org",
"ORG:Acme Corp",
"END:VCARD",
].join("\r\n"));
expect(parsed).toHaveLength(1);
expect(parsed[0].kind).toBe("org");
expect(parsed[0].organizations?.o0.name).toBe("Acme Corp");
});
});
describe("round-trip: parse → generate → parse", () => {
+8 -2
View File
@@ -844,7 +844,9 @@ function buildContact(raw: Record<string, string[]>): ContactCard | null {
const hasName = card.name && (card.name.components?.length ?? 0) > 0 || !!card.name?.full;
const hasEmail = card.emails && Object.keys(card.emails).length > 0;
if (!hasName && !hasEmail && card.kind !== "group") return null;
// An organization name identifies the card just as well as a personal name.
const hasOrg = !!Object.values(card.organizations || {})[0]?.name;
if (!hasName && !hasEmail && !hasOrg && card.kind !== "group") return null;
return card;
}
@@ -882,7 +884,11 @@ function generateSingleVCard(contact: ContactCard): string {
const suffix = findKind("generation", "suffix");
const additional = findKind("given2", "additional", "middle");
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ") || contact.name?.full || "";
// FN is mandatory in vCard, so fall back to the organization name for org cards.
const fn = [prefix, given, additional, surname, suffix].filter(Boolean).join(" ")
|| contact.name?.full
|| Object.values(contact.organizations || {})[0]?.name
|| "";
if (fn) {
lines.push(`FN:${encodeValue(fn)}`);
lines.push(`N:${encodeValue(surname)};${encodeValue(given)};${encodeValue(additional)};${encodeValue(prefix)};${encodeValue(suffix)}`);
+4 -1
View File
@@ -2376,6 +2376,9 @@
"section_address_book": "الدليل",
"select_address_book": "اختر دليلًا...",
"section_identity": "الاسم والهوية",
"contact_type": "نوع جهة الاتصال",
"type_person": "شخص",
"type_organization": "المؤسسة",
"section_work": "العمل والمؤسسة",
"prefix": "اللقب",
"prefix_placeholder": "د.، أ.، السيدة",
@@ -2460,7 +2463,7 @@
"cancel": "إلغاء",
"creating": "جارٍ الإنشاء...",
"updating": "جارٍ التحديث...",
"name_required": "يلزم إدخال الاسم الأول أو اسم العائلة على الأقل",
"name_required": "أدخل اسمًا أول أو اسم عائلة أو مؤسسة",
"email_invalid": "يرجى إدخال عنوان بريد إلكتروني صالح",
"email_error_inline": "تنسيق البريد الإلكتروني غير صالح",
"save_failed": "فشل حفظ جهة الاتصال",
+4 -1
View File
@@ -2376,6 +2376,9 @@
"section_address_book": "Directori",
"select_address_book": "Seleccioneu un directori...",
"section_identity": "Nom i identitat",
"contact_type": "Tipus de contacte",
"type_person": "Persona",
"type_organization": "Organització",
"section_work": "Feina i organització",
"prefix": "Prefix",
"prefix_placeholder": "Dr., Sr., Sra.",
@@ -2460,7 +2463,7 @@
"cancel": "Cancel·la",
"creating": "Creant...",
"updating": "Actualitzant...",
"name_required": "Cal com a mínim un nom o un cognom",
"name_required": "Introduïu un nom, un cognom o una organització",
"email_invalid": "Introduïu una adreça electrònica vàlida",
"email_error_inline": "Format de correu electrònic no vàlid",
"save_failed": "No s'ha pogut desar el contacte",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Adresář",
"select_address_book": "Vyberte adresář...",
"section_identity": "Jméno a identita",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
"type_organization": "Organizace",
"section_work": "Práce a organizace",
"prefix": "Titul",
"prefix_placeholder": "Dr., Pan, Paní",
@@ -2459,7 +2462,7 @@
"cancel": "Zrušit",
"creating": "Vytváření...",
"updating": "Aktualizování...",
"name_required": "Je vyžadováno alespoň jméno nebo příjmení",
"name_required": "Zadejte jméno, příjmení nebo organizaci",
"email_invalid": "Zadejte platnou e-mailovou adresu",
"email_error_inline": "Neplatný formát e-mailové adresy",
"save_failed": "Uložení kontaktu selhalo",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Adressebog",
"select_address_book": "Vælg en adressebog...",
"section_identity": "Navn & identitet",
"contact_type": "Kontakttype",
"type_person": "Person",
"type_organization": "Organisation",
"section_work": "Arbejde & organisation",
"prefix": "Præfiks",
"prefix_placeholder": "Dr., hr., fru",
@@ -2459,7 +2462,7 @@
"cancel": "Annuller",
"creating": "Opretter...",
"updating": "Opdaterer...",
"name_required": "Mindst et fornavn eller efternavn er påkrævet",
"name_required": "Angiv et fornavn, efternavn eller en organisation",
"email_invalid": "Indtast en gyldig e-mailadresse",
"email_error_inline": "Ugyldigt e-mailformat",
"save_failed": "Kunne ikke gemme kontakt",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Verzeichnis",
"select_address_book": "Verzeichnis auswählen...",
"section_identity": "Name & Identität",
"contact_type": "Kontakttyp",
"type_person": "Person",
"type_organization": "Organisation",
"section_work": "Beruf & Organisation",
"prefix": "Anrede",
"prefix_placeholder": "Dr., Herr, Frau",
@@ -2459,7 +2462,7 @@
"cancel": "Abbrechen",
"creating": "Wird erstellt...",
"updating": "Wird aktualisiert...",
"name_required": "Mindestens ein Vor- oder Nachname ist erforderlich",
"name_required": "Bitte Vorname, Nachname oder Organisation angeben",
"email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"email_error_inline": "Ungültiges E-Mail-Format",
"save_failed": "Kontakt konnte nicht gespeichert werden",
+4 -1
View File
@@ -2376,6 +2376,9 @@
"section_address_book": "Directory",
"select_address_book": "Select a directory...",
"section_identity": "Name & Identity",
"contact_type": "Contact type",
"type_person": "Person",
"type_organization": "Organization",
"section_work": "Work & Organization",
"prefix": "Prefix",
"prefix_placeholder": "Dr., Mr., Mrs.",
@@ -2460,7 +2463,7 @@
"cancel": "Cancel",
"creating": "Creating...",
"updating": "Updating...",
"name_required": "At least a first name or last name is required",
"name_required": "Enter a first name, last name, or organization",
"email_invalid": "Please enter a valid email address",
"email_error_inline": "Invalid email format",
"save_failed": "Failed to save contact",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Directorio",
"select_address_book": "Seleccionar un directorio...",
"section_identity": "Nombre e identidad",
"contact_type": "Tipo de contacto",
"type_person": "Persona",
"type_organization": "Organización",
"section_work": "Trabajo y organización",
"prefix": "Prefijo",
"prefix_placeholder": "Dr., Sr., Sra.",
@@ -2459,7 +2462,7 @@
"cancel": "Cancelar",
"creating": "Creando...",
"updating": "Actualizando...",
"name_required": "Se requiere al menos un nombre o apellido",
"name_required": "Introduce un nombre, un apellido o una organización",
"email_invalid": "Introduce una dirección de correo válida",
"email_error_inline": "Formato de correo inválido",
"save_failed": "Error al guardar el contacto",
+4 -1
View File
@@ -2376,6 +2376,9 @@
"section_address_book": "دفترچه",
"select_address_book": "انتخاب دفترچه...",
"section_identity": "نام و هویت",
"contact_type": "نوع مخاطب",
"type_person": "شخص",
"type_organization": "سازمان",
"section_work": "کار و سازمان",
"prefix": "پیشوند",
"prefix_placeholder": "دکتر، مهندس",
@@ -2460,7 +2463,7 @@
"cancel": "انصراف",
"creating": "در حال ایجاد...",
"updating": "در حال به‌روزرسانی...",
"name_required": "حداقل نام یا نام خانوادگی الزامی است",
"name_required": "نام، نام خانوادگی یا سازمان را وارد کنید",
"email_invalid": "لطفاً یک آدرس ایمیل معتبر وارد کنید",
"email_error_inline": "فرمت ایمیل نامعتبر است",
"save_failed": "ذخیره مخاطب ناموفق بود",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Répertoire",
"select_address_book": "Sélectionner un répertoire...",
"section_identity": "Nom et identité",
"contact_type": "Type de contact",
"type_person": "Personne",
"type_organization": "Organisation",
"section_work": "Travail et organisation",
"prefix": "Préfixe",
"prefix_placeholder": "Dr., M., Mme",
@@ -2459,7 +2462,7 @@
"cancel": "Annuler",
"creating": "Création...",
"updating": "Mise à jour...",
"name_required": "Un prénom ou un nom est requis",
"name_required": "Saisissez un prénom, un nom ou une organisation",
"email_invalid": "Veuillez saisir une adresse e-mail valide",
"email_error_inline": "Format d'e-mail invalide",
"save_failed": "Échec de l'enregistrement du contact",
+4 -1
View File
@@ -2289,6 +2289,9 @@
"section_address_book": "ספרייה",
"select_address_book": "בחר ספרייה...",
"section_identity": "שם וזהות",
"contact_type": "סוג איש קשר",
"type_person": "אדם",
"type_organization": "ארגון",
"section_work": "עבודה וארגון",
"prefix": "קידומת",
"prefix_placeholder": "ד\"ר, מר, גברת.",
@@ -2373,7 +2376,7 @@
"cancel": "לְבַטֵל",
"creating": "יוצר...",
"updating": "מעדכן...",
"name_required": "נדרש לפחות שם פרטי או שם משפחה",
"name_required": "יש להזין שם פרטי, שם משפחה או ארגון",
"email_invalid": "נא להזין כתובת אימייל חוקית",
"email_error_inline": "פורמט אימייל לא חוקי",
"save_failed": "שמירת איש הקשר נכשלה",
+4 -1
View File
@@ -2376,6 +2376,9 @@
"section_address_book": "Címtár",
"select_address_book": "Címtár kiválasztása...",
"section_identity": "Név és azonosság",
"contact_type": "Névjegy típusa",
"type_person": "Személy",
"type_organization": "Szervezet",
"section_work": "Munka és szervezet",
"prefix": "Előtag",
"prefix_placeholder": "Dr., Úr., Mrs.",
@@ -2460,7 +2463,7 @@
"cancel": "Mégse",
"creating": "Létrehozás...",
"updating": "Frissítés...",
"name_required": "Legalább a keresztnév vagy vezetéknév megadása kötelező",
"name_required": "Adjon meg egy keresztnevet, vezetéknevet vagy szervezetet",
"email_invalid": "Kérjük, adj meg egy érvényes e-mail címet",
"email_error_inline": "Érvénytelen e-mail formátum",
"save_failed": "Nem sikerült menteni a névjegyet",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Rubrica",
"select_address_book": "Seleziona una rubrica...",
"section_identity": "Nome e identità",
"contact_type": "Tipo di contatto",
"type_person": "Persona",
"type_organization": "Organizzazione",
"section_work": "Lavoro e organizzazione",
"prefix": "Prefisso",
"prefix_placeholder": "Dott., Sig., Sig.ra",
@@ -2459,7 +2462,7 @@
"cancel": "Annulla",
"creating": "Creazione...",
"updating": "Aggiornamento...",
"name_required": "È richiesto almeno un nome o cognome",
"name_required": "Inserisci un nome, un cognome o un'organizzazione",
"email_invalid": "Inserisci un indirizzo email valido",
"email_error_inline": "Formato email non valido",
"save_failed": "Impossibile salvare il contatto",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "ディレクトリ",
"select_address_book": "ディレクトリを選択...",
"section_identity": "名前と識別情報",
"contact_type": "連絡先の種類",
"type_person": "個人",
"type_organization": "組織",
"section_work": "職業と組織",
"prefix": "敬称",
"prefix_placeholder": "博士、氏",
@@ -2459,7 +2462,7 @@
"cancel": "キャンセル",
"creating": "作成中...",
"updating": "更新中...",
"name_required": "名前は必須です",
"name_required": "名、姓、または組織を入力してください",
"email_invalid": "有効なメールアドレスを入力してください",
"email_error_inline": "メールアドレスの形式が正しくありません",
"save_failed": "連絡先の保存に失敗しました",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "디렉터리",
"select_address_book": "디렉터리 선택...",
"section_identity": "이름 및 신원",
"contact_type": "연락처 유형",
"type_person": "개인",
"type_organization": "소속(회사)",
"section_work": "직장 및 소속",
"prefix": "호칭",
"prefix_placeholder": "예: Dr., Mr., Mrs.",
@@ -2459,7 +2462,7 @@
"cancel": "취소",
"creating": "만드는 중...",
"updating": "업데이트 중...",
"name_required": "이름이나중에 하나는 꼭 필요해요",
"name_required": "이름,또는 조직을 입력하세요",
"email_invalid": "올바른 이메일 주소를 입력해 주세요",
"email_error_inline": "이메일 형식이 잘못되었어요",
"save_failed": "연락처를 저장하지 못했어요",
+4 -1
View File
@@ -2371,6 +2371,9 @@
"section_address_book": "Katalogs",
"select_address_book": "Izvēlieties katalogu...",
"section_identity": "Vārds un identitāte",
"contact_type": "Kontakta veids",
"type_person": "Persona",
"type_organization": "Organizācija",
"section_work": "Darbs un organizācija",
"prefix": "Prefikss",
"prefix_placeholder": "Dr., kungs, kundze",
@@ -2455,7 +2458,7 @@
"cancel": "Atcelt",
"creating": "Izveido...",
"updating": "Atjaunina...",
"name_required": "Nepieciešams vismaz vārds vai uzvārds",
"name_required": "Ievadiet vārdu, uzvārdu vai organizāciju",
"email_invalid": "Ievadiet derīgu e-pasta adresi",
"email_error_inline": "Nederīgs e-pasta formāts",
"save_failed": "Neizdevās saglabāt kontaktu",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Adresboek",
"select_address_book": "Selecteer een adresboek...",
"section_identity": "Naam en identiteit",
"contact_type": "Contacttype",
"type_person": "Persoon",
"type_organization": "Organisatie",
"section_work": "Werk en organisatie",
"prefix": "Voorvoegsel",
"prefix_placeholder": "Dr., Dhr., Mevr.",
@@ -2459,7 +2462,7 @@
"cancel": "Annuleren",
"creating": "Aanmaken...",
"updating": "Bijwerken...",
"name_required": "Ten minste een voor- of achternaam is vereist",
"name_required": "Voer een voornaam, achternaam of organisatie in",
"email_invalid": "Voer een geldig e-mailadres in",
"email_error_inline": "Ongeldig e-mailformaat",
"save_failed": "Kon contact niet opslaan",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Katalog",
"select_address_book": "Wybierz katalog...",
"section_identity": "Imię i tożsamość",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
"type_organization": "Organizacja",
"section_work": "Praca i organizacja",
"prefix": "Tytuł",
"prefix_placeholder": "Dr, Pan, Pani",
@@ -2459,7 +2462,7 @@
"cancel": "Anuluj",
"creating": "Tworzenie...",
"updating": "Aktualizowanie...",
"name_required": "Wymagane jest przynajmniej imię lub nazwisko",
"name_required": "Podaj imię, nazwisko lub organizację",
"email_invalid": "Wprowadź prawidłowy adres e-mail",
"email_error_inline": "Nieprawidłowy format adresu e-mail",
"save_failed": "Nie udało się zapisać kontaktu",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Diretório",
"select_address_book": "Selecionar um diretório...",
"section_identity": "Nome e identidade",
"contact_type": "Tipo de contacto",
"type_person": "Pessoa",
"type_organization": "Organização",
"section_work": "Trabalho e organização",
"prefix": "Prefixo",
"prefix_placeholder": "Dr., Sr., Sra.",
@@ -2459,7 +2462,7 @@
"cancel": "Cancelar",
"creating": "Criando...",
"updating": "Atualizando...",
"name_required": "É necessário pelo menos um nome ou sobrenome",
"name_required": "Introduza um nome próprio, apelido ou organização",
"email_invalid": "Por favor, insira um endereço de e-mail válido",
"email_error_inline": "Formato de e-mail inválido",
"save_failed": "Falha ao salvar contato",
+4 -1
View File
@@ -2376,6 +2376,9 @@
"section_address_book": "Director",
"select_address_book": "Selectați un director...",
"section_identity": "Nume și identitate",
"contact_type": "Tip de contact",
"type_person": "Persoană",
"type_organization": "Organizare",
"section_work": "Muncă și organizare",
"prefix": "Prefix",
"prefix_placeholder": "Dr., Dl., Dna.",
@@ -2460,7 +2463,7 @@
"cancel": "Anulează",
"creating": "Se creează...",
"updating": "Se actualizează...",
"name_required": "Este necesar cel puțin un prenume sau un nume de familie",
"name_required": "Introduceți un prenume, un nume sau o organizație",
"email_invalid": "Vă rugăm să introduceți o adresă de e-mail validă",
"email_error_inline": "Format de e-mail nevalid",
"save_failed": "Nu s-a putut salva contactul",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Каталог",
"select_address_book": "Выберите каталог...",
"section_identity": "Имя и личность",
"contact_type": "Тип контакта",
"type_person": "Человек",
"type_organization": "Организация",
"section_work": "Работа и организация",
"prefix": "Префикс",
"prefix_placeholder": "Д-р., Г-н., Г-жа.",
@@ -2459,7 +2462,7 @@
"cancel": "Отмена",
"creating": "Создание...",
"updating": "Обновление...",
"name_required": "Требуется хотя бы имя или фамилия",
"name_required": "Укажите имя, фамилию или организацию",
"email_invalid": "Введите корректный адрес электронной почты",
"email_error_inline": "Неверный формат email",
"save_failed": "Не удалось сохранить контакт",
+4 -1
View File
@@ -2376,6 +2376,9 @@
"section_address_book": "Adresár",
"select_address_book": "Vyberte adresár...",
"section_identity": "Meno a identita",
"contact_type": "Typ kontaktu",
"type_person": "Osoba",
"type_organization": "Organizácia",
"section_work": "Práca a organizácia",
"prefix": "Titul",
"prefix_placeholder": "Dr., Pán, Pani",
@@ -2460,7 +2463,7 @@
"cancel": "Zrušiť",
"creating": "Vytváranie...",
"updating": "Aktualizovanie...",
"name_required": "Je potrebné aspoň meno alebo priezvisko",
"name_required": "Zadajte meno, priezvisko alebo organizáciu",
"email_invalid": "Zadajte platnú e-mailovú adresu",
"email_error_inline": "Neplatný formát e-mailovej adresy",
"save_failed": "Uloženie kontaktu zlyhalo",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Dizin",
"select_address_book": "Bir dizin seçin...",
"section_identity": "Ad ve Kimlik",
"contact_type": "Kişi türü",
"type_person": "Kişi",
"type_organization": "Kuruluş",
"section_work": "İş ve Kuruluş",
"prefix": "Ön Ek",
"prefix_placeholder": "Dr., Bay, Bayan",
@@ -2459,7 +2462,7 @@
"cancel": "İptal",
"creating": "Oluşturuluyor...",
"updating": "Güncelleniyor...",
"name_required": "En az bir ad veya soyadı gereklidir",
"name_required": "Bir ad, soyad veya kuruluş girin",
"email_invalid": "Lütfen geçerli bir e-posta adresi girin",
"email_error_inline": "Geçersiz e-posta biçimi",
"save_failed": "Kişi kaydedilemedi",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "Довідник",
"select_address_book": "Виберіть каталог...",
"section_identity": "Ім'я та ідентифікація",
"contact_type": "Тип контакту",
"type_person": "Людина",
"type_organization": "організація",
"section_work": "Робота та організація",
"prefix": "Префікс",
"prefix_placeholder": "доктор, пан, місіс",
@@ -2459,7 +2462,7 @@
"cancel": "Скасувати",
"creating": "Створення...",
"updating": "Оновлення...",
"name_required": "Потрібне принаймні ім’я або прізвище",
"name_required": "Вкажіть ім'я, прізвище або організацію",
"email_invalid": "Введіть дійсну електронну адресу",
"email_error_inline": "Недійсний формат електронної пошти",
"save_failed": "Не вдалося зберегти контакт",
+4 -1
View File
@@ -2375,6 +2375,9 @@
"section_address_book": "地址簿",
"select_address_book": "选择地址簿...",
"section_identity": "姓名和身份",
"contact_type": "联系人类型",
"type_person": "个人",
"type_organization": "组织",
"section_work": "工作与组织",
"prefix": "前缀",
"prefix_placeholder": "博士、先生、女士",
@@ -2459,7 +2462,7 @@
"cancel": "取消",
"creating": "创建中...",
"updating": "更新中...",
"name_required": "至少需要名字姓氏",
"name_required": "请输入名字姓氏或组织",
"email_invalid": "请输入有效的邮箱地址",
"email_error_inline": "邮箱地址格式无效",
"save_failed": "保存联系人失败",