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 ? ( + + ) : ( +
    + {groups.map((group) => ( +
  • c.id).join("|")} className="border border-border rounded-md"> +
      + {group.map((card) => { + const name = getContactDisplayName(card); + const email = getContactPrimaryEmail(card); + const phone = Object.values(card.phones ?? {})[0]?.number; + const book = bookNames(card); + return ( +
    • + +
    • + ); + })} +
    +
    + +
    +
  • + ))} +
+ )} +
+ ); +} 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 ( 0 && result === null} - footer={parsed.length > 0 && result === null ? ( - <> -

- {t("import.selected", { count: selected.size })} -

- - - - ) : undefined} + // Picked cards and matched columns are not thrown away by a stray click outside. + dirty={(parsed.length > 0 || matching) && result === null} + footer={footer} >
{result !== null ? ( @@ -154,12 +266,59 @@ export function ContactImportDialog({ {t("import.close")}
+ ) : matching && csv ? ( + <> + {errorBanner} +
+

{tCsv("columns_title")}

+

{tCsv("columns_hint", { file: csv.fileName })}

+
+
+ + + + + + + + + + {columns.map((col) => ( + + + + + + ))} + +
{tCsv("column")}{tCsv("sample")}{tCsv("field")}
+ {col.header || "-"} + {col.sample} + +
+
+ {!anyColumnMatched &&

{tCsv("no_columns")}

} + ) : parsed.length === 0 ? ( <> @@ -175,8 +334,8 @@ export function ContactImportDialog({ )} > -

{t("import.drop_hint")}

-

{t("import.file_types")}

+

{tCsv("file_hint")}

+

{tCsv("file_types")}

{errorBanner} @@ -185,6 +344,23 @@ export function ContactImportDialog({ <> {errorBanner} + {addressBooks.length > 1 && ( + + )} +

{t("import.found", { count: parsed.length })} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index fe2379c77..e7682626e 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslations, useLocale } from "next-intl"; -import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2, FolderInput, Upload, Printer } from "@/components/icons"; +import { Search, BookUser, Trash2, Users, Download, X, UserPlus, CheckSquare, Square, Filter, Mail, Phone, Image as ImageIcon, RotateCcw, Menu, ArrowDownAZ, Loader2, FolderInput, Upload, Printer, Merge } from "@/components/icons"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; @@ -126,7 +126,12 @@ interface ContactListProps { onAddContactToGroup: (id: string) => void; /** Opens the app's composer to the contact. */ onComposeContact?: (contact: ContactCard) => void; - onDuplicateContact?: (contact: ContactCard) => void; + /** "Merge with…" for one contact. */ + onMergeContact?: (contact: ContactCard) => void; + /** Two or more ticked: merge them. */ + onBulkMerge?: () => void; + /** A line over the list, e.g. "Possible duplicates (2)". */ + banner?: React.ReactNode; /** Move contacts to another address book; undefined where there is none. */ onMoveContacts?: (ids: string[]) => void; /** Show a burger button at the start that opens the (overlay) categories sidebar. */ @@ -165,7 +170,9 @@ export function ContactList({ onDeleteContact, onAddContactToGroup, onComposeContact, - onDuplicateContact, + onMergeContact, + onBulkMerge, + banner, onMoveContacts, onMenuClick, phoneMenuItems, @@ -177,6 +184,7 @@ export function ContactList({ }: ContactListProps) { const t = useTranslations("contacts"); const tx = useTranslations("ux_pim_contact"); + const tMerge = useTranslations("ux_pim_cimport"); const tCommon = useTranslations("common"); const locale = useLocale(); const density = useSettingsStore((state) => state.density); @@ -622,6 +630,12 @@ export function ContactList({ {t("address_books.move_to")} )} + {onBulkMerge && selectedContactIds.size > 1 && ( + + )}

)} + {!hasSelection && banner} + {/* Contact list */}
onDeleteContact(contextMenu.data!)} onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)} onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined} - onDuplicate={onDuplicateContact ? () => onDuplicateContact(contextMenu.data!) : undefined} + onMergeWith={onMergeContact ? () => onMergeContact(contextMenu.data!) : undefined} onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined} onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined} onBatchExport={onBulkExport} onBatchAddToGroup={onBulkAddToGroup} onBatchDelete={onBulkDelete} + onBatchMerge={onBulkMerge} readOnly={isContactReadOnly?.(contextMenu.data) ?? false} /> )} diff --git a/components/contacts/contact-merge-dialog.tsx b/components/contacts/contact-merge-dialog.tsx new file mode 100644 index 000000000..bc48e5e99 --- /dev/null +++ b/components/contacts/contact-merge-dialog.tsx @@ -0,0 +1,201 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Search } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import type { ContactAddress, ContactCard } from "@/lib/jmap/types"; +import { memberKeys, planMerge, type MergePlan } from "@/lib/contacts/merge"; +import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; + +/** One line for an address, as its parts and separators say. */ +export function addressLine(address: ContactAddress): string { + const components = address.components ?? []; + if (components.length > 0) { + let out = ""; + let lastWasValue = false; + for (const c of components) { + if (c.kind === "separator") { + out += c.value; + lastWasValue = false; + continue; + } + if (!c.value) continue; + if (lastWasValue) out += address.defaultSeparator ?? ", "; + out += c.value.replace(/\s*\n\s*/g, ", "); + lastWasValue = true; + } + return out; + } + const legacy = [address.street, [address.postcode, address.locality].filter(Boolean).join(" "), address.region, address.country] + .filter(Boolean).join(", "); + return legacy || address.full || address.fullAddress || ""; +} + +interface ContactMergeDialogProps { + /** Two or more cards: the dialog opens on what the merge keeps. */ + cards?: ContactCard[]; + /** "Merge with…": the other card is picked first. */ + pickFor?: ContactCard; + /** What can be picked: cards of the same account that can be changed. */ + candidates?: ContactCard[]; + /** Likely duplicates of `pickFor`, listed first. */ + suggested?: ContactCard[]; + /** All groups, to show the ones the kept card will be in. */ + groups: ContactCard[]; + onMerge: (plan: MergePlan) => Promise; + onClose: () => void; +} + +function PickRow({ card, onPick }: { card: ContactCard; onPick: () => void }) { + const name = getContactDisplayName(card); + const email = getContactPrimaryEmail(card); + return ( + + ); +} + +/** "Merge with…" and "Merge…": pick the other card if needed, then show what stays before merging. */ +export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = [], suggested = [], groups, onMerge, onClose }: ContactMergeDialogProps) { + const t = useTranslations("ux_pim_cimport"); + const tc = useTranslations("contacts"); + const [cards, setCards] = useState(initialCards && initialCards.length > 1 ? initialCards : null); + const [query, setQuery] = useState(""); + const [busy, setBusy] = useState(false); + + const plan = useMemo(() => (cards ? planMerge(cards) : null), [cards]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const suggestedIds = new Set(suggested.map((c) => c.id)); + return candidates + .filter((c) => !suggestedIds.has(c.id)) + .filter((c) => !q || `${getContactDisplayName(c)} ${Object.values(c.emails ?? {}).map((e) => e.address).join(" ")}`.toLowerCase().includes(q)) + .slice(0, 200); + }, [candidates, suggested, query]); + + if (!plan) { + const name = pickFor ? getContactDisplayName(pickFor) || getContactPrimaryEmail(pickFor) : ""; + const pick = (card: ContactCard) => { if (pickFor) setCards([pickFor, card]); }; + return ( + {tc("form.cancel")}} + > +
+
+ + setQuery(e.target.value)} + placeholder={t("pick_search")} + aria-label={t("pick_search")} + className="ps-8" + /> +
+
+ {suggested.length > 0 && !query.trim() && ( +
+
{t("pick_suggested")}
+ {suggested.map((card) => pick(card)} />)} +
+ )} + {filtered.length > 0 && ( +
+ {suggested.length > 0 && !query.trim() && ( +
{t("pick_all")}
+ )} + {filtered.map((card) => pick(card)} />)} +
+ )} + {filtered.length === 0 && (suggested.length === 0 || query.trim()) && ( +

{t("pick_none")}

+ )} +
+
+
+ ); + } + + const r = plan.result; + const keys = new Set([plan.keep, ...plan.others].flatMap(memberKeys)); + const groupNames = groups + .filter((g) => Object.entries(g.members ?? {}).some(([k, on]) => on && keys.has(k))) + .map((g) => getContactDisplayName(g)) + .filter(Boolean); + const rows: Array<{ label: string; values: string[] }> = [ + { label: t("group_name"), values: [getContactDisplayName(r)].filter(Boolean) }, + { label: t("group_email"), values: Object.values(r.emails ?? {}).map((e) => e.address) }, + { label: t("group_phone"), values: Object.values(r.phones ?? {}).map((p) => p.number) }, + { label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map(addressLine).filter(Boolean) }, + { + label: t("group_org"), + values: [ + ...Object.values(r.organizations ?? {}).map((o) => [o.name, ...(o.units ?? []).map((u) => u.name)].filter(Boolean).join(", ")), + ...Object.values(r.titles ?? {}).map((ti) => ti.name), + ].filter(Boolean), + }, + { label: t("t_web"), values: Object.values(r.onlineServices ?? {}).map((s) => s.uri || s.user || "").filter(Boolean) }, + { label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean) }, + { label: t("t_categories"), values: Object.keys(r.keywords ?? {}).filter((k) => r.keywords?.[k]) }, + { label: t("groups"), values: groupNames }, + ].filter((row) => row.values.length > 0); + + const merge = async () => { + setBusy(true); + try { + if (await onMerge(plan)) onClose(); + } finally { + setBusy(false); + } + }; + + const otherNames = plan.others.map((c) => getContactDisplayName(c) || getContactPrimaryEmail(c) || "-").join(", "); + + return ( + {} : onClose} + title={t("preview_title", { count: plan.others.length + 1 })} + description={t("preview_hint")} + size="lg" + footer={ + <> + {pickFor && !initialCards && ( + + )} + + + + } + > +
+
+ {rows.map((row) => ( +
+
{row.label}
+
+ {row.values.map((value, i) => ( +
{value}
+ ))} +
+
+ ))} +
+

{t("preview_deleted", { names: otherNames })}

+
+
+ ); +} diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 8e2d237d0..cc44682d4 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -6,7 +6,7 @@ import { Suspense, useState, useEffect, useCallback, useRef, useMemo } from "rea import { useLocale, useTranslations } from "next-intl"; import { useSearchParams } from "next/navigation"; import { useRouter } from "@/i18n/navigation"; -import { ArrowLeft, Users, UsersRound, AlertTriangle, Plus, UserPlus, Upload, BookPlus, Printer } from "@/components/icons"; +import { ArrowLeft, Users, UsersRound, AlertTriangle, Plus, UserPlus, Upload, BookPlus, Printer, Merge } from "@/components/icons"; import { Button } from "@/components/ui/button"; import { ContextMenuItem } from "@/components/ui/context-menu"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -18,6 +18,9 @@ import { ContactGroupForm } from "@/components/contacts/contact-group-form"; import { ContactGroupDetail } from "@/components/contacts/contact-group-detail"; import { ContactsSidebar, type ContactCategory } from "@/components/contacts/contacts-sidebar"; import { ContactImportDialog } from "@/components/contacts/contact-import-dialog"; +import { ContactMergeDialog } from "@/components/contacts/contact-merge-dialog"; +import { ContactDuplicatesView } from "@/components/contacts/contact-duplicates-view"; +import { findDuplicateGroups, ownerKey, type MergePlan } from "@/lib/contacts/merge"; import { AddressBookPickerDialog } from "@/components/contacts/address-book-picker-dialog"; import { DirectoryBrowser, directoryKey, filterDirectory } from "@/components/contacts/directory-browser"; import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-detail"; @@ -86,7 +89,8 @@ type View = | "group-create" | "group-edit" | "bulk-add-to-group" - | "directory-detail"; + | "directory-detail" + | "duplicates"; export interface ContactsAppProps { /** Path segments after `/contacts` (`['', 'edit']`). */ @@ -104,6 +108,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const tUxContact = useTranslations("ux_pim_contact"); const tCommon = useTranslations("common"); const tShell = useTranslations("shell"); + const tMerge = useTranslations("ux_pim_cimport"); useDocumentTitle(tSidebar("contacts")); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); @@ -148,11 +153,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = setDefaultAddressBook, renameKeyword, importContacts, + mergeContacts, + undoMergeContacts, } = useContactStore(); const [view, setView] = useState("list"); const [activeCategory, setActiveCategory] = useState("all"); const [showImportDialog, setShowImportDialog] = useState(false); + // "Merge…" (two or more cards) and "Merge with…" (one card, the other picked in the dialog). + const [mergeCards, setMergeCards] = useState(null); + const [mergePickFor, setMergePickFor] = useState(null); const [renamingAddressBook, setRenamingAddressBook] = useState(null); const [creatingAddressBook, setCreatingAddressBook] = useState(false); const [sharingAddressBookId, setSharingAddressBookId] = useState(null); @@ -580,12 +590,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = } }, [client, createAddressBook, refreshAddressBooks, t]); - const handleImportContacts = useCallback(async (importedContacts: ContactCard[]) => { + const handleImportContacts = useCallback(async (importedContacts: ContactCard[], addressBookId?: string) => { return importContacts( supportsSync && client ? client : null, - importedContacts + importedContacts, + addressBookId, ); }, [supportsSync, client, importContacts]); + // "Import into" starts at the address book on screen. + const openBookId = typeof activeCategory === "object" && "addressBookId" in activeCategory ? activeCategory.addressBookId : undefined; const handleSelectContact = (id: string) => { setSelectedContact(id); @@ -686,37 +699,68 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = setView("detail"); }, [setSelectedContact]); - const handleDuplicateContact = useCallback(async (source: ContactCard) => { - const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source; - void _id; void _uid; void _created; void _updated; - const data: Partial = JSON.parse(JSON.stringify(rest)); - if (supportsSync && client && source.media) { - // A blobId names the bytes embedded in the source card; the copy gets - // the bytes themselves. - data.media = await inlineContactMedia(source.media, { - client: getContactPhotoClient(source.localAccountId), - accountId: source.accountId, - localAccountId: source.localAccountId, - }, "keep"); - } - let created: boolean; - try { - created = supportsSync && client - ? await createContact(client, data) - : await addLocalContact({ - id: `local-${generateUUID()}`, - addressBookIds: data.addressBookIds || {}, - ...data, - }); - } catch (error) { - console.error("Failed to duplicate contact:", error); - toast.error(t("toast.error_create")); + // Likely duplicates among the cards that can be changed, for "Possible duplicates (n)". + const duplicateGroups = useMemo( + () => findDuplicateGroups(individuals.filter((c) => !isContactReadOnly(c))), + [individuals, isContactReadOnly], + ); + const mergeable = useCallback((cards: ContactCard[]) => cards.length > 1 + && cards.every((c) => c.kind !== "group" && !isContactReadOnly(c)) + && new Set(cards.map(ownerKey)).size === 1, [isContactReadOnly]); + const handleMergeCards = useCallback((cards: ContactCard[]) => { + if (!mergeable(cards)) { + toast.error(tMerge("merge_not_possible")); return; } - if (!created) return; - toast.success(t("toast.created")); - showNewestContact(); - }, [supportsSync, client, createContact, addLocalContact, t, showNewestContact]); + setMergeCards(cards); + }, [mergeable, tMerge]); + const handleBulkMerge = () => handleMergeCards(contacts.filter((c) => selectedContactIds.has(c.id))); + + const runMerge = useCallback(async (plan: MergePlan): Promise => { + const sync = supportsSync && !!client; + try { + let { others, patch } = plan; + if (sync) { + // Photos still in blobs go with their cards: the merged card and Undo get the bytes. + const source = (card: ContactCard) => ({ + client: getContactPhotoClient(card.localAccountId), + accountId: card.accountId, + localAccountId: card.localAccountId, + }); + others = await Promise.all(others.map(async (card) => card.media + ? { ...card, media: await inlineContactMedia(card.media, source(card), "drop") } + : card)); + if (patch.media) { + const own = plan.keep.media ?? {}; + const added = Object.fromEntries(Object.entries(patch.media).filter(([key]) => !(key in own))); + patch = { ...patch, media: { ...own, ...(await inlineContactMedia(added, source(plan.keep), "drop")) } }; + } + } + const undo = await mergeContacts(sync ? client : null, { ...plan, others, patch }); + if (!undo) return false; + // From "Possible duplicates" the list stays, for the next set. + if (view !== "duplicates") { + setSelectedContact(plan.keep.id); + setView("detail"); + } + toast.success(tMerge("merged"), { + action: { + label: tMerge("undo"), + onClick: () => { + void undoMergeContacts(sync ? client : null, undo).then((ok) => { + if (ok) toast.info(tMerge("undone")); + else toast.error(tMerge("undo_failed")); + }); + }, + }, + }); + return true; + } catch (error) { + console.error("Failed to merge contacts:", error); + toast.error(tMerge("merge_failed")); + return false; + } + }, [supportsSync, client, mergeContacts, undoMergeContacts, setSelectedContact, tMerge, view]); const handleSaveNew = useCallback(async (data: Partial, options?: ContactSaveOptions) => { const created = supportsSync && client @@ -978,6 +1022,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = useRegisterCommands(tSidebar("contacts"), [ { id: "new-contact", label: t("create_new"), icon: UserPlus, run: () => guard(handleCreateNew)() }, { id: "import", label: t("import.title"), icon: Upload, run: () => setShowImportDialog(true) }, + { id: "find-duplicates", label: tMerge("find_duplicates"), icon: Merge, run: () => guard(() => setView("duplicates"))() }, { id: "new-group", label: t("groups.create"), icon: UsersRound, run: () => guard(handleCreateGroup)() }, { id: "new-address-book", label: t("address_books.create"), icon: BookPlus, run: () => setCreatingAddressBook(true), when: () => !!client }, ], contactsEnabled); @@ -1077,6 +1122,16 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = /> ); + case "duplicates": + return ( + { setSelectedContact(id); setView("detail"); }} + /> + ); + case "bulk-add-to-group": return (
@@ -1145,9 +1200,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = ? () => handleAddContactToGroup(selectedContact.id) : undefined } - onDuplicate={ + onMergeWith={ selectedContact - ? () => void handleDuplicateContact(selectedContact) + ? () => setMergePickFor(selectedContact) : undefined } onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined} @@ -1428,7 +1483,18 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onDeleteContact={handleDeleteContact} onAddContactToGroup={guard(handleAddContactToGroup)} onComposeContact={handleComposeContact} - onDuplicateContact={(contact) => void handleDuplicateContact(contact)} + onMergeContact={(contact) => setMergePickFor(contact)} + onBulkMerge={handleBulkMerge} + banner={duplicateGroups.length > 0 && view !== "duplicates" ? ( + + ) : undefined} onMoveContacts={canMoveContacts ? handleMoveContacts : undefined} onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined} emptyView={activeCategory !== "all"} @@ -1438,6 +1504,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = <> { close(); guard(handleCreateGroup)(); }} /> { close(); setShowImportDialog(true); }} /> + { close(); guard(() => setView("duplicates"))(); }} /> {!printOff && { close(); void requestPrint({ kind: "contact-list" }); }} />} )} @@ -1604,6 +1671,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onImport={handleImportContacts} initialFile={launchedFile.file} onClose={launchedFile.done} + addressBooks={supportsSync ? writableBooks : []} + defaultAddressBookId={openBookId} /> )} {showImportDialog && ( @@ -1611,6 +1680,19 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = existingContacts={contacts} onImport={handleImportContacts} onClose={() => setShowImportDialog(false)} + addressBooks={supportsSync ? writableBooks : []} + defaultAddressBookId={openBookId} + /> + )} + {(mergeCards || mergePickFor) && ( + c.id !== mergePickFor.id && !isContactReadOnly(c) && ownerKey(c) === ownerKey(mergePickFor)) : undefined} + suggested={mergePickFor ? (duplicateGroups.find((g) => g.some((c) => c.id === mergePickFor.id)) ?? []).filter((c) => c.id !== mergePickFor.id) : undefined} + groups={groups} + onMerge={runMerge} + onClose={() => { setMergeCards(null); setMergePickFor(null); }} /> )} {sharingAddressBookId && client && (() => { diff --git a/components/file-drop/file-drop-host.tsx b/components/file-drop/file-drop-host.tsx index 039d2e010..1d1af9859 100644 --- a/components/file-drop/file-drop-host.tsx +++ b/components/file-drop/file-drop-host.tsx @@ -74,6 +74,7 @@ function currentPlace(pathname: string, translate: (key: string) => string): { p filesFolder: filesDropTarget()?.folder ?? null, calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"), contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"), + contactsScreen: matchSurface(pathname)?.surface === "contacts", importFolder: folder?.name ?? null, offline: isOfflineNow(account?.serverUrl), keepsOffline: !!auth.activeAccountId && useOfflineStore.getState().enabled.includes(auth.activeAccountId), diff --git a/components/icons/index.ts b/components/icons/index.ts index 2068d465e..2dd968f2f 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -164,6 +164,7 @@ export { IconMapPin as MapPin, IconMaximize as Maximize2, IconMenu2 as Menu, + IconArrowMerge as Merge, IconMessage as MessageSquare, IconMessages as MessagesSquare, IconMinimize as Minimize2, diff --git a/integration/tests/25-ux-pim-cimport.spec.ts b/integration/tests/25-ux-pim-cimport.spec.ts new file mode 100644 index 000000000..62db2e300 --- /dev/null +++ b/integration/tests/25-ux-pim-cimport.spec.ts @@ -0,0 +1,294 @@ +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 fixes pim-11 (CSV import into a chosen address book) and pim-14 + * (merging duplicate contacts), against a VM 106 Stalwart (1.0 and 0.16). + * Every UI step is checked on the server through JMAP. + * + * Account: dso-ux-pim (password IT_DSO_PASSWORD). The spec makes its own + * cards, named "UXFIX-cimport …", and removes them again; the seeded "Work" + * address book is only written to with those cards. + */ + +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-cimport'; +const SHOTS = 'C:/Users/info/Downloads/jmap-webmail/local-data/usability-2026-11-04/shots'; +// Shots once, from the 1.0 run. +const TAKE_SHOTS = process.env.IT_ENT_SERVER === '1.0'; + +test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")'); + +type Card = Record & { + id: string; + uid?: string; + kind?: string; + name?: { full?: string; components?: Array<{ kind: string; value: string }> }; + addressBookIds?: Record; + members?: Record; + emails?: Record; + phones?: Record; contexts?: Record }>; + addresses?: Record; contexts?: Record; isOrdered?: boolean }>; + notes?: Record; + organizations?: Record; + titles?: Record; + anniversaries?: Record }>; +}; + +const nameOf = (c: Card) => c.name?.full || (c.name?.components ?? []).filter((p) => p.kind !== 'separator').map((p) => p.value).join(' '); + +async function books(c: JmapClient): Promise> { + const r = await c.request([['AddressBook/get', { accountId: c.accountId }, '0']], [CORE, CONTACTS]); + return r.methodResponses[0][1].list; +} + +async function cards(c: JmapClient, prefix: string): Promise { + const r = await c.request([ + ['ContactCard/query', { accountId: c.accountId, limit: 2000 }, 'q'], + ['ContactCard/get', { accountId: c.accountId, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'], + ], [CORE, CONTACTS]); + return (r.methodResponses[1][1].list as Card[]).filter((card) => nameOf(card).includes(prefix)); +} + +async function destroyAll(c: JmapClient): Promise { + const ids = (await cards(c, PREFIX)).map((card) => card.id); + if (ids.length) await c.request([['ContactCard/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CONTACTS]); +} + +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 }); +} + +/** German Outlook's export: `;` between fields, Windows-1252 bytes. */ +function outlookDeCsv(rows: Array>): Buffer { + const headers = ['Anrede', 'Vorname', 'Weitere Vornamen', 'Nachname', 'Firma', 'Abteilung', 'Position', + 'Straße geschäftlich', 'Ort geschäftlich', 'Postleitzahl geschäftlich', 'Land/Region geschäftlich', + 'Telefon geschäftlich', 'Mobiltelefon', 'E-Mail-Adresse', 'E-Mail-Typ', 'E-Mail: Angezeigter Name', + 'Geburtstag', 'Jahrestag', 'Kategorien', 'Notizen', 'Privat']; + const line = (cells: string[]) => cells.map((c) => `"${c.replace(/"/g, '""')}"`).join(';'); + const text = [line(headers), ...rows.map((r) => line(headers.map((h) => r[h] ?? '')))].join('\r\n') + '\r\n'; + const high: Record = { '€': 0x80, '„': 0x84, '“': 0x93, '”': 0x94, '–': 0x96 }; + return Buffer.from([...text].map((ch) => { + if (high[ch]) return high[ch]; + const code = ch.charCodeAt(0); + if (code > 0xff) throw new Error(`not in Windows-1252: ${ch}`); + return code; + })); +} + +test.describe('Contacts import and merge (pim-11, pim-14)', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async () => { + await destroyAll(await JmapClient.connect(A, PASSWORD)); + }); + test.afterAll(async () => { + await destroyAll(await JmapClient.connect(A, PASSWORD)); + }); + + test('a German Outlook CSV goes through the column step into the address book that is open', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const work = (await books(a)).find((b) => b.name === 'Work'); + expect(work, 'seeded Work address book').toBeTruthy(); + expect(work!.isDefault).toBeFalsy(); + + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page); + await page.goto('/contacts'); + await page.getByText('Work', { exact: true }).first().click(); + await page.getByRole('button', { name: 'New', exact: true }).first().click(); + await page.getByRole('menuitem', { name: /^Import contacts/ }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + + await dialog.locator('input[type="file"]').setInputFiles({ + name: 'Outlook-Kontakte.csv', + mimeType: 'text/csv', + buffer: outlookDeCsv([ + { + Anrede: 'Frau', Vorname: 'Jürgen', Nachname: `${PREFIX} Import1 ${RUN}`, Firma: 'Muster & Söhne GmbH', Position: 'Leiterin', + 'Straße geschäftlich': 'Hauptstraße 1', 'Ort geschäftlich': 'Köln', 'Postleitzahl geschäftlich': '50667', + 'Land/Region geschäftlich': 'Deutschland', Mobiltelefon: '+49 170 1234567', + 'E-Mail-Adresse': `import1-${RUN}@example.org`, 'E-Mail-Typ': 'SMTP', 'E-Mail: Angezeigter Name': 'Jürgen', + Geburtstag: '12.04.1985', Jahrestag: '0.0.00', Notizen: 'Rabatt 10 €, „Stammkunde“', Privat: 'Aus', + }, + { Vorname: 'Ana', Nachname: `${PREFIX} Import2 ${RUN}`, 'E-Mail-Adresse': `import2-${RUN}@example.org`, Privat: 'Aus' }, + ]), + }); + + const columns = dialog.getByTestId('csv-columns'); + await expect(columns).toBeVisible(); + await expect(columns.getByRole('combobox', { name: 'Vorname', exact: true })).toHaveValue('name.given'); + await expect(columns.getByRole('combobox', { name: 'Straße geschäftlich', exact: true })).toHaveValue('adr.work.street'); + await expect(columns.getByRole('combobox', { name: 'E-Mail-Typ', exact: true })).toHaveValue('skip'); + await expect(columns.getByRole('row', { name: /^Vorname/ }).getByText('Jürgen', { exact: true })).toBeVisible(); + if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-11-after.png` }); + await dialog.getByRole('button', { name: 'Continue' }).click(); + + await expect(dialog.getByText(`Jürgen ${PREFIX} Import1 ${RUN}`)).toBeVisible(); + const into = dialog.getByRole('combobox'); + await expect(into).toHaveValue(work!.id); + if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-11-after-preview.png` }); + await dialog.getByRole('button', { name: 'Import', exact: true }).click(); + await expect(dialog.getByText('2 contacts imported')).toBeVisible({ timeout: 30000 }); + + await expect.poll(async () => (await cards(a, `${PREFIX} Import`)).length, { timeout: 30000 }).toBe(2); + const imported = await cards(a, `${PREFIX} Import`); + for (const card of imported) expect(Object.keys(card.addressBookIds ?? {})).toEqual([work!.id]); + const jurgen = imported.find((c) => nameOf(c).includes('Import1'))!; + expect(jurgen.name?.components?.filter((p) => p.kind !== 'separator')).toEqual([ + { kind: 'title', value: 'Frau' }, { kind: 'given', value: 'Jürgen' }, { kind: 'surname', value: `${PREFIX} Import1 ${RUN}` }, + ]); + expect(Object.values(jurgen.emails ?? {}).map((e) => e.address)).toEqual([`import1-${RUN}@example.org`]); + expect(Object.values(jurgen.phones ?? {}).map((p) => [p.number, p.features])).toEqual([['+49 170 1234567', { mobile: true }]]); + const address = Object.values(jurgen.addresses ?? {})[0]; + expect(address?.contexts).toEqual({ work: true }); + expect(address?.components?.filter((p) => p.kind !== 'separator').map((p) => [p.kind, p.value])).toEqual([ + ['name', 'Hauptstraße 1'], ['locality', 'Köln'], ['postcode', '50667'], ['country', 'Deutschland'], + ]); + expect(Object.values(jurgen.organizations ?? {}).map((o) => o.name)).toEqual(['Muster & Söhne GmbH']); + expect(Object.values(jurgen.titles ?? {}).map((t) => t.name)).toEqual(['Leiterin']); + expect(Object.values(jurgen.notes ?? {}).map((n) => n.note)).toEqual(['Rabatt 10 €, „Stammkunde“']); + const birthday = Object.values(jurgen.anniversaries ?? {}).find((x) => x.kind === 'birth'); + expect(birthday?.date).toMatchObject({ year: 1985, month: 4, day: 12 }); + await context.close(); + }); + + test('two cards merge into one: everything kept, the group moves, the other card is gone', async ({ browser }) => { + test.setTimeout(240_000); + const a = await JmapClient.connect(A, PASSWORD); + const home = (await books(a)).find((b) => b.isDefault) ?? (await books(a))[0]; + const surname = `${PREFIX} Merge ${RUN}`; + // The address the way a server card has it: parts and separators in order. + const berlin = { + '@type': 'Address', + components: [ + { '@type': 'AddressComponent', kind: 'name', value: 'Hauptstraße' }, + { '@type': 'AddressComponent', kind: 'separator', value: ' ' }, + { '@type': 'AddressComponent', kind: 'number', value: '1' }, + { '@type': 'AddressComponent', kind: 'postcode', value: '10115' }, + { '@type': 'AddressComponent', kind: 'locality', value: 'Berlin' }, + ], + isOrdered: true, + defaultSeparator: ', ', + contexts: { private: true }, + }; + const made = await a.request([['ContactCard/set', { + accountId: a.accountId, + create: { + one: { + '@type': 'Card', version: '1.0', uid: `urn:uuid:uxfix-cimport-one-${RUN}`, addressBookIds: { [home.id]: true }, + name: { '@type': 'Name', components: [{ '@type': 'NameComponent', kind: 'given', value: 'Anna' }, { '@type': 'NameComponent', kind: 'surname', value: surname }], isOrdered: true }, + emails: { e0: { '@type': 'EmailAddress', address: `merge-a-${RUN}@example.org` } }, + addresses: { a0: berlin }, + notes: { n0: { '@type': 'Note', note: 'Met at the fair' } }, + }, + two: { + '@type': 'Card', version: '1.0', uid: `urn:uuid:uxfix-cimport-two-${RUN}`, addressBookIds: { [home.id]: true }, + name: { '@type': 'Name', components: [{ '@type': 'NameComponent', kind: 'given', value: 'Anna' }, { '@type': 'NameComponent', kind: 'surname', value: surname }], isOrdered: true }, + emails: { e0: { '@type': 'EmailAddress', address: `merge-b-${RUN}@example.org` } }, + phones: { p0: { '@type': 'Phone', number: '+49 30 5550000' } }, + }, + group: { + '@type': 'Card', version: '1.0', kind: 'group', uid: `urn:uuid:uxfix-cimport-group-${RUN}`, addressBookIds: { [home.id]: true }, + name: { '@type': 'Name', full: `${PREFIX} Group ${RUN}` }, + members: { [`urn:uuid:uxfix-cimport-one-${RUN}`]: true }, + }, + }, + }, '0']], [CORE, CONTACTS]); + const created = made.methodResponses[0][1].created ?? {}; + expect(Object.keys(created).sort(), JSON.stringify(made.methodResponses[0][1].notCreated)).toEqual(['group', 'one', 'two']); + const [before] = (await cards(a, surname)).filter((c) => c.id === created.one.id); + const beforeAddress = Object.values(before.addresses ?? {})[0]; + + const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page); + await page.goto('/contacts'); + const search = page.getByPlaceholder(/Search contacts/).first(); + await search.fill(surname); + const rows = page.getByRole('option').filter({ hasText: surname }); + await expect(rows).toHaveCount(2, { timeout: 60000 }); + + // The single-contact menu offers Merge with…, not Duplicate. + await rows.first().click({ button: 'right' }); + await expect(page.getByRole('menuitem', { name: 'Merge with…' })).toBeVisible(); + await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toHaveCount(0); + await page.keyboard.press('Escape'); + + await rows.nth(0).click(); + await rows.nth(1).click({ modifiers: ['Control'] }); + await page.getByRole('button', { name: 'Merge…' }).first().click(); + const preview = page.getByTestId('merge-preview'); + await expect(preview).toBeVisible(); + await expect(preview.getByText(`merge-a-${RUN}@example.org`)).toBeVisible(); + await expect(preview.getByText(`merge-b-${RUN}@example.org`)).toBeVisible(); + await expect(preview.getByText('Hauptstraße 1, 10115, Berlin')).toBeVisible(); + await expect(preview.getByText(`${PREFIX} Group ${RUN}`)).toBeVisible(); + if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-14-after.png` }); + await page.getByRole('button', { name: 'Merge', exact: true }).click(); + const toastRegion = page.getByRole('region', { name: 'Notifications' }); + await expect(toastRegion.getByText('Contacts merged')).toBeVisible({ timeout: 30000 }); + + // On the server: one card with everything, the address untouched, the group pointing at it. + await expect.poll(async () => (await cards(a, surname)).filter((c) => c.kind !== 'group').length, { timeout: 30000 }).toBe(1); + const all = await cards(a, PREFIX); + const [kept] = all.filter((c) => nameOf(c).includes(surname) && c.kind !== 'group'); + const goneId = kept.id === created.one.id ? created.two.id : created.one.id; + expect(all.some((c) => c.id === goneId)).toBe(false); + const gone = await a.request([['ContactCard/get', { accountId: a.accountId, ids: [goneId] }, '0']], [CORE, CONTACTS]); + expect(gone.methodResponses[0][1].notFound).toEqual([goneId]); + expect(Object.values(kept.emails ?? {}).map((e) => e.address).sort()).toEqual([`merge-a-${RUN}@example.org`, `merge-b-${RUN}@example.org`]); + expect(Object.values(kept.phones ?? {}).map((p) => p.number)).toEqual(['+49 30 5550000']); + expect(Object.values(kept.notes ?? {}).map((n) => n.note)).toEqual(['Met at the fair']); + const keptAddresses = Object.values(kept.addresses ?? {}); + expect(keptAddresses).toHaveLength(1); + expect(keptAddresses[0].components).toEqual(beforeAddress?.components); + expect(keptAddresses[0].contexts).toEqual({ private: true }); + const group = all.find((c) => c.kind === 'group'); + const members = Object.keys(group?.members ?? {}).filter((k) => group?.members?.[k]); + expect(members).toEqual([kept.uid]); + + // Undo: the other card is back under its own uid, the group and the kept card as before. + await toastRegion.getByRole('button', { name: 'Undo' }).click(); + await expect(toastRegion.getByText('Merge undone')).toBeVisible({ timeout: 30000 }); + const after = await cards(a, PREFIX); + const back = after.filter((c) => nameOf(c).includes(surname) && c.kind !== 'group'); + expect(back.map((c) => c.uid).sort()).toEqual([`urn:uuid:uxfix-cimport-one-${RUN}`, `urn:uuid:uxfix-cimport-two-${RUN}`]); + for (const card of back) expect(Object.keys(card.emails ?? {})).toHaveLength(1); + const groupAfter = after.find((c) => c.kind === 'group'); + expect(Object.keys(groupAfter?.members ?? {}).filter((k) => groupAfter?.members?.[k])).toEqual([`urn:uuid:uxfix-cimport-one-${RUN}`]); + const one = back.find((c) => c.uid === `urn:uuid:uxfix-cimport-one-${RUN}`); + expect(Object.values(one?.addresses ?? {})[0]?.components).toEqual(beforeAddress?.components); + await context.close(); + }); + + test('the contact menu says Merge with… in German too', async ({ browser }) => { + test.skip(!TAKE_SHOTS, 'Shot only from the 1.0 run'); + test.setTimeout(180_000); + const context = await browser.newContext({ ...contextOptions(), locale: 'de-DE', viewport: { width: 1366, height: 768 } }); + const page = await context.newPage(); + await login(page); + await page.goto('/contacts'); + await page.getByRole('option').filter({ hasText: 'Anna Example' }).first().click(); + await page.getByRole('button', { name: 'Weitere Aktionen' }).first().click(); + await expect(page.getByRole('menuitem', { name: 'Zusammenführen mit…' })).toBeVisible(); + await expect(page.getByText(/Mögliche Duplikate \(\d+\)/)).toBeVisible(); + await page.screenshot({ path: `${SHOTS}/pim-fix-14-after-de.png` }); + await context.close(); + }); +}); diff --git a/lib/contacts/__tests__/csv-import.test.ts b/lib/contacts/__tests__/csv-import.test.ts new file mode 100644 index 000000000..0b0a3463b --- /dev/null +++ b/lib/contacts/__tests__/csv-import.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it } from 'vitest'; +import { + csvToContacts, decodeCsvBytes, detectDelimiter, matchColumns, parseCsv, parseCsvDate, readCsv, + type CsvColumn, type CsvTable, +} from '../csv-import'; + +// Exports shaped like the real ones (headers in the order Outlook and Google +// write them), filled with made-up people. + +const OUTLOOK_EN_HEADERS = ['Title', 'First Name', 'Middle Name', 'Last Name', 'Suffix', 'Company', 'Department', 'Job Title', + 'Business Street', 'Business Street 2', 'Business Street 3', 'Business City', 'Business State', 'Business Postal Code', + 'Business Country/Region', 'Home Street', 'Home Street 2', 'Home Street 3', 'Home City', 'Home State', 'Home Postal Code', + 'Home Country/Region', 'Other Street', 'Other Street 2', 'Other Street 3', 'Other City', 'Other State', 'Other Postal Code', + 'Other Country/Region', "Assistant's Phone", 'Business Fax', 'Business Phone', 'Business Phone 2', 'Callback', 'Car Phone', + 'Company Main Phone', 'Home Fax', 'Home Phone', 'Home Phone 2', 'ISDN', 'Mobile Phone', 'Other Fax', 'Other Phone', 'Pager', + 'Primary Phone', 'Radio Phone', 'TTY/TDD Phone', 'Telex', 'Account', 'Anniversary', "Assistant's Name", 'Billing Information', + 'Birthday', 'Business Address PO Box', 'Categories', 'Children', 'Directory Server', 'E-mail Address', 'E-mail Type', + 'E-mail Display Name', 'E-mail 2 Address', 'E-mail 2 Type', 'E-mail 2 Display Name', 'E-mail 3 Address', 'E-mail 3 Type', + 'E-mail 3 Display Name', 'Gender', 'Government ID Number', 'Hobby', 'Home Address PO Box', 'Initials', 'Internet Free Busy', + 'Keywords', 'Language', 'Location', "Manager's Name", 'Mileage', 'Notes', 'Office Location', 'Organizational ID Number', + 'Other Address PO Box', 'Priority', 'Private', 'Profession', 'Referred By', 'Sensitivity', 'Spouse', 'User 1', 'User 2', + 'User 3', 'User 4', 'Web Page']; + +const OUTLOOK_DE_HEADERS = ['Anrede', 'Vorname', 'Weitere Vornamen', 'Nachname', 'Suffix', 'Firma', 'Abteilung', 'Position', + 'Straße geschäftlich', 'Straße geschäftlich 2', 'Straße geschäftlich 3', 'Ort geschäftlich', 'Region geschäftlich', + 'Postleitzahl geschäftlich', 'Land/Region geschäftlich', 'Straße privat', 'Straße privat 2', 'Straße privat 3', 'Ort privat', + 'Bundesland/Kanton privat', 'Postleitzahl privat', 'Land/Region privat', 'Weitere Straße', 'Weitere Straße 2', + 'Weitere Straße 3', 'Weiterer Ort', 'Weiteres/r Bundesland/Kanton', 'Weitere Postleitzahl', 'Weiteres/r Land/Region', + 'Telefon Assistent', 'Fax geschäftlich', 'Telefon geschäftlich', 'Telefon geschäftlich 2', 'Rückmeldung', 'Autotelefon', + 'Telefon Firma', 'Fax privat', 'Telefon privat', 'Telefon privat 2', 'ISDN', 'Mobiltelefon', 'Weiteres Fax', + 'Weiteres Telefon', 'Pager', 'Haupttelefon', 'Mobiltelefon 2', 'Telefon für Hörbehinderte', 'Telex', + 'Abrechnungsinformation', 'Benutzer 1', 'Benutzer 2', 'Benutzer 3', 'Benutzer 4', 'Beruf', 'Büro', 'E-Mail-Adresse', + 'E-Mail-Typ', 'E-Mail: Angezeigter Name', 'E-Mail 2: Adresse', 'E-Mail 2: Typ', 'E-Mail 2: Angezeigter Name', + 'E-Mail 3: Adresse', 'E-Mail 3: Typ', 'E-Mail 3: Angezeigter Name', 'Empfohlen von', 'Geburtstag', 'Geschlecht', 'Hobby', + 'Initialen', 'Internet-Frei/Gebucht', 'Jahrestag', 'Kategorien', 'Kinder', 'Konto', 'Name Assistent', + 'Name des/der Vorgesetzten', 'Notizen', 'Organisations-Nr.', 'Ort', 'Partner', 'Postfach geschäftlich', 'Postfach privat', + 'Priorität', 'Privat', 'Regierungs-Nr.', 'Reisekilometer', 'Sprache', 'Stichwörter', 'Vertraulichkeit', 'Verzeichnisserver', + 'Webseite', 'Weiteres Postfach']; + +const GOOGLE_HEADERS = ['First Name', 'Middle Name', 'Last Name', 'Phonetic First Name', 'Phonetic Middle Name', + 'Phonetic Last Name', 'Name Prefix', 'Name Suffix', 'Nickname', 'File As', 'Organization Name', 'Organization Title', + 'Organization Department', 'Birthday', 'Notes', 'Photo', 'Labels', 'E-mail 1 - Label', 'E-mail 1 - Value', + 'E-mail 2 - Label', 'E-mail 2 - Value', 'Phone 1 - Label', 'Phone 1 - Value', 'Phone 2 - Label', 'Phone 2 - Value', + 'Address 1 - Label', 'Address 1 - Formatted', 'Address 1 - Street', 'Address 1 - City', 'Address 1 - PO Box', + 'Address 1 - Region', 'Address 1 - Postal Code', 'Address 1 - Country', 'Address 1 - Extended Address', + 'Address 2 - Label', 'Address 2 - Formatted', 'Address 2 - Street', 'Address 2 - City', 'Address 2 - PO Box', + 'Address 2 - Region', 'Address 2 - Postal Code', 'Address 2 - Country', 'Address 2 - Extended Address', + 'Website 1 - Label', 'Website 1 - Value']; + +const GOOGLE_OLD_HEADERS = ['Name', 'Given Name', 'Additional Name', 'Family Name', 'Yomi Name', 'Given Name Yomi', + 'Additional Name Yomi', 'Family Name Yomi', 'Name Prefix', 'Name Suffix', 'Initials', 'Nickname', 'Short Name', + 'Maiden Name', 'Birthday', 'Gender', 'Location', 'Billing Information', 'Directory Server', 'Mileage', 'Occupation', + 'Hobby', 'Sensitivity', 'Priority', 'Subject', 'Notes', 'Language', 'Photo', 'Group Membership', 'E-mail 1 - Type', + 'E-mail 1 - Value', 'E-mail 2 - Type', 'E-mail 2 - Value', 'Phone 1 - Type', 'Phone 1 - Value', 'Phone 2 - Type', + 'Phone 2 - Value', 'Address 1 - Type', 'Address 1 - Formatted', 'Address 1 - Street', 'Address 1 - City', + 'Address 1 - PO Box', 'Address 1 - Region', 'Address 1 - Postal Code', 'Address 1 - Country', + 'Address 1 - Extended Address', 'Organization 1 - Type', 'Organization 1 - Name', 'Organization 1 - Yomi Name', + 'Organization 1 - Title', 'Organization 1 - Department', 'Organization 1 - Symbol', 'Organization 1 - Location', + 'Organization 1 - Job Description', 'Website 1 - Type', 'Website 1 - Value']; + +function quote(value: string, delimiter: string): string { + return /["\r\n]/.test(value) || value.includes(delimiter) ? `"${value.replace(/"/g, '""')}"` : value; +} + +/** A CSV file as the exporter writes it: every row has every column. */ +function csv(headers: string[], rows: Array>, opts: { delimiter?: string; quoteAll?: boolean } = {}): string { + const d = opts.delimiter ?? ','; + const line = (cells: string[]) => cells.map((c) => (opts.quoteAll ? `"${c.replace(/"/g, '""')}"` : quote(c, d))).join(d); + for (const row of rows) for (const key of Object.keys(row)) if (!headers.includes(key)) throw new Error(`no column ${key}`); + return [line(headers), ...rows.map((row) => line(headers.map((h) => row[h] ?? '')))].join('\r\n') + '\r\n'; +} + +/** Windows-1252, as German Outlook writes the file. */ +function cp1252(text: string): Uint8Array { + const high: Record = { '€': 0x80, '„': 0x84, '“': 0x93, '”': 0x94, '–': 0x96 }; + return Uint8Array.from([...text].map((ch) => { + if (high[ch]) return high[ch]; + const code = ch.charCodeAt(0); + if (code > 0xff) throw new Error(`not in cp1252: ${ch}`); + return code; + })); +} + +function importText(text: string) { + const table = readCsv(text); + const columns = matchColumns(table); + return { table, columns, cards: csvToContacts(table, columns) }; +} + +const target = (columns: CsvColumn[], header: string) => columns.find((c) => c.header === header)?.target; + +describe('reading CSV', () => { + it('keeps quoted delimiters, doubled quotes and line breaks inside a field', () => { + const rows = parseCsv('a,b,c\r\n"Smith, Jane","say ""hi""","line 1\r\nline 2"\r\n\r\n'); + expect(rows).toEqual([['a', 'b', 'c'], ['Smith, Jane', 'say "hi"', 'line 1\r\nline 2']]); + }); + + it('finds the delimiter from the header line', () => { + expect(detectDelimiter('"Vorname";"Nachname";"Firma"\r\nA;B;C')).toBe(';'); + expect(detectDelimiter('First Name,Last Name\nA;B,C')).toBe(','); + expect(detectDelimiter('First Name\tLast Name\n')).toBe('\t'); + }); + + it('drops a UTF-8 byte order mark', () => { + const bytes = new Uint8Array([0xef, 0xbb, 0xbf, ...new TextEncoder().encode('First Name,Last Name\r\nJürgen,Beispiel\r\n')]); + const table = readCsv(decodeCsvBytes(bytes)); + expect(table.headers[0]).toBe('First Name'); + expect(table.rows).toEqual([['Jürgen', 'Beispiel']]); + }); + + it('reads UTF-16 with a byte order mark', () => { + const text = 'First Name\r\nÉlodie\r\n'; + const bytes = new Uint8Array(2 + text.length * 2); + bytes.set([0xff, 0xfe]); + for (let i = 0; i < text.length; i++) bytes[2 + i * 2] = text.charCodeAt(i); + expect(readCsv(decodeCsvBytes(bytes)).rows).toEqual([['Élodie']]); + }); + + it('falls back to Windows-1252 when the bytes are not UTF-8', () => { + expect(decodeCsvBytes(cp1252('Straße „Hauptweg“ 5 – 10 €'))).toBe('Straße „Hauptweg“ 5 – 10 €'); + // Valid UTF-8 stays UTF-8. + expect(decodeCsvBytes(new TextEncoder().encode('Müller'))).toBe('Müller'); + }); + + it('reads Outlook, Google and German dates, and none for Outlook\'s empty ones', () => { + expect(parseCsvDate('4/12/1985')).toEqual({ year: 1985, month: 4, day: 12 }); + expect(parseCsvDate('25/12/1985')).toEqual({ year: 1985, month: 12, day: 25 }); + expect(parseCsvDate('12.04.1985')).toEqual({ year: 1985, month: 4, day: 12 }); + expect(parseCsvDate('1985-04-12')).toEqual({ year: 1985, month: 4, day: 12 }); + expect(parseCsvDate('--04-12')).toEqual({ month: 4, day: 12 }); + expect(parseCsvDate('0/0/00')).toBeUndefined(); + expect(parseCsvDate('0.0.00')).toBeUndefined(); + }); +}); + +describe('Outlook (English)', () => { + const text = csv(OUTLOOK_EN_HEADERS, [{ + Title: 'Dr.', 'First Name': 'Jane', 'Middle Name': 'Q.', 'Last Name': 'Sample', Company: 'Example Ltd', + Department: 'Research', 'Job Title': 'Lead chemist', 'Business Street': '1 Lab Road', 'Business Street 2': 'Building 4', + 'Business City': 'Springfield', 'Business State': 'IL', 'Business Postal Code': '62701', 'Business Country/Region': 'United States', + 'Home Street': '22 Elm St', 'Home City': 'Springfield', 'Home Postal Code': '62702', + 'Business Fax': '+1 217 555 0101', 'Business Phone': '+1 217 555 0100', 'Home Phone': '+1 217 555 0199', + 'Mobile Phone': '+1 217 555 0142', Birthday: '4/12/1985', Anniversary: '0/0/00', Categories: 'Clients;VIP', + 'E-mail Address': 'jane.sample@example.com', 'E-mail Type': 'SMTP', 'E-mail Display Name': 'Jane Sample (jane.sample@example.com)', + 'E-mail 2 Address': 'jane@home.example.net', 'E-mail 2 Type': 'SMTP', + Notes: 'Met at the "Chem, Expo"\r\nSecond line', 'Web Page': 'https://example.com/jane', Private: 'False', Sensitivity: 'Normal', + }, { + 'First Name': 'Max', 'Last Name': 'Example', 'E-mail Address': 'max@example.org', Birthday: '0/0/00', + }], { quoteAll: true }); + + it('matches the known columns and leaves the rest out', () => { + const { columns } = importText(text); + expect(target(columns, 'First Name')).toBe('name.given'); + expect(target(columns, 'Title')).toBe('name.prefix'); + expect(target(columns, 'E-mail 2 Address')).toBe('email'); + expect(target(columns, 'E-mail 2 Type')).toBe('skip'); + expect(target(columns, 'E-mail Display Name')).toBe('skip'); + expect(target(columns, 'Business Country/Region')).toBe('adr.work.country'); + expect(target(columns, "Assistant's Phone")).toBe('phone'); + expect(target(columns, 'Private')).toBe('skip'); + expect(columns.find((c) => c.header === 'Company')?.sample).toBe('Example Ltd'); + }); + + it('builds the cards', () => { + const { cards } = importText(text); + expect(cards).toHaveLength(2); + const [jane, max] = cards; + expect(jane.name?.components).toEqual([ + { kind: 'title', value: 'Dr.' }, { kind: 'given', value: 'Jane' }, { kind: 'given2', value: 'Q.' }, { kind: 'surname', value: 'Sample' }, + ]); + expect(Object.values(jane.emails ?? {}).map((e) => e.address)).toEqual(['jane.sample@example.com', 'jane@home.example.net']); + expect(Object.values(jane.phones ?? {})).toEqual(expect.arrayContaining([ + { number: '+1 217 555 0101', contexts: { work: true }, features: { fax: true } }, + { number: '+1 217 555 0100', contexts: { work: true } }, + { number: '+1 217 555 0199', contexts: { private: true } }, + { number: '+1 217 555 0142', features: { mobile: true } }, + ])); + expect(Object.values(jane.organizations ?? {})).toEqual([{ name: 'Example Ltd', units: [{ name: 'Research' }] }]); + expect(Object.values(jane.titles ?? {})).toEqual([{ name: 'Lead chemist', kind: 'title', organizationId: 'o0' }]); + const addresses = Object.values(jane.addresses ?? {}); + expect(addresses).toHaveLength(2); + expect(addresses.find((a) => a.contexts?.work)?.components).toEqual([ + { kind: 'name', value: '1 Lab Road\nBuilding 4' }, { kind: 'locality', value: 'Springfield' }, { kind: 'region', value: 'IL' }, + { kind: 'postcode', value: '62701' }, { kind: 'country', value: 'United States' }, + ]); + expect(addresses.find((a) => a.contexts?.private)?.components).toEqual([ + { kind: 'name', value: '22 Elm St' }, { kind: 'locality', value: 'Springfield' }, { kind: 'postcode', value: '62702' }, + ]); + expect(Object.values(jane.anniversaries ?? {})).toEqual([{ kind: 'birth', date: { year: 1985, month: 4, day: 12 } }]); + expect(jane.keywords).toEqual({ Clients: true, VIP: true }); + expect(Object.values(jane.notes ?? {})).toEqual([{ note: 'Met at the "Chem, Expo"\r\nSecond line' }]); + expect(Object.values(jane.onlineServices ?? {})).toEqual([{ uri: 'https://example.com/jane' }]); + + expect(max.name?.components).toEqual([{ kind: 'given', value: 'Max' }, { kind: 'surname', value: 'Example' }]); + expect(max.anniversaries).toBeUndefined(); + expect(max.addresses).toBeUndefined(); + }); +}); + +describe('Outlook (German)', () => { + const text = csv(OUTLOOK_DE_HEADERS, [{ + Anrede: 'Frau', Vorname: 'Erika', Nachname: 'Müßig', Firma: 'Beispiel & Söhne GmbH', Position: 'Geschäftsführerin', + 'Straße geschäftlich': 'Hauptstraße 1', 'Ort geschäftlich': 'Köln', 'Postleitzahl geschäftlich': '50667', + 'Land/Region geschäftlich': 'Deutschland', 'Straße privat': 'Am Weiher 7', 'Ort privat': 'Bonn', 'Postleitzahl privat': '53111', + 'Telefon geschäftlich': '+49 221 1234567', Mobiltelefon: '+49 170 1234567', 'Fax privat': '+49 228 7654321', + 'E-Mail-Adresse': 'erika.muessig@example.de', 'E-Mail-Typ': 'SMTP', 'E-Mail: Angezeigter Name': 'Erika Müßig', + 'E-Mail 2: Adresse': 'erika@privat.example.de', Geburtstag: '12.04.1985', Jahrestag: '0.0.00', + Kategorien: 'Kunden', Notizen: 'Rabatt 10 €; „Stammkundin“', Privat: 'Aus', Ort: 'Köln', + }], { delimiter: ';', quoteAll: true }); + const bytes = cp1252(text); + + it('reads the semicolons and the Windows-1252 bytes', () => { + const table = readCsv(decodeCsvBytes(bytes)); + expect(table.headers).toEqual(OUTLOOK_DE_HEADERS); + expect(table.rows[0][OUTLOOK_DE_HEADERS.indexOf('Nachname')]).toBe('Müßig'); + }); + + it('matches the German columns and builds the card', () => { + const { columns, cards } = importText(decodeCsvBytes(bytes)); + expect(target(columns, 'E-Mail-Adresse')).toBe('email'); + expect(target(columns, 'E-Mail 2: Adresse')).toBe('email'); + expect(target(columns, 'E-Mail 2: Typ')).toBe('skip'); + expect(target(columns, 'Straße geschäftlich')).toBe('adr.work.street'); + expect(target(columns, 'Bundesland/Kanton privat')).toBe('adr.home.region'); + expect(target(columns, 'Ort')).toBe('skip'); + expect(target(columns, 'Privat')).toBe('skip'); + + expect(cards).toHaveLength(1); + const [erika] = cards; + expect(erika.name?.components).toEqual([ + { kind: 'title', value: 'Frau' }, { kind: 'given', value: 'Erika' }, { kind: 'surname', value: 'Müßig' }, + ]); + expect(Object.values(erika.emails ?? {}).map((e) => e.address)).toEqual(['erika.muessig@example.de', 'erika@privat.example.de']); + expect(Object.values(erika.phones ?? {})).toEqual(expect.arrayContaining([ + { number: '+49 221 1234567', contexts: { work: true } }, + { number: '+49 170 1234567', features: { mobile: true } }, + { number: '+49 228 7654321', contexts: { private: true }, features: { fax: true } }, + ])); + expect(Object.values(erika.addresses ?? {}).find((a) => a.contexts?.work)?.components).toEqual([ + { kind: 'name', value: 'Hauptstraße 1' }, { kind: 'locality', value: 'Köln' }, { kind: 'postcode', value: '50667' }, + { kind: 'country', value: 'Deutschland' }, + ]); + expect(Object.values(erika.organizations ?? {})).toEqual([{ name: 'Beispiel & Söhne GmbH' }]); + expect(Object.values(erika.titles ?? {})).toEqual([{ name: 'Geschäftsführerin', kind: 'title', organizationId: 'o0' }]); + expect(Object.values(erika.anniversaries ?? {})).toEqual([{ kind: 'birth', date: { year: 1985, month: 4, day: 12 } }]); + expect(Object.values(erika.notes ?? {})).toEqual([{ note: 'Rabatt 10 €; „Stammkundin“' }]); + expect(erika.keywords).toEqual({ Kunden: true }); + }); +}); + +describe('Google Contacts', () => { + it('reads the current format: typed values, several in one cell, two addresses', () => { + const text = csv(GOOGLE_HEADERS, [{ + 'First Name': 'Ana', 'Last Name': 'Placeholder', Nickname: 'Ani', 'Organization Name': 'Sample Co', + 'Organization Title': 'Designer', Birthday: '--07-30', Labels: '* myContacts ::: Friends ::: * starred', + 'E-mail 1 - Label': '* Work', 'E-mail 1 - Value': 'ana@sample.example', + 'E-mail 2 - Label': 'Home', 'E-mail 2 - Value': 'ana.p@example.net ::: ani@example.net', + 'Phone 1 - Label': 'Mobile', 'Phone 1 - Value': '+44 7700 900123', + 'Phone 2 - Label': 'Work Fax', 'Phone 2 - Value': '+44 20 7946 0000', + 'Address 1 - Label': 'Home', 'Address 1 - Formatted': '5 Mill Lane\nFlat 2\nLeeds LS1 4AB\nUK', + 'Address 1 - Street': '5 Mill Lane', 'Address 1 - Extended Address': 'Flat 2', 'Address 1 - City': 'Leeds', + 'Address 1 - Postal Code': 'LS1 4AB', 'Address 1 - Country': 'UK', + 'Address 2 - Label': 'Work', 'Address 2 - Street': '10 Dock Street', 'Address 2 - City': 'Leeds', + 'Website 1 - Label': 'Profile', 'Website 1 - Value': 'https://example.net/ana', + }]); + const { columns, cards } = importText(text); + expect(target(columns, 'E-mail 1 - Label')).toBe('type'); + expect(target(columns, 'E-mail 1 - Value')).toBe('email'); + expect(target(columns, 'Address 2 - City')).toBe('adr.any.locality'); + expect(target(columns, 'Address 1 - Formatted')).toBe('skip'); + expect(target(columns, 'Phonetic First Name')).toBe('skip'); + + const [ana] = cards; + expect(ana.name?.components).toEqual([{ kind: 'given', value: 'Ana' }, { kind: 'surname', value: 'Placeholder' }]); + expect(Object.values(ana.nicknames ?? {})).toEqual([{ name: 'Ani' }]); + expect(Object.values(ana.emails ?? {})).toEqual([ + { address: 'ana@sample.example', contexts: { work: true }, pref: 1 }, + { address: 'ana.p@example.net', contexts: { private: true } }, + { address: 'ani@example.net', contexts: { private: true } }, + ]); + expect(Object.values(ana.phones ?? {})).toEqual([ + { number: '+44 7700 900123', features: { mobile: true } }, + { number: '+44 20 7946 0000', contexts: { work: true }, features: { fax: true } }, + ]); + const addresses = Object.values(ana.addresses ?? {}); + expect(addresses).toEqual([ + { + components: [{ kind: 'name', value: '5 Mill Lane\nFlat 2' }, { kind: 'locality', value: 'Leeds' }, { kind: 'postcode', value: 'LS1 4AB' }, { kind: 'country', value: 'UK' }], + isOrdered: true, defaultSeparator: ', ', contexts: { private: true }, + }, + { + components: [{ kind: 'name', value: '10 Dock Street' }, { kind: 'locality', value: 'Leeds' }], + isOrdered: true, defaultSeparator: ', ', contexts: { work: true }, + }, + ]); + expect(Object.values(ana.anniversaries ?? {})).toEqual([{ kind: 'birth', date: { month: 7, day: 30 } }]); + expect(ana.keywords).toEqual({ Friends: true }); + expect(Object.values(ana.organizations ?? {})).toEqual([{ name: 'Sample Co' }]); + expect(Object.values(ana.titles ?? {})).toEqual([{ name: 'Designer', kind: 'title', organizationId: 'o0' }]); + }); + + it('reads the older format with Given/Family Name and numbered organisations', () => { + const text = csv(GOOGLE_OLD_HEADERS, [{ + Name: 'Tom Testperson', 'Given Name': 'Tom', 'Family Name': 'Testperson', Birthday: '1990-01-31', + 'Group Membership': '* My Contacts ::: Club', 'E-mail 1 - Type': '* Other', 'E-mail 1 - Value': 'tom@example.org', + 'Phone 1 - Type': 'Home', 'Phone 1 - Value': '+33 1 23 45 67 89', + 'Organization 1 - Type': '', 'Organization 1 - Name': 'Club Example', 'Organization 1 - Title': 'Treasurer', + 'Organization 1 - Department': 'Finance', + }, { + // Only a full name: first word given name, the rest surname, as for a vCard FN. + Name: 'Lea van Probe', 'E-mail 1 - Value': 'lea@example.org', + }, { + // Nothing to import. + Notes: '', Gender: 'female', + }]); + const { columns, cards } = importText(text); + expect(target(columns, 'Name')).toBe('name.full'); + expect(target(columns, 'Given Name')).toBe('name.given'); + expect(target(columns, 'E-mail 1 - Type')).toBe('type'); + expect(target(columns, 'Organization 1 - Department')).toBe('org.unit'); + expect(target(columns, 'Organization 1 - Type')).toBe('skip'); + + expect(cards).toHaveLength(2); + const [tom, lea] = cards; + expect(tom.name).toEqual({ components: [{ kind: 'given', value: 'Tom' }, { kind: 'surname', value: 'Testperson' }], isOrdered: true, full: 'Tom Testperson' }); + expect(Object.values(tom.emails ?? {})).toEqual([{ address: 'tom@example.org', pref: 1 }]); + expect(Object.values(tom.phones ?? {})).toEqual([{ number: '+33 1 23 45 67 89', contexts: { private: true } }]); + expect(Object.values(tom.organizations ?? {})).toEqual([{ name: 'Club Example', units: [{ name: 'Finance' }] }]); + expect(Object.values(tom.titles ?? {})).toEqual([{ name: 'Treasurer', kind: 'title', organizationId: 'o0' }]); + expect(tom.keywords).toEqual({ Club: true }); + expect(lea.name?.components).toEqual([{ kind: 'given', value: 'Lea' }, { kind: 'surname', value: 'van Probe' }]); + }); +}); + +describe('column choices', () => { + it('leaves out a column set to "Don\'t import" and uses one the user matched by hand', () => { + const table: CsvTable = readCsv('Vorname,Spalte X,Mail\r\nAnna,Notiz,anna@example.org\r\n'); + const columns = matchColumns(table); + expect(columns.map((c) => c.target)).toEqual(['name.given', 'skip', 'skip']); + const chosen = columns.map((c) => (c.header === 'Mail' ? { ...c, target: 'email.work' as const } : c)); + const [card] = csvToContacts(table, chosen); + expect(Object.values(card.emails ?? {})).toEqual([{ address: 'anna@example.org', contexts: { work: true } }]); + expect(card.notes).toBeUndefined(); + const skipped = chosen.map((c) => (c.header === 'Vorname' ? { ...c, target: 'skip' as const } : c)); + expect(csvToContacts(table, skipped)[0].name).toBeUndefined(); + }); +}); diff --git a/lib/contacts/__tests__/merge.test.ts b/lib/contacts/__tests__/merge.test.ts new file mode 100644 index 000000000..00296e388 --- /dev/null +++ b/lib/contacts/__tests__/merge.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from 'vitest'; +import type { ContactCard } from '@/lib/jmap/types'; +import { findDuplicateGroups, memberKeys, planGroupMoves, planMerge } from '../merge'; + +const card = (id: string, extra: Partial): ContactCard => ({ id, addressBookIds: { b1: true }, ...extra }); +const named = (given: string, surname: string) => ({ + components: [{ kind: 'given' as const, value: given }, { kind: 'surname' as const, value: surname }], isOrdered: true, +}); + +// A structured German address as the server sends it: it must reach the +// merged card exactly as it is, parts and order. +const BERLIN = { + components: [ + { kind: 'name', value: 'Hauptstraße' }, { kind: 'separator', value: ' ' }, { kind: 'number', value: '1' }, + { kind: 'postcode', value: '10115' }, { kind: 'locality', value: 'Berlin' }, { kind: 'country', value: 'Germany' }, + ], + isOrdered: true, + defaultSeparator: ', ', + contexts: { private: true }, +}; + +describe('findDuplicateGroups', () => { + it('groups by name (any order, case, accents), e-mail or phone, and only within one account', () => { + const anna1 = card('a1', { name: named('Anna', 'Müller'), emails: { e0: { address: 'anna@example.net' } } }); + const anna2 = card('a2', { name: { full: 'muller anna' } }); + const annaMail = card('a3', { emails: { x: { address: 'ANNA@example.net' } } }); + const ben1 = card('b1', { name: named('Ben', 'One'), phones: { p: { number: '+49 30 5550000' } } }); + const ben2 = card('b2', { name: named('Benjamin', 'Two'), phones: { p: { number: '030 5550000' } } }); + const other = card('o', { name: named('Anna', 'Müller'), localAccountId: 'second' }); + const lone = card('l', { name: named('Lone', 'Person'), emails: { e: { address: 'lone@example.net' } } }); + const group = card('g', { kind: 'group', name: { full: 'Anna Müller' } }); + + const groups = findDuplicateGroups([anna1, anna2, annaMail, ben1, ben2, other, lone, group]); + expect(groups.map((g) => g.map((c) => c.id))).toEqual([['a1', 'a2', 'a3'], ['b1', 'b2']]); + }); + + it('does not match on empty or very short names', () => { + expect(findDuplicateGroups([card('1', { name: { full: 'A' } }), card('2', { name: { full: 'a' } })])).toEqual([]); + }); +}); + +describe('planMerge', () => { + const older = card('old', { + uid: 'urn:uuid:old', + updated: '2026-01-01T00:00:00Z', + name: named('Anna', 'Example'), + emails: { e0: { address: 'anna@example.net', pref: 1 }, e1: { address: 'anna@home.example.net', contexts: { private: true } } }, + phones: { p0: { number: '+49 30 550000' } }, + addresses: { a0: structuredClone(BERLIN) }, + organizations: { o0: { name: 'Example GmbH' } }, + titles: { t0: { name: 'Project manager', kind: 'title', organizationId: 'o0' } }, + notes: { n0: { note: 'Met at the fair' } }, + language: 'de', + keywords: { Clients: true }, + anniversaries: { a0: { kind: 'birth', date: { year: 1970, month: 1, day: 1 } } }, + }); + const newer = card('new', { + uid: 'urn:uuid:new', + updated: '2026-06-01T00:00:00Z', + name: named('Anna', 'Example-Neu'), + emails: { e0: { address: 'ANNA@example.net' }, e1: { address: 'anna.work@example.net', contexts: { work: true }, pref: 1 } }, + phones: { p0: { number: '030 550000' }, p1: { number: '+49 170 9000000', features: { mobile: true } } }, + organizations: { o0: { name: 'Other AG' } }, + titles: { t0: { name: 'Advisor', kind: 'title', organizationId: 'o0' } }, + keywords: { VIP: true }, + }); + + it('keeps the newer card and adds what only the older one has, without repeats', () => { + const plan = planMerge([older, newer]); + expect(plan.keep.id).toBe('new'); + expect(plan.others.map((c) => c.id)).toEqual(['old']); + const r = plan.result; + // The newer name wins. + expect(r.name).toEqual(newer.name); + expect(Object.values(r.emails ?? {}).map((e) => e.address)).toEqual(['ANNA@example.net', 'anna.work@example.net', 'anna@home.example.net']); + // The added address has no second "preferred". + expect(r.emails?.e1m1).toEqual({ address: 'anna@home.example.net', contexts: { private: true } }); + expect(Object.values(r.phones ?? {}).map((p) => p.number)).toEqual(['030 550000', '+49 170 9000000']); + // The address arrives exactly as it was, parts and order. + expect(Object.values(r.addresses ?? {})).toEqual([BERLIN]); + expect(Object.values(r.notes ?? {})).toEqual([{ note: 'Met at the fair' }]); + expect(r.organizations).toEqual({ o0: { name: 'Other AG' }, o0m1: { name: 'Example GmbH' } }); + // The older title still points at its own organisation under its new key. + expect(r.titles).toEqual({ + t0: { name: 'Advisor', kind: 'title', organizationId: 'o0' }, + t0m1: { name: 'Project manager', kind: 'title', organizationId: 'o0m1' }, + }); + expect(r.language).toBe('de'); + expect(r.keywords).toEqual({ VIP: true, Clients: true }); + expect(r.anniversaries).toEqual(older.anniversaries); + expect(r.uid).toBe('urn:uuid:new'); + }); + + it('patches only what changes, and leaves the kept entries untouched', () => { + const plan = planMerge([older, newer]); + expect(Object.keys(plan.patch).sort()).toEqual( + ['addresses', 'anniversaries', 'emails', 'keywords', 'language', 'notes', 'organizations', 'titles'], + ); + expect(plan.patch.emails?.e0).toEqual(newer.emails!.e0); + expect(plan.patch.emails?.e1).toEqual(newer.emails!.e1); + expect(plan.patch).not.toHaveProperty('name'); + // The inputs are not changed. + expect(Object.keys(newer.emails!)).toEqual(['e0', 'e1']); + }); + + it('keeps the first card when none says when it changed, and fills an empty name from the others', () => { + const emailOnly = card('x', { emails: { e: { address: 'ben@example.net' } } }); + const namedBen = card('y', { name: named('Ben', 'Muster'), emails: { e: { address: 'ben@example.net' } } }); + const plan = planMerge([emailOnly, namedBen]); + expect(plan.keep.id).toBe('x'); + expect(plan.result.name).toEqual(namedBen.name); + expect(Object.keys(plan.patch)).toEqual(['name']); + }); + + it('takes a photo only when the kept card has none', () => { + const withPhoto = card('p', { media: { m0: { kind: 'photo', uri: 'data:image/png;base64,AA==' } } }); + const without = card('q', { updated: '2026-06-01T00:00:00Z' }); + expect(planMerge([withPhoto, without]).patch.media).toEqual({ m0: { kind: 'photo', uri: 'data:image/png;base64,AA==' } }); + const both = card('r', { updated: '2026-06-01T00:00:00Z', media: { m0: { kind: 'photo', uri: 'data:image/png;base64,BB==' } } }); + expect(planMerge([withPhoto, both]).patch).not.toHaveProperty('media'); + }); +}); + +describe('planGroupMoves', () => { + it('moves the merged cards\' memberships to the kept card', () => { + const keep = card('k', { uid: 'urn:uuid:keep' }); + const gone = card('g1', { uid: 'urn:uuid:gone' }); + const team = card('team', { kind: 'group', members: { 'urn:uuid:gone': true, 'urn:uuid:x': true } }); + const both = card('both', { kind: 'group', members: { 'urn:uuid:gone': true, 'urn:uuid:keep': true } }); + const bare = card('bare', { kind: 'group', members: { gone: true } }); + const unrelated = card('u', { kind: 'group', members: { 'urn:uuid:x': true } }); + const moves = planGroupMoves([team, both, bare, unrelated], keep, [gone]); + expect(moves.map((m) => [m.group.id, m.members])).toEqual([ + ['team', { 'urn:uuid:x': true, 'urn:uuid:keep': true }], + ['both', { 'urn:uuid:keep': true }], + ['bare', { 'urn:uuid:keep': true }], + ]); + }); + + it('knows a card under its uid with and without urn:uuid:', () => { + expect(memberKeys(card('id1', { uid: 'urn:uuid:abc', originalId: 'raw' }))).toEqual(['urn:uuid:abc', 'abc', 'raw', 'id1']); + }); +}); diff --git a/lib/contacts/csv-import.ts b/lib/contacts/csv-import.ts new file mode 100644 index 000000000..b50baf339 --- /dev/null +++ b/lib/contacts/csv-import.ts @@ -0,0 +1,494 @@ +/** + * Contacts from a CSV file, as Outlook (English and German) and Google + * Contacts (current and older format) export them. + * + * Three steps, so the import dialog can show the middle one: read the file + * into a table (`readCsvFile`), match each column to a contact field + * (`matchColumns`, which the user may change), then build the cards + * (`csvToContacts`). + * + * German Outlook writes `;` between fields and Windows-1252 bytes; Google + * writes UTF-8 with "E-mail 1 - Value" / "E-mail 1 - Type" column pairs and + * several values in one cell, joined by " ::: ". + */ + +import type { AddressComponent, ContactAddress, ContactCard, NameComponent, PartialDate } from '@/lib/jmap/types'; +import { generateUUID } from '@/lib/utils'; + +// ─── Reading the file ─────────────────────────────────────────────────────── + +/** Windows-1252 bytes 0x80–0x9F; the rest of the code page is Latin-1. */ +const CP1252_HIGH = [ + 0x20ac, 0x81, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8d, 0x017d, 0x8f, + 0x90, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022, 0x2013, 0x2014, 0x02dc, 0x2122, 0x0161, 0x203a, 0x0153, 0x9d, 0x017e, 0x0178, +]; + +function decodeWindows1252(bytes: Uint8Array): string { + let out = ''; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + out += String.fromCharCode(b >= 0x80 && b < 0xa0 ? CP1252_HIGH[b - 0x80] : b); + } + return out; +} + +/** + * The text of a CSV file. A byte order mark says UTF-8 or UTF-16; without one + * the bytes are UTF-8 if they are valid UTF-8, else Windows-1252 (what + * Outlook writes on a Western Windows). + */ +export function decodeCsvBytes(bytes: Uint8Array): string { + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) return new TextDecoder('utf-8').decode(bytes.subarray(3)); + if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes.subarray(2)); + if (bytes[0] === 0xfe && bytes[1] === 0xff) { + // Swap to little-endian: not every TextDecoder knows utf-16be. + const swapped = new Uint8Array(bytes.length - 2); + for (let i = 2; i + 1 < bytes.length; i += 2) { + swapped[i - 2] = bytes[i + 1]; + swapped[i - 1] = bytes[i]; + } + return new TextDecoder('utf-16le').decode(swapped); + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + return decodeWindows1252(bytes); + } +} + +/** `,`, `;` or tab: whichever the header line has most of, outside quotes. */ +export function detectDelimiter(text: string): string { + const counts: Record = { ',': 0, ';': 0, '\t': 0 }; + let quoted = false; + for (const ch of text) { + if (ch === '"') quoted = !quoted; + else if (!quoted && (ch === '\n' || ch === '\r')) break; + else if (!quoted && ch in counts) counts[ch]++; + } + let best = ','; + for (const d of [';', '\t']) if (counts[d] > counts[best]) best = d; + return best; +} + +/** RFC 4180 records: quoted fields may hold the delimiter, `""` and line breaks. */ +export function parseCsv(text: string, delimiter = detectDelimiter(text)): string[][] { + const rows: string[][] = []; + let row: string[] = []; + let field = ''; + let quoted = false; + let i = 0; + const endRow = () => { + row.push(field); + rows.push(row); + row = []; + field = ''; + }; + while (i < text.length) { + const ch = text[i]; + if (quoted) { + if (ch === '"') { + if (text[i + 1] === '"') { + field += '"'; + i += 2; + continue; + } + quoted = false; + } else { + field += ch; + } + i++; + continue; + } + if (ch === '"' && field === '') { + quoted = true; + } else if (ch === delimiter) { + row.push(field); + field = ''; + } else if (ch === '\r' || ch === '\n') { + endRow(); + if (ch === '\r' && text[i + 1] === '\n') i++; + } else { + field += ch; + } + i++; + } + if (field !== '' || row.length > 0) endRow(); + // Blank lines (a trailing newline, Excel's empty rows) are no records. + return rows.filter((r) => r.some((cell) => cell.trim() !== '')); +} + +export interface CsvTable { + headers: string[]; + rows: string[][]; +} + +export function readCsv(text: string): CsvTable { + const clean = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; + const [headers = [], ...rows] = parseCsv(clean); + return { headers: headers.map((h) => h.trim()), rows }; +} + +/** A .csv by name or type, or text that isn't a vCard and has a delimited header line. */ +export function isCsvFile(file: { name: string; type?: string }): boolean { + const name = file.name.toLowerCase(); + const type = (file.type ?? '').toLowerCase(); + return name.endsWith('.csv') || type === 'text/csv' || type === 'application/csv' || type === 'text/comma-separated-values'; +} + +// ─── Fields a column can go to ────────────────────────────────────────────── + +export const ADDRESS_CONTEXTS = ['any', 'home', 'work', 'other'] as const; +export type AddressContext = typeof ADDRESS_CONTEXTS[number]; +export const ADDRESS_PARTS = ['street', 'pobox', 'locality', 'region', 'postcode', 'country'] as const; +export type AddressPart = typeof ADDRESS_PARTS[number]; +type AddressTarget = `adr.${AddressContext}.${AddressPart}`; + +export const CSV_TARGET_GROUPS = [ + { id: 'name', targets: ['name.full', 'name.given', 'name.middle', 'name.surname', 'name.prefix', 'name.suffix', 'nickname'] }, + { id: 'email', targets: ['email', 'email.home', 'email.work'] }, + { id: 'phone', targets: ['phone', 'phone.mobile', 'phone.home', 'phone.work', 'phone.fax', 'phone.fax.home', 'phone.fax.work', 'phone.pager'] }, + { id: 'org', targets: ['org.name', 'org.unit', 'org.title'] }, + ...ADDRESS_CONTEXTS.map((ctx) => ({ id: `adr.${ctx}`, targets: ADDRESS_PARTS.map((part) => `adr.${ctx}.${part}` as AddressTarget) })), + { id: 'more', targets: ['web', 'birthday', 'anniversary', 'notes', 'categories', 'type'] }, +] as const; + +export type CsvTarget = 'skip' | typeof CSV_TARGET_GROUPS[number]['targets'][number]; + +/** What a numbered Google column belongs to ("E-mail 2 - Value": email, slot 2). */ +export type CsvFamily = 'email' | 'phone' | 'address' | 'web' | 'org'; + +export interface CsvColumn { + index: number; + header: string; + /** The first value in the column, to show next to it. */ + sample: string; + target: CsvTarget; + /** Google's number: columns of one slot make one address, organisation or typed value. */ + slot: string; + family?: CsvFamily; +} + +/** Lower case, no punctuation: "E-Mail 2: Adresse" → "e mail 2 adresse". */ +export function normalizeHeader(header: string): string { + return header + .normalize('NFC') + .toLowerCase() + .replace(/['’]/g, '') + .replace(/[-_:/().,]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +const HEADER_TARGETS: Record = {}; +function known(target: CsvTarget, ...headers: string[]) { + for (const header of headers) HEADER_TARGETS[header] = target; +} + +// Names: Outlook (en, de), Google (current, older). +known('name.given', 'first name', 'given name', 'vorname'); +known('name.middle', 'middle name', 'additional name', 'weitere vornamen', 'zweiter vorname'); +known('name.surname', 'last name', 'family name', 'surname', 'nachname'); +known('name.prefix', 'title', 'name prefix', 'anrede', 'titel'); +known('name.suffix', 'suffix', 'name suffix', 'namenszusatz'); +known('name.full', 'name', 'full name', 'display name', 'anzeigename'); +known('nickname', 'nickname', 'spitzname'); +// E-mail. +known('email', 'e mail address', 'e mail 2 address', 'e mail 3 address', 'email address', 'email', 'e mail', + 'e mail adresse', 'e mail 2 adresse', 'e mail 3 adresse', 'email 2 address', 'email 3 address'); +// Outlook's numbered e-mail columns look like Google's; these are not types. +known('skip', 'e mail 2 type', 'e mail 3 type', 'e mail 2 display name', 'e mail 3 display name', + 'e mail 2 typ', 'e mail 3 typ', 'e mail 2 angezeigter name', 'e mail 3 angezeigter name'); +known('email.home', 'home e mail', 'home email', 'personal e mail', 'e mail privat'); +known('email.work', 'work e mail', 'work email', 'business e mail', 'e mail geschäftlich'); +// Phones. +known('phone.mobile', 'mobile phone', 'mobile phone 2', 'mobile', 'cell phone', 'mobiltelefon', 'mobiltelefon 2', 'handy'); +known('phone.home', 'home phone', 'home phone 2', 'telefon privat', 'telefon privat 2'); +known('phone.work', 'business phone', 'business phone 2', 'work phone', 'company main phone', + 'telefon geschäftlich', 'telefon geschäftlich 2', 'telefon firma'); +known('phone.fax.work', 'business fax', 'work fax', 'fax geschäftlich'); +known('phone.fax.home', 'home fax', 'fax privat'); +known('phone.fax', 'other fax', 'fax', 'weiteres fax'); +known('phone.pager', 'pager'); +known('phone', 'other phone', 'primary phone', 'car phone', 'callback', 'radio phone', 'assistants phone', 'isdn', + 'tty tdd phone', 'phone', 'weiteres telefon', 'haupttelefon', 'autotelefon', 'rückmeldung', 'telefon assistent', + 'telefon für hörbehinderte'); +// Organisation. +known('org.name', 'company', 'organization', 'organization name', 'firma', 'unternehmen'); +known('org.unit', 'department', 'organization department', 'abteilung'); +known('org.title', 'job title', 'organization title', 'position', 'berufsbezeichnung'); +// Outlook addresses. +for (const [ctx, en, de] of [['work', 'business', 'geschäftlich'], ['home', 'home', 'privat']] as const) { + known(`adr.${ctx}.street`, `${en} street`, `${en} street 2`, `${en} street 3`, `straße ${de}`, `straße ${de} 2`, `straße ${de} 3`); + known(`adr.${ctx}.locality`, `${en} city`, `ort ${de}`); + known(`adr.${ctx}.region`, `${en} state`, `region ${de}`, `bundesland kanton ${de}`, `bundesland ${de}`); + known(`adr.${ctx}.postcode`, `${en} postal code`, `postleitzahl ${de}`); + known(`adr.${ctx}.country`, `${en} country region`, `${en} country`, `land region ${de}`, `land ${de}`); + known(`adr.${ctx}.pobox`, `${en} address po box`, `postfach ${de}`); +} +known('adr.other.street', 'other street', 'other street 2', 'other street 3', 'weitere straße', 'weitere straße 2', 'weitere straße 3'); +known('adr.other.locality', 'other city', 'weiterer ort'); +known('adr.other.region', 'other state', 'weitere region', 'weiteres bundesland kanton', 'weiteres r bundesland kanton'); +known('adr.other.postcode', 'other postal code', 'weitere postleitzahl'); +known('adr.other.country', 'other country region', 'other country', 'weiteres land region', 'weiteres r land region'); +known('adr.other.pobox', 'other address po box', 'weiteres postfach'); +// More. +known('web', 'web page', 'personal web page', 'website', 'web site', 'webseite', 'homepage', 'webpage'); +known('birthday', 'birthday', 'geburtstag'); +known('anniversary', 'anniversary', 'jahrestag'); +known('notes', 'notes', 'note', 'notizen'); +known('categories', 'categories', 'kategorien', 'labels', 'group membership'); + +const FAMILIES: Record = { + 'e mail': 'email', email: 'email', phone: 'phone', address: 'address', website: 'web', organization: 'org', +}; + +const GOOGLE_FIELDS: Record> = { + email: { value: 'email', type: 'type', label: 'type' }, + phone: { value: 'phone', type: 'type', label: 'type' }, + web: { value: 'web', type: 'type', label: 'type' }, + org: { name: 'org.name', title: 'org.title', department: 'org.unit' }, + address: { + type: 'type', label: 'type', + street: 'adr.any.street', 'extended address': 'adr.any.street', 'po box': 'adr.any.pobox', + city: 'adr.any.locality', region: 'adr.any.region', 'postal code': 'adr.any.postcode', country: 'adr.any.country', + }, +}; + +/** "E-mail 2 - Value" → email, slot 2, field "value". */ +function googleColumn(normalized: string): { family: CsvFamily; slot: string; field: string } | null { + const m = normalized.match(/^(e mail|email|phone|address|website|organization) (\d+) (.+)$/); + if (!m) return null; + return { family: FAMILIES[m[1]], slot: m[2], field: m[3] }; +} + +/** Each column with a sample value and the field it goes to: known headers matched, the rest "Don't import". */ +export function matchColumns(table: CsvTable): CsvColumn[] { + return table.headers.map((header, index) => { + const sample = table.rows.map((r) => (r[index] ?? '').trim()).find((v) => v !== '') ?? ''; + const normalized = normalizeHeader(header); + if (normalized in HEADER_TARGETS) return { index, header, sample, slot: '', target: HEADER_TARGETS[normalized] }; + const google = googleColumn(normalized); + if (google) { + return { index, header, sample, slot: google.slot, family: google.family, target: GOOGLE_FIELDS[google.family][google.field] ?? 'skip' }; + } + return { index, header, sample, slot: '', target: 'skip' }; + }); +} + +// ─── Building the cards ───────────────────────────────────────────────────── + +/** Google's several values in one cell. */ +function splitValues(value: string): string[] { + return value.split(/\s*:::\s*/).map((v) => v.trim()).filter(Boolean); +} + +interface ParsedType { + primary: boolean; + words: string[]; +} + +function parseType(value: string | undefined): ParsedType { + const raw = (value ?? '').trim(); + const primary = raw.startsWith('*'); + const words = raw.replace(/^\*\s*/, '').toLowerCase().split(/[\s,/:]+/).filter(Boolean); + return { primary, words }; +} + +const HOME_WORDS = ['home', 'personal', 'private', 'privat', 'zuhause']; +const WORK_WORDS = ['work', 'business', 'office', 'arbeit', 'geschäftlich', 'büro']; + +function contextsFor(ctx: 'home' | 'work' | 'other' | 'any', type?: ParsedType): Record | undefined { + if (ctx === 'home') return { private: true }; + if (ctx === 'work') return { work: true }; + if (ctx === 'other' || !type) return undefined; + if (type.words.some((w) => HOME_WORDS.includes(w))) return { private: true }; + if (type.words.some((w) => WORK_WORDS.includes(w))) return { work: true }; + return undefined; +} + +function phoneFeaturesFor(target: CsvTarget, type?: ParsedType): Record | undefined { + if (target === 'phone.mobile') return { mobile: true }; + if (target === 'phone.fax' || target === 'phone.fax.home' || target === 'phone.fax.work') return { fax: true }; + if (target === 'phone.pager') return { pager: true }; + if (target !== 'phone' || !type) return undefined; + if (type.words.some((w) => ['mobile', 'cell', 'mobil', 'handy'].includes(w))) return { mobile: true }; + if (type.words.includes('fax')) return { fax: true }; + if (type.words.includes('pager')) return { pager: true }; + return undefined; +} + +function phoneContextFor(target: CsvTarget): 'home' | 'work' | 'any' { + if (target === 'phone.home' || target === 'phone.fax.home') return 'home'; + if (target === 'phone.work' || target === 'phone.fax.work') return 'work'; + return 'any'; +} + +/** + * A birthday as Outlook (en "4/12/1985", de "12.04.1985", "0/0/00" for none) + * or Google ("1985-04-12", "--04-12" without a year) writes it. + */ +export function parseCsvDate(value: string): PartialDate | undefined { + const v = value.trim(); + if (!v) return undefined; + const mk = (year: number | undefined, month: number, day: number): PartialDate | undefined => { + if (!(month >= 1 && month <= 12 && day >= 1 && day <= 31)) return undefined; + return year ? { year, month, day } : { month, day }; + }; + const fullYear = (y: string) => { + const n = parseInt(y, 10); + if (!n) return undefined; + return y.length <= 2 ? (n > 30 ? 1900 + n : 2000 + n) : n; + }; + let m = v.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); + if (m) return mk(fullYear(m[1]), +m[2], +m[3]); + m = v.match(/^--(\d{1,2})-?(\d{2})$/); + if (m) return mk(undefined, +m[1], +m[2]); + m = v.match(/^(\d{1,2})\.(\d{1,2})\.(\d{2,4})$/); + if (m) return mk(fullYear(m[3]), +m[2], +m[1]); + m = v.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/); + if (m) { + // Month first (US Outlook), unless the first number can't be a month. + const [a, b] = [+m[1], +m[2]]; + return a > 12 ? mk(fullYear(m[3]), b, a) : mk(fullYear(m[3]), a, b); + } + return undefined; +} + +interface AddressDraft { + ctx: AddressContext; + slot: string; + parts: Partial>; +} + +const ADDRESS_COMPONENT_KIND: Record = { + street: 'name', pobox: 'postOfficeBox', locality: 'locality', region: 'region', postcode: 'postcode', country: 'country', +}; + +/** The cards in the table, one per row with a name, an address to write to, a phone or a company. */ +export function csvToContacts(table: CsvTable, columns: CsvColumn[]): ContactCard[] { + const used = columns.filter((c) => c.target !== 'skip' && c.target !== 'type'); + const typeColumn = (col: CsvColumn) => + col.family ? columns.find((c) => c.target === 'type' && c.family === col.family && c.slot === col.slot) : undefined; + + const cards: ContactCard[] = []; + for (const row of table.rows) { + const cell = (col: CsvColumn) => (row[col.index] ?? '').trim(); + const card: ContactCard = { id: `import-${generateUUID()}`, addressBookIds: {} }; + const name: Partial> = {}; + const orgs = new Map(); + const addresses = new Map(); + let birthday: PartialDate | undefined; + let anniversary: PartialDate | undefined; + + const add = (key: 'emails' | 'phones' | 'onlineServices' | 'notes' | 'nicknames', prefix: string, entry: T) => { + const map = ((card as unknown as Record>)[key] ??= {}); + map[`${prefix}${Object.keys(map).length}`] = entry; + }; + + for (const col of used) { + const value = cell(col); + if (!value) continue; + const type = typeColumn(col); + const parsedType = type ? parseType(cell(type)) : undefined; + const pref = parsedType?.primary ? { pref: 1 } : {}; + const target = col.target; + + if (target.startsWith('name.')) { + const part = target.slice(5) as keyof typeof name; + (name[part] ??= []).push(value); + } else if (target === 'nickname') { + add('nicknames', 'n', { name: value }); + } else if (target === 'email' || target === 'email.home' || target === 'email.work') { + const ctx = target === 'email.home' ? 'home' : target === 'email.work' ? 'work' : 'any'; + for (const address of splitValues(value)) { + const contexts = contextsFor(ctx, parsedType); + add('emails', 'e', { address, ...(contexts ? { contexts } : {}), ...pref }); + } + } else if (target === 'phone' || target.startsWith('phone.')) { + for (const number of splitValues(value)) { + const contexts = contextsFor(phoneContextFor(target), parsedType); + const features = phoneFeaturesFor(target, parsedType); + add('phones', 'p', { number, ...(contexts ? { contexts } : {}), ...(features ? { features } : {}), ...pref }); + } + } else if (target === 'org.name' || target === 'org.unit' || target === 'org.title') { + const org = orgs.get(col.slot) ?? { name: [], unit: [], title: [] }; + org[target.slice(4) as 'name' | 'unit' | 'title'].push(value); + orgs.set(col.slot, org); + } else if (target.startsWith('adr.')) { + const [, ctx, part] = target.split('.') as [string, AddressContext, AddressPart]; + const key = `${ctx}|${col.slot}`; + const draft = addresses.get(key) ?? { ctx, slot: col.slot, parts: {} }; + (draft.parts[part] ??= []).push(value); + addresses.set(key, draft); + } else if (target === 'web') { + for (const uri of splitValues(value)) { + const contexts = contextsFor('any', parsedType); + add('onlineServices', 'u', { uri, ...(contexts ? { contexts } : {}) }); + } + } else if (target === 'birthday') { + birthday ??= parseCsvDate(value); + } else if (target === 'anniversary') { + anniversary ??= parseCsvDate(value); + } else if (target === 'notes') { + add('notes', 'n', { note: value }); + } else if (target === 'categories') { + for (const label of value.split(/\s*(?::::|;)\s*/)) { + const clean = label.trim(); + // Google's own labels ("* myContacts", "* starred") are no categories. + if (!clean || clean.startsWith('*') || clean === 'myContacts') continue; + (card.keywords ??= {})[clean] = true; + } + } + } + + const join = (parts?: string[]) => (parts ?? []).join(' ').trim(); + const components: NameComponent[] = []; + if (join(name.prefix)) components.push({ kind: 'title', value: join(name.prefix) }); + if (join(name.given)) components.push({ kind: 'given', value: join(name.given) }); + if (join(name.middle)) components.push({ kind: 'given2', value: join(name.middle) }); + if (join(name.surname)) components.push({ kind: 'surname', value: join(name.surname) }); + if (join(name.suffix)) components.push({ kind: 'generation', value: join(name.suffix) }); + const full = join(name.full); + if (components.length === 0 && full) { + // As a vCard FN alone: first word given name, the rest surname. + const words = full.split(/\s+/); + components.push({ kind: 'given', value: words[0] }); + if (words.length > 1) components.push({ kind: 'surname', value: words.slice(1).join(' ') }); + } + if (components.length > 0) card.name = { components, isOrdered: true, ...(full ? { full } : {}) }; + + let o = 0; + for (const org of orgs.values()) { + const orgName = join(org.name); + const unit = join(org.unit); + if (orgName || unit) { + (card.organizations ??= {})[`o${o}`] = { name: orgName || undefined, ...(unit ? { units: [{ name: unit }] } : {}) }; + } + const title = join(org.title); + if (title) (card.titles ??= {})[`t${o}`] = { name: title, kind: 'title', ...(orgName || unit ? { organizationId: `o${o}` } : {}) }; + o++; + } + + let a = 0; + for (const draft of addresses.values()) { + const type = draft.ctx === 'any' + ? columns.find((c) => c.target === 'type' && c.family === 'address' && c.slot === draft.slot) + : undefined; + const contexts = contextsFor(draft.ctx, type ? parseType(cell(type)) : undefined); + const parts: AddressComponent[] = []; + for (const part of ADDRESS_PARTS) { + const values = (draft.parts[part] ?? []).map((v) => v.trim()).filter(Boolean); + if (values.length) parts.push({ kind: ADDRESS_COMPONENT_KIND[part], value: values.join(part === 'street' ? '\n' : ' ') }); + } + if (parts.length === 0) continue; + const address: ContactAddress = { components: parts, isOrdered: true, defaultSeparator: ', ', ...(contexts ? { contexts } : {}) }; + (card.addresses ??= {})[`a${a++}`] = address; + } + + if (birthday) (card.anniversaries ??= {}).a0 = { kind: 'birth', date: birthday }; + if (anniversary) (card.anniversaries ??= {})[`a${Object.keys(card.anniversaries ?? {}).length}`] = { kind: 'wedding', date: anniversary }; + + if (card.name || card.emails || card.phones || card.organizations) cards.push(card); + } + return cards; +} diff --git a/lib/contacts/merge.ts b/lib/contacts/merge.ts new file mode 100644 index 000000000..0117fc1dc --- /dev/null +++ b/lib/contacts/merge.ts @@ -0,0 +1,262 @@ +/** + * Duplicate contacts: finding them and merging several cards into one. + * + * Merging keeps the most recently changed card and adds to it what the + * others have and it lacks: every e-mail address, phone, address, note and + * the like that isn't already there, and single fields (name, language…) it + * has none of, from the newest card that has one. Entries are copied as they + * are, structured parts and order included; nothing of the kept card is + * rewritten. The other cards are deleted and their group memberships move + * to the kept card. + */ + +import type { ContactCard } from '@/lib/jmap/types'; + +// ─── Finding duplicates ───────────────────────────────────────────────────── + +function fold(text: string): string { + return text.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/\s+/g, ' ').trim(); +} + +/** The name as a key: case, accents, order and commas don't count ("Müller, Anna" = "anna muller"). */ +export function nameKey(card: ContactCard): string { + const components = card.name?.components ?? []; + const parts = components + .filter((c) => c.kind === 'given' || c.kind === 'surname' || c.kind === 'given2') + .map((c) => c.value); + const text = parts.length > 0 ? parts.join(' ') : card.name?.full ?? ''; + const words = fold(text).split(/[\s,]+/).filter(Boolean).sort(); + const key = words.join(' '); + return key.length >= 3 ? key : ''; +} + +/** The phone's last eight digits, so "+49 30 550000" and "030 550000" match. */ +export function phoneKey(number: string): string { + const digits = number.replace(/\D/g, ''); + return digits.length >= 7 ? digits.slice(-8) : ''; +} + +/** Cards of one account and the same shared account can be merged. */ +export function ownerKey(card: ContactCard): string { + return `${card.localAccountId ?? ''}|${card.isShared ? card.accountId ?? '' : ''}`; +} + +/** Groups of likely duplicates: the same name, e-mail address or phone, within one account. */ +export function findDuplicateGroups(contacts: ContactCard[]): ContactCard[][] { + const cards = contacts.filter((c) => c.kind !== 'group'); + const parent = cards.map((_, i) => i); + const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]))); + const seen = new Map(); + const link = (key: string, i: number) => { + const first = seen.get(key); + if (first === undefined) seen.set(key, i); + else parent[find(i)] = find(first); + }; + cards.forEach((card, i) => { + const owner = ownerKey(card); + const name = nameKey(card); + if (name) link(`n|${owner}|${name}`, i); + for (const email of Object.values(card.emails ?? {})) { + const address = email?.address?.trim().toLowerCase(); + if (address) link(`e|${owner}|${address}`, i); + } + for (const phone of Object.values(card.phones ?? {})) { + const key = phone?.number ? phoneKey(phone.number) : ''; + if (key) link(`p|${owner}|${key}`, i); + } + }); + const groups = new Map(); + cards.forEach((card, i) => { + const root = find(i); + groups.set(root, [...(groups.get(root) ?? []), card]); + }); + return [...groups.values()].filter((g) => g.length > 1); +} + +// ─── Merging ──────────────────────────────────────────────────────────────── + +/** Maps of entries: everything the cards have, without exact repeats. */ +const MULTI_KEYS = [ + 'emails', 'phones', 'addresses', 'onlineServices', 'notes', 'nicknames', 'organizations', 'titles', + 'anniversaries', 'personalInfo', 'relatedTo', 'cryptoKeys', 'links', 'directories', 'preferredLanguages', + 'calendars', 'schedulingAddresses', +] as const; + +/** One value per card: the kept card's, else the newest other card's. */ +const SINGLE_KEYS = ['name', 'language', 'speakToAs'] as const; + +function stable(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort() + .filter((k) => k !== 'pref' && (value as Record)[k] !== undefined) + .map((k) => `${JSON.stringify(k)}:${stable((value as Record)[k])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function addressText(entry: Record): string { + const components = entry.components as Array<{ kind: string; value: string }> | undefined; + const parts = components?.length + ? components.filter((c) => c.kind !== 'separator').map((c) => c.value) + : ['street', 'locality', 'region', 'postcode', 'country'].map((k) => entry[k] as string | undefined); + const text = parts.filter(Boolean).join(' ') || (entry.full as string | undefined) || (entry.fullAddress as string | undefined) || ''; + return fold(text); +} + +/** What makes two entries of a map the same thing. */ +function entrySignature(key: typeof MULTI_KEYS[number], entry: Record): string { + switch (key) { + case 'emails': return String(entry.address ?? '').trim().toLowerCase(); + case 'phones': return phoneKey(String(entry.number ?? '')) || String(entry.number ?? '').replace(/\s/g, ''); + case 'addresses': return addressText(entry); + case 'onlineServices': return String(entry.uri ?? entry.user ?? '').trim().toLowerCase(); + case 'notes': return String(entry.note ?? '').trim(); + case 'nicknames': return fold(String(entry.name ?? '')); + case 'organizations': return fold(`${entry.name ?? ''} ${stable(entry.units ?? [])}`); + case 'titles': return fold(String(entry.name ?? '')); + default: return stable(entry); + } +} + +function hasValue(value: unknown): boolean { + if (value == null) return false; + if (typeof value === 'string') return value.trim() !== ''; + if (typeof value === 'object') { + if (Array.isArray(value)) return value.length > 0; + const name = value as { components?: unknown[]; full?: string }; + if ('components' in name || 'full' in name) return (name.components?.length ?? 0) > 0 || !!name.full?.trim(); + return Object.keys(value).length > 0; + } + return true; +} + +function updatedAt(card: ContactCard): number { + const t = card.updated ? Date.parse(card.updated) : NaN; + return Number.isNaN(t) ? -Infinity : t; +} + +function freeKey(map: Record, key: string): string { + if (!(key in map)) return key; + for (let n = 1; ; n++) if (!(`${key}m${n}` in map)) return `${key}m${n}`; +} + +export interface MergePlan { + /** The card that stays: the most recently changed one. */ + keep: ContactCard; + /** The cards that are deleted, newest first. */ + others: ContactCard[]; + /** What the kept card gets: only the properties that change, each in full. */ + patch: Partial; + /** The kept card as it will be. */ + result: ContactCard; +} + +/** How the cards become one; the first card stays when none says when it changed. */ +export function planMerge(cards: ContactCard[]): MergePlan { + const order = cards.map((card, i) => ({ card, i })) + .sort((a, b) => updatedAt(b.card) - updatedAt(a.card) || a.i - b.i) + .map((x) => x.card); + const [keep, ...others] = order; + const result = structuredClone(keep) as ContactCard; + const record = result as unknown as Record; + + for (const other of others) { + const source = other as unknown as Record; + // Organisation ids of this card as the merged card has them, for its titles. + const orgIds = new Map(); + + for (const key of MULTI_KEYS) { + const from = source[key] as Record> | undefined; + if (!from || Object.keys(from).length === 0) continue; + const into = (record[key] ?? {}) as Record>; + const signatures = new Map(Object.entries(into).map(([k, e]) => [entrySignature(key, e ?? {}), k])); + const hasPref = Object.values(into).some((e) => e?.pref != null); + let changed = false; + for (const [entryKey, entry] of Object.entries(from)) { + if (!entry) continue; + const signature = entrySignature(key, entry); + const existing = signatures.get(signature); + if (existing !== undefined && signature !== '') { + if (key === 'organizations') orgIds.set(entryKey, existing); + continue; + } + const copy = structuredClone(entry); + if (hasPref) delete copy.pref; + if (key === 'titles' && typeof copy.organizationId === 'string') { + const mapped = orgIds.get(copy.organizationId); + if (mapped) copy.organizationId = mapped; + } + const newKey = freeKey(into, entryKey); + if (key === 'organizations') orgIds.set(entryKey, newKey); + into[newKey] = copy; + signatures.set(signature, newKey); + changed = true; + } + if (changed) record[key] = into; + } + + // Categories: all of them. + for (const [keyword, on] of Object.entries(other.keywords ?? {})) { + if (on && !result.keywords?.[keyword]) result.keywords = { ...(result.keywords ?? {}), [keyword]: true }; + } + + for (const key of SINGLE_KEYS) { + if (!hasValue(record[key]) && hasValue(source[key])) record[key] = structuredClone(source[key]); + } + + // A photo only when the kept card has none. + const hasPhoto = Object.values(result.media ?? {}).some((m) => m?.kind === 'photo'); + const photo = Object.entries(other.media ?? {}).find(([, m]) => m?.kind === 'photo'); + if (!hasPhoto && photo) { + result.media = { ...(result.media ?? {}), [freeKey(result.media ?? {}, photo[0])]: structuredClone(photo[1]) }; + } + } + + const patch: Partial = {}; + const before = keep as unknown as Record; + for (const key of [...MULTI_KEYS, ...SINGLE_KEYS, 'keywords', 'media'] as const) { + if (stable(record[key] ?? null) !== stable(before[key] ?? null)) { + (patch as Record)[key] = record[key]; + } + } + return { keep, others, patch, result }; +} + +/** The keys a group's `members` may list a card under. */ +export function memberKeys(card: ContactCard): string[] { + const keys = new Set(); + const uid = card.uid; + if (uid) { + keys.add(uid); + if (uid.startsWith('urn:uuid:')) keys.add(uid.slice(9)); + else keys.add(`urn:uuid:${uid}`); + } + if (card.originalId) keys.add(card.originalId); + keys.add(card.id); + return [...keys]; +} + +/** + * The groups that list one of the merged cards, with their new members: the + * merged cards out, the kept card in (under its uid, as "Add to group" does). + */ +export function planGroupMoves( + groups: ContactCard[], + keep: ContactCard, + others: ContactCard[], +): Array<{ group: ContactCard; members: Record }> { + const otherKeys = new Set(others.flatMap(memberKeys)); + const keepKeys = memberKeys(keep); + const moves: Array<{ group: ContactCard; members: Record }> = []; + for (const group of groups) { + if (group.kind !== 'group' || !group.members) continue; + const listed = Object.keys(group.members).filter((k) => group.members![k] && otherKeys.has(k)); + if (listed.length === 0) continue; + const members = { ...group.members }; + for (const key of listed) delete members[key]; + if (!keepKeys.some((k) => members[k])) members[keep.uid || keep.originalId || keep.id] = true; + moves.push({ group, members }); + } + return moves; +} diff --git a/lib/file-drop/__tests__/zones.test.ts b/lib/file-drop/__tests__/zones.test.ts index 7895658e3..9469da30b 100644 --- a/lib/file-drop/__tests__/zones.test.ts +++ b/lib/file-drop/__tests__/zones.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { defaultZone, fileDropZones, filesWithinLimits, kindForType, MAX_ZONES, type DropFiles, type DropPlace } from '../zones'; +import { defaultZone, fileDropZones, filesWithinLimits, kindForName, kindForType, MAX_ZONES, type DropFiles, type DropPlace } from '../zones'; const place = (patch: Partial = {}): DropPlace => ({ composer: false, @@ -44,6 +44,14 @@ describe('the file drop zones', () => { expect(ids(drag(['eml']), place({ importFolder: 'Inbox' }))).toEqual(['open-eml', 'import-eml', 'new-message', 'upload']); }); + it('add contacts for one .csv on the Contacts screen only', () => { + expect(kindForName({ name: 'Outlook-Kontakte.CSV' })).toBe('csv'); + expect(kindForType('text/csv')).toBe('csv'); + expect(ids(drag(['csv']), place({ contactsScreen: true }))).toEqual(['contacts', 'new-message', 'upload']); + expect(ids(drag(['csv']), place())).toEqual(['new-message', 'upload']); + expect(ids(drag(['csv', 'csv']), place({ contactsScreen: true }))).toEqual(['new-message', 'upload']); + }); + it('offer nothing special for a mix or for a calendar the account does not have', () => { expect(ids(drag(['calendar', 'other']), place())).toEqual(['new-message', 'upload']); expect(ids(drag(['calendar']), place({ calendar: false }))).toEqual(['new-message', 'upload']); diff --git a/lib/file-drop/zones.ts b/lib/file-drop/zones.ts index 2a5ec7566..57ee17c84 100644 --- a/lib/file-drop/zones.ts +++ b/lib/file-drop/zones.ts @@ -15,7 +15,8 @@ import { kindForFile, type HandoffKind } from '@/lib/launch-handoff'; export type FileDropZoneId = 'attach-here' | 'insert-inline' | 'new-message' | 'upload' | 'calendar' | 'contacts' | 'open-eml' | 'import-eml'; -export type DroppedKind = Exclude | 'image' | 'other'; +/** `csv`: a contacts import on the Contacts screen (Outlook, Google), anything else elsewhere. */ +export type DroppedKind = Exclude | 'image' | 'csv' | 'other'; export interface DropFiles { /** Unknown during a drag in some browsers (Safari gives no items). */ @@ -39,6 +40,8 @@ export interface DropPlace { filesFolder: string | null; calendar: boolean; contacts: boolean; + /** The Contacts screen is open: a CSV dropped there is a contacts import. */ + contactsScreen?: boolean; /** On the mail screen, a real folder in view that takes new messages. */ importFolder: string | null; /** No connection to the mail server. */ @@ -71,6 +74,8 @@ export const MAX_ZONES = 4; /** What a mail can show in its text. Not SVG: it can carry script, and mail clients drop it. */ const INLINE_IMAGE_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); const INLINE_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp']); +/** Windows names .csv after Excel when Excel is installed. */ +const CSV_TYPES = new Set(['text/csv', 'application/csv', 'text/comma-separated-values', 'application/vnd.ms-excel']); function dropped(kind: HandoffKind | null): DroppedKind { return kind && kind !== 'compose' ? kind : 'other'; @@ -78,6 +83,7 @@ function dropped(kind: HandoffKind | null): DroppedKind { export function kindForType(type: string): DroppedKind { if (INLINE_IMAGE_TYPES.has(type.toLowerCase())) return 'image'; + if (CSV_TYPES.has(type.toLowerCase())) return 'csv'; return dropped(kindForFile({ name: '', type })); } @@ -85,6 +91,7 @@ export function kindForName(file: { name: string; type?: string }): DroppedKind const dot = file.name.lastIndexOf('.'); const extension = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''; if (INLINE_IMAGE_TYPES.has((file.type ?? '').toLowerCase()) || INLINE_IMAGE_EXTENSIONS.has(extension)) return 'image'; + if (extension === '.csv') return 'csv'; return dropped(kindForFile(file)); } @@ -126,6 +133,7 @@ export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[ if (kind === 'eml' && place.importFolder) special.push(serverOnly(place, { id: 'import-eml', folder: place.importFolder })); if (kind === 'calendar' && one && place.calendar) special.push({ id: 'calendar' }); if (kind === 'contacts' && one && place.contacts) special.push({ id: 'contacts' }); + if (kind === 'csv' && one && place.contacts && place.contactsScreen) special.push({ id: 'contacts' }); } const room = Math.max(0, MAX_ZONES - main.length); diff --git a/locales/ar/common.json b/locales/ar/common.json index a9bba72a1..58baccead 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -5175,6 +5175,88 @@ "title": "متاح/مشغول", "description": "يرى زملاؤك في المجموعة متى تكون مشغولًا، لا تفاصيل أحداثك. تستخدمه ميزة اقتراح الأوقات." }, + "ux_pim_cimport": { + "file_hint": "انقر لاختيار ملف vCard أو CSV", + "file_types": "ملفات ‎.vcf أو ‎.csv، مثلًا من Outlook أو جهات اتصال Google", + "parse_error": "تعذّرت قراءة الملف", + "columns_title": "مطابقة الأعمدة", + "columns_hint": "اختر وجهة كل عمود في {file}. تُستبعد الأعمدة المضبوطة على «عدم الاستيراد».", + "column": "العمود", + "sample": "مثال", + "field": "حقل جهة الاتصال", + "skip": "عدم الاستيراد", + "no_columns": "اختر حقلًا لعمود واحد على الأقل.", + "continue": "متابعة", + "back": "رجوع", + "import_into": "الاستيراد إلى", + "group_name": "الاسم", + "group_email": "البريد الإلكتروني", + "group_phone": "الهاتف", + "group_org": "المؤسسة", + "group_adr_any": "العنوان", + "group_adr_home": "عنوان المنزل", + "group_adr_work": "عنوان العمل", + "group_adr_other": "عنوان آخر", + "group_more": "المزيد", + "address_field": "{group}: {part}", + "t_name_full": "الاسم الكامل", + "t_name_given": "الاسم الأول", + "t_name_middle": "الاسم الأوسط", + "t_name_surname": "اسم العائلة", + "t_name_prefix": "اللقب (د.، السيدة)", + "t_name_suffix": "اللاحقة (الابن)", + "t_nickname": "الكنية", + "t_email": "البريد الإلكتروني", + "t_email_home": "البريد الإلكتروني (المنزل)", + "t_email_work": "البريد الإلكتروني (العمل)", + "t_phone": "الهاتف", + "t_phone_mobile": "الجوال", + "t_phone_home": "الهاتف (المنزل)", + "t_phone_work": "الهاتف (العمل)", + "t_phone_fax": "الفاكس", + "t_phone_fax_home": "الفاكس (المنزل)", + "t_phone_fax_work": "الفاكس (العمل)", + "t_phone_pager": "جهاز النداء", + "t_org_name": "الشركة", + "t_org_unit": "القسم", + "t_org_title": "المسمى الوظيفي", + "t_street": "الشارع", + "t_pobox": "صندوق البريد", + "t_locality": "المدينة", + "t_region": "الولاية أو المنطقة", + "t_postcode": "الرمز البريدي", + "t_country": "البلد", + "t_web": "الموقع الإلكتروني", + "t_birthday": "تاريخ الميلاد", + "t_anniversary": "الذكرى السنوية", + "t_notes": "الملاحظات", + "t_categories": "الفئات", + "t_type": "نوع القيمة (المنزل، العمل…)", + "merge_with": "دمج مع…", + "merge_selected": "دمج…", + "find_duplicates": "البحث عن التكرارات", + "possible_duplicates": "تكرارات محتملة ({count})", + "duplicates_title": "تكرارات محتملة", + "duplicates_hint": "جهات اتصال لها الاسم نفسه أو عنوان البريد نفسه أو رقم الهاتف نفسه.", + "duplicates_none": "لم يُعثر على تكرارات محتملة.", + "pick_title": "دمج {name} مع…", + "pick_search": "البحث في جهات الاتصال", + "pick_suggested": "تكرارات محتملة", + "pick_all": "كل جهات الاتصال", + "pick_none": "لا توجد جهات اتصال أخرى للدمج معها.", + "preview_title": "دمج جهات الاتصال ({count})", + "preview_hint": "تصبح جهات الاتصال هذه بطاقة واحدة تضم كل ما يلي.", + "preview_deleted": "دُمجت ثم حُذفت: {names}", + "groups": "المجموعات", + "merge": "دمج", + "merging": "جارٍ الدمج…", + "merged": "تم دمج جهات الاتصال", + "merge_failed": "تعذّر دمج جهات الاتصال.", + "merge_not_possible": "لا يمكن دمج جهات الاتصال هذه: فهي في حسابات مختلفة أو في دفتر عناوين لا يمكنك تغييره.", + "undo": "تراجع", + "undone": "تم التراجع عن الدمج", + "undo_failed": "تعذّر التراجع عن الدمج بالكامل." + }, "find_time": { "suggest": "اقتراح أوقات", "searching": "جارٍ البحث عن أوقات متاحة", diff --git a/locales/ca/common.json b/locales/ca/common.json index 24fa23c50..881836067 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -5175,6 +5175,88 @@ "title": "Disponibilitat", "description": "Els companys del grup veuen quan esteu ocupat, no quins esdeveniments teniu. «Suggereix horaris» ho fa servir." }, + "ux_pim_cimport": { + "file_hint": "Feu clic per seleccionar un fitxer vCard o CSV", + "file_types": "Fitxers .vcf o .csv, per exemple d’Outlook o de Google Contactes", + "parse_error": "No s’ha pogut llegir el fitxer", + "columns_title": "Assigna les columnes", + "columns_hint": "Trieu on va cada columna de {file}. Les columnes amb «No importis» s’ometen.", + "column": "Columna", + "sample": "Exemple", + "field": "Camp del contacte", + "skip": "No importis", + "no_columns": "Trieu un camp per a almenys una columna.", + "continue": "Continua", + "back": "Enrere", + "import_into": "Importa a", + "group_name": "Nom", + "group_email": "Correu", + "group_phone": "Telèfon", + "group_org": "Organització", + "group_adr_any": "Adreça", + "group_adr_home": "Adreça personal", + "group_adr_work": "Adreça de la feina", + "group_adr_other": "Altra adreça", + "group_more": "Més", + "address_field": "{group}: {part}", + "t_name_full": "Nom complet", + "t_name_given": "Nom", + "t_name_middle": "Segon nom", + "t_name_surname": "Cognoms", + "t_name_prefix": "Tractament (Dr., Sra.)", + "t_name_suffix": "Sufix (Jr.)", + "t_nickname": "Sobrenom", + "t_email": "Correu", + "t_email_home": "Correu (personal)", + "t_email_work": "Correu (feina)", + "t_phone": "Telèfon", + "t_phone_mobile": "Mòbil", + "t_phone_home": "Telèfon (personal)", + "t_phone_work": "Telèfon (feina)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (personal)", + "t_phone_fax_work": "Fax (feina)", + "t_phone_pager": "Cercapersones", + "t_org_name": "Empresa", + "t_org_unit": "Departament", + "t_org_title": "Càrrec", + "t_street": "Carrer", + "t_pobox": "Apartat de correus", + "t_locality": "Ciutat", + "t_region": "Estat o regió", + "t_postcode": "Codi postal", + "t_country": "País", + "t_web": "Lloc web", + "t_birthday": "Aniversari", + "t_anniversary": "Commemoració", + "t_notes": "Notes", + "t_categories": "Categories", + "t_type": "Tipus del valor (Casa, Feina…)", + "merge_with": "Combina amb…", + "merge_selected": "Combina…", + "find_duplicates": "Cerca duplicats", + "possible_duplicates": "Possibles duplicats ({count})", + "duplicates_title": "Possibles duplicats", + "duplicates_hint": "Contactes amb el mateix nom, correu o número de telèfon.", + "duplicates_none": "No s’han trobat possibles duplicats.", + "pick_title": "Combina {name} amb…", + "pick_search": "Cerca contactes", + "pick_suggested": "Possibles duplicats", + "pick_all": "Tots els contactes", + "pick_none": "No hi ha cap altre contacte per combinar.", + "preview_title": "Combina contactes ({count})", + "preview_hint": "Aquests contactes esdevenen una sola fitxa amb tot el que hi ha a sota.", + "preview_deleted": "Incorporats i després suprimits: {names}", + "groups": "Grups", + "merge": "Combina", + "merging": "S’està combinant…", + "merged": "Contactes combinats", + "merge_failed": "No s’han pogut combinar els contactes.", + "merge_not_possible": "Aquests contactes no es poden combinar: són en comptes diferents o en una llibreta d’adreces que no podeu canviar.", + "undo": "Desfés", + "undone": "Combinació desfeta", + "undo_failed": "No s’ha pogut desfer del tot la combinació." + }, "find_time": { "suggest": "Suggereix horaris", "searching": "S’estan cercant horaris lliures", diff --git a/locales/cs/common.json b/locales/cs/common.json index 43e02f2bf..dc6ea865e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -5766,6 +5766,88 @@ "title": "Volno/obsazeno", "description": "Kolegové ve skupině vidí, kdy jste obsazeni, ne jaké máte události. Používá to funkce Navrhnout časy." }, + "ux_pim_cimport": { + "file_hint": "Klikněte a vyberte soubor vCard nebo CSV", + "file_types": "Soubory .vcf nebo .csv, například z Outlooku nebo Google Kontaktů", + "parse_error": "Soubor se nepodařilo přečíst", + "columns_title": "Přiřadit sloupce", + "columns_hint": "Vyberte, kam patří každý sloupec souboru {file}. Sloupce s volbou „Neimportovat“ se vynechají.", + "column": "Sloupec", + "sample": "Příklad", + "field": "Pole kontaktu", + "skip": "Neimportovat", + "no_columns": "Vyberte pole aspoň pro jeden sloupec.", + "continue": "Pokračovat", + "back": "Zpět", + "import_into": "Importovat do", + "group_name": "Jméno", + "group_email": "E-mail", + "group_phone": "Telefon", + "group_org": "Organizace", + "group_adr_any": "Adresa", + "group_adr_home": "Adresa domů", + "group_adr_work": "Pracovní adresa", + "group_adr_other": "Jiná adresa", + "group_more": "Další", + "address_field": "{group}: {part}", + "t_name_full": "Celé jméno", + "t_name_given": "Křestní jméno", + "t_name_middle": "Prostřední jméno", + "t_name_surname": "Příjmení", + "t_name_prefix": "Titul (Dr., paní)", + "t_name_suffix": "Přípona (ml.)", + "t_nickname": "Přezdívka", + "t_email": "E-mail", + "t_email_home": "E-mail (domů)", + "t_email_work": "E-mail (práce)", + "t_phone": "Telefon", + "t_phone_mobile": "Mobil", + "t_phone_home": "Telefon (domů)", + "t_phone_work": "Telefon (práce)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (domů)", + "t_phone_fax_work": "Fax (práce)", + "t_phone_pager": "Pager", + "t_org_name": "Firma", + "t_org_unit": "Oddělení", + "t_org_title": "Pracovní pozice", + "t_street": "Ulice", + "t_pobox": "P. O. Box", + "t_locality": "Město", + "t_region": "Kraj nebo region", + "t_postcode": "PSČ", + "t_country": "Země", + "t_web": "Web", + "t_birthday": "Narozeniny", + "t_anniversary": "Výročí", + "t_notes": "Poznámky", + "t_categories": "Kategorie", + "t_type": "Typ hodnoty (Domů, Práce…)", + "merge_with": "Sloučit s…", + "merge_selected": "Sloučit…", + "find_duplicates": "Najít duplicity", + "possible_duplicates": "Možné duplicity ({count})", + "duplicates_title": "Možné duplicity", + "duplicates_hint": "Kontakty se stejným jménem, e-mailovou adresou nebo telefonním číslem.", + "duplicates_none": "Žádné možné duplicity nenalezeny.", + "pick_title": "Sloučit {name} s…", + "pick_search": "Hledat kontakty", + "pick_suggested": "Možné duplicity", + "pick_all": "Všechny kontakty", + "pick_none": "Žádné další kontakty ke sloučení.", + "preview_title": "Sloučit kontakty ({count})", + "preview_hint": "Z těchto kontaktů vznikne jedna karta se vším níže.", + "preview_deleted": "Převzato a potom smazáno: {names}", + "groups": "Skupiny", + "merge": "Sloučit", + "merging": "Slučování…", + "merged": "Kontakty sloučeny", + "merge_failed": "Kontakty se nepodařilo sloučit.", + "merge_not_possible": "Tyto kontakty nelze sloučit: jsou v různých účtech nebo v adresáři, který nemůžete měnit.", + "undo": "Zpět", + "undone": "Sloučení vráceno", + "undo_failed": "Sloučení se nepodařilo úplně vrátit." + }, "find_time": { "suggest": "Navrhnout časy", "searching": "Hledají se volné časy", diff --git a/locales/da/common.json b/locales/da/common.json index 2abd85862..2f16c3e51 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -5175,6 +5175,88 @@ "title": "Ledig/optaget", "description": "Kolleger i gruppen ser, hvornår du er optaget, ikke hvad dine begivenheder er. Foreslå tidspunkter bruger det." }, + "ux_pim_cimport": { + "file_hint": "Klik for at vælge en vCard- eller CSV-fil", + "file_types": ".vcf- eller .csv-filer, for eksempel fra Outlook eller Google Kontakter", + "parse_error": "Filen kunne ikke læses", + "columns_title": "Tilknyt kolonnerne", + "columns_hint": "Vælg, hvor hver kolonne i {file} skal hen. Kolonner med “Importér ikke” springes over.", + "column": "Kolonne", + "sample": "Eksempel", + "field": "Kontaktfelt", + "skip": "Importér ikke", + "no_columns": "Vælg et felt til mindst én kolonne.", + "continue": "Fortsæt", + "back": "Tilbage", + "import_into": "Importér til", + "group_name": "Navn", + "group_email": "E-mail", + "group_phone": "Telefon", + "group_org": "Organisation", + "group_adr_any": "Adresse", + "group_adr_home": "Privatadresse", + "group_adr_work": "Arbejdsadresse", + "group_adr_other": "Anden adresse", + "group_more": "Mere", + "address_field": "{group}: {part}", + "t_name_full": "Fulde navn", + "t_name_given": "Fornavn", + "t_name_middle": "Mellemnavn", + "t_name_surname": "Efternavn", + "t_name_prefix": "Titel (dr., fru)", + "t_name_suffix": "Suffiks (jr.)", + "t_nickname": "Kaldenavn", + "t_email": "E-mail", + "t_email_home": "E-mail (privat)", + "t_email_work": "E-mail (arbejde)", + "t_phone": "Telefon", + "t_phone_mobile": "Mobil", + "t_phone_home": "Telefon (privat)", + "t_phone_work": "Telefon (arbejde)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (privat)", + "t_phone_fax_work": "Fax (arbejde)", + "t_phone_pager": "Personsøger", + "t_org_name": "Firma", + "t_org_unit": "Afdeling", + "t_org_title": "Stilling", + "t_street": "Gade", + "t_pobox": "Postboks", + "t_locality": "By", + "t_region": "Delstat eller region", + "t_postcode": "Postnummer", + "t_country": "Land", + "t_web": "Websted", + "t_birthday": "Fødselsdag", + "t_anniversary": "Mærkedag", + "t_notes": "Noter", + "t_categories": "Kategorier", + "t_type": "Værdiens type (Privat, Arbejde…)", + "merge_with": "Flet med…", + "merge_selected": "Flet…", + "find_duplicates": "Find dubletter", + "possible_duplicates": "Mulige dubletter ({count})", + "duplicates_title": "Mulige dubletter", + "duplicates_hint": "Kontakter med samme navn, e-mailadresse eller telefonnummer.", + "duplicates_none": "Ingen mulige dubletter fundet.", + "pick_title": "Flet {name} med…", + "pick_search": "Søg i kontakter", + "pick_suggested": "Mulige dubletter", + "pick_all": "Alle kontakter", + "pick_none": "Ingen andre kontakter at flette med.", + "preview_title": "Flet kontakter ({count})", + "preview_hint": "Disse kontakter bliver til ét kort med alt herunder.", + "preview_deleted": "Overtaget og derefter slettet: {names}", + "groups": "Grupper", + "merge": "Flet", + "merging": "Fletter…", + "merged": "Kontakter flettet", + "merge_failed": "Kontakterne kunne ikke flettes.", + "merge_not_possible": "Disse kontakter kan ikke flettes: De er i forskellige konti eller i en adressebog, du ikke må ændre.", + "undo": "Fortryd", + "undone": "Fletning fortrudt", + "undo_failed": "Fletningen kunne ikke fortrydes helt." + }, "find_time": { "suggest": "Foreslå tidspunkter", "searching": "Leder efter ledige tidspunkter", diff --git a/locales/de/common.json b/locales/de/common.json index a60cf885a..435212075 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -5766,6 +5766,88 @@ "title": "Frei/Gebucht", "description": "Kolleginnen und Kollegen in der Gruppe sehen, wann Sie belegt sind, aber nicht, welche Termine Sie haben. „Zeiten vorschlagen“ nutzt das." }, + "ux_pim_cimport": { + "file_hint": "Klicken Sie, um eine vCard- oder CSV-Datei auszuwählen", + "file_types": ".vcf- oder .csv-Dateien, zum Beispiel aus Outlook oder Google Kontakte", + "parse_error": "Die Datei konnte nicht gelesen werden", + "columns_title": "Spalten zuordnen", + "columns_hint": "Wählen Sie, wohin jede Spalte von {file} gehört. Spalten mit „Nicht importieren“ werden ausgelassen.", + "column": "Spalte", + "sample": "Beispiel", + "field": "Kontaktfeld", + "skip": "Nicht importieren", + "no_columns": "Wählen Sie für mindestens eine Spalte ein Kontaktfeld.", + "continue": "Weiter", + "back": "Zurück", + "import_into": "Importieren in", + "group_name": "Name", + "group_email": "E-Mail", + "group_phone": "Telefon", + "group_org": "Organisation", + "group_adr_any": "Adresse", + "group_adr_home": "Privatadresse", + "group_adr_work": "Geschäftsadresse", + "group_adr_other": "Weitere Adresse", + "group_more": "Weitere", + "address_field": "{group}: {part}", + "t_name_full": "Vollständiger Name", + "t_name_given": "Vorname", + "t_name_middle": "Weitere Vornamen", + "t_name_surname": "Nachname", + "t_name_prefix": "Anrede/Titel (Dr., Frau)", + "t_name_suffix": "Namenszusatz (jun.)", + "t_nickname": "Spitzname", + "t_email": "E-Mail", + "t_email_home": "E-Mail (privat)", + "t_email_work": "E-Mail (geschäftlich)", + "t_phone": "Telefon", + "t_phone_mobile": "Mobil", + "t_phone_home": "Telefon (privat)", + "t_phone_work": "Telefon (geschäftlich)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (privat)", + "t_phone_fax_work": "Fax (geschäftlich)", + "t_phone_pager": "Pager", + "t_org_name": "Firma", + "t_org_unit": "Abteilung", + "t_org_title": "Position", + "t_street": "Straße", + "t_pobox": "Postfach", + "t_locality": "Ort", + "t_region": "Bundesland oder Region", + "t_postcode": "Postleitzahl", + "t_country": "Land", + "t_web": "Webseite", + "t_birthday": "Geburtstag", + "t_anniversary": "Jahrestag", + "t_notes": "Notizen", + "t_categories": "Kategorien", + "t_type": "Art des Werts (Privat, Geschäftlich…)", + "merge_with": "Zusammenführen mit…", + "merge_selected": "Zusammenführen…", + "find_duplicates": "Duplikate finden", + "possible_duplicates": "Mögliche Duplikate ({count})", + "duplicates_title": "Mögliche Duplikate", + "duplicates_hint": "Kontakte mit gleichem Namen, gleicher E-Mail-Adresse oder Telefonnummer.", + "duplicates_none": "Keine möglichen Duplikate gefunden.", + "pick_title": "{name} zusammenführen mit…", + "pick_search": "Kontakte suchen", + "pick_suggested": "Mögliche Duplikate", + "pick_all": "Alle Kontakte", + "pick_none": "Keine anderen Kontakte zum Zusammenführen.", + "preview_title": "Kontakte zusammenführen ({count})", + "preview_hint": "Diese Kontakte werden zu einer Karte mit allem, was unten steht.", + "preview_deleted": "Übernommen und danach gelöscht: {names}", + "groups": "Gruppen", + "merge": "Zusammenführen", + "merging": "Wird zusammengeführt…", + "merged": "Kontakte zusammengeführt", + "merge_failed": "Die Kontakte konnten nicht zusammengeführt werden.", + "merge_not_possible": "Diese Kontakte lassen sich nicht zusammenführen, weil sie zu verschiedenen Konten gehören oder in einem Adressbuch liegen, das Sie nicht ändern dürfen.", + "undo": "Rückgängig", + "undone": "Zusammenführen rückgängig gemacht", + "undo_failed": "Das Zusammenführen konnte nicht ganz rückgängig gemacht werden." + }, "find_time": { "suggest": "Zeiten vorschlagen", "searching": "Freie Zeiten werden gesucht", diff --git a/locales/en/common.json b/locales/en/common.json index 4ed3c5a75..b8a6dd742 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -5175,6 +5175,88 @@ "title": "Free/busy", "description": "Colleagues in the group see when you’re busy, not what your events are. Suggest times uses it." }, + "ux_pim_cimport": { + "file_hint": "Click to select a vCard or CSV file", + "file_types": ".vcf or .csv files, for example from Outlook or Google Contacts", + "parse_error": "Couldn't read the file", + "columns_title": "Match the columns", + "columns_hint": "Choose where each column of {file} goes. Columns set to “Don't import” are left out.", + "column": "Column", + "sample": "Example", + "field": "Contact field", + "skip": "Don't import", + "no_columns": "Choose a contact field for at least one column.", + "continue": "Continue", + "back": "Back", + "import_into": "Import into", + "group_name": "Name", + "group_email": "Email", + "group_phone": "Phone", + "group_org": "Organization", + "group_adr_any": "Address", + "group_adr_home": "Home address", + "group_adr_work": "Work address", + "group_adr_other": "Other address", + "group_more": "More", + "address_field": "{group}: {part}", + "t_name_full": "Full name", + "t_name_given": "First name", + "t_name_middle": "Middle name", + "t_name_surname": "Last name", + "t_name_prefix": "Title (Dr., Ms.)", + "t_name_suffix": "Suffix (Jr.)", + "t_nickname": "Nickname", + "t_email": "Email", + "t_email_home": "Email (home)", + "t_email_work": "Email (work)", + "t_phone": "Phone", + "t_phone_mobile": "Mobile", + "t_phone_home": "Phone (home)", + "t_phone_work": "Phone (work)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (home)", + "t_phone_fax_work": "Fax (work)", + "t_phone_pager": "Pager", + "t_org_name": "Company", + "t_org_unit": "Department", + "t_org_title": "Job title", + "t_street": "Street", + "t_pobox": "PO box", + "t_locality": "City", + "t_region": "State or region", + "t_postcode": "Postal code", + "t_country": "Country", + "t_web": "Website", + "t_birthday": "Birthday", + "t_anniversary": "Anniversary", + "t_notes": "Notes", + "t_categories": "Categories", + "t_type": "Type of the value (Home, Work…)", + "merge_with": "Merge with…", + "merge_selected": "Merge…", + "find_duplicates": "Find duplicates", + "possible_duplicates": "Possible duplicates ({count})", + "duplicates_title": "Possible duplicates", + "duplicates_hint": "Contacts with the same name, email address or phone number.", + "duplicates_none": "No possible duplicates found.", + "pick_title": "Merge {name} with…", + "pick_search": "Search contacts", + "pick_suggested": "Possible duplicates", + "pick_all": "All contacts", + "pick_none": "No other contacts to merge with.", + "preview_title": "Merge contacts ({count})", + "preview_hint": "These contacts become one card with everything below.", + "preview_deleted": "Merged in, then deleted: {names}", + "groups": "Groups", + "merge": "Merge", + "merging": "Merging…", + "merged": "Contacts merged", + "merge_failed": "The contacts could not be merged.", + "merge_not_possible": "These contacts can't be merged: they are in different accounts or in an address book you can't change.", + "undo": "Undo", + "undone": "Merge undone", + "undo_failed": "The merge could not be fully undone." + }, "find_time": { "suggest": "Suggest times", "searching": "Looking for free times", diff --git a/locales/es/common.json b/locales/es/common.json index b1392c182..6d5cf80cb 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -5766,6 +5766,88 @@ "title": "Disponibilidad", "description": "Tus compañeros del grupo ven cuándo estás ocupado, no qué eventos tienes. «Sugerir horarios» lo usa." }, + "ux_pim_cimport": { + "file_hint": "Haz clic para seleccionar un archivo vCard o CSV", + "file_types": "Archivos .vcf o .csv, por ejemplo de Outlook o Google Contactos", + "parse_error": "No se pudo leer el archivo", + "columns_title": "Asignar las columnas", + "columns_hint": "Elige a dónde va cada columna de {file}. Las columnas con «No importar» se omiten.", + "column": "Columna", + "sample": "Ejemplo", + "field": "Campo del contacto", + "skip": "No importar", + "no_columns": "Elige un campo para al menos una columna.", + "continue": "Continuar", + "back": "Atrás", + "import_into": "Importar en", + "group_name": "Nombre", + "group_email": "Correo", + "group_phone": "Teléfono", + "group_org": "Organización", + "group_adr_any": "Dirección", + "group_adr_home": "Dirección personal", + "group_adr_work": "Dirección del trabajo", + "group_adr_other": "Otra dirección", + "group_more": "Más", + "address_field": "{group}: {part}", + "t_name_full": "Nombre completo", + "t_name_given": "Nombre", + "t_name_middle": "Segundo nombre", + "t_name_surname": "Apellidos", + "t_name_prefix": "Tratamiento (Dr., Sra.)", + "t_name_suffix": "Sufijo (Jr.)", + "t_nickname": "Apodo", + "t_email": "Correo", + "t_email_home": "Correo (personal)", + "t_email_work": "Correo (trabajo)", + "t_phone": "Teléfono", + "t_phone_mobile": "Móvil", + "t_phone_home": "Teléfono (personal)", + "t_phone_work": "Teléfono (trabajo)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (personal)", + "t_phone_fax_work": "Fax (trabajo)", + "t_phone_pager": "Buscapersonas", + "t_org_name": "Empresa", + "t_org_unit": "Departamento", + "t_org_title": "Cargo", + "t_street": "Calle", + "t_pobox": "Apartado postal", + "t_locality": "Ciudad", + "t_region": "Estado o región", + "t_postcode": "Código postal", + "t_country": "País", + "t_web": "Sitio web", + "t_birthday": "Cumpleaños", + "t_anniversary": "Aniversario", + "t_notes": "Notas", + "t_categories": "Categorías", + "t_type": "Tipo del valor (Casa, Trabajo…)", + "merge_with": "Combinar con…", + "merge_selected": "Combinar…", + "find_duplicates": "Buscar duplicados", + "possible_duplicates": "Posibles duplicados ({count})", + "duplicates_title": "Posibles duplicados", + "duplicates_hint": "Contactos con el mismo nombre, correo o número de teléfono.", + "duplicates_none": "No se encontraron posibles duplicados.", + "pick_title": "Combinar {name} con…", + "pick_search": "Buscar contactos", + "pick_suggested": "Posibles duplicados", + "pick_all": "Todos los contactos", + "pick_none": "No hay otros contactos con los que combinar.", + "preview_title": "Combinar contactos ({count})", + "preview_hint": "Estos contactos pasan a ser una sola ficha con todo lo de abajo.", + "preview_deleted": "Incorporados y luego eliminados: {names}", + "groups": "Grupos", + "merge": "Combinar", + "merging": "Combinando…", + "merged": "Contactos combinados", + "merge_failed": "No se pudieron combinar los contactos.", + "merge_not_possible": "Estos contactos no se pueden combinar: están en cuentas distintas o en una libreta de direcciones que no puedes cambiar.", + "undo": "Deshacer", + "undone": "Combinación deshecha", + "undo_failed": "No se pudo deshacer del todo la combinación." + }, "find_time": { "suggest": "Sugerir horarios", "searching": "Buscando horarios libres", diff --git a/locales/et/common.json b/locales/et/common.json index f0d34fa05..c0c25b494 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -5175,6 +5175,88 @@ "title": "Vaba/hõivatud", "description": "Grupi kolleegid näevad, millal oled hõivatud, aga mitte sinu sündmusi. Seda kasutab „Soovita aegu“." }, + "ux_pim_cimport": { + "file_hint": "Klõpsake, et valida vCard- või CSV-fail", + "file_types": ".vcf- või .csv-failid, näiteks Outlookist või Google Contactsist", + "parse_error": "Faili ei õnnestunud lugeda", + "columns_title": "Seosta veerud", + "columns_hint": "Valige, kuhu läheb faili {file} iga veerg. Veerud valikuga „Ära impordi” jäetakse välja.", + "column": "Veerg", + "sample": "Näide", + "field": "Kontakti väli", + "skip": "Ära impordi", + "no_columns": "Valige väli vähemalt ühele veerule.", + "continue": "Jätka", + "back": "Tagasi", + "import_into": "Impordi kausta", + "group_name": "Nimi", + "group_email": "E-post", + "group_phone": "Telefon", + "group_org": "Organisatsioon", + "group_adr_any": "Aadress", + "group_adr_home": "Koduaadress", + "group_adr_work": "Tööaadress", + "group_adr_other": "Muu aadress", + "group_more": "Veel", + "address_field": "{group}: {part}", + "t_name_full": "Täisnimi", + "t_name_given": "Eesnimi", + "t_name_middle": "Teine eesnimi", + "t_name_surname": "Perekonnanimi", + "t_name_prefix": "Tiitel (dr, pr)", + "t_name_suffix": "Järelliide (jr)", + "t_nickname": "Hüüdnimi", + "t_email": "E-post", + "t_email_home": "E-post (kodu)", + "t_email_work": "E-post (töö)", + "t_phone": "Telefon", + "t_phone_mobile": "Mobiil", + "t_phone_home": "Telefon (kodu)", + "t_phone_work": "Telefon (töö)", + "t_phone_fax": "Faks", + "t_phone_fax_home": "Faks (kodu)", + "t_phone_fax_work": "Faks (töö)", + "t_phone_pager": "Piipar", + "t_org_name": "Ettevõte", + "t_org_unit": "Osakond", + "t_org_title": "Ametikoht", + "t_street": "Tänav", + "t_pobox": "Postkast", + "t_locality": "Linn", + "t_region": "Maakond või piirkond", + "t_postcode": "Postiindeks", + "t_country": "Riik", + "t_web": "Veebileht", + "t_birthday": "Sünnipäev", + "t_anniversary": "Tähtpäev", + "t_notes": "Märkmed", + "t_categories": "Kategooriad", + "t_type": "Väärtuse liik (Kodu, Töö…)", + "merge_with": "Ühenda kontaktiga…", + "merge_selected": "Ühenda…", + "find_duplicates": "Leia duplikaadid", + "possible_duplicates": "Võimalikud duplikaadid ({count})", + "duplicates_title": "Võimalikud duplikaadid", + "duplicates_hint": "Sama nime, e-posti aadressi või telefoninumbriga kontaktid.", + "duplicates_none": "Võimalikke duplikaate ei leitud.", + "pick_title": "Ühenda {name} kontaktiga…", + "pick_search": "Otsi kontakte", + "pick_suggested": "Võimalikud duplikaadid", + "pick_all": "Kõik kontaktid", + "pick_none": "Teisi kontakte ühendamiseks pole.", + "preview_title": "Ühenda kontaktid ({count})", + "preview_hint": "Neist kontaktidest saab üks kaart kõigega, mis on allpool.", + "preview_deleted": "Üle võetud ja seejärel kustutatud: {names}", + "groups": "Grupid", + "merge": "Ühenda", + "merging": "Ühendamine…", + "merged": "Kontaktid ühendatud", + "merge_failed": "Kontakte ei õnnestunud ühendada.", + "merge_not_possible": "Neid kontakte ei saa ühendada: need on eri kontodel või aadressiraamatus, mida te ei saa muuta.", + "undo": "Võta tagasi", + "undone": "Ühendamine võetud tagasi", + "undo_failed": "Ühendamist ei õnnestunud täielikult tagasi võtta." + }, "find_time": { "suggest": "Soovita aegu", "searching": "Vabu aegu otsitakse", diff --git a/locales/fa/common.json b/locales/fa/common.json index 160bac9dd..4688b2036 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -5175,6 +5175,88 @@ "title": "آزاد/مشغول", "description": "همکاران گروه می‌بینند چه زمانی مشغول هستید، نه اینکه رویدادهایتان چیست. «پیشنهاد زمان» از آن استفاده می‌کند." }, + "ux_pim_cimport": { + "file_hint": "برای انتخاب فایل vCard یا CSV کلیک کنید", + "file_types": "فایل‌های ‎.vcf یا ‎.csv، برای نمونه از Outlook یا مخاطبین Google", + "parse_error": "خواندن فایل ممکن نشد", + "columns_title": "تطبیق ستون‌ها", + "columns_hint": "انتخاب کنید هر ستون {file} به کجا برود. ستون‌هایی که روی «وارد نشود» هستند کنار گذاشته می‌شوند.", + "column": "ستون", + "sample": "نمونه", + "field": "فیلد مخاطب", + "skip": "وارد نشود", + "no_columns": "برای دست‌کم یک ستون فیلدی انتخاب کنید.", + "continue": "ادامه", + "back": "بازگشت", + "import_into": "وارد کردن به", + "group_name": "نام", + "group_email": "ایمیل", + "group_phone": "تلفن", + "group_org": "سازمان", + "group_adr_any": "نشانی", + "group_adr_home": "نشانی منزل", + "group_adr_work": "نشانی محل کار", + "group_adr_other": "نشانی دیگر", + "group_more": "بیشتر", + "address_field": "{group}: {part}", + "t_name_full": "نام کامل", + "t_name_given": "نام", + "t_name_middle": "نام میانی", + "t_name_surname": "نام خانوادگی", + "t_name_prefix": "عنوان (دکتر، خانم)", + "t_name_suffix": "پسوند (جونیور)", + "t_nickname": "نام مستعار", + "t_email": "ایمیل", + "t_email_home": "ایمیل (منزل)", + "t_email_work": "ایمیل (کار)", + "t_phone": "تلفن", + "t_phone_mobile": "همراه", + "t_phone_home": "تلفن (منزل)", + "t_phone_work": "تلفن (کار)", + "t_phone_fax": "فکس", + "t_phone_fax_home": "فکس (منزل)", + "t_phone_fax_work": "فکس (کار)", + "t_phone_pager": "پیجر", + "t_org_name": "شرکت", + "t_org_unit": "بخش", + "t_org_title": "سمت شغلی", + "t_street": "خیابان", + "t_pobox": "صندوق پستی", + "t_locality": "شهر", + "t_region": "استان یا منطقه", + "t_postcode": "کد پستی", + "t_country": "کشور", + "t_web": "وب‌سایت", + "t_birthday": "تاریخ تولد", + "t_anniversary": "سالگرد", + "t_notes": "یادداشت‌ها", + "t_categories": "دسته‌ها", + "t_type": "نوع مقدار (منزل، کار…)", + "merge_with": "ادغام با…", + "merge_selected": "ادغام…", + "find_duplicates": "یافتن موارد تکراری", + "possible_duplicates": "موارد احتمالاً تکراری ({count})", + "duplicates_title": "موارد احتمالاً تکراری", + "duplicates_hint": "مخاطبانی با نام، نشانی ایمیل یا شماره تلفن یکسان.", + "duplicates_none": "مورد احتمالاً تکراری پیدا نشد.", + "pick_title": "ادغام {name} با…", + "pick_search": "جستجوی مخاطبان", + "pick_suggested": "موارد احتمالاً تکراری", + "pick_all": "همه مخاطبان", + "pick_none": "مخاطب دیگری برای ادغام نیست.", + "preview_title": "ادغام مخاطبان ({count})", + "preview_hint": "این مخاطبان به یک کارت با همه موارد زیر تبدیل می‌شوند.", + "preview_deleted": "ادغام و سپس حذف شد: {names}", + "groups": "گروه‌ها", + "merge": "ادغام", + "merging": "در حال ادغام…", + "merged": "مخاطبان ادغام شدند", + "merge_failed": "ادغام مخاطبان ممکن نشد.", + "merge_not_possible": "این مخاطبان را نمی‌توان ادغام کرد: در حساب‌های متفاوت یا در دفترچه نشانی‌ای هستند که نمی‌توانید تغییرش دهید.", + "undo": "واگرد", + "undone": "ادغام واگرد شد", + "undo_failed": "ادغام به‌طور کامل واگرد نشد." + }, "find_time": { "suggest": "پیشنهاد زمان", "searching": "در حال جست‌وجوی زمان‌های آزاد", diff --git a/locales/fr/common.json b/locales/fr/common.json index 6ecf94547..53806d8ee 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -5766,6 +5766,88 @@ "title": "Disponibilités", "description": "Les collègues du groupe voient quand vous êtes occupé, pas le contenu de vos événements. « Suggérer des créneaux » s’en sert." }, + "ux_pim_cimport": { + "file_hint": "Cliquez pour sélectionner un fichier vCard ou CSV", + "file_types": "Fichiers .vcf ou .csv, par exemple d’Outlook ou de Google Contacts", + "parse_error": "Impossible de lire le fichier", + "columns_title": "Associer les colonnes", + "columns_hint": "Choisissez où va chaque colonne de {file}. Les colonnes réglées sur « Ne pas importer » sont ignorées.", + "column": "Colonne", + "sample": "Exemple", + "field": "Champ du contact", + "skip": "Ne pas importer", + "no_columns": "Choisissez un champ pour au moins une colonne.", + "continue": "Continuer", + "back": "Retour", + "import_into": "Importer dans", + "group_name": "Nom", + "group_email": "E-mail", + "group_phone": "Téléphone", + "group_org": "Organisation", + "group_adr_any": "Adresse", + "group_adr_home": "Adresse personnelle", + "group_adr_work": "Adresse professionnelle", + "group_adr_other": "Autre adresse", + "group_more": "Plus", + "address_field": "{group} : {part}", + "t_name_full": "Nom complet", + "t_name_given": "Prénom", + "t_name_middle": "Deuxième prénom", + "t_name_surname": "Nom de famille", + "t_name_prefix": "Civilité (Dr, Mme)", + "t_name_suffix": "Suffixe (Jr)", + "t_nickname": "Surnom", + "t_email": "E-mail", + "t_email_home": "E-mail (perso)", + "t_email_work": "E-mail (travail)", + "t_phone": "Téléphone", + "t_phone_mobile": "Mobile", + "t_phone_home": "Téléphone (perso)", + "t_phone_work": "Téléphone (travail)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (perso)", + "t_phone_fax_work": "Fax (travail)", + "t_phone_pager": "Bipeur", + "t_org_name": "Société", + "t_org_unit": "Service", + "t_org_title": "Poste", + "t_street": "Rue", + "t_pobox": "Boîte postale", + "t_locality": "Ville", + "t_region": "État ou région", + "t_postcode": "Code postal", + "t_country": "Pays", + "t_web": "Site web", + "t_birthday": "Anniversaire", + "t_anniversary": "Anniversaire de mariage", + "t_notes": "Notes", + "t_categories": "Catégories", + "t_type": "Type de la valeur (Domicile, Travail…)", + "merge_with": "Fusionner avec…", + "merge_selected": "Fusionner…", + "find_duplicates": "Trouver les doublons", + "possible_duplicates": "Doublons possibles ({count})", + "duplicates_title": "Doublons possibles", + "duplicates_hint": "Contacts avec le même nom, la même adresse e-mail ou le même numéro.", + "duplicates_none": "Aucun doublon possible trouvé.", + "pick_title": "Fusionner {name} avec…", + "pick_search": "Rechercher des contacts", + "pick_suggested": "Doublons possibles", + "pick_all": "Tous les contacts", + "pick_none": "Aucun autre contact avec lequel fusionner.", + "preview_title": "Fusionner les contacts ({count})", + "preview_hint": "Ces contacts deviennent une seule fiche avec tout ce qui suit.", + "preview_deleted": "Repris puis supprimés : {names}", + "groups": "Groupes", + "merge": "Fusionner", + "merging": "Fusion…", + "merged": "Contacts fusionnés", + "merge_failed": "Impossible de fusionner les contacts.", + "merge_not_possible": "Impossible de fusionner ces contacts : ils sont dans des comptes différents ou dans un carnet d’adresses que vous ne pouvez pas modifier.", + "undo": "Annuler", + "undone": "Fusion annulée", + "undo_failed": "La fusion n’a pas pu être entièrement annulée." + }, "find_time": { "suggest": "Suggérer des créneaux", "searching": "Recherche de créneaux libres", diff --git a/locales/he/common.json b/locales/he/common.json index 9fe2df9ad..633a96218 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -5585,6 +5585,88 @@ "title": "פנוי/תפוס", "description": "עמיתים בקבוצה רואים מתי אתה תפוס, לא מה האירועים שלך. \"הצע זמנים\" משתמש בזה." }, + "ux_pim_cimport": { + "file_hint": "לחצו כדי לבחור קובץ vCard או CSV", + "file_types": "קובצי ‎.vcf או ‎.csv, למשל מ-Outlook או מאנשי הקשר של Google", + "parse_error": "לא ניתן לקרוא את הקובץ", + "columns_title": "התאמת עמודות", + "columns_hint": "בחרו לאן הולכת כל עמודה ב-{file}. עמודות שהוגדרו „לא לייבא” יושמטו.", + "column": "עמודה", + "sample": "דוגמה", + "field": "שדה איש קשר", + "skip": "לא לייבא", + "no_columns": "בחרו שדה לעמודה אחת לפחות.", + "continue": "המשך", + "back": "חזרה", + "import_into": "ייבוא אל", + "group_name": "שם", + "group_email": "אימייל", + "group_phone": "טלפון", + "group_org": "ארגון", + "group_adr_any": "כתובת", + "group_adr_home": "כתובת בית", + "group_adr_work": "כתובת עבודה", + "group_adr_other": "כתובת אחרת", + "group_more": "עוד", + "address_field": "{group}: {part}", + "t_name_full": "שם מלא", + "t_name_given": "שם פרטי", + "t_name_middle": "שם אמצעי", + "t_name_surname": "שם משפחה", + "t_name_prefix": "תואר (ד״ר, גב׳)", + "t_name_suffix": "סיומת (ג׳וניור)", + "t_nickname": "כינוי", + "t_email": "אימייל", + "t_email_home": "אימייל (בית)", + "t_email_work": "אימייל (עבודה)", + "t_phone": "טלפון", + "t_phone_mobile": "נייד", + "t_phone_home": "טלפון (בית)", + "t_phone_work": "טלפון (עבודה)", + "t_phone_fax": "פקס", + "t_phone_fax_home": "פקס (בית)", + "t_phone_fax_work": "פקס (עבודה)", + "t_phone_pager": "זימונית", + "t_org_name": "חברה", + "t_org_unit": "מחלקה", + "t_org_title": "תפקיד", + "t_street": "רחוב", + "t_pobox": "תא דואר", + "t_locality": "עיר", + "t_region": "מחוז או אזור", + "t_postcode": "מיקוד", + "t_country": "מדינה", + "t_web": "אתר אינטרנט", + "t_birthday": "יום הולדת", + "t_anniversary": "יום נישואין", + "t_notes": "הערות", + "t_categories": "קטגוריות", + "t_type": "סוג הערך (בית, עבודה…)", + "merge_with": "מיזוג עם…", + "merge_selected": "מיזוג…", + "find_duplicates": "חיפוש כפילויות", + "possible_duplicates": "כפילויות אפשריות ({count})", + "duplicates_title": "כפילויות אפשריות", + "duplicates_hint": "אנשי קשר עם אותו שם, אותה כתובת אימייל או אותו מספר טלפון.", + "duplicates_none": "לא נמצאו כפילויות אפשריות.", + "pick_title": "מיזוג {name} עם…", + "pick_search": "חיפוש אנשי קשר", + "pick_suggested": "כפילויות אפשריות", + "pick_all": "כל אנשי הקשר", + "pick_none": "אין אנשי קשר אחרים למיזוג.", + "preview_title": "מיזוג אנשי קשר ({count})", + "preview_hint": "אנשי הקשר האלה יהפכו לכרטיס אחד עם כל מה שמופיע למטה.", + "preview_deleted": "מוזגו ואז נמחקו: {names}", + "groups": "קבוצות", + "merge": "מיזוג", + "merging": "ממזג…", + "merged": "אנשי הקשר מוזגו", + "merge_failed": "לא ניתן למזג את אנשי הקשר.", + "merge_not_possible": "לא ניתן למזג את אנשי הקשר האלה: הם בחשבונות שונים או בפנקס כתובות שאין לכם הרשאה לשנות.", + "undo": "ביטול", + "undone": "המיזוג בוטל", + "undo_failed": "לא ניתן היה לבטל את המיזוג במלואו." + }, "find_time": { "suggest": "הצע זמנים", "searching": "מחפש זמנים פנויים", diff --git a/locales/hu/common.json b/locales/hu/common.json index 654b962dc..1096602bc 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -5175,6 +5175,88 @@ "title": "Szabad/foglalt", "description": "A csoport tagjai látják, mikor foglalt, de az eseményeit nem. Az Időpontok javaslata ezt használja." }, + "ux_pim_cimport": { + "file_hint": "Kattintson egy vCard- vagy CSV-fájl kiválasztásához", + "file_types": ".vcf- vagy .csv-fájlok, például Outlookból vagy a Google Névjegyekből", + "parse_error": "A fájl nem olvasható", + "columns_title": "Oszlopok hozzárendelése", + "columns_hint": "Válassza ki, hová kerüljön a(z) {file} egyes oszlopa. A „Ne importálja” beállítású oszlopok kimaradnak.", + "column": "Oszlop", + "sample": "Példa", + "field": "Névjegymező", + "skip": "Ne importálja", + "no_columns": "Legalább egy oszlophoz válasszon mezőt.", + "continue": "Tovább", + "back": "Vissza", + "import_into": "Importálás ide", + "group_name": "Név", + "group_email": "E-mail", + "group_phone": "Telefon", + "group_org": "Szervezet", + "group_adr_any": "Cím", + "group_adr_home": "Otthoni cím", + "group_adr_work": "Munkahelyi cím", + "group_adr_other": "Egyéb cím", + "group_more": "Egyebek", + "address_field": "{group}: {part}", + "t_name_full": "Teljes név", + "t_name_given": "Utónév", + "t_name_middle": "Második utónév", + "t_name_surname": "Vezetéknév", + "t_name_prefix": "Előtag (dr., Mrs.)", + "t_name_suffix": "Utótag (ifj.)", + "t_nickname": "Becenév", + "t_email": "E-mail", + "t_email_home": "E-mail (otthoni)", + "t_email_work": "E-mail (munkahelyi)", + "t_phone": "Telefon", + "t_phone_mobile": "Mobil", + "t_phone_home": "Telefon (otthoni)", + "t_phone_work": "Telefon (munkahelyi)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (otthoni)", + "t_phone_fax_work": "Fax (munkahelyi)", + "t_phone_pager": "Személyhívó", + "t_org_name": "Cég", + "t_org_unit": "Osztály", + "t_org_title": "Beosztás", + "t_street": "Utca", + "t_pobox": "Postafiók", + "t_locality": "Város", + "t_region": "Megye vagy régió", + "t_postcode": "Irányítószám", + "t_country": "Ország", + "t_web": "Webhely", + "t_birthday": "Születésnap", + "t_anniversary": "Évforduló", + "t_notes": "Jegyzetek", + "t_categories": "Kategóriák", + "t_type": "Az érték típusa (Otthoni, Munkahelyi…)", + "merge_with": "Egyesítés ezzel…", + "merge_selected": "Egyesítés…", + "find_duplicates": "Ismétlődések keresése", + "possible_duplicates": "Lehetséges ismétlődések ({count})", + "duplicates_title": "Lehetséges ismétlődések", + "duplicates_hint": "Azonos nevű, e-mail-című vagy telefonszámú névjegyek.", + "duplicates_none": "Nincs lehetséges ismétlődés.", + "pick_title": "{name} egyesítése ezzel…", + "pick_search": "Névjegyek keresése", + "pick_suggested": "Lehetséges ismétlődések", + "pick_all": "Minden névjegy", + "pick_none": "Nincs más névjegy, amellyel egyesíteni lehetne.", + "preview_title": "Névjegyek egyesítése ({count})", + "preview_hint": "Ezekből a névjegyekből egy kártya lesz mindazzal, ami lent látható.", + "preview_deleted": "Átvéve, majd törölve: {names}", + "groups": "Csoportok", + "merge": "Egyesítés", + "merging": "Egyesítés folyamatban…", + "merged": "Névjegyek egyesítve", + "merge_failed": "A névjegyeket nem sikerült egyesíteni.", + "merge_not_possible": "Ezek a névjegyek nem egyesíthetők: különböző fiókokban vannak, vagy olyan címjegyzékben, amelyet nem módosíthat.", + "undo": "Visszavonás", + "undone": "Egyesítés visszavonva", + "undo_failed": "Az egyesítést nem sikerült teljesen visszavonni." + }, "find_time": { "suggest": "Időpontok javaslata", "searching": "Szabad időpontok keresése", diff --git a/locales/it/common.json b/locales/it/common.json index 351ca472a..f1d150516 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -5766,6 +5766,88 @@ "title": "Disponibilità", "description": "I colleghi del gruppo vedono quando sei occupato, non quali eventi hai. «Suggerisci orari» lo usa." }, + "ux_pim_cimport": { + "file_hint": "Fai clic per selezionare un file vCard o CSV", + "file_types": "File .vcf o .csv, ad esempio da Outlook o Google Contatti", + "parse_error": "Impossibile leggere il file", + "columns_title": "Abbina le colonne", + "columns_hint": "Scegli dove va ogni colonna di {file}. Le colonne impostate su «Non importare» vengono escluse.", + "column": "Colonna", + "sample": "Esempio", + "field": "Campo del contatto", + "skip": "Non importare", + "no_columns": "Scegli un campo per almeno una colonna.", + "continue": "Continua", + "back": "Indietro", + "import_into": "Importa in", + "group_name": "Nome", + "group_email": "Email", + "group_phone": "Telefono", + "group_org": "Organizzazione", + "group_adr_any": "Indirizzo", + "group_adr_home": "Indirizzo di casa", + "group_adr_work": "Indirizzo di lavoro", + "group_adr_other": "Altro indirizzo", + "group_more": "Altro", + "address_field": "{group}: {part}", + "t_name_full": "Nome completo", + "t_name_given": "Nome", + "t_name_middle": "Secondo nome", + "t_name_surname": "Cognome", + "t_name_prefix": "Titolo (Dott., Sig.ra)", + "t_name_suffix": "Suffisso (Jr.)", + "t_nickname": "Soprannome", + "t_email": "Email", + "t_email_home": "Email (casa)", + "t_email_work": "Email (lavoro)", + "t_phone": "Telefono", + "t_phone_mobile": "Cellulare", + "t_phone_home": "Telefono (casa)", + "t_phone_work": "Telefono (lavoro)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (casa)", + "t_phone_fax_work": "Fax (lavoro)", + "t_phone_pager": "Cercapersone", + "t_org_name": "Azienda", + "t_org_unit": "Reparto", + "t_org_title": "Qualifica", + "t_street": "Via", + "t_pobox": "Casella postale", + "t_locality": "Città", + "t_region": "Stato o regione", + "t_postcode": "CAP", + "t_country": "Paese", + "t_web": "Sito web", + "t_birthday": "Compleanno", + "t_anniversary": "Anniversario", + "t_notes": "Note", + "t_categories": "Categorie", + "t_type": "Tipo del valore (Casa, Lavoro…)", + "merge_with": "Unisci con…", + "merge_selected": "Unisci…", + "find_duplicates": "Trova duplicati", + "possible_duplicates": "Possibili duplicati ({count})", + "duplicates_title": "Possibili duplicati", + "duplicates_hint": "Contatti con lo stesso nome, indirizzo email o numero di telefono.", + "duplicates_none": "Nessun possibile duplicato trovato.", + "pick_title": "Unisci {name} con…", + "pick_search": "Cerca contatti", + "pick_suggested": "Possibili duplicati", + "pick_all": "Tutti i contatti", + "pick_none": "Nessun altro contatto da unire.", + "preview_title": "Unisci contatti ({count})", + "preview_hint": "Questi contatti diventano una sola scheda con tutto ciò che segue.", + "preview_deleted": "Uniti e poi eliminati: {names}", + "groups": "Gruppi", + "merge": "Unisci", + "merging": "Unione in corso…", + "merged": "Contatti uniti", + "merge_failed": "Impossibile unire i contatti.", + "merge_not_possible": "Questi contatti non si possono unire: sono in account diversi o in una rubrica che non puoi modificare.", + "undo": "Annulla", + "undone": "Unione annullata", + "undo_failed": "Non è stato possibile annullare del tutto l’unione." + }, "find_time": { "suggest": "Suggerisci orari", "searching": "Ricerca di orari liberi", diff --git a/locales/ja/common.json b/locales/ja/common.json index 38bfac719..eeff72436 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -5766,6 +5766,88 @@ "title": "空き時間", "description": "グループのメンバーには予定がある時間だけが見え、予定の内容は見えません。「時間を提案」で使われます。" }, + "ux_pim_cimport": { + "file_hint": "クリックして vCard または CSV ファイルを選択", + "file_types": ".vcf または .csv ファイル(Outlook や Google コンタクトなど)", + "parse_error": "ファイルを読み込めませんでした", + "columns_title": "列の割り当て", + "columns_hint": "{file} の各列の取り込み先を選んでください。「取り込まない」の列は除外されます。", + "column": "列", + "sample": "例", + "field": "連絡先の項目", + "skip": "取り込まない", + "no_columns": "少なくとも 1 つの列に項目を選んでください。", + "continue": "続行", + "back": "戻る", + "import_into": "インポート先", + "group_name": "名前", + "group_email": "メール", + "group_phone": "電話", + "group_org": "組織", + "group_adr_any": "住所", + "group_adr_home": "自宅の住所", + "group_adr_work": "勤務先の住所", + "group_adr_other": "その他の住所", + "group_more": "その他", + "address_field": "{group}:{part}", + "t_name_full": "氏名", + "t_name_given": "名", + "t_name_middle": "ミドルネーム", + "t_name_surname": "姓", + "t_name_prefix": "敬称(Dr.、Ms.)", + "t_name_suffix": "サフィックス(Jr.)", + "t_nickname": "ニックネーム", + "t_email": "メール", + "t_email_home": "メール(自宅)", + "t_email_work": "メール(勤務先)", + "t_phone": "電話", + "t_phone_mobile": "携帯", + "t_phone_home": "電話(自宅)", + "t_phone_work": "電話(勤務先)", + "t_phone_fax": "FAX", + "t_phone_fax_home": "FAX(自宅)", + "t_phone_fax_work": "FAX(勤務先)", + "t_phone_pager": "ポケベル", + "t_org_name": "会社", + "t_org_unit": "部署", + "t_org_title": "役職", + "t_street": "番地", + "t_pobox": "私書箱", + "t_locality": "市区町村", + "t_region": "都道府県・地域", + "t_postcode": "郵便番号", + "t_country": "国", + "t_web": "ウェブサイト", + "t_birthday": "誕生日", + "t_anniversary": "記念日", + "t_notes": "メモ", + "t_categories": "カテゴリ", + "t_type": "値の種類(自宅、勤務先…)", + "merge_with": "統合する相手…", + "merge_selected": "統合…", + "find_duplicates": "重複を検索", + "possible_duplicates": "重複の可能性({count})", + "duplicates_title": "重複の可能性", + "duplicates_hint": "名前、メールアドレス、または電話番号が同じ連絡先です。", + "duplicates_none": "重複の可能性がある連絡先はありません。", + "pick_title": "{name} を統合する相手…", + "pick_search": "連絡先を検索", + "pick_suggested": "重複の可能性", + "pick_all": "すべての連絡先", + "pick_none": "統合できる連絡先はほかにありません。", + "preview_title": "連絡先を統合({count})", + "preview_hint": "これらの連絡先は、以下の内容をすべて持つ 1 枚のカードになります。", + "preview_deleted": "統合後に削除: {names}", + "groups": "グループ", + "merge": "統合", + "merging": "統合しています…", + "merged": "連絡先を統合しました", + "merge_failed": "連絡先を統合できませんでした。", + "merge_not_possible": "これらの連絡先は統合できません。別々のアカウントにあるか、変更できないアドレス帳にあります。", + "undo": "元に戻す", + "undone": "統合を元に戻しました", + "undo_failed": "統合を完全には元に戻せませんでした。" + }, "find_time": { "suggest": "時間を提案", "searching": "空いている時間を探しています", diff --git a/locales/ko/common.json b/locales/ko/common.json index b8930944d..2bb52c5dd 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -5766,6 +5766,88 @@ "title": "일정 가능 여부", "description": "그룹의 동료는 내가 바쁜 시간만 보고 일정 내용은 보지 못합니다. 시간 제안에 사용됩니다." }, + "ux_pim_cimport": { + "file_hint": "클릭하여 vCard 또는 CSV 파일 선택", + "file_types": ".vcf 또는 .csv 파일(예: Outlook 또는 Google 주소록)", + "parse_error": "파일을 읽을 수 없습니다", + "columns_title": "열 연결", + "columns_hint": "{file}의 각 열을 어디에 넣을지 선택하세요. ‘가져오지 않음’으로 설정한 열은 제외됩니다.", + "column": "열", + "sample": "예시", + "field": "연락처 항목", + "skip": "가져오지 않음", + "no_columns": "적어도 한 열에 항목을 선택하세요.", + "continue": "계속", + "back": "뒤로", + "import_into": "가져올 위치", + "group_name": "이름", + "group_email": "이메일", + "group_phone": "전화", + "group_org": "조직", + "group_adr_any": "주소", + "group_adr_home": "집 주소", + "group_adr_work": "직장 주소", + "group_adr_other": "기타 주소", + "group_more": "더 보기", + "address_field": "{group}: {part}", + "t_name_full": "전체 이름", + "t_name_given": "이름", + "t_name_middle": "중간 이름", + "t_name_surname": "성", + "t_name_prefix": "호칭(Dr., Ms.)", + "t_name_suffix": "접미사(Jr.)", + "t_nickname": "별명", + "t_email": "이메일", + "t_email_home": "이메일(집)", + "t_email_work": "이메일(직장)", + "t_phone": "전화", + "t_phone_mobile": "휴대폰", + "t_phone_home": "전화(집)", + "t_phone_work": "전화(직장)", + "t_phone_fax": "팩스", + "t_phone_fax_home": "팩스(집)", + "t_phone_fax_work": "팩스(직장)", + "t_phone_pager": "호출기", + "t_org_name": "회사", + "t_org_unit": "부서", + "t_org_title": "직함", + "t_street": "도로명", + "t_pobox": "사서함", + "t_locality": "시/군/구", + "t_region": "시/도 또는 지역", + "t_postcode": "우편번호", + "t_country": "국가", + "t_web": "웹사이트", + "t_birthday": "생일", + "t_anniversary": "기념일", + "t_notes": "메모", + "t_categories": "카테고리", + "t_type": "값의 종류(집, 직장…)", + "merge_with": "다음과 병합…", + "merge_selected": "병합…", + "find_duplicates": "중복 찾기", + "possible_duplicates": "중복 가능성({count})", + "duplicates_title": "중복 가능성", + "duplicates_hint": "이름, 이메일 주소 또는 전화번호가 같은 연락처입니다.", + "duplicates_none": "중복 가능성이 있는 연락처가 없습니다.", + "pick_title": "{name}을(를) 다음과 병합…", + "pick_search": "연락처 검색", + "pick_suggested": "중복 가능성", + "pick_all": "모든 연락처", + "pick_none": "병합할 다른 연락처가 없습니다.", + "preview_title": "연락처 병합({count})", + "preview_hint": "이 연락처들은 아래 내용을 모두 담은 카드 하나가 됩니다.", + "preview_deleted": "병합 후 삭제: {names}", + "groups": "그룹", + "merge": "병합", + "merging": "병합하는 중…", + "merged": "연락처를 병합했습니다", + "merge_failed": "연락처를 병합할 수 없습니다.", + "merge_not_possible": "이 연락처들은 병합할 수 없습니다. 서로 다른 계정에 있거나 변경할 수 없는 주소록에 있습니다.", + "undo": "실행 취소", + "undone": "병합을 취소했습니다", + "undo_failed": "병합을 완전히 취소하지 못했습니다." + }, "find_time": { "suggest": "시간 제안", "searching": "빈 시간을 찾는 중", diff --git a/locales/lv/common.json b/locales/lv/common.json index 4f498e4de..62dcafd39 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -5766,6 +5766,88 @@ "title": "Brīvs/aizņemts", "description": "Grupas kolēģi redz, kad esat aizņemts, bet ne jūsu notikumus. To izmanto “Ieteikt laikus”." }, + "ux_pim_cimport": { + "file_hint": "Noklikšķiniet, lai izvēlētos vCard vai CSV failu", + "file_types": ".vcf vai .csv faili, piemēram, no Outlook vai Google kontaktpersonām", + "parse_error": "Neizdevās nolasīt failu", + "columns_title": "Piesaistiet kolonnas", + "columns_hint": "Izvēlieties, kur iet katra faila {file} kolonna. Kolonnas ar “Neimportēt” tiek izlaistas.", + "column": "Kolonna", + "sample": "Piemērs", + "field": "Kontakta lauks", + "skip": "Neimportēt", + "no_columns": "Izvēlieties lauku vismaz vienai kolonnai.", + "continue": "Turpināt", + "back": "Atpakaļ", + "import_into": "Importēt uz", + "group_name": "Vārds", + "group_email": "E-pasts", + "group_phone": "Tālrunis", + "group_org": "Organizācija", + "group_adr_any": "Adrese", + "group_adr_home": "Mājas adrese", + "group_adr_work": "Darba adrese", + "group_adr_other": "Cita adrese", + "group_more": "Vairāk", + "address_field": "{group}: {part}", + "t_name_full": "Pilns vārds", + "t_name_given": "Vārds", + "t_name_middle": "Otrais vārds", + "t_name_surname": "Uzvārds", + "t_name_prefix": "Uzruna (Dr., kdz.)", + "t_name_suffix": "Sufikss (jaun.)", + "t_nickname": "Iesauka", + "t_email": "E-pasts", + "t_email_home": "E-pasts (mājas)", + "t_email_work": "E-pasts (darba)", + "t_phone": "Tālrunis", + "t_phone_mobile": "Mobilais", + "t_phone_home": "Tālrunis (mājas)", + "t_phone_work": "Tālrunis (darba)", + "t_phone_fax": "Fakss", + "t_phone_fax_home": "Fakss (mājas)", + "t_phone_fax_work": "Fakss (darba)", + "t_phone_pager": "Peidžeris", + "t_org_name": "Uzņēmums", + "t_org_unit": "Nodaļa", + "t_org_title": "Amats", + "t_street": "Iela", + "t_pobox": "Abonenta kastīte", + "t_locality": "Pilsēta", + "t_region": "Novads vai reģions", + "t_postcode": "Pasta indekss", + "t_country": "Valsts", + "t_web": "Tīmekļa vietne", + "t_birthday": "Dzimšanas diena", + "t_anniversary": "Gadadiena", + "t_notes": "Piezīmes", + "t_categories": "Kategorijas", + "t_type": "Vērtības veids (Mājas, Darba…)", + "merge_with": "Apvienot ar…", + "merge_selected": "Apvienot…", + "find_duplicates": "Atrast dublikātus", + "possible_duplicates": "Iespējamie dublikāti ({count})", + "duplicates_title": "Iespējamie dublikāti", + "duplicates_hint": "Kontakti ar vienādu vārdu, e-pasta adresi vai tālruņa numuru.", + "duplicates_none": "Iespējamie dublikāti netika atrasti.", + "pick_title": "Apvienot {name} ar…", + "pick_search": "Meklēt kontaktus", + "pick_suggested": "Iespējamie dublikāti", + "pick_all": "Visi kontakti", + "pick_none": "Nav citu kontaktu, ar ko apvienot.", + "preview_title": "Apvienot kontaktus ({count})", + "preview_hint": "Šie kontakti kļūst par vienu kartīti ar visu, kas ir zemāk.", + "preview_deleted": "Pārņemti un pēc tam dzēsti: {names}", + "groups": "Grupas", + "merge": "Apvienot", + "merging": "Apvieno…", + "merged": "Kontakti apvienoti", + "merge_failed": "Neizdevās apvienot kontaktus.", + "merge_not_possible": "Šos kontaktus nevar apvienot: tie ir dažādos kontos vai adrešu grāmatā, kuru jūs nevarat mainīt.", + "undo": "Atsaukt", + "undone": "Apvienošana atsaukta", + "undo_failed": "Apvienošanu neizdevās pilnībā atsaukt." + }, "find_time": { "suggest": "Ieteikt laikus", "searching": "Meklē brīvus laikus", diff --git a/locales/mn/common.json b/locales/mn/common.json index b140f30c1..6f767a14d 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -5175,6 +5175,88 @@ "title": "Чөлөөтэй/завгүй", "description": "Бүлгийн хамт олон таныг хэзээ завгүйг хардаг ч арга хэмжээг тань хардаггүй. “Цаг санал болгох” үүнийг ашигладаг." }, + "ux_pim_cimport": { + "file_hint": "vCard эсвэл CSV файл сонгохын тулд дарна уу", + "file_types": ".vcf эсвэл .csv файл, жишээ нь Outlook эсвэл Google Харилцагчдаас", + "parse_error": "Файлыг уншиж чадсангүй", + "columns_title": "Баганыг тааруулах", + "columns_hint": "{file}-ийн багана бүр хаашаа орохыг сонгоно уу. «Импортлохгүй» гэсэн баганыг алгасна.", + "column": "Багана", + "sample": "Жишээ", + "field": "Харилцагчийн талбар", + "skip": "Импортлохгүй", + "no_columns": "Дор хаяж нэг баганад талбар сонгоно уу.", + "continue": "Үргэлжлүүлэх", + "back": "Буцах", + "import_into": "Импортлох газар", + "group_name": "Нэр", + "group_email": "Имэйл", + "group_phone": "Утас", + "group_org": "Байгууллага", + "group_adr_any": "Хаяг", + "group_adr_home": "Гэрийн хаяг", + "group_adr_work": "Ажлын хаяг", + "group_adr_other": "Бусад хаяг", + "group_more": "Бусад", + "address_field": "{group}: {part}", + "t_name_full": "Бүтэн нэр", + "t_name_given": "Нэр", + "t_name_middle": "Дунд нэр", + "t_name_surname": "Овог", + "t_name_prefix": "Цол (Доктор, Хатагтай)", + "t_name_suffix": "Дагавар (Jr.)", + "t_nickname": "Хоч", + "t_email": "Имэйл", + "t_email_home": "Имэйл (гэр)", + "t_email_work": "Имэйл (ажил)", + "t_phone": "Утас", + "t_phone_mobile": "Гар утас", + "t_phone_home": "Утас (гэр)", + "t_phone_work": "Утас (ажил)", + "t_phone_fax": "Факс", + "t_phone_fax_home": "Факс (гэр)", + "t_phone_fax_work": "Факс (ажил)", + "t_phone_pager": "Пейжер", + "t_org_name": "Компани", + "t_org_unit": "Хэлтэс", + "t_org_title": "Албан тушаал", + "t_street": "Гудамж", + "t_pobox": "Шуудангийн хайрцаг", + "t_locality": "Хот", + "t_region": "Аймаг эсвэл бүс", + "t_postcode": "Шуудангийн код", + "t_country": "Улс", + "t_web": "Вэбсайт", + "t_birthday": "Төрсөн өдөр", + "t_anniversary": "Ой", + "t_notes": "Тэмдэглэл", + "t_categories": "Ангилал", + "t_type": "Утгын төрөл (Гэр, Ажил…)", + "merge_with": "Дараахтай нэгтгэх…", + "merge_selected": "Нэгтгэх…", + "find_duplicates": "Давхардал хайх", + "possible_duplicates": "Давхардсан байж болзошгүй ({count})", + "duplicates_title": "Давхардсан байж болзошгүй", + "duplicates_hint": "Ижил нэр, имэйл хаяг эсвэл утасны дугаартай харилцагчид.", + "duplicates_none": "Давхардсан байж болзошгүй харилцагч олдсонгүй.", + "pick_title": "{name}-г дараахтай нэгтгэх…", + "pick_search": "Харилцагч хайх", + "pick_suggested": "Давхардсан байж болзошгүй", + "pick_all": "Бүх харилцагч", + "pick_none": "Нэгтгэх өөр харилцагч алга.", + "preview_title": "Харилцагчдыг нэгтгэх ({count})", + "preview_hint": "Эдгээр харилцагч доорх бүх зүйлтэй нэг карт болно.", + "preview_deleted": "Нэгтгээд устгасан: {names}", + "groups": "Бүлгүүд", + "merge": "Нэгтгэх", + "merging": "Нэгтгэж байна…", + "merged": "Харилцагчдыг нэгтгэлээ", + "merge_failed": "Харилцагчдыг нэгтгэж чадсангүй.", + "merge_not_possible": "Эдгээр харилцагчийг нэгтгэх боломжгүй: тэд өөр өөр бүртгэлд эсвэл таны өөрчилж чадахгүй хаягийн дэвтэрт байна.", + "undo": "Буцаах", + "undone": "Нэгтгэлийг буцаалаа", + "undo_failed": "Нэгтгэлийг бүрэн буцааж чадсангүй." + }, "find_time": { "suggest": "Цаг санал болгох", "searching": "Чөлөөтэй цаг хайж байна", diff --git a/locales/nb/common.json b/locales/nb/common.json index 5f03efacb..ac7970c7e 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -5175,6 +5175,88 @@ "title": "Ledig/opptatt", "description": "Kolleger i gruppen ser når du er opptatt, ikke hva hendelsene dine er. Foreslå tidspunkter bruker det." }, + "ux_pim_cimport": { + "file_hint": "Klikk for å velge en vCard- eller CSV-fil", + "file_types": ".vcf- eller .csv-filer, for eksempel fra Outlook eller Google Kontakter", + "parse_error": "Kunne ikke lese filen", + "columns_title": "Koble kolonnene", + "columns_hint": "Velg hvor hver kolonne i {file} skal. Kolonner med «Ikke importer» hoppes over.", + "column": "Kolonne", + "sample": "Eksempel", + "field": "Kontaktfelt", + "skip": "Ikke importer", + "no_columns": "Velg et felt for minst én kolonne.", + "continue": "Fortsett", + "back": "Tilbake", + "import_into": "Importer til", + "group_name": "Navn", + "group_email": "E-post", + "group_phone": "Telefon", + "group_org": "Organisasjon", + "group_adr_any": "Adresse", + "group_adr_home": "Privatadresse", + "group_adr_work": "Jobbadresse", + "group_adr_other": "Annen adresse", + "group_more": "Mer", + "address_field": "{group}: {part}", + "t_name_full": "Fullt navn", + "t_name_given": "Fornavn", + "t_name_middle": "Mellomnavn", + "t_name_surname": "Etternavn", + "t_name_prefix": "Tittel (dr., fru)", + "t_name_suffix": "Suffiks (jr.)", + "t_nickname": "Kallenavn", + "t_email": "E-post", + "t_email_home": "E-post (privat)", + "t_email_work": "E-post (jobb)", + "t_phone": "Telefon", + "t_phone_mobile": "Mobil", + "t_phone_home": "Telefon (privat)", + "t_phone_work": "Telefon (jobb)", + "t_phone_fax": "Faks", + "t_phone_fax_home": "Faks (privat)", + "t_phone_fax_work": "Faks (jobb)", + "t_phone_pager": "Personsøker", + "t_org_name": "Firma", + "t_org_unit": "Avdeling", + "t_org_title": "Stilling", + "t_street": "Gate", + "t_pobox": "Postboks", + "t_locality": "Sted", + "t_region": "Fylke eller region", + "t_postcode": "Postnummer", + "t_country": "Land", + "t_web": "Nettsted", + "t_birthday": "Bursdag", + "t_anniversary": "Merkedag", + "t_notes": "Notater", + "t_categories": "Kategorier", + "t_type": "Type verdi (Privat, Jobb…)", + "merge_with": "Slå sammen med…", + "merge_selected": "Slå sammen…", + "find_duplicates": "Finn duplikater", + "possible_duplicates": "Mulige duplikater ({count})", + "duplicates_title": "Mulige duplikater", + "duplicates_hint": "Kontakter med samme navn, e-postadresse eller telefonnummer.", + "duplicates_none": "Fant ingen mulige duplikater.", + "pick_title": "Slå sammen {name} med…", + "pick_search": "Søk i kontakter", + "pick_suggested": "Mulige duplikater", + "pick_all": "Alle kontakter", + "pick_none": "Ingen andre kontakter å slå sammen med.", + "preview_title": "Slå sammen kontakter ({count})", + "preview_hint": "Disse kontaktene blir ett kort med alt nedenfor.", + "preview_deleted": "Tatt med og deretter slettet: {names}", + "groups": "Grupper", + "merge": "Slå sammen", + "merging": "Slår sammen…", + "merged": "Kontakter slått sammen", + "merge_failed": "Kunne ikke slå sammen kontaktene.", + "merge_not_possible": "Disse kontaktene kan ikke slås sammen: De ligger i ulike kontoer eller i en adressebok du ikke kan endre.", + "undo": "Angre", + "undone": "Sammenslåing angret", + "undo_failed": "Sammenslåingen kunne ikke angres helt." + }, "find_time": { "suggest": "Foreslå tidspunkter", "searching": "Ser etter ledige tidspunkter", diff --git a/locales/nl/common.json b/locales/nl/common.json index 74fc83929..e82cf2dfe 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -5766,6 +5766,88 @@ "title": "Vrij/bezet", "description": "Collega’s in de groep zien wanneer je bezet bent, niet wat je afspraken zijn. ‘Tijden voorstellen’ gebruikt dit." }, + "ux_pim_cimport": { + "file_hint": "Klik om een vCard- of CSV-bestand te kiezen", + "file_types": ".vcf- of .csv-bestanden, bijvoorbeeld uit Outlook of Google Contacten", + "parse_error": "Het bestand kon niet worden gelezen", + "columns_title": "Kolommen koppelen", + "columns_hint": "Kies waar elke kolom van {file} heen gaat. Kolommen met ‘Niet importeren’ worden overgeslagen.", + "column": "Kolom", + "sample": "Voorbeeld", + "field": "Contactveld", + "skip": "Niet importeren", + "no_columns": "Kies voor minstens één kolom een veld.", + "continue": "Doorgaan", + "back": "Terug", + "import_into": "Importeren in", + "group_name": "Naam", + "group_email": "E-mail", + "group_phone": "Telefoon", + "group_org": "Organisatie", + "group_adr_any": "Adres", + "group_adr_home": "Thuisadres", + "group_adr_work": "Werkadres", + "group_adr_other": "Ander adres", + "group_more": "Meer", + "address_field": "{group}: {part}", + "t_name_full": "Volledige naam", + "t_name_given": "Voornaam", + "t_name_middle": "Tweede voornaam", + "t_name_surname": "Achternaam", + "t_name_prefix": "Aanhef (Dr., mevr.)", + "t_name_suffix": "Achtervoegsel (jr.)", + "t_nickname": "Bijnaam", + "t_email": "E-mail", + "t_email_home": "E-mail (thuis)", + "t_email_work": "E-mail (werk)", + "t_phone": "Telefoon", + "t_phone_mobile": "Mobiel", + "t_phone_home": "Telefoon (thuis)", + "t_phone_work": "Telefoon (werk)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (thuis)", + "t_phone_fax_work": "Fax (werk)", + "t_phone_pager": "Pieper", + "t_org_name": "Bedrijf", + "t_org_unit": "Afdeling", + "t_org_title": "Functie", + "t_street": "Straat", + "t_pobox": "Postbus", + "t_locality": "Plaats", + "t_region": "Provincie of regio", + "t_postcode": "Postcode", + "t_country": "Land", + "t_web": "Website", + "t_birthday": "Verjaardag", + "t_anniversary": "Jubileum", + "t_notes": "Notities", + "t_categories": "Categorieën", + "t_type": "Soort waarde (Thuis, Werk…)", + "merge_with": "Samenvoegen met…", + "merge_selected": "Samenvoegen…", + "find_duplicates": "Dubbele vinden", + "possible_duplicates": "Mogelijke dubbele ({count})", + "duplicates_title": "Mogelijke dubbele", + "duplicates_hint": "Contacten met dezelfde naam, hetzelfde e-mailadres of telefoonnummer.", + "duplicates_none": "Geen mogelijke dubbele gevonden.", + "pick_title": "{name} samenvoegen met…", + "pick_search": "Contacten zoeken", + "pick_suggested": "Mogelijke dubbele", + "pick_all": "Alle contacten", + "pick_none": "Geen andere contacten om mee samen te voegen.", + "preview_title": "Contacten samenvoegen ({count})", + "preview_hint": "Deze contacten worden één kaart met alles hieronder.", + "preview_deleted": "Overgenomen en daarna verwijderd: {names}", + "groups": "Groepen", + "merge": "Samenvoegen", + "merging": "Samenvoegen…", + "merged": "Contacten samengevoegd", + "merge_failed": "De contacten konden niet worden samengevoegd.", + "merge_not_possible": "Deze contacten kunnen niet worden samengevoegd: ze staan in verschillende accounts of in een adresboek dat je niet mag wijzigen.", + "undo": "Ongedaan maken", + "undone": "Samenvoegen ongedaan gemaakt", + "undo_failed": "Het samenvoegen kon niet helemaal ongedaan worden gemaakt." + }, "find_time": { "suggest": "Tijden voorstellen", "searching": "Vrije tijden zoeken", diff --git a/locales/pl/common.json b/locales/pl/common.json index 58e431a93..37aae1a32 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -5766,6 +5766,88 @@ "title": "Dostępność", "description": "Współpracownicy z grupy widzą, kiedy jesteś zajęty, ale nie widzą Twoich wydarzeń. Korzysta z tego funkcja proponowania terminów." }, + "ux_pim_cimport": { + "file_hint": "Kliknij, aby wybrać plik vCard lub CSV", + "file_types": "Pliki .vcf lub .csv, na przykład z Outlooka lub Kontaktów Google", + "parse_error": "Nie udało się odczytać pliku", + "columns_title": "Przypisz kolumny", + "columns_hint": "Wybierz, gdzie trafia każda kolumna pliku {file}. Kolumny z opcją „Nie importuj” są pomijane.", + "column": "Kolumna", + "sample": "Przykład", + "field": "Pole kontaktu", + "skip": "Nie importuj", + "no_columns": "Wybierz pole dla co najmniej jednej kolumny.", + "continue": "Dalej", + "back": "Wstecz", + "import_into": "Importuj do", + "group_name": "Imię i nazwisko", + "group_email": "E-mail", + "group_phone": "Telefon", + "group_org": "Organizacja", + "group_adr_any": "Adres", + "group_adr_home": "Adres domowy", + "group_adr_work": "Adres służbowy", + "group_adr_other": "Inny adres", + "group_more": "Więcej", + "address_field": "{group}: {part}", + "t_name_full": "Pełne imię i nazwisko", + "t_name_given": "Imię", + "t_name_middle": "Drugie imię", + "t_name_surname": "Nazwisko", + "t_name_prefix": "Tytuł (dr, pani)", + "t_name_suffix": "Przyrostek (jr)", + "t_nickname": "Pseudonim", + "t_email": "E-mail", + "t_email_home": "E-mail (dom)", + "t_email_work": "E-mail (praca)", + "t_phone": "Telefon", + "t_phone_mobile": "Komórka", + "t_phone_home": "Telefon (dom)", + "t_phone_work": "Telefon (praca)", + "t_phone_fax": "Faks", + "t_phone_fax_home": "Faks (dom)", + "t_phone_fax_work": "Faks (praca)", + "t_phone_pager": "Pager", + "t_org_name": "Firma", + "t_org_unit": "Dział", + "t_org_title": "Stanowisko", + "t_street": "Ulica", + "t_pobox": "Skrytka pocztowa", + "t_locality": "Miejscowość", + "t_region": "Województwo lub region", + "t_postcode": "Kod pocztowy", + "t_country": "Kraj", + "t_web": "Strona WWW", + "t_birthday": "Urodziny", + "t_anniversary": "Rocznica", + "t_notes": "Notatki", + "t_categories": "Kategorie", + "t_type": "Rodzaj wartości (Dom, Praca…)", + "merge_with": "Scal z…", + "merge_selected": "Scal…", + "find_duplicates": "Znajdź duplikaty", + "possible_duplicates": "Możliwe duplikaty ({count})", + "duplicates_title": "Możliwe duplikaty", + "duplicates_hint": "Kontakty o tym samym imieniu i nazwisku, adresie e-mail lub numerze telefonu.", + "duplicates_none": "Nie znaleziono możliwych duplikatów.", + "pick_title": "Scal {name} z…", + "pick_search": "Szukaj kontaktów", + "pick_suggested": "Możliwe duplikaty", + "pick_all": "Wszystkie kontakty", + "pick_none": "Brak innych kontaktów do scalenia.", + "preview_title": "Scal kontakty ({count})", + "preview_hint": "Te kontakty staną się jedną kartą ze wszystkim poniżej.", + "preview_deleted": "Przejęte, a potem usunięte: {names}", + "groups": "Grupy", + "merge": "Scal", + "merging": "Scalanie…", + "merged": "Kontakty scalone", + "merge_failed": "Nie udało się scalić kontaktów.", + "merge_not_possible": "Tych kontaktów nie można scalić: są na różnych kontach lub w książce adresowej, której nie możesz zmieniać.", + "undo": "Cofnij", + "undone": "Scalanie cofnięte", + "undo_failed": "Nie udało się całkowicie cofnąć scalania." + }, "find_time": { "suggest": "Zaproponuj terminy", "searching": "Szukanie wolnych terminów", diff --git a/locales/pt/common.json b/locales/pt/common.json index 49acbe40e..9f1a26f38 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -5766,6 +5766,88 @@ "title": "Disponibilidade", "description": "Os colegas do grupo veem quando está ocupado, não quais são os seus eventos. «Sugerir horários» usa isto." }, + "ux_pim_cimport": { + "file_hint": "Clique para selecionar um arquivo vCard ou CSV", + "file_types": "Arquivos .vcf ou .csv, por exemplo do Outlook ou do Google Contatos", + "parse_error": "Não foi possível ler o arquivo", + "columns_title": "Associar as colunas", + "columns_hint": "Escolha para onde vai cada coluna de {file}. Colunas com “Não importar” são ignoradas.", + "column": "Coluna", + "sample": "Exemplo", + "field": "Campo do contato", + "skip": "Não importar", + "no_columns": "Escolha um campo para pelo menos uma coluna.", + "continue": "Continuar", + "back": "Voltar", + "import_into": "Importar para", + "group_name": "Nome", + "group_email": "E-mail", + "group_phone": "Telefone", + "group_org": "Organização", + "group_adr_any": "Endereço", + "group_adr_home": "Endereço residencial", + "group_adr_work": "Endereço comercial", + "group_adr_other": "Outro endereço", + "group_more": "Mais", + "address_field": "{group}: {part}", + "t_name_full": "Nome completo", + "t_name_given": "Nome", + "t_name_middle": "Nome do meio", + "t_name_surname": "Sobrenome", + "t_name_prefix": "Tratamento (Dr., Sra.)", + "t_name_suffix": "Sufixo (Jr.)", + "t_nickname": "Apelido", + "t_email": "E-mail", + "t_email_home": "E-mail (pessoal)", + "t_email_work": "E-mail (trabalho)", + "t_phone": "Telefone", + "t_phone_mobile": "Celular", + "t_phone_home": "Telefone (pessoal)", + "t_phone_work": "Telefone (trabalho)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (pessoal)", + "t_phone_fax_work": "Fax (trabalho)", + "t_phone_pager": "Pager", + "t_org_name": "Empresa", + "t_org_unit": "Departamento", + "t_org_title": "Cargo", + "t_street": "Rua", + "t_pobox": "Caixa postal", + "t_locality": "Cidade", + "t_region": "Estado ou região", + "t_postcode": "CEP", + "t_country": "País", + "t_web": "Site", + "t_birthday": "Aniversário", + "t_anniversary": "Data comemorativa", + "t_notes": "Notas", + "t_categories": "Categorias", + "t_type": "Tipo do valor (Casa, Trabalho…)", + "merge_with": "Mesclar com…", + "merge_selected": "Mesclar…", + "find_duplicates": "Encontrar duplicados", + "possible_duplicates": "Possíveis duplicados ({count})", + "duplicates_title": "Possíveis duplicados", + "duplicates_hint": "Contatos com o mesmo nome, e-mail ou telefone.", + "duplicates_none": "Nenhum possível duplicado encontrado.", + "pick_title": "Mesclar {name} com…", + "pick_search": "Pesquisar contatos", + "pick_suggested": "Possíveis duplicados", + "pick_all": "Todos os contatos", + "pick_none": "Nenhum outro contato para mesclar.", + "preview_title": "Mesclar contatos ({count})", + "preview_hint": "Estes contatos viram um só cartão com tudo o que está abaixo.", + "preview_deleted": "Incorporados e depois excluídos: {names}", + "groups": "Grupos", + "merge": "Mesclar", + "merging": "Mesclando…", + "merged": "Contatos mesclados", + "merge_failed": "Não foi possível mesclar os contatos.", + "merge_not_possible": "Estes contatos não podem ser mesclados: estão em contas diferentes ou em um catálogo de endereços que você não pode alterar.", + "undo": "Desfazer", + "undone": "Mesclagem desfeita", + "undo_failed": "Não foi possível desfazer totalmente a mesclagem." + }, "find_time": { "suggest": "Sugerir horários", "searching": "A procurar horários livres", diff --git a/locales/ro/common.json b/locales/ro/common.json index 3a895f8b6..b6b53057c 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -5175,6 +5175,88 @@ "title": "Disponibilitate", "description": "Colegii din grup văd când sunteți ocupat, nu ce evenimente aveți. „Sugerează ore” folosește asta." }, + "ux_pim_cimport": { + "file_hint": "Faceți clic pentru a selecta un fișier vCard sau CSV", + "file_types": "Fișiere .vcf sau .csv, de exemplu din Outlook sau Google Agendă", + "parse_error": "Fișierul nu a putut fi citit", + "columns_title": "Asociați coloanele", + "columns_hint": "Alegeți unde merge fiecare coloană din {file}. Coloanele setate pe „Nu importa” sunt omise.", + "column": "Coloană", + "sample": "Exemplu", + "field": "Câmp de contact", + "skip": "Nu importa", + "no_columns": "Alegeți un câmp pentru cel puțin o coloană.", + "continue": "Continuați", + "back": "Înapoi", + "import_into": "Importă în", + "group_name": "Nume", + "group_email": "E-mail", + "group_phone": "Telefon", + "group_org": "Organizație", + "group_adr_any": "Adresă", + "group_adr_home": "Adresă de acasă", + "group_adr_work": "Adresă de serviciu", + "group_adr_other": "Altă adresă", + "group_more": "Mai mult", + "address_field": "{group}: {part}", + "t_name_full": "Nume complet", + "t_name_given": "Prenume", + "t_name_middle": "Al doilea prenume", + "t_name_surname": "Nume de familie", + "t_name_prefix": "Titlu (Dr., dna)", + "t_name_suffix": "Sufix (Jr.)", + "t_nickname": "Poreclă", + "t_email": "E-mail", + "t_email_home": "E-mail (acasă)", + "t_email_work": "E-mail (serviciu)", + "t_phone": "Telefon", + "t_phone_mobile": "Mobil", + "t_phone_home": "Telefon (acasă)", + "t_phone_work": "Telefon (serviciu)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (acasă)", + "t_phone_fax_work": "Fax (serviciu)", + "t_phone_pager": "Pager", + "t_org_name": "Companie", + "t_org_unit": "Departament", + "t_org_title": "Funcție", + "t_street": "Stradă", + "t_pobox": "Căsuță poștală", + "t_locality": "Localitate", + "t_region": "Județ sau regiune", + "t_postcode": "Cod poștal", + "t_country": "Țară", + "t_web": "Site web", + "t_birthday": "Zi de naștere", + "t_anniversary": "Aniversare", + "t_notes": "Note", + "t_categories": "Categorii", + "t_type": "Tipul valorii (Acasă, Serviciu…)", + "merge_with": "Îmbinați cu…", + "merge_selected": "Îmbinați…", + "find_duplicates": "Găsiți duplicate", + "possible_duplicates": "Posibile duplicate ({count})", + "duplicates_title": "Posibile duplicate", + "duplicates_hint": "Contacte cu același nume, aceeași adresă de e-mail sau același număr de telefon.", + "duplicates_none": "Nu s-au găsit posibile duplicate.", + "pick_title": "Îmbinați {name} cu…", + "pick_search": "Căutați contacte", + "pick_suggested": "Posibile duplicate", + "pick_all": "Toate contactele", + "pick_none": "Nu există alte contacte cu care să îmbinați.", + "preview_title": "Îmbinați contactele ({count})", + "preview_hint": "Aceste contacte devin o singură fișă cu tot ce este mai jos.", + "preview_deleted": "Preluate, apoi șterse: {names}", + "groups": "Grupuri", + "merge": "Îmbinați", + "merging": "Se îmbină…", + "merged": "Contacte îmbinate", + "merge_failed": "Contactele nu au putut fi îmbinate.", + "merge_not_possible": "Aceste contacte nu pot fi îmbinate: sunt în conturi diferite sau într-o agendă pe care nu o puteți modifica.", + "undo": "Anulați", + "undone": "Îmbinare anulată", + "undo_failed": "Îmbinarea nu a putut fi anulată complet." + }, "find_time": { "suggest": "Sugerează ore", "searching": "Se caută ore libere", diff --git a/locales/ru/common.json b/locales/ru/common.json index 286329654..08a3ee2f1 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -5766,6 +5766,88 @@ "title": "Занятость", "description": "Коллеги из группы видят, когда вы заняты, но не видят ваши события. Это использует функция «Предложить время»." }, + "ux_pim_cimport": { + "file_hint": "Нажмите, чтобы выбрать файл vCard или CSV", + "file_types": "Файлы .vcf или .csv, например из Outlook или Google Контактов", + "parse_error": "Не удалось прочитать файл", + "columns_title": "Сопоставьте столбцы", + "columns_hint": "Выберите, куда идёт каждый столбец файла {file}. Столбцы с «Не импортировать» пропускаются.", + "column": "Столбец", + "sample": "Пример", + "field": "Поле контакта", + "skip": "Не импортировать", + "no_columns": "Выберите поле хотя бы для одного столбца.", + "continue": "Далее", + "back": "Назад", + "import_into": "Импортировать в", + "group_name": "Имя", + "group_email": "Эл. почта", + "group_phone": "Телефон", + "group_org": "Организация", + "group_adr_any": "Адрес", + "group_adr_home": "Домашний адрес", + "group_adr_work": "Рабочий адрес", + "group_adr_other": "Другой адрес", + "group_more": "Ещё", + "address_field": "{group}: {part}", + "t_name_full": "Полное имя", + "t_name_given": "Имя", + "t_name_middle": "Отчество", + "t_name_surname": "Фамилия", + "t_name_prefix": "Обращение (д-р, г-жа)", + "t_name_suffix": "Суффикс (мл.)", + "t_nickname": "Псевдоним", + "t_email": "Эл. почта", + "t_email_home": "Эл. почта (дом.)", + "t_email_work": "Эл. почта (раб.)", + "t_phone": "Телефон", + "t_phone_mobile": "Мобильный", + "t_phone_home": "Телефон (дом.)", + "t_phone_work": "Телефон (раб.)", + "t_phone_fax": "Факс", + "t_phone_fax_home": "Факс (дом.)", + "t_phone_fax_work": "Факс (раб.)", + "t_phone_pager": "Пейджер", + "t_org_name": "Компания", + "t_org_unit": "Отдел", + "t_org_title": "Должность", + "t_street": "Улица", + "t_pobox": "Абонентский ящик", + "t_locality": "Город", + "t_region": "Область или регион", + "t_postcode": "Индекс", + "t_country": "Страна", + "t_web": "Сайт", + "t_birthday": "День рождения", + "t_anniversary": "Годовщина", + "t_notes": "Заметки", + "t_categories": "Категории", + "t_type": "Тип значения (Дом, Работа…)", + "merge_with": "Объединить с…", + "merge_selected": "Объединить…", + "find_duplicates": "Найти дубликаты", + "possible_duplicates": "Возможные дубликаты ({count})", + "duplicates_title": "Возможные дубликаты", + "duplicates_hint": "Контакты с одинаковым именем, адресом эл. почты или номером телефона.", + "duplicates_none": "Возможных дубликатов не найдено.", + "pick_title": "Объединить {name} с…", + "pick_search": "Поиск контактов", + "pick_suggested": "Возможные дубликаты", + "pick_all": "Все контакты", + "pick_none": "Нет других контактов для объединения.", + "preview_title": "Объединить контакты ({count})", + "preview_hint": "Эти контакты станут одной карточкой со всем, что ниже.", + "preview_deleted": "Перенесены и затем удалены: {names}", + "groups": "Группы", + "merge": "Объединить", + "merging": "Объединение…", + "merged": "Контакты объединены", + "merge_failed": "Не удалось объединить контакты.", + "merge_not_possible": "Эти контакты нельзя объединить: они в разных учётных записях или в адресной книге, которую вы не можете изменять.", + "undo": "Отменить", + "undone": "Объединение отменено", + "undo_failed": "Не удалось полностью отменить объединение." + }, "find_time": { "suggest": "Предложить время", "searching": "Поиск свободного времени", diff --git a/locales/sk/common.json b/locales/sk/common.json index dd2262ca0..d4dcd98c6 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -5175,6 +5175,88 @@ "title": "Voľno/obsadené", "description": "Kolegovia v skupine vidia, kedy ste obsadení, nie aké máte udalosti. Používa to funkcia Navrhnúť časy." }, + "ux_pim_cimport": { + "file_hint": "Kliknite a vyberte súbor vCard alebo CSV", + "file_types": "Súbory .vcf alebo .csv, napríklad z Outlooku alebo Google Kontaktov", + "parse_error": "Súbor sa nepodarilo prečítať", + "columns_title": "Priradiť stĺpce", + "columns_hint": "Vyberte, kam patrí každý stĺpec súboru {file}. Stĺpce s voľbou „Neimportovať“ sa vynechajú.", + "column": "Stĺpec", + "sample": "Príklad", + "field": "Pole kontaktu", + "skip": "Neimportovať", + "no_columns": "Vyberte pole aspoň pre jeden stĺpec.", + "continue": "Pokračovať", + "back": "Späť", + "import_into": "Importovať do", + "group_name": "Meno", + "group_email": "E-mail", + "group_phone": "Telefón", + "group_org": "Organizácia", + "group_adr_any": "Adresa", + "group_adr_home": "Adresa domov", + "group_adr_work": "Pracovná adresa", + "group_adr_other": "Iná adresa", + "group_more": "Ďalšie", + "address_field": "{group}: {part}", + "t_name_full": "Celé meno", + "t_name_given": "Krstné meno", + "t_name_middle": "Stredné meno", + "t_name_surname": "Priezvisko", + "t_name_prefix": "Titul (Dr., pani)", + "t_name_suffix": "Prípona (ml.)", + "t_nickname": "Prezývka", + "t_email": "E-mail", + "t_email_home": "E-mail (domov)", + "t_email_work": "E-mail (práca)", + "t_phone": "Telefón", + "t_phone_mobile": "Mobil", + "t_phone_home": "Telefón (domov)", + "t_phone_work": "Telefón (práca)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (domov)", + "t_phone_fax_work": "Fax (práca)", + "t_phone_pager": "Pager", + "t_org_name": "Firma", + "t_org_unit": "Oddelenie", + "t_org_title": "Pracovná pozícia", + "t_street": "Ulica", + "t_pobox": "P. O. Box", + "t_locality": "Mesto", + "t_region": "Kraj alebo región", + "t_postcode": "PSČ", + "t_country": "Krajina", + "t_web": "Web", + "t_birthday": "Narodeniny", + "t_anniversary": "Výročie", + "t_notes": "Poznámky", + "t_categories": "Kategórie", + "t_type": "Typ hodnoty (Domov, Práca…)", + "merge_with": "Zlúčiť s…", + "merge_selected": "Zlúčiť…", + "find_duplicates": "Nájsť duplicity", + "possible_duplicates": "Možné duplicity ({count})", + "duplicates_title": "Možné duplicity", + "duplicates_hint": "Kontakty s rovnakým menom, e-mailovou adresou alebo telefónnym číslom.", + "duplicates_none": "Nenašli sa žiadne možné duplicity.", + "pick_title": "Zlúčiť {name} s…", + "pick_search": "Hľadať kontakty", + "pick_suggested": "Možné duplicity", + "pick_all": "Všetky kontakty", + "pick_none": "Žiadne ďalšie kontakty na zlúčenie.", + "preview_title": "Zlúčiť kontakty ({count})", + "preview_hint": "Z týchto kontaktov vznikne jedna karta so všetkým nižšie.", + "preview_deleted": "Prevzaté a potom odstránené: {names}", + "groups": "Skupiny", + "merge": "Zlúčiť", + "merging": "Zlučuje sa…", + "merged": "Kontakty zlúčené", + "merge_failed": "Kontakty sa nepodarilo zlúčiť.", + "merge_not_possible": "Tieto kontakty sa nedajú zlúčiť: sú v rôznych účtoch alebo v adresári, ktorý nemôžete meniť.", + "undo": "Späť", + "undone": "Zlúčenie vrátené", + "undo_failed": "Zlúčenie sa nepodarilo úplne vrátiť." + }, "find_time": { "suggest": "Navrhnúť časy", "searching": "Hľadajú sa voľné časy", diff --git a/locales/th/common.json b/locales/th/common.json index 6b6f20729..be2b47dfa 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -2763,6 +2763,88 @@ "title": "ว่าง/ไม่ว่าง", "description": "เพื่อนร่วมงานในกลุ่มเห็นว่าคุณไม่ว่างเมื่อใด แต่ไม่เห็นรายละเอียดกิจกรรม ฟีเจอร์แนะนำเวลาใช้ข้อมูลนี้" }, + "ux_pim_cimport": { + "file_hint": "คลิกเพื่อเลือกไฟล์ vCard หรือ CSV", + "file_types": "ไฟล์ .vcf หรือ .csv เช่น จาก Outlook หรือ Google Contacts", + "parse_error": "อ่านไฟล์ไม่ได้", + "columns_title": "จับคู่คอลัมน์", + "columns_hint": "เลือกว่าแต่ละคอลัมน์ของ {file} จะไปที่ใด คอลัมน์ที่ตั้งเป็น “ไม่นำเข้า” จะถูกข้าม", + "column": "คอลัมน์", + "sample": "ตัวอย่าง", + "field": "ช่องของผู้ติดต่อ", + "skip": "ไม่นำเข้า", + "no_columns": "เลือกช่องให้อย่างน้อยหนึ่งคอลัมน์", + "continue": "ดำเนินการต่อ", + "back": "ย้อนกลับ", + "import_into": "นำเข้าไปยัง", + "group_name": "ชื่อ", + "group_email": "อีเมล", + "group_phone": "โทรศัพท์", + "group_org": "องค์กร", + "group_adr_any": "ที่อยู่", + "group_adr_home": "ที่อยู่บ้าน", + "group_adr_work": "ที่อยู่ที่ทำงาน", + "group_adr_other": "ที่อยู่อื่น", + "group_more": "เพิ่มเติม", + "address_field": "{group}: {part}", + "t_name_full": "ชื่อเต็ม", + "t_name_given": "ชื่อ", + "t_name_middle": "ชื่อกลาง", + "t_name_surname": "นามสกุล", + "t_name_prefix": "คำนำหน้า (ดร., นาง)", + "t_name_suffix": "คำต่อท้าย (Jr.)", + "t_nickname": "ชื่อเล่น", + "t_email": "อีเมล", + "t_email_home": "อีเมล (บ้าน)", + "t_email_work": "อีเมล (ที่ทำงาน)", + "t_phone": "โทรศัพท์", + "t_phone_mobile": "มือถือ", + "t_phone_home": "โทรศัพท์ (บ้าน)", + "t_phone_work": "โทรศัพท์ (ที่ทำงาน)", + "t_phone_fax": "แฟกซ์", + "t_phone_fax_home": "แฟกซ์ (บ้าน)", + "t_phone_fax_work": "แฟกซ์ (ที่ทำงาน)", + "t_phone_pager": "เพจเจอร์", + "t_org_name": "บริษัท", + "t_org_unit": "แผนก", + "t_org_title": "ตำแหน่งงาน", + "t_street": "ถนน", + "t_pobox": "ตู้ไปรษณีย์", + "t_locality": "เมือง", + "t_region": "รัฐหรือภูมิภาค", + "t_postcode": "รหัสไปรษณีย์", + "t_country": "ประเทศ", + "t_web": "เว็บไซต์", + "t_birthday": "วันเกิด", + "t_anniversary": "วันครบรอบ", + "t_notes": "บันทึก", + "t_categories": "หมวดหมู่", + "t_type": "ประเภทของค่า (บ้าน, ที่ทำงาน…)", + "merge_with": "รวมกับ…", + "merge_selected": "รวม…", + "find_duplicates": "ค้นหารายการซ้ำ", + "possible_duplicates": "อาจซ้ำกัน ({count})", + "duplicates_title": "อาจซ้ำกัน", + "duplicates_hint": "ผู้ติดต่อที่มีชื่อ ที่อยู่อีเมล หรือหมายเลขโทรศัพท์เดียวกัน", + "duplicates_none": "ไม่พบรายการที่อาจซ้ำกัน", + "pick_title": "รวม {name} กับ…", + "pick_search": "ค้นหาผู้ติดต่อ", + "pick_suggested": "อาจซ้ำกัน", + "pick_all": "ผู้ติดต่อทั้งหมด", + "pick_none": "ไม่มีผู้ติดต่ออื่นให้รวม", + "preview_title": "รวมผู้ติดต่อ ({count})", + "preview_hint": "ผู้ติดต่อเหล่านี้จะกลายเป็นการ์ดเดียวที่มีทุกอย่างด้านล่าง", + "preview_deleted": "รวมเข้าแล้วลบ: {names}", + "groups": "กลุ่ม", + "merge": "รวม", + "merging": "กำลังรวม…", + "merged": "รวมผู้ติดต่อแล้ว", + "merge_failed": "รวมผู้ติดต่อไม่ได้", + "merge_not_possible": "รวมผู้ติดต่อเหล่านี้ไม่ได้ เพราะอยู่คนละบัญชี หรืออยู่ในสมุดที่อยู่ที่คุณเปลี่ยนแปลงไม่ได้", + "undo": "เลิกทำ", + "undone": "เลิกทำการรวมแล้ว", + "undo_failed": "เลิกทำการรวมได้ไม่ครบ" + }, "find_time": { "suggest": "แนะนำเวลา", "searching": "กำลังหาเวลาว่าง", diff --git a/locales/tr/common.json b/locales/tr/common.json index a6a174c0c..053fc2917 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -5175,6 +5175,88 @@ "title": "Müsaitlik", "description": "Gruptaki iş arkadaşlarınız ne zaman meşgul olduğunuzu görür, etkinliklerinizi görmez. Saat öner bunu kullanır." }, + "ux_pim_cimport": { + "file_hint": "Bir vCard veya CSV dosyası seçmek için tıklayın", + "file_types": "Örneğin Outlook veya Google Kişiler’den .vcf ya da .csv dosyaları", + "parse_error": "Dosya okunamadı", + "columns_title": "Sütunları eşleştirin", + "columns_hint": "{file} dosyasının her sütununun nereye gideceğini seçin. “İçe aktarma” olarak ayarlanan sütunlar atlanır.", + "column": "Sütun", + "sample": "Örnek", + "field": "Kişi alanı", + "skip": "İçe aktarma", + "no_columns": "En az bir sütun için bir alan seçin.", + "continue": "Devam", + "back": "Geri", + "import_into": "Şuraya aktar", + "group_name": "Ad", + "group_email": "E-posta", + "group_phone": "Telefon", + "group_org": "Kuruluş", + "group_adr_any": "Adres", + "group_adr_home": "Ev adresi", + "group_adr_work": "İş adresi", + "group_adr_other": "Diğer adres", + "group_more": "Diğer", + "address_field": "{group}: {part}", + "t_name_full": "Tam ad", + "t_name_given": "Ad", + "t_name_middle": "İkinci ad", + "t_name_surname": "Soyadı", + "t_name_prefix": "Unvan (Dr., Bayan)", + "t_name_suffix": "Sonek (Jr.)", + "t_nickname": "Takma ad", + "t_email": "E-posta", + "t_email_home": "E-posta (ev)", + "t_email_work": "E-posta (iş)", + "t_phone": "Telefon", + "t_phone_mobile": "Cep", + "t_phone_home": "Telefon (ev)", + "t_phone_work": "Telefon (iş)", + "t_phone_fax": "Faks", + "t_phone_fax_home": "Faks (ev)", + "t_phone_fax_work": "Faks (iş)", + "t_phone_pager": "Çağrı cihazı", + "t_org_name": "Şirket", + "t_org_unit": "Bölüm", + "t_org_title": "İş unvanı", + "t_street": "Sokak", + "t_pobox": "Posta kutusu", + "t_locality": "Şehir", + "t_region": "Eyalet veya bölge", + "t_postcode": "Posta kodu", + "t_country": "Ülke", + "t_web": "Web sitesi", + "t_birthday": "Doğum günü", + "t_anniversary": "Yıl dönümü", + "t_notes": "Notlar", + "t_categories": "Kategoriler", + "t_type": "Değerin türü (Ev, İş…)", + "merge_with": "Şununla birleştir…", + "merge_selected": "Birleştir…", + "find_duplicates": "Yinelenenleri bul", + "possible_duplicates": "Olası yinelenenler ({count})", + "duplicates_title": "Olası yinelenenler", + "duplicates_hint": "Aynı ada, e-posta adresine veya telefon numarasına sahip kişiler.", + "duplicates_none": "Olası yinelenen bulunamadı.", + "pick_title": "{name} kişisini şununla birleştir…", + "pick_search": "Kişilerde ara", + "pick_suggested": "Olası yinelenenler", + "pick_all": "Tüm kişiler", + "pick_none": "Birleştirilecek başka kişi yok.", + "preview_title": "Kişileri birleştir ({count})", + "preview_hint": "Bu kişiler, aşağıdakilerin hepsini içeren tek bir kart olur.", + "preview_deleted": "Alındı, ardından silindi: {names}", + "groups": "Gruplar", + "merge": "Birleştir", + "merging": "Birleştiriliyor…", + "merged": "Kişiler birleştirildi", + "merge_failed": "Kişiler birleştirilemedi.", + "merge_not_possible": "Bu kişiler birleştirilemez: farklı hesaplarda veya değiştiremediğiniz bir adres defterinde bulunuyorlar.", + "undo": "Geri al", + "undone": "Birleştirme geri alındı", + "undo_failed": "Birleştirme tamamen geri alınamadı." + }, "find_time": { "suggest": "Saat öner", "searching": "Boş saatler aranıyor", diff --git a/locales/uk/common.json b/locales/uk/common.json index 4ba2107e9..d9da831c0 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -5766,6 +5766,88 @@ "title": "Зайнятість", "description": "Колеги з групи бачать, коли ви зайняті, але не бачать ваших подій. Цим користується «Запропонувати час»." }, + "ux_pim_cimport": { + "file_hint": "Натисніть, щоб вибрати файл vCard або CSV", + "file_types": "Файли .vcf або .csv, наприклад з Outlook або Google Контактів", + "parse_error": "Не вдалося прочитати файл", + "columns_title": "Зіставте стовпці", + "columns_hint": "Виберіть, куди йде кожен стовпець файлу {file}. Стовпці з «Не імпортувати» пропускаються.", + "column": "Стовпець", + "sample": "Приклад", + "field": "Поле контакту", + "skip": "Не імпортувати", + "no_columns": "Виберіть поле хоча б для одного стовпця.", + "continue": "Далі", + "back": "Назад", + "import_into": "Імпортувати до", + "group_name": "Ім’я", + "group_email": "Ел. пошта", + "group_phone": "Телефон", + "group_org": "Організація", + "group_adr_any": "Адреса", + "group_adr_home": "Домашня адреса", + "group_adr_work": "Робоча адреса", + "group_adr_other": "Інша адреса", + "group_more": "Ще", + "address_field": "{group}: {part}", + "t_name_full": "Повне ім’я", + "t_name_given": "Ім’я", + "t_name_middle": "По батькові", + "t_name_surname": "Прізвище", + "t_name_prefix": "Звертання (д-р, пані)", + "t_name_suffix": "Суфікс (мол.)", + "t_nickname": "Псевдонім", + "t_email": "Ел. пошта", + "t_email_home": "Ел. пошта (дом.)", + "t_email_work": "Ел. пошта (роб.)", + "t_phone": "Телефон", + "t_phone_mobile": "Мобільний", + "t_phone_home": "Телефон (дом.)", + "t_phone_work": "Телефон (роб.)", + "t_phone_fax": "Факс", + "t_phone_fax_home": "Факс (дом.)", + "t_phone_fax_work": "Факс (роб.)", + "t_phone_pager": "Пейджер", + "t_org_name": "Компанія", + "t_org_unit": "Відділ", + "t_org_title": "Посада", + "t_street": "Вулиця", + "t_pobox": "Абонентська скринька", + "t_locality": "Місто", + "t_region": "Область або регіон", + "t_postcode": "Поштовий індекс", + "t_country": "Країна", + "t_web": "Сайт", + "t_birthday": "День народження", + "t_anniversary": "Річниця", + "t_notes": "Нотатки", + "t_categories": "Категорії", + "t_type": "Тип значення (Дім, Робота…)", + "merge_with": "Об’єднати з…", + "merge_selected": "Об’єднати…", + "find_duplicates": "Знайти дублікати", + "possible_duplicates": "Можливі дублікати ({count})", + "duplicates_title": "Можливі дублікати", + "duplicates_hint": "Контакти з однаковим ім’ям, адресою ел. пошти або номером телефону.", + "duplicates_none": "Можливих дублікатів не знайдено.", + "pick_title": "Об’єднати {name} з…", + "pick_search": "Пошук контактів", + "pick_suggested": "Можливі дублікати", + "pick_all": "Усі контакти", + "pick_none": "Немає інших контактів для об’єднання.", + "preview_title": "Об’єднати контакти ({count})", + "preview_hint": "Ці контакти стануть однією карткою з усім, що нижче.", + "preview_deleted": "Перенесено, потім видалено: {names}", + "groups": "Групи", + "merge": "Об’єднати", + "merging": "Об’єднання…", + "merged": "Контакти об’єднано", + "merge_failed": "Не вдалося об’єднати контакти.", + "merge_not_possible": "Ці контакти не можна об’єднати: вони в різних облікових записах або в адресній книзі, яку ви не можете змінювати.", + "undo": "Скасувати", + "undone": "Об’єднання скасовано", + "undo_failed": "Не вдалося повністю скасувати об’єднання." + }, "find_time": { "suggest": "Запропонувати час", "searching": "Пошук вільного часу", diff --git a/locales/vi/common.json b/locales/vi/common.json index 7f56c7fe6..d5555583a 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -5187,6 +5187,88 @@ "title": "Rảnh/bận", "description": "Đồng nghiệp trong nhóm thấy khi nào bạn bận, không thấy nội dung sự kiện. Tính năng gợi ý thời gian dùng thông tin này." }, + "ux_pim_cimport": { + "file_hint": "Bấm để chọn tệp vCard hoặc CSV", + "file_types": "Tệp .vcf hoặc .csv, ví dụ từ Outlook hoặc Google Danh bạ", + "parse_error": "Không đọc được tệp", + "columns_title": "Ghép các cột", + "columns_hint": "Chọn nơi đặt từng cột của {file}. Các cột đặt là “Không nhập” sẽ bị bỏ qua.", + "column": "Cột", + "sample": "Ví dụ", + "field": "Trường liên hệ", + "skip": "Không nhập", + "no_columns": "Chọn một trường cho ít nhất một cột.", + "continue": "Tiếp tục", + "back": "Quay lại", + "import_into": "Nhập vào", + "group_name": "Tên", + "group_email": "Email", + "group_phone": "Điện thoại", + "group_org": "Tổ chức", + "group_adr_any": "Địa chỉ", + "group_adr_home": "Địa chỉ nhà", + "group_adr_work": "Địa chỉ cơ quan", + "group_adr_other": "Địa chỉ khác", + "group_more": "Thêm", + "address_field": "{group}: {part}", + "t_name_full": "Họ và tên", + "t_name_given": "Tên", + "t_name_middle": "Tên đệm", + "t_name_surname": "Họ", + "t_name_prefix": "Danh xưng (TS., Bà)", + "t_name_suffix": "Hậu tố (Jr.)", + "t_nickname": "Biệt danh", + "t_email": "Email", + "t_email_home": "Email (nhà)", + "t_email_work": "Email (cơ quan)", + "t_phone": "Điện thoại", + "t_phone_mobile": "Di động", + "t_phone_home": "Điện thoại (nhà)", + "t_phone_work": "Điện thoại (cơ quan)", + "t_phone_fax": "Fax", + "t_phone_fax_home": "Fax (nhà)", + "t_phone_fax_work": "Fax (cơ quan)", + "t_phone_pager": "Máy nhắn tin", + "t_org_name": "Công ty", + "t_org_unit": "Phòng ban", + "t_org_title": "Chức danh", + "t_street": "Đường", + "t_pobox": "Hộp thư bưu điện", + "t_locality": "Thành phố", + "t_region": "Tỉnh hoặc vùng", + "t_postcode": "Mã bưu chính", + "t_country": "Quốc gia", + "t_web": "Trang web", + "t_birthday": "Sinh nhật", + "t_anniversary": "Ngày kỷ niệm", + "t_notes": "Ghi chú", + "t_categories": "Danh mục", + "t_type": "Loại giá trị (Nhà, Cơ quan…)", + "merge_with": "Gộp với…", + "merge_selected": "Gộp…", + "find_duplicates": "Tìm liên hệ trùng", + "possible_duplicates": "Có thể trùng ({count})", + "duplicates_title": "Có thể trùng", + "duplicates_hint": "Các liên hệ có cùng tên, địa chỉ email hoặc số điện thoại.", + "duplicates_none": "Không tìm thấy liên hệ có thể trùng.", + "pick_title": "Gộp {name} với…", + "pick_search": "Tìm liên hệ", + "pick_suggested": "Có thể trùng", + "pick_all": "Tất cả liên hệ", + "pick_none": "Không có liên hệ nào khác để gộp.", + "preview_title": "Gộp liên hệ ({count})", + "preview_hint": "Các liên hệ này trở thành một thẻ với mọi thứ bên dưới.", + "preview_deleted": "Đã gộp vào rồi xóa: {names}", + "groups": "Nhóm", + "merge": "Gộp", + "merging": "Đang gộp…", + "merged": "Đã gộp liên hệ", + "merge_failed": "Không gộp được các liên hệ.", + "merge_not_possible": "Không thể gộp các liên hệ này: chúng ở các tài khoản khác nhau hoặc trong sổ địa chỉ bạn không được thay đổi.", + "undo": "Hoàn tác", + "undone": "Đã hoàn tác việc gộp", + "undo_failed": "Không thể hoàn tác hết việc gộp." + }, "find_time": { "suggest": "Gợi ý thời gian", "searching": "Đang tìm thời gian trống", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index b505cc706..e2ed1c967 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -5175,6 +5175,88 @@ "title": "忙碌/空閒", "description": "群組中的同事能看到你何時忙碌,看不到活動內容。「建議時間」會用到它。" }, + "ux_pim_cimport": { + "file_hint": "按一下以選取 vCard 或 CSV 檔案", + "file_types": ".vcf 或 .csv 檔案,例如來自 Outlook 或 Google 聯絡人", + "parse_error": "無法讀取檔案", + "columns_title": "對應欄位", + "columns_hint": "選擇 {file} 中每一欄的去處。設為「不匯入」的欄會略過。", + "column": "欄", + "sample": "範例", + "field": "聯絡人欄位", + "skip": "不匯入", + "no_columns": "請至少為一欄選擇欄位。", + "continue": "繼續", + "back": "返回", + "import_into": "匯入至", + "group_name": "姓名", + "group_email": "電子郵件", + "group_phone": "電話", + "group_org": "組織", + "group_adr_any": "地址", + "group_adr_home": "住家地址", + "group_adr_work": "公司地址", + "group_adr_other": "其他地址", + "group_more": "更多", + "address_field": "{group}:{part}", + "t_name_full": "全名", + "t_name_given": "名字", + "t_name_middle": "中間名", + "t_name_surname": "姓氏", + "t_name_prefix": "稱謂(博士、女士)", + "t_name_suffix": "後綴(Jr.)", + "t_nickname": "暱稱", + "t_email": "電子郵件", + "t_email_home": "電子郵件(住家)", + "t_email_work": "電子郵件(公司)", + "t_phone": "電話", + "t_phone_mobile": "行動電話", + "t_phone_home": "電話(住家)", + "t_phone_work": "電話(公司)", + "t_phone_fax": "傳真", + "t_phone_fax_home": "傳真(住家)", + "t_phone_fax_work": "傳真(公司)", + "t_phone_pager": "呼叫器", + "t_org_name": "公司", + "t_org_unit": "部門", + "t_org_title": "職稱", + "t_street": "街道", + "t_pobox": "郵政信箱", + "t_locality": "城市", + "t_region": "州/省或地區", + "t_postcode": "郵遞區號", + "t_country": "國家/地區", + "t_web": "網站", + "t_birthday": "生日", + "t_anniversary": "紀念日", + "t_notes": "備註", + "t_categories": "類別", + "t_type": "值的類型(住家、公司…)", + "merge_with": "合併至…", + "merge_selected": "合併…", + "find_duplicates": "尋找重複項目", + "possible_duplicates": "可能重複({count})", + "duplicates_title": "可能重複", + "duplicates_hint": "姓名、電子郵件地址或電話號碼相同的聯絡人。", + "duplicates_none": "找不到可能重複的聯絡人。", + "pick_title": "將 {name} 合併至…", + "pick_search": "搜尋聯絡人", + "pick_suggested": "可能重複", + "pick_all": "所有聯絡人", + "pick_none": "沒有其他可合併的聯絡人。", + "preview_title": "合併聯絡人({count})", + "preview_hint": "這些聯絡人會合為一張名片,包含下方所有內容。", + "preview_deleted": "併入後刪除:{names}", + "groups": "群組", + "merge": "合併", + "merging": "正在合併…", + "merged": "聯絡人已合併", + "merge_failed": "無法合併這些聯絡人。", + "merge_not_possible": "這些聯絡人無法合併:它們位於不同帳戶,或位於您無法變更的通訊錄中。", + "undo": "復原", + "undone": "已復原合併", + "undo_failed": "無法完全復原合併。" + }, "find_time": { "suggest": "建議時間", "searching": "正在尋找空閒時間", diff --git a/locales/zh/common.json b/locales/zh/common.json index b09c5fc09..1c5ce5603 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -5766,6 +5766,88 @@ "title": "忙闲", "description": "群组中的同事能看到你何时忙碌,看不到活动内容。“建议时间”会用到它。" }, + "ux_pim_cimport": { + "file_hint": "点击选择 vCard 或 CSV 文件", + "file_types": ".vcf 或 .csv 文件,例如来自 Outlook 或 Google 通讯录", + "parse_error": "无法读取该文件", + "columns_title": "匹配列", + "columns_hint": "选择 {file} 中每一列的去向。设为“不导入”的列将被跳过。", + "column": "列", + "sample": "示例", + "field": "联系人字段", + "skip": "不导入", + "no_columns": "请至少为一列选择字段。", + "continue": "继续", + "back": "返回", + "import_into": "导入到", + "group_name": "姓名", + "group_email": "电子邮件", + "group_phone": "电话", + "group_org": "组织", + "group_adr_any": "地址", + "group_adr_home": "家庭地址", + "group_adr_work": "工作地址", + "group_adr_other": "其他地址", + "group_more": "更多", + "address_field": "{group}:{part}", + "t_name_full": "全名", + "t_name_given": "名", + "t_name_middle": "中间名", + "t_name_surname": "姓", + "t_name_prefix": "称谓(博士、女士)", + "t_name_suffix": "后缀(Jr.)", + "t_nickname": "昵称", + "t_email": "电子邮件", + "t_email_home": "电子邮件(家庭)", + "t_email_work": "电子邮件(工作)", + "t_phone": "电话", + "t_phone_mobile": "手机", + "t_phone_home": "电话(家庭)", + "t_phone_work": "电话(工作)", + "t_phone_fax": "传真", + "t_phone_fax_home": "传真(家庭)", + "t_phone_fax_work": "传真(工作)", + "t_phone_pager": "寻呼机", + "t_org_name": "公司", + "t_org_unit": "部门", + "t_org_title": "职位", + "t_street": "街道", + "t_pobox": "邮政信箱", + "t_locality": "城市", + "t_region": "省/州或地区", + "t_postcode": "邮政编码", + "t_country": "国家/地区", + "t_web": "网站", + "t_birthday": "生日", + "t_anniversary": "纪念日", + "t_notes": "备注", + "t_categories": "类别", + "t_type": "值的类型(家庭、工作…)", + "merge_with": "合并到…", + "merge_selected": "合并…", + "find_duplicates": "查找重复项", + "possible_duplicates": "可能的重复项({count})", + "duplicates_title": "可能的重复项", + "duplicates_hint": "姓名、电子邮件地址或电话号码相同的联系人。", + "duplicates_none": "未找到可能的重复项。", + "pick_title": "将 {name} 合并到…", + "pick_search": "搜索联系人", + "pick_suggested": "可能的重复项", + "pick_all": "所有联系人", + "pick_none": "没有其他可合并的联系人。", + "preview_title": "合并联系人({count})", + "preview_hint": "这些联系人将合为一张名片,包含下方的全部内容。", + "preview_deleted": "并入后删除:{names}", + "groups": "群组", + "merge": "合并", + "merging": "正在合并…", + "merged": "联系人已合并", + "merge_failed": "无法合并这些联系人。", + "merge_not_possible": "这些联系人无法合并:它们位于不同账户,或位于你无法更改的通讯录中。", + "undo": "撤销", + "undone": "已撤销合并", + "undo_failed": "无法完全撤销合并。" + }, "find_time": { "suggest": "建议时间", "searching": "正在查找空闲时间", diff --git a/stores/__tests__/contact-store-merge-import.test.ts b/stores/__tests__/contact-store-merge-import.test.ts new file mode 100644 index 000000000..e1e91eb47 --- /dev/null +++ b/stores/__tests__/contact-store-merge-import.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useContactStore } from '../contact-store'; +import { planMerge } from '@/lib/contacts/merge'; +import type { AddressBook, ContactCard } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +const books: AddressBook[] = [ + { id: 'ab-1', name: 'Personal', isDefault: true } as AddressBook, + { id: 'ab-2', name: 'Work' } as AddressBook, + { id: 'team:ab-9', originalId: 'ab-9', name: 'Team', isShared: true, accountId: 'team', accountName: 'Team' } as AddressBook, +]; + +function makeClient() { + let n = 0; + return { + getContactsAccountId: vi.fn(() => 'acct'), + createContact: vi.fn(async (c: Partial) => ({ ...c, id: `new-${++n}` } as ContactCard)), + updateContact: vi.fn().mockResolvedValue(undefined), + deleteContact: vi.fn().mockResolvedValue(undefined), + }; +} + +const incoming: ContactCard = { + id: 'import-1', + addressBookIds: {}, + name: { components: [{ kind: 'given', value: 'Ada' }], isOrdered: true }, +}; + +beforeEach(() => { + useContactStore.setState({ contacts: [], addressBooks: books, supportsSync: true, selectedContactIds: new Set() }); +}); + +describe('importContacts into a chosen address book', () => { + it('files the cards in the book picked, not the default one', async () => { + const client = makeClient(); + const count = await useContactStore.getState().importContacts(client as unknown as IJMAPClient, [incoming], 'ab-2'); + expect(count).toBe(1); + expect(client.createContact).toHaveBeenCalledWith(expect.objectContaining({ addressBookIds: { 'ab-2': true } }), undefined); + expect(client.createContact.mock.calls[0][0]).not.toHaveProperty('id'); + }); + + it('writes to the sharing account for a shared book, and lists the card under its namespaced ids', async () => { + const client = makeClient(); + await useContactStore.getState().importContacts(client as unknown as IJMAPClient, [incoming], 'team:ab-9'); + expect(client.createContact).toHaveBeenCalledWith(expect.objectContaining({ addressBookIds: { 'ab-9': true } }), 'team'); + const [card] = useContactStore.getState().contacts; + expect(card).toMatchObject({ id: 'team:new-1', originalId: 'new-1', isShared: true, accountId: 'team', addressBookIds: { 'team:ab-9': true } }); + }); + + it('without a book, leaves the choice to the server as before', async () => { + const client = makeClient(); + await useContactStore.getState().importContacts(client as unknown as IJMAPClient, [incoming]); + expect(client.createContact).toHaveBeenCalledWith(expect.objectContaining({ addressBookIds: {} })); + }); + + it('puts local cards in the book too', async () => { + useContactStore.setState({ supportsSync: false }); + await useContactStore.getState().importContacts(null, [incoming], 'ab-2'); + expect(useContactStore.getState().contacts[0].addressBookIds).toEqual({ 'ab-2': true }); + }); +}); + +describe('mergeContacts', () => { + const older: ContactCard = { + id: 'c-old', uid: 'urn:uuid:old', addressBookIds: { 'ab-1': true }, 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' } }, + phones: { p0: { number: '+49 30 550000' } }, + }; + const newer: ContactCard = { + id: 'c-new', uid: 'urn:uuid:new', addressBookIds: { 'ab-1': true }, 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 team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: { 'ab-1': true }, members: { 'urn:uuid:old': true, 'urn:uuid:x': true } }; + + it('writes the kept card, moves the group membership, deletes the other card, and Undo puts it all back', async () => { + useContactStore.setState({ contacts: [older, newer, team] }); + const client = makeClient(); + const store = useContactStore.getState(); + const plan = planMerge([older, newer]); + + const undo = await store.mergeContacts(client as unknown as IJMAPClient, plan); + expect(undo).not.toBeNull(); + expect(client.updateContact).toHaveBeenCalledWith('c-new', { + emails: { e0: { address: 'anna@example.net' }, e0m1: { address: 'anna@home.example.net' } }, + phones: { p0: { number: '+49 30 550000' } }, + }, undefined); + expect(client.updateContact).toHaveBeenCalledWith('g-team', { members: { 'urn:uuid:x': true, 'urn:uuid:new': true } }, undefined); + expect(client.deleteContact).toHaveBeenCalledWith('c-old', undefined); + let contacts = useContactStore.getState().contacts; + expect(contacts.map((c) => c.id)).toEqual(['c-new', 'g-team']); + expect(contacts[1].members).toEqual({ 'urn:uuid:x': true, 'urn:uuid:new': true }); + expect(Object.keys(contacts[0].emails ?? {})).toEqual(['e0', 'e0m1']); + + client.updateContact.mockClear(); + expect(await useContactStore.getState().undoMergeContacts(client as unknown as IJMAPClient, undo!)).toBe(true); + // The other card comes back under its own uid, so the group finds it again. + expect(client.createContact).toHaveBeenCalledWith(expect.objectContaining({ uid: 'urn:uuid:old', emails: older.emails }), undefined); + expect(client.createContact.mock.calls[0][0]).not.toHaveProperty('id'); + expect(client.updateContact).toHaveBeenCalledWith('g-team', { members: team.members }, undefined); + // What the kept card didn't have is cleared again (undefined goes out as null). + const keptCall = client.updateContact.mock.calls.find((call) => call[0] === 'c-new'); + expect(keptCall?.[1]).toEqual({ emails: newer.emails }); + expect(keptCall?.[1]).toHaveProperty('phones', undefined); + contacts = useContactStore.getState().contacts; + expect(contacts.find((c) => c.id === 'g-team')?.members).toEqual(team.members); + expect(contacts.find((c) => c.uid === 'urn:uuid:old')).toBeTruthy(); + }); + + it('deletes nothing when the kept card cannot be written', async () => { + useContactStore.setState({ contacts: [older, newer, team] }); + const client = makeClient(); + client.updateContact.mockRejectedValueOnce(new Error('server said no')); + await expect(useContactStore.getState().mergeContacts(client as unknown as IJMAPClient, planMerge([older, newer]))).rejects.toThrow(); + expect(client.deleteContact).not.toHaveBeenCalled(); + expect(useContactStore.getState().contacts).toHaveLength(3); + }); +}); diff --git a/stores/contact-store.ts b/stores/contact-store.ts index 924dfc99a..ac6f7ff85 100644 --- a/stores/contact-store.ts +++ b/stores/contact-store.ts @@ -16,6 +16,18 @@ import { PrincipalsRequestError } from '@/lib/jmap/principals-error'; import { PartialAccountsError, partialAccounts, withFailedAccountsKept } from '@/lib/jmap/partial-accounts'; import type { Principal } from '@/lib/jmap/types'; import { currentStoreEpoch } from '@/lib/store-epoch'; +import { planGroupMoves, type MergePlan } from '@/lib/contacts/merge'; + +/** What a merge changed, to put it back with Undo. */ +export interface MergeUndo { + keepId: string; + /** The kept card's values before, for each property the merge changed (undefined: it had none). */ + before: Partial; + /** The merged cards as they were; their photos as data: URIs, since their blobs go with them. */ + others: ContactCard[]; + /** The groups' members before the merge. */ + groups: Array<{ groupId: string; members: Record }>; +} /** A contact that is about to be created, projected for plugins (no id yet). */ function draftContactView(contact: Partial) { @@ -481,7 +493,12 @@ interface ContactStore { /** Put a category on contacts, e.g. to undo removing it. */ addKeyword: (client: IJMAPClient | null, keyword: string, contactIds: string[]) => Promise; - importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise; + /** Into `addressBookId` when given, else where the server files new cards. */ + importContacts: (client: IJMAPClient | null, contacts: ContactCard[], addressBookId?: string) => Promise; + /** Merges the plan's cards into its kept card (lib/contacts/merge.ts); null when a plugin stopped it. */ + mergeContacts: (client: IJMAPClient | null, plan: MergePlan) => Promise; + /** Puts a merge back: the merged cards (with new ids), their groups, the kept card's old values. */ + undoMergeContacts: (client: IJMAPClient | null, undo: MergeUndo) => Promise; // Trusted senders address book loadTrustedSendersBook: (client: IJMAPClient) => Promise; @@ -1629,22 +1646,46 @@ export const useContactStore = create()( return get().trustedSenderEmails.includes(normalizedEmail); }, - importContacts: async (client, contacts) => { + importContacts: async (client, contacts, targetBookId) => { const { supportsSync } = get(); let imported = 0; // The book the cards landed in, as the server filed the first one. let addressBookId = ''; + const book = targetBookId ? get().addressBooks.find(b => b.id === targetBookId) : undefined; + // The chosen book as the server knows it, and the login and account it belongs to. + const target = book ? resolveRawAddressBookIds({ [book.id]: true }, get().addressBooks) : undefined; for (const contact of contacts) { try { let added: ContactCard; - if (client && supportsSync) { + if (client && supportsSync && book && target) { + const { id: _id, ...data } = contact; + const sharedAccountId = target.sharedAccountId; + const created = await resolveAccountClient(client, target.localAccountId) + .createContact({ ...data, addressBookIds: target.addressBookIds }, sharedAccountId); + // Under the ids the listings give it, as createContact keeps it. + added = sharedAccountId + ? { + ...created, + id: `${sharedAccountId}:${created.id}`, + originalId: created.id, + accountId: sharedAccountId, + accountName: book.accountName, + isShared: true, + addressBookIds: Object.fromEntries( + Object.entries(created.addressBookIds ?? {}).map(([bookId, v]) => [`${sharedAccountId}:${bookId}`, v]), + ), + } + : created; + if (target.localAccountId) added = prefixContactsWithLocalAccount([added], target.localAccountId)[0]; + } else if (client && supportsSync) { const { id: _id, ...data } = contact; added = await client.createContact(data); } else { added = { ...contact, id: `local-${generateUUID()}`, + ...(book ? { addressBookIds: { [book.id]: true } } : {}), }; } set((state) => ({ contacts: [...state.contacts, added] })); @@ -1661,6 +1702,94 @@ export const useContactStore = create()( } return imported; }, + + mergeContacts: async (client, plan) => { + const sync = !!client && get().supportsSync; + const { keep, others, patch } = plan; + const before: Partial = {}; + for (const key of Object.keys(patch) as Array) { + (before as Record)[key] = keep[key]; + } + // The kept card first: if that fails, nothing is deleted. + if (Object.keys(patch).length > 0) { + const updated = sync + ? await get().updateContact(client!, keep.id, patch) + : await get().updateLocalContact(keep.id, patch); + if (!updated) return null; + } + const groups: MergeUndo['groups'] = []; + const moves = planGroupMoves(get().contacts.filter(c => c.kind === 'group'), keep, others); + for (const { group, members } of moves) { + if (sync) { + const accountId = group.isShared ? group.accountId : undefined; + await resolveAccountClient(client!, group.localAccountId) + .updateContact(group.originalId || group.id, { members }, accountId); + } + groups.push({ groupId: group.id, members: { ...(group.members ?? {}) } }); + set((state) => ({ + contacts: state.contacts.map(c => c.id === group.id ? { ...c, members } : c), + })); + void contactHooks.onContactGroupMemberChange.emit({ groupId: group.id, added: [keep.id], removed: others.map(o => o.id) }); + } + await get().bulkDeleteContacts(sync ? client : null, others.map(o => o.id)); + return { keepId: keep.id, before, others, groups }; + }, + + undoMergeContacts: async (client, undo) => { + const sync = !!client && get().supportsSync; + let ok = true; + for (const card of undo.others) { + try { + let created: boolean; + if (sync) { + const { id: _id, originalId: _originalId, created: _created, updated: _updated, ...data } = card; + // A photo still only in a blob went with the card. + const media = card.media + ? Object.fromEntries(Object.entries(card.media).filter(([, m]) => !!m?.uri)) + : undefined; + created = await get().createContact(client!, { + ...data, + media: media && Object.keys(media).length > 0 ? media : undefined, + }); + } else { + created = await get().addLocalContact(card); + } + if (!created) ok = false; + } catch (error) { + console.error('Failed to restore a merged contact:', error); + ok = false; + } + } + for (const { groupId, members } of undo.groups) { + const group = get().contacts.find(c => c.id === groupId); + if (!group) continue; + try { + if (sync) { + const accountId = group.isShared ? group.accountId : undefined; + await resolveAccountClient(client!, group.localAccountId) + .updateContact(group.originalId || group.id, { members }, accountId); + } + set((state) => ({ + contacts: state.contacts.map(c => c.id === groupId ? { ...c, members } : c), + })); + } catch (error) { + console.error('Failed to restore group members:', error); + ok = false; + } + } + if (Object.keys(undo.before).length > 0) { + try { + const restored = sync + ? await get().updateContact(client!, undo.keepId, undo.before) + : await get().updateLocalContact(undo.keepId, undo.before); + if (!restored) ok = false; + } catch (error) { + console.error('Failed to restore the kept contact:', error); + ok = false; + } + } + return ok; + }, }); }, {