-
{t("title")}
+ {/* Which account it shows, when there are several: the address, which switches. */}
+
{/* Same h-14 box as the mail sidebar's account switcher, so the apps line up. */}
-
{t("title")}
+ {/* Which account it shows, when there are several: the address, which switches. */}
+
{canCreate && (
false, onShare: noop,
+ onCreateIn: noop, onRenameFolder: noop, onDeleteFolder: noop, onRevealRecent: noop,
+ onDownloadRecent: noop, onRemoveRecent: noop,
+ } as unknown as FilesSidebarProps;
+ return render();
+}
+
+function renderContacts() {
+ const noop = vi.fn();
+ return render(
+ ,
+ );
+}
+
+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();
+ });
+});
diff --git a/components/layout/account-scope-label.tsx b/components/layout/account-scope-label.tsx
new file mode 100644
index 000000000..586a46947
--- /dev/null
+++ b/components/layout/account-scope-label.tsx
@@ -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();
+
+ const active = accounts.find((account) => account.id === activeAccountId);
+ if (accounts.length < 2 || !active) return null;
+ const address = addressOf(active);
+
+ return (
+ <>
+
+
+ {accounts.map((account) => (
+ {
+ menu.closeContextMenu();
+ void switchAccount(account.id);
+ }}
+ />
+ ))}
+
+ >
+ );
+}
diff --git a/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx b/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx
new file mode 100644
index 000000000..c363bb1db
--- /dev/null
+++ b/hooks/__tests__/use-refresh-gesture-desktop-menu.test.tsx
@@ -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 {indicator}
;
+}
+
+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();
+ 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();
+ expect(mergedDesktopState()['view.refresh']).toBeUndefined();
+ expect(runDesktopCommand('view.refresh')).toBe(false);
+ });
+});
diff --git a/hooks/use-refresh-gesture.tsx b/hooks/use-refresh-gesture.tsx
index f74737516..a4a26ac68 100644
--- a/hooks/use-refresh-gesture.tsx
+++ b/hooks/use-refresh-gesture.tsx
@@ -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]);
diff --git a/integration/tests/25-ux-pim-cimport.spec.ts b/integration/tests/25-ux-pim-cimport.spec.ts
index 62db2e300..392ed6fb8 100644
--- a/integration/tests/25-ux-pim-cimport.spec.ts
+++ b/integration/tests/25-ux-pim-cimport.spec.ts
@@ -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();
diff --git a/integration/tests/26-ux-p2-pim-cm.spec.ts b/integration/tests/26-ux-p2-pim-cm.spec.ts
new file mode 100644
index 000000000..f3966ca79
--- /dev/null
+++ b/integration/tests/26-ux-p2-pim-cm.spec.ts
@@ -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 …", 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 & {
+ id: string;
+ kind?: string;
+ name?: { full?: string; components?: Array<{ kind: string; value: string }> };
+ emails?: Record;
+ addressBookIds?: Record;
+};
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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();
+ });
+});
diff --git a/lib/__tests__/vcard.test.ts b/lib/__tests__/vcard.test.ts
index 5f956778a..127a5221d 100644
--- a/lib/__tests__/vcard.test.ts
+++ b/lib/__tests__/vcard.test.ts
@@ -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[] = [
{
diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts
index 010b284fa..86c6d598a 100644
--- a/lib/desktop/languages.ts
+++ b/lib/desktop/languages.ts
@@ -91,6 +91,7 @@ export function shellLabels(t: (key: string, values?: Record) =>
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) =>
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'),
diff --git a/lib/vcard.ts b/lib/vcard.ts
index b52d69aca..6ed7b6231 100644
--- a/lib/vcard.ts
+++ b/lib/vcard.ts
@@ -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();
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;
diff --git a/locales/ar/common.json b/locales/ar/common.json
index ea0f02ca9..7f5aad8bc 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -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}",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 12c354240..54e0d1fba 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -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}",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index eca6999c2..fba0fac85 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -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}",
diff --git a/locales/da/common.json b/locales/da/common.json
index 759c11a0f..f60bfb332 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -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}",
diff --git a/locales/de/common.json b/locales/de/common.json
index 1ebb8afa1..ed063e582 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -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",
diff --git a/locales/en/common.json b/locales/en/common.json
index 6c81e6608..33466a43c 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -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}",
diff --git a/locales/es/common.json b/locales/es/common.json
index b5f59cdc0..9550ee59b 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -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}",
diff --git a/locales/et/common.json b/locales/et/common.json
index 20c6af6bd..b748649b7 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -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}",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 36a468265..700e074fe 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -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}",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index e2fa05429..248be0cdd 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -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}",
diff --git a/locales/he/common.json b/locales/he/common.json
index bbdbf7a7b..86d5dc5d1 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -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}",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index a68efbf24..cf636003c 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -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",
diff --git a/locales/it/common.json b/locales/it/common.json
index 0ed9eaae3..aeb7774da 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -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}",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 34b7c2961..53ab9e786 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -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} を編集",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 6767a8e7e..2c914f1b4 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -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} 편집",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 5217abb6f..b8d6490d7 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -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}",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 9a5466a87..aad259f9d 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -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} засах",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 1c1417363..3bb4c8441 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -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}",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 385ae75ee..f3749ed2d 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -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",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index a8ef88f15..94bd5dcfa 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -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}",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index ab8f335a1..12e3228a7 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -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}",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 7365f92a5..e55f6ff59 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -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}",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index fdc56cfb5..621563169 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -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}",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 787213e0b..00ebf0f8f 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -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}",
diff --git a/locales/th/common.json b/locales/th/common.json
index a27d4c2f3..288de3711 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -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}",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index a626d596d..ba1f08b24 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -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",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 730f131ee..663b45ecd 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -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}",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index c76a612e6..163472e33 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -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}",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index d0da2031d..4a93a244d 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -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}",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 306c195bc..9f2248c9b 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -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}",