mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 16:15:39 +00:00
fix: show contact dates, print headings and the disabled notice in the app's language
This commit is contained in:
@@ -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<');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "جهات الاتصال",
|
||||
"disabled_title": "عطّل المسؤول جهات الاتصال",
|
||||
"disabled_description": "تواصل مع المسؤول إذا كنت بحاجة إليها.",
|
||||
"drag_count": "{count, plural, one {جهة اتصال واحدة} other {# جهة اتصال}}",
|
||||
"search_placeholder": "بحث في جهات الاتصال...",
|
||||
"create_new": "جهة اتصال جديدة",
|
||||
"no_category": "بلا فئة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "مخاطبین",
|
||||
"disabled_title": "مدیر شما مخاطبان را غیرفعال کرده است",
|
||||
"disabled_description": "اگر به آنها نیاز دارید، با مدیر تماس بگیرید.",
|
||||
"drag_count": "{count, plural, one {۱ مخاطب} other {# مخاطب}}",
|
||||
"search_placeholder": "جستجوی مخاطبین...",
|
||||
"create_new": "مخاطب جدید",
|
||||
"no_category": "بدون دستهبندی",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2822,6 +2822,9 @@
|
||||
}
|
||||
},
|
||||
"title": "אנשי קשר",
|
||||
"disabled_title": "מנהל המערכת השבית את אנשי הקשר",
|
||||
"disabled_description": "פנה למנהל המערכת אם אתה צריך אותם.",
|
||||
"drag_count": "{count, plural, one {איש קשר אחד} other {# אנשי קשר}}",
|
||||
"search_placeholder": "חפש אנשי קשר...",
|
||||
"create_new": "איש קשר חדש",
|
||||
"no_category": "אין קטגוריה",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "連絡先",
|
||||
"disabled_title": "連絡先は管理者によって無効にされています",
|
||||
"disabled_description": "必要な場合は管理者にお問い合わせください。",
|
||||
"drag_count": "{count, plural, other {#件の連絡先}}",
|
||||
"search_placeholder": "連絡先を検索...",
|
||||
"create_new": "新しい連絡先",
|
||||
"no_category": "カテゴリなし",
|
||||
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "연락처",
|
||||
"disabled_title": "관리자가 연락처를 사용 중지했습니다",
|
||||
"disabled_description": "필요하면 관리자에게 문의하세요.",
|
||||
"drag_count": "{count, plural, other {연락처 #개}}",
|
||||
"search_placeholder": "연락처 검색...",
|
||||
"create_new": "새 연락처",
|
||||
"no_category": "카테고리 없음",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "Харилцах утас",
|
||||
"disabled_title": "Таны администратор харилцагчдыг унтраасан",
|
||||
"disabled_description": "Хэрэгтэй бол администратортаа хандана уу.",
|
||||
"drag_count": "{count, plural, one {1 харилцагч} other {# харилцагч}}",
|
||||
"search_placeholder": "Харилцагч хайх...",
|
||||
"create_new": "Шинэ холбоо барих",
|
||||
"no_category": "Ангилалгүй",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "Контакты",
|
||||
"disabled_title": "Администратор отключил контакты",
|
||||
"disabled_description": "Обратитесь к администратору, если они вам нужны.",
|
||||
"drag_count": "{count, plural, one {# контакт} few {# контакта} other {# контактов}}",
|
||||
"search_placeholder": "Поиск контактов...",
|
||||
"create_new": "Новый контакт",
|
||||
"no_category": "Без категории",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2258,6 +2258,9 @@
|
||||
}
|
||||
},
|
||||
"title": "ผู้ติดต่อ",
|
||||
"disabled_title": "ผู้ดูแลระบบปิดรายชื่อติดต่อ",
|
||||
"disabled_description": "ติดต่อผู้ดูแลระบบหากคุณต้องการใช้",
|
||||
"drag_count": "{count, plural, other {ผู้ติดต่อ # รายการ}}",
|
||||
"search_placeholder": "ค้นหาผู้ติดต่อ...",
|
||||
"create_new": "ผู้ติดต่อใหม่",
|
||||
"no_category": "ไม่มีหมวดหมู่",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "Контакти",
|
||||
"disabled_title": "Адміністратор вимкнув контакти",
|
||||
"disabled_description": "Зверніться до адміністратора, якщо вони вам потрібні.",
|
||||
"drag_count": "{count, plural, one {# контакт} few {# контакти} other {# контактів}}",
|
||||
"search_placeholder": "Пошук контактів...",
|
||||
"create_new": "Новий контакт",
|
||||
"no_category": "Без категорії",
|
||||
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "聯絡人",
|
||||
"disabled_title": "管理員已關閉聯絡人",
|
||||
"disabled_description": "如需使用,請聯絡管理員。",
|
||||
"drag_count": "{count, plural, other {# 位聯絡人}}",
|
||||
"search_placeholder": "搜尋聯絡人…",
|
||||
"create_new": "新增聯絡人",
|
||||
"no_category": "無類別",
|
||||
|
||||
@@ -2904,6 +2904,9 @@
|
||||
}
|
||||
},
|
||||
"title": "联系人",
|
||||
"disabled_title": "管理员已关闭联系人",
|
||||
"disabled_description": "如需使用,请联系管理员。",
|
||||
"drag_count": "{count, plural, other {# 个联系人}}",
|
||||
"search_placeholder": "搜索联系人...",
|
||||
"create_new": "新联系人",
|
||||
"no_category": "没有类别",
|
||||
|
||||
Reference in New Issue
Block a user