Merge branch 'feat/do-ux-pim-cimport' into feat/do-ux-pim-fixes

# Conflicts:
#	components/contacts/contact-list.tsx
#	components/contacts/contacts-app.tsx
This commit is contained in:
Linus Rath
2026-11-04 11:09:40 +01:00
51 changed files with 5082 additions and 97 deletions
@@ -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(
<ContactImportDialog
existingContacts={existing}
onImport={onImport}
onClose={vi.fn()}
initialFile={cp1252File(OUTLOOK_DE)}
addressBooks={books}
defaultAddressBookId="b-work"
/>,
);
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(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={cp1252File(OUTLOOK_DE)} />);
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(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} initialFile={cp1252File('Foo,Bar\r\n1,2\r\n')} />);
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(<ContactImportDialog existingContacts={[]} onImport={vi.fn()} onClose={vi.fn()} />);
expect(container.ownerDocument.querySelector('input[type="file"]')?.getAttribute('accept')).toContain('.csv');
});
});
@@ -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", () => {
@@ -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(
<ContactList
contacts={[alice]} selectedContactId={null} searchQuery="" onSearchChange={noop} onSelectContact={noop}
onCreateNew={noop} selectedContactIds={new Set()} onToggleSelection={noop} onSelectRangeContacts={noop}
onSelectAll={noop} onClearSelection={noop} onBulkDelete={noop} onBulkAddToGroup={noop} onBulkExport={noop}
onEditContact={noop} onDeleteContact={noop} onAddContactToGroup={noop} onDuplicateContact={noop} onComposeContact={noop}
onEditContact={noop} onDeleteContact={noop} onAddContactToGroup={noop} onMergeContact={noop} onComposeContact={noop}
/>,
);
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(<ContactDetail contact={alice} onEdit={noop} onDelete={noop} onDuplicate={noop} onAddToGroup={noop} onCompose={noop} />);
render(<ContactDetail contact={alice} onEdit={noop} onDelete={noop} onMergeWith={noop} onAddToGroup={noop} onCompose={noop} />);
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.
+15 -7
View File
@@ -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 && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onBatchMoveTo)} />
)}
{onBatchMerge && (
<ContextMenuItem icon={Merge} label={tMerge("merge_selected")} onClick={handle(onBatchMerge)} />
)}
<ContextMenuItem
icon={Download}
label={t("bulk.export")}
@@ -192,11 +200,11 @@ export function ContactContextMenu({
onClick={handle(onAddToGroup)}
/>
)}
{onDuplicate && (
{onMergeWith && !readOnly && (
<ContextMenuItem
icon={Copy}
label={t("context_menu.duplicate")}
onClick={handle(onDuplicate)}
icon={Merge}
label={tMerge("merge_with")}
onClick={handle(onMergeWith)}
/>
)}
{onMoveTo && !readOnly && (
+4 -3
View File
@@ -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}
/>
)}
@@ -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 (
<div className="flex flex-col h-full" data-testid="contact-duplicates">
<div className="px-6 py-4 border-b border-border">
<h2 className="text-lg font-semibold">{t("duplicates_title")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("duplicates_hint")}</p>
</div>
{groups.length === 0 ? (
<EmptyState compact icon={Users} title={t("duplicates_none")} />
) : (
<ul className="flex-1 overflow-y-auto p-4 space-y-3">
{groups.map((group) => (
<li key={group.map((c) => c.id).join("|")} className="border border-border rounded-md">
<ul className="divide-y divide-border">
{group.map((card) => {
const name = getContactDisplayName(card);
const email = getContactPrimaryEmail(card);
const phone = Object.values(card.phones ?? {})[0]?.number;
const book = bookNames(card);
return (
<li key={card.id}>
<button
type="button"
onClick={() => onOpen(card.id)}
className="w-full text-start px-3 py-2 hover:bg-muted transition-colors"
>
<div className="text-sm font-medium truncate">{name || email || "-"}</div>
<div className="text-xs text-muted-foreground truncate">
{[name ? email : "", phone, book].filter(Boolean).join(" · ")}
</div>
</button>
</li>
);
})}
</ul>
<div className="flex justify-end px-3 py-2 border-t border-border">
<Button size="sm" variant="outline" onClick={() => onMerge(group)}>
<Merge className="w-4 h-4 me-1.5" />
{t("merge_selected")}
</Button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
+207 -31
View File
@@ -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<number>;
onImport: (contacts: ContactCard[], addressBookId?: string) => Promise<number>;
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<Uint8Array> {
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<HTMLInputElement>(null);
const [parsed, setParsed] = useState<ContactCard[]>([]);
@@ -34,6 +62,26 @@ export function ContactImportDialog({
const [isImporting, setIsImporting] = useState(false);
const [result, setResult] = useState<number | null>(null);
const [error, setError] = useState<string | null>(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<CsvColumn[]>([]);
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<number>();
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<number>();
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<HTMLInputElement>) => {
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({
</div>
);
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 ? (
<>
<Button variant="outline" onClick={backToFile} className="me-auto">
{tCsv("back")}
</Button>
<Button variant="outline" onClick={onClose}>
{t("form.cancel")}
</Button>
<Button onClick={continueFromColumns} disabled={!anyColumnMatched}>
{tCsv("continue")}
</Button>
</>
) : parsed.length > 0 ? (
<>
{csv && (
<Button variant="outline" onClick={backToColumns} disabled={isImporting}>
{tCsv("back")}
</Button>
)}
<p className="text-sm text-muted-foreground me-auto self-center">
{t("import.selected", { count: selected.size })}
</p>
<Button variant="outline" onClick={onClose} disabled={isImporting}>
{t("form.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
{isImporting ? t("import.importing") : t("import.import_button")}
</Button>
</>
) : undefined;
return (
<Dialog
isOpen
onClose={onClose}
title={t("import.title")}
size="lg"
// Picked cards are not thrown away by a stray click outside.
dirty={parsed.length > 0 && result === null}
footer={parsed.length > 0 && result === null ? (
<>
<p className="text-sm text-muted-foreground me-auto self-center">
{t("import.selected", { count: selected.size })}
</p>
<Button variant="outline" onClick={onClose} disabled={isImporting}>
{t("form.cancel")}
</Button>
<Button onClick={handleImport} disabled={isImporting || selected.size === 0}>
{isImporting ? t("import.importing") : t("import.import_button")}
</Button>
</>
) : undefined}
// Picked cards and matched columns are not thrown away by a stray click outside.
dirty={(parsed.length > 0 || matching) && result === null}
footer={footer}
>
<div className="space-y-4 pt-4">
{result !== null ? (
@@ -154,12 +266,59 @@ export function ContactImportDialog({
{t("import.close")}
</Button>
</div>
) : matching && csv ? (
<>
{errorBanner}
<div>
<h3 className="text-sm font-medium">{tCsv("columns_title")}</h3>
<p className="text-sm text-muted-foreground mt-1">{tCsv("columns_hint", { file: csv.fileName })}</p>
</div>
<div className="border rounded-md max-h-96 overflow-y-auto" data-testid="csv-columns">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-background">
<tr className="border-b text-start text-xs text-muted-foreground">
<th scope="col" className="px-3 py-2 font-medium text-start">{tCsv("column")}</th>
<th scope="col" className="px-3 py-2 font-medium text-start">{tCsv("sample")}</th>
<th scope="col" className="px-3 py-2 font-medium text-start">{tCsv("field")}</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{columns.map((col) => (
<tr key={col.index} className={cn(col.target === "skip" && "text-muted-foreground")}>
<th scope="row" className="px-3 py-1.5 font-normal text-start max-w-[12rem] truncate" title={col.header}>
{col.header || "-"}
</th>
<td className="px-3 py-1.5 max-w-[12rem] truncate" title={col.sample}>{col.sample}</td>
<td className="px-3 py-1.5">
<select
aria-label={col.header}
value={col.target}
onChange={(e) => setTarget(col.index, e.target.value as CsvTarget)}
className="w-full text-sm bg-transparent border border-input rounded-md px-2 py-1 text-foreground focus:outline-hidden hover:border-muted-foreground/50 transition-colors"
>
<option value="skip">{tCsv("skip")}</option>
{CSV_TARGET_GROUPS.map((group) => (
<optgroup key={group.id} label={groupLabel(group.id)}>
{group.targets.map((target) => (
<option key={target} value={target}>{optionLabel(group.id, target)}</option>
))}
</optgroup>
))}
</select>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!anyColumnMatched && <p className="text-sm text-muted-foreground">{tCsv("no_columns")}</p>}
</>
) : parsed.length === 0 ? (
<>
<input
ref={fileRef}
type="file"
accept=".vcf,.vcard"
accept=".vcf,.vcard,.csv,text/csv"
onChange={handleFileChange}
className="hidden"
/>
@@ -175,8 +334,8 @@ export function ContactImportDialog({
)}
>
<Upload className="w-8 h-8" />
<p className="text-sm font-medium">{t("import.drop_hint")}</p>
<p className="text-xs">{t("import.file_types")}</p>
<p className="text-sm font-medium">{tCsv("file_hint")}</p>
<p className="text-xs">{tCsv("file_types")}</p>
</button>
{errorBanner}
@@ -185,6 +344,23 @@ export function ContactImportDialog({
<>
{errorBanner}
{addressBooks.length > 1 && (
<label className="flex items-center gap-3 text-sm">
<span className="font-medium shrink-0">{tCsv("import_into")}</span>
<select
value={bookId}
onChange={(e) => setBookId(e.target.value)}
className="flex-1 min-w-0 text-sm bg-transparent border border-input rounded-md px-2.5 py-1.5 text-foreground focus:outline-hidden hover:border-muted-foreground/50 transition-colors"
>
{addressBooks.map((book) => (
<option key={book.id} value={book.id}>
{book.isShared && book.accountName ? `${book.name} (${book.accountName})` : book.name}
</option>
))}
</select>
</label>
)}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
{t("import.found", { count: parsed.length })}
+21 -4
View File
@@ -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({
<span className="hidden @md:inline">{t("address_books.move_to")}</span>
</Button>
)}
{onBulkMerge && selectedContactIds.size > 1 && (
<Button variant="ghost" size="xs" onClick={onBulkMerge} aria-label={tMerge("merge_selected")} title={tMerge("merge_selected")} className="shrink-0">
<Merge className="w-3.5 h-3.5 @md:me-1" />
<span className="hidden @md:inline">{tMerge("merge_selected")}</span>
</Button>
)}
<Button variant="ghost" size="xs" onClick={onBulkExport} disabled={exporting} aria-busy={exporting} aria-label={t("bulk.export")} title={t("bulk.export")} className="shrink-0">
{exporting
? <Loader2 className="w-3.5 h-3.5 @md:me-1 animate-spin" />
@@ -646,6 +660,8 @@ export function ContactList({
</div>
)}
{!hasSelection && banner}
{/* Contact list */}
<div
ref={listRef}
@@ -787,12 +803,13 @@ export function ContactList({
onDelete={() => 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}
/>
)}
@@ -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<boolean>;
onClose: () => void;
}
function PickRow({ card, onPick }: { card: ContactCard; onPick: () => void }) {
const name = getContactDisplayName(card);
const email = getContactPrimaryEmail(card);
return (
<button
type="button"
onClick={onPick}
className="w-full flex flex-col items-start px-3 py-2 text-start hover:bg-muted transition-colors"
>
<span className="text-sm font-medium truncate max-w-full">{name || email || "-"}</span>
{name && email && <span className="text-xs text-muted-foreground truncate max-w-full">{email}</span>}
</button>
);
}
/** "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<ContactCard[] | null>(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 (
<Dialog
isOpen
onClose={onClose}
title={t("pick_title", { name })}
footer={<Button variant="outline" onClick={onClose}>{tc("form.cancel")}</Button>}
>
<div className="space-y-3 pt-4">
<div className="relative">
<Search className="w-4 h-4 absolute start-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("pick_search")}
aria-label={t("pick_search")}
className="ps-8"
/>
</div>
<div className="border rounded-md max-h-80 overflow-y-auto divide-y divide-border" data-testid="merge-pick-list">
{suggested.length > 0 && !query.trim() && (
<div>
<div className="px-3 py-1 text-xs font-medium text-muted-foreground bg-muted/40">{t("pick_suggested")}</div>
{suggested.map((card) => <PickRow key={card.id} card={card} onPick={() => pick(card)} />)}
</div>
)}
{filtered.length > 0 && (
<div>
{suggested.length > 0 && !query.trim() && (
<div className="px-3 py-1 text-xs font-medium text-muted-foreground bg-muted/40">{t("pick_all")}</div>
)}
{filtered.map((card) => <PickRow key={card.id} card={card} onPick={() => pick(card)} />)}
</div>
)}
{filtered.length === 0 && (suggested.length === 0 || query.trim()) && (
<p className="px-3 py-6 text-sm text-center text-muted-foreground">{t("pick_none")}</p>
)}
</div>
</div>
</Dialog>
);
}
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 (
<Dialog
isOpen
onClose={busy ? () => {} : onClose}
title={t("preview_title", { count: plan.others.length + 1 })}
description={t("preview_hint")}
size="lg"
footer={
<>
{pickFor && !initialCards && (
<Button variant="outline" onClick={() => setCards(null)} disabled={busy} className="me-auto">{t("back")}</Button>
)}
<Button variant="outline" onClick={onClose} disabled={busy}>{tc("form.cancel")}</Button>
<Button onClick={() => void merge()} disabled={busy}>{busy ? t("merging") : t("merge")}</Button>
</>
}
>
<div className="space-y-3 pt-4" data-testid="merge-preview">
<dl className="border rounded-md divide-y divide-border max-h-96 overflow-y-auto">
{rows.map((row) => (
<div key={row.label} className="grid grid-cols-[8rem_1fr] gap-3 px-3 py-2 text-sm">
<dt className="text-muted-foreground">{row.label}</dt>
<dd className="min-w-0">
{row.values.map((value, i) => (
<div key={i} className={cn("break-words", row.label === t("t_notes") && "whitespace-pre-line")}>{value}</div>
))}
</dd>
</div>
))}
</dl>
<p className="text-sm text-muted-foreground">{t("preview_deleted", { names: otherNames })}</p>
</div>
</Dialog>
);
}
+118 -36
View File
@@ -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` (`['<contactId>', '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<View>("list");
const [activeCategory, setActiveCategory] = useState<ContactCategory>("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<ContactCard[] | null>(null);
const [mergePickFor, setMergePickFor] = useState<ContactCard | null>(null);
const [renamingAddressBook, setRenamingAddressBook] = useState<AddressBook | null>(null);
const [creatingAddressBook, setCreatingAddressBook] = useState(false);
const [sharingAddressBookId, setSharingAddressBookId] = useState<string | null>(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<ContactCard> = 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<boolean> => {
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<ContactCard>, 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 (
<ContactDuplicatesView
groups={duplicateGroups}
addressBooks={addressBooks}
onMerge={handleMergeCards}
onOpen={(id) => { setSelectedContact(id); setView("detail"); }}
/>
);
case "bulk-add-to-group":
return (
<div className="flex flex-col h-full">
@@ -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" ? (
<button
type="button"
onClick={guard(() => setView("duplicates"))}
className="w-full flex items-center gap-2 px-4 py-1.5 border-b border-border text-start text-xs font-medium text-primary hover:bg-muted transition-colors"
>
<Merge className="w-3.5 h-3.5" />
{tMerge("possible_duplicates", { count: duplicateGroups.length })}
</button>
) : undefined}
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
emptyView={activeCategory !== "all"}
@@ -1438,6 +1504,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<>
<ContextMenuItem icon={UsersRound} label={t("groups.create")} onClick={() => { close(); guard(handleCreateGroup)(); }} />
<ContextMenuItem icon={Upload} label={tUxContact("import_menu")} onClick={() => { close(); setShowImportDialog(true); }} />
<ContextMenuItem icon={Merge} label={tMerge("find_duplicates")} onClick={() => { close(); guard(() => setView("duplicates"))(); }} />
{!printOff && <ContextMenuItem icon={Printer} label={tCommon("print")} onClick={() => { 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) && (
<ContactMergeDialog
cards={mergeCards ?? undefined}
pickFor={mergePickFor ?? undefined}
candidates={mergePickFor ? individuals.filter((c) => 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 && (() => {
+1
View File
@@ -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),
+1
View File
@@ -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,
+294
View File
@@ -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<string, unknown> & {
id: string;
uid?: string;
kind?: string;
name?: { full?: string; components?: Array<{ kind: string; value: string }> };
addressBookIds?: Record<string, boolean>;
members?: Record<string, boolean>;
emails?: Record<string, { address: string }>;
phones?: Record<string, { number: string; features?: Record<string, boolean>; contexts?: Record<string, boolean> }>;
addresses?: Record<string, { components?: Array<{ kind: string; value: string }>; contexts?: Record<string, boolean>; isOrdered?: boolean }>;
notes?: Record<string, { note: string }>;
organizations?: Record<string, { name?: string }>;
titles?: Record<string, { name: string }>;
anniversaries?: Record<string, { kind: string; date: Record<string, unknown> }>;
};
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<Array<{ id: string; name: string; isDefault?: boolean }>> {
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<Card[]> {
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<void> {
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<void> {
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<Record<string, string>>): 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<string, number> = { '€': 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();
});
});
+347
View File
@@ -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<Record<string, string>>, 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<string, number> = { '€': 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();
});
});
+143
View File
@@ -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>): 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']);
});
});
+494
View File
@@ -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<string, number> = { ',': 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<string, CsvTarget> = {};
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<string, CsvFamily> = {
'e mail': 'email', email: 'email', phone: 'phone', address: 'address', website: 'web', organization: 'org',
};
const GOOGLE_FIELDS: Record<CsvFamily, Record<string, CsvTarget>> = {
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<string, boolean> | 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<string, boolean> | 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<Record<AddressPart, string[]>>;
}
const ADDRESS_COMPONENT_KIND: Record<AddressPart, AddressComponent['kind']> = {
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<Record<'full' | 'given' | 'middle' | 'surname' | 'prefix' | 'suffix', string[]>> = {};
const orgs = new Map<string, { name: string[]; unit: string[]; title: string[] }>();
const addresses = new Map<string, AddressDraft>();
let birthday: PartialDate | undefined;
let anniversary: PartialDate | undefined;
const add = <T>(key: 'emails' | 'phones' | 'onlineServices' | 'notes' | 'nicknames', prefix: string, entry: T) => {
const map = ((card as unknown as Record<string, Record<string, T>>)[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;
}
+262
View File
@@ -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<string, number>();
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<number, ContactCard[]>();
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<string, unknown>)[k] !== undefined)
.map((k) => `${JSON.stringify(k)}:${stable((value as Record<string, unknown>)[k])}`).join(',')}}`;
}
return JSON.stringify(value);
}
function addressText(entry: Record<string, unknown>): 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, unknown>): 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<string, unknown>, 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<ContactCard>;
/** 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<string, unknown>;
for (const other of others) {
const source = other as unknown as Record<string, unknown>;
// Organisation ids of this card as the merged card has them, for its titles.
const orgIds = new Map<string, string>();
for (const key of MULTI_KEYS) {
const from = source[key] as Record<string, Record<string, unknown>> | undefined;
if (!from || Object.keys(from).length === 0) continue;
const into = (record[key] ?? {}) as Record<string, Record<string, unknown>>;
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<ContactCard> = {};
const before = keep as unknown as Record<string, unknown>;
for (const key of [...MULTI_KEYS, ...SINGLE_KEYS, 'keywords', 'media'] as const) {
if (stable(record[key] ?? null) !== stable(before[key] ?? null)) {
(patch as Record<string, unknown>)[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<string>();
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<string, boolean> }> {
const otherKeys = new Set(others.flatMap(memberKeys));
const keepKeys = memberKeys(keep);
const moves: Array<{ group: ContactCard; members: Record<string, boolean> }> = [];
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;
}
+9 -1
View File
@@ -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> = {}): 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']);
+9 -1
View File
@@ -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<HandoffKind, 'compose'> | 'image' | 'other';
/** `csv`: a contacts import on the Contacts screen (Outlook, Google), anything else elsewhere. */
export type DroppedKind = Exclude<HandoffKind, 'compose'> | '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);
+82
View File
@@ -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": "جارٍ البحث عن أوقات متاحة",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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": "در حال جست‌وجوی زمان‌های آزاد",
+82
View File
@@ -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",
+82
View File
@@ -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": "מחפש זמנים פנויים",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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": "空いている時間を探しています",
+82
View File
@@ -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": "빈 시간을 찾는 중",
+82
View File
@@ -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",
+82
View File
@@ -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": "Чөлөөтэй цаг хайж байна",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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",
+82
View File
@@ -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": "Поиск свободного времени",
+82
View File
@@ -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",
+82
View File
@@ -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": "กำลังหาเวลาว่าง",
+82
View File
@@ -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",
+82
View File
@@ -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": "Пошук вільного часу",
+82
View File
@@ -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",
+82
View File
@@ -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": "正在尋找空閒時間",
+82
View File
@@ -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": "正在查找空闲时间",
@@ -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<ContactCard>) => ({ ...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);
});
});
+132 -3
View File
@@ -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<ContactCard>;
/** 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<string, boolean> }>;
}
/** A contact that is about to be created, projected for plugins (no id yet). */
function draftContactView(contact: Partial<ContactCard>) {
@@ -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<void>;
importContacts: (client: IJMAPClient | null, contacts: ContactCard[]) => Promise<number>;
/** Into `addressBookId` when given, else where the server files new cards. */
importContacts: (client: IJMAPClient | null, contacts: ContactCard[], addressBookId?: string) => Promise<number>;
/** 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<MergeUndo | null>;
/** 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<boolean>;
// Trusted senders address book
loadTrustedSendersBook: (client: IJMAPClient) => Promise<void>;
@@ -1629,22 +1646,46 @@ export const useContactStore = create<ContactStore>()(
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<ContactStore>()(
}
return imported;
},
mergeContacts: async (client, plan) => {
const sync = !!client && get().supportsSync;
const { keep, others, patch } = plan;
const before: Partial<ContactCard> = {};
for (const key of Object.keys(patch) as Array<keyof ContactCard>) {
(before as Record<string, unknown>)[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;
},
});
},
{