{lines.map((line, idx) => (
-
{line}
+
{line}
))}
{a.timeZone && (
{t("detail.timezone")}: {a.timeZone}
diff --git a/components/contacts/contact-form.tsx b/components/contacts/contact-form.tsx
index bea3db826..c506aab54 100644
--- a/components/contacts/contact-form.tsx
+++ b/components/contacts/contact-form.tsx
@@ -2,7 +2,7 @@
import { useState, useMemo, useCallback, useRef, useEffect, useId } from "react";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
-import { useTranslations } from "next-intl";
+import { useLocale, useTranslations } from "next-intl";
import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2, MoreHorizontal } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
@@ -11,38 +11,47 @@ import { Avatar } from "@/components/ui/avatar";
import { findContactPhotoEntry, getContactPhotoRef, type ContactPhotoRef } from "@/stores/contact-store";
import type { ContactSaveOptions } from "@/lib/contact-media";
import { cn } from "@/lib/utils";
+import { addressStyle, addressToParts, countryCodeFromName, partsToComponents, type AddressStyle } from "@/lib/address-format";
import { useBottomControls } from "@/hooks/use-bottom-controls";
import type { ContactCard, ContactOnlineService, ContactAnniversary, ContactPersonalInfo, AddressBook, AnniversaryDate, PartialDate, ContactAddress, ContactMedia } from "@/lib/jmap/types";
+// Each row of a list field keeps the `key` of the card's entry it was read
+// from, so Save can send an entry the user didn't touch back unchanged.
interface EmailEntry {
+ key?: string;
address: string;
context: "work" | "private" | "";
}
interface PhoneEntry {
+ key?: string;
number: string;
context: "work" | "private" | "";
feature: "voice" | "mobile" | "fax" | "pager" | "video" | "text" | "";
}
interface OnlineServiceEntry {
+ key?: string;
uri: string;
service: string;
label: string;
}
interface AnniversaryEntry {
+ key?: string;
date: string;
kind: "birth" | "death" | "wedding" | "other";
}
interface PersonalInfoEntry {
+ key?: string;
value: string;
kind: "expertise" | "hobby" | "interest" | "other";
level: "high" | "medium" | "low" | "";
}
interface AddressEntry {
+ key?: string;
street: string;
locality: string;
region: string;
@@ -125,6 +134,62 @@ function mediaForPhotoEdit(
return { media, photoKey };
}
+/**
+ * The map to save for a list field (emails, phones, addresses...). A row the
+ * user didn't touch goes back exactly as the card had it, under its key. An
+ * edited row is built on the card's entry (`previous`), so properties the
+ * form doesn't show stay; a new row gets a key no other entry uses.
+ */
+function entriesToSave
(
+ rows: E[],
+ initialRows: E[],
+ original: Record | undefined,
+ prefix: string,
+ build: (row: E, previous: V | undefined, before: E | undefined) => V,
+): Record {
+ const out: Record = {};
+ const taken = new Set(rows.map(r => r.key).filter((k): k is string => !!k && !!original?.[k]));
+ let n = 0;
+ const freshKey = () => {
+ let key = `${prefix}${n++}`;
+ while (taken.has(key)) key = `${prefix}${n++}`;
+ taken.add(key);
+ return key;
+ };
+ for (const row of rows) {
+ const previous = row.key ? original?.[row.key] : undefined;
+ const before = previous !== undefined ? initialRows.find(r => r.key === row.key) : undefined;
+ if (previous !== undefined && before && JSON.stringify(before) === JSON.stringify(row)) {
+ out[row.key!] = previous;
+ } else {
+ out[previous !== undefined ? row.key! : freshKey()] = build(row, previous, before);
+ }
+ }
+ return out;
+}
+
+/** `contexts` for a row: the card's own when the select wasn't changed. */
+function contextsToSave(context: string, previous: Record | undefined, before: { context: string } | undefined): Record | undefined {
+ if (before && before.context === context) return previous;
+ return context ? { [context]: true } : undefined;
+}
+
+/** A card's first entry of a map with one field changed, the others kept; undefined when none is left. */
+function firstEntryToSave(original: Record | undefined, fallbackKey: string, value: V | null): Record | undefined {
+ const entries = Object.entries(original ?? {});
+ const [firstKey, first] = entries[0] ?? [fallbackKey, undefined];
+ const rest = entries.slice(1);
+ const out: Record = {};
+ if (value) out[firstKey] = { ...(first ?? {}), ...value } as V;
+ for (const [k, v] of rest) out[k] = v;
+ return Object.keys(out).length > 0 ? out : undefined;
+}
+
+/** Drops keys whose value is undefined, so a built entry has no empty properties. */
+function compact(obj: T): T {
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T;
+}
+
function FormSection({ icon: Icon, title, children, collapsible, defaultOpen = true }: {
icon: React.ComponentType<{ className?: string }>;
title: string;
@@ -221,6 +286,7 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa
export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) {
const t = useTranslations("contacts.form");
const tx = useTranslations("ux_pim_contact");
+ const locale = useLocale();
const isEditing = !!contact;
// The header's close goes to its start when the window's own close is at its end.
const [header, setHeader] = useState(null);
@@ -283,25 +349,12 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
return pd;
}
- // Extract flat address fields from RFC 9553 components format
- function addressToFlat(a: ContactAddress): AddressEntry {
- if (a.components && a.components.length > 0) {
- const findComp = (kind: string) => a.components!.filter(c => c.kind === kind).map(c => c.value).join(' ');
- return {
- street: findComp('name') || findComp('number') ? [findComp('number'), findComp('name')].filter(Boolean).join(' ') : '',
- locality: findComp('locality'),
- region: findComp('region'),
- postcode: findComp('postcode'),
- country: findComp('country'),
- context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
- };
- }
+ // The form's fields for an address: street name and house number in one
+ // field, in the order of the address's country ("Hauptstraße 1").
+ function addressToFlat(key: string, a: ContactAddress): AddressEntry {
return {
- street: a.street || '',
- locality: a.locality || '',
- region: a.region || '',
- postcode: a.postcode || '',
- country: a.country || '',
+ key,
+ ...addressToParts(a, locale),
context: a.contexts?.work ? 'work' : a.contexts?.private ? 'private' : '',
};
}
@@ -318,7 +371,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [emails, setEmails] = useState(() => {
if (contact?.emails) {
- return Object.values(contact.emails).map(e => ({
+ return Object.entries(contact.emails).map(([key, e]) => ({
+ key,
address: e.address,
context: e.contexts?.work ? "work" : e.contexts?.private ? "private" : "",
}));
@@ -328,7 +382,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [phones, setPhones] = useState(() => {
if (contact?.phones) {
- return Object.values(contact.phones).map(p => ({
+ return Object.entries(contact.phones).map(([key, p]) => ({
+ key,
number: p.number,
context: p.contexts?.work ? "work" : p.contexts?.private ? "private" : "",
// RFC 9553 calls it "mobile"; cards saved by older versions of this form say "cell".
@@ -372,14 +427,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [addresses, setAddresses] = useState(() => {
if (contact?.addresses) {
- return Object.values(contact.addresses).map(a => addressToFlat(a));
+ return Object.entries(contact.addresses).map(([key, a]) => addressToFlat(key, a));
}
return [];
});
const [onlineServices, setOnlineServices] = useState(() => {
if (contact?.onlineServices) {
- return Object.values(contact.onlineServices).map(s => ({
+ return Object.entries(contact.onlineServices).map(([key, s]) => ({
+ key,
uri: s.uri,
service: s.service || "",
label: s.label || "",
@@ -390,7 +446,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [anniversaries, setAnniversaries] = useState(() => {
if (contact?.anniversaries) {
- return Object.values(contact.anniversaries).map(a => ({
+ return Object.entries(contact.anniversaries).map(([key, a]) => ({
+ key,
date: anniversaryDateToString(a.date),
kind: a.kind,
}));
@@ -400,7 +457,8 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const [personalInfoEntries, setPersonalInfoEntries] = useState(() => {
if (contact?.personalInfo) {
- return Object.values(contact.personalInfo).map(p => ({
+ return Object.entries(contact.personalInfo).map(([key, p]) => ({
+ key,
value: p.value,
kind: p.kind,
level: p.level || "",
@@ -475,6 +533,32 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
keywordsStr, categoryDraft, note, genderSex, genderIdentity, calendarUri, schedulingUri, freeBusyUri, selectedBookId,
]);
const [initialFields] = useState(fields);
+
+ // What each card property is built from. Saving an existing card sends only
+ // the properties whose fields changed, so the rest stay exactly as stored,
+ // structured address parts included (#pim-4).
+ const personNamed = !!(givenName.trim() || surname.trim());
+ const propertyFields: Record = {
+ name: [prefix, givenName, additionalName, surname, suffix, isOrg, isOrg || !personNamed ? organization : ""],
+ nicknames: nickname,
+ emails,
+ phones,
+ titles: [jobTitle, role],
+ organizations: [organization, department],
+ addresses,
+ onlineServices,
+ anniversaries,
+ personalInfo: personalInfoEntries,
+ keywords: [keywordsStr, categoryDraft],
+ notes: note,
+ speakToAs: [genderSex, genderIdentity],
+ calendarUri: [calendarUri, freeBusyUri],
+ freeBusyUri: [calendarUri, freeBusyUri],
+ schedulingUri,
+ addressBookIds: selectedBookId,
+ };
+ const [initialPropertyFields] = useState(() => JSON.stringify(propertyFields));
+ const [initialRows] = useState(() => ({ emails, phones, addresses, onlineServices, anniversaries, personalInfoEntries, department }));
const dirty = fields !== initialFields || photoEdit.kind !== "unchanged";
useEffect(() => { onDirtyChange?.(dirty); }, [dirty, onDirtyChange]);
const footerRef = useBottomControls();
@@ -547,27 +631,21 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
}
}
- const emailsMap: Record }> = {};
- validEmails.forEach((entry, i) => {
- const obj: { address: string; contexts?: Record } = { address: entry.address.trim() };
- if (entry.context) {
- obj.contexts = { [entry.context]: true };
- }
- emailsMap[`e${i}`] = obj;
- });
+ type Email = NonNullable[string];
+ type PhoneValue = NonNullable[string];
+ const emailsMap = entriesToSave(validEmails, initialRows.emails, contact?.emails, "e", (row, previous, before) => compact({
+ ...previous,
+ address: row.address.trim(),
+ contexts: contextsToSave(row.context, previous?.contexts, before),
+ }));
const validPhones = phones.filter(p => p.number.trim());
- const phonesMap: Record; features?: Record }> = {};
- validPhones.forEach((entry, i) => {
- const obj: { number: string; contexts?: Record; features?: Record } = { number: entry.number.trim() };
- if (entry.context) {
- obj.contexts = { [entry.context]: true };
- }
- if (entry.feature) {
- obj.features = { [entry.feature]: true };
- }
- phonesMap[`p${i}`] = obj;
- });
+ const phonesMap = entriesToSave(validPhones, initialRows.phones, contact?.phones, "p", (row, previous, before) => compact({
+ ...previous,
+ number: row.number.trim(),
+ contexts: contextsToSave(row.context, previous?.contexts, before),
+ features: before && before.feature === row.feature ? previous?.features : row.feature ? { [row.feature]: true } : undefined,
+ }));
// Emit JSContact-standard kinds (RFC 9553) so the JMAP server stores them losslessly.
const nameComponents = [];
@@ -597,41 +675,58 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
if (jobTitle.trim()) titlesMap["t0"] = { name: jobTitle.trim(), kind: "title" };
if (role.trim()) titlesMap["t1"] = { name: role.trim(), kind: "role" };
- const orgUnits = department.trim() ? [{ name: department.trim() }] : undefined;
+ const firstOrg = Object.values(contact?.organizations ?? {})[0];
+ const orgUnits = firstOrg && department === initialRows.department
+ ? firstOrg.units
+ : department.trim() ? [{ name: department.trim() }] : undefined;
- const addressesMap: Record ? V : never> = {};
- addresses.filter(a => a.street.trim() || a.locality.trim() || a.country.trim()).forEach((a, i) => {
- const components: Array<{ kind: string; value: string }> = [];
- if (a.street.trim()) components.push({ kind: "name", value: a.street.trim() });
- if (a.locality.trim()) components.push({ kind: "locality", value: a.locality.trim() });
- if (a.region.trim()) components.push({ kind: "region", value: a.region.trim() });
- if (a.postcode.trim()) components.push({ kind: "postcode", value: a.postcode.trim() });
- if (a.country.trim()) components.push({ kind: "country", value: a.country.trim() });
- const obj: Record = { components, isOrdered: true, defaultSeparator: ", " };
- if (a.context) obj.contexts = { [a.context]: true };
- // @ts-expect-error - dynamic build
- addressesMap[`a${i}`] = obj;
- });
+ // An address the user didn't touch goes back as the card had it. An edited
+ // one is rebuilt in its country's order ("Hauptstraße" + "1", then
+ // postcode and city), keeping the parts the form doesn't show.
+ const addressesMap = entriesToSave(
+ addresses.filter(a => a.street.trim() || a.locality.trim() || a.region.trim() || a.postcode.trim() || a.country.trim()),
+ initialRows.addresses, contact?.addresses, "a",
+ (row, previous, before) => {
+ const { components: previousComponents, full: _full, fullAddress: _fullAddress, street: _s, locality: _l, region: _r, postcode: _p, country: _c, ...kept } = previous ?? {};
+ void _full; void _fullAddress; void _s; void _l; void _r; void _p; void _c;
+ const countryCode = before && before.country === row.country
+ ? previous?.countryCode
+ : countryCodeFromName(row.country) ?? undefined;
+ return compact({
+ ...kept,
+ components: partsToComponents(row, previousComponents ?? [], locale, countryCode),
+ isOrdered: true,
+ defaultSeparator: previous?.defaultSeparator ?? ", ",
+ countryCode,
+ contexts: contextsToSave(row.context, previous?.contexts, before),
+ });
+ },
+ );
- const onlineServicesMap: Record = {};
- onlineServices.filter(s => s.uri.trim()).forEach((s, i) => {
- const obj: ContactOnlineService = { uri: s.uri.trim() };
- if (s.service.trim()) obj.service = s.service.trim();
- if (s.label.trim()) obj.label = s.label.trim();
- onlineServicesMap[`os${i}`] = obj;
- });
+ const onlineServicesMap = entriesToSave(
+ onlineServices.filter(s => s.uri.trim()), initialRows.onlineServices, contact?.onlineServices, "os",
+ (row, previous) => compact({
+ ...previous,
+ uri: row.uri.trim(),
+ service: row.service.trim() || undefined,
+ label: row.label.trim() || undefined,
+ }),
+ );
- const anniversariesMap: Record = {};
- anniversaries.filter(a => a.date.trim()).forEach((a, i) => {
- anniversariesMap[`an${i}`] = { date: stringToPartialDate(a.date.trim()), kind: a.kind };
- });
+ const anniversariesMap = entriesToSave(
+ anniversaries.filter(a => a.date.trim()), initialRows.anniversaries, contact?.anniversaries, "an",
+ (row, previous) => ({ ...previous, date: stringToPartialDate(row.date.trim()), kind: row.kind }),
+ );
- const personalInfoMap: Record = {};
- personalInfoEntries.filter(p => p.value.trim()).forEach((p, i) => {
- const obj: ContactPersonalInfo = { value: p.value.trim(), kind: p.kind };
- if (p.level) obj.level = p.level as "high" | "medium" | "low";
- personalInfoMap[`pi${i}`] = obj;
- });
+ const personalInfoMap = entriesToSave(
+ personalInfoEntries.filter(p => p.value.trim()), initialRows.personalInfoEntries, contact?.personalInfo, "pi",
+ (row, previous) => compact({
+ ...previous,
+ value: row.value.trim(),
+ kind: row.kind,
+ level: (row.level || undefined) as ContactPersonalInfo["level"],
+ }),
+ );
const keywordsMap: Record = {};
keywordsStr.split(",").map(k => k.trim()).filter(Boolean).forEach(k => {
@@ -651,21 +746,18 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
const data: Partial = {
name: nameValue,
...(kindValue ? { kind: kindValue } : {}),
- nicknames: nickname.trim() ? { n0: { name: nickname.trim() } } : undefined,
+ // The form edits the first nickname, note and organization; others stay.
+ nicknames: firstEntryToSave(contact?.nicknames, "n0", nickname.trim() ? { name: nickname.trim() } : null),
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: orgName
- ? { o0: { name: orgName, units: orgUnits } }
- : undefined,
+ organizations: firstEntryToSave(contact?.organizations, "o0", orgName ? { name: orgName, units: orgUnits } : null),
addresses: Object.keys(addressesMap).length > 0 ? addressesMap : undefined,
onlineServices: Object.keys(onlineServicesMap).length > 0 ? onlineServicesMap : undefined,
anniversaries: Object.keys(anniversariesMap).length > 0 ? anniversariesMap : undefined,
personalInfo: Object.keys(personalInfoMap).length > 0 ? personalInfoMap : undefined,
keywords: Object.keys(keywordsMap).length > 0 ? keywordsMap : undefined,
- notes: note.trim()
- ? { n0: { note: note.trim() } }
- : undefined,
+ notes: firstEntryToSave(contact?.notes, "n0", note.trim() ? { note: note.trim() } : null),
speakToAs: (genderSex.trim() || genderIdentity.trim())
? {
grammaticalGender: genderSex.trim() || undefined,
@@ -678,6 +770,15 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
...(selectedBookId ? { addressBookIds: { [selectedBookId]: true } } : {}),
};
+ // An existing card gets only what changed: every property left out stays
+ // on the server exactly as it is.
+ if (contact) {
+ const before = JSON.parse(initialPropertyFields) as Record;
+ for (const [property, value] of Object.entries(propertyFields)) {
+ if (JSON.stringify(value) === JSON.stringify(before[property])) delete data[property as keyof ContactCard];
+ }
+ }
+
setIsSaving(true);
try {
// `media` goes out only when the photo changed. An update without it
@@ -1029,31 +1130,61 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
{/* Addresses */}
0}>
- {addresses.map((addr, i) => (
-
-
setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1">
-
-
-
{ const n = [...addresses]; n[i] = { ...n[i], street: e.target.value }; setAddresses(n); }} placeholder={t("street")} aria-label={t("street")} />
-
-
{ const n = [...addresses]; n[i] = { ...n[i], locality: e.target.value }; setAddresses(n); }} placeholder={t("city")} aria-label={t("city")} />
-
{ const n = [...addresses]; n[i] = { ...n[i], region: e.target.value }; setAddresses(n); }} placeholder={t("region")} aria-label={t("region")} />
+ {addresses.map((addr, i) => {
+ const set = (name: keyof Omit
) => (e: React.ChangeEvent) => {
+ const n = [...addresses];
+ n[i] = { ...n[i], [name]: e.target.value };
+ setAddresses(n);
+ };
+ const original = addr.key ? contact?.addresses?.[addr.key] : undefined;
+ const initialCountry = addr.key ? initialRows.addresses.find(a => a.key === addr.key)?.country : undefined;
+ const style: AddressStyle = addressStyle(addr.country, initialCountry === addr.country ? original?.countryCode : null, locale);
+ const field = (name: "locality" | "region" | "postcode", label: string, className?: string) => (
+
+ );
+ const country = ;
+ const context = (
+
+ -
+ {t("context_work")}
+ {tx("context_private")}
+
+ );
+ return (
+
+
setAddresses(addresses.filter((_, j) => j !== i))} className="absolute top-1 end-1">
+
+
+ {/* Street and house number in one field, in the order the country writes them. */}
+
+ {style.town === "postcode-city" ? (
+ <>
+
+ {field("postcode", t("postcode"))}
+ {field("locality", t("city"), "col-span-2")}
+
+
+ {field("region", t("region"))}
+ {country}
+ {context}
+
+ >
+ ) : (
+ <>
+
+ {field("locality", t("city"))}
+ {field("region", t("region"))}
+
+
+ {field("postcode", t("postcode"))}
+ {country}
+ {context}
+
+ >
+ )}
-
- { const n = [...addresses]; n[i] = { ...n[i], postcode: e.target.value }; setAddresses(n); }} placeholder={t("postcode")} aria-label={t("postcode")} />
- { const n = [...addresses]; n[i] = { ...n[i], country: e.target.value }; setAddresses(n); }} placeholder={t("country")} aria-label={t("country")} />
- { const n = [...addresses]; n[i] = { ...n[i], context: e.target.value as AddressEntry["context"] }; setAddresses(n); }}
- aria-label={t("context_label")}
- >
- -
- {t("context_work")}
- {tx("context_private")}
-
-
-
- ))}
+ );
+ })}
setAddresses([...addresses, { street: "", locality: "", region: "", postcode: "", country: "", context: "" }])} className="text-xs">
{t("add_address")}
diff --git a/integration/tests/25-ux-pim-contact.spec.ts b/integration/tests/25-ux-pim-contact.spec.ts
new file mode 100644
index 000000000..71d161c5f
--- /dev/null
+++ b/integration/tests/25-ux-pim-contact.spec.ts
@@ -0,0 +1,154 @@
+import { test, expect, type Page } from '@playwright/test';
+import { contextOptions } from './helpers/ent';
+import { JmapClient } from './helpers/jmap';
+import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
+
+/**
+ * Usability fix pim-4: saving a contact never rewrites an address the user
+ * didn't touch, and an edited address is saved in its country's order.
+ * Against a VM 106 Stalwart (1.0 and 0.16, which converts cards through
+ * vCard), every UI step is checked on the server.
+ *
+ * Account: dso-ux-pim (password IT_DSO_PASSWORD). The spec makes its own
+ * card, named "UXFIX-contact …", and removes it again.
+ */
+
+const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
+const A = 'dso-ux-pim@example.org';
+const CORE = 'urn:ietf:params:jmap:core';
+const CONTACTS = 'urn:ietf:params:jmap:contacts';
+const RUN = Date.now().toString(36);
+const PREFIX = 'UXFIX-contact';
+
+test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
+
+type Address = Record & { components?: Array<{ kind: string; value: string }> };
+type Card = Record & {
+ id: string;
+ name?: { full?: string; components?: Array<{ kind: string; value: string }> };
+ nicknames?: Record;
+ addresses?: Record;
+};
+
+async function defaultBook(c: JmapClient): Promise {
+ const r = await c.request([['AddressBook/get', { accountId: c.accountId }, '0']], [CORE, CONTACTS]);
+ const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>;
+ return (list.find((b) => b.isDefault) ?? list[0]).id;
+}
+
+async function cards(c: JmapClient): Promise {
+ const r = await c.request([
+ ['ContactCard/query', { accountId: c.accountId, limit: 1000 }, 'q'],
+ ['ContactCard/get', { accountId: c.accountId, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
+ ], [CORE, CONTACTS]);
+ const named = (card: Card) => card.name?.full ?? card.name?.components?.map((x) => x.value).join(' ') ?? '';
+ return (r.methodResponses[1][1].list as Card[]).filter((card) => named(card).startsWith(PREFIX));
+}
+
+async function getCard(c: JmapClient, id: string): Promise {
+ const r = await c.request([['ContactCard/get', { accountId: c.accountId, ids: [id] }, '0']], [CORE, CONTACTS]);
+ return r.methodResponses[0][1].list[0] as Card;
+}
+
+async function login(page: Page): Promise {
+ await neutralizeDevOverlay(page);
+ await page.goto('/');
+ await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
+ await page.fill('#username', A);
+ await page.fill('#password', PASSWORD);
+ await page.click('button[type="submit"]');
+ await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
+}
+
+test.describe('Saving a contact keeps its address (pim-4)', () => {
+ test.afterAll(async () => {
+ const a = await JmapClient.connect(A, PASSWORD);
+ const ids = (await cards(a)).filter((card) => JSON.stringify(card.name).includes(RUN)).map((card) => card.id);
+ if (ids.length) await a.request([['ContactCard/set', { accountId: a.accountId, destroy: ids }, '0']], [CORE, CONTACTS]);
+ });
+
+ test('a nickname edit leaves a German address as stored, an address edit saves it in German order', async ({ browser }) => {
+ test.setTimeout(240_000);
+ const a = await JmapClient.connect(A, PASSWORD);
+ const surname = `Adresse ${RUN}`;
+ const created = await a.request([['ContactCard/set', {
+ accountId: a.accountId,
+ create: {
+ c: {
+ '@type': 'Card',
+ version: '1.0',
+ kind: 'individual',
+ addressBookIds: { [await defaultBook(a)]: true },
+ name: { '@type': 'Name', components: [{ kind: 'given', value: PREFIX }, { kind: 'surname', value: surname }], isOrdered: true },
+ phones: { p1: { '@type': 'Phone', number: '+49 30 123456', features: { voice: true }, contexts: { private: true } } },
+ addresses: {
+ home: {
+ '@type': 'Address',
+ contexts: { private: true },
+ countryCode: 'DE',
+ isOrdered: true,
+ defaultSeparator: ', ',
+ components: [
+ { kind: 'name', value: 'Hauptstraße' },
+ { kind: 'separator', value: ' ' },
+ { kind: 'number', value: '1' },
+ { kind: 'apartment', value: '3. OG' },
+ { kind: 'postcode', value: '10000' },
+ { kind: 'locality', value: 'Berlin' },
+ { kind: 'country', value: 'Germany' },
+ ],
+ },
+ },
+ },
+ },
+ }, '0']], [CORE, CONTACTS]);
+ const id = created.methodResponses[0][1].created?.c?.id as string | undefined;
+ expect(id, JSON.stringify(created.methodResponses[0][1].notCreated ?? {})).toBeTruthy();
+ // The address as this server keeps it (0.16 goes through vCard).
+ const stored = (await getCard(a, id!)).addresses!;
+ expect(Object.keys(stored)).toHaveLength(1);
+
+ const context = await browser.newContext(contextOptions());
+ const page = await context.newPage();
+ await login(page);
+ await page.goto('/contacts');
+ await page.getByPlaceholder('Search contacts...').fill(RUN);
+ await page.locator('[data-contact-id]', { hasText: surname }).first().click();
+
+ // The contact page lays the address out the German way, one line per part.
+ const addressLines = page.getByTestId('contact-address').first().locator('[data-address-line]');
+ await expect(addressLines).toHaveText(['Hauptstraße 1, 3. OG', '10000 Berlin', 'Germany']);
+ await expect(page.getByText('Home · Phone', { exact: true })).toBeVisible();
+
+ // 1. Only the nickname changes: the server's address stays exactly as it was.
+ await page.getByRole('button', { name: 'Edit contact' }).click();
+ await expect(page.getByLabel('Street')).toHaveValue('Hauptstraße 1');
+ await page.getByLabel('Nickname').fill('Adi');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Contact updated')).toBeVisible({ timeout: 30000 });
+ await expect.poll(async () => Object.values((await getCard(a, id!)).nicknames ?? {})[0]?.name, { timeout: 30000 }).toBe('Adi');
+ expect((await getCard(a, id!)).addresses).toEqual(stored);
+
+ // 2. The address is edited: saved in German order, apartment and context kept.
+ await page.getByRole('button', { name: 'Edit contact' }).click();
+ await page.getByLabel('Street').fill('Hauptstraße 12a');
+ await page.getByLabel('Postal code').fill('10115');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect.poll(async () => {
+ const address = Object.values((await getCard(a, id!)).addresses ?? {})[0];
+ return address?.components?.map((x) => `${x.kind}:${x.value}`);
+ }, { timeout: 30000 }).toEqual([
+ 'name:Hauptstraße', 'separator: ', 'number:12a', 'apartment:3. OG', 'postcode:10115', 'separator: ', 'locality:Berlin', 'country:Germany',
+ ]);
+ const edited = Object.values((await getCard(a, id!)).addresses!)[0];
+ expect(edited).toMatchObject({ countryCode: 'DE', contexts: { private: true }, isOrdered: true });
+ await expect(addressLines).toHaveText(['Hauptstraße 12a, 3. OG', '10115 Berlin', 'Germany']);
+
+ // A reload reads the card back from the server, not from the app's memory.
+ await page.reload();
+ await page.getByPlaceholder('Search contacts...').fill(RUN);
+ await page.locator('[data-contact-id]', { hasText: surname }).first().click();
+ await expect(addressLines).toHaveText(['Hauptstraße 12a, 3. OG', '10115 Berlin', 'Germany']);
+ await context.close();
+ });
+});
diff --git a/lib/__tests__/address-format.test.ts b/lib/__tests__/address-format.test.ts
new file mode 100644
index 000000000..c6fd7818e
--- /dev/null
+++ b/lib/__tests__/address-format.test.ts
@@ -0,0 +1,86 @@
+import { describe, it, expect } from 'vitest';
+import {
+ addressStyle,
+ addressToParts,
+ countryCodeFromName,
+ formatAddressLines,
+ partsToComponents,
+ splitStreet,
+} from '../address-format';
+import type { ContactAddress } from '@/lib/jmap/types';
+
+const german: ContactAddress = {
+ components: [
+ { kind: 'name', value: 'Hauptstraße' },
+ { kind: 'number', value: '1' },
+ { kind: 'postcode', value: '10000' },
+ { kind: 'locality', value: 'Berlin' },
+ { kind: 'country', value: 'Germany' },
+ ],
+};
+
+describe('address-format', () => {
+ it('knows countries by code and by name in several languages', () => {
+ expect(countryCodeFromName('Germany')).toBe('DE');
+ expect(countryCodeFromName('Deutschland')).toBe('DE');
+ expect(countryCodeFromName('Schweiz')).toBe('CH');
+ expect(countryCodeFromName('de')).toBe('DE');
+ expect(countryCodeFromName('USA')).toBe('US');
+ expect(countryCodeFromName('United Kingdom')).toBe('GB');
+ expect(countryCodeFromName('Atlantis')).toBeNull();
+ });
+
+ it('puts the number after the street for Germany and before it for the US', () => {
+ expect(addressStyle('Germany').street).toBe('number-after');
+ expect(addressStyle('Österreich').street).toBe('number-after');
+ expect(addressStyle('Netherlands').street).toBe('number-after');
+ expect(addressStyle('United States').street).toBe('number-before');
+ expect(addressStyle('France')).toEqual({ street: 'number-before', town: 'postcode-city' });
+ expect(addressStyle('', 'CA').town).toBe('city-region-postcode');
+ // No country: the UI language's.
+ expect(addressStyle('', null, 'de').street).toBe('number-after');
+ expect(addressStyle('', null, 'en').street).toBe('number-before');
+ });
+
+ it('shows street and number in one field in the country\'s order', () => {
+ expect(addressToParts(german).street).toBe('Hauptstraße 1');
+ expect(addressToParts({ ...german, countryCode: 'US', components: [
+ { kind: 'number', value: '1' }, { kind: 'name', value: 'Main Street' }, { kind: 'country', value: 'USA' },
+ ] }).street).toBe('1 Main Street');
+ // A country this file doesn't know keeps the card's own order.
+ expect(addressToParts({ components: [
+ { kind: 'name', value: 'Ulitsa' }, { kind: 'number', value: '7' }, { kind: 'country', value: 'Atlantis' },
+ ] }).street).toBe('Ulitsa 7');
+ });
+
+ it('splits a typed street line where the country puts the number', () => {
+ expect(splitStreet('Hauptstraße 12a', 'number-after')).toEqual({ name: 'Hauptstraße', number: '12a' });
+ expect(splitStreet('Am Markt 3-5', 'number-after')).toEqual({ name: 'Am Markt', number: '3-5' });
+ expect(splitStreet('Am Markt', 'number-after')).toEqual({ name: 'Am Markt', number: '' });
+ expect(splitStreet('221B Baker Street', 'number-before')).toEqual({ name: 'Baker Street', number: '221B' });
+ expect(splitStreet('Baker Street 221', 'number-before')).toEqual({ name: 'Baker Street 221', number: '' });
+ });
+
+ it('builds components in the country\'s order, keeping parts the form doesn\'t show', () => {
+ const parts = { street: 'Hauptstraße 1', locality: 'Berlin', region: '', postcode: '10000', country: 'Germany' };
+ const components = partsToComponents(parts, [{ kind: 'apartment', value: '3. OG' }]);
+ expect(components.map(c => `${c.kind}:${c.value}`)).toEqual([
+ 'name:Hauptstraße', 'separator: ', 'number:1', 'apartment:3. OG', 'postcode:10000', 'separator: ', 'locality:Berlin', 'country:Germany',
+ ]);
+ const us = partsToComponents({ street: '1 Main St', locality: 'Springfield', region: 'IL', postcode: '62701', country: 'USA' });
+ expect(us.map(c => `${c.kind}:${c.value}`)).toEqual([
+ 'number:1', 'separator: ', 'name:Main St', 'locality:Springfield', 'region:IL', 'separator: ', 'postcode:62701', 'country:USA',
+ ]);
+ });
+
+ it('formats an address one line per part, for its country', () => {
+ expect(formatAddressLines(german)).toEqual(['Hauptstraße 1', '10000 Berlin', 'Germany']);
+ expect(formatAddressLines({ components: [
+ { kind: 'number', value: '1' }, { kind: 'name', value: 'Main St' }, { kind: 'locality', value: 'Springfield' },
+ { kind: 'region', value: 'IL' }, { kind: 'postcode', value: '62701' }, { kind: 'country', value: 'USA' },
+ ] })).toEqual(['1 Main St', 'Springfield, IL 62701', 'USA']);
+ expect(formatAddressLines({ street: '10 Downing Street', locality: 'London', postcode: 'SW1A 2AA', country: 'United Kingdom' }))
+ .toEqual(['10 Downing Street', 'London', 'SW1A 2AA', 'United Kingdom']);
+ expect(formatAddressLines({ full: 'Somewhere 1\n12345 Town' })).toEqual(['Somewhere 1', '12345 Town']);
+ });
+});
diff --git a/lib/address-format.ts b/lib/address-format.ts
new file mode 100644
index 000000000..4018a370b
--- /dev/null
+++ b/lib/address-format.ts
@@ -0,0 +1,262 @@
+import type { AddressComponent, ContactAddress } from "@/lib/jmap/types";
+
+/**
+ * Postal addresses laid out the way the address's country writes them.
+ *
+ * Two things differ between countries here: whether the house number comes
+ * after the street name ("Hauptstraße 1") or before it ("1 Main Street"), and
+ * whether the postcode leads the town line ("10115 Berlin", "75001 Paris"),
+ * follows it ("Springfield, IL 62701") or has a line of its own (UK).
+ */
+
+export type StreetOrder = "number-after" | "number-before";
+export type TownLine = "postcode-city" | "city-region-postcode" | "city-then-postcode";
+
+export interface AddressStyle {
+ street: StreetOrder;
+ town: TownLine;
+}
+
+const NUMBER_AFTER = [
+ "DE", "AT", "CH", "LI", "NL", "BE", "IT", "SM", "VA", "ES", "AD", "PT", "DK", "IS", "NO", "SE", "FI",
+ "PL", "CZ", "SK", "HU", "SI", "HR", "BA", "RS", "ME", "MK", "AL", "RO", "BG", "GR", "CY", "TR",
+ "EE", "LV", "LT", "RU", "UA", "BY", "MX", "AR", "BR", "CL", "UY", "PY", "BO", "PE", "VE", "EC",
+];
+const NUMBER_BEFORE = ["US", "CA", "GB", "IE", "FR", "MC", "LU", "AU", "NZ", "ZA", "IN", "SG", "PH", "MY", "IL", "HK"];
+const CITY_REGION_POSTCODE = ["US", "CA", "AU"];
+const CITY_THEN_POSTCODE = ["GB", "IE"];
+
+const KNOWN_CODES = [...NUMBER_AFTER, ...NUMBER_BEFORE];
+
+/** The style for an ISO 3166 code, or null for a country this file doesn't know. */
+export function styleForCountryCode(code: string | undefined | null): AddressStyle | null {
+ const cc = code?.trim().toUpperCase();
+ if (!cc) return null;
+ const c = cc === "UK" ? "GB" : cc;
+ if (!KNOWN_CODES.includes(c)) return null;
+ return {
+ street: NUMBER_AFTER.includes(c) ? "number-after" : "number-before",
+ town: CITY_REGION_POSTCODE.includes(c) ? "city-region-postcode"
+ : CITY_THEN_POSTCODE.includes(c) ? "city-then-postcode"
+ : "postcode-city",
+ };
+}
+
+// Country names as people type them: each known code's name in these
+// languages, plus a few short forms.
+const NAME_LOCALES = ["en", "de", "fr", "es", "it", "nl", "pt", "pl", "sv", "da", "nb", "fi", "cs", "sk", "hu", "tr", "ru", "uk", "ro", "el", "hr", "sl", "et", "lv", "lt", "ca"];
+const ALIASES: Record = {
+ usa: "US", "u.s.a.": "US", "u.s.": "US", "united states of america": "US", america: "US",
+ uk: "GB", "u.k.": "GB", england: "GB", scotland: "GB", wales: "GB", "great britain": "GB", "northern ireland": "GB",
+ holland: "NL", brd: "DE", "bundesrepublik deutschland": "DE", "the netherlands": "NL",
+ "schweiz": "CH", "suisse": "CH", "svizzera": "CH", "österreich": "AT", "osterreich": "AT",
+};
+let nameIndex: Map | null = null;
+
+function normalise(name: string): string {
+ return name.trim().toLowerCase().replace(/\s+/g, " ");
+}
+
+function countryNameIndex(): Map {
+ if (nameIndex) return nameIndex;
+ const index = new Map();
+ for (const [alias, code] of Object.entries(ALIASES)) index.set(alias, code);
+ for (const locale of NAME_LOCALES) {
+ let names: Intl.DisplayNames;
+ try {
+ names = new Intl.DisplayNames([locale], { type: "region" });
+ } catch {
+ continue;
+ }
+ for (const code of KNOWN_CODES) {
+ const name = names.of(code);
+ if (name && name !== code) {
+ const key = normalise(name);
+ if (!index.has(key)) index.set(key, code);
+ }
+ }
+ }
+ nameIndex = index;
+ return index;
+}
+
+/** The ISO code for a typed country ("Germany", "Deutschland", "DE"), or null. */
+export function countryCodeFromName(country: string | undefined | null): string | null {
+ const key = normalise(country ?? "");
+ if (!key) return null;
+ if (/^[a-z]{2}$/.test(key)) {
+ const code = key.toUpperCase() === "UK" ? "GB" : key.toUpperCase();
+ return KNOWN_CODES.includes(code) ? code : null;
+ }
+ return countryNameIndex().get(key) ?? null;
+}
+
+// The country a UI language most likely means, for addresses without one.
+const LANGUAGE_COUNTRY: Record = {
+ de: "DE", nl: "NL", it: "IT", es: "ES", ca: "ES", pt: "PT", da: "DK", nb: "NO", sv: "SE", fi: "FI",
+ pl: "PL", cs: "CZ", sk: "SK", hu: "HU", ro: "RO", tr: "TR", et: "EE", lv: "LV", lt: "LT", ru: "RU",
+ uk: "UA", el: "GR", fr: "FR", en: "US",
+};
+
+/**
+ * The style for an address: from its country code, else its country's name,
+ * else the UI language's country, else US style.
+ */
+export function addressStyle(country: string | undefined | null, countryCode?: string | null, locale?: string): AddressStyle {
+ return styleForCountryCode(countryCode)
+ ?? styleForCountryCode(countryCodeFromName(country))
+ ?? styleForCountryCode(LANGUAGE_COUNTRY[(locale ?? "").split(/[-_]/)[0].toLowerCase()])
+ ?? { street: "number-before", town: "city-region-postcode" };
+}
+
+/** "Hauptstraße" + "1" as the country writes it. */
+export function joinStreet(name: string, number: string, order: StreetOrder): string {
+ const parts = order === "number-after" ? [name, number] : [number, name];
+ return parts.map(p => p.trim()).filter(Boolean).join(" ");
+}
+
+const HOUSE_NUMBER = String.raw`\d+\s?[a-zA-Z]?(?:\s?[-–/]\s?\d+\s?[a-zA-Z]?)?`;
+const NUMBER_LAST = new RegExp(String.raw`^(.*\S)\s+(${HOUSE_NUMBER})$`);
+const NUMBER_FIRST = new RegExp(String.raw`^(${HOUSE_NUMBER})\s+(\S.*)$`);
+
+/**
+ * Splits a typed street line into street name and house number where the
+ * number sits where the country puts it. Anything else stays whole as the
+ * name ("Am Markt", "Hauptstraße 1, Hinterhaus").
+ */
+export function splitStreet(line: string, order: StreetOrder): { name: string; number: string } {
+ const text = line.trim().replace(/\s+/g, " ");
+ if (order === "number-after") {
+ const m = NUMBER_LAST.exec(text);
+ if (m) return { name: m[1].replace(/,$/, ""), number: m[2] };
+ } else {
+ const m = NUMBER_FIRST.exec(text);
+ if (m) return { name: m[2], number: m[1].replace(/,$/, "") };
+ }
+ return { name: text, number: "" };
+}
+
+/** Kinds the contact form shows as fields of their own. */
+const FORM_KINDS = new Set(["name", "number", "locality", "region", "postcode", "country", "separator"]);
+/** Kinds that belong on the street line ("Hauptstraße 1, 3. OG"). */
+const STREET_EXTRAS = new Set(["apartment", "room", "floor", "building", "block", "direction", "landmark"]);
+
+function valuesOf(components: AddressComponent[], kind: string): string {
+ return components.filter(c => c.kind === kind).map(c => c.value?.trim()).filter(Boolean).join(" ");
+}
+
+/** The parts of an address as the contact form edits them. */
+export interface AddressParts {
+ street: string;
+ locality: string;
+ region: string;
+ postcode: string;
+ country: string;
+}
+
+/**
+ * The form's fields for an address. Street name and number become one line in
+ * the order of the address's country; for a country not known here, in the
+ * order the components have.
+ */
+export function addressToParts(address: ContactAddress, locale?: string): AddressParts {
+ const components = address.components ?? [];
+ if (components.length === 0) {
+ return {
+ street: address.street ?? "",
+ locality: address.locality ?? "",
+ region: address.region ?? "",
+ postcode: address.postcode ?? "",
+ country: address.country ?? "",
+ };
+ }
+ const country = valuesOf(components, "country");
+ const name = valuesOf(components, "name");
+ const number = valuesOf(components, "number");
+ const known = styleForCountryCode(address.countryCode) ?? styleForCountryCode(countryCodeFromName(country));
+ let order: StreetOrder;
+ if (known) order = known.street;
+ else {
+ const nameAt = components.findIndex(c => c.kind === "name");
+ const numberAt = components.findIndex(c => c.kind === "number");
+ order = nameAt >= 0 && numberAt >= 0
+ ? (numberAt > nameAt ? "number-after" : "number-before")
+ : addressStyle(country, null, locale).street;
+ }
+ return {
+ street: joinStreet(name, number, order),
+ locality: valuesOf(components, "locality"),
+ region: valuesOf(components, "region"),
+ postcode: valuesOf(components, "postcode"),
+ country,
+ };
+}
+
+const sep = (value: string): AddressComponent => ({ kind: "separator", value });
+
+/**
+ * RFC 9553 components for edited form fields, in the country's order, with
+ * separators inside a line and `defaultSeparator` (", ") between lines.
+ * Components the form doesn't show (apartment, district, PO box...) are kept
+ * from `previous`.
+ */
+export function partsToComponents(parts: AddressParts, previous: AddressComponent[] = [], locale?: string, countryCode?: string | null): AddressComponent[] {
+ const style = addressStyle(parts.country, countryCode, locale);
+ const { name, number } = splitStreet(parts.street, style.street);
+ const extras = previous.filter(c => !FORM_KINDS.has(c.kind) && c.value?.trim());
+ const comp = (kind: string, value: string): AddressComponent[] => value.trim() ? [{ kind, value: value.trim() }] : [];
+ const line = (...groups: AddressComponent[][]): AddressComponent[] => {
+ const filled = groups.filter(g => g.length > 0);
+ return filled.flatMap((g, i) => (i === 0 ? g : [sep(" "), ...g]));
+ };
+
+ const streetLine = style.street === "number-after"
+ ? line(comp("name", name), comp("number", number))
+ : line(comp("number", number), comp("name", name));
+ const streetExtras = extras.filter(c => STREET_EXTRAS.has(c.kind));
+ const otherExtras = extras.filter(c => !STREET_EXTRAS.has(c.kind));
+
+ const lines: AddressComponent[][] = [streetLine, ...streetExtras.map(c => [c]), ...otherExtras.map(c => [c])];
+ if (style.town === "postcode-city") {
+ lines.push(line(comp("postcode", parts.postcode), comp("locality", parts.locality)), comp("region", parts.region));
+ } else if (style.town === "city-region-postcode") {
+ lines.push(comp("locality", parts.locality), line(comp("region", parts.region), comp("postcode", parts.postcode)));
+ } else {
+ lines.push(comp("locality", parts.locality), comp("region", parts.region), comp("postcode", parts.postcode));
+ }
+ lines.push(comp("country", parts.country));
+ // Lines follow each other through defaultSeparator, so no separator between them.
+ return lines.filter(l => l.length > 0).flat();
+}
+
+/**
+ * An address as lines to show, laid out for its country: "Hauptstraße 1" /
+ * "10000 Berlin" / "Germany". Addresses without components show their `full`
+ * text or their flat fields.
+ */
+export function formatAddressLines(address: ContactAddress, locale?: string): string[] {
+ const components = (address.components ?? []).filter(c => c.kind !== "separator" && c.value?.trim());
+ if (components.length === 0) {
+ const full = address.full || address.fullAddress;
+ if (full) return full.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
+ }
+ const parts = addressToParts(address, locale);
+ const style = addressStyle(parts.country, address.countryCode, locale);
+ const lines: string[] = [];
+ const extras = components.filter(c => !FORM_KINDS.has(c.kind));
+ const streetExtras = extras.filter(c => STREET_EXTRAS.has(c.kind)).map(c => c.value.trim());
+ const street = [parts.street, ...streetExtras].filter(Boolean).join(", ");
+ if (street) lines.push(street);
+ for (const c of extras.filter(c => !STREET_EXTRAS.has(c.kind))) lines.push(c.value.trim());
+ const join = (...xs: string[]) => xs.map(x => x.trim()).filter(Boolean).join(" ");
+ if (style.town === "postcode-city") {
+ lines.push(join(parts.postcode, parts.locality), parts.region);
+ } else if (style.town === "city-region-postcode") {
+ const regionPostcode = join(parts.region, parts.postcode);
+ lines.push([parts.locality.trim(), regionPostcode].filter(Boolean).join(", "));
+ } else {
+ lines.push(parts.locality, parts.region, parts.postcode);
+ }
+ lines.push(parts.country);
+ return lines.map(l => l.trim()).filter(Boolean);
+}