mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
feat: import contacts from Outlook and Google CSV files into the address book you choose
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 })}
|
||||
|
||||
@@ -579,12 +579,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);
|
||||
@@ -1602,6 +1605,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onImport={handleImportContacts}
|
||||
initialFile={launchedFile.file}
|
||||
onClose={launchedFile.done}
|
||||
addressBooks={supportsSync ? writableBooks : []}
|
||||
defaultAddressBookId={openBookId}
|
||||
/>
|
||||
)}
|
||||
{showImportDialog && (
|
||||
@@ -1609,6 +1614,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
existingContacts={contacts}
|
||||
onImport={handleImportContacts}
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
addressBooks={supportsSync ? writableBooks : []}
|
||||
defaultAddressBookId={openBookId}
|
||||
/>
|
||||
)}
|
||||
{sharingAddressBookId && client && (() => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5163,6 +5163,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": "جارٍ البحث عن أوقات متاحة",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -5163,6 +5163,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": "در حال جستوجوی زمانهای آزاد",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5573,6 +5573,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": "מחפש זמנים פנויים",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5754,6 +5754,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": "空いている時間を探しています",
|
||||
|
||||
@@ -5754,6 +5754,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": "빈 시간을 찾는 중",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5163,6 +5163,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": "Чөлөөтэй цаг хайж байна",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5754,6 +5754,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",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -5754,6 +5754,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": "Поиск свободного времени",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -2758,6 +2758,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": "กำลังหาเวลาว่าง",
|
||||
|
||||
@@ -5163,6 +5163,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",
|
||||
|
||||
@@ -5754,6 +5754,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": "Пошук вільного часу",
|
||||
|
||||
@@ -5175,6 +5175,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",
|
||||
|
||||
@@ -5163,6 +5163,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": "正在尋找空閒時間",
|
||||
|
||||
@@ -5754,6 +5754,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": "正在查找空闲时间",
|
||||
|
||||
+28
-3
@@ -481,7 +481,8 @@ 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>;
|
||||
|
||||
// Trusted senders address book
|
||||
loadTrustedSendersBook: (client: IJMAPClient) => Promise<void>;
|
||||
@@ -1629,22 +1630,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] }));
|
||||
|
||||
Reference in New Issue
Block a user