diff --git a/components/contacts/__tests__/contact-import-csv.test.tsx b/components/contacts/__tests__/contact-import-csv.test.tsx
new file mode 100644
index 000000000..eb9edcbbe
--- /dev/null
+++ b/components/contacts/__tests__/contact-import-csv.test.tsx
@@ -0,0 +1,90 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { AddressBook, ContactCard } from '@/lib/jmap/types';
+import { ContactImportDialog } from '../contact-import-dialog';
+
+// pim-11: a CSV from Outlook or Google goes through a column step, then the
+// usual preview with the duplicate check, into the address book chosen.
+
+const OUTLOOK_DE = [
+ '"Vorname";"Nachname";"Firma";"E-Mail-Adresse";"E-Mail-Typ";"Mobiltelefon";"Privat"',
+ '"Jürgen";"Beispiel";"Muster GmbH";"juergen@example.de";"SMTP";"+49 170 1111111";"Aus"',
+ '"Ada";"Lovelace";"";"ada@example.org";"SMTP";"";"Aus"',
+ '',
+].join('\r\n');
+
+/** German Outlook's bytes: Windows-1252, so "ü" is one byte. */
+function cp1252File(text: string, name = 'kontakte.csv'): File {
+ const bytes = Uint8Array.from([...text].map((ch) => ch.charCodeAt(0)));
+ return new File([bytes], name, { type: 'text/csv' });
+}
+
+const books = [
+ { id: 'b-personal', name: 'Personal', isDefault: true },
+ { id: 'b-work', name: 'Work' },
+] as AddressBook[];
+
+const existing: ContactCard[] = [{ id: 'x', addressBookIds: {}, emails: { e: { address: 'ada@example.org' } } }];
+
+describe('ContactImportDialog with a CSV file', () => {
+ it('asks for the columns, then previews the cards and imports them into the book chosen', async () => {
+ const onImport = vi.fn(async (cards: ContactCard[]) => cards.length);
+ render(
+ ,
+ );
+
+ const table = await screen.findByTestId('csv-columns');
+ // Each column with an example and a field: known ones matched, the rest left out.
+ expect(within(table).getByText('Jürgen')).toBeInTheDocument();
+ expect(within(table).getByRole('combobox', { name: 'Vorname' })).toHaveValue('name.given');
+ expect(within(table).getByRole('combobox', { name: 'E-Mail-Adresse' })).toHaveValue('email');
+ expect(within(table).getByRole('combobox', { name: 'E-Mail-Typ' })).toHaveValue('skip');
+ expect(within(table).getByRole('combobox', { name: 'Privat' })).toHaveValue('skip');
+ // The user leaves out the company.
+ fireEvent.change(within(table).getByRole('combobox', { name: 'Firma' }), { target: { value: 'skip' } });
+ fireEvent.click(screen.getByRole('button', { name: 'continue' }));
+
+ expect(await screen.findByText('Jürgen Beispiel')).toBeInTheDocument();
+ // The existing address is marked as a duplicate and not ticked.
+ expect(screen.getByText('import.duplicate')).toBeInTheDocument();
+ expect(screen.getByRole('combobox')).toHaveValue('b-work');
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'b-personal' } });
+ fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
+
+ await waitFor(() => expect(onImport).toHaveBeenCalled());
+ const [cards, bookId] = onImport.mock.calls[0] as unknown as [ContactCard[], string];
+ expect(bookId).toBe('b-personal');
+ expect(cards).toHaveLength(1);
+ expect(cards[0].name?.components).toEqual([{ kind: 'given', value: 'Jürgen' }, { kind: 'surname', value: 'Beispiel' }]);
+ expect(Object.values(cards[0].phones ?? {})).toEqual([{ number: '+49 170 1111111', features: { mobile: true } }]);
+ expect(cards[0].organizations).toBeUndefined();
+ });
+
+ it('goes back from the preview to the columns', async () => {
+ render();
+ await screen.findByTestId('csv-columns');
+ fireEvent.click(screen.getByRole('button', { name: 'continue' }));
+ await screen.findByText('Ada Lovelace');
+ fireEvent.click(screen.getByRole('button', { name: 'back' }));
+ expect(await screen.findByTestId('csv-columns')).toBeInTheDocument();
+ });
+
+ it('cannot continue with every column left out', async () => {
+ render();
+ await screen.findByTestId('csv-columns');
+ expect(screen.getByRole('button', { name: 'continue' })).toBeDisabled();
+ expect(screen.getByText('no_columns')).toBeInTheDocument();
+ });
+
+ it('takes .csv in the file picker', () => {
+ const { container } = render();
+ expect(container.ownerDocument.querySelector('input[type="file"]')?.getAttribute('accept')).toContain('.csv');
+ });
+});
diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx
index 31f533ba1..59c673620 100644
--- a/components/contacts/__tests__/contacts-app.test.tsx
+++ b/components/contacts/__tests__/contacts-app.test.tsx
@@ -299,13 +299,84 @@ describe('ContactsApp', () => {
expect(consumePendingMailto()).toMatchObject({ to: [expect.stringContaining('alice@example.com')], inApp: true });
});
- it('says so when a duplicate fails', async () => {
- renderContacts();
- const addLocalContact = vi.fn(async () => { throw new Error('quota'); });
- act(() => { useContactStore.setState({ addLocalContact }); });
- fireEvent.contextMenu(screen.getByText('Alice Smith'));
- fireEvent.click(screen.getByText('context_menu.duplicate'));
- await waitFor(() => expect(useToastStore.getState().toasts.at(-1)?.title).toBe('toast.error_create'));
+ describe('merging duplicates (pim-14)', () => {
+ const anna1: ContactCard = {
+ id: 'c-anna1', uid: 'urn:uuid:anna1', addressBookIds: {}, updated: '2026-01-01T00:00:00Z',
+ name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Example' }], isOrdered: true },
+ emails: { e0: { address: 'anna@home.example.net' } },
+ addresses: { a0: { components: [{ kind: 'name', value: 'Hauptstraße 1' }, { kind: 'locality', value: 'Berlin' }], isOrdered: true } },
+ };
+ const anna2: ContactCard = {
+ id: 'c-anna2', uid: 'urn:uuid:anna2', addressBookIds: {}, updated: '2026-06-01T00:00:00Z',
+ name: { components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: 'Example' }], isOrdered: true },
+ emails: { e0: { address: 'anna@example.net' } },
+ };
+ const club: ContactCard = { id: 'g-club', kind: 'group', addressBookIds: {}, name: { full: 'Club' }, members: { 'urn:uuid:anna1': true } };
+ const lastToast = () => useToastStore.getState().toasts.at(-1);
+
+ it('merges two ticked contacts from the selection bar, keeps all their data and group, and Undo brings the other back', async () => {
+ renderContacts([anna1, anna2, bob, club]);
+ fireEvent.click(screen.getAllByText('Anna Example')[0]);
+ fireEvent.click(screen.getAllByText('Anna Example')[1], { ctrlKey: true });
+ fireEvent.click(screen.getByRole('button', { name: 'merge_selected' }));
+ const preview = await screen.findByTestId('merge-preview');
+ expect(within(preview).getByText('anna@example.net')).toBeInTheDocument();
+ expect(within(preview).getByText('anna@home.example.net')).toBeInTheDocument();
+ expect(within(preview).getByText('Hauptstraße 1, Berlin')).toBeInTheDocument();
+ expect(within(preview).getByText('Club')).toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'merge' }));
+
+ await waitFor(() => expect(useContactStore.getState().contacts.map((c) => c.id)).toEqual(['c-anna2', 'c-bob', 'g-club']));
+ const kept = useContactStore.getState().contacts[0];
+ expect(Object.values(kept.emails ?? {}).map((e) => e.address)).toEqual(['anna@example.net', 'anna@home.example.net']);
+ expect(Object.values(kept.addresses ?? {})).toEqual([anna1.addresses!.a0]);
+ expect(useContactStore.getState().contacts[2].members).toEqual({ 'urn:uuid:anna2': true });
+ expect(lastToast()?.title).toBe('merged');
+
+ await act(async () => { lastToast()?.action?.onClick(); });
+ await waitFor(() => expect(useContactStore.getState().contacts.map((c) => c.id).sort()).toEqual(['c-anna1', 'c-anna2', 'c-bob', 'g-club']));
+ const restored = useContactStore.getState().contacts;
+ expect(Object.keys(restored.find((c) => c.id === 'c-anna2')!.emails ?? {})).toEqual(['e0']);
+ expect(restored.find((c) => c.id === 'g-club')?.members).toEqual({ 'urn:uuid:anna1': true });
+ });
+
+ it('lists possible duplicates over the list and merges a set from there', async () => {
+ renderContacts([anna1, anna2, bob]);
+ fireEvent.click(screen.getByText('possible_duplicates'));
+ const view = screen.getByTestId('contact-duplicates');
+ expect(within(view).getAllByText('Anna Example')).toHaveLength(2);
+ expect(within(view).queryByText('Bob Jones')).toBeNull();
+ fireEvent.click(within(view).getByRole('button', { name: /merge_selected/ }));
+ await screen.findByTestId('merge-preview');
+ fireEvent.click(screen.getByRole('button', { name: 'merge' }));
+ await waitFor(() => expect(useContactStore.getState().contacts.map((c) => c.id)).toEqual(['c-anna2', 'c-bob']));
+ // Nothing left to merge: the line goes away.
+ await waitFor(() => expect(screen.queryByText('possible_duplicates')).toBeNull());
+ });
+
+ it('offers Merge with… instead of Duplicate, picks the other card, then merges', async () => {
+ renderContacts([anna1, anna2, bob]);
+ fireEvent.contextMenu(screen.getByText('Bob Jones'));
+ expect(screen.queryByText('context_menu.duplicate')).toBeNull();
+ fireEvent.click(screen.getByText('merge_with'));
+ const list = await screen.findByTestId('merge-pick-list');
+ fireEvent.click(within(list).getAllByText('Anna Example')[0]);
+ await screen.findByTestId('merge-preview');
+ fireEvent.click(screen.getByRole('button', { name: 'merge' }));
+ await waitFor(() => expect(useContactStore.getState().contacts).toHaveLength(2));
+ });
+
+ it('says so when the merge fails, and deletes nothing', async () => {
+ renderContacts([anna1, anna2]);
+ const updateLocalContact = vi.fn(async () => { throw new Error('quota'); });
+ act(() => { useContactStore.setState({ updateLocalContact }); });
+ fireEvent.click(screen.getByText('possible_duplicates'));
+ fireEvent.click(within(screen.getByTestId('contact-duplicates')).getByRole('button', { name: /merge_selected/ }));
+ await screen.findByTestId('merge-preview');
+ fireEvent.click(screen.getByRole('button', { name: 'merge' }));
+ await waitFor(() => expect(lastToast()?.title).toBe('merge_failed'));
+ expect(useContactStore.getState().contacts).toHaveLength(2);
+ });
});
it("marks no member in the list on a group's page, even with a contact open before", () => {
diff --git a/components/contacts/__tests__/contacts-menus.test.tsx b/components/contacts/__tests__/contacts-menus.test.tsx
index bd7158564..60bd9e245 100644
--- a/components/contacts/__tests__/contacts-menus.test.tsx
+++ b/components/contacts/__tests__/contacts-menus.test.tsx
@@ -17,22 +17,23 @@ const alice: ContactCard = {
const menuLabels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => i.textContent);
describe('one action list per contact (R6)', () => {
- it('offers Duplicate in the right-click menu as in the detail ⋮', () => {
+ it('offers Merge with… in the right-click menu as in the detail ⋮, and no Duplicate', () => {
const noop = vi.fn();
render(
,
);
fireEvent.contextMenu(screen.getByText('Alice'));
const rightClick = menuLabels();
- expect(rightClick).toContain('context_menu.duplicate');
+ expect(rightClick).toContain('merge_with');
+ expect(rightClick).not.toContain('context_menu.duplicate');
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
- render();
+ render();
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
const detail = menuLabels();
// The detail's ⋮ is the same list, without what the page has as buttons.
diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx
index 6281846ca..8a4370fa0 100644
--- a/components/contacts/contact-context-menu.tsx
+++ b/components/contacts/contact-context-menu.tsx
@@ -16,7 +16,7 @@ import {
Download,
Users,
Trash2,
- Copy,
+ Merge,
Printer,
FolderInput,
CheckSquare,
@@ -58,13 +58,16 @@ interface ContactContextMenuProps {
inDetail?: boolean;
/** Writes to the contact in the app's composer. */
onCompose?: () => void;
- onDuplicate?: () => void;
+ /** "Merge with…": pick another card to merge this one with. */
+ onMergeWith?: () => void;
/** Move to another address book; undefined where there is none to move to. */
onMoveTo?: () => void;
onBatchMoveTo?: () => void;
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
+ /** Two or more ticked: merge them into one. */
+ onBatchMerge?: () => void;
/** The contact is in an address book shared read-only: no Edit, Move or Delete. */
readOnly?: boolean;
}
@@ -83,16 +86,18 @@ export function ContactContextMenu({
onDelete,
onAddToGroup,
onCompose,
- onDuplicate,
+ onMergeWith,
onMoveTo,
onBatchMoveTo,
inDetail = false,
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
+ onBatchMerge,
readOnly = false,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
+ const tMerge = useTranslations("ux_pim_cimport");
const tMenu = useTranslations("menuAudit");
const printOff = useStrictestDevices().noPrint;
const email = getContactPrimaryEmail(contact);
@@ -145,6 +150,9 @@ export function ContactContextMenu({
{onBatchMoveTo && (
)}
+ {onBatchMerge && (
+
+ )}
)}
- {onDuplicate && (
+ {onMergeWith && !readOnly && (
)}
{onMoveTo && !readOnly && (
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx
index 1c35b2977..c1b463a11 100644
--- a/components/contacts/contact-detail.tsx
+++ b/components/contacts/contact-detail.tsx
@@ -27,7 +27,8 @@ interface ContactDetailProps {
onEdit: () => void;
onDelete: () => void;
onAddToGroup?: () => void;
- onDuplicate?: () => void;
+ /** "Merge with…" in the ⋮. */
+ onMergeWith?: () => void;
/** Move to another address book; undefined where there is none to move to. */
onMoveTo?: () => void;
/** Compose an email to this contact in the app (no OS mailto handoff). */
@@ -78,7 +79,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
}
-export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
+export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onMergeWith, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
const t = useTranslations("contacts");
const tRoot = useTranslations();
const tCommon = useTranslations("common");
@@ -218,7 +219,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
onCompose={onCompose}
onDelete={onDelete}
onAddToGroup={onAddToGroup}
- onDuplicate={onDuplicate}
+ onMergeWith={onMergeWith}
onMoveTo={onMoveTo}
/>
)}
diff --git a/components/contacts/contact-duplicates-view.tsx b/components/contacts/contact-duplicates-view.tsx
new file mode 100644
index 000000000..4ad211c78
--- /dev/null
+++ b/components/contacts/contact-duplicates-view.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+import { Merge, Users } from "@/components/icons";
+import { Button } from "@/components/ui/button";
+import { EmptyState } from "@/components/ui/empty-state";
+import type { AddressBook, ContactCard } from "@/lib/jmap/types";
+import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
+
+interface ContactDuplicatesViewProps {
+ groups: ContactCard[][];
+ addressBooks: AddressBook[];
+ onMerge: (cards: ContactCard[]) => void;
+ onOpen: (id: string) => void;
+}
+
+/** "Possible duplicates": cards with the same name, address or phone, each set with its own Merge. */
+export function ContactDuplicatesView({ groups, addressBooks, onMerge, onOpen }: ContactDuplicatesViewProps) {
+ const t = useTranslations("ux_pim_cimport");
+ const bookNames = (card: ContactCard) => Object.keys(card.addressBookIds ?? {})
+ .filter((id) => card.addressBookIds[id])
+ .map((id) => addressBooks.find((b) => b.id === id)?.name)
+ .filter(Boolean)
+ .join(", ");
+
+ return (
+
+
+
{t("duplicates_title")}
+
{t("duplicates_hint")}
+
+ {groups.length === 0 ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/components/contacts/contact-import-dialog.tsx b/components/contacts/contact-import-dialog.tsx
index dca624055..276c182b2 100644
--- a/components/contacts/contact-import-dialog.tsx
+++ b/components/contacts/contact-import-dialog.tsx
@@ -7,16 +7,41 @@ import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { parseVCard, detectDuplicates } from "@/lib/vcard";
-import type { ContactCard } from "@/lib/jmap/types";
+import {
+ CSV_TARGET_GROUPS, csvToContacts, decodeCsvBytes, isCsvFile, matchColumns, readCsv,
+ type CsvColumn, type CsvTable, type CsvTarget,
+} from "@/lib/contacts/csv-import";
+import type { AddressBook, ContactCard } from "@/lib/jmap/types";
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { isOfflineNow } from "@/lib/offline/state";
interface ContactImportDialogProps {
existingContacts: ContactCard[];
- onImport: (contacts: ContactCard[]) => Promise;
+ onImport: (contacts: ContactCard[], addressBookId?: string) => Promise;
onClose: () => void;
/** A file opened from the OS: read at once, as if picked here. */
initialFile?: File | null;
+ /** Books the cards can go into; "Import into" shows when there are two or more. */
+ addressBooks?: AddressBook[];
+ /** The book open in Contacts: "Import into" starts there. */
+ defaultAddressBookId?: string;
+}
+
+/** The file's bytes; jsdom's File has no arrayBuffer(). */
+function readBytes(file: File): Promise {
+ if (typeof file.arrayBuffer === "function") return file.arrayBuffer().then((b) => new Uint8Array(b));
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer));
+ reader.onerror = () => reject(reader.error);
+ reader.readAsArrayBuffer(file);
+ });
+}
+
+/** "adr.home.street" → "t_street"; "phone.fax.home" → "t_phone_fax_home". */
+function targetKey(target: CsvTarget): string {
+ if (target.startsWith("adr.")) return `t_${target.split(".")[2]}`;
+ return `t_${target.replace(/\./g, "_")}`;
}
export function ContactImportDialog({
@@ -24,8 +49,11 @@ export function ContactImportDialog({
onImport,
onClose,
initialFile,
+ addressBooks = [],
+ defaultAddressBookId,
}: ContactImportDialogProps) {
const t = useTranslations("contacts");
+ const tCsv = useTranslations("ux_pim_cimport");
const tLaunch = useTranslations("pwaLaunch");
const fileRef = useRef(null);
const [parsed, setParsed] = useState([]);
@@ -34,6 +62,26 @@ export function ContactImportDialog({
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
+ // A CSV file waits for its columns to be matched before the preview.
+ const [csv, setCsv] = useState<{ table: CsvTable; fileName: string } | null>(null);
+ const [columns, setColumns] = useState([]);
+ const [matching, setMatching] = useState(false);
+ const pickBook = () => {
+ if (defaultAddressBookId && addressBooks.some((b) => b.id === defaultAddressBookId)) return defaultAddressBookId;
+ return (addressBooks.find((b) => b.isDefault) ?? addressBooks[0])?.id ?? "";
+ };
+ const [bookId, setBookId] = useState(pickBook);
+
+ const showCards = useCallback((contacts: ContactCard[]) => {
+ const dupes = detectDuplicates(existingContacts, contacts);
+ setParsed(contacts);
+ setDuplicates(dupes);
+ const initialSelected = new Set();
+ contacts.forEach((_, idx) => {
+ if (!dupes.has(idx)) initialSelected.add(idx);
+ });
+ setSelected(initialSelected);
+ }, [existingContacts]);
const readFile = useCallback(async (file: File) => {
setError(null);
@@ -44,6 +92,23 @@ export function ContactImportDialog({
return;
}
+ if (isCsvFile(file)) {
+ try {
+ const table = readCsv(decodeCsvBytes(await readBytes(file)));
+ if (table.headers.length === 0 || table.rows.length === 0) {
+ setError(t("import.no_contacts"));
+ return;
+ }
+ setCsv({ table, fileName: file.name });
+ setColumns(matchColumns(table));
+ setMatching(true);
+ } catch (error) {
+ console.error('Failed to read CSV:', error);
+ setError(tCsv("parse_error"));
+ }
+ return;
+ }
+
try {
const text = await file.text();
const contacts = parseVCard(text);
@@ -53,20 +118,12 @@ export function ContactImportDialog({
return;
}
- const dupes = detectDuplicates(existingContacts, contacts);
- setParsed(contacts);
- setDuplicates(dupes);
-
- const initialSelected = new Set();
- contacts.forEach((_, idx) => {
- if (!dupes.has(idx)) initialSelected.add(idx);
- });
- setSelected(initialSelected);
+ showCards(contacts);
} catch (error) {
console.error('Failed to parse vCard:', error);
setError(t("import.parse_error"));
}
- }, [existingContacts, t]);
+ }, [t, tCsv, showCards]);
const handleFileChange = useCallback(async (e: React.ChangeEvent) => {
const file = e.target.files?.[0];
@@ -98,13 +155,44 @@ export function ContactImportDialog({
setSelected(new Set());
};
+ const setTarget = (index: number, target: CsvTarget) => {
+ setColumns((cols) => cols.map((c) => (c.index === index ? { ...c, target } : c)));
+ };
+
+ const anyColumnMatched = columns.some((c) => c.target !== "skip" && c.target !== "type");
+
+ const continueFromColumns = () => {
+ if (!csv) return;
+ const contacts = csvToContacts(csv.table, columns);
+ if (contacts.length === 0) {
+ setError(t("import.no_contacts"));
+ return;
+ }
+ setError(null);
+ setMatching(false);
+ showCards(contacts);
+ };
+
+ const backToColumns = () => {
+ setParsed([]);
+ setError(null);
+ setMatching(true);
+ };
+
+ const backToFile = () => {
+ setCsv(null);
+ setColumns([]);
+ setMatching(false);
+ setError(null);
+ };
+
const handleImport = async () => {
const toImport = parsed.filter((_, i) => selected.has(i));
if (toImport.length === 0) return;
setIsImporting(true);
try {
- const count = await onImport(toImport);
+ const count = await onImport(toImport, addressBooks.length > 0 && bookId ? bookId : undefined);
setResult(count);
} catch (error) {
console.error('Failed to import contacts:', error);
@@ -121,27 +209,51 @@ export function ContactImportDialog({
);
+ const groupLabel = (id: string) => tCsv(`group_${id.replace(".", "_")}`);
+ const optionLabel = (groupId: string, target: CsvTarget) => target.startsWith("adr.")
+ ? tCsv("address_field", { group: groupLabel(groupId), part: tCsv(targetKey(target)) })
+ : tCsv(targetKey(target));
+
+ const footer = result !== null ? undefined : matching ? (
+ <>
+
+
+
+ >
+ ) : parsed.length > 0 ? (
+ <>
+ {csv && (
+
+ )}
+
+ {t("import.selected", { count: selected.size })}
+
+
+
+ >
+ ) : undefined;
+
return (