fix: show contact dates, print headings and the disabled notice in the app's language

This commit is contained in:
Linus Rath
2026-10-28 04:28:09 +01:00
parent 46eb9cf42d
commit 6f371deaa7
37 changed files with 251 additions and 114 deletions
@@ -1,9 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { printContact } from '../contact-print';
import { printContact, printLabels } from '../contact-print';
import { useAuthStore } from '@/stores/auth-store';
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
import type { ContactCard } from '@/lib/jmap/types';
const LABELS = printLabels((key) => key, 'en');
const base: ContactCard = {
id: 'c1',
addressBookIds: {},
@@ -36,7 +38,7 @@ describe('printContact', () => {
it('opens the window right away and prints a Stalwart 1.0 photo inline as a data: URI', async () => {
fetchBlob.mockResolvedValue(new Blob([new Uint8Array([0xff, 0xd8, 0xff])], { type: 'image/jpeg' }));
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } });
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX', mediaType: 'image/jpeg' } } }, LABELS);
// Before anything is awaited, while the click still allows a popup.
expect(open).toHaveBeenCalledTimes(1);
await done;
@@ -47,7 +49,7 @@ describe('printContact', () => {
});
it('prints a Stalwart 0.16 photo as it is', async () => {
await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } });
await printContact({ ...base, media: { photo: { kind: 'photo', uri: 'data:base64,/9j/' } } }, LABELS);
expect(fetchBlob).not.toHaveBeenCalled();
expect(written()).toContain('src="data:image/jpeg;base64,/9j/"');
@@ -57,7 +59,7 @@ describe('printContact', () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
fetchBlob.mockRejectedValue(new Error('Failed to fetch blob: 404'));
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } });
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcGone' } } }, LABELS);
await vi.advanceTimersByTimeAsync(1000);
await done;
@@ -68,9 +70,21 @@ describe('printContact', () => {
it('does not write into a window the user already closed', async () => {
fetchBlob.mockResolvedValue(new Blob(['x'], { type: 'image/jpeg' }));
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } });
const done = printContact({ ...base, media: { photo: { kind: 'photo', blobId: 'dbcX' } } }, LABELS);
printWindow.closed = true;
await done;
expect(printWindow.document.write).not.toHaveBeenCalled();
});
it('prints the headings and dates in the language of the app', async () => {
const de = printLabels((key) => ({ 'detail.emails': 'E-Mail-Adressen', 'detail.anniversaries': 'Jahrestage', 'detail.anniversary_birth': 'Geburtstag' } as Record<string, string>)[key] ?? key, 'de');
await printContact({
...base,
emails: { e: { address: 'ann@example.com' } },
anniversaries: { b: { kind: 'birth', date: { year: 1988, month: 3, day: 15 } } },
} as ContactCard, de);
expect(written()).toContain('<h2>E-Mail-Adressen</h2>');
expect(written()).toContain('<dt>Geburtstag</dt><dd>15. März 1988</dd>');
expect(written()).not.toContain('>Email<');
});
});
+4 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import {
ContextMenu,
ContextMenuItem,
@@ -24,7 +24,7 @@ import {
import type { ContactCard } from "@/lib/jmap/types";
import { getContactPrimaryEmail } from "@/stores/contact-store";
import { exportContact, useContactExportBusy } from "./contact-export";
import { printContact } from "./contact-print";
import { printContact, printLabels } from "./contact-print";
import { toast } from "@/stores/toast-store";
function getContactPrimaryPhone(contact: ContactCard): string {
@@ -84,6 +84,7 @@ export function ContactContextMenu({
onBatchDelete,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const locale = useLocale();
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
const showBatchActions = isMultiSelect && selectedCount > 1;
@@ -116,7 +117,7 @@ export function ContactContextMenu({
};
const handlePrint = () => {
void printContact(contact);
void printContact(contact, printLabels(t, locale));
};
if (showBatchActions) {
+10 -67
View File
@@ -1,19 +1,20 @@
"use client";
import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { contactDateParts, formatContactDate } from "@/lib/contact-dates";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer, FolderInput } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
import { MailtoLink } from "@/components/ui/mailto-link";
import { Button } from "@/components/ui/button";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
import type { ContactCard, AnniversaryDate } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store";
import { ContactActivity } from "./contact-activity";
import { toast } from "@/stores/toast-store";
import { exportContact, useContactExportBusy } from "./contact-export";
import { printContact } from "./contact-print";
import { printContact, printLabels } from "./contact-print";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { contactHooks } from "@/lib/plugin-hooks";
@@ -66,32 +67,8 @@ function formatPhoneFeatures(features: Record<string, boolean> | undefined, labe
return Object.keys(features).filter(k => features[k]).map(label).join(", ");
}
function getDateParts(dateInput: AnniversaryDate): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof dateInput.utc === "string") {
const d = new Date(dateInput.utc);
if (!isNaN(d.getTime())) {
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
return {};
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const parts = s.substring(2).split("-");
return { month: parseInt(parts[0], 10), day: parts[1] ? parseInt(parts[1], 10) : undefined };
}
const d = new Date(s);
if (!isNaN(d.getTime())) {
return { year: d.getFullYear(), month: d.getMonth() + 1, day: d.getDate() };
}
return {};
}
function getCompletedYears(dateInput: AnniversaryDate): number | null {
const { year, month, day } = getDateParts(dateInput);
const { year, month, day } = contactDateParts(dateInput);
if (!year) return null;
const now = new Date();
let years = now.getFullYear() - year;
@@ -104,44 +81,10 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
return years;
}
function formatDate(dateInput: AnniversaryDate): string {
if (typeof dateInput === 'object' && dateInput !== null) {
if (dateInput['@type'] === 'Timestamp' && typeof dateInput.utc === 'string') {
try {
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 monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const parts: string[] = [];
if (pd.month && monthNames[pd.month - 1]) parts.push(monthNames[pd.month - 1]);
if (pd.day) parts.push(String(pd.day));
if (pd.year) parts.push(String(pd.year));
return parts.join(' ') || String(dateInput);
}
const dateStr = String(dateInput);
if (dateStr.startsWith("--")) {
const parts = dateStr.substring(2).split("-");
const month = parseInt(parts[0], 10);
const day = parts[1] ? parseInt(parts[1], 10) : undefined;
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return day ? `${monthNames[month - 1]} ${day}` : monthNames[month - 1];
}
try {
const d = new Date(dateStr);
if (!isNaN(d.getTime())) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
}
} catch { /* fallback */ }
return dateStr;
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, isMobile, className }: ContactDetailProps) {
const t = useTranslations("contacts");
const locale = useLocale();
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key);
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key);
@@ -179,7 +122,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
};
const handlePrint = () => {
void printContact(contact, name);
void printContact(contact, printLabels(t, locale), name);
};
const moreItems: MoreItem[] = [];
@@ -396,7 +339,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
return (
<FieldRow key={`an${i}`} icon={Cake} label={t(`detail.anniversary_${ann.kind}`)}>
<div className="text-sm">
{formatDate(ann.date)}
{formatContactDate(ann.date, locale)}
{years !== null && (
<span className="text-muted-foreground"> · {t(suffixKey, { count: years })}</span>
)}
@@ -543,8 +486,8 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
{(contact.created || contact.updated) && (
<div className="py-4 text-xs text-muted-foreground space-y-1">
{contact.created && <div>{t("detail.created")}: {formatDate(contact.created)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatDate(contact.updated)}</div>}
{contact.created && <div>{t("detail.created")}: {formatContactDate(contact.created, locale)}</div>}
{contact.updated && <div>{t("detail.updated")}: {formatContactDate(contact.updated, locale)}</div>}
</div>
)}
</div>
+4 -2
View File
@@ -2,6 +2,7 @@
import { useCallback, useRef, type DragEvent } from "react";
import { useLongPress } from "@/hooks/use-long-press";
import { useTranslations } from "next-intl";
import { Avatar } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import type { ContactCard } from "@/lib/jmap/types";
@@ -31,6 +32,7 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
const longPress = useLongPress(() => {
if (rowRef.current) onLongPress?.(rowRef.current);
}, !!onLongPress);
const t = useTranslations("contacts");
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const org = contact.organizations
@@ -56,11 +58,11 @@ export function ContactListItem({ contact, isSelected, isChecked, hasSelection,
box-shadow: 0 4px 12px rgba(0,0,0,0.15); font-size: 14px; font-weight: 500;
z-index: 9999; white-space: nowrap; pointer-events: none;
`;
preview.textContent = ids.length === 1 ? (name || "1 contact") : `${ids.length} contacts`;
preview.textContent = ids.length === 1 && name ? name : t("drag_count", { count: ids.length });
document.body.appendChild(preview);
e.dataTransfer.setDragImage(preview, 0, 0);
requestAnimationFrame(() => preview.remove());
}, [contact.id, name, email, selectedContactIds]);
}, [contact.id, name, email, selectedContactIds, t]);
return (
<div
+64 -34
View File
@@ -1,4 +1,5 @@
import type { ContactCard, PartialDate } from "@/lib/jmap/types";
import type { ContactCard } from "@/lib/jmap/types";
import { formatContactDate } from "@/lib/contact-dates";
import { getContactDisplayName, getContactPhotoRef } from "@/stores/contact-store";
import { loadContactMediaBlob } from "@/lib/contact-media";
import { blobToDataUri } from "@/lib/data-uri";
@@ -37,7 +38,53 @@ async function printablePhotoUri(contact: ContactCard): Promise<string | undefin
return blob ? blobToDataUri(blob, photo.mediaType).catch(() => undefined) : undefined;
}
export async function printContact(contact: ContactCard, displayName?: string): Promise<void> {
/** The print sheet's words, in the UI language. */
export interface PrintLabels {
locale: string;
contact: string;
emails: string;
email: string;
phones: string;
phone: string;
organizations: string;
organization: string;
titles: string;
title: string;
role: string;
addresses: string;
address: string;
online: string;
onlineService: string;
anniversaries: string;
anniversary: (kind: string) => string;
notes: string;
}
/** The print labels from the contacts translations (`t` of the "contacts" namespace). */
export function printLabels(t: (key: string) => string, locale: string): PrintLabels {
return {
locale,
contact: t("detail.section_contact"),
emails: t("detail.emails"),
email: t("detail.email_default_label"),
phones: t("detail.phones"),
phone: t("detail.phone_default_label"),
organizations: t("detail.organizations"),
organization: t("detail.organization_label"),
titles: t("detail.titles"),
title: t("detail.title_label"),
role: t("detail.role_label"),
addresses: t("detail.addresses"),
address: t("detail.address_default_label"),
online: t("detail.online_services"),
onlineService: t("detail.online_service_default_label"),
anniversaries: t("detail.anniversaries"),
anniversary: (kind) => t(`detail.anniversary_${["birth", "death", "wedding"].includes(kind) ? kind : "other"}`),
notes: t("detail.notes"),
};
}
export async function printContact(contact: ContactCard, labels: PrintLabels, displayName?: string): Promise<void> {
// Open the window before anything is awaited: browsers allow the popup only
// while the click that asked for it is being handled.
const printWindow = window.open("", "_blank");
@@ -63,62 +110,45 @@ export async function printContact(contact: ContactCard, displayName?: string):
const row = (label: string, value: string) =>
`<dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd>`;
section("Email", emails.map(e => row(e.label || "Email", e.address || "")));
section("Phone", phones.map(p => row(p.label || "Phone", p.number || "")));
section(labels.emails, emails.map(e => row(e.label || labels.email, e.address || "")));
section(labels.phones, phones.map(p => row(p.label || labels.phone, p.number || "")));
section(
"Organization",
labels.organizations,
orgs.map(o => {
const units = o.units ? Object.values(o.units).map(u => u.name).filter(Boolean).join(", ") : "";
const value = [o.name, units].filter(Boolean).join(" \u2014 ");
return row("Organization", value);
return row(labels.organization, value);
}),
);
section("Title", titles.map(t => row(t.kind === "role" ? "Role" : "Title", t.name || "")));
section(labels.titles, titles.map(t => row(t.kind === "role" ? labels.role : labels.title, t.name || "")));
section(
"Address",
labels.addresses,
addresses.map(a => {
const parts = [a.street, a.locality, a.region, a.postcode, a.country]
.map(p => (typeof p === "string" ? p : ""))
.filter(Boolean);
return row(a.label || "Address", parts.join(", "));
return row(a.label || labels.address, parts.join(", "));
}),
);
section(
"Online",
onlineServices.map(s => row(s.label || s.service || "Online", s.uri || s.user || "")),
labels.online,
onlineServices.map(s => row(s.label || s.service || labels.onlineService, s.uri || s.user || "")),
);
section(
"Anniversary",
anniversaries.map(a => {
const date = a.date;
let formatted = "";
if (typeof date === "object" && date !== null) {
if ("utc" in date && typeof date.utc === "string") formatted = date.utc;
else {
const parts: string[] = [];
const pd = date as PartialDate;
if (pd.year) parts.push(String(pd.year));
if (pd.month) parts.push(String(pd.month).padStart(2, "0"));
if (pd.day) parts.push(String(pd.day).padStart(2, "0"));
formatted = parts.join("-");
}
} else if (date) {
formatted = String(date);
}
return row(a.kind || "Date", formatted);
}),
labels.anniversaries,
anniversaries.map(a => row(labels.anniversary(a.kind || "other"), a.date ? formatContactDate(a.date, labels.locale) : "")),
);
section("Notes", notes.map(n => row("", n.note || "")));
section(labels.notes, notes.map(n => row("", n.note || "")));
const photoTag = photoUri
? `<img class="photo" src="${escapeHtml(photoUri)}" alt="" />`
: `<div class="photo placeholder">${escapeHtml((name || "?").charAt(0).toUpperCase())}</div>`;
const html = `<!doctype html>
<html>
<html lang="${escapeHtml(labels.locale)}">
<head>
<meta charset="utf-8" />
<title>${escapeHtml(name || "Contact")}</title>
<title>${escapeHtml(name || labels.contact)}</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #111; margin: 32px; }
@@ -137,7 +167,7 @@ export async function printContact(contact: ContactCard, displayName?: string):
<body>
<header>
${photoTag}
<div><h1>${escapeHtml(name || "Contact")}</h1></div>
<div><h1>${escapeHtml(name || labels.contact)}</h1></div>
</header>
${rows.join("")}
</body>
+3 -3
View File
@@ -1078,9 +1078,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
return (
<div className="flex h-dvh items-center justify-center bg-background p-6">
<div className="max-w-lg text-center space-y-3">
<AlertTriangle className="w-10 h-10 text-yellow-500 mx-auto" />
<p className="text-sm font-medium">Contacts feature is disabled by your administrator</p>
<p className="text-xs text-muted-foreground">Please contact your administrator if you need access.</p>
<AlertTriangle className="w-10 h-10 text-amber-600 dark:text-amber-400 mx-auto" />
<p className="text-sm font-medium">{t("disabled_title")}</p>
<p className="text-xs text-muted-foreground">{t("disabled_description")}</p>
</div>
</div>
);
+15
View File
@@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { formatContactDate } from '../contact-dates';
describe('contact dates in the UI language', () => {
it('writes a birthday the German way in German, not "Mar 15 1988"', () => {
expect(formatContactDate({ year: 1988, month: 3, day: 15 }, 'de')).toBe('15. März 1988');
expect(formatContactDate('1988-03-15', 'de')).toBe('15. März 1988');
});
it('leaves out what the date does not name', () => {
expect(formatContactDate('--03-15', 'en')).toBe('March 15');
expect(formatContactDate({ year: 1988 }, 'en')).toBe('1988');
expect(formatContactDate('--02-29', 'fr')).toBe('29 février');
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { AnniversaryDate, PartialDate } from "@/lib/jmap/types";
/**
* A contact's date (birthday, anniversary, created) in the UI language:
* "15. März 1988", "March 15", "1988". A date without a year (vCard
* "--03-15") or without a day shows only what it has.
*/
export function formatContactDate(dateInput: AnniversaryDate | string, locale: string): string {
const parts = contactDateParts(dateInput);
const { year, month, day } = parts;
try {
if (month && day) {
// A leap year, so 29 February survives without a year.
const d = new Date(Date.UTC(year ?? 2000, month - 1, day));
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", month: "long", day: "numeric", ...(year ? { year: "numeric" } : {}) }).format(d);
}
if (month) {
const d = new Date(Date.UTC(year ?? 2000, month - 1, 1));
return new Intl.DateTimeFormat(locale, { timeZone: "UTC", month: "long", ...(year ? { year: "numeric" } : {}) }).format(d);
}
if (year) return String(year);
} catch { /* an unknown locale: below */ }
return typeof dateInput === "string" ? dateInput : JSON.stringify(dateInput);
}
/** The year, month and day a contact's date names, whichever it has. */
export function contactDateParts(dateInput: AnniversaryDate | string): { year?: number; month?: number; day?: number } {
if (typeof dateInput === "object" && dateInput !== null) {
if (dateInput["@type"] === "Timestamp" && typeof (dateInput as { utc?: unknown }).utc === "string") {
const d = new Date((dateInput as { utc: string }).utc);
return isNaN(d.getTime()) ? {} : { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
const pd = dateInput as PartialDate;
return { year: pd.year, month: pd.month, day: pd.day };
}
const s = String(dateInput);
if (s.startsWith("--")) {
const [m, d] = s.substring(2).split("-");
return { month: parseInt(m, 10) || undefined, day: d ? parseInt(d, 10) || undefined : undefined };
}
const iso = /^(\d{4})-(\d{2})(?:-(\d{2}))?/.exec(s);
if (iso) return { year: Number(iso[1]), month: Number(iso[2]), day: iso[3] ? Number(iso[3]) : undefined };
const d = new Date(s);
return isNaN(d.getTime()) ? {} : { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
}
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "جهات الاتصال",
"disabled_title": "عطّل المسؤول جهات الاتصال",
"disabled_description": "تواصل مع المسؤول إذا كنت بحاجة إليها.",
"drag_count": "{count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}",
"search_placeholder": "بحث في جهات الاتصال...",
"create_new": "جهة اتصال جديدة",
"no_category": "بلا فئة",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Contactes",
"disabled_title": "L'administrador ha desactivat els contactes",
"disabled_description": "Contacteu amb l'administrador si els necessiteu.",
"drag_count": "{count, plural, one {1 contacte} other {# contactes}}",
"search_placeholder": "Cerca contactes...",
"create_new": "Contacte nou",
"no_category": "Sense categoria",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kontakty",
"disabled_title": "Kontakty vypnul správce",
"disabled_description": "Pokud je potřebujete, obraťte se na správce.",
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktů}}",
"search_placeholder": "Hledat kontakty...",
"create_new": "Nový kontakt",
"no_category": "Bez kategorie",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kontakter",
"disabled_title": "Kontakter er slået fra af din administrator",
"disabled_description": "Kontakt din administrator, hvis du har brug for dem.",
"drag_count": "{count, plural, one {1 kontakt} other {# kontakter}}",
"search_placeholder": "Søg efter kontakter...",
"create_new": "Ny kontakt",
"no_category": "Ingen kategori",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kontakte",
"disabled_title": "Kontakte wurden von Ihrem Administrator deaktiviert",
"disabled_description": "Wenden Sie sich an Ihren Administrator, wenn Sie Zugriff benötigen.",
"drag_count": "{count, plural, one {1 Kontakt} other {# Kontakte}}",
"search_placeholder": "Kontakte suchen...",
"create_new": "Neuer Kontakt",
"no_category": "Ohne Kategorie",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Contacts",
"disabled_title": "Contacts is turned off by your administrator",
"disabled_description": "Ask your administrator if you need them.",
"drag_count": "{count, plural, one {1 contact} other {# contacts}}",
"search_placeholder": "Search contacts...",
"create_new": "New Contact",
"no_category": "No Category",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Contactos",
"disabled_title": "Tu administrador ha desactivado los contactos",
"disabled_description": "Pídeselos a tu administrador si los necesitas.",
"drag_count": "{count, plural, one {1 contacto} other {# contactos}}",
"search_placeholder": "Buscar contactos...",
"create_new": "Nuevo contacto",
"no_category": "Sin categoría",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kontaktid",
"disabled_title": "Administraator on kontaktid välja lülitanud",
"disabled_description": "Kui neid vajate, pöörduge administraatori poole.",
"drag_count": "{count, plural, one {1 kontakt} other {# kontakti}}",
"search_placeholder": "Otsi kontakte...",
"create_new": "Uus kontakt",
"no_category": "Kategooria puudub",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "مخاطبین",
"disabled_title": "مدیر شما مخاطبان را غیرفعال کرده است",
"disabled_description": "اگر به آن‌ها نیاز دارید، با مدیر تماس بگیرید.",
"drag_count": "{count, plural, one {۱ مخاطب} other {# مخاطب}}",
"search_placeholder": "جستجوی مخاطبین...",
"create_new": "مخاطب جدید",
"no_category": "بدون دسته‌بندی",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Contacts",
"disabled_title": "Les contacts ont été désactivés par votre administrateur",
"disabled_description": "Contactez votre administrateur si vous en avez besoin.",
"drag_count": "{count, plural, one {1 contact} other {# contacts}}",
"search_placeholder": "Rechercher des contacts...",
"create_new": "Nouveau contact",
"no_category": "Sans catégorie",
+3
View File
@@ -2822,6 +2822,9 @@
}
},
"title": "אנשי קשר",
"disabled_title": "מנהל המערכת השבית את אנשי הקשר",
"disabled_description": "פנה למנהל המערכת אם אתה צריך אותם.",
"drag_count": "{count, plural, one {איש קשר אחד} other {# אנשי קשר}}",
"search_placeholder": "חפש אנשי קשר...",
"create_new": "איש קשר חדש",
"no_category": "אין קטגוריה",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Névjegyek",
"disabled_title": "A névjegyeket a rendszergazda kikapcsolta",
"disabled_description": "Ha szüksége van rájuk, forduljon a rendszergazdához.",
"drag_count": "{count, plural, one {1 névjegy} other {# névjegy}}",
"search_placeholder": "Névjegyek keresése...",
"create_new": "Új névjegy",
"no_category": "Nincs kategória",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Contatti",
"disabled_title": "I contatti sono stati disattivati dall'amministratore",
"disabled_description": "Rivolgiti all'amministratore se ti servono.",
"drag_count": "{count, plural, one {1 contatto} other {# contatti}}",
"search_placeholder": "Cerca contatti...",
"create_new": "Nuovo contatto",
"no_category": "Senza categoria",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "連絡先",
"disabled_title": "連絡先は管理者によって無効にされています",
"disabled_description": "必要な場合は管理者にお問い合わせください。",
"drag_count": "{count, plural, other {#件の連絡先}}",
"search_placeholder": "連絡先を検索...",
"create_new": "新しい連絡先",
"no_category": "カテゴリなし",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "연락처",
"disabled_title": "관리자가 연락처를 사용 중지했습니다",
"disabled_description": "필요하면 관리자에게 문의하세요.",
"drag_count": "{count, plural, other {연락처 #개}}",
"search_placeholder": "연락처 검색...",
"create_new": "새 연락처",
"no_category": "카테고리 없음",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kontakti",
"disabled_title": "Administrators ir izslēdzis kontaktpersonas",
"disabled_description": "Ja tās ir nepieciešamas, sazinieties ar administratoru.",
"drag_count": "{count, plural, one {1 kontaktpersona} other {# kontaktpersonas}}",
"search_placeholder": "Meklēt kontaktus...",
"create_new": "Jauns kontakts",
"no_category": "Bez kategorijas",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Харилцах утас",
"disabled_title": "Таны администратор харилцагчдыг унтраасан",
"disabled_description": "Хэрэгтэй бол администратортаа хандана уу.",
"drag_count": "{count, plural, one {1 харилцагч} other {# харилцагч}}",
"search_placeholder": "Харилцагч хайх...",
"create_new": "Шинэ холбоо барих",
"no_category": "Ангилалгүй",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kontakter",
"disabled_title": "Kontakter er slått av av administratoren",
"disabled_description": "Spør administratoren hvis du trenger dem.",
"drag_count": "{count, plural, one {1 kontakt} other {# kontakter}}",
"search_placeholder": "Søk etter kontakter...",
"create_new": "Ny kontakt",
"no_category": "Ingen kategori",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Contacten",
"disabled_title": "Contacten zijn uitgeschakeld door je beheerder",
"disabled_description": "Vraag je beheerder als je ze nodig hebt.",
"drag_count": "{count, plural, one {1 contact} other {# contacten}}",
"search_placeholder": "Contacten zoeken...",
"create_new": "Nieuw contact",
"no_category": "Geen categorie",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kontakty",
"disabled_title": "Administrator wyłączył kontakty",
"disabled_description": "Jeśli ich potrzebujesz, skontaktuj się z administratorem.",
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktów}}",
"search_placeholder": "Szukaj kontaktów...",
"create_new": "Nowy kontakt",
"no_category": "Brak kategorii",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Contatos",
"disabled_title": "Os contatos foram desativados pelo administrador",
"disabled_description": "Peça ao administrador se precisar deles.",
"drag_count": "{count, plural, one {1 contato} other {# contatos}}",
"search_placeholder": "Pesquisar contatos...",
"create_new": "Novo contato",
"no_category": "Sem categoria",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Contacte",
"disabled_title": "Contactele au fost dezactivate de administrator",
"disabled_description": "Contactați administratorul dacă aveți nevoie de ele.",
"drag_count": "{count, plural, one {1 contact} other {# contacte}}",
"search_placeholder": "Căutare contacte...",
"create_new": "Contact nou",
"no_category": "Fără categorie",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Контакты",
"disabled_title": "Администратор отключил контакты",
"disabled_description": "Обратитесь к администратору, если они вам нужны.",
"drag_count": "{count, plural, one {# контакт} few {# контакта} other {# контактов}}",
"search_placeholder": "Поиск контактов...",
"create_new": "Новый контакт",
"no_category": "Без категории",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kontakty",
"disabled_title": "Kontakty vypol správca",
"disabled_description": "Ak ich potrebujete, obráťte sa na správcu.",
"drag_count": "{count, plural, one {1 kontakt} few {# kontakty} other {# kontaktov}}",
"search_placeholder": "Hľadať kontakty...",
"create_new": "Nový kontakt",
"no_category": "Bez kategórie",
+3
View File
@@ -2258,6 +2258,9 @@
}
},
"title": "ผู้ติดต่อ",
"disabled_title": "ผู้ดูแลระบบปิดรายชื่อติดต่อ",
"disabled_description": "ติดต่อผู้ดูแลระบบหากคุณต้องการใช้",
"drag_count": "{count, plural, other {ผู้ติดต่อ # รายการ}}",
"search_placeholder": "ค้นหาผู้ติดต่อ...",
"create_new": "ผู้ติดต่อใหม่",
"no_category": "ไม่มีหมวดหมู่",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Kişiler",
"disabled_title": "Kişiler yöneticiniz tarafından kapatıldı",
"disabled_description": "İhtiyacınız varsa yöneticinize başvurun.",
"drag_count": "{count, plural, one {1 kişi} other {# kişi}}",
"search_placeholder": "Kişilerde ara...",
"create_new": "Yeni Kişi",
"no_category": "Kategori Yok",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "Контакти",
"disabled_title": "Адміністратор вимкнув контакти",
"disabled_description": "Зверніться до адміністратора, якщо вони вам потрібні.",
"drag_count": "{count, plural, one {# контакт} few {# контакти} other {# контактів}}",
"search_placeholder": "Пошук контактів...",
"create_new": "Новий контакт",
"no_category": "Без категорії",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "聯絡人",
"disabled_title": "管理員已關閉聯絡人",
"disabled_description": "如需使用,請聯絡管理員。",
"drag_count": "{count, plural, other {# 位聯絡人}}",
"search_placeholder": "搜尋聯絡人…",
"create_new": "新增聯絡人",
"no_category": "無類別",
+3
View File
@@ -2904,6 +2904,9 @@
}
},
"title": "联系人",
"disabled_title": "管理员已关闭联系人",
"disabled_description": "如需使用,请联系管理员。",
"drag_count": "{count, plural, other {# 个联系人}}",
"search_placeholder": "搜索联系人...",
"create_new": "新联系人",
"no_category": "没有类别",