mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
Merge branch 'feat/do-ux-p2-pim-cm' into feat/do-ux-p2-pim-fix
# Conflicts: # components/files/files-sidebar.tsx
This commit is contained in:
@@ -30,6 +30,7 @@ vi.mock('@/hooks/use-config', () => ({ useConfig: () => config }));
|
||||
|
||||
import { CalendarApp } from '../calendar-app';
|
||||
import { requestAppCommand } from '@/lib/app-commands';
|
||||
import { mergedDesktopState, resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
|
||||
|
||||
vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
@@ -84,3 +85,32 @@ describe('Calendar in the desktop menus', () => {
|
||||
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// pim-p2-9 (2026-11-04): the desktop app's Go menu in Calendar moves through time
|
||||
// (Today, Next period, Previous period) instead of offering Mail's folders.
|
||||
describe('Calendar in the desktop Go menu', () => {
|
||||
beforeEach(() => {
|
||||
resetDesktopCommands();
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn(), onCommand: vi.fn() } };
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
resetDesktopCommands();
|
||||
});
|
||||
|
||||
it('offers Today, Next period and Previous period, and each moves the week', () => {
|
||||
render(<CalendarApp />);
|
||||
const state = mergedDesktopState();
|
||||
expect(state['go.today']).toBe(true);
|
||||
expect(state['go.nextPeriod']).toBe(true);
|
||||
expect(state['go.previousPeriod']).toBe(true);
|
||||
const day = () => useCalendarStore.getState().selectedDate.toDateString();
|
||||
act(() => { expect(runDesktopCommand('go.nextPeriod')).toBe(true); });
|
||||
expect(day()).toBe(new Date(2026, 9, 19).toDateString());
|
||||
act(() => { runDesktopCommand('go.previousPeriod'); });
|
||||
act(() => { runDesktopCommand('go.previousPeriod'); });
|
||||
expect(day()).toBe(new Date(2026, 9, 5).toDateString());
|
||||
act(() => { runDesktopCommand('go.today'); });
|
||||
expect(day()).toBe(new Date().toDateString());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1714,7 +1714,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
"new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); },
|
||||
"import-calendar": () => { if (!demoMode) setShowImportModal(true); },
|
||||
}, true);
|
||||
// Its View menu's Day, Week, Month and Agenda, and Edit > Search events.
|
||||
// Its View menu's Day, Week, Month and Agenda, Go's Today and periods, and Edit > Search events.
|
||||
const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); };
|
||||
useDesktopCommands({
|
||||
"view.calendar.day": viewFromMenu("day"),
|
||||
@@ -1722,7 +1722,14 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
"view.calendar.month": viewFromMenu("month"),
|
||||
"view.calendar.agenda": viewFromMenu("agenda"),
|
||||
"edit.search": openSearch,
|
||||
// Go > Today, Next period and Previous period: the toolbar's arrows and T.
|
||||
"go.today": () => { leaveSearch(); goToToday(); },
|
||||
"go.nextPeriod": () => { leaveSearch(); navigateNext(); },
|
||||
"go.previousPeriod": () => { leaveSearch(); navigatePrev(); },
|
||||
}, {
|
||||
"go.today": normalizedViewMode !== "tasks",
|
||||
"go.nextPeriod": normalizedViewMode !== "tasks",
|
||||
"go.previousPeriod": normalizedViewMode !== "tasks",
|
||||
"view.calendar.day": { enabled: true, checked: normalizedViewMode === "day" },
|
||||
"view.calendar.week": { enabled: true, checked: normalizedViewMode === "week" },
|
||||
"view.calendar.month": { enabled: true, checked: normalizedViewMode === "month" },
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import { ContactImportDialog } from '../contact-import-dialog';
|
||||
|
||||
// pim-p2-15 (2026-11-04): a CSV with the same address on two rows imported both,
|
||||
// and the import ended in a dialog page that only had Close.
|
||||
|
||||
const CSV = [
|
||||
'First Name,Last Name,E-mail Address',
|
||||
'Anna,Schmidt,anna@example.org',
|
||||
'Bob,Meyer,bob@example.org',
|
||||
'Anna,Schmidt,ANNA@example.org',
|
||||
'',
|
||||
].join('\r\n');
|
||||
|
||||
const file = () => new File([CSV], 'contacts-export.csv', { type: 'text/csv' });
|
||||
|
||||
describe('ContactImportDialog, pass 2', () => {
|
||||
it('marks the second row with the same address as a duplicate, and imports the rest', async () => {
|
||||
const onImport = vi.fn(async (cards: ContactCard[]) => cards.length);
|
||||
render(<ContactImportDialog existingContacts={[]} onImport={onImport} onClose={vi.fn()} initialFile={file()} />);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'continue' }));
|
||||
expect(await screen.findByText('Bob Meyer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('import.duplicate')).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
|
||||
await waitFor(() => expect(onImport).toHaveBeenCalled());
|
||||
const cards = onImport.mock.calls[0][0] as ContactCard[];
|
||||
expect(cards.map((c) => Object.values(c.emails ?? {})[0]?.address)).toEqual(['anna@example.org', 'bob@example.org']);
|
||||
});
|
||||
|
||||
it('closes when done and leaves the word to the caller', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onImported = vi.fn();
|
||||
render(
|
||||
<ContactImportDialog existingContacts={[]} onImport={async (cards) => cards.length} onClose={onClose} onImported={onImported} initialFile={file()} />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'continue' }));
|
||||
await screen.findByText('Bob Meyer');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'import.import_button' }));
|
||||
await waitFor(() => expect(onImported).toHaveBeenCalledWith(2, undefined));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(screen.queryByText('import.success')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
import { ContactMergeDialog } from '../contact-merge-dialog';
|
||||
|
||||
// pim-p2-14 and pim-p2-15 (2026-11-04): the merge preview listed "Hauptstraße 5,
|
||||
// Berlin, 10115, Germany" where the card shows "Hauptstraße 5" / "10115 Berlin", and
|
||||
// left out the birthday the merge keeps. "Merge with…" listed every contact unsorted.
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const anna = {
|
||||
id: 'anna', kind: 'individual', name: { full: 'Anna Schmidt' },
|
||||
emails: { e: { address: 'anna@example.org' } },
|
||||
addresses: {
|
||||
a: {
|
||||
countryCode: 'DE',
|
||||
components: [
|
||||
{ kind: 'name', value: 'Hauptstraße' }, { kind: 'number', value: '5' },
|
||||
{ kind: 'locality', value: 'Berlin' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' },
|
||||
],
|
||||
},
|
||||
},
|
||||
updated: '2026-11-01T10:00:00Z',
|
||||
} as unknown as ContactCard;
|
||||
|
||||
const annaOld = {
|
||||
id: 'anna-old', kind: 'individual', name: { full: 'Anna Schmidt' },
|
||||
emails: { e: { address: 'anna.s@example.org' } },
|
||||
anniversaries: { b: { kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 3, day: 14 } } },
|
||||
updated: '2025-01-01T10:00:00Z',
|
||||
} as unknown as ContactCard;
|
||||
|
||||
const named = (id: string, full: string) => ({ id, kind: 'individual', name: { full } }) as unknown as ContactCard;
|
||||
|
||||
describe('the merge preview', () => {
|
||||
it('lays the address out as the card does and lists the birthday it keeps', () => {
|
||||
render(<ContactMergeDialog cards={[anna, annaOld]} groups={[]} onMerge={vi.fn()} onClose={vi.fn()} />);
|
||||
const preview = screen.getByTestId('merge-preview');
|
||||
const address = within(preview).getByText('group_adr_any').nextElementSibling as HTMLElement;
|
||||
expect(address.textContent).toBe('Hauptstraße 5\n10115 Berlin\nGermany');
|
||||
const birthday = within(preview).getByText('detail.anniversary_birth').nextElementSibling as HTMLElement;
|
||||
expect(birthday.textContent).toMatch(/1990/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Merge with…', () => {
|
||||
it('lists the contacts to pick by name', () => {
|
||||
const candidates = [named('3', 'Jürgen'), named('1', 'Alice'), named('5', 'Zoë'), named('2', 'anna'), named('4', 'Bob')];
|
||||
render(<ContactMergeDialog pickFor={named('0', 'Me')} candidates={candidates} groups={[]} onMerge={vi.fn()} onClose={vi.fn()} />);
|
||||
const rows = within(screen.getByTestId('merge-pick-list')).getAllByRole('button');
|
||||
expect(rows.map((row) => row.textContent)).toEqual(['Alice', 'anna', 'Bob', 'Jürgen', 'Zoë']);
|
||||
fireEvent.click(rows[0]);
|
||||
expect(screen.getByTestId('merge-preview')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -322,7 +322,8 @@ describe('ContactsApp', () => {
|
||||
const preview = await screen.findByTestId('merge-preview');
|
||||
expect(within(preview).getByText('anna@example.net')).toBeInTheDocument();
|
||||
expect(within(preview).getByText('anna@home.example.net')).toBeInTheDocument();
|
||||
expect(within(preview).getByText('Hauptstraße 1, Berlin')).toBeInTheDocument();
|
||||
// One line per address line, as the card shows it (pim-p2-14); getByText joins the lines with a space.
|
||||
expect(within(preview).getByText('Hauptstraße 1 Berlin')).toBeInTheDocument();
|
||||
expect(within(preview).getByText('Club')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'merge' }));
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ interface ContactImportDialogProps {
|
||||
addressBooks?: AddressBook[];
|
||||
/** The book open in Contacts: "Import into" starts there. */
|
||||
defaultAddressBookId?: string;
|
||||
/**
|
||||
* Done: the dialog closes and the caller says so (a toast with Show) instead of a
|
||||
* page of its own that only has Close. Without it the dialog shows the count.
|
||||
*/
|
||||
onImported?: (count: number, addressBookId?: string) => void;
|
||||
}
|
||||
|
||||
/** The file's bytes; jsdom's File has no arrayBuffer(). */
|
||||
@@ -53,6 +58,7 @@ export function ContactImportDialog({
|
||||
initialFile,
|
||||
addressBooks = [],
|
||||
defaultAddressBookId,
|
||||
onImported,
|
||||
}: ContactImportDialogProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const shownName = useCollectionNames();
|
||||
@@ -195,7 +201,13 @@ export function ContactImportDialog({
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const count = await onImport(toImport, addressBooks.length > 0 && bookId ? bookId : undefined);
|
||||
const target = addressBooks.length > 0 && bookId ? bookId : undefined;
|
||||
const count = await onImport(toImport, target);
|
||||
if (onImported) {
|
||||
onClose();
|
||||
onImported(count, target);
|
||||
return;
|
||||
}
|
||||
setResult(count);
|
||||
} catch (error) {
|
||||
console.error('Failed to import contacts:', error);
|
||||
|
||||
@@ -1,39 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { Search } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { formatAddressLines } from "@/lib/address-format";
|
||||
import { formatContactDate } from "@/lib/contact-dates";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactAddress, ContactCard } from "@/lib/jmap/types";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
import { memberKeys, planMerge, type MergePlan } from "@/lib/contacts/merge";
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
|
||||
|
||||
/** One line for an address, as its parts and separators say. */
|
||||
export function addressLine(address: ContactAddress): string {
|
||||
const components = address.components ?? [];
|
||||
if (components.length > 0) {
|
||||
let out = "";
|
||||
let lastWasValue = false;
|
||||
for (const c of components) {
|
||||
if (c.kind === "separator") {
|
||||
out += c.value;
|
||||
lastWasValue = false;
|
||||
continue;
|
||||
}
|
||||
if (!c.value) continue;
|
||||
if (lastWasValue) out += address.defaultSeparator ?? ", ";
|
||||
out += c.value.replace(/\s*\n\s*/g, ", ");
|
||||
lastWasValue = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const legacy = [address.street, [address.postcode, address.locality].filter(Boolean).join(" "), address.region, address.country]
|
||||
.filter(Boolean).join(", ");
|
||||
return legacy || address.full || address.fullAddress || "";
|
||||
}
|
||||
/** Lines of one value, shown with whitespace-pre-line. */
|
||||
const NEWLINE = String.fromCharCode(10);
|
||||
|
||||
interface ContactMergeDialogProps {
|
||||
/** Two or more cards: the dialog opens on what the merge keeps. */
|
||||
@@ -69,11 +50,14 @@ function PickRow({ card, onPick }: { card: ContactCard; onPick: () => void }) {
|
||||
export function ContactMergeDialog({ cards: initialCards, pickFor, candidates = [], suggested = [], groups, onMerge, onClose }: ContactMergeDialogProps) {
|
||||
const t = useTranslations("ux_pim_cimport");
|
||||
const tc = useTranslations("contacts");
|
||||
const tm = useTranslations("ux_p2_pim_cm");
|
||||
const locale = useLocale();
|
||||
const [cards, setCards] = useState<ContactCard[] | null>(initialCards && initialCards.length > 1 ? initialCards : null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const plan = useMemo(() => (cards ? planMerge(cards) : null), [cards]);
|
||||
const collator = useMemo(() => new Intl.Collator(locale, { sensitivity: "base", numeric: true }), [locale]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -81,8 +65,10 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
|
||||
return candidates
|
||||
.filter((c) => !suggestedIds.has(c.id))
|
||||
.filter((c) => !q || `${getContactDisplayName(c)} ${Object.values(c.emails ?? {}).map((e) => e.address).join(" ")}`.toLowerCase().includes(q))
|
||||
// By name, as the contact list sorts, in the UI language's order.
|
||||
.sort((a, b) => collator.compare(getContactDisplayName(a) || getContactPrimaryEmail(a) || "", getContactDisplayName(b) || getContactPrimaryEmail(b) || ""))
|
||||
.slice(0, 200);
|
||||
}, [candidates, suggested, query]);
|
||||
}, [candidates, suggested, query, collator]);
|
||||
|
||||
if (!plan) {
|
||||
const name = pickFor ? getContactDisplayName(pickFor) || getContactPrimaryEmail(pickFor) : "";
|
||||
@@ -135,11 +121,12 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
|
||||
.filter((g) => Object.entries(g.members ?? {}).some(([k, on]) => on && keys.has(k)))
|
||||
.map((g) => getContactDisplayName(g))
|
||||
.filter(Boolean);
|
||||
const rows: Array<{ label: string; values: string[] }> = [
|
||||
const rows: Array<{ label: string; values: string[]; multiline?: boolean }> = [
|
||||
{ label: t("group_name"), values: [getContactDisplayName(r)].filter(Boolean) },
|
||||
{ label: t("group_email"), values: Object.values(r.emails ?? {}).map((e) => e.address) },
|
||||
{ label: t("group_phone"), values: Object.values(r.phones ?? {}).map((p) => p.number) },
|
||||
{ label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map(addressLine).filter(Boolean) },
|
||||
// Laid out as the card shows it ("Hauptstraße 5" / "10115 Berlin"), not the parts in stored order.
|
||||
{ label: t("group_adr_any"), values: Object.values(r.addresses ?? {}).map((a) => formatAddressLines(a, locale).join(NEWLINE)).filter(Boolean), multiline: true },
|
||||
{
|
||||
label: t("group_org"),
|
||||
values: [
|
||||
@@ -147,8 +134,29 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
|
||||
...Object.values(r.titles ?? {}).map((ti) => ti.name),
|
||||
].filter(Boolean),
|
||||
},
|
||||
{ label: t("t_nickname"), values: Object.values(r.nicknames ?? {}).map((n) => n.name).filter(Boolean) },
|
||||
// Every date the kept card will have, each under its own name ("Birthday").
|
||||
...(["birth", "wedding", "death", "other"] as const).map((kind) => ({
|
||||
label: tc(`detail.anniversary_${kind}`),
|
||||
values: Object.values(r.anniversaries ?? {}).filter((a) => a.kind === kind && a.date).map((a) => formatContactDate(a.date, locale)),
|
||||
})),
|
||||
{
|
||||
label: tc("detail.gender"),
|
||||
values: [
|
||||
r.speakToAs?.grammaticalGender ? tc(`detail.gender_${r.speakToAs.grammaticalGender}`, { defaultValue: r.speakToAs.grammaticalGender }) : "",
|
||||
...Object.values(r.speakToAs?.pronouns ?? {}).map((p) => p.pronouns),
|
||||
].filter(Boolean),
|
||||
},
|
||||
{ label: tc("detail.language_label"), values: Object.values(r.preferredLanguages ?? {}).map((l) => l.language).filter(Boolean) },
|
||||
{ label: tc("detail.personal_info"), values: Object.values(r.personalInfo ?? {}).map((pi) => pi.value).filter(Boolean) },
|
||||
{ label: t("t_web"), values: Object.values(r.onlineServices ?? {}).map((s) => s.uri || s.user || "").filter(Boolean) },
|
||||
{ label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean) },
|
||||
{ label: tm("merge_links"), values: [...Object.values(r.links ?? {}), ...Object.values(r.directories ?? {})].map((l) => l.uri).filter(Boolean) },
|
||||
{ label: tc("detail.calendar_uri"), values: Object.values(r.calendars ?? {}).map((c) => c.uri).filter(Boolean) },
|
||||
{ label: tc("detail.scheduling_uri"), values: Object.values(r.schedulingAddresses ?? {}).map((c) => c.uri).filter(Boolean) },
|
||||
{ label: tc("detail.related_contacts"), values: Object.keys(r.relatedTo ?? {}) },
|
||||
{ label: tc("detail.crypto_keys"), values: Object.keys(r.cryptoKeys ?? {}).length > 0 ? [tm("merge_kept")] : [] },
|
||||
{ label: tm("merge_photo"), values: Object.keys(r.media ?? {}).length > 0 ? [tm("merge_kept")] : [] },
|
||||
{ label: t("t_notes"), values: Object.values(r.notes ?? {}).map((n) => n.note).filter(Boolean), multiline: true },
|
||||
{ label: t("t_categories"), values: Object.keys(r.keywords ?? {}).filter((k) => r.keywords?.[k]) },
|
||||
{ label: t("groups"), values: groupNames },
|
||||
].filter((row) => row.values.length > 0);
|
||||
@@ -188,7 +196,7 @@ export function ContactMergeDialog({ cards: initialCards, pickFor, candidates =
|
||||
<dt className="text-muted-foreground">{row.label}</dt>
|
||||
<dd className="min-w-0">
|
||||
{row.values.map((value, i) => (
|
||||
<div key={i} className={cn("break-words", row.label === t("t_notes") && "whitespace-pre-line")}>{value}</div>
|
||||
<div key={i} className={cn("break-words", row.multiline && "whitespace-pre-line")}>{value}</div>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -104,6 +104,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
const linkSegments = useLiteLinkSegments('contacts', routeSegments);
|
||||
const shownName = useCollectionNames();
|
||||
const t = useTranslations("contacts");
|
||||
const tImport = useTranslations("ux_p2_pim_cm");
|
||||
const skeleton = useSkeletonLabels();
|
||||
// Organisation > Devices > No printing: no Print buttons (Ctrl+P prints a note).
|
||||
const printOff = useStrictestDevices().noPrint;
|
||||
@@ -601,6 +602,19 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
addressBookId,
|
||||
);
|
||||
}, [supportsSync, client, importContacts]);
|
||||
// Done importing: a toast instead of a dialog page, and Show opens where they went.
|
||||
const handleImported = useCallback((count: number, addressBookId?: string) => {
|
||||
if (count <= 0) return;
|
||||
toast.success(t("import.success", { count }), {
|
||||
action: {
|
||||
label: tImport("import_show"),
|
||||
onClick: () => {
|
||||
setSearchQuery("");
|
||||
handleSelectCategory(addressBookId ? { addressBookId } : "all");
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [t, tImport, handleSelectCategory, setSearchQuery]);
|
||||
// "Import into" starts at the address book on screen.
|
||||
const openBookId = typeof activeCategory === "object" && "addressBookId" in activeCategory ? activeCategory.addressBookId : undefined;
|
||||
|
||||
@@ -1679,6 +1693,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onClose={launchedFile.done}
|
||||
addressBooks={supportsSync ? writableBooks : []}
|
||||
defaultAddressBookId={openBookId}
|
||||
onImported={handleImported}
|
||||
/>
|
||||
)}
|
||||
{showImportDialog && (
|
||||
@@ -1688,6 +1703,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onClose={() => setShowImportDialog(false)}
|
||||
addressBooks={supportsSync ? writableBooks : []}
|
||||
defaultAddressBookId={openBookId}
|
||||
onImported={handleImported}
|
||||
/>
|
||||
)}
|
||||
{(mergeCards || mergePickFor) && (
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ContactCard, AddressBook } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, TRUSTED_SENDERS_BOOK_NAME } from "@/stores/contact-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useCollectionNames } from "@/hooks/use-collection-names";
|
||||
import { AccountScopeLabel } from "@/components/layout/account-scope-label";
|
||||
|
||||
export type ContactCategory = "all" | "directory" | { groupId: string } | { addressBookId: string } | { keyword: string } | "uncategorized";
|
||||
|
||||
@@ -283,7 +284,11 @@ export function ContactsSidebar({
|
||||
{/* Header */}
|
||||
{/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */}
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
{/* Which account it shows, when there are several: the address, which switches. */}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
<AccountScopeLabel />
|
||||
</div>
|
||||
<div className="relative flex-shrink-0">
|
||||
<IconButton
|
||||
label={t("new")}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { cn } from "@/lib/utils";
|
||||
import { sharedFilePages, type FileResource } from "@/stores/file-store";
|
||||
import { getFileIconByName } from "@/components/files/file-icons";
|
||||
import { isInternalDrag, readDraggedNames } from "@/components/files/file-item-parts";
|
||||
import { AccountScopeLabel } from "@/components/layout/account-scope-label";
|
||||
|
||||
const COLLAPSED_KEY = "files-sidebar-collapsed";
|
||||
const RECENT_LIMIT = 8;
|
||||
@@ -286,7 +287,11 @@ export function FilesSidebar({
|
||||
<div className="flex flex-col h-full bg-secondary">
|
||||
{/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */}
|
||||
<div className="px-3 h-14 box-content border-b border-border flex items-center justify-between flex-shrink-0">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
{/* Which account it shows, when there are several: the address, which switches. */}
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-sm font-semibold truncate">{t("title")}</span>
|
||||
<AccountScopeLabel />
|
||||
</div>
|
||||
{canCreate && (
|
||||
<IconButton
|
||||
ref={newButtonRef}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContactsSidebar } from '@/components/contacts/contacts-sidebar';
|
||||
import { FilesSidebar, type FilesSidebarProps } from '@/components/files/files-sidebar';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
// pim-p2-11 (2026-11-04): with two accounts, Contacts and Files changed their whole
|
||||
// list on an account switch with nothing on screen naming the account. Each now shows
|
||||
// the account's address under its title, and a click there switches.
|
||||
|
||||
const switchAccount = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
function renderFiles() {
|
||||
const noop = vi.fn();
|
||||
const props = {
|
||||
currentParentId: null, currentPath: '/', allFolders: [], sharedRoots: [], favorites: [], recentFiles: [],
|
||||
canCreate: false,
|
||||
onNavigate: noop, onOpenRecent: noop, onUploadFiles: noop, onUploadFolder: noop, onNewFolder: noop,
|
||||
onNewTextFile: noop, onToggleFavorite: noop, onDropItems: noop, canShare: () => false, onShare: noop,
|
||||
onCreateIn: noop, onRenameFolder: noop, onDeleteFolder: noop, onRevealRecent: noop,
|
||||
onDownloadRecent: noop, onRemoveRecent: noop,
|
||||
} as unknown as FilesSidebarProps;
|
||||
return render(<FilesSidebar {...props} />);
|
||||
}
|
||||
|
||||
function renderContacts() {
|
||||
const noop = vi.fn();
|
||||
return render(
|
||||
<ContactsSidebar
|
||||
groups={[]} individuals={[]} addressBooks={[]} activeCategory="all"
|
||||
onSelectCategory={noop} onCreateGroup={noop} onCreateContact={noop}
|
||||
onEditGroup={noop} onDeleteGroup={noop} onComposeGroup={noop}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const account = (id: string, email: string, isDefault = false) => ({
|
||||
id, email, username: email, displayName: 'Same Name', label: 'Same Name', serverUrl: 'https://mail.example.org',
|
||||
isDefault, isConnected: true, hasError: false, lastLoginAt: 0, avatarColor: '#888888',
|
||||
});
|
||||
|
||||
describe('the account a screen shows', () => {
|
||||
beforeEach(() => {
|
||||
switchAccount.mockClear();
|
||||
useAccountStore.setState({ accounts: [account('a', 'a@example.org', true), account('b', 'b@example.org')] } as never);
|
||||
useAuthStore.setState({ activeAccountId: 'b', switchAccount } as never);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useAccountStore.setState({ accounts: [] } as never);
|
||||
});
|
||||
|
||||
for (const [name, renderScreen] of [['Files', renderFiles], ['Contacts', renderContacts]] as const) {
|
||||
it(`${name} names the active account by its address and switches from there`, () => {
|
||||
renderScreen();
|
||||
const label = screen.getByTestId('account-scope');
|
||||
expect(label).toHaveTextContent('b@example.org');
|
||||
expect(label).not.toHaveTextContent('Same Name');
|
||||
act(() => { fireEvent.click(label); });
|
||||
const menu = screen.getByRole('menu');
|
||||
const options = within(menu).getAllByRole('menuitem');
|
||||
expect(options.map((o) => o.textContent)).toEqual(['a@example.org', 'b@example.org']);
|
||||
act(() => { fireEvent.click(options[0]); });
|
||||
expect(switchAccount).toHaveBeenCalledWith('a');
|
||||
});
|
||||
}
|
||||
|
||||
it('says nothing with one account', () => {
|
||||
useAccountStore.setState({ accounts: [account('b', 'b@example.org', true)] } as never);
|
||||
renderFiles();
|
||||
expect(screen.queryByTestId('account-scope')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Check, ChevronDown } from "@/components/icons";
|
||||
import { ContextMenu, ContextMenuItem } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { sortDefaultFirst } from "@/lib/account-utils";
|
||||
import { useDesktopPolicy } from "@/lib/desktop/policy";
|
||||
import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
const addressOf = (account: AccountEntry) => toUnicodeEmail(account.email || account.username);
|
||||
|
||||
/**
|
||||
* Which account a screen that shows one account at a time (Contacts, Files) is
|
||||
* showing, by its address, under the screen's title. A click lists the other
|
||||
* signed-in accounts by address and switches. Nothing with a single account.
|
||||
*/
|
||||
export function AccountScopeLabel({ className }: { className?: string }) {
|
||||
const t = useTranslations("ux_p2_pim_cm");
|
||||
const storedAccounts = useAccountStore((s) => s.accounts);
|
||||
const devicePolicy = useDesktopPolicy();
|
||||
const accounts = useMemo(
|
||||
() => sortDefaultFirst(devicePolicy.AllowedJmapServers.value ? storedAccounts.filter((account) => !accountRefusedByDesktopPolicy(account, devicePolicy)) : storedAccounts),
|
||||
[storedAccounts, devicePolicy],
|
||||
);
|
||||
const activeAccountId = useAuthStore((s) => s.activeAccountId);
|
||||
const switchAccount = useAuthStore((s) => s.switchAccount);
|
||||
const menu = useContextMenu<null>();
|
||||
|
||||
const active = accounts.find((account) => account.id === activeAccountId);
|
||||
if (accounts.length < 2 || !active) return null;
|
||||
const address = addressOf(active);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="account-scope"
|
||||
data-account-email={active.email || active.username}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu.contextMenu.isOpen}
|
||||
aria-label={t("account_scope_label", { address })}
|
||||
title={t("account_scope_label", { address })}
|
||||
onMouseDown={(e) => { if (menu.contextMenu.isOpen) e.stopPropagation(); }}
|
||||
onClick={(e) => {
|
||||
if (menu.contextMenu.isOpen) menu.closeContextMenu();
|
||||
else menu.openContextMenuFrom(e.currentTarget, null, "start");
|
||||
}}
|
||||
className={cn(
|
||||
"flex max-w-full min-w-0 items-center gap-0.5 rounded text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="truncate" dir="ltr">{address}</span>
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
|
||||
</button>
|
||||
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu} label={t("account_scope_menu")}>
|
||||
{accounts.map((account) => (
|
||||
<ContextMenuItem
|
||||
key={account.id}
|
||||
icon={account.id === active.id ? Check : undefined}
|
||||
label={addressOf(account)}
|
||||
testId="account-scope-option"
|
||||
disabled={account.id === active.id}
|
||||
onClick={() => {
|
||||
menu.closeContextMenu();
|
||||
void switchAccount(account.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, act } from '@testing-library/react';
|
||||
import { useRefreshGesture } from '@/hooks/use-refresh-gesture';
|
||||
import { mergedDesktopState, resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
|
||||
|
||||
/**
|
||||
* pim-p2-9 (2026-11-04): in the desktop app View > Refresh (F9) was Mail's "Check
|
||||
* for new mail" and greyed in Calendar, Contacts and Files. Each screen's own
|
||||
* refresh (the one F5 and pull-to-refresh run) now answers the menu.
|
||||
*/
|
||||
|
||||
function Screen({ onRefresh, enabled = true }: { onRefresh: () => void; enabled?: boolean }) {
|
||||
const { indicator } = useRefreshGesture({ onRefresh, enabled });
|
||||
return <div>{indicator}</div>;
|
||||
}
|
||||
|
||||
describe('useRefreshGesture in the desktop app', () => {
|
||||
beforeEach(() => {
|
||||
resetDesktopCommands();
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn(), onCommand: vi.fn() } };
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
resetDesktopCommands();
|
||||
});
|
||||
|
||||
it('enables View > Refresh and runs the screen refresh from it', async () => {
|
||||
const onRefresh = vi.fn();
|
||||
render(<Screen onRefresh={onRefresh} />);
|
||||
expect(mergedDesktopState()['view.refresh']).toBe(true);
|
||||
await act(async () => {
|
||||
expect(runDesktopCommand('view.refresh')).toBe(true);
|
||||
});
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers nothing while the screen can not refresh', () => {
|
||||
render(<Screen onRefresh={vi.fn()} enabled={false} />);
|
||||
expect(mergedDesktopState()['view.refresh']).toBeUndefined();
|
||||
expect(runDesktopCommand('view.refresh')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import {
|
||||
PullToRefreshIndicator,
|
||||
createPullToRefreshStore,
|
||||
@@ -93,8 +94,12 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu
|
||||
const t = useTranslations("common");
|
||||
const onRefreshRef = useRef(onRefresh);
|
||||
const runningRef = useRef(false);
|
||||
const triggerRef = useRef<(() => void) | null>(null);
|
||||
const store = useMemo(() => createPullToRefreshStore(), []);
|
||||
|
||||
// The desktop app's View > Refresh (F9, F5) runs the same refresh for the screen in front.
|
||||
useDesktopCommands({ "view.refresh": () => triggerRef.current?.() }, { "view.refresh": enabled }, enabled);
|
||||
|
||||
useEffect(() => {
|
||||
onRefreshRef.current = onRefresh;
|
||||
}, [onRefresh]);
|
||||
@@ -135,6 +140,7 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu
|
||||
}
|
||||
})();
|
||||
};
|
||||
triggerRef.current = trigger;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const isReloadKey =
|
||||
@@ -240,6 +246,7 @@ export function useRefreshGesture({ onRefresh, enabled = true }: UseRefreshGestu
|
||||
window.removeEventListener("touchcancel", cancelPull);
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
if (triggerRef.current === trigger) triggerRef.current = null;
|
||||
store.set({ phase: "idle", distance: 0 });
|
||||
};
|
||||
}, [enabled, store]);
|
||||
|
||||
@@ -144,7 +144,8 @@ test.describe('Contacts import and merge (pim-11, pim-14)', () => {
|
||||
await expect(into).toHaveValue(work!.id);
|
||||
if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-11-after-preview.png` });
|
||||
await dialog.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
await expect(dialog.getByText('2 contacts imported')).toBeVisible({ timeout: 30000 });
|
||||
// pim-p2-15: the dialog closes and a toast says it.
|
||||
await expect(page.getByRole('region', { name: 'Notifications' }).getByText('2 contacts imported')).toBeVisible({ timeout: 30000 });
|
||||
|
||||
await expect.poll(async () => (await cards(a, `${PREFIX} Import`)).length, { timeout: 30000 }).toBe(2);
|
||||
const imported = await cards(a, `${PREFIX} Import`);
|
||||
@@ -237,7 +238,10 @@ test.describe('Contacts import and merge (pim-11, pim-14)', () => {
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview.getByText(`merge-a-${RUN}@example.org`)).toBeVisible();
|
||||
await expect(preview.getByText(`merge-b-${RUN}@example.org`)).toBeVisible();
|
||||
await expect(preview.getByText('Hauptstraße 1, 10115, Berlin')).toBeVisible();
|
||||
// pim-p2-14: laid out on its own lines as the card shows it, not one line of parts.
|
||||
const addressCell = preview.locator('dd').filter({ hasText: 'Hauptstraße' });
|
||||
await expect(addressCell).toContainText('Berlin');
|
||||
await expect(addressCell).not.toContainText('Hauptstraße 1, 10115, Berlin');
|
||||
await expect(preview.getByText(`${PREFIX} Group ${RUN}`)).toBeVisible();
|
||||
if (TAKE_SHOTS) await page.screenshot({ path: `${SHOTS}/pim-fix-14-after.png` });
|
||||
await page.getByRole('button', { name: 'Merge', exact: true }).click();
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { contextOptions } from './helpers/ent';
|
||||
import { JmapClient } from './helpers/jmap';
|
||||
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
|
||||
|
||||
/**
|
||||
* Usability pass 2, pim-p2-11, p2-14 and p2-15, against a VM 106 Stalwart
|
||||
* (1.0 and 0.16). Every write is checked on the server.
|
||||
*
|
||||
* - Contacts and Files name the account they show by its address, and switch from there.
|
||||
* - A CSV with the same address on two rows imports one card, and the import ends in a toast.
|
||||
* - The merge preview lays out the address as the card does and lists the birthday it keeps.
|
||||
*
|
||||
* Accounts: dso-ux-pim (A) and dso-ux-pim-b (B), password IT_DSO_PASSWORD. The spec
|
||||
* makes its own cards, named "UXP2-cm <run> …", and removes them again.
|
||||
*/
|
||||
|
||||
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
|
||||
const A = 'dso-ux-pim@example.org';
|
||||
const B = 'dso-ux-pim-b@example.org';
|
||||
const CORE = 'urn:ietf:params:jmap:core';
|
||||
const CONTACTS = 'urn:ietf:params:jmap:contacts';
|
||||
const RUN = Date.now().toString(36);
|
||||
const PREFIX = `UXP2-cm ${RUN}`;
|
||||
|
||||
test.skip(!PASSWORD, 'Set IT_DSO_PASSWORD (vault "Stalwart test – dso-* throwaway accounts")');
|
||||
|
||||
type Card = Record<string, unknown> & {
|
||||
id: string;
|
||||
kind?: string;
|
||||
name?: { full?: string; components?: Array<{ kind: string; value: string }> };
|
||||
emails?: Record<string, { address: string }>;
|
||||
addressBookIds?: Record<string, boolean>;
|
||||
};
|
||||
|
||||
const nameOf = (c: Card) => c.name?.full || (c.name?.components ?? []).filter((p) => p.kind !== 'separator').map((p) => p.value).join(' ');
|
||||
|
||||
async function defaultBook(c: JmapClient): Promise<string> {
|
||||
const r = await c.request([['AddressBook/get', { accountId: c.accountId }, '0']], [CORE, CONTACTS]);
|
||||
const list = r.methodResponses[0][1].list as Array<{ id: string; isDefault?: boolean }>;
|
||||
return (list.find((b) => b.isDefault) ?? list[0]).id;
|
||||
}
|
||||
|
||||
async function cards(c: JmapClient): Promise<Card[]> {
|
||||
const r = await c.request([
|
||||
['ContactCard/query', { accountId: c.accountId, limit: 2000 }, 'q'],
|
||||
['ContactCard/get', { accountId: c.accountId, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
|
||||
], [CORE, CONTACTS]);
|
||||
return (r.methodResponses[1][1].list as Card[]).filter((card) => nameOf(card).includes(PREFIX));
|
||||
}
|
||||
|
||||
async function destroyOurs(c: JmapClient): Promise<void> {
|
||||
const ids = (await cards(c)).map((card) => card.id);
|
||||
if (ids.length) await c.request([['ContactCard/set', { accountId: c.accountId, destroy: ids }, '0']], [CORE, CONTACTS]);
|
||||
}
|
||||
|
||||
async function signIn(page: Page, email: string): Promise<void> {
|
||||
await page.locator('#username').waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.fill('#username', email);
|
||||
await page.fill('#password', PASSWORD);
|
||||
await page.click('button[type="submit"]');
|
||||
}
|
||||
|
||||
async function login(page: Page, email: string): Promise<void> {
|
||||
await neutralizeDevOverlay(page);
|
||||
await page.goto('/');
|
||||
await signIn(page, email);
|
||||
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
|
||||
}
|
||||
|
||||
/** The account the app has active, by its address, from the switcher. */
|
||||
async function activeEmail(page: Page): Promise<string | null> {
|
||||
const id = await accountSwitcher(page).getAttribute('data-active-account-id');
|
||||
if (!id) return null;
|
||||
await accountSwitcher(page).click();
|
||||
const email = await page.locator(`[data-testid="account-option"][data-account-id="${id}"]`).first().getAttribute('data-account-email');
|
||||
await page.keyboard.press('Escape');
|
||||
return email;
|
||||
}
|
||||
|
||||
test.describe('Which account Contacts and Files show (pim-p2-11)', () => {
|
||||
test('the address under the title names the account and switches it', async ({ browser }) => {
|
||||
test.setTimeout(300_000);
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await accountSwitcher(page).click();
|
||||
await page.locator('[data-testid="add-account"]').click();
|
||||
await signIn(page, B);
|
||||
await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B);
|
||||
|
||||
await page.goto('/contacts');
|
||||
const scope = page.getByTestId('account-scope');
|
||||
await expect(scope).toHaveText(B, { timeout: 60000 });
|
||||
await scope.click();
|
||||
const options = page.getByRole('menuitem');
|
||||
await expect(options.filter({ hasText: A })).toBeVisible();
|
||||
await expect(options.filter({ hasText: B })).toBeVisible();
|
||||
await options.filter({ hasText: A }).click();
|
||||
await expect(scope).toHaveText(A, { timeout: 60000 });
|
||||
await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(A);
|
||||
|
||||
await page.goto('/files');
|
||||
await expect(scope).toHaveText(A, { timeout: 60000 });
|
||||
await scope.click();
|
||||
await page.getByRole('menuitem').filter({ hasText: B }).click();
|
||||
await expect(scope).toHaveText(B, { timeout: 60000 });
|
||||
await expect.poll(() => activeEmail(page), { timeout: 60000 }).toBe(B);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Contacts import and merge, pass 2 (pim-p2-14, pim-p2-15)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.afterAll(async () => {
|
||||
await destroyOurs(await JmapClient.connect(A, PASSWORD));
|
||||
});
|
||||
|
||||
test('a CSV with one person twice imports one card, and a toast says how many went in', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await page.goto('/contacts');
|
||||
await page.getByRole('button', { name: 'New', exact: true }).first().click();
|
||||
await page.getByRole('menuitem', { name: /^Import contacts/ }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
const csv = [
|
||||
'First Name,Last Name,E-mail Address',
|
||||
`Anna,${PREFIX} Twice,twice-${RUN}@example.org`,
|
||||
`Bob,${PREFIX} Once,once-${RUN}@example.org`,
|
||||
`Anna,${PREFIX} Twice,TWICE-${RUN}@example.org`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
await dialog.locator('input[type="file"]').setInputFiles({ name: 'contacts-export.csv', mimeType: 'text/csv', buffer: Buffer.from(csv, 'utf-8') });
|
||||
await dialog.getByRole('button', { name: 'Continue' }).click();
|
||||
await expect(dialog.getByText(`Bob ${PREFIX} Once`)).toBeVisible();
|
||||
// The second Anna is marked and left out.
|
||||
await expect(dialog.getByText('Duplicate', { exact: true })).toHaveCount(1);
|
||||
await dialog.getByRole('button', { name: 'Import', exact: true }).click();
|
||||
|
||||
const toastRegion = page.getByRole('region', { name: 'Notifications' });
|
||||
await expect(toastRegion.getByText('2 contacts imported')).toBeVisible({ timeout: 30000 });
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect.poll(async () => (await cards(a)).length, { timeout: 30000 }).toBe(2);
|
||||
const imported = await cards(a);
|
||||
expect(imported.flatMap((c) => Object.values(c.emails ?? {}).map((e) => e.address.toLowerCase())).sort())
|
||||
.toEqual([`once-${RUN}@example.org`, `twice-${RUN}@example.org`]);
|
||||
// Show opens where they went.
|
||||
await toastRegion.getByRole('button', { name: 'Show' }).click();
|
||||
await page.getByPlaceholder(/Search contacts/).first().fill(PREFIX);
|
||||
await expect(page.getByRole('option').filter({ hasText: PREFIX })).toHaveCount(2, { timeout: 30000 });
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('the merge preview shows the address as the card does and the birthday it keeps; Merge with is by name', async ({ browser }) => {
|
||||
test.setTimeout(240_000);
|
||||
const a = await JmapClient.connect(A, PASSWORD);
|
||||
const book = await defaultBook(a);
|
||||
const surname = `${PREFIX} Merge`;
|
||||
const made = await a.request([['ContactCard/set', {
|
||||
accountId: a.accountId,
|
||||
create: {
|
||||
one: {
|
||||
'@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true },
|
||||
name: { '@type': 'Name', components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: surname }], isOrdered: true },
|
||||
emails: { e0: { '@type': 'EmailAddress', address: `merge-a-${RUN}@example.org` } },
|
||||
addresses: {
|
||||
a0: {
|
||||
'@type': 'Address', countryCode: 'DE', isOrdered: true, defaultSeparator: ', ', contexts: { private: true },
|
||||
components: [
|
||||
{ kind: 'name', value: 'Hauptstraße' }, { kind: 'separator', value: ' ' }, { kind: 'number', value: '5' },
|
||||
{ kind: 'locality', value: 'Berlin' }, { kind: 'postcode', value: '10115' }, { kind: 'country', value: 'Germany' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
two: {
|
||||
'@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true },
|
||||
name: { '@type': 'Name', components: [{ kind: 'given', value: 'Anna' }, { kind: 'surname', value: surname }], isOrdered: true },
|
||||
emails: { e0: { '@type': 'EmailAddress', address: `merge-b-${RUN}@example.org` } },
|
||||
anniversaries: { b0: { '@type': 'Anniversary', kind: 'birth', date: { '@type': 'PartialDate', year: 1990, month: 3, day: 14 } } },
|
||||
},
|
||||
zed: {
|
||||
'@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book]: true },
|
||||
name: { '@type': 'Name', full: `Zed ${PREFIX} Picker` },
|
||||
},
|
||||
},
|
||||
}, '0']], [CORE, CONTACTS]);
|
||||
const created = made.methodResponses[0][1].created ?? {};
|
||||
expect(Object.keys(created).sort(), JSON.stringify(made.methodResponses[0][1].notCreated)).toEqual(['one', 'two', 'zed']);
|
||||
|
||||
const context = await browser.newContext({ ...contextOptions(), viewport: { width: 1366, height: 768 } });
|
||||
const page = await context.newPage();
|
||||
await login(page, A);
|
||||
await page.goto('/contacts');
|
||||
await page.getByPlaceholder(/Search contacts/).first().fill(surname);
|
||||
const rows = page.getByRole('option').filter({ hasText: surname });
|
||||
await expect(rows).toHaveCount(2, { timeout: 60000 });
|
||||
|
||||
// Merge with…: the list to pick from goes by name.
|
||||
await rows.first().click({ button: 'right' });
|
||||
await page.getByRole('menuitem', { name: 'Merge with…' }).click();
|
||||
const pickList = page.getByTestId('merge-pick-list');
|
||||
await expect(pickList).toBeVisible();
|
||||
// The suggested ones come first, in their own block; "All contacts" is the last block.
|
||||
const all = (await pickList.locator(':scope > div').last().getByRole('button').allInnerTexts()).map((t) => t.split('\n')[0].trim());
|
||||
expect(all.length).toBeGreaterThan(1);
|
||||
expect(all).toEqual([...all].sort((x, y) => x.localeCompare(y, 'en', { sensitivity: 'base', numeric: true })));
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
|
||||
await rows.nth(0).click();
|
||||
await rows.nth(1).click({ modifiers: ['Control'] });
|
||||
await page.getByRole('button', { name: 'Merge…' }).first().click();
|
||||
const preview = page.getByTestId('merge-preview');
|
||||
await expect(preview).toBeVisible();
|
||||
const address = preview.locator('dd').filter({ hasText: 'Hauptstraße' });
|
||||
// Street, then postcode and city, then the country, each on its own line.
|
||||
expect(await address.innerText()).toBe('Hauptstraße 5\n10115 Berlin\nGermany');
|
||||
const birthdayRow = preview.locator('div').filter({ has: page.locator('dt', { hasText: 'Birthday' }) }).last();
|
||||
await expect(birthdayRow.locator('dd')).toContainText('1990');
|
||||
await page.getByRole('button', { name: 'Cancel' }).click();
|
||||
await expect(preview).toBeHidden();
|
||||
// Nothing merged: both cards are still there.
|
||||
expect((await cards(a)).filter((c) => nameOf(c).includes(surname))).toHaveLength(2);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
@@ -1177,6 +1177,17 @@ describe("detectDuplicates", () => {
|
||||
expect(dupes.get(0)).toBe("existing-1");
|
||||
});
|
||||
|
||||
// pim-p2-15 (2026-11-04): a CSV with the same address on two rows imported both.
|
||||
it("marks a later card of the same file with the same address as a duplicate of the first", () => {
|
||||
const incoming: ContactCard[] = [
|
||||
{ id: "row-1", addressBookIds: {}, emails: { e0: { address: "anna@example.org" } } },
|
||||
{ id: "row-2", addressBookIds: {}, emails: { e0: { address: "bob@example.org" } } },
|
||||
{ id: "row-3", addressBookIds: {}, emails: { e0: { address: " Anna@Example.org" } } },
|
||||
];
|
||||
const dupes = detectDuplicates([], incoming);
|
||||
expect([...dupes.entries()]).toEqual([[2, "row-1"]]);
|
||||
});
|
||||
|
||||
it("returns empty map when no duplicates", () => {
|
||||
const existing: ContactCard[] = [
|
||||
{
|
||||
|
||||
@@ -91,6 +91,7 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
|
||||
menuSelectAll: t('desktopUi.menu.select_all'),
|
||||
menuSearch: t('desktopUi.menu.search'),
|
||||
menuRefresh: t('desktopUi.menu.refresh'),
|
||||
menuRefreshView: t('ux_p2_pim_cm.menu_refresh'),
|
||||
menuReadingPane: t('desktopUi.menu.reading_pane'),
|
||||
menuPaneRight: t('desktopUi.menu.pane_right'),
|
||||
menuPaneBelow: t('desktopUi.menu.pane_below'),
|
||||
@@ -112,6 +113,9 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
|
||||
menuDelete: t('context_menu.delete'),
|
||||
menuOpenInWindow: t('desktopUi.menu.open_in_window'),
|
||||
menuGoInbox: t('sidebar.mailboxes.inbox'),
|
||||
menuGoToday: t('ux_p2_pim_cm.menu_today'),
|
||||
menuGoNextPeriod: t('ux_p2_pim_cm.menu_next_period'),
|
||||
menuGoPreviousPeriod: t('ux_p2_pim_cm.menu_previous_period'),
|
||||
menuGoSent: t('sidebar.mailboxes.sent'),
|
||||
menuGoDrafts: t('sidebar.mailboxes.drafts'),
|
||||
menuGoNext: t('desktopUi.menu.next_message'),
|
||||
|
||||
+10
-7
@@ -1596,16 +1596,19 @@ export function detectDuplicates(
|
||||
}
|
||||
}
|
||||
|
||||
// A file can hold the same person twice (a CSV export often does): the first
|
||||
// one counts, the later ones are duplicates of it, by its id.
|
||||
const seenInFile = new Map<string, string>();
|
||||
incoming.forEach((card, idx) => {
|
||||
if (card.emails) {
|
||||
for (const e of Object.values(card.emails)) {
|
||||
const match = existingEmails.get(e.address.toLowerCase());
|
||||
if (match) {
|
||||
dupes.set(idx, match);
|
||||
return;
|
||||
}
|
||||
const addresses = Object.values(card.emails ?? {}).map((e) => e.address?.trim().toLowerCase()).filter(Boolean) as string[];
|
||||
for (const address of addresses) {
|
||||
const match = existingEmails.get(address) ?? seenInFile.get(address);
|
||||
if (match) {
|
||||
dupes.set(idx, match);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const address of addresses) seenInFile.set(address, card.id);
|
||||
});
|
||||
|
||||
return dupes;
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "التقويم…",
|
||||
"import_contacts": "جهات الاتصال…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "تحديث",
|
||||
"menu_today": "اليوم",
|
||||
"menu_next_period": "الفترة التالية",
|
||||
"menu_previous_period": "الفترة السابقة",
|
||||
"account_scope_label": "يعرض {address}. تبديل الحساب",
|
||||
"account_scope_menu": "تبديل الحساب",
|
||||
"merge_links": "روابط",
|
||||
"merge_photo": "صورة",
|
||||
"merge_kept": "محفوظ",
|
||||
"import_show": "عرض"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "تعديل",
|
||||
"edit_named": "تعديل {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Calendari…",
|
||||
"import_contacts": "Contactes…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Actualitza",
|
||||
"menu_today": "Avui",
|
||||
"menu_next_period": "Període següent",
|
||||
"menu_previous_period": "Període anterior",
|
||||
"account_scope_label": "Es mostra {address}. Canvia de compte",
|
||||
"account_scope_menu": "Canvia de compte",
|
||||
"merge_links": "Enllaços",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Es conserva",
|
||||
"import_show": "Mostra"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Edita",
|
||||
"edit_named": "Edita {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Kalendář…",
|
||||
"import_contacts": "Kontakty…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Obnovit",
|
||||
"menu_today": "Dnes",
|
||||
"menu_next_period": "Další období",
|
||||
"menu_previous_period": "Předchozí období",
|
||||
"account_scope_label": "Zobrazuje se {address}. Přepnout účet",
|
||||
"account_scope_menu": "Přepnout účet",
|
||||
"merge_links": "Odkazy",
|
||||
"merge_photo": "Fotka",
|
||||
"merge_kept": "Zůstane",
|
||||
"import_show": "Zobrazit"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Upravit",
|
||||
"edit_named": "Upravit {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Kalender…",
|
||||
"import_contacts": "Kontakter…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Opdater",
|
||||
"menu_today": "I dag",
|
||||
"menu_next_period": "Næste periode",
|
||||
"menu_previous_period": "Forrige periode",
|
||||
"account_scope_label": "Viser {address}. Skift konto",
|
||||
"account_scope_menu": "Skift konto",
|
||||
"merge_links": "Links",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Beholdes",
|
||||
"import_show": "Vis"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Rediger",
|
||||
"edit_named": "Rediger {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Kalender…",
|
||||
"import_contacts": "Kontakte…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Aktualisieren",
|
||||
"menu_today": "Heute",
|
||||
"menu_next_period": "Nächster Zeitraum",
|
||||
"menu_previous_period": "Vorheriger Zeitraum",
|
||||
"account_scope_label": "Zeigt {address}. Konto wechseln",
|
||||
"account_scope_menu": "Konto wechseln",
|
||||
"merge_links": "Links",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Bleibt erhalten",
|
||||
"import_show": "Anzeigen"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Bearbeiten",
|
||||
"edit_named": "{name} bearbeiten",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Calendar…",
|
||||
"import_contacts": "Contacts…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Refresh",
|
||||
"menu_today": "Today",
|
||||
"menu_next_period": "Next period",
|
||||
"menu_previous_period": "Previous period",
|
||||
"account_scope_label": "Showing {address}. Switch account",
|
||||
"account_scope_menu": "Switch account",
|
||||
"merge_links": "Links",
|
||||
"merge_photo": "Photo",
|
||||
"merge_kept": "Kept",
|
||||
"import_show": "Show"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Edit",
|
||||
"edit_named": "Edit {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Calendario…",
|
||||
"import_contacts": "Contactos…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Actualizar",
|
||||
"menu_today": "Hoy",
|
||||
"menu_next_period": "Periodo siguiente",
|
||||
"menu_previous_period": "Periodo anterior",
|
||||
"account_scope_label": "Se muestra {address}. Cambiar de cuenta",
|
||||
"account_scope_menu": "Cambiar de cuenta",
|
||||
"merge_links": "Enlaces",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Se conserva",
|
||||
"import_show": "Mostrar"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Editar",
|
||||
"edit_named": "Editar {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Kalender…",
|
||||
"import_contacts": "Kontaktid…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Värskenda",
|
||||
"menu_today": "Täna",
|
||||
"menu_next_period": "Järgmine periood",
|
||||
"menu_previous_period": "Eelmine periood",
|
||||
"account_scope_label": "Kuvatakse {address}. Vaheta kontot",
|
||||
"account_scope_menu": "Vaheta kontot",
|
||||
"merge_links": "Lingid",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Jääb alles",
|
||||
"import_show": "Näita"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Muuda",
|
||||
"edit_named": "Muuda faili {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "تقویم…",
|
||||
"import_contacts": "مخاطبین…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "بازخوانی",
|
||||
"menu_today": "امروز",
|
||||
"menu_next_period": "بازه بعدی",
|
||||
"menu_previous_period": "بازه قبلی",
|
||||
"account_scope_label": "نمایش {address}. تغییر حساب",
|
||||
"account_scope_menu": "تغییر حساب",
|
||||
"merge_links": "پیوندها",
|
||||
"merge_photo": "عکس",
|
||||
"merge_kept": "حفظ میشود",
|
||||
"import_show": "نمایش"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "ویرایش",
|
||||
"edit_named": "ویرایش {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Calendrier…",
|
||||
"import_contacts": "Contacts…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Actualiser",
|
||||
"menu_today": "Aujourd’hui",
|
||||
"menu_next_period": "Période suivante",
|
||||
"menu_previous_period": "Période précédente",
|
||||
"account_scope_label": "Affiche {address}. Changer de compte",
|
||||
"account_scope_menu": "Changer de compte",
|
||||
"merge_links": "Liens",
|
||||
"merge_photo": "Photo",
|
||||
"merge_kept": "Conservé",
|
||||
"import_show": "Afficher"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Modifier",
|
||||
"edit_named": "Modifier {name}",
|
||||
|
||||
@@ -7553,6 +7553,18 @@
|
||||
"import_calendar": "לוח שנה…",
|
||||
"import_contacts": "אנשי קשר…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "רענון",
|
||||
"menu_today": "היום",
|
||||
"menu_next_period": "התקופה הבאה",
|
||||
"menu_previous_period": "התקופה הקודמת",
|
||||
"account_scope_label": "מוצג {address}. החלפת חשבון",
|
||||
"account_scope_menu": "החלפת חשבון",
|
||||
"merge_links": "קישורים",
|
||||
"merge_photo": "תמונה",
|
||||
"merge_kept": "נשמר",
|
||||
"import_show": "הצגה"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "עריכה",
|
||||
"edit_named": "עריכת {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Naptár…",
|
||||
"import_contacts": "Névjegyek…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Frissítés",
|
||||
"menu_today": "Ma",
|
||||
"menu_next_period": "Következő időszak",
|
||||
"menu_previous_period": "Előző időszak",
|
||||
"account_scope_label": "Látható: {address}. Fiókváltás",
|
||||
"account_scope_menu": "Fiókváltás",
|
||||
"merge_links": "Hivatkozások",
|
||||
"merge_photo": "Fénykép",
|
||||
"merge_kept": "Megmarad",
|
||||
"import_show": "Megjelenítés"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Szerkesztés",
|
||||
"edit_named": "{name} szerkesztése",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Calendario…",
|
||||
"import_contacts": "Contatti…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Aggiorna",
|
||||
"menu_today": "Oggi",
|
||||
"menu_next_period": "Periodo successivo",
|
||||
"menu_previous_period": "Periodo precedente",
|
||||
"account_scope_label": "Mostra {address}. Cambia account",
|
||||
"account_scope_menu": "Cambia account",
|
||||
"merge_links": "Link",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Mantenuto",
|
||||
"import_show": "Mostra"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Modifica",
|
||||
"edit_named": "Modifica {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "カレンダー…",
|
||||
"import_contacts": "連絡先…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "更新",
|
||||
"menu_today": "今日",
|
||||
"menu_next_period": "次の期間",
|
||||
"menu_previous_period": "前の期間",
|
||||
"account_scope_label": "{address} を表示中。アカウントを切り替え",
|
||||
"account_scope_menu": "アカウントを切り替え",
|
||||
"merge_links": "リンク",
|
||||
"merge_photo": "写真",
|
||||
"merge_kept": "残ります",
|
||||
"import_show": "表示"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "編集",
|
||||
"edit_named": "{name} を編集",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "캘린더…",
|
||||
"import_contacts": "연락처…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "새로 고침",
|
||||
"menu_today": "오늘",
|
||||
"menu_next_period": "다음 기간",
|
||||
"menu_previous_period": "이전 기간",
|
||||
"account_scope_label": "{address} 표시 중. 계정 전환",
|
||||
"account_scope_menu": "계정 전환",
|
||||
"merge_links": "링크",
|
||||
"merge_photo": "사진",
|
||||
"merge_kept": "유지됨",
|
||||
"import_show": "보기"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "편집",
|
||||
"edit_named": "{name} 편집",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Kalendārs…",
|
||||
"import_contacts": "Kontakti…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Atsvaidzināt",
|
||||
"menu_today": "Šodien",
|
||||
"menu_next_period": "Nākamais periods",
|
||||
"menu_previous_period": "Iepriekšējais periods",
|
||||
"account_scope_label": "Rāda {address}. Pārslēgt kontu",
|
||||
"account_scope_menu": "Pārslēgt kontu",
|
||||
"merge_links": "Saites",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Saglabājas",
|
||||
"import_show": "Rādīt"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Rediģēt",
|
||||
"edit_named": "Rediģēt {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Хуанли…",
|
||||
"import_contacts": "Харилцагчид…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Шинэчлэх",
|
||||
"menu_today": "Өнөөдөр",
|
||||
"menu_next_period": "Дараагийн үе",
|
||||
"menu_previous_period": "Өмнөх үе",
|
||||
"account_scope_label": "{address}-г харуулж байна. Бүртгэл солих",
|
||||
"account_scope_menu": "Бүртгэл солих",
|
||||
"merge_links": "Холбоосууд",
|
||||
"merge_photo": "Зураг",
|
||||
"merge_kept": "Хадгалагдана",
|
||||
"import_show": "Харуулах"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Засах",
|
||||
"edit_named": "{name} засах",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Kalender…",
|
||||
"import_contacts": "Kontakter…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Oppdater",
|
||||
"menu_today": "I dag",
|
||||
"menu_next_period": "Neste periode",
|
||||
"menu_previous_period": "Forrige periode",
|
||||
"account_scope_label": "Viser {address}. Bytt konto",
|
||||
"account_scope_menu": "Bytt konto",
|
||||
"merge_links": "Lenker",
|
||||
"merge_photo": "Bilde",
|
||||
"merge_kept": "Beholdes",
|
||||
"import_show": "Vis"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Rediger",
|
||||
"edit_named": "Rediger {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Agenda…",
|
||||
"import_contacts": "Contacten…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Vernieuwen",
|
||||
"menu_today": "Vandaag",
|
||||
"menu_next_period": "Volgende periode",
|
||||
"menu_previous_period": "Vorige periode",
|
||||
"account_scope_label": "Toont {address}. Ander account kiezen",
|
||||
"account_scope_menu": "Ander account kiezen",
|
||||
"merge_links": "Links",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Blijft bewaard",
|
||||
"import_show": "Tonen"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Bewerken",
|
||||
"edit_named": "{name} bewerken",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Kalendarz…",
|
||||
"import_contacts": "Kontakty…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Odśwież",
|
||||
"menu_today": "Dzisiaj",
|
||||
"menu_next_period": "Następny okres",
|
||||
"menu_previous_period": "Poprzedni okres",
|
||||
"account_scope_label": "Wyświetlane: {address}. Przełącz konto",
|
||||
"account_scope_menu": "Przełącz konto",
|
||||
"merge_links": "Linki",
|
||||
"merge_photo": "Zdjęcie",
|
||||
"merge_kept": "Zostaje",
|
||||
"import_show": "Pokaż"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Edytuj",
|
||||
"edit_named": "Edytuj {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Calendário…",
|
||||
"import_contacts": "Contactos…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Atualizar",
|
||||
"menu_today": "Hoje",
|
||||
"menu_next_period": "Próximo período",
|
||||
"menu_previous_period": "Período anterior",
|
||||
"account_scope_label": "A mostrar {address}. Mudar de conta",
|
||||
"account_scope_menu": "Mudar de conta",
|
||||
"merge_links": "Ligações",
|
||||
"merge_photo": "Foto",
|
||||
"merge_kept": "Mantém-se",
|
||||
"import_show": "Mostrar"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Editar",
|
||||
"edit_named": "Editar {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Calendar…",
|
||||
"import_contacts": "Contacte…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Reîmprospătează",
|
||||
"menu_today": "Astăzi",
|
||||
"menu_next_period": "Perioada următoare",
|
||||
"menu_previous_period": "Perioada anterioară",
|
||||
"account_scope_label": "Se afișează {address}. Schimbă contul",
|
||||
"account_scope_menu": "Schimbă contul",
|
||||
"merge_links": "Linkuri",
|
||||
"merge_photo": "Fotografie",
|
||||
"merge_kept": "Se păstrează",
|
||||
"import_show": "Afișează"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Editează",
|
||||
"edit_named": "Editează {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Календарь…",
|
||||
"import_contacts": "Контакты…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Обновить",
|
||||
"menu_today": "Сегодня",
|
||||
"menu_next_period": "Следующий период",
|
||||
"menu_previous_period": "Предыдущий период",
|
||||
"account_scope_label": "Показан {address}. Сменить учётную запись",
|
||||
"account_scope_menu": "Сменить учётную запись",
|
||||
"merge_links": "Ссылки",
|
||||
"merge_photo": "Фото",
|
||||
"merge_kept": "Сохранится",
|
||||
"import_show": "Показать"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Изменить",
|
||||
"edit_named": "Изменить {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Kalendár…",
|
||||
"import_contacts": "Kontakty…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Obnoviť",
|
||||
"menu_today": "Dnes",
|
||||
"menu_next_period": "Ďalšie obdobie",
|
||||
"menu_previous_period": "Predchádzajúce obdobie",
|
||||
"account_scope_label": "Zobrazuje sa {address}. Prepnúť účet",
|
||||
"account_scope_menu": "Prepnúť účet",
|
||||
"merge_links": "Odkazy",
|
||||
"merge_photo": "Fotka",
|
||||
"merge_kept": "Zostane",
|
||||
"import_show": "Zobraziť"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Upraviť",
|
||||
"edit_named": "Upraviť {name}",
|
||||
|
||||
@@ -7560,6 +7560,18 @@
|
||||
"import_calendar": "ปฏิทิน…",
|
||||
"import_contacts": "ผู้ติดต่อ…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "รีเฟรช",
|
||||
"menu_today": "วันนี้",
|
||||
"menu_next_period": "ช่วงถัดไป",
|
||||
"menu_previous_period": "ช่วงก่อนหน้า",
|
||||
"account_scope_label": "กำลังแสดง {address} สลับบัญชี",
|
||||
"account_scope_menu": "สลับบัญชี",
|
||||
"merge_links": "ลิงก์",
|
||||
"merge_photo": "รูปภาพ",
|
||||
"merge_kept": "เก็บไว้",
|
||||
"import_show": "แสดง"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "แก้ไข",
|
||||
"edit_named": "แก้ไข {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Takvim…",
|
||||
"import_contacts": "Kişiler…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Yenile",
|
||||
"menu_today": "Bugün",
|
||||
"menu_next_period": "Sonraki dönem",
|
||||
"menu_previous_period": "Önceki dönem",
|
||||
"account_scope_label": "{address} gösteriliyor. Hesap değiştir",
|
||||
"account_scope_menu": "Hesap değiştir",
|
||||
"merge_links": "Bağlantılar",
|
||||
"merge_photo": "Fotoğraf",
|
||||
"merge_kept": "Korunur",
|
||||
"import_show": "Göster"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Düzenle",
|
||||
"edit_named": "{name} dosyasını düzenle",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "Календар…",
|
||||
"import_contacts": "Контакти…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Оновити",
|
||||
"menu_today": "Сьогодні",
|
||||
"menu_next_period": "Наступний період",
|
||||
"menu_previous_period": "Попередній період",
|
||||
"account_scope_label": "Показано {address}. Змінити обліковий запис",
|
||||
"account_scope_menu": "Змінити обліковий запис",
|
||||
"merge_links": "Посилання",
|
||||
"merge_photo": "Фото",
|
||||
"merge_kept": "Залишиться",
|
||||
"import_show": "Показати"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Редагувати",
|
||||
"edit_named": "Редагувати {name}",
|
||||
|
||||
@@ -7564,6 +7564,18 @@
|
||||
"import_calendar": "Lịch…",
|
||||
"import_contacts": "Danh bạ…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "Làm mới",
|
||||
"menu_today": "Hôm nay",
|
||||
"menu_next_period": "Khoảng thời gian sau",
|
||||
"menu_previous_period": "Khoảng thời gian trước",
|
||||
"account_scope_label": "Đang hiển thị {address}. Chuyển tài khoản",
|
||||
"account_scope_menu": "Chuyển tài khoản",
|
||||
"merge_links": "Liên kết",
|
||||
"merge_photo": "Ảnh",
|
||||
"merge_kept": "Được giữ lại",
|
||||
"import_show": "Hiển thị"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "Chỉnh sửa",
|
||||
"edit_named": "Chỉnh sửa {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "行事曆…",
|
||||
"import_contacts": "聯絡人…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "重新整理",
|
||||
"menu_today": "今天",
|
||||
"menu_next_period": "下一個期間",
|
||||
"menu_previous_period": "上一個期間",
|
||||
"account_scope_label": "正在顯示 {address}。切換帳號",
|
||||
"account_scope_menu": "切換帳號",
|
||||
"merge_links": "連結",
|
||||
"merge_photo": "相片",
|
||||
"merge_kept": "保留",
|
||||
"import_show": "顯示"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "編輯",
|
||||
"edit_named": "編輯 {name}",
|
||||
|
||||
@@ -7552,6 +7552,18 @@
|
||||
"import_calendar": "日历…",
|
||||
"import_contacts": "联系人…"
|
||||
},
|
||||
"ux_p2_pim_cm": {
|
||||
"menu_refresh": "刷新",
|
||||
"menu_today": "今天",
|
||||
"menu_next_period": "下一时段",
|
||||
"menu_previous_period": "上一时段",
|
||||
"account_scope_label": "正在显示 {address}。切换账户",
|
||||
"account_scope_menu": "切换账户",
|
||||
"merge_links": "链接",
|
||||
"merge_photo": "照片",
|
||||
"merge_kept": "保留",
|
||||
"import_show": "显示"
|
||||
},
|
||||
"desktopEdit": {
|
||||
"edit": "编辑",
|
||||
"edit_named": "编辑 {name}",
|
||||
|
||||
Reference in New Issue
Block a user